Merge branch 'devel' into feature/page-preload
commit
2b8c713169
@ -1,41 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.base.ui.widgets
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import android.util.AttributeSet
|
|
||||||
import android.view.View
|
|
||||||
import androidx.appcompat.widget.Toolbar
|
|
||||||
import androidx.core.view.isGone
|
|
||||||
import com.google.android.material.R
|
|
||||||
import com.google.android.material.appbar.MaterialToolbar
|
|
||||||
import java.lang.reflect.Field
|
|
||||||
|
|
||||||
class AnimatedToolbar @JvmOverloads constructor(
|
|
||||||
context: Context,
|
|
||||||
attrs: AttributeSet? = null,
|
|
||||||
defStyleAttr: Int = R.attr.toolbarStyle,
|
|
||||||
) : MaterialToolbar(context, attrs, defStyleAttr) {
|
|
||||||
|
|
||||||
private var navButtonView: View? = null
|
|
||||||
get() {
|
|
||||||
if (field == null) {
|
|
||||||
runCatching {
|
|
||||||
field = navButtonViewField?.get(this) as? View
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return field
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setNavigationIcon(icon: Drawable?) {
|
|
||||||
super.setNavigationIcon(icon)
|
|
||||||
navButtonView?.isGone = (icon == null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
|
|
||||||
val navButtonViewField: Field? = runCatching {
|
|
||||||
Toolbar::class.java.getDeclaredField("mNavButtonView")
|
|
||||||
.also { it.isAccessible = true }
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package org.koitharu.kotatsu.browser
|
||||||
|
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import com.google.android.material.progressindicator.BaseProgressIndicator
|
||||||
|
import org.koitharu.kotatsu.utils.ext.setIndeterminateCompat
|
||||||
|
|
||||||
|
private const val PROGRESS_MAX = 100
|
||||||
|
|
||||||
|
class ProgressChromeClient(
|
||||||
|
private val progressIndicator: BaseProgressIndicator<*>,
|
||||||
|
) : WebChromeClient() {
|
||||||
|
|
||||||
|
init {
|
||||||
|
progressIndicator.max = PROGRESS_MAX
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||||
|
super.onProgressChanged(view, newProgress)
|
||||||
|
if (!progressIndicator.isVisible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newProgress in 1 until PROGRESS_MAX) {
|
||||||
|
progressIndicator.setIndeterminateCompat(false)
|
||||||
|
progressIndicator.setProgressCompat(newProgress.coerceAtMost(PROGRESS_MAX), true)
|
||||||
|
} else {
|
||||||
|
progressIndicator.setIndeterminateCompat(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.model
|
|
||||||
|
|
||||||
import android.os.Parcelable
|
|
||||||
import kotlinx.parcelize.Parcelize
|
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data class MangaFilter(
|
|
||||||
val sortOrder: SortOrder?,
|
|
||||||
val tags: Set<MangaTag>,
|
|
||||||
) : Parcelable
|
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
package org.koitharu.kotatsu.list.ui.filter
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.os.bundleOf
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
|
import org.koin.androidx.viewmodel.ViewModelOwner.Companion.from
|
||||||
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.databinding.SheetFilterBinding
|
||||||
|
import org.koitharu.kotatsu.utils.ext.withArgs
|
||||||
|
|
||||||
|
class FilterBottomSheet : BaseBottomSheet<SheetFilterBinding>() {
|
||||||
|
|
||||||
|
private val viewModel by sharedViewModel<FilterViewModel>(
|
||||||
|
owner = { from(requireParentFragment(), requireParentFragment()) }
|
||||||
|
) {
|
||||||
|
parametersOf(
|
||||||
|
requireArguments().getParcelable<MangaSource>(ARG_SOURCE),
|
||||||
|
requireArguments().getParcelable<FilterState>(ARG_STATE),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
|
||||||
|
return SheetFilterBinding.inflate(inflater, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
binding.toolbar.setNavigationOnClickListener { dismiss() }
|
||||||
|
if (!resources.getBoolean(R.bool.is_tablet)) {
|
||||||
|
binding.toolbar.navigationIcon = null
|
||||||
|
}
|
||||||
|
val adapter = FilterAdapter(viewModel)
|
||||||
|
binding.recyclerView.adapter = adapter
|
||||||
|
viewModel.filter.observe(viewLifecycleOwner, adapter::setItems)
|
||||||
|
viewModel.result.observe(viewLifecycleOwner) {
|
||||||
|
parentFragmentManager.setFragmentResult(REQUEST_KEY, bundleOf(ARG_STATE to it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?) = super.onCreateDialog(savedInstanceState).also {
|
||||||
|
val behavior = (it as? BottomSheetDialog)?.behavior ?: return@also
|
||||||
|
behavior.addBottomSheetCallback(
|
||||||
|
object : BottomSheetBehavior.BottomSheetCallback() {
|
||||||
|
|
||||||
|
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
|
||||||
|
|
||||||
|
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||||
|
if (newState == BottomSheetBehavior.STATE_EXPANDED) {
|
||||||
|
binding.toolbar.setNavigationIcon(R.drawable.ic_cross)
|
||||||
|
} else {
|
||||||
|
binding.toolbar.navigationIcon = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val REQUEST_KEY = "filter"
|
||||||
|
|
||||||
|
const val ARG_STATE = "state"
|
||||||
|
private const val TAG = "FilterBottomSheet"
|
||||||
|
private const val ARG_SOURCE = "source"
|
||||||
|
|
||||||
|
fun show(
|
||||||
|
fm: FragmentManager,
|
||||||
|
source: MangaSource,
|
||||||
|
state: FilterState,
|
||||||
|
) = FilterBottomSheet().withArgs(2) {
|
||||||
|
putParcelable(ARG_SOURCE, source)
|
||||||
|
putParcelable(ARG_STATE, state)
|
||||||
|
}.show(fm, TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package org.koitharu.kotatsu.list.ui.filter
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.core.model.SortOrder
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
class FilterState(
|
||||||
|
val sortOrder: SortOrder?,
|
||||||
|
val tags: Set<MangaTag>,
|
||||||
|
) : Parcelable
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
package org.koitharu.kotatsu.list.ui.filter
|
||||||
|
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.base.domain.MangaDataRepository
|
||||||
|
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.core.model.SortOrder
|
||||||
|
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class FilterViewModel(
|
||||||
|
private val repository: RemoteMangaRepository,
|
||||||
|
dataRepository: MangaDataRepository,
|
||||||
|
state: FilterState,
|
||||||
|
) : BaseViewModel(), OnFilterChangedListener {
|
||||||
|
|
||||||
|
val filter = MutableLiveData<List<FilterItem>>()
|
||||||
|
val result = MutableLiveData<FilterState>()
|
||||||
|
private var job: Job? = null
|
||||||
|
private var selectedSortOrder: SortOrder? = state.sortOrder
|
||||||
|
private val selectedTags = HashSet(state.tags)
|
||||||
|
private val localTagsDeferred = viewModelScope.async(Dispatchers.Default) {
|
||||||
|
dataRepository.findTags(repository.source)
|
||||||
|
}
|
||||||
|
private var availableTagsDeferred = loadTagsAsync()
|
||||||
|
|
||||||
|
init {
|
||||||
|
showFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSortItemClick(item: FilterItem.Sort) {
|
||||||
|
selectedSortOrder = item.order
|
||||||
|
updateFilters()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTagItemClick(item: FilterItem.Tag) {
|
||||||
|
val isModified = if (item.isChecked) {
|
||||||
|
selectedTags.remove(item.tag)
|
||||||
|
} else {
|
||||||
|
selectedTags.add(item.tag)
|
||||||
|
}
|
||||||
|
if (isModified) {
|
||||||
|
updateFilters()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateFilters() {
|
||||||
|
val previousJob = job
|
||||||
|
job = launchJob(Dispatchers.Default) {
|
||||||
|
previousJob?.cancelAndJoin()
|
||||||
|
val tags = tryLoadTags()
|
||||||
|
val localTags = localTagsDeferred.await()
|
||||||
|
val sortOrders = repository.sortOrders
|
||||||
|
val list = ArrayList<FilterItem>(sortOrders.size + (tags?.size ?: 1) + 2)
|
||||||
|
list.add(FilterItem.Header(R.string.sort_order))
|
||||||
|
sortOrders.sortedBy { it.ordinal }.mapTo(list) {
|
||||||
|
FilterItem.Sort(it, isSelected = it == selectedSortOrder)
|
||||||
|
}
|
||||||
|
if (tags == null || tags.isNotEmpty() || selectedTags.isNotEmpty()) {
|
||||||
|
list.add(FilterItem.Header(R.string.genres))
|
||||||
|
val mappedTags = TreeSet<FilterItem.Tag>(compareBy({ !it.isChecked }, { it.tag.title }))
|
||||||
|
localTags.mapTo(mappedTags) { FilterItem.Tag(it, isChecked = it in selectedTags) }
|
||||||
|
tags?.mapTo(mappedTags) { FilterItem.Tag(it, isChecked = it in selectedTags) }
|
||||||
|
selectedTags.mapTo(mappedTags) { FilterItem.Tag(it, isChecked = true) }
|
||||||
|
list.addAll(mappedTags)
|
||||||
|
if (tags == null) {
|
||||||
|
list.add(FilterItem.Error(R.string.filter_load_error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ensureActive()
|
||||||
|
filter.postValue(list)
|
||||||
|
}
|
||||||
|
result.value = FilterState(selectedSortOrder, selectedTags)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showFilter() {
|
||||||
|
job = launchJob(Dispatchers.Default) {
|
||||||
|
val sortOrders = repository.sortOrders
|
||||||
|
val list = ArrayList<FilterItem>(sortOrders.size + selectedTags.size + 3)
|
||||||
|
list.add(FilterItem.Header(R.string.sort_order))
|
||||||
|
sortOrders.sortedBy { it.ordinal }.mapTo(list) {
|
||||||
|
FilterItem.Sort(it, isSelected = it == selectedSortOrder)
|
||||||
|
}
|
||||||
|
if (selectedTags.isNotEmpty()) {
|
||||||
|
list.add(FilterItem.Header(R.string.genres))
|
||||||
|
selectedTags.sortedBy { it.title }.mapTo(list) {
|
||||||
|
FilterItem.Tag(it, isChecked = it in selectedTags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list.add(FilterItem.Loading)
|
||||||
|
filter.postValue(list)
|
||||||
|
updateFilters()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun tryLoadTags(): Set<MangaTag>? {
|
||||||
|
val shouldRetryOnError = availableTagsDeferred.isCompleted
|
||||||
|
val result = availableTagsDeferred.await()
|
||||||
|
if (result == null && shouldRetryOnError) {
|
||||||
|
availableTagsDeferred = loadTagsAsync()
|
||||||
|
return availableTagsDeferred.await()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadTagsAsync() = viewModelScope.async(Dispatchers.Default) {
|
||||||
|
kotlin.runCatching {
|
||||||
|
repository.getTags()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,10 @@
|
|||||||
package org.koitharu.kotatsu.list.ui.model
|
package org.koitharu.kotatsu.list.ui.model
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import org.koitharu.kotatsu.core.model.SortOrder
|
||||||
|
|
||||||
data class ListHeader(
|
data class ListHeader(
|
||||||
val text: CharSequence?,
|
val text: CharSequence?,
|
||||||
@StringRes val textRes: Int,
|
@StringRes val textRes: Int,
|
||||||
|
val sortOrder: SortOrder?,
|
||||||
) : ListModel
|
) : ListModel
|
||||||
@ -0,0 +1,125 @@
|
|||||||
|
package org.koitharu.kotatsu.reader.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
|
import com.google.android.material.divider.MaterialDividerItemDecoration
|
||||||
|
import org.koin.android.ext.android.get
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
|
||||||
|
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.databinding.SheetChaptersBinding
|
||||||
|
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
||||||
|
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||||
|
import org.koitharu.kotatsu.details.ui.model.toListItem
|
||||||
|
import org.koitharu.kotatsu.utils.ext.withArgs
|
||||||
|
|
||||||
|
class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemClickListener<ChapterListItem> {
|
||||||
|
|
||||||
|
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetChaptersBinding {
|
||||||
|
return SheetChaptersBinding.inflate(inflater, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
binding.toolbar.setNavigationOnClickListener { dismiss() }
|
||||||
|
if (!resources.getBoolean(R.bool.is_tablet)) {
|
||||||
|
binding.toolbar.navigationIcon = null
|
||||||
|
}
|
||||||
|
binding.recyclerView.addItemDecoration(
|
||||||
|
MaterialDividerItemDecoration(view.context, RecyclerView.VERTICAL)
|
||||||
|
)
|
||||||
|
val chapters = arguments?.getParcelableArrayList<MangaChapter>(ARG_CHAPTERS)
|
||||||
|
if (chapters.isNullOrEmpty()) {
|
||||||
|
dismissAllowingStateLoss()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val currentId = requireArguments().getLong(ARG_CURRENT_ID, 0L)
|
||||||
|
val currentPosition = chapters.indexOfFirst { it.id == currentId }
|
||||||
|
val dateFormat = get<AppSettings>().getDateFormat()
|
||||||
|
val items = chapters.mapIndexed { index, chapter ->
|
||||||
|
chapter.toListItem(
|
||||||
|
isCurrent = index == currentPosition,
|
||||||
|
isUnread = index > currentPosition,
|
||||||
|
isNew = false,
|
||||||
|
isMissing = false,
|
||||||
|
isDownloaded = false,
|
||||||
|
dateFormat = dateFormat,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
binding.recyclerView.adapter = ChaptersAdapter(this).also { adapter ->
|
||||||
|
if (currentPosition >= 0) {
|
||||||
|
val targetPosition = (currentPosition - 1).coerceAtLeast(0)
|
||||||
|
adapter.setItems(items, Scroller(binding.recyclerView, targetPosition))
|
||||||
|
} else {
|
||||||
|
adapter.items = items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?) = super.onCreateDialog(savedInstanceState).also {
|
||||||
|
val behavior = (it as? BottomSheetDialog)?.behavior ?: return@also
|
||||||
|
behavior.addBottomSheetCallback(
|
||||||
|
object : BottomSheetBehavior.BottomSheetCallback() {
|
||||||
|
|
||||||
|
override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
|
||||||
|
|
||||||
|
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||||
|
if (newState == BottomSheetBehavior.STATE_EXPANDED) {
|
||||||
|
binding.toolbar.setNavigationIcon(R.drawable.ic_cross)
|
||||||
|
} else {
|
||||||
|
binding.toolbar.navigationIcon = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onItemClick(item: ChapterListItem, view: View) {
|
||||||
|
((parentFragment as? OnChapterChangeListener) ?: (activity as? OnChapterChangeListener))?.let {
|
||||||
|
dismiss()
|
||||||
|
it.onChapterChanged(item.chapter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interface OnChapterChangeListener {
|
||||||
|
|
||||||
|
fun onChapterChanged(chapter: MangaChapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Scroller(private val recyclerView: RecyclerView, private val position: Int) : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
val offset = recyclerView.resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) / 2
|
||||||
|
(recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(position, offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val ARG_CHAPTERS = "chapters"
|
||||||
|
private const val ARG_CURRENT_ID = "current_id"
|
||||||
|
|
||||||
|
private const val TAG = "ChaptersBottomSheet"
|
||||||
|
|
||||||
|
fun show(
|
||||||
|
fm: FragmentManager,
|
||||||
|
chapters: List<MangaChapter>,
|
||||||
|
currentId: Long,
|
||||||
|
) = ChaptersBottomSheet().withArgs(2) {
|
||||||
|
putParcelableArrayList(ARG_CHAPTERS, chapters.asArrayList())
|
||||||
|
putLong(ARG_CURRENT_ID, currentId)
|
||||||
|
}.show(fm, TAG)
|
||||||
|
|
||||||
|
private fun <T> List<T>.asArrayList(): ArrayList<T> {
|
||||||
|
return this as? ArrayList<T> ?: ArrayList(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,99 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.reader.ui
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.fragment.app.FragmentManager
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import com.google.android.material.divider.MaterialDividerItemDecoration
|
|
||||||
import org.koin.android.ext.android.get
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
|
|
||||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.core.model.MangaChapter
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.databinding.DialogChaptersBinding
|
|
||||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
|
||||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
|
||||||
import org.koitharu.kotatsu.details.ui.model.toListItem
|
|
||||||
import org.koitharu.kotatsu.utils.ext.withArgs
|
|
||||||
|
|
||||||
class ChaptersDialog : AlertDialogFragment<DialogChaptersBinding>(),
|
|
||||||
OnListItemClickListener<ChapterListItem> {
|
|
||||||
|
|
||||||
override fun onInflateView(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
) = DialogChaptersBinding.inflate(inflater, container, false)
|
|
||||||
|
|
||||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) {
|
|
||||||
builder.setTitle(R.string.chapters)
|
|
||||||
.setNegativeButton(R.string.close, null)
|
|
||||||
.setCancelable(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
binding.recyclerViewChapters.addItemDecoration(
|
|
||||||
MaterialDividerItemDecoration(view.context, RecyclerView.VERTICAL)
|
|
||||||
)
|
|
||||||
val chapters = arguments?.getParcelableArrayList<MangaChapter>(ARG_CHAPTERS)
|
|
||||||
if (chapters == null) {
|
|
||||||
dismissAllowingStateLoss()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val currentId = arguments?.getLong(ARG_CURRENT_ID, 0L) ?: 0L
|
|
||||||
val currentPosition = chapters.indexOfFirst { it.id == currentId }
|
|
||||||
val dateFormat = get<AppSettings>().getDateFormat()
|
|
||||||
binding.recyclerViewChapters.adapter = ChaptersAdapter(this).apply {
|
|
||||||
setItems(chapters.mapIndexed { index, chapter ->
|
|
||||||
chapter.toListItem(
|
|
||||||
isCurrent = index == currentPosition,
|
|
||||||
isUnread = index > currentPosition,
|
|
||||||
isNew = false,
|
|
||||||
isMissing = false,
|
|
||||||
isDownloaded = false,
|
|
||||||
dateFormat = dateFormat,
|
|
||||||
)
|
|
||||||
}) {
|
|
||||||
if (currentPosition >= 0) {
|
|
||||||
with(binding.recyclerViewChapters) {
|
|
||||||
(layoutManager as LinearLayoutManager).scrollToPositionWithOffset(
|
|
||||||
currentPosition,
|
|
||||||
height / 3
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onItemClick(item: ChapterListItem, view: View) {
|
|
||||||
((parentFragment as? OnChapterChangeListener)
|
|
||||||
?: (activity as? OnChapterChangeListener))?.let {
|
|
||||||
dismiss()
|
|
||||||
it.onChapterChanged(item.chapter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun interface OnChapterChangeListener {
|
|
||||||
|
|
||||||
fun onChapterChanged(chapter: MangaChapter)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
private const val TAG = "ChaptersDialog"
|
|
||||||
|
|
||||||
private const val ARG_CHAPTERS = "chapters"
|
|
||||||
private const val ARG_CURRENT_ID = "current_id"
|
|
||||||
|
|
||||||
fun show(fm: FragmentManager, chapters: List<MangaChapter>, currentId: Long = 0L) =
|
|
||||||
ChaptersDialog().withArgs(2) {
|
|
||||||
putParcelableArrayList(ARG_CHAPTERS, ArrayList(chapters))
|
|
||||||
putLong(ARG_CURRENT_ID, currentId)
|
|
||||||
}.show(fm, TAG)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package org.koitharu.kotatsu.settings
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.android.ext.android.inject
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
|
||||||
|
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
|
||||||
|
|
||||||
|
class SuggestionsSettingsFragment : BasePreferenceFragment(R.string.suggestions),
|
||||||
|
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
|
|
||||||
|
private val repository by inject<SuggestionRepository>(mode = LazyThreadSafetyMode.NONE)
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
settings.subscribe(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
|
addPreferencesFromResource(R.xml.pref_suggestions)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
settings.unsubscribe(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||||
|
if (key == AppSettings.KEY_SUGGESTIONS && settings.isSuggestionsEnabled) {
|
||||||
|
onSuggestionsEnabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onSuggestionsEnabled() {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
if (repository.isEmpty()) {
|
||||||
|
SuggestionsWorker.startNow(context ?: return@launch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package org.koitharu.kotatsu.suggestions
|
||||||
|
|
||||||
|
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
|
||||||
|
import org.koitharu.kotatsu.suggestions.ui.SuggestionsViewModel
|
||||||
|
|
||||||
|
val suggestionsModule
|
||||||
|
get() = module {
|
||||||
|
|
||||||
|
factory { SuggestionRepository(get()) }
|
||||||
|
|
||||||
|
viewModel { SuggestionsViewModel(get(), get()) }
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package org.koitharu.kotatsu.suggestions.data
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class SuggestionDao {
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM suggestions ORDER BY relevance DESC")
|
||||||
|
abstract fun observeAll(): Flow<List<SuggestionWithManga>>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM suggestions")
|
||||||
|
abstract suspend fun count(): Int
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
abstract suspend fun insert(entity: SuggestionEntity): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
abstract suspend fun update(entity: SuggestionEntity): Int
|
||||||
|
|
||||||
|
@Query("DELETE FROM suggestions")
|
||||||
|
abstract suspend fun deleteAll()
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open suspend fun upsert(entity: SuggestionEntity) {
|
||||||
|
if (update(entity) == 0) {
|
||||||
|
insert(entity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package org.koitharu.kotatsu.suggestions.data
|
||||||
|
|
||||||
|
import androidx.room.Embedded
|
||||||
|
import androidx.room.Junction
|
||||||
|
import androidx.room.Relation
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||||
|
|
||||||
|
data class SuggestionWithManga(
|
||||||
|
@Embedded val suggestion: SuggestionEntity,
|
||||||
|
@Relation(
|
||||||
|
parentColumn = "manga_id",
|
||||||
|
entityColumn = "manga_id"
|
||||||
|
)
|
||||||
|
val manga: MangaEntity,
|
||||||
|
@Relation(
|
||||||
|
parentColumn = "manga_id",
|
||||||
|
entityColumn = "tag_id",
|
||||||
|
associateBy = Junction(MangaTagsEntity::class)
|
||||||
|
)
|
||||||
|
val tags: List<TagEntity>
|
||||||
|
)
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package org.koitharu.kotatsu.suggestions.domain
|
||||||
|
|
||||||
|
import androidx.annotation.FloatRange
|
||||||
|
import org.koitharu.kotatsu.core.model.Manga
|
||||||
|
|
||||||
|
data class MangaSuggestion(
|
||||||
|
val manga: Manga,
|
||||||
|
@FloatRange(from = 0.0, to = 1.0)
|
||||||
|
val relevance: Float,
|
||||||
|
)
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
package org.koitharu.kotatsu.suggestions.domain
|
||||||
|
|
||||||
|
import androidx.room.withTransaction
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||||
|
import org.koitharu.kotatsu.core.model.Manga
|
||||||
|
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
|
||||||
|
import org.koitharu.kotatsu.utils.ext.mapItems
|
||||||
|
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||||
|
|
||||||
|
class SuggestionRepository(
|
||||||
|
private val db: MangaDatabase,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun observeAll(): Flow<List<Manga>> {
|
||||||
|
return db.suggestionDao.observeAll().mapItems {
|
||||||
|
it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clear() {
|
||||||
|
db.suggestionDao.deleteAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun isEmpty(): Boolean {
|
||||||
|
return db.suggestionDao.count() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun replace(suggestions: Iterable<MangaSuggestion>) {
|
||||||
|
db.withTransaction {
|
||||||
|
db.suggestionDao.deleteAll()
|
||||||
|
suggestions.forEach { x ->
|
||||||
|
val tags = x.manga.tags.map(TagEntity.Companion::fromMangaTag)
|
||||||
|
db.tagsDao.upsert(tags)
|
||||||
|
db.mangaDao.upsert(MangaEntity.from(x.manga), tags)
|
||||||
|
db.suggestionDao.upsert(
|
||||||
|
SuggestionEntity(
|
||||||
|
mangaId = x.manga.id,
|
||||||
|
relevance = x.relevance,
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package org.koitharu.kotatsu.suggestions.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
|
import android.view.MenuItem
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||||
|
import org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity
|
||||||
|
|
||||||
|
class SuggestionsFragment : MangaListFragment() {
|
||||||
|
|
||||||
|
override val viewModel by viewModel<SuggestionsViewModel>()
|
||||||
|
override val isSwipeRefreshEnabled = false
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setHasOptionsMenu(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
|
super.onCreateOptionsMenu(menu, inflater)
|
||||||
|
inflater.inflate(R.menu.opt_suggestions, menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
|
return when (item.itemId) {
|
||||||
|
R.id.action_update -> {
|
||||||
|
SuggestionsWorker.startNow(requireContext())
|
||||||
|
Snackbar.make(
|
||||||
|
binding.recyclerView,
|
||||||
|
R.string.feed_will_update_soon,
|
||||||
|
Snackbar.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.action_settings -> {
|
||||||
|
startActivity(SimpleSettingsActivity.newSuggestionsSettingsIntent(requireContext()))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> super.onOptionsItemSelected(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onScrolledToEnd() = Unit
|
||||||
|
|
||||||
|
override fun getTitle(): CharSequence? {
|
||||||
|
return context?.getString(R.string.suggestions)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun newInstance() = SuggestionsFragment()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
package org.koitharu.kotatsu.suggestions.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.*
|
||||||
|
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
|
||||||
|
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||||
|
import org.koitharu.kotatsu.utils.ext.onFirst
|
||||||
|
|
||||||
|
class SuggestionsViewModel(
|
||||||
|
repository: SuggestionRepository,
|
||||||
|
settings: AppSettings,
|
||||||
|
) : MangaListViewModel(settings) {
|
||||||
|
|
||||||
|
private val headerModel = ListHeader(null, R.string.suggestions, null)
|
||||||
|
|
||||||
|
override val content = combine(
|
||||||
|
repository.observeAll(),
|
||||||
|
createListModeFlow()
|
||||||
|
) { list, mode ->
|
||||||
|
when {
|
||||||
|
list.isEmpty() -> listOf(EmptyState(
|
||||||
|
icon = R.drawable.ic_book_cross,
|
||||||
|
textPrimary = R.string.nothing_found,
|
||||||
|
textSecondary = R.string.text_suggestion_holder,
|
||||||
|
))
|
||||||
|
else -> buildList<ListModel>(list.size + 1) {
|
||||||
|
add(headerModel)
|
||||||
|
list.toUi(this, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFirst {
|
||||||
|
isLoading.postValue(false)
|
||||||
|
}.catch {
|
||||||
|
it.toErrorState(canRetry = false)
|
||||||
|
}.asLiveDataDistinct(
|
||||||
|
viewModelScope.coroutineContext + Dispatchers.Default,
|
||||||
|
listOf(LoadingState)
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun onRefresh() = Unit
|
||||||
|
|
||||||
|
override fun onRetry() = Unit
|
||||||
|
}
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
package org.koitharu.kotatsu.suggestions.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.*
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
import org.koitharu.kotatsu.core.model.Manga
|
||||||
|
import org.koitharu.kotatsu.core.model.SortOrder
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.history.domain.HistoryRepository
|
||||||
|
import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion
|
||||||
|
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
|
||||||
|
import org.koitharu.kotatsu.utils.ext.mangaRepositoryOf
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.math.pow
|
||||||
|
|
||||||
|
class SuggestionsWorker(appContext: Context, params: WorkerParameters) :
|
||||||
|
CoroutineWorker(appContext, params), KoinComponent {
|
||||||
|
|
||||||
|
private val suggestionRepository by inject<SuggestionRepository>()
|
||||||
|
private val historyRepository by inject<HistoryRepository>()
|
||||||
|
private val appSettings by inject<AppSettings>()
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result = try {
|
||||||
|
val count = doWorkImpl()
|
||||||
|
Result.success(workDataOf(DATA_COUNT to count))
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Result.failure()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun doWorkImpl(): Int {
|
||||||
|
if (!appSettings.isSuggestionsEnabled) {
|
||||||
|
suggestionRepository.clear()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
val rawResults = ArrayList<Manga>()
|
||||||
|
val allTags = historyRepository.getAllTags()
|
||||||
|
if (allTags.isEmpty()) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
val tagsBySources = allTags.groupBy { x -> x.source }
|
||||||
|
for ((source, tags) in tagsBySources) {
|
||||||
|
val repo = mangaRepositoryOf(source)
|
||||||
|
tags.flatMapTo(rawResults) { tag ->
|
||||||
|
repo.getList2(
|
||||||
|
offset = 0,
|
||||||
|
sortOrder = SortOrder.UPDATED,
|
||||||
|
tags = setOf(tag),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (appSettings.isSuggestionsExcludeNsfw) {
|
||||||
|
rawResults.removeAll { it.isNsfw }
|
||||||
|
}
|
||||||
|
if (rawResults.isEmpty()) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
val suggestions = rawResults.distinctBy { manga ->
|
||||||
|
manga.id
|
||||||
|
}.map { manga ->
|
||||||
|
val jointTags = manga.tags intersect allTags
|
||||||
|
MangaSuggestion(
|
||||||
|
manga = manga,
|
||||||
|
relevance = (jointTags.size / manga.tags.size.toDouble()).pow(2.0).toFloat(),
|
||||||
|
)
|
||||||
|
}.sortedBy { it.relevance }.take(LIMIT)
|
||||||
|
suggestionRepository.replace(suggestions)
|
||||||
|
return suggestions.size
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val TAG = "suggestions"
|
||||||
|
private const val TAG_ONESHOT = "suggestions_oneshot"
|
||||||
|
private const val LIMIT = 140
|
||||||
|
private const val DATA_COUNT = "count"
|
||||||
|
|
||||||
|
fun setup(context: Context) {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||||
|
.setRequiresBatteryNotLow(true)
|
||||||
|
.build()
|
||||||
|
val request = PeriodicWorkRequestBuilder<SuggestionsWorker>(6, TimeUnit.HOURS)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.addTag(TAG)
|
||||||
|
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startNow(context: Context) {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
val request = OneTimeWorkRequestBuilder<SuggestionsWorker>()
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.addTag(TAG_ONESHOT)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
.enqueue(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000"
|
||||||
|
android:pathData="M12,2A7,7 0 0,1 19,9C19,11.38 17.81,13.47 16,14.74V17A1,1 0 0,1 15,18H9A1,1 0 0,1 8,17V14.74C6.19,13.47 5,11.38 5,9A7,7 0 0,1 12,2M9,21V20H15V21A1,1 0 0,1 14,22H10A1,1 0 0,1 9,21M12,4A5,5 0 0,0 7,9C7,11.05 8.23,12.81 10,13.58V16H14V13.58C15.77,12.81 17,11.05 17,9A5,5 0 0,0 12,4Z" />
|
||||||
|
</vector>
|
||||||
@ -1,50 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<LinearLayout
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:animateLayoutChanges="true"
|
|
||||||
android:orientation="horizontal">
|
|
||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
|
||||||
android:id="@+id/swipeRefreshLayout"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:layout_weight="1">
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/recyclerView"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="@dimen/grid_spacing_outer"
|
|
||||||
app:fastScrollEnabled="true"
|
|
||||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
|
||||||
tools:listitem="@layout/item_manga_list" />
|
|
||||||
|
|
||||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
|
||||||
|
|
||||||
<View
|
|
||||||
android:id="@+id/divider_filter"
|
|
||||||
android:layout_width="1dp"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:background="?attr/colorOutline"
|
|
||||||
android:visibility="gone"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/recyclerView_filter"
|
|
||||||
android:layout_width="240dp"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:layout_gravity="end"
|
|
||||||
android:background="?android:windowBackground"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:scrollbars="vertical"
|
|
||||||
android:visibility="gone"
|
|
||||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
|
||||||
tools:listitem="@layout/item_category_checkable"
|
|
||||||
tools:visibility="visible" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
@ -1,39 +1,21 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<androidx.drawerlayout.widget.DrawerLayout
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/swipeRefreshLayout"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
|
||||||
android:id="@+id/swipeRefreshLayout"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent">
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
|
||||||
android:id="@+id/recyclerView"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:clipToPadding="false"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="@dimen/grid_spacing_outer"
|
|
||||||
app:fastScrollEnabled="true"
|
|
||||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
|
||||||
tools:listitem="@layout/item_manga_list" />
|
|
||||||
|
|
||||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/recyclerView_filter"
|
android:id="@+id/recyclerView"
|
||||||
android:layout_width="240dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:layout_gravity="end"
|
|
||||||
android:background="?android:windowBackground"
|
|
||||||
android:clipToPadding="false"
|
android:clipToPadding="false"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:scrollbars="vertical"
|
android:padding="@dimen/grid_spacing_outer"
|
||||||
|
app:fastScrollEnabled="true"
|
||||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||||
tools:listitem="@layout/item_category_checkable" />
|
tools:listitem="@layout/item_manga_list" />
|
||||||
|
|
||||||
</androidx.drawerlayout.widget.DrawerLayout>
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textView_title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:layout_toStartOf="@id/textView_filter"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
|
||||||
|
tools:text="@tools:sample/lorem[21]" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textView_filter"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:background="@drawable/list_selector"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingStart="6dp"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
|
||||||
|
app:drawableEndCompat="@drawable/ic_drop_down"
|
||||||
|
app:drawableTint="?android:attr/textColorSecondary"
|
||||||
|
tools:ignore="RtlSymmetry"
|
||||||
|
tools:text="@string/popular" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.AppBarLayout
|
||||||
|
android:id="@+id/appbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/actionBarSize">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
app:navigationIcon="@drawable/ic_cross"
|
||||||
|
app:title="@string/chapters" />
|
||||||
|
|
||||||
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/recyclerView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:orientation="vertical"
|
||||||
|
app:fastScrollEnabled="true"
|
||||||
|
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||||
|
tools:listitem="@layout/item_chapter" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.AppBarLayout
|
||||||
|
android:id="@+id/appbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/actionBarSize">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
app:navigationIcon="@drawable/ic_cross"
|
||||||
|
app:title="@string/filter" />
|
||||||
|
|
||||||
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/recyclerView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:orientation="vertical"
|
||||||
|
app:fastScrollEnabled="true"
|
||||||
|
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||||
|
tools:listitem="@layout/item_category_checkable" />
|
||||||
|
</LinearLayout>
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<menu
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_update"
|
||||||
|
android:orderInCategory="50"
|
||||||
|
android:title="@string/update"
|
||||||
|
app:showAsAction="never" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_settings"
|
||||||
|
android:orderInCategory="90"
|
||||||
|
android:title="@string/settings"
|
||||||
|
app:showAsAction="never" />
|
||||||
|
|
||||||
|
</menu>
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<PreferenceScreen
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<SwitchPreferenceCompat
|
||||||
|
android:defaultValue="false"
|
||||||
|
android:key="suggestions"
|
||||||
|
android:summary="@string/suggestions_summary"
|
||||||
|
android:title="@string/suggestions_enable"
|
||||||
|
app:iconSpaceReserved="false" />
|
||||||
|
|
||||||
|
<SwitchPreferenceCompat
|
||||||
|
android:dependency="suggestions"
|
||||||
|
android:key="suggestions_exclude_nsfw"
|
||||||
|
android:title="@string/exclude_nsfw_from_suggestions"
|
||||||
|
app:iconSpaceReserved="false" />
|
||||||
|
|
||||||
|
<Preference
|
||||||
|
android:icon="@drawable/ic_info_outline"
|
||||||
|
android:key="track_warning"
|
||||||
|
android:persistent="false"
|
||||||
|
android:selectable="false"
|
||||||
|
android:summary="@string/suggestions_info"
|
||||||
|
app:allowDividerAbove="true" />
|
||||||
|
|
||||||
|
</PreferenceScreen>
|
||||||
Loading…
Reference in New Issue