From 9b5510ac596f98f7544c4790c8dae3f35963e56a Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 5 Dec 2020 18:03:34 +0200 Subject: [PATCH] Move list states to adapter delegates --- .../koitharu/kotatsu/core/ui/ChipsFactory.kt | 7 +- .../koitharu/kotatsu/core/ui/DateTimeAgo.kt | 3 +- .../kotatsu/details/ui/DetailsFragment.kt | 26 +-- .../details/ui/adapter/ChaptersAdapter.kt | 4 + .../ui/list/FavouritesListFragment.kt | 11 -- .../ui/list/FavouritesListViewModel.kt | 38 ++-- .../kotatsu/history/ui/HistoryListFragment.kt | 5 - .../history/ui/HistoryListViewModel.kt | 29 +-- .../kotatsu/list/ui/MangaListFragment.kt | 47 ++--- .../kotatsu/list/ui/MangaListSheet.kt | 166 +++++++++++------- .../kotatsu/list/ui/MangaListViewModel.kt | 8 +- .../list/ui/adapter/EmptyStateListAD.kt | 14 ++ .../kotatsu/list/ui/adapter/ErrorFooterAD.kt | 23 +++ .../list/ui/adapter/ErrorStateListAD.kt | 27 +++ .../ui/adapter/IndeterminateProgressAD.kt | 8 - .../list/ui/adapter/LoadingFooterAD.kt | 9 + .../kotatsu/list/ui/adapter/LoadingStateAD.kt | 9 + .../list/ui/adapter/MangaGridItemAD.kt | 3 +- .../list/ui/adapter/MangaListAdapter.kt | 36 ++-- .../ui/adapter/MangaListDetailedItemAD.kt | 3 +- .../list/ui/adapter/MangaListItemAD.kt | 3 +- .../list/ui/adapter/RelatedDateItemAD.kt | 3 +- .../kotatsu/list/ui/model/EmptyState.kt | 7 + .../kotatsu/list/ui/model/ErrorFooter.kt | 8 + .../kotatsu/list/ui/model/ErrorState.kt | 9 + ...{IndeterminateProgress.kt => ListModel.kt} | 2 +- .../list/ui/model/ListModelConversionExt.kt | 25 +++ .../kotatsu/list/ui/model/LoadingFooter.kt | 3 + .../kotatsu/list/ui/model/LoadingState.kt | 3 + .../kotatsu/list/ui/model/MangaGridModel.kt | 2 +- .../list/ui/model/MangaListDetailedModel.kt | 2 +- .../kotatsu/list/ui/model/MangaListModel.kt | 2 +- .../kotatsu/local/ui/LocalListFragment.kt | 10 -- .../kotatsu/local/ui/LocalListViewModel.kt | 45 +++-- .../kotatsu/reader/ui/ChaptersDialog.kt | 44 +++-- .../remotelist/ui/RemoteListFragment.kt | 7 +- .../remotelist/ui/RemoteListViewModel.kt | 85 ++++++--- .../kotatsu/search/ui/MangaSearchSheet.kt | 3 +- .../kotatsu/search/ui/SearchFragment.kt | 7 +- .../kotatsu/search/ui/SearchViewModel.kt | 83 ++++++--- .../search/ui/global/GlobalSearchFragment.kt | 5 - .../search/ui/global/GlobalSearchViewModel.kt | 59 ++++--- .../kotatsu/tracker/ui/FeedFragment.kt | 3 +- .../kotatsu/tracker/ui/FeedViewModel.kt | 18 +- .../kotatsu/tracker/ui/adapter/FeedAdapter.kt | 27 ++- .../kotatsu/tracker/ui/adapter/FeedItemAD.kt | 3 +- .../kotatsu/tracker/ui/model/FeedItem.kt | 3 +- .../koitharu/kotatsu/utils/ext/CommonExt.kt | 2 +- .../main/res/drawable/ic_alert_outline.xml | 12 ++ .../main/res/layout-w600dp/fragment_list.xml | 73 +++----- .../main/res/layout/fragment_cloudflare.xml | 5 +- app/src/main/res/layout/fragment_list.xml | 67 ++----- app/src/main/res/layout/item_empty_state.xml | 10 ++ app/src/main/res/layout/item_error_footer.xml | 42 +++++ app/src/main/res/layout/item_error_state.xml | 31 ++++ .../main/res/layout/item_loading_footer.xml | 16 ++ ...em_progress.xml => item_loading_state.xml} | 9 +- app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/dimens.xml | 1 + app/src/main/res/values/strings.xml | 1 + 60 files changed, 752 insertions(+), 465 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ErrorFooterAD.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ErrorStateListAD.kt delete mode 100644 app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/IndeterminateProgressAD.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/LoadingFooterAD.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/LoadingStateAD.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/list/ui/model/EmptyState.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/list/ui/model/ErrorFooter.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/list/ui/model/ErrorState.kt rename app/src/main/java/org/koitharu/kotatsu/list/ui/model/{IndeterminateProgress.kt => ListModel.kt} (61%) create mode 100644 app/src/main/java/org/koitharu/kotatsu/list/ui/model/LoadingFooter.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/list/ui/model/LoadingState.kt create mode 100644 app/src/main/res/drawable/ic_alert_outline.xml create mode 100644 app/src/main/res/layout/item_empty_state.xml create mode 100644 app/src/main/res/layout/item_error_footer.xml create mode 100644 app/src/main/res/layout/item_error_state.xml create mode 100644 app/src/main/res/layout/item_loading_footer.xml rename app/src/main/res/layout/{item_progress.xml => item_loading_state.xml} (55%) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/ChipsFactory.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/ChipsFactory.kt index a4cceb152..c7d28982b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/ui/ChipsFactory.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/ChipsFactory.kt @@ -9,8 +9,11 @@ import org.koitharu.kotatsu.utils.ext.getThemeColor class ChipsFactory(val context: Context) { fun create( - convertView: Chip? = null, text: CharSequence, @DrawableRes iconRes: Int = 0, - tag: Any? = null, onClickListener: View.OnClickListener? = null + convertView: Chip? = null, + text: CharSequence, + @DrawableRes iconRes: Int = 0, + tag: Any? = null, + onClickListener: View.OnClickListener? = null ): Chip { val chip = convertView ?: Chip(context).apply { setTextColor(context.getThemeColor(android.R.attr.textColorPrimary)) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt index d398cd1ca..8deefacfe 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt @@ -2,8 +2,9 @@ package org.koitharu.kotatsu.core.ui import android.content.res.Resources import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.list.ui.model.ListModel -sealed class DateTimeAgo { +sealed class DateTimeAgo : ListModel { abstract fun format(resources: Resources): String diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt index 057b846d1..35538c847 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt @@ -10,7 +10,6 @@ import androidx.core.text.parseAsHtml import androidx.core.view.isVisible import coil.ImageLoader import coil.util.CoilUtils -import com.google.android.material.chip.Chip import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -23,7 +22,6 @@ import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.databinding.FragmentDetailsBinding import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog import org.koitharu.kotatsu.reader.ui.ReaderActivity -import org.koitharu.kotatsu.search.ui.MangaSearchSheet import org.koitharu.kotatsu.utils.FileSizeUtils import org.koitharu.kotatsu.utils.ext.* import kotlin.math.roundToInt @@ -71,8 +69,7 @@ class DetailsFragment : BaseFragment(), View.OnClickList create( text = it, iconRes = R.drawable.ic_chip_user, - tag = it, - onClickListener = this@DetailsFragment + tag = it ) } } @@ -80,8 +77,7 @@ class DetailsFragment : BaseFragment(), View.OnClickList create( text = it.title, iconRes = R.drawable.ic_chip_tag, - tag = it, - onClickListener = this@DetailsFragment + tag = it ) } manga.url.toUri().toFileOrNull()?.let { f -> @@ -93,8 +89,7 @@ class DetailsFragment : BaseFragment(), View.OnClickList create( text = FileSizeUtils.formatBytes(context, size), iconRes = R.drawable.ic_chip_storage, - tag = it, - onClickListener = this@DetailsFragment + tag = it ) } } @@ -134,11 +129,11 @@ class DetailsFragment : BaseFragment(), View.OnClickList override fun onClick(v: View) { val manga = viewModel.manga.value - when { - v.id == R.id.imageView_favourite -> { + when (v.id) { + R.id.imageView_favourite -> { FavouriteCategoriesDialog.show(childFragmentManager, manga ?: return) } - v.id == R.id.button_read -> { + R.id.button_read -> { startActivity( ReaderActivity.newIntent( context ?: return, @@ -147,15 +142,6 @@ class DetailsFragment : BaseFragment(), View.OnClickList ) ) } - v is Chip -> { - when (val tag = v.tag) { - is String -> MangaSearchSheet.show( - activity?.supportFragmentManager - ?: childFragmentManager, - manga?.source ?: return, tag - ) - } - } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt index f032dcc23..154a2e038 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt @@ -20,6 +20,10 @@ class ChaptersAdapter( return items[position].chapter.id } + fun setItems(newItems: List, callback: Runnable) { + differ.submitList(newItems, callback) + } + private class DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: ChapterListItem, newItem: ChapterListItem): Boolean { diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt index ea19da787..a78674817 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt @@ -23,17 +23,6 @@ class FavouritesListFragment : MangaListFragment() { override fun onScrolledToEnd() = Unit - override fun setUpEmptyListHolder() { - binding.textViewHolder.setText( - if (categoryId == 0L) { - R.string.you_have_not_favourites_yet - } else { - R.string.favourites_category_empty - } - ) - binding.textViewHolder.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) - } - override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) { super.onCreatePopupMenu(inflater, menu, data) inflater.inflate(R.menu.popup_favourites, menu) diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt index e6daa5a75..9ba9ff730 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt @@ -3,17 +3,18 @@ package org.koitharu.kotatsu.favourites.ui.list import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.list.ui.MangaListViewModel -import org.koitharu.kotatsu.list.ui.model.toGridModel -import org.koitharu.kotatsu.list.ui.model.toListDetailedModel -import org.koitharu.kotatsu.list.ui.model.toListModel +import org.koitharu.kotatsu.list.ui.model.EmptyState +import org.koitharu.kotatsu.list.ui.model.LoadingState +import org.koitharu.kotatsu.list.ui.model.toErrorState +import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.utils.ext.onFirst class FavouritesListViewModel( @@ -26,19 +27,30 @@ class FavouritesListViewModel( if (categoryId == 0L) repository.observeAll() else repository.observeAll(categoryId), createListModeFlow() ) { list, mode -> - when (mode) { - ListMode.LIST -> list.map { it.toListModel() } - ListMode.DETAILED_LIST -> list.map { it.toListDetailedModel() } - ListMode.GRID -> list.map { it.toGridModel() } + when { + list.isEmpty() -> listOf( + EmptyState( + if (categoryId == 0L) { + R.string.you_have_not_favourites_yet + } else { + R.string.favourites_category_empty + } + ) + ) + else -> list.toUi(mode) } - }.onEach { - isEmptyState.postValue(it.isEmpty()) - }.onStart { - isLoading.postValue(true) }.onFirst { isLoading.postValue(false) + }.onStart { + emit(listOf(LoadingState)) + }.catch { + emit(listOf(it.toErrorState(canRetry = false))) }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) + override fun onRefresh() = Unit + + override fun onRetry() = Unit + fun removeFromFavourites(manga: Manga) { launchJob { if (categoryId == 0L) { diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt index 0e3e4fe70..76dc36ee8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt @@ -63,11 +63,6 @@ class HistoryListFragment : MangaListFragment() { return context?.getString(R.string.history) } - override fun setUpEmptyListHolder() { - binding.textViewHolder.setText(R.string.text_history_holder) - binding.textViewHolder.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) - } - override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) { super.onCreatePopupMenu(inflater, menu, data) inflater.inflate(R.menu.popup_history, menu) diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt index 13a2ad852..fa9884543 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode @@ -14,9 +15,7 @@ import org.koitharu.kotatsu.core.ui.DateTimeAgo import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.MangaWithHistory import org.koitharu.kotatsu.list.ui.MangaListViewModel -import org.koitharu.kotatsu.list.ui.model.toGridModel -import org.koitharu.kotatsu.list.ui.model.toListDetailedModel -import org.koitharu.kotatsu.list.ui.model.toListModel +import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.utils.MangaShortcut import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.daysDiff @@ -44,16 +43,24 @@ class HistoryListViewModel( override val content = combine( repository.observeAllWithHistory(), historyGrouping, - createListModeFlow(), - ::mapList - ).onEach { - isEmptyState.postValue(it.isEmpty()) - }.onStart { - isLoading.postValue(true) + createListModeFlow() + ) { list, grouped, mode -> + when { + list.isEmpty() -> listOf(EmptyState(R.string.text_history_holder)) + else -> mapList(list, grouped, mode) + } }.onFirst { isLoading.postValue(false) + }.onStart { + emit(listOf(LoadingState)) + }.catch { + it.toErrorState(canRetry = false) }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) + override fun onRefresh() = Unit + + override fun onRetry() = Unit + fun clearHistory() { launchLoadingJob { repository.clear() @@ -77,8 +84,8 @@ class HistoryListViewModel( settings.historyGrouping = isGroupingEnabled } - private fun mapList(list: List, grouped: Boolean, mode: ListMode): List { - val result = ArrayList((list.size * 1.4).toInt()) + private fun mapList(list: List, grouped: Boolean, mode: ListMode): List { + val result = ArrayList(if (grouped) (list.size * 1.4).toInt() else list.size) var prevDate: DateTimeAgo? = null for ((manga, history) in list) { if (grouped) { diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index feab4893c..c1ef8e674 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -32,9 +32,9 @@ import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter import org.koitharu.kotatsu.list.ui.filter.FilterAdapter import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener +import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.utils.ext.clearItemDecorations import org.koitharu.kotatsu.utils.ext.getDisplayMessage -import org.koitharu.kotatsu.utils.ext.hasItems import org.koitharu.kotatsu.utils.ext.toggleDrawer abstract class MangaListFragment : BaseFragment(), @@ -62,7 +62,9 @@ abstract class MangaListFragment : BaseFragment(), override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) - listAdapter = MangaListAdapter(get(), viewLifecycleOwner, this) + listAdapter = MangaListAdapter(get(), viewLifecycleOwner, this) { + viewModel.onRetry() + } paginationListener = PaginationScrollListener(4, this) with(binding.recyclerView) { setHasFixedSize(true) @@ -85,7 +87,6 @@ abstract class MangaListFragment : BaseFragment(), viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged) viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) - viewModel.isEmptyState.observe(viewLifecycleOwner, ::onEmptyStateChanged) } override fun onDestroyView() { @@ -140,9 +141,10 @@ abstract class MangaListFragment : BaseFragment(), @CallSuper override fun onRefresh() { binding.swipeRefreshLayout.isRefreshing = true + viewModel.onRefresh() } - private fun onListChanged(list: List) { + private fun onListChanged(list: List) { spanSizeLookup.invalidateCache() listAdapter?.items = list } @@ -150,45 +152,24 @@ abstract class MangaListFragment : BaseFragment(), private fun onError(e: Throwable) { if (e is CloudFlareProtectedException) { CloudFlareDialog.newInstance(e.url).show(childFragmentManager, CloudFlareDialog.TAG) - } - if (viewModel.isEmptyState.value == true) { - binding.textViewHolder.text = e.getDisplayMessage(resources) - binding.textViewHolder.setCompoundDrawablesRelativeWithIntrinsicBounds( - 0, - R.drawable.ic_error_large, - 0, - 0 - ) - binding.textViewHolder.isVisible = true } else { Snackbar.make( binding.recyclerView, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT - ) - .show() + ).show() } } @CallSuper protected open fun onLoadingStateChanged(isLoading: Boolean) { - val hasItems = binding.recyclerView.hasItems - binding.progressBar.isVisible = - isLoading && !hasItems && viewModel.isEmptyState.value != true binding.swipeRefreshLayout.isEnabled = - isSwipeRefreshEnabled && !binding.progressBar.isVisible + isSwipeRefreshEnabled && !isLoading if (!isLoading) { binding.swipeRefreshLayout.isRefreshing = false } } - private fun onEmptyStateChanged(isEmpty: Boolean) { - if (isEmpty) { - setUpEmptyListHolder() - } - binding.layoutHolder.isVisible = isEmpty - } - protected fun onInitFilter(config: MangaFilterConfig) { binding.recyclerViewFilter.adapter = FilterAdapter( sortOrders = config.sortOrders, @@ -214,13 +195,6 @@ abstract class MangaListFragment : BaseFragment(), binding.drawer?.closeDrawers() } - protected open fun setUpEmptyListHolder() { - with(binding.textViewHolder) { - setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null) - setText(R.string.nothing_found) - } - } - private fun onGridScaleChanged(scale: Float) { spanSizeLookup.invalidateCache() spanResolver.setGridSize(scale, binding.recyclerView) @@ -293,9 +267,8 @@ abstract class MangaListFragment : BaseFragment(), val total = (binding.recyclerView.layoutManager as? GridLayoutManager)?.spanCount ?: return 1 return when (listAdapter?.getItemViewType(position)) { - MangaListAdapter.ITEM_TYPE_DATE, - MangaListAdapter.ITEM_TYPE_PROGRESS -> total - else -> 1 + MangaListAdapter.ITEM_TYPE_MANGA_GRID -> 1 + else -> total } } diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListSheet.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListSheet.kt index f15768e69..5417d6069 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListSheet.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListSheet.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.list.ui -import android.content.SharedPreferences import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem @@ -16,28 +15,30 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.snackbar.Snackbar import org.koin.android.ext.android.get -import org.koin.android.ext.android.inject import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration +import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog +import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.model.Manga -import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.databinding.SheetListBinding import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter -import org.koitharu.kotatsu.utils.UiUtils +import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.utils.ext.* abstract class MangaListSheet : BaseBottomSheet(), PaginationScrollListener.Callback, OnListItemClickListener, - SharedPreferences.OnSharedPreferenceChangeListener, Toolbar.OnMenuItemClickListener { + Toolbar.OnMenuItemClickListener { - private val settings by inject() - - private var adapter: MangaListAdapter? = null + private var listAdapter: MangaListAdapter? = null + private var paginationListener: PaginationScrollListener? = null + private val spanResolver = MangaListSpanResolver() + private val spanSizeLookup = SpanSizeLookup() + open val isSwipeRefreshEnabled = true protected abstract val viewModel: MangaListViewModel @@ -47,33 +48,39 @@ abstract class MangaListSheet : BaseBottomSheet(), override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - adapter = MangaListAdapter(get(), viewLifecycleOwner, this) - initListMode(settings.listMode) - binding.recyclerView.adapter = adapter - binding.recyclerView.addOnScrollListener(PaginationScrollListener(4, this)) - settings.subscribe(this) - binding.toolbar.inflateMenu(R.menu.opt_list_sheet) - binding.toolbar.setOnMenuItemClickListener(this) - binding.toolbar.setNavigationOnClickListener { - dismiss() + listAdapter = MangaListAdapter(get(), viewLifecycleOwner, this) { + viewModel.onRetry() + } + paginationListener = PaginationScrollListener(4, this) + with(binding.recyclerView) { + setHasFixedSize(true) + adapter = listAdapter + addOnScrollListener(paginationListener!!) + } + with(binding.toolbar) { + inflateMenu(R.menu.opt_list_sheet) + setOnMenuItemClickListener(this@MangaListSheet) + setNavigationOnClickListener { + dismiss() + } } if (dialog !is BottomSheetDialog) { binding.toolbar.isVisible = true binding.textViewTitle.isVisible = false binding.appbar.elevation = resources.getDimension(R.dimen.elevation_large) } - if (savedInstanceState == null) { - onScrolledToEnd() - } + viewModel.content.observe(viewLifecycleOwner, ::onListChanged) viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) - viewModel.listMode.observe(viewLifecycleOwner, ::initListMode) + viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged) + viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) } override fun onDestroyView() { - settings.unsubscribe(this) - adapter = null + listAdapter = null + paginationListener = null + spanSizeLookup.invalidateCache() super.onDestroyView() } @@ -117,65 +124,94 @@ abstract class MangaListSheet : BaseBottomSheet(), else -> false } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { - when (key) { - AppSettings.KEY_LIST_MODE -> initListMode(settings.listMode) - AppSettings.KEY_GRID_SIZE -> UiUtils.SpanCountResolver.update(binding.recyclerView) - } - } - override fun onItemClick(item: Manga, view: View) { startActivity(DetailsActivity.newIntent(context ?: return, item)) } - private fun onListChanged(list: List) { - adapter?.items = list - binding.textViewHolder.isVisible = list.isEmpty() - binding.recyclerView.callOnScrollListeners() + private fun onListChanged(list: List) { + spanSizeLookup.invalidateCache() + listAdapter?.items = list } private fun onError(e: Throwable) { - Snackbar.make(binding.recyclerView, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT) - .show() + if (e is CloudFlareProtectedException) { + CloudFlareDialog.newInstance(e.url).show(childFragmentManager, CloudFlareDialog.TAG) + } else { + Snackbar.make( + binding.recyclerView, + e.getDisplayMessage(resources), + Snackbar.LENGTH_SHORT + ).show() + } } private fun onLoadingStateChanged(isLoading: Boolean) { - binding.progressBar.isVisible = isLoading && !binding.recyclerView.hasItems - if (isLoading) { - binding.textViewHolder.isVisible = false - } + binding.progressBar.isVisible = + isLoading && !binding.recyclerView.hasItems + } + + private fun onGridScaleChanged(scale: Float) { + spanSizeLookup.invalidateCache() + spanResolver.setGridSize(scale, binding.recyclerView) } - private fun initListMode(mode: ListMode) { - val ctx = context ?: return - val position = binding.recyclerView.firstItem - binding.recyclerView.layoutManager = null - binding.recyclerView.clearItemDecorations() - binding.recyclerView.removeOnLayoutChangeListener(UiUtils.SpanCountResolver) - binding.recyclerView.layoutManager = when (mode) { - ListMode.GRID -> { - GridLayoutManager(ctx, UiUtils.resolveGridSpanCount(ctx)).apply { - spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { - override fun getSpanSize(position: Int) = if (position < TODO() as Int) - 1 else this@apply.spanCount + private fun onListModeChanged(mode: ListMode) { + spanSizeLookup.invalidateCache() + with(binding.recyclerView) { + clearItemDecorations() + removeOnLayoutChangeListener(spanResolver) + when (mode) { + ListMode.LIST -> { + layoutManager = LinearLayoutManager(context) + addItemDecoration( + DividerItemDecoration( + context, + RecyclerView.VERTICAL + ) + ) + } + ListMode.DETAILED_LIST -> { + layoutManager = LinearLayoutManager(context) + addItemDecoration( + SpacingItemDecoration( + resources.getDimensionPixelOffset(R.dimen.grid_spacing) + ) + ) + } + ListMode.GRID -> { + layoutManager = GridLayoutManager(context, spanResolver.spanCount).also { + it.spanSizeLookup = spanSizeLookup } + addItemDecoration( + SpacingItemDecoration( + resources.getDimensionPixelOffset(R.dimen.grid_spacing) + ) + ) + addOnLayoutChangeListener(spanResolver) } } - else -> LinearLayoutManager(ctx) } - binding.recyclerView.addItemDecoration( - when (mode) { - ListMode.LIST -> DividerItemDecoration(ctx, RecyclerView.VERTICAL) - ListMode.DETAILED_LIST, - ListMode.GRID -> SpacingItemDecoration( - resources.getDimensionPixelOffset(R.dimen.grid_spacing) - ) + } + + private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() { + + init { + isSpanIndexCacheEnabled = true + isSpanGroupIndexCacheEnabled = true + } + + override fun getSpanSize(position: Int): Int { + val total = + (binding.recyclerView.layoutManager as? GridLayoutManager)?.spanCount ?: return 1 + return when (listAdapter?.getItemViewType(position)) { + MangaListAdapter.ITEM_TYPE_MANGA_GRID -> 1 + else -> total } - ) - if (mode == ListMode.GRID) { - binding.recyclerView.addOnLayoutChangeListener(UiUtils.SpanCountResolver) } - adapter?.notifyDataSetChanged() - binding.recyclerView.firstItem = position + + fun invalidateCache() { + invalidateSpanGroupIndexCache() + invalidateSpanIndexCache() + } } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt index 4b9b1603f..a80d21432 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt @@ -9,13 +9,13 @@ import kotlinx.coroutines.flow.* import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode +import org.koitharu.kotatsu.list.ui.model.ListModel abstract class MangaListViewModel( private val settings: AppSettings ) : BaseViewModel() { - abstract val content: LiveData> - val isEmptyState = MutableLiveData(false) + abstract val content: LiveData> val filter = MutableLiveData() val listMode = MutableLiveData() val gridScale = settings.observe() @@ -30,4 +30,8 @@ abstract class MangaListViewModel( .onStart { emit(settings.listMode) } .distinctUntilChanged() .onEach { listMode.postValue(it) } + + abstract fun onRefresh() + + abstract fun onRetry() } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt new file mode 100644 index 000000000..77481c8c5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt @@ -0,0 +1,14 @@ +package org.koitharu.kotatsu.list.ui.adapter + +import android.widget.TextView +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.list.ui.model.EmptyState +import org.koitharu.kotatsu.list.ui.model.ListModel + +fun emptyStateListAD() = adapterDelegate(R.layout.item_empty_state) { + + bind { + (itemView as TextView).setText(item.text) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ErrorFooterAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ErrorFooterAD.kt new file mode 100644 index 000000000..c8ce8b96c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ErrorFooterAD.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.list.ui.adapter + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.databinding.ItemErrorFooterBinding +import org.koitharu.kotatsu.list.ui.model.ErrorFooter +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.utils.ext.getDisplayMessage + +fun errorFooterAD( + onRetryClick: () -> Unit +) = adapterDelegateViewBinding( + { inflater, parent -> ItemErrorFooterBinding.inflate(inflater, parent, false) } +) { + + binding.root.setOnClickListener { + onRetryClick() + } + + bind { + binding.textViewTitle.text = item.exception.getDisplayMessage(context.resources) + binding.imageViewIcon.setImageResource(item.icon) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ErrorStateListAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ErrorStateListAD.kt new file mode 100644 index 000000000..c1ffe00a5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ErrorStateListAD.kt @@ -0,0 +1,27 @@ +package org.koitharu.kotatsu.list.ui.adapter + +import androidx.core.view.isVisible +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.databinding.ItemErrorStateBinding +import org.koitharu.kotatsu.list.ui.model.ErrorState +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.utils.ext.getDisplayMessage + +fun errorStateListAD( + onRetryClick: () -> Unit +) = adapterDelegateViewBinding( + { inflater, parent -> ItemErrorStateBinding.inflate(inflater, parent, false) } +) { + + binding.buttonRetry.setOnClickListener { + onRetryClick() + } + + bind { + with(binding.textViewError) { + text = item.exception.getDisplayMessage(context.resources) + setCompoundDrawablesWithIntrinsicBounds(0, item.icon, 0, 0) + } + binding.buttonRetry.isVisible = item.canRetry + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/IndeterminateProgressAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/IndeterminateProgressAD.kt deleted file mode 100644 index 1b7a3fd17..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/IndeterminateProgressAD.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.koitharu.kotatsu.list.ui.adapter - -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.list.ui.model.IndeterminateProgress - -fun indeterminateProgressAD() = adapterDelegate(R.layout.item_progress) { -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/LoadingFooterAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/LoadingFooterAD.kt new file mode 100644 index 000000000..3ed4c8425 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/LoadingFooterAD.kt @@ -0,0 +1,9 @@ +package org.koitharu.kotatsu.list.ui.adapter + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.LoadingFooter + +fun loadingFooterAD() = adapterDelegate(R.layout.item_loading_footer) { +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/LoadingStateAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/LoadingStateAD.kt new file mode 100644 index 000000000..5d4f185c8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/LoadingStateAD.kt @@ -0,0 +1,9 @@ +package org.koitharu.kotatsu.list.ui.adapter + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.LoadingState + +fun loadingStateAD() = adapterDelegate(R.layout.item_loading_state) { +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt index e78f8f26e..f096c8892 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt @@ -9,6 +9,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.databinding.ItemMangaGridBinding +import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.newImageRequest @@ -17,7 +18,7 @@ fun mangaGridItemAD( coil: ImageLoader, lifecycleOwner: LifecycleOwner, clickListener: OnListItemClickListener -) = adapterDelegateViewBinding( +) = adapterDelegateViewBinding( { inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) } ) { diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt index 466bea18d..562a6e6d3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt @@ -6,8 +6,7 @@ import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.model.Manga -import org.koitharu.kotatsu.core.ui.DateTimeAgo -import org.koitharu.kotatsu.list.ui.model.IndeterminateProgress +import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel import org.koitharu.kotatsu.list.ui.model.MangaListModel @@ -16,8 +15,9 @@ import kotlin.jvm.internal.Intrinsics class MangaListAdapter( coil: ImageLoader, lifecycleOwner: LifecycleOwner, - clickListener: OnListItemClickListener -) : AsyncListDifferDelegationAdapter(DiffCallback()) { + clickListener: OnListItemClickListener, + onRetryClick: () -> Unit +) : AsyncListDifferDelegationAdapter(DiffCallback()) { init { delegatesManager @@ -30,13 +30,17 @@ class MangaListAdapter( mangaListDetailedItemAD(coil, lifecycleOwner, clickListener) ) .addDelegate(ITEM_TYPE_MANGA_GRID, mangaGridItemAD(coil, lifecycleOwner, clickListener)) - .addDelegate(ITEM_TYPE_PROGRESS, indeterminateProgressAD()) + .addDelegate(ITEM_TYPE_LOADING_FOOTER, loadingFooterAD()) + .addDelegate(ITEM_TYPE_LOADING_STATE, loadingStateAD()) .addDelegate(ITEM_TYPE_DATE, relatedDateItemAD()) + .addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(onRetryClick)) + .addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(onRetryClick)) + .addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD()) } - private class DiffCallback : DiffUtil.ItemCallback() { + private class DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Any, newItem: Any) = when { + override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel) = when { oldItem is MangaListModel && newItem is MangaListModel -> { oldItem.id == newItem.id } @@ -46,16 +50,10 @@ class MangaListAdapter( oldItem is MangaGridModel && newItem is MangaGridModel -> { oldItem.id == newItem.id } - oldItem == IndeterminateProgress && newItem == IndeterminateProgress -> { - true - } - oldItem is DateTimeAgo && newItem is DateTimeAgo -> { - oldItem == newItem - } - else -> false + else -> oldItem.javaClass == newItem.javaClass } - override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean { + override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { return Intrinsics.areEqual(oldItem, newItem) } } @@ -65,7 +63,11 @@ class MangaListAdapter( const val ITEM_TYPE_MANGA_LIST = 0 const val ITEM_TYPE_MANGA_LIST_DETAILED = 1 const val ITEM_TYPE_MANGA_GRID = 2 - const val ITEM_TYPE_PROGRESS = 3 - const val ITEM_TYPE_DATE = 4 + const val ITEM_TYPE_LOADING_FOOTER = 3 + const val ITEM_TYPE_LOADING_STATE = 4 + const val ITEM_TYPE_DATE = 5 + const val ITEM_TYPE_ERROR_STATE = 6 + const val ITEM_TYPE_ERROR_FOOTER = 7 + const val ITEM_TYPE_EMPTY = 8 } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt index 1e3dd87db..c318bdfca 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt @@ -9,6 +9,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding +import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.newImageRequest @@ -18,7 +19,7 @@ fun mangaListDetailedItemAD( coil: ImageLoader, lifecycleOwner: LifecycleOwner, clickListener: OnListItemClickListener -) = adapterDelegateViewBinding( +) = adapterDelegateViewBinding( { inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) } ) { diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt index 91f1e6dd8..021ce7e44 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt @@ -9,6 +9,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.databinding.ItemMangaListBinding +import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.newImageRequest @@ -18,7 +19,7 @@ fun mangaListItemAD( coil: ImageLoader, lifecycleOwner: LifecycleOwner, clickListener: OnListItemClickListener -) = adapterDelegateViewBinding( +) = adapterDelegateViewBinding( { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) } ) { diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/RelatedDateItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/RelatedDateItemAD.kt index 065d9efd6..98a4bc8f6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/RelatedDateItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/RelatedDateItemAD.kt @@ -4,8 +4,9 @@ import android.widget.TextView import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.DateTimeAgo +import org.koitharu.kotatsu.list.ui.model.ListModel -fun relatedDateItemAD() = adapterDelegate(R.layout.item_header) { +fun relatedDateItemAD() = adapterDelegate(R.layout.item_header) { bind { (itemView as TextView).text = item.format(context.resources) diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/EmptyState.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/EmptyState.kt new file mode 100644 index 000000000..e23c5b59b --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/EmptyState.kt @@ -0,0 +1,7 @@ +package org.koitharu.kotatsu.list.ui.model + +import androidx.annotation.StringRes + +data class EmptyState( + @StringRes val text: Int +) : ListModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ErrorFooter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ErrorFooter.kt new file mode 100644 index 000000000..7bcaf03d8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ErrorFooter.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.list.ui.model + +import androidx.annotation.DrawableRes + +data class ErrorFooter( + val exception: Throwable, + @DrawableRes val icon: Int +) : ListModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ErrorState.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ErrorState.kt new file mode 100644 index 000000000..868435096 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ErrorState.kt @@ -0,0 +1,9 @@ +package org.koitharu.kotatsu.list.ui.model + +import androidx.annotation.DrawableRes + +data class ErrorState( + val exception: Throwable, + @DrawableRes val icon: Int, + val canRetry: Boolean +) : ListModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/IndeterminateProgress.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModel.kt similarity index 61% rename from app/src/main/java/org/koitharu/kotatsu/list/ui/model/IndeterminateProgress.kt rename to app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModel.kt index cb739762d..1ae2536f9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/IndeterminateProgress.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModel.kt @@ -1,3 +1,3 @@ package org.koitharu.kotatsu.list.ui.model -object IndeterminateProgress \ No newline at end of file +interface ListModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt index 2a73c09c0..0a45d8432 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListModelConversionExt.kt @@ -1,6 +1,8 @@ package org.koitharu.kotatsu.list.ui.model +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.Manga +import org.koitharu.kotatsu.core.prefs.ListMode import kotlin.math.roundToInt fun Manga.toListModel() = MangaListModel( @@ -26,4 +28,27 @@ fun Manga.toGridModel() = MangaGridModel( title = title, coverUrl = coverUrl, manga = this +) + +fun List.toUi(mode: ListMode): List = when(mode) { + ListMode.LIST -> map(Manga::toListModel) + ListMode.DETAILED_LIST -> map(Manga::toListDetailedModel) + ListMode.GRID -> map(Manga::toGridModel) +} + +fun > List.toUi(destination: C, mode: ListMode): C = when(mode) { + ListMode.LIST -> mapTo(destination, Manga::toListModel) + ListMode.DETAILED_LIST -> mapTo(destination, Manga::toListDetailedModel) + ListMode.GRID -> mapTo(destination, Manga::toGridModel) +} + +fun Throwable.toErrorState(canRetry: Boolean = true) = ErrorState( + exception = this, + icon = R.drawable.ic_error_large, + canRetry = canRetry +) + +fun Throwable.toErrorFooter() = ErrorFooter( + exception = this, + icon = R.drawable.ic_alert_outline ) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/LoadingFooter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/LoadingFooter.kt new file mode 100644 index 000000000..97a63b6a6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/LoadingFooter.kt @@ -0,0 +1,3 @@ +package org.koitharu.kotatsu.list.ui.model + +object LoadingFooter : ListModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/LoadingState.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/LoadingState.kt new file mode 100644 index 000000000..a866e7427 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/LoadingState.kt @@ -0,0 +1,3 @@ +package org.koitharu.kotatsu.list.ui.model + +object LoadingState : ListModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt index 601bc9304..1dc1e245c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt @@ -7,4 +7,4 @@ data class MangaGridModel( val title: String, val coverUrl: String, val manga: Manga -) \ No newline at end of file +) : ListModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt index 6dac0ec99..cb9b6b2a9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListDetailedModel.kt @@ -10,4 +10,4 @@ data class MangaListDetailedModel( val coverUrl: String, val rating: String?, val manga: Manga -) \ No newline at end of file +) : ListModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt index 20384aaa1..1f7d4aab1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt @@ -8,4 +8,4 @@ data class MangaListModel( val subtitle: String, val coverUrl: String, val manga: Manga -) \ No newline at end of file +) : ListModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt index e72751389..bc30d30d4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt @@ -31,11 +31,6 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback { viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved) } - override fun onRefresh() { - super.onRefresh() - viewModel.onRefresh() - } - override fun onScrolledToEnd() = Unit override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { @@ -68,11 +63,6 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback { return context?.getString(R.string.local_storage) } - override fun setUpEmptyListHolder() { - binding.textViewHolder.setText(R.string.text_local_holder) - binding.textViewHolder.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) - } - override fun onActivityResult(result: Uri?) { if (result != null) { viewModel.importFile(result) diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index f1eaf8dae..9faa943e2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -8,16 +8,18 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.list.ui.MangaListViewModel -import org.koitharu.kotatsu.list.ui.model.toGridModel -import org.koitharu.kotatsu.list.ui.model.toListDetailedModel -import org.koitharu.kotatsu.list.ui.model.toListModel +import org.koitharu.kotatsu.list.ui.model.EmptyState +import org.koitharu.kotatsu.list.ui.model.LoadingState +import org.koitharu.kotatsu.list.ui.model.toErrorState +import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.utils.MangaShortcut import org.koitharu.kotatsu.utils.MediaStoreCompat @@ -34,30 +36,41 @@ class LocalListViewModel( ) : MangaListViewModel(settings) { val onMangaRemoved = SingleLiveEvent() - private val mangaList = MutableStateFlow>(emptyList()) + private val listError = MutableStateFlow(null) + private val mangaList = MutableStateFlow?>(null) - override val content = combine(mangaList, createListModeFlow()) { list, mode -> - when (mode) { - ListMode.LIST -> list.map { it.toListModel() } - ListMode.DETAILED_LIST -> list.map { it.toListDetailedModel() } - ListMode.GRID -> list.map { it.toGridModel() } + override val content = combine( + mangaList, + createListModeFlow(), + listError + ) { list, mode, error -> + when { + error != null -> listOf(error.toErrorState(canRetry = true)) + list == null -> listOf(LoadingState) + list.isEmpty() -> listOf(EmptyState(R.string.text_local_holder)) + else -> list.toUi(mode) } + }.onStart { + emit(listOf(LoadingState)) }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) init { onRefresh() } - fun onRefresh() { - launchLoadingJob { - withContext(Dispatchers.Default) { - val list = repository.getList(0) - mangaList.value = list - isEmptyState.postValue(list.isEmpty()) + override fun onRefresh() { + launchLoadingJob(Dispatchers.Default) { + try { + listError.value = null + mangaList.value = repository.getList(0) + } catch (e: Throwable) { + listError.value = e } } } + override fun onRetry() = onRefresh() + fun importFile(uri: Uri) { launchLoadingJob { val contentResolver = context.contentResolver diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersDialog.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersDialog.kt index 6ca5db113..667d7113a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersDialog.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersDialog.kt @@ -7,6 +7,7 @@ import android.view.ViewGroup import androidx.appcompat.app.AlertDialog import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.AlertDialogFragment @@ -14,6 +15,8 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.model.MangaChapter import org.koitharu.kotatsu.databinding.DialogChaptersBinding import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter +import org.koitharu.kotatsu.details.ui.model.toListItem +import org.koitharu.kotatsu.history.domain.ChapterExtra import org.koitharu.kotatsu.utils.ext.withArgs class ChaptersDialog : AlertDialogFragment(), @@ -32,14 +35,34 @@ class ChaptersDialog : AlertDialogFragment(), override fun onViewCreated(view: View, savedInstanceState: Bundle?) { binding.recyclerViewChapters.addItemDecoration( - DividerItemDecoration( - requireContext(), - RecyclerView.VERTICAL - ) + DividerItemDecoration(requireContext(), RecyclerView.VERTICAL) ) + val chapters = arguments?.getParcelableArrayList(ARG_CHAPTERS) + if (chapters == null) { + dismissAllowingStateLoss() + return + } + val currentId = arguments?.getLong(ARG_CURRENT_ID, 0L) ?: 0L + val currentPosition = chapters.indexOfFirst { it.id == currentId } binding.recyclerViewChapters.adapter = ChaptersAdapter(this).apply { - // arguments?.getParcelableArrayList(ARG_CHAPTERS)?.let(this::setItems) - // currentChapterId = arguments?.getLong(ARG_CURRENT_ID, 0L)?.takeUnless { it == 0L } + setItems(chapters.mapIndexed { index, chapter -> + chapter.toListItem( + when { + index < currentPosition -> ChapterExtra.READ + index == currentPosition -> ChapterExtra.CURRENT + else -> ChapterExtra.UNREAD + } + ) + }) { + if (currentPosition >= 0) { + with(binding.recyclerViewChapters) { + (layoutManager as LinearLayoutManager).scrollToPositionWithOffset( + currentPosition, + height / 3 + ) + } + } + } } } @@ -64,10 +87,9 @@ class ChaptersDialog : AlertDialogFragment(), private const val ARG_CURRENT_ID = "current_id" fun show(fm: FragmentManager, chapters: List, currentId: Long = 0L) = - ChaptersDialog() - .withArgs(2) { - putParcelableArrayList(ARG_CHAPTERS, ArrayList(chapters)) - putLong(ARG_CURRENT_ID, currentId) - }.show(fm, TAG) + ChaptersDialog().withArgs(2) { + putParcelableArrayList(ARG_CHAPTERS, ArrayList(chapters)) + putLong(ARG_CURRENT_ID, currentId) + }.show(fm, TAG) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt index d59a914fd..05f804366 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt @@ -21,13 +21,8 @@ class RemoteListFragment : MangaListFragment() { private val source by parcelableArgument(ARG_SOURCE) - override fun onRefresh() { - super.onRefresh() - viewModel.loadList(append = false) - } - override fun onScrolledToEnd() { - viewModel.loadList(append = true) + viewModel.loadNextPage() } override fun getTitle(): CharSequence? { diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index 10bfb9347..48e121d42 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -6,41 +6,51 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.MangaFilter import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.list.ui.MangaFilterConfig import org.koitharu.kotatsu.list.ui.MangaListViewModel -import org.koitharu.kotatsu.list.ui.model.IndeterminateProgress -import org.koitharu.kotatsu.list.ui.model.toGridModel -import org.koitharu.kotatsu.list.ui.model.toListDetailedModel -import org.koitharu.kotatsu.list.ui.model.toListModel +import org.koitharu.kotatsu.list.ui.model.* +import java.util.* class RemoteListViewModel( private val repository: MangaRepository, settings: AppSettings ) : MangaListViewModel(settings) { - private val mangaList = MutableStateFlow>(emptyList()) + private val mangaList = MutableStateFlow?>(null) private val hasNextPage = MutableStateFlow(false) + private val listError = MutableStateFlow(null) private var appliedFilter: MangaFilter? = null private var loadingJob: Job? = null - override val content = combine(mangaList.drop(1), createListModeFlow()) { list, mode -> - when (mode) { - ListMode.LIST -> list.map { it.toListModel() } - ListMode.DETAILED_LIST -> list.map { it.toListDetailedModel() } - ListMode.GRID -> list.map { it.toGridModel() } + override val content = combine( + mangaList, + createListModeFlow(), + listError, + hasNextPage + ) { list, mode, error, hasNext -> + when { + list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true)) + list == null -> listOf(LoadingState) + list.isEmpty() -> listOf(EmptyState(R.string.nothing_found)) + else -> { + val result = ArrayList(list.size + 1) + list.toUi(result, mode) + when { + error != null -> result += error.toErrorFooter() + hasNext -> result += LoadingFooter + } + result + } } - }.onEach { - isEmptyState.postValue(it.isEmpty()) - }.combine(hasNextPage) { list, isHasNextPage -> - if (isHasNextPage && list.isNotEmpty()) list + IndeterminateProgress else list + }.onStart { + emit(listOf(LoadingState)) }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) init { @@ -48,22 +58,41 @@ class RemoteListViewModel( loadFilter() } - fun loadList(append: Boolean) { + override fun onRefresh() { + loadList(append = false) + } + + override fun onRetry() { + loadList(append = !mangaList.value.isNullOrEmpty()) + } + + fun loadNextPage() { + if (hasNextPage.value && listError.value == null) { + loadList(append = true) + } + } + + private fun loadList(append: Boolean) { if (loadingJob?.isActive == true) { return } loadingJob = launchLoadingJob(Dispatchers.Default) { - val list = repository.getList( - offset = if (append) mangaList.value.size else 0, - sortOrder = appliedFilter?.sortOrder, - tag = appliedFilter?.tag - ) - if (!append) { - mangaList.value = list - } else if (list.isNotEmpty()) { - mangaList.value += list + try { + listError.value = null + val list = repository.getList( + offset = if (append) mangaList.value?.size ?: 0 else 0, + sortOrder = appliedFilter?.sortOrder, + tag = appliedFilter?.tag + ) + if (!append) { + mangaList.value = list + } else if (list.isNotEmpty()) { + mangaList.value = mangaList.value?.plus(list) ?: list + } + hasNextPage.value = list.isNotEmpty() + } catch (e: Throwable) { + listError.value = e } - hasNextPage.value = list.isNotEmpty() } } diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaSearchSheet.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaSearchSheet.kt index 897910836..a894fd650 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaSearchSheet.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/MangaSearchSheet.kt @@ -29,7 +29,7 @@ class MangaSearchSheet : MangaListSheet() { } override fun onScrolledToEnd() { - viewModel.loadList(append = true) + viewModel.loadNextPage() } companion object { @@ -39,6 +39,7 @@ class MangaSearchSheet : MangaListSheet() { private const val TAG = "MangaSearchSheet" + @Deprecated("Not ready for use") fun show(fm: FragmentManager, source: MangaSource, query: String) { MangaSearchSheet().withArgs(2) { putParcelable(ARG_SOURCE, source) diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt index 1c9ab57c9..6f2621475 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt @@ -17,13 +17,8 @@ class SearchFragment : MangaListFragment() { private val query by stringArgument(ARG_QUERY) private val source by parcelableArgument(ARG_SOURCE) - override fun onRefresh() { - super.onRefresh() - viewModel.loadList(append = false) - } - override fun onScrolledToEnd() { - viewModel.loadList(append = true) + viewModel.loadNextPage() } override fun getTitle() = query diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt index a52dd9f62..b6104b995 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt @@ -6,17 +6,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.list.ui.MangaListViewModel -import org.koitharu.kotatsu.list.ui.model.IndeterminateProgress -import org.koitharu.kotatsu.list.ui.model.toGridModel -import org.koitharu.kotatsu.list.ui.model.toListDetailedModel -import org.koitharu.kotatsu.list.ui.model.toListModel +import org.koitharu.kotatsu.list.ui.model.* +import java.util.* class SearchViewModel( private val repository: MangaRepository, @@ -24,41 +21,73 @@ class SearchViewModel( settings: AppSettings ) : MangaListViewModel(settings) { - private val mangaList = MutableStateFlow>(emptyList()) + private val mangaList = MutableStateFlow?>(null) private val hasNextPage = MutableStateFlow(false) + private val listError = MutableStateFlow(null) private var loadingJob: Job? = null - override val content = combine(mangaList.drop(1), createListModeFlow()) { list, mode -> - when (mode) { - ListMode.LIST -> list.map { it.toListModel() } - ListMode.DETAILED_LIST -> list.map { it.toListDetailedModel() } - ListMode.GRID -> list.map { it.toGridModel() } + override val content = combine( + mangaList, + createListModeFlow(), + listError, + hasNextPage + ) { list, mode, error, hasNext -> + when { + list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true)) + list == null -> listOf(LoadingState) + list.isEmpty() -> listOf(EmptyState(R.string.nothing_found)) + else -> { + val result = ArrayList(list.size + 1) + list.toUi(result, mode) + when { + error != null -> result += error.toErrorFooter() + hasNext -> result += LoadingFooter + } + result + } } - }.onEach { - isEmptyState.postValue(it.isEmpty()) - }.combine(hasNextPage) { list, isHasNextPage -> - if (isHasNextPage && list.isNotEmpty()) list + IndeterminateProgress else list + }.onStart { + emit(listOf(LoadingState)) }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) init { loadList(append = false) } - fun loadList(append: Boolean) { + override fun onRefresh() { + loadList(append = false) + } + + override fun onRetry() { + loadList(append = !mangaList.value.isNullOrEmpty()) + } + + fun loadNextPage() { + if (hasNextPage.value && listError.value == null) { + loadList(append = true) + } + } + + private fun loadList(append: Boolean) { if (loadingJob?.isActive == true) { return } loadingJob = launchLoadingJob(Dispatchers.Default) { - val list = repository.getList( - offset = if (append) mangaList.value.size else 0, - query = query - ) - if (!append) { - mangaList.value = list - } else if (list.isNotEmpty()) { - mangaList.value += list + try { + listError.value = null + val list = repository.getList( + offset = if (append) mangaList.value?.size ?: 0 else 0, + query = query + ) + if (!append) { + mangaList.value = list + } else if (list.isNotEmpty()) { + mangaList.value = mangaList.value?.plus(list) ?: list + } + hasNextPage.value = list.isNotEmpty() + } catch (e: Throwable) { + listError.value = e } - hasNextPage.value = list.isNotEmpty() } } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt index 5f0b13a73..2f6ca1ae3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt @@ -15,11 +15,6 @@ class GlobalSearchFragment : MangaListFragment() { private val query by stringArgument(ARG_QUERY) - override fun onRefresh() { - super.onRefresh() - viewModel.onRefresh() - } - override fun onScrolledToEnd() = Unit override fun getTitle(): CharSequence? { diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchViewModel.kt index 679c0bbce..019597155 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchViewModel.kt @@ -6,16 +6,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* import kotlinx.coroutines.plus +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.list.ui.MangaListViewModel -import org.koitharu.kotatsu.list.ui.model.IndeterminateProgress -import org.koitharu.kotatsu.list.ui.model.toGridModel -import org.koitharu.kotatsu.list.ui.model.toListDetailedModel -import org.koitharu.kotatsu.list.ui.model.toListModel +import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.utils.ext.onFirst +import java.util.* class GlobalSearchViewModel( private val query: String, @@ -23,48 +21,63 @@ class GlobalSearchViewModel( settings: AppSettings ) : MangaListViewModel(settings) { - private val mangaList = MutableStateFlow>(emptyList()) + private val mangaList = MutableStateFlow?>(null) private val hasNextPage = MutableStateFlow(false) + private val listError = MutableStateFlow(null) private var searchJob: Job? = null - override val content = combine(mangaList.drop(1), createListModeFlow()) { list, mode -> - when (mode) { - ListMode.LIST -> list.map { it.toListModel() } - ListMode.DETAILED_LIST -> list.map { it.toListDetailedModel() } - ListMode.GRID -> list.map { it.toGridModel() } + override val content = combine( + mangaList, + createListModeFlow(), + listError, + hasNextPage + ) { list, mode, error, hasNext -> + when { + list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true)) + list == null -> listOf(LoadingState) + list.isEmpty() -> listOf(EmptyState(R.string.nothing_found)) + else -> { + val result = ArrayList(list.size + 1) + list.toUi(result, mode) + when { + error != null -> result += error.toErrorFooter() + hasNext -> result += LoadingFooter + } + result + } } - }.combine(hasNextPage) { list, isHasNextPage -> - if (isHasNextPage && list.isNotEmpty()) list + IndeterminateProgress else list + }.onStart { + emit(listOf(LoadingState)) }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) init { onRefresh() } - fun onRefresh() { + override fun onRetry() { + onRefresh() + } + + override fun onRefresh() { searchJob?.cancel() searchJob = repository.globalSearch(query) - .flowOn(Dispatchers.Default) .catch { e -> - onError.postCall(e) + listError.value = e isLoading.postValue(false) - hasNextPage.value = false }.filterNot { x -> x.isEmpty() } .onStart { + listError.value = null isLoading.postValue(true) + hasNextPage.value = true }.onEmpty { mangaList.value = emptyList() - isEmptyState.postValue(true) - isLoading.postValue(false) }.onCompletion { isLoading.postValue(false) hasNextPage.value = false }.onFirst { - isEmptyState.postValue(false) - hasNextPage.value = true - isLoading.value = false + isLoading.postValue(false) }.onEach { - mangaList.value += it + mangaList.value = mangaList.value?.plus(it) ?: it }.launchIn(viewModelScope + Dispatchers.Default) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt index 292377db4..af64c9ca8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt @@ -14,6 +14,7 @@ import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.databinding.FragmentFeedBinding import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.tracker.ui.adapter.FeedAdapter import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.utils.ext.getDisplayMessage @@ -79,7 +80,7 @@ class FeedFragment : BaseFragment(), PaginationScrollListen super.onDestroyView() } - private fun onListChanged(list: List) { + private fun onListChanged(list: List) { feedAdapter?.items = list } diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt index c3da6366b..c4c6ddefd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt @@ -8,10 +8,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.onStart import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.core.model.TrackingLogItem -import org.koitharu.kotatsu.list.ui.model.IndeterminateProgress +import org.koitharu.kotatsu.list.ui.model.LoadingFooter +import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.ui.model.toFeedItem import org.koitharu.kotatsu.utils.ext.mapItems @@ -21,18 +23,20 @@ class FeedViewModel( private val repository: TrackingRepository ) : BaseViewModel() { - private val logList = MutableStateFlow>(emptyList()) + private val logList = MutableStateFlow?>(null) private val hasNextPage = MutableStateFlow(false) private var loadingJob: Job? = null val isEmptyState = MutableLiveData(false) val content = combine( - logList.drop(1).mapItems { + logList.filterNotNull().mapItems { it.toFeedItem(context.resources) }, hasNextPage ) { list, isHasNextPage -> - if (isHasNextPage && list.isNotEmpty()) list + IndeterminateProgress else list + if (isHasNextPage && list.isNotEmpty()) list + LoadingFooter else list + }.onStart { + emit(listOf(LoadingState)) }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) init { @@ -44,13 +48,13 @@ class FeedViewModel( return } loadingJob = launchLoadingJob(Dispatchers.Default) { - val offset = if (append) logList.value.size else 0 + val offset = if (append) logList.value?.size ?: 0 else 0 val list = repository.getTrackingLog(offset, 20) if (!append) { logList.value = list isEmptyState.postValue(list.isEmpty()) } else if (list.isNotEmpty()) { - logList.value += list + logList.value = logList.value?.plus(list) ?: list } hasNextPage.value = list.isNotEmpty() } diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedAdapter.kt index 4d533dfa1..1314267e2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedAdapter.kt @@ -6,8 +6,9 @@ import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.model.Manga -import org.koitharu.kotatsu.list.ui.adapter.indeterminateProgressAD -import org.koitharu.kotatsu.list.ui.model.IndeterminateProgress +import org.koitharu.kotatsu.list.ui.adapter.* +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.tracker.ui.model.FeedItem import kotlin.jvm.internal.Intrinsics @@ -15,27 +16,31 @@ class FeedAdapter( coil: ImageLoader, lifecycleOwner: LifecycleOwner, clickListener: OnListItemClickListener -) : AsyncListDifferDelegationAdapter(DiffCallback()) { +) : AsyncListDifferDelegationAdapter(DiffCallback()) { init { delegatesManager .addDelegate(ITEM_TYPE_FEED, feedItemAD(coil, lifecycleOwner, clickListener)) - .addDelegate(ITEM_TYPE_PROGRESS, indeterminateProgressAD()) + .addDelegate(ITEM_TYPE_LOADING_FOOTER, loadingFooterAD()) + .addDelegate(ITEM_TYPE_LOADING_STATE, loadingStateAD()) + .addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD {}) + .addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD {}) + .addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD()) } - private class DiffCallback : DiffUtil.ItemCallback() { + private class DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Any, newItem: Any) = when { + override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel) = when { oldItem is FeedItem && newItem is FeedItem -> { oldItem.id == newItem.id } - oldItem == IndeterminateProgress && newItem == IndeterminateProgress -> { + oldItem == LoadingFooter && newItem == LoadingFooter -> { true } else -> false } - override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean { + override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean { return Intrinsics.areEqual(oldItem, newItem) } } @@ -43,6 +48,10 @@ class FeedAdapter( companion object { const val ITEM_TYPE_FEED = 0 - const val ITEM_TYPE_PROGRESS = 1 + const val ITEM_TYPE_LOADING_FOOTER = 1 + const val ITEM_TYPE_LOADING_STATE = 2 + const val ITEM_TYPE_ERROR_STATE = 3 + const val ITEM_TYPE_ERROR_FOOTER = 4 + const val ITEM_TYPE_EMPTY = 5 } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedItemAD.kt index f85684f71..75ffadd6d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedItemAD.kt @@ -8,6 +8,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.databinding.ItemTracklogBinding +import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.tracker.ui.model.FeedItem import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.newImageRequest @@ -16,7 +17,7 @@ fun feedItemAD( coil: ImageLoader, lifecycleOwner: LifecycleOwner, clickListener: OnListItemClickListener -) = adapterDelegateViewBinding( +) = adapterDelegateViewBinding( { inflater, parent -> ItemTracklogBinding.inflate(inflater, parent, false) } ) { diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/model/FeedItem.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/model/FeedItem.kt index 887f41db3..c3992b9f0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/model/FeedItem.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/model/FeedItem.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.tracker.ui.model import org.koitharu.kotatsu.core.model.Manga +import org.koitharu.kotatsu.list.ui.model.ListModel data class FeedItem( val id: Long, @@ -9,4 +10,4 @@ data class FeedItem( val subtitle: String, val chapters: CharSequence, val manga: Manga -) \ No newline at end of file +) : ListModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt index 9aca6fbd7..1e3688ebe 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt @@ -43,7 +43,7 @@ fun Throwable.getDisplayMessage(resources: Resources) = when (this) { is EmptyHistoryException -> resources.getString(R.string.history_is_empty) is SocketTimeoutException -> resources.getString(R.string.network_error) is WrongPasswordException -> resources.getString(R.string.wrong_password) - else -> message ?: resources.getString(R.string.error_occurred) + else -> localizedMessage ?: resources.getString(R.string.error_occurred) } inline fun measured(tag: String, block: () -> T): T { diff --git a/app/src/main/res/drawable/ic_alert_outline.xml b/app/src/main/res/drawable/ic_alert_outline.xml new file mode 100644 index 000000000..de5e13ef2 --- /dev/null +++ b/app/src/main/res/drawable/ic_alert_outline.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-w600dp/fragment_list.xml b/app/src/main/res/layout-w600dp/fragment_list.xml index c0c281ce0..2df1d9fe6 100644 --- a/app/src/main/res/layout-w600dp/fragment_list.xml +++ b/app/src/main/res/layout-w600dp/fragment_list.xml @@ -8,69 +8,35 @@ android:animateLayoutChanges="true" android:orientation="horizontal"> - - - - - - - - - - - - - - - - + android:layout_height="match_parent" + android:orientation="vertical" + android:scrollbars="vertical" + app:fastScrollEnabled="true" + app:fastScrollHorizontalThumbDrawable="@drawable/list_thumb" + app:fastScrollHorizontalTrackDrawable="@drawable/list_track" + app:fastScrollVerticalThumbDrawable="@drawable/list_thumb" + app:fastScrollVerticalTrackDrawable="@drawable/list_track" + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" + tools:listitem="@layout/item_manga_list" /> + + + android:visibility="gone" + tools:visibility="visible" /> + tools:listitem="@layout/item_category_checkable" + tools:visibility="visible" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_cloudflare.xml b/app/src/main/res/layout/fragment_cloudflare.xml index 75d2d813d..962633827 100644 --- a/app/src/main/res/layout/fragment_cloudflare.xml +++ b/app/src/main/res/layout/fragment_cloudflare.xml @@ -11,13 +11,12 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> - + android:indeterminate="true" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_list.xml b/app/src/main/res/layout/fragment_list.xml index 51caa6c05..91940022c 100644 --- a/app/src/main/res/layout/fragment_list.xml +++ b/app/src/main/res/layout/fragment_list.xml @@ -12,61 +12,26 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - - - - - - - - - - - - - - - + android:layout_height="match_parent" + android:orientation="vertical" + android:scrollbars="vertical" + app:fastScrollEnabled="true" + app:fastScrollHorizontalThumbDrawable="@drawable/list_thumb" + app:fastScrollHorizontalTrackDrawable="@drawable/list_track" + app:fastScrollVerticalThumbDrawable="@drawable/list_thumb" + app:fastScrollVerticalTrackDrawable="@drawable/list_track" + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" + tools:listitem="@layout/item_manga_list" /> + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_error_footer.xml b/app/src/main/res/layout/item_error_footer.xml new file mode 100644 index 000000000..a023d6c3c --- /dev/null +++ b/app/src/main/res/layout/item_error_footer.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_error_state.xml b/app/src/main/res/layout/item_error_state.xml new file mode 100644 index 000000000..ad19d5292 --- /dev/null +++ b/app/src/main/res/layout/item_error_state.xml @@ -0,0 +1,31 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_loading_footer.xml b/app/src/main/res/layout/item_loading_footer.xml new file mode 100644 index 000000000..075b4ceba --- /dev/null +++ b/app/src/main/res/layout/item_loading_footer.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_progress.xml b/app/src/main/res/layout/item_loading_state.xml similarity index 55% rename from app/src/main/res/layout/item_progress.xml rename to app/src/main/res/layout/item_loading_state.xml index 2ca64e856..f096fd875 100644 --- a/app/src/main/res/layout/item_progress.xml +++ b/app/src/main/res/layout/item_loading_state.xml @@ -2,14 +2,13 @@ + android:layout_height="match_parent"> + android:layout_gravity="center" + android:indeterminate="true" /> \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index daee82b79..0149d06e8 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -185,4 +185,5 @@ Давно Группировать Сегодня + Tap to try again \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 558c95b14..78eda8e09 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -7,4 +7,5 @@ 120dp 34dp 16dp + 48dp \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d540e0088..5d1ad70db 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -187,4 +187,5 @@ Long ago Group Today + Tap to try again \ No newline at end of file