Show all favorites on categories screen

This commit is contained in:
Koitharu
2024-01-18 16:16:12 +02:00
parent 59bfa929fd
commit 2a5300a634
9 changed files with 290 additions and 26 deletions

View File

@@ -82,19 +82,35 @@ abstract class FavouritesDao {
) )
abstract suspend fun findAllManga(categoryId: Int): List<MangaEntity> abstract suspend fun findAllManga(categoryId: Int): List<MangaEntity>
suspend fun findCovers(categoryId: Long, order: ListSortOrder): List<Cover> { suspend fun findCovers(categoryId: Long, order: ListSortOrder, limit: Int): List<Cover> {
val orderBy = getOrderBy(order) val orderBy = getOrderBy(order)
@Language("RoomSql") @Language("RoomSql")
val query = SimpleSQLiteQuery( val query = SimpleSQLiteQuery(
"SELECT manga.cover_url AS url, manga.source AS source FROM favourites " + "SELECT manga.cover_url AS url, manga.source AS source FROM favourites " +
"LEFT JOIN manga ON favourites.manga_id = manga.manga_id " + "LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
"WHERE favourites.category_id = ? AND deleted_at = 0 ORDER BY $orderBy", "WHERE favourites.category_id = ? AND deleted_at = 0 ORDER BY $orderBy LIMIT ?",
arrayOf<Any>(categoryId), arrayOf<Any>(categoryId, limit),
) )
return findCoversImpl(query) return findCoversImpl(query)
} }
suspend fun findCovers(order: ListSortOrder, limit: Int): List<Cover> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
val query = SimpleSQLiteQuery(
"SELECT manga.cover_url AS url, manga.source AS source FROM favourites " +
"LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
"WHERE deleted_at = 0 GROUP BY manga.manga_id ORDER BY $orderBy LIMIT ?",
arrayOf<Any>(limit),
)
return findCoversImpl(query)
}
@Query("SELECT COUNT(DISTINCT manga_id) FROM favourites WHERE deleted_at = 0")
abstract fun observeMangaCount(): Flow<Int>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)") @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)")
abstract suspend fun findAllManga(): List<MangaEntity> abstract suspend fun findAllManga(): List<MangaEntity>

View File

@@ -60,6 +60,11 @@ class FavouritesRepository @Inject constructor(
.flatMapLatest { order -> observeAll(categoryId, order) } .flatMapLatest { order -> observeAll(categoryId, order) }
} }
fun observeMangaCount(): Flow<Int> {
return db.getFavouritesDao().observeMangaCount()
.distinctUntilChanged()
}
fun observeCategories(): Flow<List<FavouriteCategory>> { fun observeCategories(): Flow<List<FavouriteCategory>> {
return db.getFavouriteCategoriesDao().observeAll().mapItems { return db.getFavouriteCategoriesDao().observeAll().mapItems {
it.toFavouriteCategory() it.toFavouriteCategory()
@@ -72,7 +77,7 @@ class FavouritesRepository @Inject constructor(
}.distinctUntilChanged() }.distinctUntilChanged()
} }
fun observeCategoriesWithCovers(): Flow<Map<FavouriteCategory, List<Cover>>> { fun observeCategoriesWithCovers(coversLimit: Int): Flow<Map<FavouriteCategory, List<Cover>>> {
return db.getFavouriteCategoriesDao().observeAll() return db.getFavouriteCategoriesDao().observeAll()
.map { .map {
db.withTransaction { db.withTransaction {
@@ -82,6 +87,7 @@ class FavouritesRepository @Inject constructor(
res[cat] = db.getFavouritesDao().findCovers( res[cat] = db.getFavouritesDao().findCovers(
categoryId = cat.id, categoryId = cat.id,
order = cat.order, order = cat.order,
limit = coversLimit,
) )
} }
res res
@@ -89,6 +95,10 @@ class FavouritesRepository @Inject constructor(
} }
} }
suspend fun getAllFavoritesCovers(order: ListSortOrder, limit: Int): List<Cover> {
return db.getFavouritesDao().findCovers(order, limit)
}
fun observeCategory(id: Long): Flow<FavouriteCategory?> { fun observeCategory(id: Long): Flow<FavouriteCategory?> {
return db.getFavouriteCategoriesDao().observe(id) return db.getFavouriteCategoriesDao().observe(id)
.map { it?.toFavouriteCategory() } .map { it?.toFavouriteCategory() }

View File

@@ -76,7 +76,13 @@ class FavouriteCategoriesActivity :
} }
} }
override fun onItemClick(item: FavouriteCategory, view: View) { override fun onItemClick(item: FavouriteCategory?, view: View) {
if (item == null) {
if (selectionController.count == 0) {
startActivity(FavouritesActivity.newIntent(view.context))
}
return
}
if (selectionController.onItemClick(item.id)) { if (selectionController.onItemClick(item.id)) {
return return
} }
@@ -92,8 +98,12 @@ class FavouriteCategoriesActivity :
startActivity(intent) startActivity(intent)
} }
override fun onItemLongClick(item: FavouriteCategory, view: View): Boolean { override fun onItemLongClick(item: FavouriteCategory?, view: View): Boolean {
return selectionController.onItemLongClick(item.id) return item != null && selectionController.onItemLongClick(item.id)
}
override fun onShowAllClick(isChecked: Boolean) {
viewModel.setAllCategoriesVisible(isChecked)
} }
override fun onDragHandleTouch(holder: RecyclerView.ViewHolder): Boolean { override fun onDragHandleTouch(holder: RecyclerView.ViewHolder): Boolean {

View File

@@ -5,9 +5,11 @@ import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
interface FavouriteCategoriesListListener : OnListItemClickListener<FavouriteCategory> { interface FavouriteCategoriesListListener : OnListItemClickListener<FavouriteCategory?> {
fun onDragHandleTouch(holder: RecyclerView.ViewHolder): Boolean fun onDragHandleTouch(holder: RecyclerView.ViewHolder): Boolean
fun onEditClick(item: FavouriteCategory, view: View) fun onEditClick(item: FavouriteCategory, view: View)
fun onShowAllClick(isChecked: Boolean)
} }

View File

@@ -5,17 +5,21 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.favourites.ui.categories.adapter.AllCategoriesListModel
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -30,9 +34,13 @@ class FavouritesCategoriesViewModel @Inject constructor(
private var commitJob: Job? = null private var commitJob: Job? = null
val content = repository.observeCategoriesWithCovers() val content = combine(
.map { it.toUiList() } repository.observeCategoriesWithCovers(coversLimit = 3),
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) observeAllCategories(),
settings.observeAsFlow(AppSettings.KEY_ALL_FAVOURITES_VISIBLE) { isAllFavouritesVisible },
) { cats, all, showAll ->
cats.toUiList(all, showAll)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
fun deleteCategories(ids: Set<Long>) { fun deleteCategories(ids: Set<Long>) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
@@ -74,21 +82,46 @@ class FavouritesCategoriesViewModel @Inject constructor(
} }
} }
private fun Map<FavouriteCategory, List<Cover>>.toUiList(): List<ListModel> = map { (category, covers) -> private fun Map<FavouriteCategory, List<Cover>>.toUiList(
CategoryListModel( allFavorites: Pair<Int, List<Cover>>,
mangaCount = covers.size, showAll: Boolean
covers = covers.take(3), ): List<ListModel> {
category = category, if (isEmpty()) {
isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources, return listOf(
) EmptyState(
}.ifEmpty { icon = R.drawable.ic_empty_favourites,
listOf( textPrimary = R.string.text_empty_holder_primary,
EmptyState( textSecondary = R.string.empty_favourite_categories,
icon = R.drawable.ic_empty_favourites, actionStringRes = 0,
textPrimary = R.string.text_empty_holder_primary, ),
textSecondary = R.string.empty_favourite_categories, )
actionStringRes = 0, }
val result = ArrayList<ListModel>(size + 1)
result.add(
AllCategoriesListModel(
mangaCount = allFavorites.first,
covers = allFavorites.second,
isVisible = showAll,
), ),
) )
mapTo(result) { (category, covers) ->
CategoryListModel(
mangaCount = covers.size,
covers = covers.take(3),
category = category,
isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources,
)
}
return result
}
private fun observeAllCategories(): Flow<Pair<Int, List<Cover>>> {
return settings.observeAsFlow(AppSettings.KEY_FAVORITES_ORDER) {
allFavoritesSortOrder
}.mapLatest { order ->
repository.getAllFavoritesCovers(order, limit = 3)
}.combine(repository.observeMangaCount()) { covers, count ->
count to covers
}
} }
} }

View File

@@ -0,0 +1,15 @@
package org.koitharu.kotatsu.favourites.ui.categories.adapter
import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.list.ui.model.ListModel
data class AllCategoriesListModel(
val mangaCount: Int,
val covers: List<Cover>,
val isVisible: Boolean,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is AllCategoriesListModel
}
}

View File

@@ -19,6 +19,7 @@ class CategoriesAdapter(
init { init {
addDelegate(ListItemType.CATEGORY_LARGE, categoryAD(coil, lifecycleOwner, onItemClickListener)) addDelegate(ListItemType.CATEGORY_LARGE, categoryAD(coil, lifecycleOwner, onItemClickListener))
addDelegate(ListItemType.NAV_ITEM, allCategoriesAD(coil, lifecycleOwner, onItemClickListener))
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listListener)) addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listListener))
addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
} }

View File

@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemCategoriesAllBinding
import org.koitharu.kotatsu.databinding.ItemCategoryBinding import org.koitharu.kotatsu.databinding.ItemCategoryBinding
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -92,3 +93,68 @@ fun categoryAD(
} }
} }
} }
fun allCategoriesAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: FavouriteCategoriesListListener,
) = adapterDelegateViewBinding<AllCategoriesListModel, ListModel, ItemCategoriesAllBinding>(
{ inflater, parent -> ItemCategoriesAllBinding.inflate(inflater, parent, false) },
) {
val eventListener = OnClickListener { v ->
if (v.id == R.id.imageView_visible) {
clickListener.onShowAllClick(!item.isVisible)
} else {
clickListener.onItemClick(null, v)
}
}
val backgroundColor = context.getThemeColor(android.R.attr.colorBackground)
ImageViewCompat.setImageTintList(
binding.imageViewCover3,
ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153)),
)
ImageViewCompat.setImageTintList(
binding.imageViewCover2,
ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 76)),
)
binding.imageViewCover2.backgroundTintList =
ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 76))
binding.imageViewCover3.backgroundTintList =
ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153))
val fallback = ColorDrawable(Color.TRANSPARENT)
val coverViews = arrayOf(binding.imageViewCover1, binding.imageViewCover2, binding.imageViewCover3)
val crossFadeDuration = context.getAnimationDuration(R.integer.config_defaultAnimTime).toInt()
itemView.setOnClickListener(eventListener)
binding.imageViewVisible.setOnClickListener(eventListener)
bind {
binding.textViewSubtitle.text = if (item.mangaCount == 0) {
getString(R.string.empty)
} else {
context.resources.getQuantityString(
R.plurals.items,
item.mangaCount,
item.mangaCount,
)
}
binding.imageViewVisible.setImageResource(
if (item.isVisible) {
R.drawable.ic_eye
} else {
R.drawable.ic_eye_off
},
)
repeat(coverViews.size) { i ->
val cover = item.covers.getOrNull(i)
coverViews[i].newImageRequest(lifecycleOwner, cover?.url)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(fallback)
source(cover?.mangaSource)
crossfade(crossFadeDuration * (i + 1))
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
enqueueWith(coil)
}
}
}
}

View File

@@ -0,0 +1,111 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/list_selector"
android:minHeight="98dp"
android:paddingStart="?android:listPreferredItemPaddingStart"
tools:ignore="RtlSymmetry">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_cover3"
android:layout_width="0dp"
android:layout_height="64dp"
android:layout_marginStart="24dp"
android:layout_marginBottom="12dp"
android:background="?attr/colorSecondaryContainer"
android:backgroundTintMode="src_atop"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="W,13:18"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
app:tintMode="src_atop"
tools:backgroundTint="#99FFFFFF"
tools:src="@tools:sample/backgrounds/scenic"
tools:tint="#99FFFFFF" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_cover2"
android:layout_width="0dp"
android:layout_height="64dp"
android:layout_marginStart="12dp"
android:background="?attr/colorSecondaryContainer"
android:backgroundTintMode="src_atop"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="W,13:18"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
app:tintMode="src_atop"
tools:backgroundTint="#4DFFFFFF"
tools:src="@tools:sample/backgrounds/scenic"
tools:tint="#4DFFFFFF" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_cover1"
android:layout_width="0dp"
android:layout_height="64dp"
android:layout_marginTop="12dp"
android:background="?attr/colorSecondaryContainer"
android:backgroundTintMode="src_atop"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="W,13:18"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
tools:src="@tools:sample/backgrounds/scenic" />
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_normal"
android:layout_marginEnd="?listPreferredItemPaddingEnd"
android:ellipsize="end"
android:singleLine="true"
android:text="@string/all_favourites"
android:textAppearance="?attr/textAppearanceBodyLarge"
app:layout_constraintBottom_toTopOf="@id/textView_subtitle"
app:layout_constraintEnd_toStartOf="@id/imageView_visible"
app:layout_constraintStart_toEndOf="@id/imageView_cover3"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/textView_subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_normal"
android:layout_marginTop="4dp"
android:layout_marginEnd="8dp"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodySmall"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/imageView_visible"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@id/imageView_cover3"
app:layout_constraintTop_toBottomOf="@id/textView_title"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintWidth_default="wrap"
tools:text="@tools:sample/lorem[1]" />
<ImageView
android:id="@+id/imageView_visible"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/show_all"
android:padding="@dimen/margin_normal"
android:src="@drawable/ic_eye"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>