Improve quick filters
This commit is contained in:
@@ -43,6 +43,8 @@ fun MangaSource(name: String?): MangaSource {
|
|||||||
return UnknownMangaSource
|
return UnknownMangaSource
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Collection<String>.toMangaSources() = map(::MangaSource)
|
||||||
|
|
||||||
fun MangaSource.isNsfw(): Boolean = when (this) {
|
fun MangaSource.isNsfw(): Boolean = when (this) {
|
||||||
is MangaSourceInfo -> mangaSource.isNsfw()
|
is MangaSourceInfo -> mangaSource.isNsfw()
|
||||||
is MangaParserSource -> contentType == ContentType.HENTAI
|
is MangaParserSource -> contentType == ContentType.HENTAI
|
||||||
@@ -61,11 +63,16 @@ val ContentType.titleResId
|
|||||||
ContentType.NOVEL -> R.string.content_type_novel
|
ContentType.NOVEL -> R.string.content_type_novel
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MangaSource.getSummary(context: Context): String? = when (this) {
|
tailrec fun MangaSource.unwrap(): MangaSource = if (this is MangaSourceInfo) {
|
||||||
is MangaSourceInfo -> mangaSource.getSummary(context)
|
mangaSource.unwrap()
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MangaSource.getSummary(context: Context): String? = when (val source = unwrap()) {
|
||||||
is MangaParserSource -> {
|
is MangaParserSource -> {
|
||||||
val type = context.getString(contentType.titleResId)
|
val type = context.getString(source.contentType.titleResId)
|
||||||
val locale = locale.toLocale().getDisplayName(context)
|
val locale = source.locale.toLocale().getDisplayName(context)
|
||||||
context.getString(R.string.source_summary_pattern, type, locale)
|
context.getString(R.string.source_summary_pattern, type, locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,11 +81,10 @@ fun MangaSource.getSummary(context: Context): String? = when (this) {
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MangaSource.getTitle(context: Context): String = when (this) {
|
fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()) {
|
||||||
is MangaSourceInfo -> mangaSource.getTitle(context)
|
is MangaParserSource -> source.title
|
||||||
is MangaParserSource -> title
|
|
||||||
LocalMangaSource -> context.getString(R.string.local_storage)
|
LocalMangaSource -> context.getString(R.string.local_storage)
|
||||||
is ExternalMangaSource -> resolveName(context)
|
is ExternalMangaSource -> source.resolveName(context)
|
||||||
else -> context.getString(R.string.unknown)
|
else -> context.getString(R.string.unknown)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ class FaviconFetcher(
|
|||||||
.url(url)
|
.url(url)
|
||||||
.get()
|
.get()
|
||||||
.tag(MangaSource::class.java, source)
|
.tag(MangaSource::class.java, source)
|
||||||
|
request.tag(MangaSource::class.java, source)
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
options.tags.asMap().forEach { request.tag(it.key as Class<Any>, it.value) }
|
options.tags.asMap().forEach { request.tag(it.key as Class<Any>, it.value) }
|
||||||
val response = okHttpClient.newCall(request.build()).await()
|
val response = okHttpClient.newCall(request.build()).await()
|
||||||
|
|||||||
@@ -8,18 +8,32 @@ import androidx.annotation.ColorRes
|
|||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.request.Disposable
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import coil.transform.RoundedCornersTransformation
|
||||||
import com.google.android.material.chip.Chip
|
import com.google.android.material.chip.Chip
|
||||||
import com.google.android.material.chip.ChipDrawable
|
import com.google.android.material.chip.ChipDrawable
|
||||||
import com.google.android.material.chip.ChipGroup
|
import com.google.android.material.chip.ChipGroup
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.setProgressIcon
|
||||||
|
import org.koitharu.kotatsu.parsers.util.ifZero
|
||||||
|
import javax.inject.Inject
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
class ChipsView @JvmOverloads constructor(
|
class ChipsView @JvmOverloads constructor(
|
||||||
context: Context,
|
context: Context,
|
||||||
attrs: AttributeSet? = null,
|
attrs: AttributeSet? = null,
|
||||||
defStyleAttr: Int = com.google.android.material.R.attr.chipGroupStyle,
|
defStyleAttr: Int = com.google.android.material.R.attr.chipGroupStyle,
|
||||||
) : ChipGroup(context, attrs, defStyleAttr) {
|
) : ChipGroup(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
private var isLayoutSuppressedCompat = false
|
private var isLayoutSuppressedCompat = false
|
||||||
private var isLayoutCalledOnSuppressed = false
|
private var isLayoutCalledOnSuppressed = false
|
||||||
private val chipOnClickListener = InternalChipClickListener()
|
private val chipOnClickListener = InternalChipClickListener()
|
||||||
@@ -90,8 +104,10 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
val title: CharSequence? = null,
|
val title: CharSequence? = null,
|
||||||
@StringRes val titleResId: Int = 0,
|
@StringRes val titleResId: Int = 0,
|
||||||
@DrawableRes val icon: Int = 0,
|
@DrawableRes val icon: Int = 0,
|
||||||
|
val iconData: Any? = null,
|
||||||
@ColorRes val tint: Int = 0,
|
@ColorRes val tint: Int = 0,
|
||||||
val isChecked: Boolean = false,
|
val isChecked: Boolean = false,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
val isDropdown: Boolean = false,
|
val isDropdown: Boolean = false,
|
||||||
val isCloseable: Boolean = false,
|
val isCloseable: Boolean = false,
|
||||||
val data: Any? = null,
|
val data: Any? = null,
|
||||||
@@ -100,6 +116,7 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
private inner class DataChip(context: Context) : Chip(context) {
|
private inner class DataChip(context: Context) : Chip(context) {
|
||||||
|
|
||||||
private var model: ChipModel? = null
|
private var model: ChipModel? = null
|
||||||
|
private var imageRequest: Disposable? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
|
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
|
||||||
@@ -112,6 +129,9 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun bind(model: ChipModel) {
|
fun bind(model: ChipModel) {
|
||||||
|
if (this.model == model) {
|
||||||
|
return
|
||||||
|
}
|
||||||
this.model = model
|
this.model = model
|
||||||
|
|
||||||
if (model.titleResId == 0) {
|
if (model.titleResId == 0) {
|
||||||
@@ -127,13 +147,7 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
isChecked = false
|
isChecked = false
|
||||||
isCheckable = false
|
isCheckable = false
|
||||||
}
|
}
|
||||||
if (model.icon == 0 || model.isChecked) {
|
bindIcon(model)
|
||||||
chipIcon = null
|
|
||||||
isChipIconVisible = false
|
|
||||||
} else {
|
|
||||||
setChipIconResource(model.icon)
|
|
||||||
isChipIconVisible = true
|
|
||||||
}
|
|
||||||
isCheckedIconVisible = model.isChecked
|
isCheckedIconVisible = model.isChecked
|
||||||
isCloseIconVisible = if (model.isCloseable || model.isDropdown) {
|
isCloseIconVisible = if (model.isCloseable || model.isDropdown) {
|
||||||
setCloseIconResource(
|
setCloseIconResource(
|
||||||
@@ -147,6 +161,54 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun toggle() = Unit
|
override fun toggle() = Unit
|
||||||
|
|
||||||
|
private fun bindIcon(model: ChipModel) {
|
||||||
|
when {
|
||||||
|
model.isChecked -> {
|
||||||
|
imageRequest?.dispose()
|
||||||
|
imageRequest = null
|
||||||
|
chipIcon = null
|
||||||
|
isChipIconVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
model.isLoading -> {
|
||||||
|
imageRequest?.dispose()
|
||||||
|
imageRequest = null
|
||||||
|
isChipIconVisible = true
|
||||||
|
setProgressIcon()
|
||||||
|
}
|
||||||
|
|
||||||
|
model.iconData != null -> {
|
||||||
|
val placeholder = model.icon.ifZero { materialR.drawable.navigation_empty_icon }
|
||||||
|
imageRequest = ImageRequest.Builder(context)
|
||||||
|
.data(model.iconData)
|
||||||
|
.crossfade(false)
|
||||||
|
.size(resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
|
||||||
|
.target(ChipIconTarget(this))
|
||||||
|
.placeholder(placeholder)
|
||||||
|
.fallback(placeholder)
|
||||||
|
.error(placeholder)
|
||||||
|
.transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.chip_icon_corner)))
|
||||||
|
.allowRgb565(true)
|
||||||
|
.enqueueWith(coil)
|
||||||
|
isChipIconVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
model.icon != 0 -> {
|
||||||
|
imageRequest?.dispose()
|
||||||
|
imageRequest = null
|
||||||
|
setChipIconResource(model.icon)
|
||||||
|
isChipIconVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
imageRequest?.dispose()
|
||||||
|
imageRequest = null
|
||||||
|
chipIcon = null
|
||||||
|
isChipIconVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class InternalChipClickListener : OnClickListener {
|
private inner class InternalChipClickListener : OnClickListener {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.core.util.ext
|
package org.koitharu.kotatsu.core.util.ext
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.database.DatabaseUtils
|
||||||
import androidx.annotation.FloatRange
|
import androidx.annotation.FloatRange
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.parsers.util.ellipsize
|
import org.koitharu.kotatsu.parsers.util.ellipsize
|
||||||
@@ -64,3 +65,11 @@ 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)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.favourites.data
|
package org.koitharu.kotatsu.favourites.data
|
||||||
|
|
||||||
|
import android.database.DatabaseUtils.sqlEscapeString
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
@@ -120,6 +121,12 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback {
|
|||||||
@Query("SELECT COUNT(category_id) FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0")
|
@Query("SELECT COUNT(category_id) FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0")
|
||||||
abstract suspend fun findCategoriesCount(mangaId: Long): Int
|
abstract suspend fun findCategoriesCount(mangaId: Long): Int
|
||||||
|
|
||||||
|
@Query("SELECT manga.source AS count FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit")
|
||||||
|
abstract suspend fun findPopularSources(limit: Int): List<String>
|
||||||
|
|
||||||
|
@Query("SELECT manga.source AS count FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.category_id = :categoryId GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit")
|
||||||
|
abstract suspend fun findPopularSources(categoryId: Long, limit: Int): List<String>
|
||||||
|
|
||||||
/** INSERT **/
|
/** INSERT **/
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
@@ -200,6 +207,7 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback {
|
|||||||
ListFilterOption.Macro.NSFW -> "manga.nsfw = 1"
|
ListFilterOption.Macro.NSFW -> "manga.nsfw = 1"
|
||||||
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE favourites.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})"
|
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE favourites.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})"
|
||||||
ListFilterOption.Downloaded -> "EXISTS(SELECT * FROM local_index WHERE local_index.manga_id = favourites.manga_id)"
|
ListFilterOption.Downloaded -> "EXISTS(SELECT * FROM local_index WHERE local_index.manga_id = favourites.manga_id)"
|
||||||
|
is ListFilterOption.Source -> "manga.source = ${sqlEscapeString(option.mangaSource.name)}"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package org.koitharu.kotatsu.favourites.domain
|
package org.koitharu.kotatsu.favourites.domain
|
||||||
|
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
import org.koitharu.kotatsu.core.os.NetworkState
|
import org.koitharu.kotatsu.core.os.NetworkState
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||||
import org.koitharu.kotatsu.list.domain.MangaListQuickFilter
|
import org.koitharu.kotatsu.list.domain.MangaListQuickFilter
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class FavoritesListQuickFilter @Inject constructor(
|
class FavoritesListQuickFilter @AssistedInject constructor(
|
||||||
|
@Assisted private val categoryId: Long,
|
||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
private val repository: FavouritesRepository,
|
private val repository: FavouritesRepository,
|
||||||
networkState: NetworkState,
|
networkState: NetworkState,
|
||||||
@@ -22,5 +25,14 @@ class FavoritesListQuickFilter @Inject constructor(
|
|||||||
add(ListFilterOption.Macro.NEW_CHAPTERS)
|
add(ListFilterOption.Macro.NEW_CHAPTERS)
|
||||||
}
|
}
|
||||||
add(ListFilterOption.Macro.COMPLETED)
|
add(ListFilterOption.Macro.COMPLETED)
|
||||||
|
repository.findPopularSources(categoryId, 3).mapTo(this) {
|
||||||
|
ListFilterOption.Source(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
|
||||||
|
fun create(categoryId: Long): FavoritesListQuickFilter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
|
|||||||
import org.koitharu.kotatsu.core.db.entity.toEntities
|
import org.koitharu.kotatsu.core.db.entity.toEntities
|
||||||
import org.koitharu.kotatsu.core.db.entity.toEntity
|
import org.koitharu.kotatsu.core.db.entity.toEntity
|
||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.core.model.toMangaSources
|
||||||
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
|
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
|
||||||
import org.koitharu.kotatsu.core.util.ext.mapItems
|
import org.koitharu.kotatsu.core.util.ext.mapItems
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||||
@@ -22,6 +24,7 @@ import org.koitharu.kotatsu.favourites.domain.model.Cover
|
|||||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@Reusable
|
@Reusable
|
||||||
@@ -136,6 +139,16 @@ class FavouritesRepository @Inject constructor(
|
|||||||
return db.getFavouritesDao().findCategoriesIds(mangaId).toSet()
|
return db.getFavouritesDao().findCategoriesIds(mangaId).toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun findPopularSources(categoryId: Long, limit: Int): List<MangaSource> {
|
||||||
|
return db.getFavouritesDao().run {
|
||||||
|
if (categoryId == 0L) {
|
||||||
|
findPopularSources(limit)
|
||||||
|
} else {
|
||||||
|
findPopularSources(categoryId, limit)
|
||||||
|
}
|
||||||
|
}.toMangaSources()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun createCategory(
|
suspend fun createCategory(
|
||||||
title: String,
|
title: String,
|
||||||
sortOrder: ListSortOrder,
|
sortOrder: ListSortOrder,
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import android.graphics.Color
|
|||||||
import android.graphics.drawable.ColorDrawable
|
import android.graphics.drawable.ColorDrawable
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.core.graphics.ColorUtils
|
import androidx.core.graphics.ColorUtils
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.widget.ImageViewCompat
|
import androidx.core.widget.ImageViewCompat
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||||
@@ -64,14 +66,20 @@ fun categoriesHeaderAD(
|
|||||||
|
|
||||||
repeat(coverViews.size) { i ->
|
repeat(coverViews.size) { i ->
|
||||||
val cover = item.covers.getOrNull(i)
|
val cover = item.covers.getOrNull(i)
|
||||||
coverViews[i].newImageRequest(lifecycleOwner, cover?.url)?.run {
|
val view = coverViews[i]
|
||||||
placeholder(R.drawable.ic_placeholder)
|
view.isVisible = cover != null
|
||||||
fallback(fallback)
|
if (cover == null) {
|
||||||
source(cover?.mangaSource)
|
view.disposeImageRequest()
|
||||||
crossfade(crossFadeDuration * (i + 1))
|
} else {
|
||||||
error(R.drawable.ic_error_placeholder)
|
view.newImageRequest(lifecycleOwner, cover.url)?.run {
|
||||||
allowRgb565(true)
|
placeholder(R.drawable.ic_placeholder)
|
||||||
enqueueWith(coil)
|
fallback(fallback)
|
||||||
|
source(cover.mangaSource)
|
||||||
|
crossfade(crossFadeDuration * (i + 1))
|
||||||
|
error(R.drawable.ic_error_placeholder)
|
||||||
|
allowRgb565(true)
|
||||||
|
enqueueWith(coil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,12 +49,13 @@ class FavouritesListViewModel @Inject constructor(
|
|||||||
private val repository: FavouritesRepository,
|
private val repository: FavouritesRepository,
|
||||||
private val mangaListMapper: MangaListMapper,
|
private val mangaListMapper: MangaListMapper,
|
||||||
private val markAsReadUseCase: MarkAsReadUseCase,
|
private val markAsReadUseCase: MarkAsReadUseCase,
|
||||||
private val quickFilter: FavoritesListQuickFilter,
|
quickFilterFactory: FavoritesListQuickFilter.Factory,
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
downloadScheduler: DownloadWorker.Scheduler,
|
downloadScheduler: DownloadWorker.Scheduler,
|
||||||
) : MangaListViewModel(settings, downloadScheduler), QuickFilterListener by quickFilter {
|
) : MangaListViewModel(settings, downloadScheduler), QuickFilterListener {
|
||||||
|
|
||||||
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
|
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
|
||||||
|
private val quickFilter = quickFilterFactory.create(categoryId)
|
||||||
private val refreshTrigger = MutableStateFlow(Any())
|
private val refreshTrigger = MutableStateFlow(Any())
|
||||||
private val limit = MutableStateFlow(PAGE_SIZE)
|
private val limit = MutableStateFlow(PAGE_SIZE)
|
||||||
private val isPaginationReady = AtomicBoolean(false)
|
private val isPaginationReady = AtomicBoolean(false)
|
||||||
@@ -91,6 +92,12 @@ class FavouritesListViewModel @Inject constructor(
|
|||||||
|
|
||||||
override fun onRetry() = Unit
|
override fun onRetry() = Unit
|
||||||
|
|
||||||
|
override fun setFilterOption(option: ListFilterOption, isApplied: Boolean) = quickFilter.setFilterOption(option, isApplied)
|
||||||
|
|
||||||
|
override fun toggleFilterOption(option: ListFilterOption) = quickFilter.toggleFilterOption(option)
|
||||||
|
|
||||||
|
override fun clearFilter() = quickFilter.clearFilter()
|
||||||
|
|
||||||
fun markAsRead(items: Set<Manga>) {
|
fun markAsRead(items: Set<Manga>) {
|
||||||
launchLoadingJob(Dispatchers.Default) {
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
markAsReadUseCase(items)
|
markAsReadUseCase(items)
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class FilterCoordinator @Inject constructor(
|
|||||||
get() = repository.source
|
get() = repository.source
|
||||||
|
|
||||||
val isFilterApplied: Boolean
|
val isFilterApplied: Boolean
|
||||||
get() = !currentListFilter.value.isEmpty()
|
get() = currentListFilter.value.isNotEmpty()
|
||||||
|
|
||||||
val query: StateFlow<String?> = currentListFilter.map { it.query }
|
val query: StateFlow<String?> = currentListFilter.map { it.query }
|
||||||
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
|
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.history.data
|
package org.koitharu.kotatsu.history.data
|
||||||
|
|
||||||
|
import android.database.DatabaseUtils.sqlEscapeString
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
@@ -73,6 +74,9 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback {
|
|||||||
)
|
)
|
||||||
abstract suspend fun findPopularTags(limit: Int): List<TagEntity>
|
abstract suspend fun findPopularTags(limit: Int): List<TagEntity>
|
||||||
|
|
||||||
|
@Query("SELECT manga.source AS count FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit")
|
||||||
|
abstract suspend fun findPopularSources(limit: Int): List<String>
|
||||||
|
|
||||||
@Query("SELECT * FROM history WHERE manga_id = :id AND deleted_at = 0")
|
@Query("SELECT * FROM history WHERE manga_id = :id AND deleted_at = 0")
|
||||||
abstract suspend fun find(id: Long): HistoryEntity?
|
abstract suspend fun find(id: Long): HistoryEntity?
|
||||||
|
|
||||||
@@ -160,6 +164,7 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback {
|
|||||||
ListFilterOption.Macro.NSFW -> "manga.nsfw = 1"
|
ListFilterOption.Macro.NSFW -> "manga.nsfw = 1"
|
||||||
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE history.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})"
|
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE history.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})"
|
||||||
ListFilterOption.Downloaded -> "EXISTS(SELECT * FROM local_index WHERE local_index.manga_id = history.manga_id)"
|
ListFilterOption.Downloaded -> "EXISTS(SELECT * FROM local_index WHERE local_index.manga_id = history.manga_id)"
|
||||||
|
is ListFilterOption.Source -> "manga.source = ${sqlEscapeString(option.mangaSource.name)}"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ import org.koitharu.kotatsu.core.db.entity.toEntity
|
|||||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||||
import org.koitharu.kotatsu.core.db.entity.toMangaTag
|
import org.koitharu.kotatsu.core.db.entity.toMangaTag
|
||||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.toMangaTagsList
|
||||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.model.findById
|
import org.koitharu.kotatsu.core.model.findById
|
||||||
import org.koitharu.kotatsu.core.model.isLocal
|
import org.koitharu.kotatsu.core.model.isLocal
|
||||||
import org.koitharu.kotatsu.core.model.isNsfw
|
import org.koitharu.kotatsu.core.model.isNsfw
|
||||||
|
import org.koitharu.kotatsu.core.model.toMangaSources
|
||||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode
|
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode
|
||||||
@@ -26,6 +29,7 @@ import org.koitharu.kotatsu.list.domain.ListFilterOption
|
|||||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||||
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
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.parsers.model.MangaTag
|
||||||
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
|
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
|
||||||
import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble
|
import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble
|
||||||
@@ -177,7 +181,11 @@ class HistoryRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getPopularTags(limit: Int): List<MangaTag> {
|
suspend fun getPopularTags(limit: Int): List<MangaTag> {
|
||||||
return db.getHistoryDao().findPopularTags(limit).map { x -> x.toMangaTag() }
|
return db.getHistoryDao().findPopularTags(limit).toMangaTagsList()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getPopularSources(limit: Int): List<MangaSource> {
|
||||||
|
return db.getHistoryDao().findPopularSources(limit).toMangaSources()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun shouldSkip(manga: Manga): Boolean {
|
fun shouldSkip(manga: Manga): Boolean {
|
||||||
|
|||||||
@@ -31,5 +31,8 @@ class HistoryListQuickFilter @Inject constructor(
|
|||||||
repository.getPopularTags(3).mapTo(this) {
|
repository.getPopularTags(3).mapTo(this) {
|
||||||
ListFilterOption.Tag(it)
|
ListFilterOption.Tag(it)
|
||||||
}
|
}
|
||||||
|
repository.getPopularSources(3).mapTo(this) {
|
||||||
|
ListFilterOption.Source(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import androidx.annotation.StringRes
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.db.entity.toEntity
|
import org.koitharu.kotatsu.core.db.entity.toEntity
|
||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
|
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||||
|
import org.koitharu.kotatsu.core.model.unwrap
|
||||||
|
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||||
|
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
|
||||||
sealed interface ListFilterOption {
|
sealed interface ListFilterOption {
|
||||||
@@ -19,6 +25,8 @@ sealed interface ListFilterOption {
|
|||||||
|
|
||||||
val groupKey: String
|
val groupKey: String
|
||||||
|
|
||||||
|
fun getIconData(): Any? = null
|
||||||
|
|
||||||
data object Downloaded : ListFilterOption {
|
data object Downloaded : ListFilterOption {
|
||||||
|
|
||||||
override val titleResId: Int
|
override val titleResId: Int
|
||||||
@@ -88,6 +96,31 @@ sealed interface ListFilterOption {
|
|||||||
get() = "_favcat"
|
get() = "_favcat"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class Source(
|
||||||
|
val mangaSource: MangaSource
|
||||||
|
) : ListFilterOption {
|
||||||
|
override val titleResId: Int
|
||||||
|
get() = when (mangaSource.unwrap()) {
|
||||||
|
is ExternalMangaSource -> R.string.external_source
|
||||||
|
LocalMangaSource -> R.string.local_storage
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override val iconResId: Int
|
||||||
|
get() = R.drawable.ic_web
|
||||||
|
|
||||||
|
override val titleText: CharSequence?
|
||||||
|
get() = when (val source = mangaSource.unwrap()) {
|
||||||
|
is MangaParserSource -> source.title
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
override val groupKey: String
|
||||||
|
get() = "_source"
|
||||||
|
|
||||||
|
override fun getIconData() = mangaSource.faviconUri()
|
||||||
|
}
|
||||||
|
|
||||||
data class Inverted(
|
data class Inverted(
|
||||||
val option: ListFilterOption,
|
val option: ListFilterOption,
|
||||||
override val iconResId: Int,
|
override val iconResId: Int,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ abstract class MangaListQuickFilter(
|
|||||||
title = option.titleText,
|
title = option.titleText,
|
||||||
titleResId = option.titleResId,
|
titleResId = option.titleResId,
|
||||||
icon = option.iconResId,
|
icon = option.iconResId,
|
||||||
|
iconData = option.getIconData(),
|
||||||
isChecked = option in selectedOptions,
|
isChecked = option in selectedOptions,
|
||||||
data = option,
|
data = option,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -251,10 +251,6 @@ abstract class MangaListFragment :
|
|||||||
resolveException(error)
|
resolveException(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onUpdateFilter(tags: Set<MangaTag>) {
|
|
||||||
viewModel.onUpdateFilter(tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onGridScaleChanged(scale: Float) {
|
private fun onGridScaleChanged(scale: Float) {
|
||||||
spanSizeLookup.invalidateCache()
|
spanSizeLookup.invalidateCache()
|
||||||
spanResolver?.setGridSize(scale, requireViewBinding().recyclerView)
|
spanResolver?.setGridSize(scale, requireViewBinding().recyclerView)
|
||||||
|
|||||||
@@ -43,8 +43,6 @@ abstract class MangaListViewModel(
|
|||||||
val isIncognitoModeEnabled: Boolean
|
val isIncognitoModeEnabled: Boolean
|
||||||
get() = settings.isIncognitoModeEnabled
|
get() = settings.isIncognitoModeEnabled
|
||||||
|
|
||||||
open fun onUpdateFilter(tags: Set<MangaTag>) = Unit
|
|
||||||
|
|
||||||
abstract fun onRefresh()
|
abstract fun onRefresh()
|
||||||
|
|
||||||
abstract fun onRetry()
|
abstract fun onRetry()
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ package org.koitharu.kotatsu.list.ui.adapter
|
|||||||
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import org.koitharu.kotatsu.core.ui.widgets.TipView
|
import org.koitharu.kotatsu.core.ui.widgets.TipView
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
|
||||||
|
|
||||||
interface MangaListListener : MangaDetailsClickListener, ListStateHolderListener, ListHeaderClickListener,
|
interface MangaListListener : MangaDetailsClickListener, ListStateHolderListener, ListHeaderClickListener,
|
||||||
TipView.OnButtonClickListener, QuickFilterClickListener {
|
TipView.OnButtonClickListener, QuickFilterClickListener {
|
||||||
|
|
||||||
fun onUpdateFilter(tags: Set<MangaTag>)
|
|
||||||
|
|
||||||
fun onFilterClick(view: View?)
|
fun onFilterClick(view: View?)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import android.content.Context
|
|||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
|
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
|
||||||
|
|
||||||
@Suppress("DataClassPrivateConstructor")
|
@ExposedCopyVisibility
|
||||||
data class ListHeader private constructor(
|
data class ListHeader private constructor(
|
||||||
private val textRaw: Any,
|
private val textRaw: Any,
|
||||||
@StringRes val buttonTextRes: Int,
|
@StringRes val buttonTextRes: Int,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.FlowCollector
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
@@ -30,6 +32,7 @@ class LocalMangaIndex @Inject constructor(
|
|||||||
) : FlowCollector<LocalManga?> {
|
) : FlowCollector<LocalManga?> {
|
||||||
|
|
||||||
private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
private var previousHash: Long
|
private var previousHash: Long
|
||||||
get() = prefs.getLong(KEY_HASH, 0L)
|
get() = prefs.getLong(KEY_HASH, 0L)
|
||||||
@@ -41,7 +44,7 @@ class LocalMangaIndex @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun update(): Boolean {
|
suspend fun update(): Boolean = mutex.withLock {
|
||||||
val newHash = computeHash()
|
val newHash = computeHash()
|
||||||
if (newHash == previousHash) {
|
if (newHash == previousHash) {
|
||||||
return false
|
return false
|
||||||
@@ -57,7 +60,13 @@ class LocalMangaIndex @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun get(mangaId: Long): LocalManga? {
|
suspend fun get(mangaId: Long): LocalManga? {
|
||||||
val path = db.getLocalMangaIndexDao().findPath(mangaId) ?: return null
|
var path = db.getLocalMangaIndexDao().findPath(mangaId)
|
||||||
|
if (path == null && mutex.isLocked) { // wait for updating complete
|
||||||
|
path = mutex.withLock { db.getLocalMangaIndexDao().findPath(mangaId) }
|
||||||
|
}
|
||||||
|
if (path == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
return runCatchingCancellable {
|
return runCatchingCancellable {
|
||||||
LocalMangaInput.of(File(path)).getManga()
|
LocalMangaInput.of(File(path)).getManga()
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
@@ -65,9 +74,11 @@ class LocalMangaIndex @Inject constructor(
|
|||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun put(manga: LocalManga) = db.withTransaction {
|
suspend fun put(manga: LocalManga) = mutex.withLock {
|
||||||
mangaDataRepository.storeManga(manga.manga)
|
db.withTransaction {
|
||||||
db.getLocalMangaIndexDao().upsert(manga.toEntity())
|
mangaDataRepository.storeManga(manga.manga)
|
||||||
|
db.getLocalMangaIndexDao().upsert(manga.toEntity())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun delete(mangaId: Long) {
|
suspend fun delete(mangaId: Long) {
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ class LocalListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun createEmptyState(canResetFilter: Boolean): EmptyState = if (canResetFilter) {
|
override fun createEmptyState(canResetFilter: Boolean): EmptyState = if (canResetFilter) {
|
||||||
super.createEmptyState(canResetFilter)
|
super.createEmptyState(true)
|
||||||
} else {
|
} else {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
icon = R.drawable.ic_empty_local,
|
icon = R.drawable.ic_empty_local,
|
||||||
|
|||||||
@@ -66,21 +66,32 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onEmptyActionClick() {
|
override fun onEmptyActionClick() {
|
||||||
viewModel.resetFilter()
|
if (filterCoordinator.isFilterApplied) {
|
||||||
|
filterCoordinator.reset()
|
||||||
|
} else {
|
||||||
|
openInBrowser()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSecondaryErrorActionClick(error: Throwable) {
|
override fun onSecondaryErrorActionClick(error: Throwable) {
|
||||||
viewModel.browserUrl?.also { url ->
|
openInBrowser()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openInBrowser() {
|
||||||
|
val browserUrl = viewModel.browserUrl
|
||||||
|
if (browserUrl.isNullOrEmpty()) {
|
||||||
|
Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
} else {
|
||||||
startActivity(
|
startActivity(
|
||||||
BrowserActivity.newIntent(
|
BrowserActivity.newIntent(
|
||||||
requireContext(),
|
requireContext(),
|
||||||
url,
|
browserUrl,
|
||||||
viewModel.source,
|
viewModel.source,
|
||||||
viewModel.source.getTitle(requireContext()),
|
viewModel.source.getTitle(requireContext()),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} ?: Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
|
}
|
||||||
.show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class RemoteListMenuProvider : MenuProvider {
|
private inner class RemoteListMenuProvider : MenuProvider {
|
||||||
@@ -106,7 +117,7 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
R.id.action_filter_reset -> {
|
R.id.action_filter_reset -> {
|
||||||
viewModel.resetFilter()
|
filterCoordinator.reset()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ import org.koitharu.kotatsu.list.ui.model.toErrorFooter
|
|||||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val FILTER_MIN_INTERVAL = 250L
|
private const val FILTER_MIN_INTERVAL = 250L
|
||||||
@@ -51,7 +49,7 @@ private const val FILTER_MIN_INTERVAL = 250L
|
|||||||
open class RemoteListViewModel @Inject constructor(
|
open class RemoteListViewModel @Inject constructor(
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
mangaRepositoryFactory: MangaRepository.Factory,
|
mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
override val filterCoordinator: FilterCoordinator,
|
final override val filterCoordinator: FilterCoordinator,
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
mangaListMapper: MangaListMapper,
|
mangaListMapper: MangaListMapper,
|
||||||
downloadScheduler: DownloadWorker.Scheduler,
|
downloadScheduler: DownloadWorker.Scheduler,
|
||||||
@@ -132,12 +130,6 @@ open class RemoteListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resetFilter() = filterCoordinator.reset()
|
|
||||||
|
|
||||||
override fun onUpdateFilter(tags: Set<MangaTag>) {
|
|
||||||
filterCoordinator.set(MangaListFilter(tags = tags))
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun loadList(filterState: FilterCoordinator.Snapshot, append: Boolean): Job {
|
protected fun loadList(filterState: FilterCoordinator.Snapshot, append: Boolean): Job {
|
||||||
loadingJob?.let {
|
loadingJob?.let {
|
||||||
if (it.isActive) return it
|
if (it.isActive) return it
|
||||||
@@ -178,7 +170,7 @@ open class RemoteListViewModel @Inject constructor(
|
|||||||
icon = R.drawable.ic_empty_common,
|
icon = R.drawable.ic_empty_common,
|
||||||
textPrimary = R.string.nothing_found,
|
textPrimary = R.string.nothing_found,
|
||||||
textSecondary = 0,
|
textSecondary = 0,
|
||||||
actionStringRes = if (canResetFilter) R.string.reset_filter else 0,
|
actionStringRes = if (canResetFilter) R.string.reset_filter else R.string.open_in_browser,
|
||||||
)
|
)
|
||||||
|
|
||||||
protected open suspend fun onBuildList(list: MutableList<ListModel>) = Unit
|
protected open suspend fun onBuildList(list: MutableList<ListModel>) = Unit
|
||||||
|
|||||||
@@ -142,8 +142,6 @@ class MultiSearchActivity :
|
|||||||
|
|
||||||
override fun onFilterOptionClick(option: ListFilterOption) = Unit
|
override fun onFilterOptionClick(option: ListFilterOption) = Unit
|
||||||
|
|
||||||
override fun onUpdateFilter(tags: Set<MangaTag>) = Unit
|
|
||||||
|
|
||||||
override fun onFilterClick(view: View?) = Unit
|
override fun onFilterClick(view: View?) = Unit
|
||||||
|
|
||||||
override fun onEmptyActionClick() = Unit
|
override fun onEmptyActionClick() = Unit
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.suggestions.data
|
package org.koitharu.kotatsu.suggestions.data
|
||||||
|
|
||||||
|
import android.database.DatabaseUtils.sqlEscapeString
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
@@ -48,6 +49,9 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback {
|
|||||||
@Query("SELECT tags.* FROM suggestions LEFT JOIN tags ON (tag_id IN (SELECT tag_id FROM manga_tags WHERE manga_tags.manga_id = suggestions.manga_id)) GROUP BY tag_id ORDER BY COUNT(tags.tag_id) DESC LIMIT :limit")
|
@Query("SELECT tags.* FROM suggestions LEFT JOIN tags ON (tag_id IN (SELECT tag_id FROM manga_tags WHERE manga_tags.manga_id = suggestions.manga_id)) GROUP BY tag_id ORDER BY COUNT(tags.tag_id) DESC LIMIT :limit")
|
||||||
abstract suspend fun getTopTags(limit: Int): List<TagEntity>
|
abstract suspend fun getTopTags(limit: Int): List<TagEntity>
|
||||||
|
|
||||||
|
@Query("SELECT manga.source AS count FROM suggestions LEFT JOIN manga ON manga.manga_id = suggestions.manga_id GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit")
|
||||||
|
abstract suspend fun getTopSources(limit: Int): List<String>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
abstract suspend fun insert(entity: SuggestionEntity): Long
|
abstract suspend fun insert(entity: SuggestionEntity): Long
|
||||||
|
|
||||||
@@ -71,6 +75,7 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback {
|
|||||||
override fun getCondition(option: ListFilterOption): String? = when (option) {
|
override fun getCondition(option: ListFilterOption): String? = when (option) {
|
||||||
ListFilterOption.Macro.NSFW -> "(SELECT nsfw FROM manga WHERE manga.manga_id = suggestions.manga_id) = 1"
|
ListFilterOption.Macro.NSFW -> "(SELECT nsfw FROM manga WHERE manga.manga_id = suggestions.manga_id) = 1"
|
||||||
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE manga_tags.manga_id = suggestions.manga_id AND tag_id = ${option.tagId})"
|
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE manga_tags.manga_id = suggestions.manga_id AND tag_id = ${option.tagId})"
|
||||||
|
is ListFilterOption.Source -> "(SELECT source FROM manga WHERE manga.manga_id = suggestions.manga_id) = ${sqlEscapeString(option.mangaSource.name)}"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import org.koitharu.kotatsu.core.db.entity.toEntity
|
|||||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||||
import org.koitharu.kotatsu.core.db.entity.toMangaTagsList
|
import org.koitharu.kotatsu.core.db.entity.toMangaTagsList
|
||||||
|
import org.koitharu.kotatsu.core.model.toMangaSources
|
||||||
import org.koitharu.kotatsu.core.util.ext.mapItems
|
import org.koitharu.kotatsu.core.util.ext.mapItems
|
||||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
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.parsers.model.MangaTag
|
||||||
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
|
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -56,6 +58,11 @@ class SuggestionRepository @Inject constructor(
|
|||||||
.toMangaTagsList()
|
.toMangaTagsList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getTopSources(limit: Int): List<MangaSource> {
|
||||||
|
return db.getSuggestionDao().getTopSources(limit)
|
||||||
|
.toMangaSources()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun replace(suggestions: Iterable<MangaSuggestion>) {
|
suspend fun replace(suggestions: Iterable<MangaSuggestion>) {
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
db.getSuggestionDao().deleteAll()
|
db.getSuggestionDao().deleteAll()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.koitharu.kotatsu.suggestions.domain
|
package org.koitharu.kotatsu.suggestions.domain
|
||||||
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||||
import org.koitharu.kotatsu.list.domain.MangaListQuickFilter
|
import org.koitharu.kotatsu.list.domain.MangaListQuickFilter
|
||||||
@@ -19,5 +18,8 @@ class SuggestionsListQuickFilter @Inject constructor(
|
|||||||
add(ListFilterOption.Macro.NSFW)
|
add(ListFilterOption.Macro.NSFW)
|
||||||
add(ListFilterOption.SFW)
|
add(ListFilterOption.SFW)
|
||||||
}
|
}
|
||||||
|
suggestionRepository.getTopSources(3).mapTo(this) {
|
||||||
|
ListFilterOption.Source(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import org.koitharu.kotatsu.core.ui.widgets.TipView
|
|||||||
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
import org.koitharu.kotatsu.databinding.FragmentFeedBinding
|
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
|
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
|
||||||
@@ -39,7 +39,7 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class FeedFragment :
|
class FeedFragment :
|
||||||
BaseFragment<FragmentFeedBinding>(),
|
BaseFragment<FragmentListBinding>(),
|
||||||
PaginationScrollListener.Callback,
|
PaginationScrollListener.Callback,
|
||||||
MangaListListener, SwipeRefreshLayout.OnRefreshListener {
|
MangaListListener, SwipeRefreshLayout.OnRefreshListener {
|
||||||
|
|
||||||
@@ -53,9 +53,9 @@ class FeedFragment :
|
|||||||
override fun onCreateViewBinding(
|
override fun onCreateViewBinding(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
) = FragmentFeedBinding.inflate(inflater, container, false)
|
) = FragmentListBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
override fun onViewBindingCreated(binding: FragmentFeedBinding, savedInstanceState: Bundle?) {
|
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
val sizeResolver = StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width))
|
val sizeResolver = StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width))
|
||||||
feedAdapter = FeedAdapter(coil, viewLifecycleOwner, this, sizeResolver) { item, v ->
|
feedAdapter = FeedAdapter(coil, viewLifecycleOwner, this, sizeResolver) { item, v ->
|
||||||
@@ -99,8 +99,6 @@ class FeedFragment :
|
|||||||
|
|
||||||
override fun onRetryClick(error: Throwable) = Unit
|
override fun onRetryClick(error: Throwable) = Unit
|
||||||
|
|
||||||
override fun onUpdateFilter(tags: Set<MangaTag>) = Unit
|
|
||||||
|
|
||||||
override fun onFilterClick(view: View?) = Unit
|
override fun onFilterClick(view: View?) = Unit
|
||||||
|
|
||||||
override fun onEmptyActionClick() = Unit
|
override fun onEmptyActionClick() = Unit
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<FrameLayout
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent">
|
|
||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
|
||||||
android:id="@+id/swipeRefreshLayout"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent">
|
|
||||||
|
|
||||||
<org.koitharu.kotatsu.core.ui.list.fastscroll.FastScrollRecyclerView
|
|
||||||
android:id="@+id/recyclerView"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:clipToPadding="false"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:paddingVertical="@dimen/list_spacing_normal"
|
|
||||||
app:bubbleSize="small"
|
|
||||||
app:layoutManager="org.koitharu.kotatsu.core.ui.list.FitHeightLinearLayoutManager"
|
|
||||||
tools:listitem="@layout/item_feed" />
|
|
||||||
|
|
||||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
|
||||||
|
|
||||||
</FrameLayout>
|
|
||||||
@@ -58,25 +58,33 @@
|
|||||||
android:background="?attr/colorSecondaryContainer"
|
android:background="?attr/colorSecondaryContainer"
|
||||||
android:backgroundTintMode="src_atop"
|
android:backgroundTintMode="src_atop"
|
||||||
android:scaleType="centerCrop"
|
android:scaleType="centerCrop"
|
||||||
app:layout_constraintBottom_toBottomOf="@id/guideline"
|
app:layout_constraintBottom_toBottomOf="@id/imageView_cover2"
|
||||||
app:layout_constraintDimensionRatio="W,13:18"
|
app:layout_constraintDimensionRatio="W,13:18"
|
||||||
app:layout_constraintStart_toStartOf="@id/guideline_start"
|
app:layout_constraintStart_toStartOf="@id/guideline_start"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="@id/imageView_cover2"
|
||||||
|
app:layout_goneMarginTop="0dp"
|
||||||
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
|
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
|
||||||
tools:src="@tools:sample/backgrounds/scenic" />
|
tools:src="@tools:sample/backgrounds/scenic" />
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.Barrier
|
||||||
|
android:id="@+id/barrier_covers"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:barrierDirection="end"
|
||||||
|
app:constraint_referenced_ids="imageView_cover1,imageView_cover2,imageView_cover3" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/textView_title"
|
android:id="@+id/textView_title"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="0dp"
|
||||||
android:layout_marginStart="@dimen/margin_normal"
|
android:layout_marginStart="@dimen/margin_normal"
|
||||||
android:layout_marginEnd="?listPreferredItemPaddingEnd"
|
android:layout_marginEnd="?listPreferredItemPaddingEnd"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical|start"
|
||||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||||
app:layout_constrainedHeight="true"
|
|
||||||
app:layout_constraintBottom_toBottomOf="@id/guideline"
|
app:layout_constraintBottom_toBottomOf="@id/guideline"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toEndOf="@id/imageView_cover3"
|
app:layout_constraintStart_toEndOf="@id/barrier_covers"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
tools:text="@tools:sample/lorem[22]" />
|
tools:text="@tools:sample/lorem[22]" />
|
||||||
|
|
||||||
|
|||||||
@@ -325,7 +325,7 @@
|
|||||||
<string name="manga_error_description_pattern">Error details:<br><tt>%1$s</tt><br><br>1. Try to <a href="%2$s">open manga in a web browser</a> to ensure it is available on its source<br>2. Make sure you are using the <a href="kotatsu://about">latest version of Kotatsu</a><br>3. If it is available, send an error report to the developers.</string>
|
<string name="manga_error_description_pattern">Error details:<br><tt>%1$s</tt><br><br>1. Try to <a href="%2$s">open manga in a web browser</a> to ensure it is available on its source<br>2. Make sure you are using the <a href="kotatsu://about">latest version of Kotatsu</a><br>3. If it is available, send an error report to the developers.</string>
|
||||||
<string name="history_shortcuts">Show recent manga shortcuts</string>
|
<string name="history_shortcuts">Show recent manga shortcuts</string>
|
||||||
<string name="history_shortcuts_summary">Make recent manga available by long pressing on application icon</string>
|
<string name="history_shortcuts_summary">Make recent manga available by long pressing on application icon</string>
|
||||||
<string name="reader_control_ltr_summary">Tapping on the right edge, or pressing the right key, always switches to the next page.</string>
|
<string name="reader_control_ltr_summary">Do not adjust the page switching direction to the reader mode, e. g. pressing the right key always switches to the next page. This option affects only hardware input devices</string>
|
||||||
<string name="reader_control_ltr">Ergonomic reader control</string>
|
<string name="reader_control_ltr">Ergonomic reader control</string>
|
||||||
<string name="color_correction">Color correction</string>
|
<string name="color_correction">Color correction</string>
|
||||||
<string name="brightness">Brightness</string>
|
<string name="brightness">Brightness</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user