New global search activity

This commit is contained in:
Koitharu
2022-05-17 11:10:25 +03:00
parent e4b29b3ff9
commit 5d881ca154
22 changed files with 549 additions and 196 deletions

View File

@@ -88,6 +88,9 @@
<activity
android:name="org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity"
android:label="@string/search" />
<activity
android:name="org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity"
android:label="@string/search" />
<activity
android:name="org.koitharu.kotatsu.main.ui.protect.ProtectActivity"
android:noHistory="true"

View File

@@ -44,7 +44,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
@@ -208,7 +208,7 @@ class DetailsActivity :
}
R.id.action_related -> {
viewModel.manga.value?.let {
startActivity(GlobalSearchActivity.newIntent(this, it.title))
startActivity(MultiSearchActivity.newIntent(this, it.title))
}
true
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.list.ui.adapter
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import coil.request.Disposable
@@ -13,6 +14,7 @@ import org.koitharu.kotatsu.databinding.ItemMangaGridBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.search.ui.multi.adapter.ItemSizeResolver
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer
@@ -21,6 +23,7 @@ fun mangaGridItemAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>,
sizeResolver: ItemSizeResolver?,
) = adapterDelegateViewBinding<MangaGridModel, ListModel, ItemMangaGridBinding>(
{ inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) }
) {
@@ -34,6 +37,11 @@ fun mangaGridItemAD(
itemView.setOnLongClickListener {
clickListener.onItemLongClick(item.manga, it)
}
if (sizeResolver != null) {
itemView.updateLayoutParams {
width = sizeResolver.cellWidth
}
}
bind {
binding.textViewTitle.text = item.title

View File

@@ -18,7 +18,7 @@ class MangaListAdapter(
delegatesManager
.addDelegate(ITEM_TYPE_MANGA_LIST, mangaListItemAD(coil, lifecycleOwner, listener))
.addDelegate(ITEM_TYPE_MANGA_LIST_DETAILED, mangaListDetailedItemAD(coil, lifecycleOwner, listener))
.addDelegate(ITEM_TYPE_MANGA_GRID, mangaGridItemAD(coil, lifecycleOwner, listener))
.addDelegate(ITEM_TYPE_MANGA_GRID, mangaGridItemAD(coil, lifecycleOwner, listener, null))
.addDelegate(ITEM_TYPE_LOADING_FOOTER, loadingFooterAD())
.addDelegate(ITEM_TYPE_LOADING_STATE, loadingStateAD())
.addDelegate(ITEM_TYPE_DATE, relatedDateItemAD())

View File

@@ -40,7 +40,7 @@ fun Manga.toGridModel(counter: Int) = MangaGridModel(
suspend fun List<Manga>.toUi(
mode: ListMode,
countersProvider: CountersProvider,
): List<ListModel> = when (mode) {
): List<MangaItemModel> = when (mode) {
ListMode.LIST -> map { it.toListModel(countersProvider.getCounter(it.id)) }
ListMode.DETAILED_LIST -> map { it.toListDetailedModel(countersProvider.getCounter(it.id)) }
ListMode.GRID -> map { it.toGridModel(countersProvider.getCounter(it.id)) }
@@ -58,7 +58,7 @@ suspend fun <C : MutableCollection<ListModel>> List<Manga>.toUi(
fun List<Manga>.toUi(
mode: ListMode,
): List<ListModel> = when (mode) {
): List<MangaItemModel> = when (mode) {
ListMode.LIST -> map { it.toListModel(0) }
ListMode.DETAILED_LIST -> map { it.toListDetailedModel(0) }
ListMode.GRID -> map { it.toGridModel(0) }

View File

@@ -45,7 +45,7 @@ import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
@@ -268,7 +268,7 @@ class MainActivity :
if (source != null) {
startActivity(SearchActivity.newIntent(this, source, query))
} else {
startActivity(GlobalSearchActivity.newIntent(this, query))
startActivity(MultiSearchActivity.newIntent(this, query))
}
searchSuggestionViewModel.saveQuery(query)
}

View File

@@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.search.ui.SearchViewModel
import org.koitharu.kotatsu.search.ui.global.GlobalSearchViewModel
import org.koitharu.kotatsu.search.ui.multi.MultiSearchViewModel
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
val searchModule
@@ -16,11 +16,7 @@ val searchModule
factory { MangaSearchRepository(get(), get(), androidContext(), get()) }
factory { MangaSuggestionsProvider.createSuggestions(androidContext()) }
viewModel { params ->
SearchViewModel(MangaRepository(params[0]), params[1], get())
}
viewModel { query ->
GlobalSearchViewModel(query.get(), get(), get())
}
viewModel { params -> SearchViewModel(MangaRepository(params[0]), params[1], get()) }
viewModel { SearchSuggestionViewModel(get(), get()) }
viewModel { params -> MultiSearchViewModel(params[0], get()) }
}

View File

@@ -14,16 +14,16 @@ import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags
import org.koitharu.kotatsu.databinding.ActivitySearchGlobalBinding
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
class MangaListActivity : BaseActivity<ActivitySearchGlobalBinding>() {
class MangaListActivity : BaseActivity<ActivityContainerBinding>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivitySearchGlobalBinding.inflate(layoutInflater))
setContentView(ActivityContainerBinding.inflate(layoutInflater))
val tags = intent.getParcelableExtra<ParcelableMangaTags>(EXTRA_TAGS)?.tags ?: run {
finishAfterTransition()
return

View File

@@ -1,55 +0,0 @@
package org.koitharu.kotatsu.search.ui.global
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivitySearchGlobalBinding
class GlobalSearchActivity : BaseActivity<ActivitySearchGlobalBinding>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivitySearchGlobalBinding.inflate(layoutInflater))
val query = intent.getStringExtra(EXTRA_QUERY)
if (query == null) {
finishAfterTransition()
return
}
supportActionBar?.setDisplayHomeAsUpEnabled(true)
title = query
supportActionBar?.subtitle = getString(R.string.search_results)
supportFragmentManager
.beginTransaction()
.replace(R.id.container, GlobalSearchFragment.newInstance(query))
.commit()
}
override fun onWindowInsetsChanged(insets: Insets) {
with(binding.toolbar) {
updatePadding(
left = insets.left,
right = insets.right
)
updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
}
companion object {
private const val EXTRA_QUERY = "query"
fun newIntent(context: Context, query: String) =
Intent(context, GlobalSearchActivity::class.java)
.putExtra(EXTRA_QUERY, query)
}
}

View File

@@ -1,35 +0,0 @@
package org.koitharu.kotatsu.search.ui.global
import android.view.Menu
import androidx.appcompat.view.ActionMode
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.utils.ext.stringArgument
import org.koitharu.kotatsu.utils.ext.withArgs
class GlobalSearchFragment : MangaListFragment() {
override val viewModel by viewModel<GlobalSearchViewModel> {
parametersOf(query)
}
private val query by stringArgument(ARG_QUERY)
override fun onScrolledToEnd() = Unit
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_remote, menu)
return super.onCreateActionMode(mode, menu)
}
companion object {
private const val ARG_QUERY = "query"
fun newInstance(query: String) = GlobalSearchFragment().withArgs(1) {
putString(ARG_QUERY, query)
}
}
}

View File

@@ -1,87 +0,0 @@
package org.koitharu.kotatsu.search.ui.global
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.plus
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.parsers.model.Manga
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.onFirst
class GlobalSearchViewModel(
private val query: String,
private val repository: MangaSearchRepository,
settings: AppSettings
) : MangaListViewModel(settings) {
private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val hasNextPage = MutableStateFlow(false)
private val listError = MutableStateFlow<Throwable?>(null)
private var searchJob: Job? = null
override val content = combine(
mangaList,
createListModeFlow(),
listError,
hasNextPage
) { list, mode, error, hasNext ->
when {
list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true))
list == null -> listOf(LoadingState)
list.isEmpty() -> listOf(
EmptyState(
icon = R.drawable.ic_book_search,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0,
)
)
else -> {
val result = ArrayList<ListModel>(list.size + 1)
list.toUi(result, mode)
when {
error != null -> result += error.toErrorFooter()
hasNext -> result += LoadingFooter
}
result
}
}
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
init {
onRefresh()
}
override fun onRetry() {
onRefresh()
}
override fun onRefresh() {
searchJob?.cancel()
searchJob = repository.globalSearch(query)
.catch { e ->
listError.value = e
loadingCounter.reset()
}.onStart {
mangaList.value = null
listError.value = null
loadingCounter.increment()
hasNextPage.value = true
}.onEmpty {
mangaList.value = emptyList()
}.onCompletion {
loadingCounter.reset()
hasNextPage.value = false
}.onFirst {
loadingCounter.reset()
}.onEach {
mangaList.value = mangaList.value?.plus(it) ?: listOf(it)
}.launchIn(viewModelScope + Dispatchers.Default)
}
}

View File

@@ -0,0 +1,183 @@
package org.koitharu.kotatsu.search.ui.multi
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.RecyclerView
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ActivitySearchMultiBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.search.ui.multi.adapter.ItemSizeResolver
import org.koitharu.kotatsu.search.ui.multi.adapter.MultiSearchAdapter
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.findViewsByType
class MultiSearchActivity : BaseActivity<ActivitySearchMultiBinding>(), MangaListListener, ActionMode.Callback {
private val viewModel by viewModel<MultiSearchViewModel> {
parametersOf(intent.getStringExtra(EXTRA_QUERY).orEmpty())
}
private lateinit var adapter: MultiSearchAdapter
private lateinit var selectionDecoration: MangaSelectionDecoration
private var actionMode: ActionMode? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivitySearchMultiBinding.inflate(layoutInflater))
val itemCLickListener = object : OnListItemClickListener<MultiSearchListModel> {
override fun onItemClick(item: MultiSearchListModel, view: View) {
startActivity(SearchActivity.newIntent(view.context, item.source, viewModel.query.value))
}
}
val sizeResolver = ItemSizeResolver(resources, get())
selectionDecoration = MangaSelectionDecoration(this)
adapter = MultiSearchAdapter(
lifecycleOwner = this,
coil = get(),
listener = this,
itemClickListener = itemCLickListener,
sizeResolver = sizeResolver,
selectionDecoration = selectionDecoration,
)
binding.recyclerView.adapter = adapter
binding.recyclerView.setHasFixedSize(true)
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setSubtitle(R.string.search_results)
}
viewModel.query.observe(this) { title = it }
viewModel.list.observe(this) { adapter.items = it }
}
override fun onWindowInsetsChanged(insets: Insets) {
with(binding.toolbar) {
updatePadding(
left = insets.left,
right = insets.right,
)
updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
binding.recyclerView.updatePadding(
bottom = insets.bottom,
left = insets.left,
right = insets.right,
)
}
override fun onItemClick(item: Manga, view: View) {
if (selectionDecoration.checkedItemsCount != 0) {
selectionDecoration.toggleItemChecked(item.id)
if (selectionDecoration.checkedItemsCount == 0) {
actionMode?.finish()
} else {
actionMode?.invalidate()
invalidateItemDecorations()
}
return
}
val intent = DetailsActivity.newIntent(this, item)
startActivity(intent)
}
override fun onItemLongClick(item: Manga, view: View): Boolean {
if (actionMode == null) {
actionMode = startSupportActionMode(this)
}
return actionMode?.also {
selectionDecoration.setItemIsChecked(item.id, true)
invalidateItemDecorations()
it.invalidate()
} != null
}
override fun onRetryClick(error: Throwable) {
viewModel.doSearch(viewModel.query.value.orEmpty())
}
override fun onTagRemoveClick(tag: MangaTag) = Unit
override fun onFilterClick() = Unit
override fun onEmptyActionClick() = Unit
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_remote, menu)
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.title = selectionDecoration.checkedItemsCount.toString()
return true
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_share -> {
ShareHelper(this).shareMangaLinks(collectSelectedItems())
mode.finish()
true
}
R.id.action_favourite -> {
FavouriteCategoriesBottomSheet.show(supportFragmentManager, collectSelectedItems())
mode.finish()
true
}
R.id.action_save -> {
DownloadService.confirmAndStart(this, collectSelectedItems())
mode.finish()
true
}
else -> false
}
}
override fun onDestroyActionMode(mode: ActionMode) {
selectionDecoration.clearSelection()
invalidateItemDecorations()
actionMode = null
}
private fun collectSelectedItems(): Set<Manga> {
return viewModel.getItems(selectionDecoration.checkedItemsIds)
}
private fun invalidateItemDecorations() {
binding.recyclerView.findViewsByType(RecyclerView::class.java).forEach {
it.invalidateItemDecorations()
}
}
companion object {
private const val EXTRA_QUERY = "query"
fun newIntent(context: Context, query: String) =
Intent(context, MultiSearchActivity::class.java)
.putExtra(EXTRA_QUERY, query)
}
}

View File

@@ -0,0 +1,29 @@
package org.koitharu.kotatsu.search.ui.multi
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.parsers.model.MangaSource
class MultiSearchListModel(
val source: MangaSource,
val list: List<MangaItemModel>,
) : ListModel {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MultiSearchListModel
if (source != other.source) return false
if (list != other.list) return false
return true
}
override fun hashCode(): Int {
var result = source.hashCode()
result = 31 * result + list.hashCode()
return result
}
}

View File

@@ -0,0 +1,112 @@
package org.koitharu.kotatsu.search.ui.multi
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
private const val MAX_PARALLELISM = 4
class MultiSearchViewModel(
initialQuery: String,
private val settings: AppSettings,
) : BaseViewModel() {
private var searchJob: Job? = null
private val listData = MutableStateFlow<List<MultiSearchListModel>>(emptyList())
private val loadingData = MutableStateFlow(false)
private var listError = MutableStateFlow<Throwable?>(null)
val query = MutableLiveData(initialQuery)
val list: LiveData<List<ListModel>> = combine(
listData,
loadingData,
listError,
) { list, loading, error ->
when {
list.isEmpty() -> listOf(
when {
loading -> LoadingState
error != null -> error.toErrorState(canRetry = true)
else -> EmptyState(
icon = R.drawable.ic_book_search,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0,
)
}
)
loading -> list + LoadingFooter
else -> list
}
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
init {
doSearch(initialQuery)
}
fun getItems(ids: Set<Long>): Set<Manga> {
val result = HashSet<Manga>(ids.size)
listData.value.forEach { x ->
for (item in x.list) {
if (item.id in ids) {
result.add(item.manga)
}
}
}
return result
}
fun doSearch(q: String) {
val prevJob = searchJob
searchJob = launchJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
try {
listError.value = null
listData.value = emptyList()
loadingData.value = true
query.postValue(q)
val errors = searchImpl(q)
listError.value = errors.firstOrNull()
} catch (e: Throwable) {
listError.value = e
} finally {
loadingData.value = false
}
}
}
private suspend fun searchImpl(q: String): List<Throwable> {
val sources = settings.getMangaSources(includeHidden = false)
val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM)
return coroutineScope {
sources.map { source ->
async(dispatcher) {
runCatching {
val list = MangaRepository(source).getList(offset = 0, query = q)
// .sortedBy { x -> x.title.levenshteinDistance(q) }
.toUi(ListMode.GRID)
if (list.isNotEmpty()) {
val item = MultiSearchListModel(source, list)
listData.update { x -> x + item }
}
}.onFailure {
it.printStackTraceDebug()
}.exceptionOrNull()
}
}
}.awaitAll().filterNotNull()
}
}

View File

@@ -0,0 +1,15 @@
package org.koitharu.kotatsu.search.ui.multi.adapter
import android.content.res.Resources
import kotlin.math.roundToInt
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
class ItemSizeResolver(resources: Resources, settings: AppSettings) {
private val scaleFactor = settings.gridSize / 100f
private val gridWidth = resources.getDimension(R.dimen.preferred_grid_width)
val cellWidth: Int
get() = (gridWidth * scaleFactor).roundToInt()
}

View File

@@ -0,0 +1,59 @@
package org.koitharu.kotatsu.search.ui.multi.adapter
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView.RecycledViewPool
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.list.ui.adapter.*
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.search.ui.multi.MultiSearchListModel
import kotlin.jvm.internal.Intrinsics
class MultiSearchAdapter(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
listener: MangaListListener,
itemClickListener: OnListItemClickListener<MultiSearchListModel>,
sizeResolver: ItemSizeResolver,
selectionDecoration: MangaSelectionDecoration,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init {
val pool = RecycledViewPool()
delegatesManager
.addDelegate(
searchResultsAD(
sharedPool = pool,
lifecycleOwner = lifecycleOwner,
coil = coil,
sizeResolver = sizeResolver,
selectionDecoration = selectionDecoration,
listener = listener,
itemClickListener = itemClickListener,
)
)
.addDelegate(loadingStateAD())
.addDelegate(loadingFooterAD())
.addDelegate(emptyStateListAD(listener))
.addDelegate(errorStateListAD(listener))
}
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return when {
oldItem is MultiSearchListModel && newItem is MultiSearchListModel -> {
oldItem.source == newItem.source
}
else -> oldItem.javaClass == newItem.javaClass
}
}
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return Intrinsics.areEqual(oldItem, newItem)
}
}
}

View File

@@ -0,0 +1,47 @@
package org.koitharu.kotatsu.search.ui.multi.adapter
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView.RecycledViewPool
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.databinding.ItemListGroupBinding
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.search.ui.multi.MultiSearchListModel
fun searchResultsAD(
sharedPool: RecycledViewPool,
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
sizeResolver: ItemSizeResolver,
selectionDecoration: MangaSelectionDecoration,
listener: OnListItemClickListener<Manga>,
itemClickListener: OnListItemClickListener<MultiSearchListModel>,
) = adapterDelegateViewBinding<MultiSearchListModel, ListModel, ItemListGroupBinding>(
{ layoutInflater, parent -> ItemListGroupBinding.inflate(layoutInflater, parent, false) }
) {
binding.recyclerView.setRecycledViewPool(sharedPool)
val adapter = ListDelegationAdapter(
mangaGridItemAD(coil, lifecycleOwner, listener, sizeResolver)
)
binding.recyclerView.addItemDecoration(selectionDecoration)
binding.recyclerView.adapter = adapter
val spacing = context.resources.getDimensionPixelOffset(R.dimen.grid_spacing)
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
val eventListener = AdapterDelegateClickListenerAdapter(this, itemClickListener)
itemView.setOnClickListener(eventListener)
bind {
binding.textViewTitle.text = item.source.title
adapter.items = item.list
adapter.notifyDataSetChanged()
}
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.utils.ext
import android.app.Activity
import android.graphics.Rect
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import androidx.core.view.children
import androidx.recyclerview.widget.LinearLayoutManager
@@ -138,4 +139,19 @@ val RecyclerView.isScrolledToTop: Boolean
}
val holder = findViewHolderForAdapterPosition(0)
return holder != null && holder.itemView.top >= 0
}
}
fun <T : View> ViewGroup.findViewsByType(clazz: Class<T>): Sequence<T> {
if (childCount == 0) {
return emptySequence()
}
return sequence {
for (view in children) {
if (clazz.isInstance(view)) {
yield(clazz.cast(view)!!)
} else if (view is ViewGroup && view.childCount != 0) {
yieldAll(view.findViewsByType(clazz))
}
}
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_scrollFlags="scroll|enterAlways" />
</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:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,32 @@
<?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:background="?selectableItemBackground"
android:orientation="vertical"
android:paddingVertical="@dimen/grid_spacing_outer">
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/grid_spacing"
android:gravity="center_vertical|start"
android:padding="@dimen/grid_spacing"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
tools:text="@tools:sample/lorem[2]" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/grid_spacing"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</LinearLayout>

View File

@@ -27,8 +27,9 @@
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elegantTextHeight="false"
android:ellipsize="end"
android:maxLines="2"
android:lines="2"
android:padding="8dp"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?android:attr/textColorPrimary"