diff --git a/.gitignore b/.gitignore index 56cee6345..621f3e800 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ /.idea/navEditor.xml /.idea/assetWizardSettings.xml /.idea/kotlinScripting.xml +/.idea/kotlinc.xml /.idea/deploymentTargetDropDown.xml /.idea/androidTestResultsUserPreferences.xml /.idea/render.experimental.xml diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml deleted file mode 100644 index 22dcb880f..000000000 --- a/.idea/kotlinc.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index ad55f77f0..1b8ed2bc9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -90,7 +90,7 @@ dependencies { exclude group: 'org.json', module: 'json' } - implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.0' + implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.10' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' implementation "androidx.appcompat:appcompat:1.6.0" 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 e6af87f8c..5ade1ae07 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) @@ -377,6 +381,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/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt index 0cffa3422..32b84b4ca 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorBottomSheet.kt @@ -91,6 +91,12 @@ class ScrobblingSelectorBottomSheet : viewModel.onClose.observe(viewLifecycleOwner) { dismiss() } + viewModel.selectedScrobblerIndex.observe(viewLifecycleOwner) { index -> + val tab = binding.tabs.getTabAt(index) + if (tab != null && !tab.isSelected) { + tab.select() + } + } viewModel.searchQuery.observe(viewLifecycleOwner) { binding.headerBar.subtitle = it } @@ -106,14 +112,16 @@ class ScrobblingSelectorBottomSheet : viewModel.selectedItemId.value = item.id } - override fun onRetryClick(error: Throwable) = Unit + override fun onRetryClick(error: Throwable) { + viewModel.retry() + } override fun onEmptyActionClick() { openSearch() } override fun onScrolledToEnd() { - viewModel.loadList(append = true) + viewModel.loadNextPage() } override fun onMenuItemActionExpand(item: MenuItem): Boolean { diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt index cb926248f..6c8a6ada1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/ScrobblingSelectorViewModel.kt @@ -11,18 +11,19 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseViewModel -import org.koitharu.kotatsu.list.ui.model.EmptyHint import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.scrobbling.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga +import org.koitharu.kotatsu.scrobbling.ui.selector.model.ScrobblerHint import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct +import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.requireValue class ScrobblingSelectorViewModel @AssistedInject constructor( @@ -34,8 +35,9 @@ class ScrobblingSelectorViewModel @AssistedInject constructor( val selectedScrobblerIndex = MutableLiveData(0) - private val scrobblerMangaList = MutableStateFlow?>(null) - private val hasNextPage = MutableStateFlow(false) + private val scrobblerMangaList = MutableStateFlow>(emptyList()) + private val hasNextPage = MutableStateFlow(true) + private val listError = MutableStateFlow(null) private var loadingJob: Job? = null private var doneJob: Job? = null private var initJob: Job? = null @@ -44,13 +46,24 @@ class ScrobblingSelectorViewModel @AssistedInject constructor( get() = availableScrobblers[selectedScrobblerIndex.requireValue()] val content: LiveData> = combine( - scrobblerMangaList.filterNotNull(), + scrobblerMangaList, + listError, hasNextPage, - ) { list, isHasNextPage -> - when { - list.isEmpty() -> listOf(emptyResultsHint()) - isHasNextPage -> list + LoadingFooter - else -> list + ) { list, error, isHasNextPage -> + if (list.isNotEmpty()) { + if (isHasNextPage) { + list + LoadingFooter + } else { + list + } + } else { + listOf( + when { + error != null -> errorHint(error) + isHasNextPage -> LoadingFooter + else -> emptyResultsHint() + }, + ) } }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) @@ -59,7 +72,7 @@ class ScrobblingSelectorViewModel @AssistedInject constructor( val onClose = SingleLiveEvent() val isEmpty: Boolean - get() = scrobblerMangaList.value.isNullOrEmpty() + get() = scrobblerMangaList.value.isEmpty() init { initialize() @@ -71,22 +84,39 @@ class ScrobblingSelectorViewModel @AssistedInject constructor( loadList(append = false) } - fun loadList(append: Boolean) { + fun loadNextPage() { + if (scrobblerMangaList.value.isNotEmpty() && hasNextPage.value) { + loadList(append = true) + } + } + + fun retry() { + loadingJob?.cancel() + hasNextPage.value = true + scrobblerMangaList.value = emptyList() + loadList(append = false) + } + + private fun loadList(append: Boolean) { if (loadingJob?.isActive == true) { return } - if (append && !hasNextPage.value) { - return - } loadingJob = launchLoadingJob(Dispatchers.Default) { - val offset = if (append) scrobblerMangaList.value?.size ?: 0 else 0 - val list = currentScrobbler.findManga(checkNotNull(searchQuery.value), offset) - if (!append) { - scrobblerMangaList.value = list - } else if (list.isNotEmpty()) { - scrobblerMangaList.value = scrobblerMangaList.value?.plus(list) ?: list + listError.value = null + val offset = if (append) scrobblerMangaList.value.size else 0 + runCatchingCancellable { + currentScrobbler.findManga(checkNotNull(searchQuery.value), offset) + }.onSuccess { list -> + if (!append) { + scrobblerMangaList.value = list + } else if (list.isNotEmpty()) { + scrobblerMangaList.value = scrobblerMangaList.value + list + } + hasNextPage.value = list.isNotEmpty() + }.onFailure { error -> + error.printStackTraceDebug() + listError.value = error } - hasNextPage.value = list.isNotEmpty() } } @@ -113,8 +143,8 @@ class ScrobblingSelectorViewModel @AssistedInject constructor( private fun initialize() { initJob?.cancel() loadingJob?.cancel() - hasNextPage.value = false - scrobblerMangaList.value = null + hasNextPage.value = true + scrobblerMangaList.value = emptyList() initJob = launchJob(Dispatchers.Default) { try { val info = currentScrobbler.getScrobblingInfoOrNull(manga.id) @@ -127,13 +157,22 @@ class ScrobblingSelectorViewModel @AssistedInject constructor( } } - private fun emptyResultsHint() = EmptyHint( + private fun emptyResultsHint() = ScrobblerHint( icon = R.drawable.ic_empty_history, textPrimary = R.string.nothing_found, textSecondary = R.string.text_search_holder_secondary, + error = null, actionStringRes = R.string.search, ) + private fun errorHint(e: Throwable) = ScrobblerHint( + icon = R.drawable.ic_error_large, + textPrimary = R.string.error_occurred, + error = e, + textSecondary = 0, + actionStringRes = R.string.try_again, + ) + @AssistedFactory interface Factory { diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ScrobblerHintAD.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ScrobblerHintAD.kt new file mode 100644 index 000000000..311615826 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ScrobblerHintAD.kt @@ -0,0 +1,37 @@ +package org.koitharu.kotatsu.scrobbling.ui.selector.adapter + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.databinding.ItemEmptyHintBinding +import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.scrobbling.ui.selector.model.ScrobblerHint +import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.setTextAndVisible +import org.koitharu.kotatsu.utils.ext.textAndVisible + +fun scrobblerHintAD( + listener: ListStateHolderListener, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemEmptyHintBinding.inflate(inflater, parent, false) }, +) { + + binding.buttonRetry.setOnClickListener { + val e = item.error + if (e != null) { + listener.onRetryClick(e) + } else { + listener.onEmptyActionClick() + } + } + + bind { + binding.icon.setImageResource(item.icon) + binding.textPrimary.setText(item.textPrimary) + if (item.error != null) { + binding.textSecondary.textAndVisible = item.error?.getDisplayMessage(context.resources) + } else { + binding.textSecondary.setTextAndVisible(item.textSecondary) + } + binding.buttonRetry.setTextAndVisible(item.actionStringRes) + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ScrobblerSelectorAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ScrobblerSelectorAdapter.kt index cf1bd5aeb..d0f1d78b0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ScrobblerSelectorAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/adapter/ScrobblerSelectorAdapter.kt @@ -6,11 +6,11 @@ import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener -import org.koitharu.kotatsu.list.ui.adapter.emptyHintAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga +import org.koitharu.kotatsu.scrobbling.ui.selector.model.ScrobblerHint import kotlin.jvm.internal.Intrinsics class ScrobblerSelectorAdapter( @@ -24,7 +24,7 @@ class ScrobblerSelectorAdapter( delegatesManager.addDelegate(loadingStateAD()) .addDelegate(scrobblingMangaAD(lifecycleOwner, coil, clickListener)) .addDelegate(loadingFooterAD()) - .addDelegate(emptyHintAD(stateHolderListener)) + .addDelegate(scrobblerHintAD(stateHolderListener)) } private class DiffCallback : DiffUtil.ItemCallback() { @@ -33,6 +33,7 @@ class ScrobblerSelectorAdapter( return when { oldItem === newItem -> true oldItem is ScrobblerManga && newItem is ScrobblerManga -> oldItem.id == newItem.id + oldItem is ScrobblerHint && newItem is ScrobblerHint -> oldItem.textPrimary == newItem.textPrimary else -> false } } diff --git a/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/model/ScrobblerHint.kt b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/model/ScrobblerHint.kt new file mode 100644 index 000000000..e30614da2 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/scrobbling/ui/selector/model/ScrobblerHint.kt @@ -0,0 +1,38 @@ +package org.koitharu.kotatsu.scrobbling.ui.selector.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import org.koitharu.kotatsu.list.ui.model.ListModel + +class ScrobblerHint( + @DrawableRes val icon: Int, + @StringRes val textPrimary: Int, + @StringRes val textSecondary: Int, + val error: Throwable?, + @StringRes val actionStringRes: Int, +) : ListModel { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ScrobblerHint + + if (icon != other.icon) return false + if (textPrimary != other.textPrimary) return false + if (textSecondary != other.textSecondary) return false + if (error != other.error) return false + if (actionStringRes != other.actionStringRes) return false + + return true + } + + override fun hashCode(): Int { + var result = icon + result = 31 * result + textPrimary + result = 31 * result + textSecondary + result = 31 * result + (error?.hashCode() ?: 0) + result = 31 * result + actionStringRes + 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_empty_hint.xml b/app/src/main/res/layout/item_empty_hint.xml new file mode 100644 index 000000000..eb3fe5f7d --- /dev/null +++ b/app/src/main/res/layout/item_empty_hint.xml @@ -0,0 +1,55 @@ + + + + + + + + + +