Merge branch 'devel'
commit
2e7867f60c
@ -0,0 +1,13 @@
|
|||||||
|
package org.koitharu.kotatsu
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.runner.AndroidJUnitRunner
|
||||||
|
import dagger.hilt.android.testing.HiltTestApplication
|
||||||
|
|
||||||
|
class HiltTestRunner : AndroidJUnitRunner() {
|
||||||
|
|
||||||
|
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
|
||||||
|
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,37 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
|
|
||||||
|
|
||||||
class LoggingAdapterDataObserver(
|
|
||||||
private val tag: String,
|
|
||||||
) : AdapterDataObserver() {
|
|
||||||
|
|
||||||
override fun onChanged() {
|
|
||||||
Log.d(tag, "onChanged()")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
|
|
||||||
Log.d(tag, "onItemRangeChanged(positionStart=$positionStart, itemCount=$itemCount)")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
|
|
||||||
Log.d(tag, "onItemRangeChanged(positionStart=$positionStart, itemCount=$itemCount, payload=$payload)")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
|
||||||
Log.d(tag, "onItemRangeInserted(positionStart=$positionStart, itemCount=$itemCount)")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
|
|
||||||
Log.d(tag, "onItemRangeRemoved(positionStart=$positionStart, itemCount=$itemCount)")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
|
|
||||||
Log.d(tag, "onItemRangeMoved(fromPosition=$fromPosition, toPosition=$toPosition, itemCount=$itemCount)")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStateRestorationPolicyChanged() {
|
|
||||||
Log.d(tag, "onStateRestorationPolicyChanged()")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,32 +1,15 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
|
||||||
class BookmarksAdapter(
|
class BookmarksAdapter(
|
||||||
coil: ImageLoader,
|
coil: ImageLoader,
|
||||||
lifecycleOwner: LifecycleOwner,
|
lifecycleOwner: LifecycleOwner,
|
||||||
clickListener: OnListItemClickListener<Bookmark>,
|
clickListener: OnListItemClickListener<Bookmark>,
|
||||||
) : AsyncListDifferDelegationAdapter<Bookmark>(
|
) : BaseListAdapter<Bookmark>(
|
||||||
DiffCallback(),
|
|
||||||
bookmarkListAD(coil, lifecycleOwner, clickListener),
|
bookmarkListAD(coil, lifecycleOwner, clickListener),
|
||||||
) {
|
)
|
||||||
|
|
||||||
private class DiffCallback : DiffUtil.ItemCallback<Bookmark>() {
|
|
||||||
|
|
||||||
override fun areItemsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
|
|
||||||
return oldItem.manga.id == newItem.manga.id &&
|
|
||||||
oldItem.chapterId == newItem.chapterId &&
|
|
||||||
oldItem.page == newItem.page
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
|
|
||||||
return oldItem.imageUrl == newItem.imageUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import coil.ImageLoader
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
|
||||||
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
|
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.clearItemDecorations
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.source
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemBookmarksGroupBinding
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
|
|
||||||
fun bookmarksGroupAD(
|
|
||||||
coil: ImageLoader,
|
|
||||||
lifecycleOwner: LifecycleOwner,
|
|
||||||
sharedPool: RecyclerView.RecycledViewPool,
|
|
||||||
selectionController: SectionedSelectionController<Manga>,
|
|
||||||
bookmarkClickListener: OnListItemClickListener<Bookmark>,
|
|
||||||
groupClickListener: OnListItemClickListener<BookmarksGroup>,
|
|
||||||
) = adapterDelegateViewBinding<BookmarksGroup, ListModel, ItemBookmarksGroupBinding>(
|
|
||||||
{ layoutInflater, parent -> ItemBookmarksGroupBinding.inflate(layoutInflater, parent, false) },
|
|
||||||
) {
|
|
||||||
val viewListenerAdapter = object : View.OnClickListener, View.OnLongClickListener {
|
|
||||||
override fun onClick(v: View) = groupClickListener.onItemClick(item, v)
|
|
||||||
override fun onLongClick(v: View) = groupClickListener.onItemLongClick(item, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
val adapter = BookmarksAdapter(coil, lifecycleOwner, bookmarkClickListener)
|
|
||||||
binding.recyclerView.setRecycledViewPool(sharedPool)
|
|
||||||
binding.recyclerView.adapter = adapter
|
|
||||||
val spacingDecoration = SpacingItemDecoration(context.resources.getDimensionPixelOffset(R.dimen.grid_spacing))
|
|
||||||
binding.recyclerView.addItemDecoration(spacingDecoration)
|
|
||||||
binding.root.setOnClickListener(viewListenerAdapter)
|
|
||||||
binding.root.setOnLongClickListener(viewListenerAdapter)
|
|
||||||
|
|
||||||
bind { payloads ->
|
|
||||||
if (payloads.isEmpty()) {
|
|
||||||
binding.recyclerView.clearItemDecorations()
|
|
||||||
binding.recyclerView.addItemDecoration(spacingDecoration)
|
|
||||||
selectionController.attachToRecyclerView(item.manga, binding.recyclerView)
|
|
||||||
}
|
|
||||||
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
|
||||||
placeholder(R.drawable.ic_placeholder)
|
|
||||||
fallback(R.drawable.ic_placeholder)
|
|
||||||
error(R.drawable.ic_error_placeholder)
|
|
||||||
allowRgb565(true)
|
|
||||||
size(CoverSizeResolver(binding.imageViewCover))
|
|
||||||
source(item.manga.source)
|
|
||||||
enqueueWith(coil)
|
|
||||||
}
|
|
||||||
binding.textViewTitle.text = item.manga.title
|
|
||||||
adapter.items = item.bookmarks
|
|
||||||
}
|
|
||||||
|
|
||||||
onViewRecycled {
|
|
||||||
binding.imageViewCover.disposeImageRequest()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import coil.ImageLoader
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
|
||||||
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.SectionedSelectionController
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import kotlin.jvm.internal.Intrinsics
|
|
||||||
|
|
||||||
class BookmarksGroupAdapter(
|
|
||||||
coil: ImageLoader,
|
|
||||||
lifecycleOwner: LifecycleOwner,
|
|
||||||
selectionController: SectionedSelectionController<Manga>,
|
|
||||||
listener: ListStateHolderListener,
|
|
||||||
bookmarkClickListener: OnListItemClickListener<Bookmark>,
|
|
||||||
groupClickListener: OnListItemClickListener<BookmarksGroup>,
|
|
||||||
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
|
|
||||||
|
|
||||||
init {
|
|
||||||
val pool = RecyclerView.RecycledViewPool()
|
|
||||||
delegatesManager
|
|
||||||
.addDelegate(
|
|
||||||
bookmarksGroupAD(
|
|
||||||
coil = coil,
|
|
||||||
lifecycleOwner = lifecycleOwner,
|
|
||||||
sharedPool = pool,
|
|
||||||
selectionController = selectionController,
|
|
||||||
bookmarkClickListener = bookmarkClickListener,
|
|
||||||
groupClickListener = groupClickListener,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.addDelegate(loadingStateAD())
|
|
||||||
.addDelegate(loadingFooterAD())
|
|
||||||
.addDelegate(emptyStateListAD(coil, lifecycleOwner, listener))
|
|
||||||
.addDelegate(errorStateListAD(listener))
|
|
||||||
}
|
|
||||||
|
|
||||||
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
|
|
||||||
|
|
||||||
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
|
|
||||||
return when {
|
|
||||||
oldItem is BookmarksGroup && newItem is BookmarksGroup -> {
|
|
||||||
oldItem.manga.id == newItem.manga.id
|
|
||||||
}
|
|
||||||
|
|
||||||
oldItem is LoadingFooter && newItem is LoadingFooter -> {
|
|
||||||
oldItem.key == newItem.key
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> oldItem.javaClass == newItem.javaClass
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
|
|
||||||
return Intrinsics.areEqual(oldItem, newItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? {
|
|
||||||
return when {
|
|
||||||
oldItem is BookmarksGroup && newItem is BookmarksGroup -> Unit
|
|
||||||
else -> super.getChangePayload(oldItem, newItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.model
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.util.areItemsEquals
|
|
||||||
|
|
||||||
class BookmarksGroup(
|
|
||||||
val manga: Manga,
|
|
||||||
val bookmarks: List<Bookmark>,
|
|
||||||
) : ListModel {
|
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
|
||||||
if (this === other) return true
|
|
||||||
if (javaClass != other?.javaClass) return false
|
|
||||||
|
|
||||||
other as BookmarksGroup
|
|
||||||
|
|
||||||
if (manga != other.manga) return false
|
|
||||||
|
|
||||||
return bookmarks.areItemsEquals(other.bookmarks) { a, b ->
|
|
||||||
a.imageUrl == b.imageUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
|
||||||
var result = manga.hashCode()
|
|
||||||
result = 31 * result + bookmarks.sumOf { it.imageUrl.hashCode() }
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
||||||
|
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import coil.ImageLoader
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
|
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.source
|
||||||
|
import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
|
fun bookmarkLargeAD(
|
||||||
|
coil: ImageLoader,
|
||||||
|
lifecycleOwner: LifecycleOwner,
|
||||||
|
clickListener: OnListItemClickListener<Bookmark>,
|
||||||
|
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
|
||||||
|
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
|
||||||
|
) {
|
||||||
|
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||||
|
|
||||||
|
binding.root.setOnClickListener(listener)
|
||||||
|
binding.root.setOnLongClickListener(listener)
|
||||||
|
|
||||||
|
bind {
|
||||||
|
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
||||||
|
size(CoverSizeResolver(binding.imageViewThumb))
|
||||||
|
placeholder(R.drawable.ic_placeholder)
|
||||||
|
fallback(R.drawable.ic_placeholder)
|
||||||
|
error(R.drawable.ic_error_placeholder)
|
||||||
|
allowRgb565(true)
|
||||||
|
decodeRegion(item.scroll)
|
||||||
|
source(item.manga.source)
|
||||||
|
enqueueWith(coil)
|
||||||
|
}
|
||||||
|
binding.progressView.percent = item.percent
|
||||||
|
}
|
||||||
|
|
||||||
|
onViewRecycled {
|
||||||
|
binding.imageViewThumb.disposeImageRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import coil.ImageLoader
|
||||||
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
|
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.list.ui.adapter.ListHeaderClickListener
|
||||||
|
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.ListHeader
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
|
class BookmarksAdapter(
|
||||||
|
coil: ImageLoader,
|
||||||
|
lifecycleOwner: LifecycleOwner,
|
||||||
|
clickListener: OnListItemClickListener<Bookmark>,
|
||||||
|
headerClickListener: ListHeaderClickListener?,
|
||||||
|
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
||||||
|
|
||||||
|
init {
|
||||||
|
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
|
||||||
|
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
|
||||||
|
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||||
|
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ListHeader) {
|
||||||
|
return item.getText(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,169 @@
|
|||||||
|
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import coil.ImageLoader
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
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.RecyclerViewScrollCallback
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.plus
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||||
|
import org.koitharu.kotatsu.databinding.SheetPagesBinding
|
||||||
|
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
||||||
|
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||||
|
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
|
||||||
|
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class BookmarksSheet :
|
||||||
|
BaseAdaptiveSheet<SheetPagesBinding>(),
|
||||||
|
AdaptiveSheetCallback,
|
||||||
|
OnListItemClickListener<Bookmark> {
|
||||||
|
|
||||||
|
private val viewModel by viewModels<BookmarksSheetViewModel>()
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var settings: AppSettings
|
||||||
|
|
||||||
|
private var bookmarksAdapter: BookmarksAdapter? = null
|
||||||
|
private var spanResolver: MangaListSpanResolver? = null
|
||||||
|
|
||||||
|
private val spanSizeLookup = SpanSizeLookup()
|
||||||
|
private val listCommitCallback = Runnable {
|
||||||
|
spanSizeLookup.invalidateCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetPagesBinding {
|
||||||
|
return SheetPagesBinding.inflate(inflater, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewBindingCreated(binding: SheetPagesBinding, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
|
addSheetCallback(this)
|
||||||
|
spanResolver = MangaListSpanResolver(binding.root.resources)
|
||||||
|
bookmarksAdapter = BookmarksAdapter(
|
||||||
|
coil = coil,
|
||||||
|
lifecycleOwner = viewLifecycleOwner,
|
||||||
|
clickListener = this@BookmarksSheet,
|
||||||
|
headerClickListener = null,
|
||||||
|
)
|
||||||
|
viewBinding?.headerBar?.setTitle(R.string.bookmarks)
|
||||||
|
with(binding.recyclerView) {
|
||||||
|
addItemDecoration(TypedListSpacingDecoration(context))
|
||||||
|
adapter = bookmarksAdapter
|
||||||
|
addOnLayoutChangeListener(spanResolver)
|
||||||
|
spanResolver?.setGridSize(settings.gridSize / 100f, this)
|
||||||
|
(layoutManager as GridLayoutManager).spanSizeLookup = spanSizeLookup
|
||||||
|
}
|
||||||
|
viewModel.content.observe(viewLifecycleOwner, ::onThumbnailsChanged)
|
||||||
|
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
spanResolver = null
|
||||||
|
bookmarksAdapter = null
|
||||||
|
spanSizeLookup.invalidateCache()
|
||||||
|
super.onDestroyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onItemClick(item: Bookmark, view: View) {
|
||||||
|
val listener = (parentFragment as? OnPageSelectListener) ?: (activity as? OnPageSelectListener)
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onPageSelected(ReaderPage(item.toMangaPage(), item.page, item.chapterId))
|
||||||
|
} else {
|
||||||
|
val intent = IntentBuilder(view.context)
|
||||||
|
.manga(viewModel.manga)
|
||||||
|
.bookmark(item)
|
||||||
|
.incognito(true)
|
||||||
|
.build()
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStateChanged(sheet: View, newState: Int) {
|
||||||
|
viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onThumbnailsChanged(list: List<ListModel>) {
|
||||||
|
val adapter = bookmarksAdapter ?: return
|
||||||
|
if (adapter.itemCount == 0) {
|
||||||
|
var position = list.indexOfFirst { it is PageThumbnail && it.isCurrent }
|
||||||
|
if (position > 0) {
|
||||||
|
val spanCount = spanResolver?.spanCount ?: 0
|
||||||
|
val offset = if (position > spanCount + 1) {
|
||||||
|
(resources.getDimensionPixelSize(R.dimen.manga_list_details_item_height) * 0.6).roundToInt()
|
||||||
|
} else {
|
||||||
|
position = 0
|
||||||
|
0
|
||||||
|
}
|
||||||
|
val scrollCallback = RecyclerViewScrollCallback(requireViewBinding().recyclerView, position, offset)
|
||||||
|
adapter.setItems(list, listCommitCallback + scrollCallback)
|
||||||
|
} else {
|
||||||
|
adapter.setItems(list, listCommitCallback)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
adapter.setItems(list, listCommitCallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {
|
||||||
|
|
||||||
|
init {
|
||||||
|
isSpanIndexCacheEnabled = true
|
||||||
|
isSpanGroupIndexCacheEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSpanSize(position: Int): Int {
|
||||||
|
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1
|
||||||
|
return when (bookmarksAdapter?.getItemViewType(position)) {
|
||||||
|
ListItemType.PAGE_THUMB.ordinal -> 1
|
||||||
|
else -> total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invalidateCache() {
|
||||||
|
invalidateSpanGroupIndexCache()
|
||||||
|
invalidateSpanIndexCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val ARG_MANGA = "manga"
|
||||||
|
|
||||||
|
private const val TAG = "BookmarksSheet"
|
||||||
|
|
||||||
|
fun show(fm: FragmentManager, manga: Manga) {
|
||||||
|
BookmarksSheet().withArgs(1) {
|
||||||
|
putParcelable(ARG_MANGA, ParcelableManga(manga, withChapters = true))
|
||||||
|
}.showDistinct(fm, TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package org.koitharu.kotatsu.bookmarks.ui.sheet
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
|
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||||
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.require
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||||
|
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class BookmarksSheetViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
bookmarksRepository: BookmarksRepository,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val manga = savedStateHandle.require<ParcelableManga>(BookmarksSheet.ARG_MANGA).manga
|
||||||
|
private val chaptersLazy = SuspendLazy {
|
||||||
|
requireNotNull(manga.chapters ?: mangaRepositoryFactory.create(manga.source).getDetails(manga).chapters)
|
||||||
|
}
|
||||||
|
|
||||||
|
val content: StateFlow<List<ListModel>> = bookmarksRepository.observeBookmarks(manga)
|
||||||
|
.map { mapList(it) }
|
||||||
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingFooter()))
|
||||||
|
|
||||||
|
private suspend fun mapList(bookmarks: List<Bookmark>): List<ListModel> {
|
||||||
|
val chapters = chaptersLazy.get()
|
||||||
|
val bookmarksMap = bookmarks.groupBy { it.chapterId }
|
||||||
|
val result = ArrayList<ListModel>(bookmarks.size + bookmarksMap.size)
|
||||||
|
for (chapter in chapters) {
|
||||||
|
val b = bookmarksMap[chapter.id]
|
||||||
|
if (b.isNullOrEmpty()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result += ListHeader(chapter.name)
|
||||||
|
result.addAll(b)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
package org.koitharu.kotatsu.browser.cloudflare
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.app.NotificationChannelCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import coil.request.ErrorResult
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
|
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||||
|
|
||||||
|
class CaptchaNotifier(
|
||||||
|
private val context: Context,
|
||||||
|
) : ImageRequest.Listener {
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun notify(exception: CloudFlareProtectedException) {
|
||||||
|
val manager = NotificationManagerCompat.from(context)
|
||||||
|
if (!manager.areNotificationsEnabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
||||||
|
.setName(context.getString(R.string.captcha_required))
|
||||||
|
.setShowBadge(true)
|
||||||
|
.setVibrationEnabled(false)
|
||||||
|
.setSound(null, null)
|
||||||
|
.setLightsEnabled(false)
|
||||||
|
.build()
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
|
||||||
|
val intent = CloudFlareActivity.newIntent(context, exception.url, exception.headers)
|
||||||
|
.setData(exception.url.toUri())
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setContentTitle(channel.name)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setDefaults(NotificationCompat.DEFAULT_SOUND)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setVisibility(
|
||||||
|
if (exception.source?.contentType == ContentType.HENTAI) {
|
||||||
|
NotificationCompat.VISIBILITY_SECRET
|
||||||
|
} else {
|
||||||
|
NotificationCompat.VISIBILITY_PUBLIC
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.setContentText(
|
||||||
|
context.getString(
|
||||||
|
R.string.captcha_required_summary,
|
||||||
|
exception.source?.title ?: context.getString(R.string.app_name),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
|
||||||
|
.build()
|
||||||
|
manager.notify(TAG, exception.source.hashCode(), notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(request: ImageRequest, result: ErrorResult) {
|
||||||
|
super.onError(request, result)
|
||||||
|
val e = result.throwable
|
||||||
|
if (e is CloudFlareProtectedException) {
|
||||||
|
notify(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
private const val CHANNEL_ID = "captcha"
|
||||||
|
private const val TAG = CHANNEL_ID
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import androidx.room.Upsert
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class MangaSourcesDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sources ORDER BY sort_key")
|
||||||
|
abstract suspend fun findAll(): List<MangaSourceEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key")
|
||||||
|
abstract suspend fun findAllEnabled(): List<MangaSourceEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY sort_key")
|
||||||
|
abstract fun observeEnabled(): Flow<List<MangaSourceEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sources ORDER BY sort_key")
|
||||||
|
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT IFNULL(MAX(sort_key),0) FROM sources")
|
||||||
|
abstract suspend fun getMaxSortKey(): Int
|
||||||
|
|
||||||
|
@Query("UPDATE sources SET enabled = 0")
|
||||||
|
abstract suspend fun disableAllSources()
|
||||||
|
|
||||||
|
@Query("UPDATE sources SET sort_key = :sortKey WHERE source = :source")
|
||||||
|
abstract suspend fun setSortKey(source: String, sortKey: Int)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
@Transaction
|
||||||
|
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
abstract suspend fun upsert(entry: MangaSourceEntity)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open suspend fun setEnabled(source: String, isEnabled: Boolean) {
|
||||||
|
if (updateIsEnabled(source, isEnabled) == 0) {
|
||||||
|
val entity = MangaSourceEntity(
|
||||||
|
source = source,
|
||||||
|
isEnabled = isEnabled,
|
||||||
|
sortKey = getMaxSortKey() + 1,
|
||||||
|
)
|
||||||
|
upsert(entity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
|
||||||
|
protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.entity
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "sources",
|
||||||
|
)
|
||||||
|
data class MangaSourceEntity(
|
||||||
|
@PrimaryKey(autoGenerate = false)
|
||||||
|
@ColumnInfo(name = "source")
|
||||||
|
val source: String,
|
||||||
|
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
|
||||||
|
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
|
||||||
|
)
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
class Migration16To17(context: Context) : Migration(16, 17) {
|
||||||
|
|
||||||
|
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
database.execSQL("CREATE TABLE `sources` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))")
|
||||||
|
database.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
|
||||||
|
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
|
||||||
|
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
|
||||||
|
val sources = MangaSource.entries
|
||||||
|
for (source in sources) {
|
||||||
|
if (source == MangaSource.LOCAL) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val name = source.name
|
||||||
|
var sortKey = order.indexOf(name)
|
||||||
|
if (sortKey == -1) {
|
||||||
|
sortKey = order.size + source.ordinal
|
||||||
|
}
|
||||||
|
database.execSQL(
|
||||||
|
"INSERT INTO `sources` (`source`, `enabled`, `sort_key`) VALUES (?, ?, ?)",
|
||||||
|
arrayOf(name, (name !in hiddenSources).toInt(), sortKey),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Boolean.toInt() = if (this) 1 else 0
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import okio.IOException
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class TooManyRequestExceptions(
|
||||||
|
val url: String,
|
||||||
|
val retryAt: Date?,
|
||||||
|
) : IOException()
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package org.koitharu.kotatsu.core.fs
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import org.koitharu.kotatsu.core.util.iterator.CloseableIterator
|
||||||
|
import org.koitharu.kotatsu.core.util.iterator.MappingIterator
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
class FileSequence(private val dir: File) : Sequence<File> {
|
||||||
|
|
||||||
|
override fun iterator(): Iterator<File> {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val stream = Files.newDirectoryStream(dir.toPath())
|
||||||
|
CloseableIterator(MappingIterator(stream.iterator(), Path::toFile), stream)
|
||||||
|
} else {
|
||||||
|
dir.listFiles().orEmpty().iterator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,98 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.model.parcelable
|
|
||||||
|
|
||||||
import android.os.Parcel
|
|
||||||
import androidx.core.os.ParcelCompat
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
|
||||||
|
|
||||||
fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) {
|
|
||||||
out.writeLong(id)
|
|
||||||
out.writeString(title)
|
|
||||||
out.writeString(altTitle)
|
|
||||||
out.writeString(url)
|
|
||||||
out.writeString(publicUrl)
|
|
||||||
out.writeFloat(rating)
|
|
||||||
ParcelCompat.writeBoolean(out, isNsfw)
|
|
||||||
out.writeString(coverUrl)
|
|
||||||
out.writeString(largeCoverUrl)
|
|
||||||
out.writeString(description)
|
|
||||||
out.writeParcelable(ParcelableMangaTags(tags), flags)
|
|
||||||
out.writeSerializable(state)
|
|
||||||
out.writeString(author)
|
|
||||||
if (withChapters) {
|
|
||||||
out.writeParcelable(chapters?.let(::ParcelableMangaChapters), flags)
|
|
||||||
} else {
|
|
||||||
out.writeString(null)
|
|
||||||
}
|
|
||||||
out.writeSerializable(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Parcel.readManga() = Manga(
|
|
||||||
id = readLong(),
|
|
||||||
title = requireNotNull(readString()),
|
|
||||||
altTitle = readString(),
|
|
||||||
url = requireNotNull(readString()),
|
|
||||||
publicUrl = requireNotNull(readString()),
|
|
||||||
rating = readFloat(),
|
|
||||||
isNsfw = ParcelCompat.readBoolean(this),
|
|
||||||
coverUrl = requireNotNull(readString()),
|
|
||||||
largeCoverUrl = readString(),
|
|
||||||
description = readString(),
|
|
||||||
tags = requireNotNull(readParcelableCompat<ParcelableMangaTags>()).tags,
|
|
||||||
state = readSerializableCompat(),
|
|
||||||
author = readString(),
|
|
||||||
chapters = readParcelableCompat<ParcelableMangaChapters>()?.chapters,
|
|
||||||
source = checkNotNull(readSerializableCompat()),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun MangaPage.writeToParcel(out: Parcel) {
|
|
||||||
out.writeLong(id)
|
|
||||||
out.writeString(url)
|
|
||||||
out.writeString(preview)
|
|
||||||
out.writeSerializable(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Parcel.readMangaPage() = MangaPage(
|
|
||||||
id = readLong(),
|
|
||||||
url = requireNotNull(readString()),
|
|
||||||
preview = readString(),
|
|
||||||
source = checkNotNull(readSerializableCompat()),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun MangaChapter.writeToParcel(out: Parcel) {
|
|
||||||
out.writeLong(id)
|
|
||||||
out.writeString(name)
|
|
||||||
out.writeInt(number)
|
|
||||||
out.writeString(url)
|
|
||||||
out.writeString(scanlator)
|
|
||||||
out.writeLong(uploadDate)
|
|
||||||
out.writeString(branch)
|
|
||||||
out.writeSerializable(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Parcel.readMangaChapter() = MangaChapter(
|
|
||||||
id = readLong(),
|
|
||||||
name = requireNotNull(readString()),
|
|
||||||
number = readInt(),
|
|
||||||
url = requireNotNull(readString()),
|
|
||||||
scanlator = readString(),
|
|
||||||
uploadDate = readLong(),
|
|
||||||
branch = readString(),
|
|
||||||
source = checkNotNull(readSerializableCompat()),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun MangaTag.writeToParcel(out: Parcel) {
|
|
||||||
out.writeString(title)
|
|
||||||
out.writeString(key)
|
|
||||||
out.writeSerializable(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Parcel.readMangaTag() = MangaTag(
|
|
||||||
title = requireNotNull(readString()),
|
|
||||||
key = requireNotNull(readString()),
|
|
||||||
source = checkNotNull(readSerializableCompat()),
|
|
||||||
)
|
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package org.koitharu.kotatsu.core.model.parcelable
|
||||||
|
|
||||||
|
import android.os.Parcel
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parceler
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import kotlinx.parcelize.TypeParceler
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
|
||||||
|
object MangaPageParceler : Parceler<MangaPage> {
|
||||||
|
override fun create(parcel: Parcel) = MangaPage(
|
||||||
|
id = parcel.readLong(),
|
||||||
|
url = requireNotNull(parcel.readString()),
|
||||||
|
preview = parcel.readString(),
|
||||||
|
source = requireNotNull(parcel.readSerializableCompat()),
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun MangaPage.write(parcel: Parcel, flags: Int) {
|
||||||
|
parcel.writeLong(id)
|
||||||
|
parcel.writeString(url)
|
||||||
|
parcel.writeString(preview)
|
||||||
|
parcel.writeSerializable(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
@TypeParceler<MangaPage, MangaPageParceler>
|
||||||
|
class ParcelableMangaPage(val page: MangaPage) : Parcelable
|
||||||
@ -1,39 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.model.parcelable
|
|
||||||
|
|
||||||
import android.os.Parcel
|
|
||||||
import android.os.Parcelable
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
|
||||||
|
|
||||||
class ParcelableMangaPages(
|
|
||||||
val pages: List<MangaPage>,
|
|
||||||
) : Parcelable {
|
|
||||||
|
|
||||||
constructor(parcel: Parcel) : this(
|
|
||||||
List(parcel.readInt()) { parcel.readMangaPage() }
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
|
||||||
parcel.writeInt(pages.size)
|
|
||||||
for (page in pages) {
|
|
||||||
page.writeToParcel(parcel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun describeContents(): Int {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun toString(): String {
|
|
||||||
return "ParcelableMangaPages(pages=$pages)"
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object CREATOR : Parcelable.Creator<ParcelableMangaPages> {
|
|
||||||
override fun createFromParcel(parcel: Parcel): ParcelableMangaPages {
|
|
||||||
return ParcelableMangaPages(parcel)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun newArray(size: Int): Array<ParcelableMangaPages?> {
|
|
||||||
return arrayOfNulls(size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package org.koitharu.kotatsu.core.network
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.internal.closeQuietly
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class RateLimitInterceptor : Interceptor {
|
||||||
|
|
||||||
|
private val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss ZZZ", Locale.ENGLISH)
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val response = chain.proceed(chain.request())
|
||||||
|
if (response.code == 429) {
|
||||||
|
val retryDate = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryDate()
|
||||||
|
val request = response.request
|
||||||
|
response.closeQuietly()
|
||||||
|
throw TooManyRequestExceptions(
|
||||||
|
url = request.url.toString(),
|
||||||
|
retryAt = retryDate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.parseRetryDate(): Date? {
|
||||||
|
toIntOrNull()?.let {
|
||||||
|
return Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(it.toLong()))
|
||||||
|
}
|
||||||
|
return dateFormat.parse(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package org.koitharu.kotatsu.core.prefs
|
||||||
|
|
||||||
|
enum class ReaderAnimation {
|
||||||
|
|
||||||
|
// Do not rename this
|
||||||
|
NONE, DEFAULT, ADVANCED;
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package org.koitharu.kotatsu.core.prefs
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.ContextThemeWrapper
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
enum class ReaderBackground {
|
||||||
|
|
||||||
|
DEFAULT, LIGHT, DARK, WHITE, BLACK;
|
||||||
|
|
||||||
|
fun resolve(context: Context) = when (this) {
|
||||||
|
DEFAULT -> context.getThemeDrawable(android.R.attr.windowBackground)
|
||||||
|
LIGHT -> ContextThemeWrapper(context, materialR.style.ThemeOverlay_Material3_Light)
|
||||||
|
.getThemeDrawable(android.R.attr.windowBackground)
|
||||||
|
|
||||||
|
DARK -> ContextThemeWrapper(context, materialR.style.ThemeOverlay_Material3_Dark)
|
||||||
|
.getThemeDrawable(android.R.attr.windowBackground)
|
||||||
|
|
||||||
|
WHITE -> ContextCompat.getColor(context, android.R.color.white).toDrawable()
|
||||||
|
BLACK -> ContextCompat.getColor(context, android.R.color.black).toDrawable()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,106 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui
|
|
||||||
|
|
||||||
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.OnBackPressedDispatcher
|
|
||||||
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.bottomsheet.BottomSheetDialogFragment
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.AppBottomSheetDialog
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.findActivity
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplaySize
|
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
|
|
||||||
@Deprecated(
|
|
||||||
"Use BaseAdaptiveSheet",
|
|
||||||
replaceWith = ReplaceWith("BaseAdaptiveSheet<B>", "org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet"),
|
|
||||||
)
|
|
||||||
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
|
|
||||||
|
|
||||||
var viewBinding: B? = null
|
|
||||||
private set
|
|
||||||
|
|
||||||
@Deprecated("", ReplaceWith("requireViewBinding()"))
|
|
||||||
protected val binding: B
|
|
||||||
get() = requireViewBinding()
|
|
||||||
|
|
||||||
protected val behavior: BottomSheetBehavior<*>?
|
|
||||||
get() = (dialog as? BottomSheetDialog)?.behavior
|
|
||||||
|
|
||||||
val isExpanded: Boolean
|
|
||||||
get() = behavior?.state == BottomSheetBehavior.STATE_EXPANDED
|
|
||||||
|
|
||||||
val onBackPressedDispatcher: OnBackPressedDispatcher
|
|
||||||
get() = (requireDialog() as AppBottomSheetDialog).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()
|
|
||||||
// Enforce max width for tablets
|
|
||||||
val width = resources.getDimensionPixelSize(R.dimen.bottom_sheet_width)
|
|
||||||
if (width > 0) {
|
|
||||||
behavior?.maxWidth = width
|
|
||||||
}
|
|
||||||
// Set peek height to 40% display height
|
|
||||||
binding.root.context.findActivity()?.getDisplaySize()?.let {
|
|
||||||
behavior?.peekHeight = (it.height() * 0.4).toInt()
|
|
||||||
}
|
|
||||||
onViewBindingCreated(binding, savedInstanceState)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
|
||||||
viewBinding = null
|
|
||||||
super.onDestroyView()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
|
||||||
return AppBottomSheetDialog(requireContext(), theme)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addBottomSheetCallback(callback: BottomSheetBehavior.BottomSheetCallback) {
|
|
||||||
val b = behavior ?: return
|
|
||||||
b.addBottomSheetCallback(callback)
|
|
||||||
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
b.isFitToContents = !isExpanded
|
|
||||||
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet)
|
|
||||||
rootView?.updateLayoutParams {
|
|
||||||
height = if (isExpanded) LayoutParams.MATCH_PARENT else LayoutParams.WRAP_CONTENT
|
|
||||||
}
|
|
||||||
b.isDraggable = !isLocked
|
|
||||||
}
|
|
||||||
|
|
||||||
fun requireViewBinding(): B = checkNotNull(viewBinding) {
|
|
||||||
"Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui
|
||||||
|
|
||||||
|
import androidx.recyclerview.widget.AsyncDifferConfig
|
||||||
|
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.asExecutor
|
||||||
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
|
import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable
|
||||||
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
|
open class BaseListAdapter<T : ListModel>(
|
||||||
|
vararg delegates: AdapterDelegate<List<T>>,
|
||||||
|
) : AsyncListDifferDelegationAdapter<T>(
|
||||||
|
AsyncDifferConfig.Builder(ListModelDiffCallback<T>())
|
||||||
|
.setBackgroundThreadExecutor(Dispatchers.Default.limitedParallelism(2).asExecutor())
|
||||||
|
.build(),
|
||||||
|
*delegates,
|
||||||
|
), FlowCollector<List<T>?> {
|
||||||
|
|
||||||
|
override suspend fun emit(value: List<T>?) = suspendCoroutine { cont ->
|
||||||
|
setItems(value.orEmpty(), ContinuationResumeRunnable(cont))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addDelegate(type: ListItemType, delegate: AdapterDelegate<List<T>>): BaseListAdapter<T> {
|
||||||
|
delegatesManager.addDelegate(type.ordinal, delegate)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addListListener(listListener: ListListener<T>) {
|
||||||
|
differ.addListListener(listListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeListListener(listListener: ListListener<T>) {
|
||||||
|
differ.removeListListener(listListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,29 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.dialog
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.view.View
|
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
|
||||||
|
|
||||||
class AppBottomSheetDialog(context: Context, theme: Int) : BottomSheetDialog(context, theme) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* https://github.com/material-components/material-components-android/issues/2582
|
|
||||||
*/
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
override fun onAttachedToWindow() {
|
|
||||||
val window = window
|
|
||||||
val initialSystemUiVisibility = window?.decorView?.systemUiVisibility ?: 0
|
|
||||||
super.onAttachedToWindow()
|
|
||||||
if (window != null) {
|
|
||||||
// If the navigation bar is translucent at all, the BottomSheet should be edge to edge
|
|
||||||
val drawEdgeToEdge = edgeToEdgeEnabled && Color.alpha(window.navigationBarColor) < 0xFF
|
|
||||||
if (drawEdgeToEdge) {
|
|
||||||
// Copied from super.onAttachedToWindow:
|
|
||||||
val edgeToEdgeFlags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
|
||||||
// Fix super-class's window flag bug by respecting the initial system UI visibility:
|
|
||||||
window.decorView.systemUiVisibility = edgeToEdgeFlags or initialSystemUiVisibility
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.list.decor
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.Paint
|
|
||||||
import android.graphics.Rect
|
|
||||||
import android.view.View
|
|
||||||
import androidx.core.content.res.getColorOrThrow
|
|
||||||
import androidx.core.view.children
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
|
|
||||||
@SuppressLint("PrivateResource")
|
|
||||||
abstract class AbstractDividerItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
|
|
||||||
|
|
||||||
private val bounds = Rect()
|
|
||||||
private val thickness: Int
|
|
||||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
|
||||||
|
|
||||||
init {
|
|
||||||
paint.style = Paint.Style.FILL
|
|
||||||
val ta = context.obtainStyledAttributes(
|
|
||||||
null,
|
|
||||||
materialR.styleable.MaterialDivider,
|
|
||||||
materialR.attr.materialDividerStyle,
|
|
||||||
materialR.style.Widget_Material3_MaterialDivider,
|
|
||||||
)
|
|
||||||
paint.color = ta.getColorOrThrow(materialR.styleable.MaterialDivider_dividerColor)
|
|
||||||
thickness = ta.getDimensionPixelSize(
|
|
||||||
materialR.styleable.MaterialDivider_dividerThickness,
|
|
||||||
context.resources.getDimensionPixelSize(materialR.dimen.material_divider_thickness),
|
|
||||||
)
|
|
||||||
ta.recycle()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
override fun getItemOffsets(
|
|
||||||
outRect: Rect,
|
|
||||||
view: View,
|
|
||||||
parent: RecyclerView,
|
|
||||||
state: RecyclerView.State,
|
|
||||||
) {
|
|
||||||
outRect.set(0, thickness, 0, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO implement for horizontal lists on demand
|
|
||||||
override fun onDraw(canvas: Canvas, parent: RecyclerView, s: RecyclerView.State) {
|
|
||||||
if (parent.layoutManager == null || thickness == 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
canvas.save()
|
|
||||||
val left: Float
|
|
||||||
val right: Float
|
|
||||||
if (parent.clipToPadding) {
|
|
||||||
left = parent.paddingLeft.toFloat()
|
|
||||||
right = (parent.width - parent.paddingRight).toFloat()
|
|
||||||
canvas.clipRect(
|
|
||||||
left,
|
|
||||||
parent.paddingTop.toFloat(),
|
|
||||||
right,
|
|
||||||
(parent.height - parent.paddingBottom).toFloat(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
left = 0f
|
|
||||||
right = parent.width.toFloat()
|
|
||||||
}
|
|
||||||
|
|
||||||
var previous: RecyclerView.ViewHolder? = null
|
|
||||||
for (child in parent.children) {
|
|
||||||
val holder = parent.getChildViewHolder(child)
|
|
||||||
if (previous != null && shouldDrawDivider(previous, holder)) {
|
|
||||||
parent.getDecoratedBoundsWithMargins(child, bounds)
|
|
||||||
val top: Float = bounds.top + child.translationY
|
|
||||||
val bottom: Float = top + thickness
|
|
||||||
canvas.drawRect(left, top, right, bottom, paint)
|
|
||||||
}
|
|
||||||
previous = holder
|
|
||||||
}
|
|
||||||
canvas.restore()
|
|
||||||
}
|
|
||||||
|
|
||||||
protected abstract fun shouldDrawDivider(
|
|
||||||
above: RecyclerView.ViewHolder,
|
|
||||||
below: RecyclerView.ViewHolder,
|
|
||||||
): Boolean
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.list.decor
|
|
||||||
|
|
||||||
import android.graphics.Rect
|
|
||||||
import android.util.SparseIntArray
|
|
||||||
import android.view.View
|
|
||||||
import androidx.core.util.getOrDefault
|
|
||||||
import androidx.core.util.set
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
|
|
||||||
class TypedSpacingItemDecoration(
|
|
||||||
vararg spacingMapping: Pair<Int, Int>,
|
|
||||||
private val fallbackSpacing: Int = 0,
|
|
||||||
) : RecyclerView.ItemDecoration() {
|
|
||||||
|
|
||||||
private val mapping = SparseIntArray(spacingMapping.size)
|
|
||||||
|
|
||||||
init {
|
|
||||||
spacingMapping.forEach { (k, v) -> mapping[k] = v }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemOffsets(
|
|
||||||
outRect: Rect,
|
|
||||||
view: View,
|
|
||||||
parent: RecyclerView,
|
|
||||||
state: RecyclerView.State
|
|
||||||
) {
|
|
||||||
val itemType = parent.getChildViewHolder(view)?.itemViewType
|
|
||||||
val spacing = if (itemType == null) {
|
|
||||||
fallbackSpacing
|
|
||||||
} else {
|
|
||||||
mapping.getOrDefault(itemType, fallbackSpacing)
|
|
||||||
}
|
|
||||||
outRect.set(spacing, spacing, spacing, spacing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.util
|
||||||
|
|
||||||
|
import androidx.core.view.MenuHost
|
||||||
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
|
|
||||||
|
class MenuInvalidator(
|
||||||
|
private val host: MenuHost,
|
||||||
|
) : FlowCollector<Any?> {
|
||||||
|
|
||||||
|
override suspend fun emit(value: Any?) {
|
||||||
|
host.invalidateMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.util
|
||||||
|
|
||||||
|
import androidx.annotation.IdRes
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import com.google.android.material.badge.BadgeDrawable
|
||||||
|
import com.google.android.material.badge.BadgeUtils
|
||||||
|
import com.google.android.material.badge.ExperimentalBadgeUtils
|
||||||
|
|
||||||
|
@androidx.annotation.OptIn(ExperimentalBadgeUtils::class)
|
||||||
|
class OptionsMenuBadgeHelper(
|
||||||
|
private val toolbar: Toolbar,
|
||||||
|
@IdRes private val itemId: Int,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private var badge: BadgeDrawable? = null
|
||||||
|
|
||||||
|
fun setBadgeVisible(isVisible: Boolean) {
|
||||||
|
if (isVisible) {
|
||||||
|
showBadge()
|
||||||
|
} else {
|
||||||
|
hideBadge()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideBadge() {
|
||||||
|
badge?.let {
|
||||||
|
BadgeUtils.detachBadgeDrawable(it, toolbar, itemId)
|
||||||
|
}
|
||||||
|
badge = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showBadge() {
|
||||||
|
val badgeDrawable = badge ?: BadgeDrawable.create(toolbar.context).also {
|
||||||
|
badge = it
|
||||||
|
}
|
||||||
|
BadgeUtils.attachBadgeDrawable(badgeDrawable, toolbar, itemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,316 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.widgets
|
|
||||||
|
|
||||||
import android.animation.LayoutTransition
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.AttributeSet
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.Menu
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.view.WindowInsets
|
|
||||||
import androidx.annotation.AttrRes
|
|
||||||
import androidx.annotation.MenuRes
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.appcompat.widget.Toolbar
|
|
||||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
|
||||||
import androidx.core.content.withStyledAttributes
|
|
||||||
import androidx.core.view.*
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import com.google.android.material.appbar.AppBarLayout
|
|
||||||
import com.google.android.material.appbar.MaterialToolbar
|
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.parents
|
|
||||||
import org.koitharu.kotatsu.databinding.LayoutSheetHeaderBinding
|
|
||||||
import java.util.*
|
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
|
|
||||||
private const val THROTTLE_DELAY = 200L
|
|
||||||
|
|
||||||
@Deprecated("")
|
|
||||||
class BottomSheetHeaderBar @JvmOverloads constructor(
|
|
||||||
context: Context,
|
|
||||||
attrs: AttributeSet? = null,
|
|
||||||
@AttrRes defStyleAttr: Int = materialR.attr.appBarLayoutStyle,
|
|
||||||
) : AppBarLayout(context, attrs, defStyleAttr), MenuHost {
|
|
||||||
|
|
||||||
private val binding = LayoutSheetHeaderBinding.inflate(LayoutInflater.from(context), this)
|
|
||||||
private val closeDrawable = context.getThemeDrawable(materialR.attr.actionModeCloseDrawable)
|
|
||||||
private val bottomSheetCallback = Callback()
|
|
||||||
private val adjustStateRunnable = Runnable { adjustState() }
|
|
||||||
private var bottomSheetBehavior: BottomSheetBehavior<*>? = null
|
|
||||||
private val locationBuffer = IntArray(2)
|
|
||||||
private val expansionListeners = LinkedList<OnExpansionChangeListener>()
|
|
||||||
private var fitStatusBar = false
|
|
||||||
private val minHandleHeight = context.resources.getDimensionPixelSize(R.dimen.bottom_sheet_handle_size_min)
|
|
||||||
private val maxHandleHeight = context.resources.getDimensionPixelSize(R.dimen.bottom_sheet_handle_size_max)
|
|
||||||
private var isLayoutSuppressedCompat = false
|
|
||||||
private var isLayoutCalledWhileSuppressed = false
|
|
||||||
private var isBsExpanded = false
|
|
||||||
private var stateAdjustedAt = 0L
|
|
||||||
|
|
||||||
@Deprecated("")
|
|
||||||
val toolbar: MaterialToolbar
|
|
||||||
get() = binding.toolbar
|
|
||||||
|
|
||||||
val menu: Menu
|
|
||||||
get() = binding.toolbar.menu
|
|
||||||
|
|
||||||
var title: CharSequence?
|
|
||||||
get() = binding.toolbar.title
|
|
||||||
set(value) {
|
|
||||||
binding.toolbar.title = value
|
|
||||||
}
|
|
||||||
|
|
||||||
var subtitle: CharSequence?
|
|
||||||
get() = binding.toolbar.subtitle
|
|
||||||
set(value) {
|
|
||||||
binding.toolbar.subtitle = value
|
|
||||||
}
|
|
||||||
|
|
||||||
val isExpanded: Boolean
|
|
||||||
get() = binding.dragHandle.isGone
|
|
||||||
|
|
||||||
init {
|
|
||||||
setBackgroundResource(R.drawable.sheet_toolbar_background)
|
|
||||||
layoutTransition = LayoutTransition().apply {
|
|
||||||
setDuration(context.getAnimationDuration(R.integer.config_tinyAnimTime))
|
|
||||||
}
|
|
||||||
context.withStyledAttributes(attrs, R.styleable.BottomSheetHeaderBar, defStyleAttr) {
|
|
||||||
binding.toolbar.title = getString(R.styleable.BottomSheetHeaderBar_title)
|
|
||||||
fitStatusBar = getBoolean(R.styleable.BottomSheetHeaderBar_fitStatusBar, fitStatusBar)
|
|
||||||
val menuResId = getResourceId(R.styleable.BottomSheetHeaderBar_menu, 0)
|
|
||||||
if (menuResId != 0) {
|
|
||||||
binding.toolbar.inflateMenu(menuResId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binding.toolbar.setNavigationOnClickListener(bottomSheetCallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAttachedToWindow() {
|
|
||||||
super.onAttachedToWindow()
|
|
||||||
setBottomSheetBehavior(findParentBottomSheetBehavior())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDetachedFromWindow() {
|
|
||||||
setBottomSheetBehavior(null)
|
|
||||||
super.onDetachedFromWindow()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun addView(child: View?, index: Int) {
|
|
||||||
if (shouldAddView(child)) {
|
|
||||||
super.addView(child, index)
|
|
||||||
} else {
|
|
||||||
binding.toolbar.addView(child, index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun addView(child: View?, width: Int, height: Int) {
|
|
||||||
if (shouldAddView(child)) {
|
|
||||||
super.addView(child, width, height)
|
|
||||||
} else {
|
|
||||||
binding.toolbar.addView(child, width, height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
|
|
||||||
if (shouldAddView(child)) {
|
|
||||||
super.addView(child, index, params)
|
|
||||||
} else {
|
|
||||||
binding.toolbar.addView(child, index, convertLayoutParams(params))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onApplyWindowInsets(insets: WindowInsets?): WindowInsets {
|
|
||||||
dispatchInsets(if (insets != null) WindowInsetsCompat.toWindowInsetsCompat(insets) else null)
|
|
||||||
return super.onApplyWindowInsets(insets)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun addMenuProvider(provider: MenuProvider) {
|
|
||||||
binding.toolbar.addMenuProvider(provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun addMenuProvider(provider: MenuProvider, owner: LifecycleOwner) {
|
|
||||||
binding.toolbar.addMenuProvider(provider, owner)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun addMenuProvider(provider: MenuProvider, owner: LifecycleOwner, state: Lifecycle.State) {
|
|
||||||
binding.toolbar.addMenuProvider(provider, owner, state)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun removeMenuProvider(provider: MenuProvider) {
|
|
||||||
binding.toolbar.removeMenuProvider(provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun invalidateMenu() {
|
|
||||||
binding.toolbar.invalidateMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun inflateMenu(@MenuRes resId: Int) {
|
|
||||||
binding.toolbar.inflateMenu(resId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setNavigationOnClickListener(onClickListener: OnClickListener) {
|
|
||||||
binding.toolbar.setNavigationOnClickListener(onClickListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addOnExpansionChangeListener(listener: OnExpansionChangeListener) {
|
|
||||||
expansionListeners.add(listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeOnExpansionChangeListener(listener: OnExpansionChangeListener) {
|
|
||||||
expansionListeners.remove(listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setTitle(@StringRes resId: Int) {
|
|
||||||
binding.toolbar.setTitle(resId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSubtitle(@StringRes resId: Int) {
|
|
||||||
binding.toolbar.setSubtitle(resId)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
|
||||||
if (isLayoutSuppressedCompat) {
|
|
||||||
isLayoutCalledWhileSuppressed = true
|
|
||||||
} else {
|
|
||||||
super.onLayout(changed, l, t, r, b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setBottomSheetBehavior(behavior: BottomSheetBehavior<*>?) {
|
|
||||||
bottomSheetBehavior?.removeBottomSheetCallback(bottomSheetCallback)
|
|
||||||
bottomSheetBehavior = behavior
|
|
||||||
if (behavior != null) {
|
|
||||||
onBottomSheetStateChanged(behavior.state)
|
|
||||||
behavior.addBottomSheetCallback(bottomSheetCallback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onBottomSheetStateChanged(newState: Int) {
|
|
||||||
val expanded = newState == BottomSheetBehavior.STATE_EXPANDED && isOnTopOfScreen()
|
|
||||||
if (isBsExpanded != expanded) {
|
|
||||||
isBsExpanded = expanded
|
|
||||||
postAdjustState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun suppressLayoutCompat(suppress: Boolean) {
|
|
||||||
if (suppress == isLayoutSuppressedCompat) return
|
|
||||||
isLayoutSuppressedCompat = suppress
|
|
||||||
if (!suppress && isLayoutCalledWhileSuppressed) {
|
|
||||||
requestLayout()
|
|
||||||
}
|
|
||||||
isLayoutCalledWhileSuppressed = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun dispatchInsets(insets: WindowInsetsCompat?) {
|
|
||||||
if (!fitStatusBar) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val isExpanded = binding.dragHandle.isGone
|
|
||||||
val topInset = insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0
|
|
||||||
if (isExpanded) {
|
|
||||||
updatePadding(top = topInset)
|
|
||||||
} else {
|
|
||||||
updatePadding(top = 0)
|
|
||||||
binding.dragHandle.updateLayoutParams {
|
|
||||||
height = topInset.coerceIn(minHandleHeight, maxHandleHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findParentBottomSheetBehavior(): BottomSheetBehavior<*>? {
|
|
||||||
for (p in parents) {
|
|
||||||
val layoutParams = (p as? View)?.layoutParams
|
|
||||||
if (layoutParams is CoordinatorLayout.LayoutParams) {
|
|
||||||
val behavior = layoutParams.behavior
|
|
||||||
if (behavior is BottomSheetBehavior<*>) {
|
|
||||||
return behavior
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isOnTopOfScreen(): Boolean {
|
|
||||||
getLocationInWindow(locationBuffer)
|
|
||||||
val topInset = ViewCompat.getRootWindowInsets(this)
|
|
||||||
?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0
|
|
||||||
val zeroTop = (layoutParams as? MarginLayoutParams)?.topMargin ?: 0
|
|
||||||
return (locationBuffer[1] - topInset) <= zeroTop
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun dismissBottomSheet() {
|
|
||||||
val behavior = bottomSheetBehavior ?: return
|
|
||||||
if (behavior.isHideable) {
|
|
||||||
behavior.state = BottomSheetBehavior.STATE_HIDDEN
|
|
||||||
} else {
|
|
||||||
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun shouldAddView(child: View?): Boolean {
|
|
||||||
if (child == null) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
val viewId = child.id
|
|
||||||
return viewId == R.id.dragHandle || viewId == R.id.toolbar
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun convertLayoutParams(params: ViewGroup.LayoutParams?): Toolbar.LayoutParams? {
|
|
||||||
return when (params) {
|
|
||||||
null -> null
|
|
||||||
is MarginLayoutParams -> {
|
|
||||||
val lp = Toolbar.LayoutParams(params)
|
|
||||||
if (params is LayoutParams) {
|
|
||||||
lp.gravity = params.gravity
|
|
||||||
}
|
|
||||||
lp
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> Toolbar.LayoutParams(params)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun postAdjustState() {
|
|
||||||
removeCallbacks(adjustStateRunnable)
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
if (stateAdjustedAt + THROTTLE_DELAY < now) {
|
|
||||||
adjustState()
|
|
||||||
} else {
|
|
||||||
postDelayed(adjustStateRunnable, THROTTLE_DELAY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun adjustState() {
|
|
||||||
suppressLayoutCompat(true)
|
|
||||||
binding.toolbar.navigationIcon = (if (isBsExpanded) closeDrawable else null)
|
|
||||||
binding.dragHandle.isGone = isBsExpanded
|
|
||||||
expansionListeners.forEach { it.onExpansionStateChanged(this, isBsExpanded) }
|
|
||||||
dispatchInsets(ViewCompat.getRootWindowInsets(this))
|
|
||||||
stateAdjustedAt = System.currentTimeMillis()
|
|
||||||
suppressLayoutCompat(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class Callback : BottomSheetBehavior.BottomSheetCallback(), OnClickListener {
|
|
||||||
|
|
||||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
|
||||||
onBottomSheetStateChanged(newState)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
|
|
||||||
|
|
||||||
override fun onClick(v: View?) {
|
|
||||||
dismissBottomSheet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun interface OnExpansionChangeListener {
|
|
||||||
|
|
||||||
fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.widgets
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import androidx.viewpager.widget.ViewPager
|
||||||
|
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
|
class EnhancedViewPager @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
) : ViewPager(context, attrs) {
|
||||||
|
|
||||||
|
var isUserInputEnabled: Boolean = true
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
if (!value) {
|
||||||
|
cancelPendingInputEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||||
|
return isUserInputEnabled && super.onTouchEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
|
||||||
|
return isUserInputEnabled && super.onInterceptTouchEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,397 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.widgets
|
||||||
|
|
||||||
|
import android.animation.ValueAnimator
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.CornerPathEffect
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Parcelable
|
||||||
|
import android.text.Layout
|
||||||
|
import android.text.StaticLayout
|
||||||
|
import android.text.TextDirectionHeuristic
|
||||||
|
import android.text.TextDirectionHeuristics
|
||||||
|
import android.text.TextPaint
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.draw
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.resolveDp
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.resolveSp
|
||||||
|
|
||||||
|
class PieChart @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyleAttr: Int = 0
|
||||||
|
) : View(context, attrs, defStyleAttr), PieChartInterface {
|
||||||
|
|
||||||
|
private var marginTextFirst: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_1)
|
||||||
|
private var marginTextSecond: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_2)
|
||||||
|
private var marginTextThird: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_3)
|
||||||
|
private var marginSmallCircle: Float = context.resources.resolveDp(DEFAULT_MARGIN_SMALL_CIRCLE)
|
||||||
|
private val marginText: Float = marginTextFirst + marginTextSecond
|
||||||
|
private val circleRect = RectF()
|
||||||
|
private var circleStrokeWidth: Float = context.resources.resolveDp(6f)
|
||||||
|
private var circleRadius: Float = 0f
|
||||||
|
private var circlePadding: Float = context.resources.resolveDp(8f)
|
||||||
|
private var circlePaintRoundSize: Boolean = true
|
||||||
|
private var circleSectionSpace: Float = 3f
|
||||||
|
private var circleCenterX: Float = 0f
|
||||||
|
private var circleCenterY: Float = 0f
|
||||||
|
private var numberTextPaint: TextPaint = TextPaint()
|
||||||
|
private var descriptionTextPain: TextPaint = TextPaint()
|
||||||
|
private var amountTextPaint: TextPaint = TextPaint()
|
||||||
|
private var textStartX: Float = 0f
|
||||||
|
private var textStartY: Float = 0f
|
||||||
|
private var textHeight: Int = 0
|
||||||
|
private var textCircleRadius: Float = context.resources.resolveDp(4f)
|
||||||
|
private var textAmountStr: String = ""
|
||||||
|
private var textAmountY: Float = 0f
|
||||||
|
private var textAmountXNumber: Float = 0f
|
||||||
|
private var textAmountXDescription: Float = 0f
|
||||||
|
private var textAmountYDescription: Float = 0f
|
||||||
|
private var totalAmount: Int = 0
|
||||||
|
private var pieChartColors: List<String> = listOf()
|
||||||
|
private var percentageCircleList: List<PieChartModel> = listOf()
|
||||||
|
private var textRowList: MutableList<StaticLayout> = mutableListOf()
|
||||||
|
private var dataList: List<Pair<Int, String>> = listOf()
|
||||||
|
private var animationSweepAngle: Int = 0
|
||||||
|
|
||||||
|
init {
|
||||||
|
var textAmountSize: Float = context.resources.resolveSp(22f)
|
||||||
|
var textNumberSize: Float = context.resources.resolveSp(20f)
|
||||||
|
var textDescriptionSize: Float = context.resources.resolveSp(14f)
|
||||||
|
var textAmountColor: Int = Color.WHITE
|
||||||
|
var textNumberColor: Int = Color.WHITE
|
||||||
|
var textDescriptionColor: Int = Color.GRAY
|
||||||
|
|
||||||
|
if (attrs != null) {
|
||||||
|
val typeArray = context.obtainStyledAttributes(attrs, R.styleable.PieChart)
|
||||||
|
|
||||||
|
val colorResId = typeArray.getResourceId(R.styleable.PieChart_pieChartColors, 0)
|
||||||
|
pieChartColors = typeArray.resources.getStringArray(colorResId).toList()
|
||||||
|
|
||||||
|
marginTextFirst = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextFirst, marginTextFirst)
|
||||||
|
marginTextSecond = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextSecond, marginTextSecond)
|
||||||
|
marginTextThird = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextThird, marginTextThird)
|
||||||
|
marginSmallCircle =
|
||||||
|
typeArray.getDimension(R.styleable.PieChart_pieChartMarginSmallCircle, marginSmallCircle)
|
||||||
|
|
||||||
|
circleStrokeWidth =
|
||||||
|
typeArray.getDimension(R.styleable.PieChart_pieChartCircleStrokeWidth, circleStrokeWidth)
|
||||||
|
circlePadding = typeArray.getDimension(R.styleable.PieChart_pieChartCirclePadding, circlePadding)
|
||||||
|
circlePaintRoundSize =
|
||||||
|
typeArray.getBoolean(R.styleable.PieChart_pieChartCirclePaintRoundSize, circlePaintRoundSize)
|
||||||
|
circleSectionSpace = typeArray.getFloat(R.styleable.PieChart_pieChartCircleSectionSpace, circleSectionSpace)
|
||||||
|
|
||||||
|
textCircleRadius = typeArray.getDimension(R.styleable.PieChart_pieChartTextCircleRadius, textCircleRadius)
|
||||||
|
textAmountSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextAmountSize, textAmountSize)
|
||||||
|
textNumberSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextNumberSize, textNumberSize)
|
||||||
|
textDescriptionSize =
|
||||||
|
typeArray.getDimension(R.styleable.PieChart_pieChartTextDescriptionSize, textDescriptionSize)
|
||||||
|
textAmountColor = typeArray.getColor(R.styleable.PieChart_pieChartTextAmountColor, textAmountColor)
|
||||||
|
textNumberColor = typeArray.getColor(R.styleable.PieChart_pieChartTextNumberColor, textNumberColor)
|
||||||
|
textDescriptionColor =
|
||||||
|
typeArray.getColor(R.styleable.PieChart_pieChartTextDescriptionColor, textDescriptionColor)
|
||||||
|
textAmountStr = typeArray.getString(R.styleable.PieChart_pieChartTextAmount) ?: ""
|
||||||
|
|
||||||
|
typeArray.recycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
circlePadding += circleStrokeWidth
|
||||||
|
|
||||||
|
// Инициализация кистей View
|
||||||
|
initPaints(amountTextPaint, textAmountSize, textAmountColor)
|
||||||
|
initPaints(numberTextPaint, textNumberSize, textNumberColor)
|
||||||
|
initPaints(descriptionTextPain, textDescriptionSize, textDescriptionColor, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.M)
|
||||||
|
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||||
|
textRowList.clear()
|
||||||
|
|
||||||
|
val initSizeWidth = resolveDefaultSize(widthMeasureSpec, DEFAULT_VIEW_SIZE_WIDTH)
|
||||||
|
|
||||||
|
val textTextWidth = (initSizeWidth * TEXT_WIDTH_PERCENT)
|
||||||
|
val initSizeHeight = calculateViewHeight(heightMeasureSpec, textTextWidth.toInt())
|
||||||
|
|
||||||
|
textStartX = initSizeWidth - textTextWidth.toFloat()
|
||||||
|
textStartY = initSizeHeight.toFloat() / 2 - textHeight / 2
|
||||||
|
|
||||||
|
calculateCircleRadius(initSizeWidth, initSizeHeight)
|
||||||
|
|
||||||
|
setMeasuredDimension(initSizeWidth, initSizeHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
super.onDraw(canvas)
|
||||||
|
|
||||||
|
drawCircle(canvas)
|
||||||
|
drawText(canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRestoreInstanceState(state: Parcelable?) {
|
||||||
|
val pieChartState = state as? PieChartState
|
||||||
|
super.onRestoreInstanceState(pieChartState?.superState ?: state)
|
||||||
|
|
||||||
|
dataList = pieChartState?.dataList ?: listOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(): Parcelable {
|
||||||
|
val superState = super.onSaveInstanceState()
|
||||||
|
return PieChartState(superState, dataList)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setDataChart(list: List<Pair<Int, String>>) {
|
||||||
|
dataList = list
|
||||||
|
calculatePercentageOfData()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun startAnimation() {
|
||||||
|
val animator = ValueAnimator.ofInt(0, 360).apply {
|
||||||
|
duration = context.getAnimationDuration(android.R.integer.config_longAnimTime)
|
||||||
|
interpolator = FastOutSlowInInterpolator()
|
||||||
|
addUpdateListener { valueAnimator ->
|
||||||
|
animationSweepAngle = valueAnimator.animatedValue as Int
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
animator.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawCircle(canvas: Canvas) {
|
||||||
|
for (percent in percentageCircleList) {
|
||||||
|
if (animationSweepAngle > percent.percentToStartAt + percent.percentOfCircle) {
|
||||||
|
canvas.drawArc(circleRect, percent.percentToStartAt, percent.percentOfCircle, false, percent.paint)
|
||||||
|
} else if (animationSweepAngle > percent.percentToStartAt) {
|
||||||
|
canvas.drawArc(
|
||||||
|
circleRect,
|
||||||
|
percent.percentToStartAt,
|
||||||
|
animationSweepAngle - percent.percentToStartAt,
|
||||||
|
false,
|
||||||
|
percent.paint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawText(canvas: Canvas) {
|
||||||
|
var textBuffY = textStartY
|
||||||
|
textRowList.forEachIndexed { index, staticLayout ->
|
||||||
|
if (index % 2 == 0) {
|
||||||
|
staticLayout.draw(canvas, textStartX + marginSmallCircle + textCircleRadius, textBuffY)
|
||||||
|
canvas.drawCircle(
|
||||||
|
textStartX + marginSmallCircle / 2,
|
||||||
|
textBuffY + staticLayout.height / 2 + textCircleRadius / 2,
|
||||||
|
textCircleRadius,
|
||||||
|
Paint().apply { color = Color.parseColor(pieChartColors[(index / 2) % pieChartColors.size]) },
|
||||||
|
)
|
||||||
|
textBuffY += staticLayout.height + marginTextFirst
|
||||||
|
} else {
|
||||||
|
staticLayout.draw(canvas, textStartX, textBuffY)
|
||||||
|
textBuffY += staticLayout.height + marginTextSecond
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.drawText(totalAmount.toString(), textAmountXNumber, textAmountY, amountTextPaint)
|
||||||
|
canvas.drawText(textAmountStr, textAmountXDescription, textAmountYDescription, descriptionTextPain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initPaints(textPaint: TextPaint, textSize: Float, textColor: Int, isDescription: Boolean = false) {
|
||||||
|
textPaint.color = textColor
|
||||||
|
textPaint.textSize = textSize
|
||||||
|
textPaint.isAntiAlias = true
|
||||||
|
|
||||||
|
if (!isDescription) textPaint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveDefaultSize(spec: Int, defValue: Int): Int {
|
||||||
|
return when (MeasureSpec.getMode(spec)) {
|
||||||
|
MeasureSpec.UNSPECIFIED -> resources.resolveDp(defValue)
|
||||||
|
else -> MeasureSpec.getSize(spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.M)
|
||||||
|
private fun calculateViewHeight(heightMeasureSpec: Int, textWidth: Int): Int {
|
||||||
|
val initSizeHeight = resolveDefaultSize(heightMeasureSpec, DEFAULT_VIEW_SIZE_HEIGHT)
|
||||||
|
textHeight = (dataList.size * marginText + getTextViewHeight(textWidth)).toInt()
|
||||||
|
|
||||||
|
val textHeightWithPadding = textHeight + paddingTop + paddingBottom
|
||||||
|
return if (textHeightWithPadding > initSizeHeight) textHeightWithPadding else initSizeHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateCircleRadius(width: Int, height: Int) {
|
||||||
|
val circleViewWidth = (width * CIRCLE_WIDTH_PERCENT)
|
||||||
|
circleRadius = if (circleViewWidth > height) {
|
||||||
|
(height.toFloat() - circlePadding) / 2
|
||||||
|
} else {
|
||||||
|
circleViewWidth.toFloat() / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
with(circleRect) {
|
||||||
|
left = circlePadding
|
||||||
|
top = height / 2 - circleRadius
|
||||||
|
right = circleRadius * 2 + circlePadding
|
||||||
|
bottom = height / 2 + circleRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
circleCenterX = (circleRadius * 2 + circlePadding + circlePadding) / 2
|
||||||
|
circleCenterY = (height / 2 + circleRadius + (height / 2 - circleRadius)) / 2
|
||||||
|
|
||||||
|
textAmountY = circleCenterY
|
||||||
|
|
||||||
|
val sizeTextAmountNumber = getWidthOfAmountText(
|
||||||
|
totalAmount.toString(),
|
||||||
|
amountTextPaint,
|
||||||
|
)
|
||||||
|
|
||||||
|
textAmountXNumber = circleCenterX - sizeTextAmountNumber.width() / 2
|
||||||
|
textAmountXDescription = circleCenterX - getWidthOfAmountText(textAmountStr, descriptionTextPain).width() / 2
|
||||||
|
textAmountYDescription = circleCenterY + sizeTextAmountNumber.height() + marginTextThird
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.M)
|
||||||
|
private fun getTextViewHeight(maxWidth: Int): Int {
|
||||||
|
var textHeight = 0
|
||||||
|
dataList.forEach {
|
||||||
|
val textLayoutNumber = getMultilineText(
|
||||||
|
text = it.first.toString(),
|
||||||
|
textPaint = numberTextPaint,
|
||||||
|
width = maxWidth,
|
||||||
|
)
|
||||||
|
val textLayoutDescription = getMultilineText(
|
||||||
|
text = it.second,
|
||||||
|
textPaint = descriptionTextPain,
|
||||||
|
width = maxWidth,
|
||||||
|
)
|
||||||
|
textRowList.apply {
|
||||||
|
add(textLayoutNumber)
|
||||||
|
add(textLayoutDescription)
|
||||||
|
}
|
||||||
|
textHeight += textLayoutNumber.height + textLayoutDescription.height
|
||||||
|
}
|
||||||
|
|
||||||
|
return textHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculatePercentageOfData() {
|
||||||
|
totalAmount = dataList.fold(0) { res, value -> res + value.first }
|
||||||
|
|
||||||
|
var startAt = circleSectionSpace
|
||||||
|
percentageCircleList = dataList.mapIndexed { index, pair ->
|
||||||
|
var percent = pair.first * 100 / totalAmount.toFloat() - circleSectionSpace
|
||||||
|
percent = if (percent < 0f) 0f else percent
|
||||||
|
|
||||||
|
val resultModel = PieChartModel(
|
||||||
|
percentOfCircle = percent,
|
||||||
|
percentToStartAt = startAt,
|
||||||
|
colorOfLine = Color.parseColor(pieChartColors[index % pieChartColors.size]),
|
||||||
|
stroke = circleStrokeWidth,
|
||||||
|
paintRound = circlePaintRoundSize,
|
||||||
|
)
|
||||||
|
if (percent != 0f) startAt += percent + circleSectionSpace
|
||||||
|
resultModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getWidthOfAmountText(text: String, textPaint: TextPaint): Rect {
|
||||||
|
val bounds = Rect()
|
||||||
|
textPaint.getTextBounds(text, 0, text.length, bounds)
|
||||||
|
return bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.M)
|
||||||
|
private fun getMultilineText(
|
||||||
|
text: CharSequence,
|
||||||
|
textPaint: TextPaint,
|
||||||
|
width: Int,
|
||||||
|
start: Int = 0,
|
||||||
|
end: Int = text.length,
|
||||||
|
alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL,
|
||||||
|
textDir: TextDirectionHeuristic = TextDirectionHeuristics.LTR,
|
||||||
|
spacingMult: Float = 1f,
|
||||||
|
spacingAdd: Float = 0f
|
||||||
|
): StaticLayout {
|
||||||
|
|
||||||
|
return StaticLayout.Builder
|
||||||
|
.obtain(text, start, end, textPaint, width)
|
||||||
|
.setAlignment(alignment)
|
||||||
|
.setTextDirection(textDir)
|
||||||
|
.setLineSpacing(spacingAdd, spacingMult)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DEFAULT_MARGIN_TEXT_1 = 2f
|
||||||
|
private const val DEFAULT_MARGIN_TEXT_2 = 10f
|
||||||
|
private const val DEFAULT_MARGIN_TEXT_3 = 2f
|
||||||
|
private const val DEFAULT_MARGIN_SMALL_CIRCLE = 12f
|
||||||
|
|
||||||
|
private const val TEXT_WIDTH_PERCENT = 0.40
|
||||||
|
private const val CIRCLE_WIDTH_PERCENT = 0.50
|
||||||
|
|
||||||
|
const val DEFAULT_VIEW_SIZE_HEIGHT = 150
|
||||||
|
const val DEFAULT_VIEW_SIZE_WIDTH = 250
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PieChartInterface {
|
||||||
|
|
||||||
|
fun setDataChart(list: List<Pair<Int, String>>)
|
||||||
|
|
||||||
|
fun startAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PieChartModel(
|
||||||
|
var percentOfCircle: Float = 0f,
|
||||||
|
var percentToStartAt: Float = 0f,
|
||||||
|
var colorOfLine: Int = 0,
|
||||||
|
var stroke: Float = 0f,
|
||||||
|
var paint: Paint = Paint(),
|
||||||
|
var paintRound: Boolean = true
|
||||||
|
) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (percentOfCircle < 0 || percentOfCircle > 100) {
|
||||||
|
percentOfCircle = 100f
|
||||||
|
}
|
||||||
|
|
||||||
|
percentOfCircle = 360 * percentOfCircle / 100
|
||||||
|
|
||||||
|
if (percentToStartAt < 0 || percentToStartAt > 100) {
|
||||||
|
percentToStartAt = 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
percentToStartAt = 360 * percentToStartAt / 100
|
||||||
|
|
||||||
|
if (colorOfLine == 0) {
|
||||||
|
colorOfLine = Color.parseColor("#000000")
|
||||||
|
}
|
||||||
|
|
||||||
|
paint = Paint()
|
||||||
|
paint.color = colorOfLine
|
||||||
|
paint.isAntiAlias = true
|
||||||
|
paint.style = Paint.Style.STROKE
|
||||||
|
paint.strokeWidth = stroke
|
||||||
|
paint.isDither = true
|
||||||
|
|
||||||
|
if (paintRound) {
|
||||||
|
paint.strokeJoin = Paint.Join.ROUND
|
||||||
|
paint.strokeCap = Paint.Cap.ROUND
|
||||||
|
paint.pathEffect = CornerPathEffect(8f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PieChartState(
|
||||||
|
superSavedState: Parcelable?,
|
||||||
|
val dataList: List<Pair<Int, String>>
|
||||||
|
) : View.BaseSavedState(superSavedState), Parcelable
|
||||||
@ -0,0 +1,135 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.widgets
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Outline
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewOutlineProvider
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.withStyledAttributes
|
||||||
|
import androidx.core.view.setPadding
|
||||||
|
import com.google.android.material.shape.MaterialShapeDrawable
|
||||||
|
import com.google.android.material.shape.ShapeAppearanceModel
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.drawableStart
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getDrawableCompat
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||||
|
import org.koitharu.kotatsu.databinding.ViewTipBinding
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
class TipView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyleAttr: Int = R.attr.tipViewStyle,
|
||||||
|
) : LinearLayout(context, attrs, defStyleAttr), View.OnClickListener {
|
||||||
|
|
||||||
|
private val binding = ViewTipBinding.inflate(LayoutInflater.from(context), this)
|
||||||
|
|
||||||
|
var title: CharSequence?
|
||||||
|
get() = binding.textViewTitle.text
|
||||||
|
set(value) {
|
||||||
|
binding.textViewTitle.text = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var text: CharSequence?
|
||||||
|
get() = binding.textViewBody.text
|
||||||
|
set(value) {
|
||||||
|
binding.textViewBody.text = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: Drawable?
|
||||||
|
get() = binding.textViewTitle.drawableStart
|
||||||
|
set(value) {
|
||||||
|
binding.textViewTitle.drawableStart = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var primaryButtonText: CharSequence?
|
||||||
|
get() = binding.buttonPrimary.textAndVisible
|
||||||
|
set(value) {
|
||||||
|
binding.buttonPrimary.textAndVisible = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var secondaryButtonText: CharSequence?
|
||||||
|
get() = binding.buttonSecondary.textAndVisible
|
||||||
|
set(value) {
|
||||||
|
binding.buttonSecondary.textAndVisible = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var onButtonClickListener: OnButtonClickListener? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
orientation = VERTICAL
|
||||||
|
setPadding(context.resources.getDimensionPixelOffset(R.dimen.margin_normal))
|
||||||
|
context.withStyledAttributes(attrs, R.styleable.TipView, defStyleAttr) {
|
||||||
|
title = getText(R.styleable.TipView_title)
|
||||||
|
text = getText(R.styleable.TipView_android_text)
|
||||||
|
icon = getDrawableCompat(context, R.styleable.TipView_icon)
|
||||||
|
primaryButtonText = getString(R.styleable.TipView_primaryButtonText)
|
||||||
|
secondaryButtonText = getString(R.styleable.TipView_secondaryButtonText)
|
||||||
|
val shapeAppearanceModel = ShapeAppearanceModel.builder(context, attrs, defStyleAttr, 0).build()
|
||||||
|
background = MaterialShapeDrawable(shapeAppearanceModel).also {
|
||||||
|
it.fillColor = getColorStateList(R.styleable.TipView_cardBackgroundColor)
|
||||||
|
?: context.getThemeColorStateList(materialR.attr.colorBackgroundFloating)
|
||||||
|
it.strokeWidth = getDimension(R.styleable.TipView_strokeWidth, 0f)
|
||||||
|
it.strokeColor = getColorStateList(R.styleable.TipView_strokeColor)
|
||||||
|
it.elevation = getDimension(R.styleable.TipView_elevation, 0f)
|
||||||
|
}
|
||||||
|
outlineProvider = OutlineProvider(shapeAppearanceModel)
|
||||||
|
}
|
||||||
|
binding.buttonPrimary.setOnClickListener(this)
|
||||||
|
binding.buttonSecondary.setOnClickListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(v: View) {
|
||||||
|
when (v.id) {
|
||||||
|
R.id.button_primary -> onButtonClickListener?.onPrimaryButtonClick(this)
|
||||||
|
R.id.button_secondary -> onButtonClickListener?.onSecondaryButtonClick(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTitle(@StringRes resId: Int) {
|
||||||
|
binding.textViewTitle.setText(resId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setText(@StringRes resId: Int) {
|
||||||
|
binding.textViewBody.setText(resId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPrimaryButtonText(@StringRes resId: Int) {
|
||||||
|
binding.buttonPrimary.setTextAndVisible(resId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSecondaryButtonText(@StringRes resId: Int) {
|
||||||
|
binding.buttonSecondary.setTextAndVisible(resId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setIcon(@DrawableRes resId: Int) {
|
||||||
|
icon = ContextCompat.getDrawable(context, resId)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnButtonClickListener {
|
||||||
|
|
||||||
|
fun onPrimaryButtonClick(tipView: TipView)
|
||||||
|
|
||||||
|
fun onSecondaryButtonClick(tipView: TipView)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class OutlineProvider(
|
||||||
|
shapeAppearanceModel: ShapeAppearanceModel,
|
||||||
|
) : ViewOutlineProvider() {
|
||||||
|
|
||||||
|
private val shapeDrawable = MaterialShapeDrawable(shapeAppearanceModel)
|
||||||
|
override fun getOutline(view: View, outline: Outline) {
|
||||||
|
shapeDrawable.setBounds(0, 0, view.width, view.height)
|
||||||
|
shapeDrawable.getOutline(outline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util
|
||||||
|
|
||||||
|
import kotlin.coroutines.Continuation
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
|
class ContinuationResumeRunnable(
|
||||||
|
private val continuation: Continuation<Unit>,
|
||||||
|
) : Runnable {
|
||||||
|
|
||||||
|
override fun run() {
|
||||||
|
continuation.resume(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,71 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import androidx.work.WorkInfo
|
|
||||||
import androidx.work.WorkManager
|
|
||||||
import androidx.work.WorkQuery
|
|
||||||
import androidx.work.WorkRequest
|
|
||||||
import androidx.work.await
|
|
||||||
import androidx.work.impl.WorkManagerImpl
|
|
||||||
import java.util.UUID
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.coroutines.resumeWithException
|
|
||||||
import kotlin.coroutines.suspendCoroutine
|
|
||||||
|
|
||||||
@SuppressLint("RestrictedApi")
|
|
||||||
class WorkManagerHelper(
|
|
||||||
workManager: WorkManager,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val workManagerImpl = workManager as WorkManagerImpl
|
|
||||||
|
|
||||||
suspend fun deleteWork(id: UUID) = suspendCoroutine { cont ->
|
|
||||||
workManagerImpl.workTaskExecutor.executeOnTaskThread {
|
|
||||||
try {
|
|
||||||
workManagerImpl.workDatabase.workSpecDao().delete(id.toString())
|
|
||||||
cont.resume(Unit)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
cont.resumeWithException(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun deleteWorks(ids: Collection<UUID>) = suspendCoroutine { cont ->
|
|
||||||
workManagerImpl.workTaskExecutor.executeOnTaskThread {
|
|
||||||
try {
|
|
||||||
val db = workManagerImpl.workDatabase
|
|
||||||
db.runInTransaction {
|
|
||||||
for (id in ids) {
|
|
||||||
db.workSpecDao().delete(id.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cont.resume(Unit)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
cont.resumeWithException(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getWorkInfosByTag(tag: String): List<WorkInfo> {
|
|
||||||
return workManagerImpl.getWorkInfosByTag(tag).await()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getFinishedWorkInfosByTag(tag: String): List<WorkInfo> {
|
|
||||||
val query = WorkQuery.Builder.fromTags(listOf(tag))
|
|
||||||
.addStates(listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.CANCELLED, WorkInfo.State.FAILED))
|
|
||||||
.build()
|
|
||||||
return workManagerImpl.getWorkInfos(query).await()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getWorkInfoById(id: UUID): WorkInfo? {
|
|
||||||
return workManagerImpl.getWorkInfoById(id).await()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getUniqueWorkInfoByName(name: String): List<WorkInfo> {
|
|
||||||
return workManagerImpl.getWorkInfosForUniqueWork(name).await().orEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateWork(request: WorkRequest): WorkManager.UpdateResult {
|
|
||||||
return workManagerImpl.updateWork(request).await()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.lifecycle.asFlow
|
|
||||||
import androidx.work.WorkInfo
|
|
||||||
import androidx.work.WorkManager
|
|
||||||
import androidx.work.WorkQuery
|
|
||||||
import androidx.work.impl.foreground.SystemForegroundService
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Workaround for issue
|
|
||||||
* https://issuetracker.google.com/issues/270245927
|
|
||||||
* https://issuetracker.google.com/issues/280504155
|
|
||||||
*/
|
|
||||||
class WorkServiceStopHelper(
|
|
||||||
private val context: Context,
|
|
||||||
) {
|
|
||||||
|
|
||||||
fun setup() {
|
|
||||||
processLifecycleScope.launch(Dispatchers.Default) {
|
|
||||||
WorkManager.getInstance(context)
|
|
||||||
.getWorkInfosLiveData(WorkQuery.fromStates(WorkInfo.State.RUNNING))
|
|
||||||
.asFlow()
|
|
||||||
.map { it.isEmpty() }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.collectLatest {
|
|
||||||
if (it) {
|
|
||||||
delay(1_000)
|
|
||||||
stopWorkerService()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("RestrictedApi")
|
|
||||||
private fun stopWorkerService() {
|
|
||||||
SystemForegroundService.getInstance()?.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue