Advanced global search
This commit is contained in:
@@ -10,25 +10,22 @@ import kotlinx.coroutines.sync.withPermit
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.almostEquals
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||
import org.koitharu.kotatsu.search.domain.SearchV2Helper
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val MAX_PARALLELISM = 4
|
||||
private const val MATCH_THRESHOLD_DEFAULT = 0.2f
|
||||
|
||||
class AlternativesUseCase @Inject constructor(
|
||||
private val sourcesRepository: MangaSourcesRepository,
|
||||
private val searchHelperFactory: SearchV2Helper.Factory,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(manga: Manga): Flow<Manga> = invoke(manga, MATCH_THRESHOLD_DEFAULT)
|
||||
|
||||
suspend operator fun invoke(manga: Manga, matchThreshold: Float): Flow<Manga> {
|
||||
suspend operator fun invoke(manga: Manga): Flow<Manga> {
|
||||
val sources = getSources(manga.source)
|
||||
if (sources.isEmpty()) {
|
||||
return emptyFlow()
|
||||
@@ -36,21 +33,14 @@ class AlternativesUseCase @Inject constructor(
|
||||
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||
return channelFlow {
|
||||
for (source in sources) {
|
||||
val repository = mangaRepositoryFactory.create(source)
|
||||
if (!repository.filterCapabilities.isSearchSupported) {
|
||||
continue
|
||||
}
|
||||
launch {
|
||||
val searchHelper = searchHelperFactory.create(source)
|
||||
val list = runCatchingCancellable {
|
||||
semaphore.withPermit {
|
||||
repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
|
||||
searchHelper(manga.title, SearchKind.TITLE)?.manga
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
for (item in list) {
|
||||
if (item.matches(manga, matchThreshold)) {
|
||||
send(item)
|
||||
}
|
||||
}
|
||||
}.getOrNull()
|
||||
list?.forEach { send(it) }
|
||||
}
|
||||
}
|
||||
}.map {
|
||||
@@ -68,18 +58,6 @@ class AlternativesUseCase @Inject constructor(
|
||||
return result
|
||||
}
|
||||
|
||||
private fun Manga.matches(ref: Manga, threshold: Float): Boolean {
|
||||
return matchesTitles(title, ref.title, threshold) ||
|
||||
matchesTitles(title, ref.altTitle, threshold) ||
|
||||
matchesTitles(altTitle, ref.title, threshold) ||
|
||||
matchesTitles(altTitle, ref.altTitle, threshold)
|
||||
|
||||
}
|
||||
|
||||
private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean {
|
||||
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold)
|
||||
}
|
||||
|
||||
private fun MangaSource.priority(ref: MangaSource): Int {
|
||||
var res = 0
|
||||
if (this is MangaParserSource && ref is MangaParserSource) {
|
||||
|
||||
@@ -35,7 +35,7 @@ class AutoFixUseCase @Inject constructor(
|
||||
if (seed.isHealthy()) {
|
||||
return seed to null // no fix required
|
||||
}
|
||||
val replacement = alternativesUseCase(seed, matchThreshold = 0.02f)
|
||||
val replacement = alternativesUseCase(seed)
|
||||
.filter { it.isHealthy() }
|
||||
.runningFold<Manga, Manga?>(null) { best, candidate ->
|
||||
if (best == null || best < candidate) {
|
||||
|
||||
@@ -4,12 +4,14 @@ import android.accounts.Account
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.View
|
||||
import androidx.annotation.CheckResult
|
||||
import androidx.annotation.UiContext
|
||||
import androidx.core.net.toUri
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
@@ -17,7 +19,9 @@ import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.findFragment
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
||||
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
@@ -25,13 +29,19 @@ import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.model.MangaSourceInfo
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaListFilter
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPage
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.core.prefs.TriStateOption
|
||||
import org.koitharu.kotatsu.core.ui.dialog.BigButtonsAlertDialog
|
||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
@@ -61,6 +71,7 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.isNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.mapToArray
|
||||
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity
|
||||
@@ -68,6 +79,7 @@ import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
|
||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
import org.koitharu.kotatsu.search.ui.multi.SearchActivity
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
@@ -94,22 +106,27 @@ class AppRouter private constructor(
|
||||
|
||||
constructor(fragment: Fragment) : this(null, fragment)
|
||||
|
||||
/** Activities **/
|
||||
|
||||
fun openList(source: MangaSource, filter: MangaListFilter?) {
|
||||
startActivity(listIntent(contextOrNull() ?: return, source, filter))
|
||||
private val settings: AppSettings by lazy {
|
||||
EntryPointAccessors.fromApplication<AppRouterEntryPoint>(checkNotNull(contextOrNull())).settings
|
||||
}
|
||||
|
||||
fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag)))
|
||||
/** Activities **/
|
||||
|
||||
fun openSearch(query: String) {
|
||||
fun openList(source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?) {
|
||||
startActivity(listIntent(contextOrNull() ?: return, source, filter, sortOrder))
|
||||
}
|
||||
|
||||
fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag)), null)
|
||||
|
||||
fun openSearch(query: String, kind: SearchKind = SearchKind.SIMPLE) {
|
||||
startActivity(
|
||||
Intent(contextOrNull() ?: return, SearchActivity::class.java)
|
||||
.putExtra(KEY_QUERY, query),
|
||||
.putExtra(KEY_QUERY, query)
|
||||
.putExtra(KEY_KIND, kind),
|
||||
)
|
||||
}
|
||||
|
||||
fun openSearch(source: MangaSource, query: String) = openList(source, MangaListFilter(query = query))
|
||||
fun openSearch(source: MangaSource, query: String) = openList(source, MangaListFilter(query = query), null)
|
||||
|
||||
fun openDetails(manga: Manga) {
|
||||
startActivity(detailsIntent(contextOrNull() ?: return, manga))
|
||||
@@ -119,6 +136,13 @@ class AppRouter private constructor(
|
||||
startActivity(detailsIntent(contextOrNull() ?: return, mangaId))
|
||||
}
|
||||
|
||||
fun openDetails(link: Uri) {
|
||||
startActivity(
|
||||
Intent(contextOrNull() ?: return, DetailsActivity::class.java)
|
||||
.setData(link),
|
||||
)
|
||||
}
|
||||
|
||||
fun openReader(manga: Manga, anchor: View? = null) {
|
||||
openReader(
|
||||
ReaderIntent.Builder(contextOrNull() ?: return)
|
||||
@@ -327,6 +351,25 @@ class AppRouter private constructor(
|
||||
}.showDistinct()
|
||||
}
|
||||
|
||||
fun showTagDialog(tag: MangaTag) {
|
||||
buildAlertDialog(contextOrNull() ?: return) {
|
||||
setTitle(tag.title)
|
||||
setItems(
|
||||
arrayOf(
|
||||
context.getString(R.string.search_on_s, tag.source.getTitle(context)),
|
||||
context.getString(R.string.search_everywhere),
|
||||
),
|
||||
) { _, which ->
|
||||
when (which) {
|
||||
0 -> openList(tag)
|
||||
1 -> openSearch(tag.title, SearchKind.TAG)
|
||||
}
|
||||
}
|
||||
setNegativeButton(R.string.close, null)
|
||||
setCancelable(true)
|
||||
}.show()
|
||||
}
|
||||
|
||||
fun showErrorDialog(error: Throwable, url: String? = null) {
|
||||
ErrorDetailsDialog().withArgs(2) {
|
||||
putSerializable(KEY_ERROR, error)
|
||||
@@ -414,6 +457,45 @@ class AppRouter private constructor(
|
||||
TrackerCategoriesConfigSheet().showDistinct()
|
||||
}
|
||||
|
||||
fun askForDownloadOverMeteredNetwork(onConfirmed: (allow: Boolean) -> Unit) {
|
||||
val context = contextOrNull() ?: return
|
||||
when (settings.allowDownloadOnMeteredNetwork) {
|
||||
TriStateOption.ENABLED -> onConfirmed(true)
|
||||
TriStateOption.DISABLED -> onConfirmed(false)
|
||||
TriStateOption.ASK -> {
|
||||
if (!context.connectivityManager.isActiveNetworkMetered) {
|
||||
onConfirmed(true)
|
||||
return
|
||||
}
|
||||
val listener = DialogInterface.OnClickListener { _, which ->
|
||||
when (which) {
|
||||
DialogInterface.BUTTON_POSITIVE -> {
|
||||
settings.allowDownloadOnMeteredNetwork = TriStateOption.ENABLED
|
||||
onConfirmed(true)
|
||||
}
|
||||
|
||||
DialogInterface.BUTTON_NEUTRAL -> {
|
||||
onConfirmed(true)
|
||||
}
|
||||
|
||||
DialogInterface.BUTTON_NEGATIVE -> {
|
||||
settings.allowDownloadOnMeteredNetwork = TriStateOption.DISABLED
|
||||
onConfirmed(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
BigButtonsAlertDialog.Builder(context)
|
||||
.setIcon(R.drawable.ic_network_cellular)
|
||||
.setTitle(R.string.download_cellular_confirm)
|
||||
.setPositiveButton(R.string.allow_always, listener)
|
||||
.setNeutralButton(R.string.allow_once, listener)
|
||||
.setNegativeButton(R.string.dont_allow, listener)
|
||||
.create()
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Public utils **/
|
||||
|
||||
fun isFilterSupported(): Boolean = when {
|
||||
@@ -462,6 +544,7 @@ class AppRouter private constructor(
|
||||
return fragment?.childFragmentManager ?: activity?.supportFragmentManager
|
||||
}
|
||||
|
||||
@UiContext
|
||||
private fun contextOrNull(): Context? = activity ?: fragment?.context
|
||||
|
||||
private fun getLifecycleOwner(): LifecycleOwner? = activity ?: fragment?.viewLifecycleOwner
|
||||
@@ -510,7 +593,7 @@ class AppRouter private constructor(
|
||||
fun detailsIntent(context: Context, mangaId: Long) = Intent(context, DetailsActivity::class.java)
|
||||
.putExtra(KEY_ID, mangaId)
|
||||
|
||||
fun listIntent(context: Context, source: MangaSource, filter: MangaListFilter?): Intent =
|
||||
fun listIntent(context: Context, source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?): Intent =
|
||||
Intent(context, MangaListActivity::class.java)
|
||||
.setAction(ACTION_MANGA_EXPLORE)
|
||||
.putExtra(KEY_SOURCE, source.name)
|
||||
@@ -518,6 +601,9 @@ class AppRouter private constructor(
|
||||
if (!filter.isNullOrEmpty()) {
|
||||
putExtra(KEY_FILTER, ParcelableMangaListFilter(filter))
|
||||
}
|
||||
if (sortOrder != null) {
|
||||
putExtra(KEY_SORT_ORDER, sortOrder)
|
||||
}
|
||||
}
|
||||
|
||||
fun cloudFlareResolveIntent(context: Context, exception: CloudFlareProtectedException): Intent =
|
||||
@@ -590,12 +676,14 @@ class AppRouter private constructor(
|
||||
const val KEY_FILTER = "filter"
|
||||
const val KEY_ID = "id"
|
||||
const val KEY_INDEX = "index"
|
||||
const val KEY_KIND = "kind"
|
||||
const val KEY_LIST_SECTION = "list_section"
|
||||
const val KEY_MANGA = "manga"
|
||||
const val KEY_MANGA_LIST = "manga_list"
|
||||
const val KEY_PAGES = "pages"
|
||||
const val KEY_QUERY = "query"
|
||||
const val KEY_READER_MODE = "reader_mode"
|
||||
const val KEY_SORT_ORDER = "sort_order"
|
||||
const val KEY_SOURCE = "source"
|
||||
const val KEY_TAB = "tab"
|
||||
const val KEY_TITLE = "title"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.koitharu.kotatsu.core.nav
|
||||
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface AppRouterEntryPoint {
|
||||
|
||||
val settings: AppSettings
|
||||
}
|
||||
@@ -182,7 +182,7 @@ class AppShortcutManager @Inject constructor(
|
||||
.setLongLabel(title)
|
||||
.setIcon(icon)
|
||||
.setLongLived(true)
|
||||
.setIntent(AppRouter.listIntent(context, source, null))
|
||||
.setIntent(AppRouter.listIntent(context, source, null, null))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import dagger.Reusable
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
@@ -109,4 +110,11 @@ class MangaLinkResolver @Inject constructor(
|
||||
chapters = null,
|
||||
source = source,
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
fun isValidLink(str: String): Boolean {
|
||||
return str.isHttpUrl() || str.startsWith("kotatsu://", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.CompoundButton.OnCheckedChangeListener
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.UiContext
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
@@ -17,7 +18,7 @@ import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
inline fun buildAlertDialog(
|
||||
context: Context,
|
||||
@UiContext context: Context,
|
||||
isCentered: Boolean = false,
|
||||
block: MaterialAlertDialogBuilder.() -> Unit,
|
||||
): AlertDialog = MaterialAlertDialogBuilder(
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.ui.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import androidx.annotation.UiContext
|
||||
import androidx.core.net.ConnectivityManagerCompat
|
||||
import dagger.Lazy
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.TriStateOption
|
||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||
import javax.inject.Inject
|
||||
|
||||
class CommonAlertDialogs @Inject constructor(
|
||||
private val settings: Lazy<AppSettings>,
|
||||
) {
|
||||
|
||||
fun askForDownloadOverMeteredNetwork(
|
||||
@UiContext context: Context,
|
||||
onConfirmed: (allow: Boolean) -> Unit
|
||||
) {
|
||||
when (settings.get().allowDownloadOnMeteredNetwork) {
|
||||
TriStateOption.ENABLED -> onConfirmed(true)
|
||||
TriStateOption.DISABLED -> onConfirmed(false)
|
||||
TriStateOption.ASK -> {
|
||||
if (!ConnectivityManagerCompat.isActiveNetworkMetered(context.connectivityManager)) {
|
||||
onConfirmed(true)
|
||||
return
|
||||
}
|
||||
val listener = DialogInterface.OnClickListener { _, which ->
|
||||
when (which) {
|
||||
DialogInterface.BUTTON_POSITIVE -> {
|
||||
settings.get().allowDownloadOnMeteredNetwork = TriStateOption.ENABLED
|
||||
onConfirmed(true)
|
||||
}
|
||||
|
||||
DialogInterface.BUTTON_NEUTRAL -> {
|
||||
onConfirmed(true)
|
||||
}
|
||||
|
||||
DialogInterface.BUTTON_NEGATIVE -> {
|
||||
settings.get().allowDownloadOnMeteredNetwork = TriStateOption.DISABLED
|
||||
onConfirmed(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
BigButtonsAlertDialog.Builder(context)
|
||||
.setIcon(R.drawable.ic_network_cellular)
|
||||
.setTitle(R.string.download_cellular_confirm)
|
||||
.setPositiveButton(R.string.allow_always, listener)
|
||||
.setNeutralButton(R.string.allow_once, listener)
|
||||
.setNegativeButton(R.string.dont_allow, listener)
|
||||
.create()
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.content.Context
|
||||
import android.database.DatabaseUtils
|
||||
import androidx.collection.arraySetOf
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.util.ellipsize
|
||||
@@ -70,11 +69,4 @@ fun <T> Collection<T>.joinToStringWithLimit(context: Context, limit: Int, transf
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
"",
|
||||
ReplaceWith(
|
||||
"sqlEscapeString(this)",
|
||||
"android.database.DatabaseUtils.sqlEscapeString",
|
||||
),
|
||||
)
|
||||
fun String.sqlEscape(): String = DatabaseUtils.sqlEscapeString(this)
|
||||
fun String.isHttpUrl() = startsWith("https://", ignoreCase = true) || startsWith("http://", ignoreCase = true)
|
||||
|
||||
@@ -22,12 +22,12 @@ fun Uri.isNetworkUri() = scheme.let {
|
||||
it == URI_SCHEME_HTTP || it == URI_SCHEME_HTTPS
|
||||
}
|
||||
|
||||
fun File.toZipUri(entryPath: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryPath")
|
||||
fun File.toZipUri(entryPath: String): Uri = "$URI_SCHEME_ZIP://$absolutePath#$entryPath".toUri()
|
||||
|
||||
fun File.toZipUri(entryPath: Path?): Uri =
|
||||
toZipUri(entryPath?.toString()?.removePrefix(Path.DIRECTORY_SEPARATOR).orEmpty())
|
||||
|
||||
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
|
||||
fun String.toUriOrNull() = if (isEmpty()) null else this.toUri()
|
||||
|
||||
fun File.toUri(fragment: String?): Uri = toUri().run {
|
||||
if (fragment != null) {
|
||||
|
||||
@@ -209,7 +209,7 @@ class DetailsActivity :
|
||||
|
||||
R.id.textView_source -> {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
router.openList(manga.source, null)
|
||||
router.openList(manga.source, null, null)
|
||||
}
|
||||
|
||||
R.id.textView_local -> {
|
||||
@@ -255,8 +255,7 @@ class DetailsActivity :
|
||||
|
||||
override fun onChipClick(chip: Chip, data: Any?) {
|
||||
val tag = data as? MangaTag ?: return
|
||||
// TODO dialog
|
||||
router.openList(tag)
|
||||
router.showTagDialog(tag)
|
||||
}
|
||||
|
||||
override fun onContextClick(v: View): Boolean = onLongClick(v)
|
||||
|
||||
@@ -19,7 +19,6 @@ import org.koitharu.kotatsu.core.nav.ReaderIntent
|
||||
import org.koitharu.kotatsu.core.nav.dismissParentDialog
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs
|
||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
|
||||
@@ -39,7 +38,6 @@ import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -49,9 +47,6 @@ class ChaptersFragment :
|
||||
|
||||
private val viewModel by ChaptersPagesViewModel.ActivityVMLazy(this)
|
||||
|
||||
@Inject
|
||||
lateinit var commonAlertDialogs: CommonAlertDialogs
|
||||
|
||||
private var chaptersAdapter: ChaptersAdapter? = null
|
||||
private var selectionController: ListSelectionController? = null
|
||||
|
||||
@@ -67,7 +62,7 @@ class ChaptersFragment :
|
||||
appCompatDelegate = checkNotNull(findAppCompatDelegate()),
|
||||
decoration = ChaptersSelectionDecoration(binding.root.context),
|
||||
registryOwner = this,
|
||||
callback = ChaptersSelectionCallback(viewModel, commonAlertDialogs, binding.recyclerViewChapters),
|
||||
callback = ChaptersSelectionCallback(viewModel, router, binding.recyclerViewChapters),
|
||||
)
|
||||
viewModel.isChaptersInGridView.observe(viewLifecycleOwner) { chaptersInGridView ->
|
||||
binding.recyclerViewChapters.layoutManager = if (chaptersInGridView) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.ui.list.BaseListSelectionCallback
|
||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.util.ext.toCollection
|
||||
@@ -18,7 +18,7 @@ import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
|
||||
|
||||
class ChaptersSelectionCallback(
|
||||
private val viewModel: ChaptersPagesViewModel,
|
||||
private val commonAlertDialogs: CommonAlertDialogs,
|
||||
private val router: AppRouter,
|
||||
recyclerView: RecyclerView,
|
||||
) : BaseListSelectionCallback(recyclerView) {
|
||||
|
||||
@@ -63,10 +63,9 @@ class ChaptersSelectionCallback(
|
||||
val snapshot = controller.snapshot()
|
||||
mode?.finish()
|
||||
if (snapshot.isNotEmpty()) {
|
||||
commonAlertDialogs.askForDownloadOverMeteredNetwork(
|
||||
context = recyclerView.context,
|
||||
onConfirmed = { viewModel.download(snapshot, it) },
|
||||
)
|
||||
router.askForDownloadOverMeteredNetwork {
|
||||
viewModel.download(snapshot, it)
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.prefs.DownloadFormat
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs
|
||||
import org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView
|
||||
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
@@ -32,9 +32,7 @@ import org.koitharu.kotatsu.core.util.ext.showOrHide
|
||||
import org.koitharu.kotatsu.databinding.DialogDownloadBinding
|
||||
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
||||
import org.koitharu.kotatsu.parsers.util.format
|
||||
import org.koitharu.kotatsu.parsers.util.mapToArray
|
||||
import org.koitharu.kotatsu.settings.storage.DirectoryModel
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DownloadDialogFragment : AlertDialogFragment<DialogDownloadBinding>(), View.OnClickListener {
|
||||
@@ -42,9 +40,6 @@ class DownloadDialogFragment : AlertDialogFragment<DialogDownloadBinding>(), Vie
|
||||
private val viewModel by viewModels<DownloadDialogViewModel>()
|
||||
private var optionViews: Array<out TwoLinesItemView>? = null
|
||||
|
||||
@Inject
|
||||
lateinit var commonAlertDialogs: CommonAlertDialogs
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?) =
|
||||
DialogDownloadBinding.inflate(inflater, container, false)
|
||||
|
||||
@@ -104,10 +99,7 @@ class DownloadDialogFragment : AlertDialogFragment<DialogDownloadBinding>(), Vie
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_cancel -> dialog?.cancel()
|
||||
R.id.button_confirm -> commonAlertDialogs.askForDownloadOverMeteredNetwork(
|
||||
context = context ?: return,
|
||||
onConfirmed = ::schedule,
|
||||
)
|
||||
R.id.button_confirm -> router.askForDownloadOverMeteredNetwork(::schedule)
|
||||
|
||||
R.id.textView_more -> {
|
||||
val binding = viewBinding ?: return
|
||||
|
||||
@@ -268,7 +268,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
||||
if (manga != null) {
|
||||
AppRouter.detailsIntent(context, manga)
|
||||
} else {
|
||||
AppRouter.listIntent(context, LocalMangaSource, null)
|
||||
AppRouter.listIntent(context, LocalMangaSource, null, null)
|
||||
},
|
||||
PendingIntent.FLAG_CANCEL_CURRENT,
|
||||
false,
|
||||
|
||||
@@ -121,7 +121,7 @@ class ExploreFragment :
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_local -> router.openList(LocalMangaSource, null)
|
||||
R.id.button_local -> router.openList(LocalMangaSource, null, null)
|
||||
R.id.button_bookmarks -> router.openBookmarks()
|
||||
R.id.button_more -> router.openSuggestions()
|
||||
R.id.button_downloads -> router.openDownloads()
|
||||
@@ -133,7 +133,7 @@ class ExploreFragment :
|
||||
if (sourceSelectionController?.onItemClick(item.id) == true) {
|
||||
return
|
||||
}
|
||||
router.openList(item.source, null)
|
||||
router.openList(item.source, null, null)
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: MangaSourceItem, view: View): Boolean {
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.SoftwareKeyboardControllerCompat
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat
|
||||
@@ -41,6 +42,7 @@ import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.parser.MangaLinkResolver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.NavItem
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
@@ -61,6 +63,7 @@ import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
|
||||
@@ -246,11 +249,17 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
||||
router.openDetails(manga)
|
||||
}
|
||||
|
||||
override fun onQueryClick(query: String, submit: Boolean) {
|
||||
override fun onQueryClick(query: String, kind: SearchKind, submit: Boolean) {
|
||||
viewBinding.searchView.query = query
|
||||
if (submit && query.isNotEmpty()) {
|
||||
router.openSearch(query)
|
||||
searchSuggestionViewModel.saveQuery(query)
|
||||
if (kind == SearchKind.SIMPLE && MangaLinkResolver.isValidLink(query)) {
|
||||
router.openDetails(query.toUri())
|
||||
} else {
|
||||
router.openSearch(query, kind)
|
||||
if (kind != SearchKind.TAG) {
|
||||
searchSuggestionViewModel.saveQuery(query)
|
||||
}
|
||||
}
|
||||
viewBinding.searchView.post {
|
||||
closeSearchCallback.handleOnBackPressed()
|
||||
}
|
||||
@@ -258,7 +267,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
||||
}
|
||||
|
||||
override fun onTagClick(tag: MangaTag) {
|
||||
router.openList(tag)
|
||||
router.openSearch(tag.title, SearchKind.TAG)
|
||||
}
|
||||
|
||||
override fun onQueryChanged(query: String) {
|
||||
@@ -270,7 +279,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
|
||||
}
|
||||
|
||||
override fun onSourceClick(source: MangaSource) {
|
||||
router.openList(source, null)
|
||||
router.openList(source, null, null)
|
||||
}
|
||||
|
||||
override fun onSupportActionModeStarted(mode: ActionMode) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.koitharu.kotatsu.search.domain
|
||||
|
||||
enum class SearchKind {
|
||||
|
||||
SIMPLE, TITLE, AUTHOR, TAG
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.search.domain
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
|
||||
data class SearchResults(
|
||||
val listFilter: MangaListFilter,
|
||||
val sortOrder: SortOrder,
|
||||
val manga: List<Manga>,
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
package org.koitharu.kotatsu.search.domain
|
||||
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.almostEquals
|
||||
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
|
||||
private const val MATCH_THRESHOLD_DEFAULT = 0.2f
|
||||
|
||||
class SearchV2Helper @AssistedInject constructor(
|
||||
@Assisted private val source: MangaSource,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val dataRepository: MangaDataRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(query: String, kind: SearchKind): SearchResults? {
|
||||
val repository = mangaRepositoryFactory.create(source)
|
||||
val listFilter = repository.getFilter(query, kind) ?: return null
|
||||
val sortOrder = repository.getSortOrder(kind)
|
||||
val list = repository.getList(0, sortOrder, listFilter)
|
||||
if (list.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
val result = list.toMutableList()
|
||||
result.postFilter(query, kind)
|
||||
result.sortByRelevance(query, kind)
|
||||
return SearchResults(listFilter = listFilter, sortOrder = sortOrder, manga = result)
|
||||
}
|
||||
|
||||
private suspend fun MangaRepository.getFilter(query: String, kind: SearchKind): MangaListFilter? = when (kind) {
|
||||
SearchKind.SIMPLE,
|
||||
SearchKind.TITLE,
|
||||
SearchKind.AUTHOR -> if (filterCapabilities.isSearchSupported) { // TODO author support
|
||||
MangaListFilter(query = query)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
SearchKind.TAG -> {
|
||||
val tags = this@SearchV2Helper.dataRepository.findTags(this.source) + runCatchingCancellable {
|
||||
this@getFilter.getFilterOptions().availableTags
|
||||
}.onFailure { e ->
|
||||
e.printStackTraceDebug()
|
||||
}.getOrDefault(emptySet())
|
||||
val tag = tags.find { x -> x.title.equals(query, ignoreCase = true) }
|
||||
if (tag != null) {
|
||||
MangaListFilter(tags = setOf(tag))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<Manga>.postFilter(query: String, kind: SearchKind) {
|
||||
when (kind) {
|
||||
SearchKind.TITLE -> retainAll { m ->
|
||||
m.matches(query, MATCH_THRESHOLD_DEFAULT)
|
||||
}
|
||||
|
||||
SearchKind.AUTHOR -> retainAll { m ->
|
||||
m.author.isNullOrEmpty() || m.author.equals(query, ignoreCase = true)
|
||||
}
|
||||
|
||||
SearchKind.SIMPLE, // no filtering expected
|
||||
SearchKind.TAG -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<Manga>.sortByRelevance(query: String, kind: SearchKind) {
|
||||
when (kind) {
|
||||
SearchKind.SIMPLE,
|
||||
SearchKind.TITLE -> sortBy { m ->
|
||||
minOf(m.title.levenshteinDistance(query), m.altTitle?.levenshteinDistance(query) ?: Int.MAX_VALUE)
|
||||
}
|
||||
|
||||
SearchKind.AUTHOR -> sortByDescending { m ->
|
||||
m.author?.equals(query, ignoreCase = true) == true
|
||||
}
|
||||
|
||||
SearchKind.TAG -> sortByDescending { m ->
|
||||
m.tags.any { tag -> tag.title.equals(query, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MangaRepository.getSortOrder(kind: SearchKind): SortOrder {
|
||||
val preferred: SortOrder = when (kind) {
|
||||
SearchKind.SIMPLE,
|
||||
SearchKind.TITLE,
|
||||
SearchKind.AUTHOR -> SortOrder.RELEVANCE
|
||||
|
||||
SearchKind.TAG -> SortOrder.POPULARITY
|
||||
}
|
||||
return if (preferred in sortOrders) {
|
||||
preferred
|
||||
} else {
|
||||
defaultSortOrder
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun Manga.matches(query: String, threshold: Float): Boolean {
|
||||
return matchesTitles(title, query, threshold) || matchesTitles(altTitle, query, threshold)
|
||||
}
|
||||
|
||||
private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean {
|
||||
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
||||
fun create(source: MangaSource): SearchV2Helper
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.model.titleRes
|
||||
import org.koitharu.kotatsu.core.util.ViewBadge
|
||||
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
|
||||
@@ -44,6 +45,7 @@ import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
||||
import kotlin.math.absoluteValue
|
||||
import com.google.android.material.R as materialR
|
||||
@@ -67,6 +69,7 @@ class MangaListActivity :
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityMangaListBinding.inflate(layoutInflater))
|
||||
val filter = intent.getParcelableExtraCompat<ParcelableMangaListFilter>(AppRouter.KEY_FILTER)?.filter
|
||||
val sortOrder = intent.getSerializableExtraCompat<SortOrder>(AppRouter.KEY_SORT_ORDER)
|
||||
source = MangaSource(intent.getStringExtra(AppRouter.KEY_SOURCE))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
if (viewBinding.containerFilterHeader != null) {
|
||||
@@ -74,7 +77,7 @@ class MangaListActivity :
|
||||
}
|
||||
viewBinding.buttonOrder?.setOnClickListener(this)
|
||||
title = source.getTitle(this)
|
||||
initList(source, filter)
|
||||
initList(source, filter, sortOrder)
|
||||
}
|
||||
|
||||
override fun isNsfwContent(): Flow<Boolean> = flowOf(source.isNsfw())
|
||||
@@ -112,7 +115,7 @@ class MangaListActivity :
|
||||
|
||||
fun hidePreview() = setSideFragment(FilterSheetFragment::class.java, null)
|
||||
|
||||
private fun initList(source: MangaSource, filter: MangaListFilter?) {
|
||||
private fun initList(source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?) {
|
||||
val fm = supportFragmentManager
|
||||
val existingFragment = fm.findFragmentById(R.id.container)
|
||||
if (existingFragment is FilterCoordinator.Owner) {
|
||||
@@ -127,8 +130,8 @@ class MangaListActivity :
|
||||
}
|
||||
replace(R.id.container, fragment)
|
||||
runOnCommit { initFilter(fragment) }
|
||||
if (filter != null) {
|
||||
runOnCommit(ApplyFilterRunnable(fragment, filter))
|
||||
if (filter != null || sortOrder != null) {
|
||||
runOnCommit(ApplyFilterRunnable(fragment, filter, sortOrder))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,11 +185,17 @@ class MangaListActivity :
|
||||
|
||||
private class ApplyFilterRunnable(
|
||||
private val filterOwner: FilterCoordinator.Owner,
|
||||
private val filter: MangaListFilter,
|
||||
private val filter: MangaListFilter?,
|
||||
private val sortOrder: SortOrder?,
|
||||
) : Runnable {
|
||||
|
||||
override fun run() {
|
||||
filterOwner.filterCoordinator.set(filter)
|
||||
if (sortOrder != null) {
|
||||
filterOwner.filterCoordinator.setSortOrder(sortOrder)
|
||||
}
|
||||
if (filter != null) {
|
||||
filterOwner.filterCoordinator.set(filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||
import org.koitharu.kotatsu.search.ui.multi.adapter.SearchAdapter
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -53,10 +54,25 @@ class SearchActivity :
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivitySearchBinding.inflate(layoutInflater))
|
||||
title = viewModel.query
|
||||
title = when (viewModel.kind) {
|
||||
SearchKind.SIMPLE,
|
||||
SearchKind.TITLE -> viewModel.query
|
||||
|
||||
SearchKind.AUTHOR -> getString(
|
||||
R.string.inline_preference_pattern,
|
||||
getString(R.string.author),
|
||||
viewModel.query,
|
||||
)
|
||||
|
||||
SearchKind.TAG -> getString(R.string.inline_preference_pattern, getString(R.string.genre), viewModel.query)
|
||||
}
|
||||
|
||||
val itemClickListener = OnListItemClickListener<SearchResultsListModel> { item, view ->
|
||||
router.openSearch(item.source, viewModel.query)
|
||||
if (item.listFilter == null) {
|
||||
router.openSearch(item.source, viewModel.query)
|
||||
} else {
|
||||
router.openList(item.source, item.listFilter, item.sortOrder)
|
||||
}
|
||||
}
|
||||
val sizeResolver = DynamicItemSizeResolver(resources, settings, adjustWidth = true)
|
||||
val selectionDecoration = MangaSelectionDecoration(this)
|
||||
|
||||
@@ -6,11 +6,15 @@ import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaListModel
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
|
||||
data class SearchResultsListModel(
|
||||
@StringRes val titleResId: Int,
|
||||
val source: MangaSource,
|
||||
val listFilter: MangaListFilter?,
|
||||
val sortOrder: SortOrder?,
|
||||
val hasMore: Boolean,
|
||||
val list: List<MangaListModel>,
|
||||
val error: Throwable?,
|
||||
|
||||
@@ -26,7 +26,6 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
@@ -43,6 +42,8 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||
import org.koitharu.kotatsu.search.domain.SearchV2Helper
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val MAX_PARALLELISM = 4
|
||||
@@ -52,7 +53,7 @@ private const val MIN_HAS_MORE_ITEMS = 8
|
||||
class SearchViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val mangaListMapper: MangaListMapper,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val searchHelperFactory: SearchV2Helper.Factory,
|
||||
private val sourcesRepository: MangaSourcesRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
@@ -60,6 +61,7 @@ class SearchViewModel @Inject constructor(
|
||||
) : BaseViewModel() {
|
||||
|
||||
val query = savedStateHandle.get<String>(AppRouter.KEY_QUERY).orEmpty()
|
||||
val kind = savedStateHandle.get<SearchKind>(AppRouter.KEY_KIND) ?: SearchKind.SIMPLE
|
||||
|
||||
private val retryCounter = MutableStateFlow(0)
|
||||
private val listData = retryCounter.flatMapLatest {
|
||||
@@ -115,35 +117,40 @@ class SearchViewModel @Inject constructor(
|
||||
return@channelFlow
|
||||
}
|
||||
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||
sources.mapNotNull { source ->
|
||||
val repository = mangaRepositoryFactory.create(source)
|
||||
if (!repository.filterCapabilities.isSearchSupported) {
|
||||
null
|
||||
} else {
|
||||
launch {
|
||||
val item = runCatchingCancellable {
|
||||
semaphore.withPermit {
|
||||
mangaListMapper.toListModelList(
|
||||
manga = repository.getList(offset = 0, null, MangaListFilter(query = q)),
|
||||
sources.map { source ->
|
||||
launch {
|
||||
val item = runCatchingCancellable {
|
||||
semaphore.withPermit {
|
||||
val searchHelper = searchHelperFactory.create(source)
|
||||
searchHelper(query, kind)
|
||||
}
|
||||
}.fold(
|
||||
onSuccess = { result ->
|
||||
if (result == null || result.manga.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
val list = mangaListMapper.toListModelList(
|
||||
manga = result.manga,
|
||||
mode = ListMode.GRID,
|
||||
)
|
||||
SearchResultsListModel(
|
||||
titleResId = 0,
|
||||
source = source,
|
||||
hasMore = list.size > MIN_HAS_MORE_ITEMS,
|
||||
list = list,
|
||||
error = null,
|
||||
listFilter = result.listFilter,
|
||||
sortOrder = result.sortOrder,
|
||||
)
|
||||
}
|
||||
}.fold(
|
||||
onSuccess = { list ->
|
||||
if (list.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
SearchResultsListModel(0, source, list.size > MIN_HAS_MORE_ITEMS, list, null)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
error.printStackTraceDebug()
|
||||
SearchResultsListModel(0, source, true, emptyList(), error)
|
||||
},
|
||||
)
|
||||
if (item != null) {
|
||||
send(item)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
error.printStackTraceDebug()
|
||||
SearchResultsListModel(0, source, null, null, true, emptyList(), error)
|
||||
},
|
||||
)
|
||||
if (item != null) {
|
||||
send(item)
|
||||
}
|
||||
}
|
||||
}.joinAll()
|
||||
@@ -163,6 +170,8 @@ class SearchViewModel @Inject constructor(
|
||||
hasMore = false,
|
||||
list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID),
|
||||
error = null,
|
||||
listFilter = null,
|
||||
sortOrder = null,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -175,6 +184,8 @@ class SearchViewModel @Inject constructor(
|
||||
hasMore = false,
|
||||
list = emptyList(),
|
||||
error = error,
|
||||
listFilter = null,
|
||||
sortOrder = null,
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -192,6 +203,8 @@ class SearchViewModel @Inject constructor(
|
||||
hasMore = false,
|
||||
list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID),
|
||||
error = null,
|
||||
listFilter = null,
|
||||
sortOrder = null,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -204,6 +217,8 @@ class SearchViewModel @Inject constructor(
|
||||
hasMore = false,
|
||||
list = emptyList(),
|
||||
error = error,
|
||||
listFilter = null,
|
||||
sortOrder = null,
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -221,6 +236,8 @@ class SearchViewModel @Inject constructor(
|
||||
hasMore = result.size > MIN_HAS_MORE_ITEMS,
|
||||
list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID),
|
||||
error = null,
|
||||
listFilter = null,
|
||||
sortOrder = null,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -233,6 +250,8 @@ class SearchViewModel @Inject constructor(
|
||||
hasMore = true,
|
||||
list = emptyList(),
|
||||
error = error,
|
||||
listFilter = null,
|
||||
sortOrder = null,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -3,12 +3,13 @@ package org.koitharu.kotatsu.search.ui.suggestion
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||
|
||||
interface SearchSuggestionListener {
|
||||
|
||||
fun onMangaClick(manga: Manga)
|
||||
|
||||
fun onQueryClick(query: String, submit: Boolean)
|
||||
fun onQueryClick(query: String, kind: SearchKind, submit: Boolean)
|
||||
|
||||
fun onQueryChanged(query: String)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.view.View
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ItemSearchSuggestionQueryHintBinding
|
||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
|
||||
|
||||
@@ -14,7 +15,7 @@ fun searchSuggestionAuthorAD(
|
||||
) {
|
||||
|
||||
val viewClickListener = View.OnClickListener { _ ->
|
||||
listener.onQueryClick(item.name, true)
|
||||
listener.onQueryClick(item.name, SearchKind.AUTHOR, true)
|
||||
}
|
||||
|
||||
binding.root.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_user, 0, 0, 0)
|
||||
|
||||
@@ -4,23 +4,25 @@ import android.view.View
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ItemSearchSuggestionQueryBinding
|
||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
|
||||
|
||||
fun searchSuggestionQueryAD(
|
||||
listener: SearchSuggestionListener,
|
||||
) = adapterDelegateViewBinding<SearchSuggestionItem.RecentQuery, SearchSuggestionItem, ItemSearchSuggestionQueryBinding>(
|
||||
{ inflater, parent -> ItemSearchSuggestionQueryBinding.inflate(inflater, parent, false) }
|
||||
) {
|
||||
) =
|
||||
adapterDelegateViewBinding<SearchSuggestionItem.RecentQuery, SearchSuggestionItem, ItemSearchSuggestionQueryBinding>(
|
||||
{ inflater, parent -> ItemSearchSuggestionQueryBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
|
||||
val viewClickListener = View.OnClickListener { v ->
|
||||
listener.onQueryClick(item.query, v.id != R.id.button_complete)
|
||||
val viewClickListener = View.OnClickListener { v ->
|
||||
listener.onQueryClick(item.query, SearchKind.SIMPLE, v.id != R.id.button_complete)
|
||||
}
|
||||
|
||||
binding.root.setOnClickListener(viewClickListener)
|
||||
binding.buttonComplete.setOnClickListener(viewClickListener)
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.query
|
||||
}
|
||||
}
|
||||
|
||||
binding.root.setOnClickListener(viewClickListener)
|
||||
binding.buttonComplete.setOnClickListener(viewClickListener)
|
||||
|
||||
bind {
|
||||
binding.textViewTitle.text = item.query
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.search.ui.suggestion.adapter
|
||||
import android.view.View
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.databinding.ItemSearchSuggestionQueryHintBinding
|
||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
|
||||
|
||||
@@ -13,7 +14,7 @@ fun searchSuggestionQueryHintAD(
|
||||
) {
|
||||
|
||||
val viewClickListener = View.OnClickListener { _ ->
|
||||
listener.onQueryClick(item.query, true)
|
||||
listener.onQueryClick(item.query, SearchKind.SIMPLE, true)
|
||||
}
|
||||
|
||||
binding.root.setOnClickListener(viewClickListener)
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.core.content.ContextCompat
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.drawableEnd
|
||||
import org.koitharu.kotatsu.core.util.ext.drawableStart
|
||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -66,7 +67,7 @@ class SearchEditText @JvmOverloads constructor(
|
||||
&& query.isNotEmpty()
|
||||
) {
|
||||
cancelLongPress()
|
||||
searchSuggestionListener?.onQueryClick(query, submit = true)
|
||||
searchSuggestionListener?.onQueryClick(query, SearchKind.SIMPLE, submit = true)
|
||||
clearFocus()
|
||||
return true
|
||||
}
|
||||
@@ -76,7 +77,7 @@ class SearchEditText @JvmOverloads constructor(
|
||||
override fun onEditorAction(actionCode: Int) {
|
||||
super.onEditorAction(actionCode)
|
||||
if (actionCode == EditorInfo.IME_ACTION_SEARCH) {
|
||||
searchSuggestionListener?.onQueryClick(query, submit = true)
|
||||
searchSuggestionListener?.onQueryClick(query, SearchKind.SIMPLE, submit = true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
|
||||
}
|
||||
|
||||
override fun onItemClick(item: SourceCatalogItem.Source, view: View) {
|
||||
router.openList(item.source, null)
|
||||
router.openList(item.source, null, null)
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: SourceCatalogItem.Source, view: View): Boolean {
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -39,10 +40,10 @@ class SyncSettings(
|
||||
|
||||
companion object {
|
||||
|
||||
private fun String.withHttpSchema(): String = if (!startsWith("http://") && !startsWith("https://")) {
|
||||
"http://$this"
|
||||
} else {
|
||||
private fun String.withHttpSchema(): String = if (isHttpUrl()) {
|
||||
this
|
||||
} else {
|
||||
"http://$this"
|
||||
}
|
||||
|
||||
const val KEY_SYNC_URL = "host"
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.PreferenceDialogAutocompletetextviewBinding
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
@@ -66,7 +67,7 @@ class SyncHostDialogFragment : AlertDialogFragment<PreferenceDialogAutocompletet
|
||||
DialogInterface.BUTTON_POSITIVE -> {
|
||||
val result = requireViewBinding().edit.text?.toString().orEmpty()
|
||||
var scheme = ""
|
||||
if (!result.startsWith("https://") && !result.startsWith("http://")) {
|
||||
if (!result.isHttpUrl()) {
|
||||
scheme = "http://"
|
||||
}
|
||||
syncSettings.syncUrl = "$scheme$result"
|
||||
|
||||
@@ -801,4 +801,5 @@
|
||||
<string name="screen_rotation_locked">Screen rotation has been locked</string>
|
||||
<string name="screen_rotation_unlocked">Screen rotation has been unlocked</string>
|
||||
<string name="badges_in_lists">Badges in lists</string>
|
||||
<string name="search_everywhere">Search everywhere</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user