From 28670bc7fbf2143a9129b25fec0736d1aafb0abe Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 15 Aug 2024 16:14:24 +0300 Subject: [PATCH] Improve favorites dialog --- .../koitharu/kotatsu/core/util/ext/String.kt | 24 ++++ .../kotatsu/details/ui/DetailsActivity.kt | 19 +-- .../kotatsu/favourites/data/FavouritesDao.kt | 4 + .../favourites/domain/FavouritesRepository.kt | 5 + .../ui/categories/select/FavoriteSheet.kt | 7 +- .../select/FavoriteSheetViewModel.kt | 68 +++++---- .../select/adapter/CategoriesHeaderAD.kt | 52 ++++++- .../select/adapter/MangaCategoriesAdapter.kt | 6 +- .../select/adapter/MangaCategoryAD.kt | 2 + .../select/model/CategoriesHeaderItem.kt | 6 +- .../select/model/MangaCategoryItem.kt | 1 + .../res/layout/item_categories_header.xml | 131 +++++++++++++++--- 12 files changed, 258 insertions(+), 67 deletions(-) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt index 5fe420064..92d77b6a1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt @@ -1,6 +1,9 @@ package org.koitharu.kotatsu.core.util.ext +import android.content.Context import androidx.annotation.FloatRange +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.parsers.util.ellipsize import org.koitharu.kotatsu.parsers.util.levenshteinDistance import java.util.UUID @@ -40,3 +43,24 @@ fun CharSequence.sanitize(): CharSequence { } fun Char.isReplacement() = this in '\uFFF0'..'\uFFFF' + +fun Collection.joinToStringWithLimit(context: Context, limit: Int, transform: ((T) -> String)): String { + if (size == 1) { + return transform(first()).ellipsize(limit) + } + return buildString(limit + 6) { + for ((i, item) in this@joinToStringWithLimit.withIndex()) { + val str = transform(item) + when { + i == 0 -> append(str.ellipsize(limit - 4)) + length + str.length > limit -> { + append(", ") + append(context.getString(R.string.list_ellipsize_pattern, this@joinToStringWithLimit.size - i)) + break + } + + else -> append(", ").append(str) + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index 77b087326..482cb5422 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -68,6 +68,7 @@ import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.isTextTruncated +import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.parentView @@ -356,23 +357,7 @@ class DetailsActivity : chip.text = if (categories.isEmpty()) { getString(R.string.add_to_favourites) } else { - if (categories.size == 1) { - categories.first().title.ellipsize(FAV_LABEL_LIMIT) - } - buildString(FAV_LABEL_LIMIT + 6) { - for ((i, cat) in categories.withIndex()) { - if (i == 0) { - append(cat.title.ellipsize(FAV_LABEL_LIMIT - 4)) - } else if (length + cat.title.length > FAV_LABEL_LIMIT) { - append(", ") - append(getString(R.string.list_ellipsize_pattern, categories.size - i)) - break - } else { - append(", ") - append(cat.title) - } - } - } + categories.joinToStringWithLimit(this, FAV_LABEL_LIMIT) { it.title } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt index 136c8195f..3e1eeeda5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt @@ -128,9 +128,13 @@ abstract class FavouritesDao { @Query("SELECT favourite_categories.* FROM favourites LEFT JOIN favourite_categories ON favourite_categories.category_id = favourites.category_id WHERE favourites.manga_id = :mangaId AND favourites.deleted_at = 0") abstract fun observeCategories(mangaId: Long): Flow> + @Deprecated("") @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0 ORDER BY favourites.created_at ASC") abstract suspend fun findCategoriesIds(mangaIds: Collection): List + @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0 ORDER BY favourites.created_at ASC") + abstract suspend fun findCategoriesIds(mangaId: Long): List + @Query("SELECT COUNT(category_id) FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0") abstract suspend fun findCategoriesCount(mangaId: Long): Int diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt index 03f0d2feb..12d3abd0e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt @@ -125,10 +125,15 @@ class FavouritesRepository @Inject constructor( return db.getFavouritesDao().findCategoriesCount(mangaId) != 0 } + @Deprecated("") suspend fun getCategoriesIds(mangaIds: Collection): Set { return db.getFavouritesDao().findCategoriesIds(mangaIds).toSet() } + suspend fun getCategoriesIds(mangaId: Long): Set { + return db.getFavouritesDao().findCategoriesIds(mangaId).toSet() + } + suspend fun createCategory( title: String, sortOrder: ListSortOrder, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheet.kt index 52e1762c4..a05651ab8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheet.kt @@ -7,6 +7,7 @@ import android.view.ViewGroup import android.widget.Toast import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels +import coil.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener @@ -20,12 +21,16 @@ import org.koitharu.kotatsu.databinding.SheetFavoriteCategoriesBinding import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem import org.koitharu.kotatsu.parsers.model.Manga +import javax.inject.Inject @AndroidEntryPoint class FavoriteSheet : BaseAdaptiveSheet(), OnListItemClickListener { private val viewModel by viewModels() + @Inject + lateinit var coil: ImageLoader + override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, @@ -36,7 +41,7 @@ class FavoriteSheet : BaseAdaptiveSheet(), OnLis savedInstanceState: Bundle?, ) { super.onViewBindingCreated(binding, savedInstanceState) - val adapter = MangaCategoriesAdapter(this) + val adapter = MangaCategoriesAdapter(coil, viewLifecycleOwner, this) binding.recyclerViewCategories.adapter = adapter viewModel.content.observe(viewLifecycleOwner, adapter) viewModel.onError.observeEvent(viewLifecycleOwner, ::onError) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheetViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheetViewModel.kt index 2a9c85daf..472e9b863 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheetViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheetViewModel.kt @@ -1,5 +1,7 @@ package org.koitharu.kotatsu.favourites.ui.categories.select +import androidx.collection.MutableLongObjectMap +import androidx.collection.MutableLongSet import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -7,19 +9,20 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus +import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga 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.util.ext.firstNotNull import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.favourites.domain.FavouritesRepository +import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem +import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.util.mapToSet import javax.inject.Inject @@ -33,41 +36,52 @@ class FavoriteSheetViewModel @Inject constructor( private val manga = savedStateHandle.require>(FavoriteSheet.KEY_MANGA_LIST).mapToSet { it.manga } - private val header = CategoriesHeaderItem() - private val checkedCategories = MutableStateFlow?>(null) + private val header = CategoriesHeaderItem( + titles = manga.map { it.title }, + covers = manga.take(3).map { + Cover( + url = it.coverUrl, + source = it.source.name, + ) + }, + ) + private val refreshTrigger = MutableStateFlow(Any()) val content = combine( favouritesRepository.observeCategories(), - checkedCategories.filterNotNull(), + refreshTrigger, settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled }, - ) { categories, checked, tracker -> - buildList(categories.size + 1) { + ) { categories, _, tracker -> + mapList(categories, tracker) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(header)) + + fun setChecked(categoryId: Long, isChecked: Boolean) { + launchJob(Dispatchers.Default) { + if (isChecked) { + favouritesRepository.addToCategory(categoryId, manga) + } else { + favouritesRepository.removeFromCategory(categoryId, manga.ids()) + } + refreshTrigger.value = Any() + } + } + + private suspend fun mapList(categories: List, tracker: Boolean): List { + val cats = MutableLongObjectMap(categories.size) + categories.forEach { cats[it.id] = MutableLongSet(manga.size) } + for (m in manga) { + val ids = favouritesRepository.getCategoriesIds(m.id) + ids.forEach { id -> cats[id]?.add(m.id) } + } + return buildList(categories.size + 1) { add(header) categories.mapTo(this) { cat -> MangaCategoryItem( category = cat, - isChecked = cat.id in checked, + isChecked = cats[cat.id]?.isNotEmpty() == true, isTrackerEnabled = tracker, + isEnabled = cats[cat.id]?.let { it.size == 0 || it.size == manga.size } == true, ) } } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(header)) - - init { - launchJob(Dispatchers.Default) { - checkedCategories.value = favouritesRepository.getCategoriesIds(manga.ids()) - } - } - - fun setChecked(categoryId: Long, isChecked: Boolean) { - launchJob(Dispatchers.Default) { - val checkedIds = checkedCategories.firstNotNull() - if (isChecked) { - checkedCategories.value = checkedIds + categoryId - favouritesRepository.addToCategory(categoryId, manga) - } else { - checkedCategories.value = checkedIds - categoryId - favouritesRepository.removeFromCategory(categoryId, manga.ids()) - } - } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt index c0ad9b772..60872f802 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt @@ -1,16 +1,32 @@ package org.koitharu.kotatsu.favourites.ui.categories.select.adapter import android.content.Intent +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.ColorDrawable import android.view.View +import androidx.core.graphics.ColorUtils +import androidx.core.widget.ImageViewCompat +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.getAnimationDuration +import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit +import org.koitharu.kotatsu.core.util.ext.newImageRequest +import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemCategoriesHeaderBinding import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem import org.koitharu.kotatsu.list.ui.model.ListModel -fun categoriesHeaderAD() = adapterDelegateViewBinding( +fun categoriesHeaderAD( + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, +) = adapterDelegateViewBinding( { inflater, parent -> ItemCategoriesHeaderBinding.inflate(inflater, parent, false) }, ) { @@ -25,4 +41,38 @@ fun categoriesHeaderAD() = adapterDelegateViewBinding + 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) + } + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt index e8ab695a4..dcdc92844 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt @@ -1,16 +1,20 @@ package org.koitharu.kotatsu.favourites.ui.categories.select.adapter +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem import org.koitharu.kotatsu.list.ui.model.ListModel class MangaCategoriesAdapter( + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, clickListener: OnListItemClickListener, ) : BaseListAdapter() { init { delegatesManager.addDelegate(mangaCategoryAD(clickListener)) - .addDelegate(categoriesHeaderAD()) + .addDelegate(categoriesHeaderAD(coil, lifecycleOwner)) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt index bcc6e2db7..57c0f051a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt @@ -21,6 +21,8 @@ fun mangaCategoryAD( } bind { payloads -> + binding.root.isEnabled = item.isEnabled + binding.checkableImageView.isEnabled = item.isEnabled binding.checkableImageView.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads) binding.textViewTitle.text = item.category.title binding.imageViewTracker.isVisible = item.category.isTrackingEnabled && item.isTrackerEnabled diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/CategoriesHeaderItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/CategoriesHeaderItem.kt index 8f7f34bb0..7c294476d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/CategoriesHeaderItem.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/CategoriesHeaderItem.kt @@ -1,8 +1,12 @@ package org.koitharu.kotatsu.favourites.ui.categories.select.model +import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.list.ui.model.ListModel -class CategoriesHeaderItem : ListModel { +data class CategoriesHeaderItem( + val titles: List, + val covers: List, +) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { return other is CategoriesHeaderItem diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt index d5b09a9ca..0b76176a9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt @@ -7,6 +7,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel data class MangaCategoryItem( val category: FavouriteCategory, val isChecked: Boolean, + val isEnabled: Boolean, val isTrackerEnabled: Boolean, ) : ListModel { diff --git a/app/src/main/res/layout/item_categories_header.xml b/app/src/main/res/layout/item_categories_header.xml index 7f99680a7..dc176e98a 100644 --- a/app/src/main/res/layout/item_categories_header.xml +++ b/app/src/main/res/layout/item_categories_header.xml @@ -1,34 +1,127 @@ - + android:layout_height="wrap_content"> - + + + + + + + + + + + + + android:scrollbars="none" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/guideline"> - + app:singleLine="true"> - + - + - + + + + +