diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 13f5cecbf..9cb967580 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -88,6 +88,9 @@ + { viewModel.manga.value?.let { - startActivity(GlobalSearchActivity.newIntent(this, it.title)) + startActivity(MultiSearchActivity.newIntent(this, it.title)) } true } diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt index f1d6d3af4..e4ad38d3e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt @@ -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, + sizeResolver: ItemSizeResolver?, ) = adapterDelegateViewBinding( { 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 diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt index 2b359a8a9..93f271c0c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt @@ -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()) diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt index 0d9de41f3..cda127e73 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt @@ -40,7 +40,7 @@ fun Manga.toGridModel(counter: Int) = MangaGridModel( suspend fun List.toUi( mode: ListMode, countersProvider: CountersProvider, -): List = when (mode) { +): List = 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 > List.toUi( fun List.toUi( mode: ListMode, -): List = when (mode) { +): List = when (mode) { ListMode.LIST -> map { it.toListModel(0) } ListMode.DETAILED_LIST -> map { it.toListDetailedModel(0) } ListMode.GRID -> map { it.toGridModel(0) } diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt index b57158069..e11527071 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -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) } diff --git a/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt b/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt index 1d1fb43fc..b06e06bfd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt @@ -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()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaListActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaListActivity.kt index 151bb2b33..4904ab34a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaListActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaListActivity.kt @@ -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() { +class MangaListActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(ActivitySearchGlobalBinding.inflate(layoutInflater)) + setContentView(ActivityContainerBinding.inflate(layoutInflater)) val tags = intent.getParcelableExtra(EXTRA_TAGS)?.tags ?: run { finishAfterTransition() return diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchActivity.kt deleted file mode 100644 index ad23f0b98..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchActivity.kt +++ /dev/null @@ -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() { - - 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 { - 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) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt deleted file mode 100644 index 185de3d25..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt +++ /dev/null @@ -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 { - 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) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchViewModel.kt deleted file mode 100644 index 3511f3b3d..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchViewModel.kt +++ /dev/null @@ -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?>(null) - private val hasNextPage = MutableStateFlow(false) - private val listError = MutableStateFlow(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(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) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt new file mode 100644 index 000000000..5bd119a83 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchActivity.kt @@ -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(), MangaListListener, ActionMode.Callback { + + private val viewModel by viewModel { + 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 { + 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 { + 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 { + 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchListModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchListModel.kt new file mode 100644 index 000000000..eb8d71a7e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchListModel.kt @@ -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, +) : 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt new file mode 100644 index 000000000..40b6a8619 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt @@ -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>(emptyList()) + private val loadingData = MutableStateFlow(false) + private var listError = MutableStateFlow(null) + + val query = MutableLiveData(initialQuery) + val list: LiveData> = 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): Set { + val result = HashSet(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 { + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/ItemSizeResolver.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/ItemSizeResolver.kt new file mode 100644 index 000000000..a5f5d3f72 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/ItemSizeResolver.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt new file mode 100644 index 000000000..35afb49d4 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/MultiSearchAdapter.kt @@ -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, + sizeResolver: ItemSizeResolver, + selectionDecoration: MangaSelectionDecoration, +) : AsyncListDifferDelegationAdapter(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() { + + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt new file mode 100644 index 000000000..ee58933e8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt @@ -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, + itemClickListener: OnListItemClickListener, +) = adapterDelegateViewBinding( + { 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt index 021c77859..586e40eef 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt @@ -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 - } \ No newline at end of file + } + +fun ViewGroup.findViewsByType(clazz: Class): Sequence { + 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)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_search_global.xml b/app/src/main/res/layout/activity_container.xml similarity index 100% rename from app/src/main/res/layout/activity_search_global.xml rename to app/src/main/res/layout/activity_container.xml diff --git a/app/src/main/res/layout/activity_search_multi.xml b/app/src/main/res/layout/activity_search_multi.xml new file mode 100644 index 000000000..23b613556 --- /dev/null +++ b/app/src/main/res/layout/activity_search_multi.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_list_group.xml b/app/src/main/res/layout/item_list_group.xml new file mode 100644 index 000000000..3296973f5 --- /dev/null +++ b/app/src/main/res/layout/item_list_group.xml @@ -0,0 +1,32 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_manga_grid.xml b/app/src/main/res/layout/item_manga_grid.xml index c0cbb05b9..5174b647a 100644 --- a/app/src/main/res/layout/item_manga_grid.xml +++ b/app/src/main/res/layout/item_manga_grid.xml @@ -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"