diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/SpanSizeResolver.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/SpanSizeResolver.kt new file mode 100644 index 000000000..71e5dc398 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/SpanSizeResolver.kt @@ -0,0 +1,55 @@ +package org.koitharu.kotatsu.base.ui.util + +import android.view.View +import androidx.annotation.Px +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import org.koitharu.kotatsu.parsers.util.toIntUp +import kotlin.math.abs + +class SpanSizeResolver( + private val recyclerView: RecyclerView, + @Px private val minItemWidth: Int, +) : View.OnLayoutChangeListener { + + fun attach() { + recyclerView.addOnLayoutChangeListener(this) + } + + fun detach() { + recyclerView.removeOnLayoutChangeListener(this) + } + + override fun onLayoutChange( + v: View?, + left: Int, + top: Int, + right: Int, + bottom: Int, + oldLeft: Int, + oldTop: Int, + oldRight: Int, + oldBottom: Int, + ) { + invalidateInternal(abs(right - left)) + } + + fun invalidate() { + invalidateInternal(recyclerView.width) + } + + private fun invalidateInternal(width: Int) { + if (width <= 0) { + return + } + val lm = recyclerView.layoutManager as? GridLayoutManager ?: return + val estimatedCount = (width / minItemWidth.toFloat()).toIntUp() + if (lm.spanCount != estimatedCount) { + lm.spanCount = estimatedCount + lm.spanSizeLookup?.run { + invalidateSpanGroupIndexCache() + invalidateSpanIndexCache() + } + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 014107460..9b5929c31 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -205,6 +205,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { sourcesOrder = (sourcesOrder + sources.map { it.name }).distinct() } + var isSourcesGridMode: Boolean + get() = prefs.getBoolean(KEY_SOURCES_GRID, false) + set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) } + val isPagesNumbersEnabled: Boolean get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false) @@ -376,6 +380,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_APP_LOCALE = "app_locale" const val KEY_LOGGING_ENABLED = "logging" const val KEY_LOGS_SHARE = "logs_share" + const val KEY_SOURCES_GRID = "sources_grid" // About const val KEY_APP_UPDATE = "app_update" diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt index a8e46ccc2..606ae9d84 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettingsObserver.kt @@ -1,8 +1,13 @@ package org.koitharu.kotatsu.core.prefs import androidx.lifecycle.liveData -import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transform +import kotlin.coroutines.CoroutineContext fun AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow { var lastValue: T = valueProducer() @@ -33,3 +38,13 @@ fun AppSettings.observeAsLiveData( } } } + +fun AppSettings.observeAsStateFlow( + key: String, + scope: CoroutineScope, + valueProducer: AppSettings.() -> T, +): StateFlow = observe().transform { + if (it == key) { + emit(valueProducer()) + } +}.stateIn(scope, SharingStarted.Eagerly, valueProducer()) diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt index 98086832c..ac681432b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt @@ -9,6 +9,8 @@ import androidx.appcompat.widget.PopupMenu import androidx.core.graphics.Insets import androidx.core.view.updatePadding import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import coil.ImageLoader import com.google.android.material.snackbar.Snackbar @@ -19,6 +21,7 @@ import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.base.ui.util.ReversibleAction +import org.koitharu.kotatsu.base.ui.util.SpanSizeResolver import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity import org.koitharu.kotatsu.databinding.FragmentExploreBinding import org.koitharu.kotatsu.details.ui.DetailsActivity @@ -63,6 +66,7 @@ class ExploreFragment : with(binding.recyclerView) { adapter = exploreAdapter setHasFixedSize(true) + SpanSizeResolver(this, resources.getDimensionPixelSize(R.dimen.explore_grid_width)).attach() val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing) paddingHorizontal = spacing } @@ -72,6 +76,7 @@ class ExploreFragment : viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga) viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone) + viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged) } override fun onDestroyView() { @@ -149,6 +154,16 @@ class ExploreFragment : snackbar.show() } + private fun onGridModeChanged(isGrid: Boolean) { + binding.recyclerView.layoutManager = if (isGrid) { + GridLayoutManager(requireContext(), 4).also { lm -> + lm.spanSizeLookup = ExploreGridSpanSizeLookup(checkNotNull(exploreAdapter), lm) + } + } else { + LinearLayoutManager(requireContext()) + } + } + private inner class SourceMenuListener( private val sourceItem: ExploreItem.Source, ) : PopupMenu.OnMenuItemClickListener { diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreGridSpanSizeLookup.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreGridSpanSizeLookup.kt new file mode 100644 index 000000000..bb02ff6f9 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreGridSpanSizeLookup.kt @@ -0,0 +1,16 @@ +package org.koitharu.kotatsu.explore.ui + +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup +import org.koitharu.kotatsu.explore.ui.adapter.ExploreAdapter + +class ExploreGridSpanSizeLookup( + private val adapter: ExploreAdapter, + private val layoutManager: GridLayoutManager, +) : SpanSizeLookup() { + + override fun getSpanSize(position: Int): Int { + val itemType = adapter.getItemViewType(position) + return if (itemType == ExploreAdapter.ITEM_TYPE_SOURCE_GRID) 1 else layoutManager.spanCount + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt index 7783ebac0..5ab8c8c27 100644 --- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt @@ -5,22 +5,26 @@ import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.domain.ReversibleHandle import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.ui.model.ExploreItem import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.utils.SingleLiveEvent +import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import javax.inject.Inject @@ -30,8 +34,15 @@ class ExploreViewModel @Inject constructor( private val exploreRepository: ExploreRepository, ) : BaseViewModel() { + private val gridMode = settings.observeAsStateFlow( + key = AppSettings.KEY_SOURCES_GRID, + scope = viewModelScope + Dispatchers.IO, + valueProducer = { isSourcesGridMode }, + ) + val onOpenManga = SingleLiveEvent() val onActionDone = SingleLiveEvent() + val isGrid = gridMode.asFlowLiveData(viewModelScope.coroutineContext) val content: LiveData> = isLoading.asFlow().flatMapLatest { loading -> if (loading) { @@ -67,16 +78,16 @@ class ExploreViewModel @Inject constructor( .onStart { emit("") } .map { settings.getMangaSources(includeHidden = false) } .distinctUntilChanged() - .map { buildList(it) } + .combine(gridMode) { content, grid -> buildList(content, grid) } - private fun buildList(sources: List): List { + private fun buildList(sources: List, isGrid: Boolean): List { val result = ArrayList(sources.size + 3) result += ExploreItem.Buttons( isSuggestionsEnabled = settings.isSuggestionsEnabled, ) result += ExploreItem.Header(R.string.remote_sources, sources.isNotEmpty()) if (sources.isNotEmpty()) { - sources.mapTo(result) { ExploreItem.Source(it) } + sources.mapTo(result) { ExploreItem.Source(it, isGrid) } } else { result += ExploreItem.EmptyHint( icon = R.drawable.ic_empty_common, diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt index 10a40c900..51f5ccc13 100644 --- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt @@ -11,11 +11,25 @@ class ExploreAdapter( lifecycleOwner: LifecycleOwner, listener: ExploreListEventListener, clickListener: OnListItemClickListener, -) : AsyncListDifferDelegationAdapter( - ExploreDiffCallback(), - exploreButtonsAD(listener), - exploreSourcesHeaderAD(listener), - exploreSourceItemAD(coil, clickListener, lifecycleOwner), - exploreEmptyHintListAD(listener), - exploreLoadingAD(), -) \ No newline at end of file +) : AsyncListDifferDelegationAdapter(ExploreDiffCallback()) { + + init { + delegatesManager + .addDelegate(ITEM_TYPE_BUTTONS, exploreButtonsAD(listener)) + .addDelegate(ITEM_TYPE_HEADER, exploreSourcesHeaderAD(listener)) + .addDelegate(ITEM_TYPE_SOURCE_LIST, exploreSourceListItemAD(coil, clickListener, lifecycleOwner)) + .addDelegate(ITEM_TYPE_SOURCE_GRID, exploreSourceGridItemAD(coil, clickListener, lifecycleOwner)) + .addDelegate(ITEM_TYPE_HINT, exploreEmptyHintListAD(listener)) + .addDelegate(ITEM_TYPE_LOADING, exploreLoadingAD()) + } + + companion object { + + const val ITEM_TYPE_BUTTONS = 0 + const val ITEM_TYPE_HEADER = 1 + const val ITEM_TYPE_SOURCE_LIST = 2 + const val ITEM_TYPE_SOURCE_GRID = 3 + const val ITEM_TYPE_HINT = 4 + const val ITEM_TYPE_LOADING = 5 + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt index 6712ab46e..c1cf4f753 100644 --- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt +++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt @@ -12,7 +12,8 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding -import org.koitharu.kotatsu.databinding.ItemExploreSourceBinding +import org.koitharu.kotatsu.databinding.ItemExploreSourceGridBinding +import org.koitharu.kotatsu.databinding.ItemExploreSourceListBinding import org.koitharu.kotatsu.databinding.ItemHeaderButtonBinding import org.koitharu.kotatsu.explore.ui.model.ExploreItem import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener @@ -25,7 +26,7 @@ import org.koitharu.kotatsu.utils.image.FaviconFallbackDrawable fun exploreButtonsAD( clickListener: View.OnClickListener, ) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemExploreButtonsBinding.inflate(layoutInflater, parent, false) } + { layoutInflater, parent -> ItemExploreButtonsBinding.inflate(layoutInflater, parent, false) }, ) { binding.buttonBookmarks.setOnClickListener(clickListener) @@ -43,7 +44,7 @@ fun exploreButtonsAD( fun exploreSourcesHeaderAD( listener: ExploreListEventListener, ) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemHeaderButtonBinding.inflate(layoutInflater, parent, false) } + { layoutInflater, parent -> ItemHeaderButtonBinding.inflate(layoutInflater, parent, false) }, ) { val listenerAdapter = View.OnClickListener { @@ -58,13 +59,44 @@ fun exploreSourcesHeaderAD( } } -fun exploreSourceItemAD( +fun exploreSourceListItemAD( coil: ImageLoader, listener: OnListItemClickListener, lifecycleOwner: LifecycleOwner, -) = adapterDelegateViewBinding( - { layoutInflater, parent -> ItemExploreSourceBinding.inflate(layoutInflater, parent, false) }, - on = { item, _, _ -> item is ExploreItem.Source } +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemExploreSourceListBinding.inflate(layoutInflater, parent, false) }, + on = { item, _, _ -> item is ExploreItem.Source && !item.isGrid }, +) { + + val eventListener = AdapterDelegateClickListenerAdapter(this, listener) + + binding.root.setOnClickListener(eventListener) + binding.root.setOnLongClickListener(eventListener) + + bind { + binding.textViewTitle.text = item.source.title + val fallbackIcon = FaviconFallbackDrawable(context, item.source.name) + binding.imageViewIcon.newImageRequest(item.source.faviconUri())?.run { + fallback(fallbackIcon) + placeholder(fallbackIcon) + error(fallbackIcon) + lifecycle(lifecycleOwner) + enqueueWith(coil) + } + } + + onViewRecycled { + binding.imageViewIcon.disposeImageRequest() + } +} + +fun exploreSourceGridItemAD( + coil: ImageLoader, + listener: OnListItemClickListener, + lifecycleOwner: LifecycleOwner, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemExploreSourceGridBinding.inflate(layoutInflater, parent, false) }, + on = { item, _, _ -> item is ExploreItem.Source && item.isGrid }, ) { val eventListener = AdapterDelegateClickListenerAdapter(this, listener) @@ -92,7 +124,7 @@ fun exploreSourceItemAD( fun exploreEmptyHintListAD( listener: ListStateHolderListener, ) = adapterDelegateViewBinding( - { inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) } + { inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) }, ) { binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() } diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreDiffCallback.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreDiffCallback.kt index 352edd401..d8bf22bd9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreDiffCallback.kt +++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreDiffCallback.kt @@ -12,11 +12,13 @@ class ExploreDiffCallback : DiffUtil.ItemCallback() { oldItem is ExploreItem.Loading && newItem is ExploreItem.Loading -> true oldItem is ExploreItem.EmptyHint && newItem is ExploreItem.EmptyHint -> true oldItem is ExploreItem.Source && newItem is ExploreItem.Source -> { - oldItem.source == newItem.source + oldItem.source == newItem.source && oldItem.isGrid == newItem.isGrid } + oldItem is ExploreItem.Header && newItem is ExploreItem.Header -> { oldItem.titleResId == newItem.titleResId } + else -> false } } @@ -24,4 +26,4 @@ class ExploreDiffCallback : DiffUtil.ItemCallback() { override fun areContentsTheSame(oldItem: ExploreItem, newItem: ExploreItem): Boolean { return oldItem == newItem } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/model/ExploreItem.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/model/ExploreItem.kt index 4420a415d..5adb55563 100644 --- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/model/ExploreItem.kt +++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/model/ExploreItem.kt @@ -54,6 +54,7 @@ sealed interface ExploreItem : ListModel { class Source( val source: MangaSource, + val isGrid: Boolean, ) : ExploreItem { override fun equals(other: Any?): Boolean { @@ -63,12 +64,15 @@ sealed interface ExploreItem : ListModel { other as Source if (source != other.source) return false + if (isGrid != other.isGrid) return false return true } override fun hashCode(): Int { - return source.hashCode() + var result = source.hashCode() + result = 31 * result + isGrid.hashCode() + return result } } diff --git a/app/src/main/res/layout/fragment_explore.xml b/app/src/main/res/layout/fragment_explore.xml index dd7b22355..6267e23f6 100644 --- a/app/src/main/res/layout/fragment_explore.xml +++ b/app/src/main/res/layout/fragment_explore.xml @@ -1,7 +1,6 @@ + tools:listitem="@layout/item_explore_source_list" /> diff --git a/app/src/main/res/layout/item_explore_source_grid.xml b/app/src/main/res/layout/item_explore_source_grid.xml new file mode 100644 index 000000000..2f952ffee --- /dev/null +++ b/app/src/main/res/layout/item_explore_source_grid.xml @@ -0,0 +1,38 @@ + + + + + + + + diff --git a/app/src/main/res/layout/item_explore_source.xml b/app/src/main/res/layout/item_explore_source_list.xml similarity index 100% rename from app/src/main/res/layout/item_explore_source.xml rename to app/src/main/res/layout/item_explore_source_list.xml diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index d0d4412d9..b05f302df 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -31,6 +31,7 @@ 10dp 8dp 12dp + 120dp 124dp 4dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5ca1acb67..b70b06579 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -411,4 +411,5 @@ Dynamic Color scheme October + Show in grid view diff --git a/app/src/main/res/xml/pref_content.xml b/app/src/main/res/xml/pref_content.xml index 1c94f2674..176d8931c 100644 --- a/app/src/main/res/xml/pref_content.xml +++ b/app/src/main/res/xml/pref_content.xml @@ -9,10 +9,16 @@ android:key="remote_sources" android:title="@string/remote_sources" /> + + + android:title="@string/suggestions" + app:allowDividerAbove="true" />