From 7f5ff1ab14273afdbdc7b85d2e8ac642edfd0db8 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 8 Apr 2024 19:26:42 +0300 Subject: [PATCH] Update recommendations item in explore section --- .../kotatsu/core/ui/widgets/DotsIndicator.kt | 143 ++++++++++++++++++ .../kotatsu/explore/ui/ExploreViewModel.kt | 28 +++- .../explore/ui/adapter/ExploreAdapter.kt | 2 +- .../ui/adapter/ExploreAdapterDelegates.kt | 27 +++- .../explore/ui/model/RecommendationsItem.kt | 5 +- .../domain/SuggestionRepository.kt | 4 + .../main/res/layout/item_recommendation.xml | 74 ++------- .../item_recommendation_manga.xml} | 29 +--- app/src/main/res/values/attrs.xml | 8 + app/src/main/res/values/dimens.xml | 1 + 10 files changed, 225 insertions(+), 96 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/DotsIndicator.kt rename app/src/main/res/{layout-w600dp-land/item_recommendation.xml => layout/item_recommendation_manga.xml} (72%) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/DotsIndicator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/DotsIndicator.kt new file mode 100644 index 000000000..9a2d84701 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/DotsIndicator.kt @@ -0,0 +1,143 @@ +package org.koitharu.kotatsu.core.ui.widgets + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.util.AttributeSet +import android.view.View +import androidx.core.content.withStyledAttributes +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver +import androidx.viewpager2.widget.ViewPager2 +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.measureDimension +import org.koitharu.kotatsu.core.util.ext.resolveDp +import org.koitharu.kotatsu.parsers.util.toIntUp + +class DotsIndicator @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : View(context, attrs, defStyleAttr) { + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private var indicatorSize = context.resources.resolveDp(12f) + private var dotSpacing = 0f + private var positionOffset: Float = 0f + var max: Int = 6 + set(value) { + if (field != value) { + field = value + requestLayout() + invalidate() + } + } + var position: Int = 2 + set(value) { + if (field != value) { + field = value + invalidate() + } + } + + init { + paint.strokeWidth = context.resources.resolveDp(1.5f) + context.withStyledAttributes(attrs, R.styleable.DotsIndicator, defStyleAttr) { + paint.color = getColor( + R.styleable.DotsIndicator_dotColor, + context.getThemeColor(com.google.android.material.R.attr.colorPrimary, Color.DKGRAY), + ) + indicatorSize = getDimension(R.styleable.DotsIndicator_dotSize, indicatorSize) + dotSpacing = getDimension(R.styleable.DotsIndicator_dotSpacing, dotSpacing) + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val dotSize = getDotSize() + val y = paddingTop + (height - paddingTop - paddingBottom) / 2f + var x = paddingLeft + dotSize / 2f + val radius = dotSize / 2f - paint.strokeWidth + val spacing = (width - paddingLeft - paddingRight) / max.toFloat() - dotSize + x += spacing / 2f + paint.style = Paint.Style.STROKE + for (i in 0 until max) { + canvas.drawCircle(x, y, radius, paint) + if (i == position) { + paint.style = Paint.Style.FILL + paint.alpha = (255 * (1f - positionOffset)).toInt() + canvas.drawCircle(x, y, radius, paint) + paint.alpha = 255 + paint.style = Paint.Style.STROKE + } + if (i == position + 1 && positionOffset > 0f) { + paint.style = Paint.Style.FILL + paint.alpha = (255 * positionOffset).toInt() + canvas.drawCircle(x, y, radius, paint) + paint.alpha = 255 + paint.style = Paint.Style.STROKE + } + x += spacing + dotSize + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val dotSize = getDotSize() + val desiredHeight = (dotSize + paddingTop + paddingBottom).toIntUp() + val desiredWidth = ((dotSize + dotSpacing) * max).toIntUp() + paddingLeft + paddingRight + setMeasuredDimension( + measureDimension(desiredWidth, widthMeasureSpec), + measureDimension(desiredHeight, heightMeasureSpec), + ) + } + + fun bindToViewPager(pager: ViewPager2) { + pager.registerOnPageChangeCallback(ViewPagerCallback()) + pager.adapter?.let { + it.registerAdapterDataObserver(AdapterObserver(it)) + } + } + + private fun getDotSize() = if (indicatorSize <= 0) { + (height - paddingTop - paddingBottom).toFloat() + } else { + indicatorSize + } + + private inner class ViewPagerCallback : ViewPager2.OnPageChangeCallback() { + + override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { + super.onPageScrolled(position, positionOffset, positionOffsetPixels) + this@DotsIndicator.position = position + this@DotsIndicator.positionOffset = positionOffset + invalidate() + } + + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + this@DotsIndicator.position = position + } + } + + private inner class AdapterObserver( + private val adapter: RecyclerView.Adapter<*>, + ) : AdapterDataObserver() { + + override fun onChanged() { + super.onChanged() + max = adapter.itemCount + } + + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + super.onItemRangeInserted(positionStart, itemCount) + max = adapter.itemCount + } + + override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { + super.onItemRangeRemoved(positionStart, itemCount) + max = adapter.itemCount + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt index 98973d443..ee1c14d73 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt @@ -25,10 +25,12 @@ import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.ui.model.ExploreButtons import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem import org.koitharu.kotatsu.explore.ui.model.RecommendationsItem +import org.koitharu.kotatsu.history.data.PROGRESS_NONE import org.koitharu.kotatsu.list.ui.model.EmptyHint import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingState +import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.runCatchingCancellable @@ -120,16 +122,16 @@ class ExploreViewModel @Inject constructor( private fun buildList( sources: List, - recommendation: Manga?, + recommendation: List, isGrid: Boolean, randomLoading: Boolean, newSources: Set, ): List { val result = ArrayList(sources.size + 3) result += ExploreButtons(randomLoading) - if (recommendation != null) { - result += ListHeader(R.string.suggestions) - result += RecommendationsItem(recommendation) + if (recommendation.isNotEmpty()) { + result += ListHeader(R.string.suggestions, R.string.more) + result += RecommendationsItem(recommendation.toRecommendationList()) } if (sources.isNotEmpty()) { result += ListHeader( @@ -157,13 +159,25 @@ class ExploreViewModel @Inject constructor( private fun getSuggestionFlow() = isSuggestionsEnabled.mapLatest { isEnabled -> if (isEnabled) { runCatchingCancellable { - suggestionRepository.getRandom() - }.getOrNull() + suggestionRepository.getRandomList(8) + }.getOrDefault(emptyList()) } else { - null + emptyList() } } + private fun List.toRecommendationList() = map { manga -> + MangaListModel( + id = manga.id, + title = manga.title, + subtitle = manga.tags.joinToString { it.title }, + coverUrl = manga.coverUrl, + manga = manga, + counter = 0, + progress = PROGRESS_NONE, + ) + } + companion object { private const val TIP_SUGGESTIONS = "suggestions" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt index 83d7ee149..ad6834281 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt @@ -27,7 +27,7 @@ class ExploreAdapter( addDelegate(ListItemType.EXPLORE_BUTTONS, exploreButtonsAD(listener)) addDelegate( ListItemType.EXPLORE_SUGGESTION, - exploreRecommendationItemAD(coil, listener, mangaClickListener, lifecycleOwner), + exploreRecommendationItemAD(coil, mangaClickListener, lifecycleOwner), ) addDelegate(ListItemType.HEADER, listHeaderAD(listener)) addDelegate(ListItemType.EXPLORE_SOURCE_LIST, exploreSourceListItemAD(coil, clickListener, lifecycleOwner)) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt index 996bd17fa..de9bb75f5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt @@ -8,6 +8,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.getSummary import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.parser.favicon.faviconUri +import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.image.FaviconDrawable import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter @@ -22,10 +23,13 @@ import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding import org.koitharu.kotatsu.databinding.ItemExploreSourceGridBinding import org.koitharu.kotatsu.databinding.ItemExploreSourceListBinding import org.koitharu.kotatsu.databinding.ItemRecommendationBinding +import org.koitharu.kotatsu.databinding.ItemRecommendationMangaBinding import org.koitharu.kotatsu.explore.ui.model.ExploreButtons import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem import org.koitharu.kotatsu.explore.ui.model.RecommendationsItem +import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.parsers.model.Manga fun exploreButtonsAD( @@ -51,21 +55,37 @@ fun exploreButtonsAD( fun exploreRecommendationItemAD( coil: ImageLoader, - clickListener: View.OnClickListener, itemClickListener: OnListItemClickListener, lifecycleOwner: LifecycleOwner, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemRecommendationBinding.inflate(layoutInflater, parent, false) }, ) { - binding.buttonMore.setOnClickListener(clickListener) + val adapter = BaseListAdapter() + .addDelegate(ListItemType.MANGA_LIST, recommendationMangaItemAD(coil, itemClickListener, lifecycleOwner)) + binding.pager.adapter = adapter + binding.dots.bindToViewPager(binding.pager) + + bind { + adapter.items = item.manga + } +} + +fun recommendationMangaItemAD( + coil: ImageLoader, + itemClickListener: OnListItemClickListener, + lifecycleOwner: LifecycleOwner, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemRecommendationMangaBinding.inflate(layoutInflater, parent, false) }, +) { + binding.root.setOnClickListener { v -> itemClickListener.onItemClick(item.manga, v) } bind { binding.textViewTitle.text = item.manga.title - binding.textViewSubtitle.textAndVisible = item.summary + binding.textViewSubtitle.textAndVisible = item.subtitle binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run { placeholder(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder) @@ -78,6 +98,7 @@ fun exploreRecommendationItemAD( } } + fun exploreSourceListItemAD( coil: ImageLoader, listener: OnListItemClickListener, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/RecommendationsItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/RecommendationsItem.kt index 5d1a99ba0..347ea9bba 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/RecommendationsItem.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/model/RecommendationsItem.kt @@ -1,12 +1,11 @@ package org.koitharu.kotatsu.explore.ui.model import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.list.ui.model.MangaListModel data class RecommendationsItem( - val manga: Manga + val manga: List ) : ListModel { - val summary: String = manga.tags.joinToString { it.title } override fun areItemsTheSame(other: ListModel): Boolean { return other is RecommendationsItem diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt index 389fb96da..f62a3b7de 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt @@ -34,6 +34,10 @@ class SuggestionRepository @Inject constructor( } } + suspend fun getRandomList(limit: Int): List { + return List(limit) { getRandom() }.filterNotNull().distinct() //TODO improve + } + suspend fun clear() { db.getSuggestionDao().deleteAll() } diff --git a/app/src/main/res/layout/item_recommendation.xml b/app/src/main/res/layout/item_recommendation.xml index 08777e553..3bde7885d 100644 --- a/app/src/main/res/layout/item_recommendation.xml +++ b/app/src/main/res/layout/item_recommendation.xml @@ -1,70 +1,24 @@ - + android:orientation="vertical"> - + - + android:layout_gravity="center_horizontal" + android:layout_marginVertical="@dimen/margin_small" + app:dotSize="8dp" + app:dotSpacing="6dp" /> - - -