Move list states to adapter delegates

This commit is contained in:
Koitharu
2020-12-05 18:03:34 +02:00
parent 90be936c82
commit 9b5510ac59
60 changed files with 752 additions and 465 deletions

View File

@@ -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))

View File

@@ -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

View File

@@ -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<FragmentDetailsBinding>(), 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<FragmentDetailsBinding>(), 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<FragmentDetailsBinding>(), 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<FragmentDetailsBinding>(), 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<FragmentDetailsBinding>(), View.OnClickList
)
)
}
v is Chip -> {
when (val tag = v.tag) {
is String -> MangaSearchSheet.show(
activity?.supportFragmentManager
?: childFragmentManager,
manga?.source ?: return, tag
)
}
}
}
}

View File

@@ -20,6 +20,10 @@ class ChaptersAdapter(
return items[position].chapter.id
}
fun setItems(newItems: List<ChapterListItem>, callback: Runnable) {
differ.submitList(newItems, callback)
}
private class DiffCallback : DiffUtil.ItemCallback<ChapterListItem>() {
override fun areItemsTheSame(oldItem: ChapterListItem, newItem: ChapterListItem): Boolean {

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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<MangaWithHistory>, grouped: Boolean, mode: ListMode): List<Any> {
val result = ArrayList<Any>((list.size * 1.4).toInt())
private fun mapList(list: List<MangaWithHistory>, grouped: Boolean, mode: ListMode): List<ListModel> {
val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size)
var prevDate: DateTimeAgo? = null
for ((manga, history) in list) {
if (grouped) {

View File

@@ -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<FragmentListBinding>(),
@@ -62,7 +62,9 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
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<FragmentListBinding>(),
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<FragmentListBinding>(),
@CallSuper
override fun onRefresh() {
binding.swipeRefreshLayout.isRefreshing = true
viewModel.onRefresh()
}
private fun onListChanged(list: List<Any>) {
private fun onListChanged(list: List<ListModel>) {
spanSizeLookup.invalidateCache()
listAdapter?.items = list
}
@@ -150,45 +152,24 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
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<FragmentListBinding>(),
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<FragmentListBinding>(),
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
}
}

View File

@@ -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<SheetListBinding>(),
PaginationScrollListener.Callback, OnListItemClickListener<Manga>,
SharedPreferences.OnSharedPreferenceChangeListener, Toolbar.OnMenuItemClickListener {
Toolbar.OnMenuItemClickListener {
private val settings by inject<AppSettings>()
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<SheetListBinding>(),
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<SheetListBinding>(),
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<Any>) {
adapter?.items = list
binding.textViewHolder.isVisible = list.isEmpty()
binding.recyclerView.callOnScrollListeners()
private fun onListChanged(list: List<ListModel>) {
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 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)
}
}
}
}
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
}
}
}
else -> LinearLayoutManager(ctx)
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {
init {
isSpanIndexCacheEnabled = true
isSpanGroupIndexCacheEnabled = true
}
binding.recyclerView.addItemDecoration(
when (mode) {
ListMode.LIST -> DividerItemDecoration(ctx, RecyclerView.VERTICAL)
ListMode.DETAILED_LIST,
ListMode.GRID -> SpacingItemDecoration(
resources.getDimensionPixelOffset(R.dimen.grid_spacing)
)
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()
}
}
}

View File

@@ -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<List<Any>>
val isEmptyState = MutableLiveData(false)
abstract val content: LiveData<List<ListModel>>
val filter = MutableLiveData<MangaFilterConfig>()
val listMode = MutableLiveData<ListMode>()
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()
}

View File

@@ -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<EmptyState, ListModel>(R.layout.item_empty_state) {
bind {
(itemView as TextView).setText(item.text)
}
}

View File

@@ -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<ErrorFooter, ListModel, ItemErrorFooterBinding>(
{ 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)
}
}

View File

@@ -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<ErrorState, ListModel, ItemErrorStateBinding>(
{ 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
}
}

View File

@@ -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<IndeterminateProgress, Any>(R.layout.item_progress) {
}

View File

@@ -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<LoadingFooter, ListModel>(R.layout.item_loading_footer) {
}

View File

@@ -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<LoadingState, ListModel>(R.layout.item_loading_state) {
}

View File

@@ -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<Manga>
) = adapterDelegateViewBinding<MangaGridModel, Any, ItemMangaGridBinding>(
) = adapterDelegateViewBinding<MangaGridModel, ListModel, ItemMangaGridBinding>(
{ inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) }
) {

View File

@@ -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<Manga>
) : AsyncListDifferDelegationAdapter<Any>(DiffCallback()) {
clickListener: OnListItemClickListener<Manga>,
onRetryClick: () -> Unit
) : AsyncListDifferDelegationAdapter<ListModel>(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<Any>() {
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
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
}
}

View File

@@ -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<Manga>
) = adapterDelegateViewBinding<MangaListDetailedModel, Any, ItemMangaListDetailsBinding>(
) = adapterDelegateViewBinding<MangaListDetailedModel, ListModel, ItemMangaListDetailsBinding>(
{ inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) }
) {

View File

@@ -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<Manga>
) = adapterDelegateViewBinding<MangaListModel, Any, ItemMangaListBinding>(
) = adapterDelegateViewBinding<MangaListModel, ListModel, ItemMangaListBinding>(
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) }
) {

View File

@@ -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<DateTimeAgo, Any>(R.layout.item_header) {
fun relatedDateItemAD() = adapterDelegate<DateTimeAgo, ListModel>(R.layout.item_header) {
bind {
(itemView as TextView).text = item.format(context.resources)

View File

@@ -0,0 +1,7 @@
package org.koitharu.kotatsu.list.ui.model
import androidx.annotation.StringRes
data class EmptyState(
@StringRes val text: Int
) : ListModel

View File

@@ -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

View File

@@ -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

View File

@@ -1,3 +1,3 @@
package org.koitharu.kotatsu.list.ui.model
object IndeterminateProgress
interface ListModel

View File

@@ -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<Manga>.toUi(mode: ListMode): List<ListModel> = when(mode) {
ListMode.LIST -> map(Manga::toListModel)
ListMode.DETAILED_LIST -> map(Manga::toListDetailedModel)
ListMode.GRID -> map(Manga::toGridModel)
}
fun <C : MutableCollection<ListModel>> List<Manga>.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
)

View File

@@ -0,0 +1,3 @@
package org.koitharu.kotatsu.list.ui.model
object LoadingFooter : ListModel

View File

@@ -0,0 +1,3 @@
package org.koitharu.kotatsu.list.ui.model
object LoadingState : ListModel

View File

@@ -7,4 +7,4 @@ data class MangaGridModel(
val title: String,
val coverUrl: String,
val manga: Manga
)
) : ListModel

View File

@@ -10,4 +10,4 @@ data class MangaListDetailedModel(
val coverUrl: String,
val rating: String?,
val manga: Manga
)
) : ListModel

View File

@@ -8,4 +8,4 @@ data class MangaListModel(
val subtitle: String,
val coverUrl: String,
val manga: Manga
)
) : ListModel

View File

@@ -31,11 +31,6 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> {
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<Uri> {
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)

View File

@@ -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<Manga>()
private val mangaList = MutableStateFlow<List<Manga>>(emptyList())
private val listError = MutableStateFlow<Throwable?>(null)
private val mangaList = MutableStateFlow<List<Manga>?>(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

View File

@@ -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<DialogChaptersBinding>(),
@@ -32,14 +35,34 @@ class ChaptersDialog : AlertDialogFragment<DialogChaptersBinding>(),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.recyclerViewChapters.addItemDecoration(
DividerItemDecoration(
requireContext(),
RecyclerView.VERTICAL
)
DividerItemDecoration(requireContext(), RecyclerView.VERTICAL)
)
val chapters = arguments?.getParcelableArrayList<MangaChapter>(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<MangaChapter>(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<DialogChaptersBinding>(),
private const val ARG_CURRENT_ID = "current_id"
fun show(fm: FragmentManager, chapters: List<MangaChapter>, 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)
}
}

View File

@@ -21,13 +21,8 @@ class RemoteListFragment : MangaListFragment() {
private val source by parcelableArgument<MangaSource>(ARG_SOURCE)
override fun onRefresh() {
super.onRefresh()
viewModel.loadList(append = false)
}
override fun onScrolledToEnd() {
viewModel.loadList(append = true)
viewModel.loadNextPage()
}
override fun getTitle(): CharSequence? {

View File

@@ -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<List<Manga>>(emptyList())
private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val hasNextPage = MutableStateFlow(false)
private val listError = MutableStateFlow<Throwable?>(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<ListModel>(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()
}
}

View File

@@ -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)

View File

@@ -17,13 +17,8 @@ class SearchFragment : MangaListFragment() {
private val query by stringArgument(ARG_QUERY)
private val source by parcelableArgument<MangaSource>(ARG_SOURCE)
override fun onRefresh() {
super.onRefresh()
viewModel.loadList(append = false)
}
override fun onScrolledToEnd() {
viewModel.loadList(append = true)
viewModel.loadNextPage()
}
override fun getTitle() = query

View File

@@ -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<List<Manga>>(emptyList())
private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val hasNextPage = MutableStateFlow(false)
private val listError = MutableStateFlow<Throwable?>(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<ListModel>(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()
}
}
}

View File

@@ -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? {

View File

@@ -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<List<Manga>>(emptyList())
private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val hasNextPage = MutableStateFlow(false)
private val listError = MutableStateFlow<Throwable?>(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<ListModel>(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)
}
}

View File

@@ -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<FragmentFeedBinding>(), PaginationScrollListen
super.onDestroyView()
}
private fun onListChanged(list: List<Any>) {
private fun onListChanged(list: List<ListModel>) {
feedAdapter?.items = list
}

View File

@@ -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<List<TrackingLogItem>>(emptyList())
private val logList = MutableStateFlow<List<TrackingLogItem>?>(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()
}

View File

@@ -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<Manga>
) : AsyncListDifferDelegationAdapter<Any>(DiffCallback()) {
) : AsyncListDifferDelegationAdapter<ListModel>(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<Any>() {
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
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
}
}

View File

@@ -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<Manga>
) = adapterDelegateViewBinding<FeedItem, Any, ItemTracklogBinding>(
) = adapterDelegateViewBinding<FeedItem, ListModel, ItemTracklogBinding>(
{ inflater, parent -> ItemTracklogBinding.inflate(inflater, parent, false) }
) {

View File

@@ -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
)
) : ListModel

View File

@@ -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 <T> measured(tag: String, block: () -> T): T {

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M12,2L1,21H23M12,6L19.53,19H4.47M11,10V14H13V10M11,16V18H13V16" />
</vector>

View File

@@ -8,69 +8,35 @@
android:animateLayoutChanges="true"
android:orientation="horizontal">
<FrameLayout
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
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" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="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" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<LinearLayout
android:id="@+id/layout_holder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:id="@+id/textView_holder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:textAppearance="?android:textAppearanceMedium"
android:textColor="?android:textColorSecondary"
tools:text="@tools:sample/lorem[3]" />
</LinearLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<View
android:id="@+id/divider_filter"
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="?dividerVertical"
android:visibility="gone" />
android:visibility="gone"
tools:visibility="visible" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView_filter"
@@ -82,6 +48,7 @@
android:scrollbars="vertical"
android:visibility="gone"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_category_checkable" />
tools:listitem="@layout/item_category_checkable"
tools:visibility="visible" />
</LinearLayout>

View File

@@ -11,13 +11,12 @@
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.google.android.material.progressindicator.CircularProgressIndicator
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="40dp"
android:indeterminate="true"
app:indicatorColor="?colorAccent" />
android:indeterminate="true" />
</FrameLayout>

View File

@@ -12,61 +12,26 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
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" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="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" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<LinearLayout
android:id="@+id/layout_holder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:id="@+id/textView_holder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:textAppearance="?android:textAppearanceMedium"
android:textColor="?android:textColorSecondary"
tools:text="@tools:sample/lorem[3]" />
</LinearLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView_filter"

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:textAppearance="?android:textAppearanceMedium"
android:textColor="?android:textColorSecondary"
tools:text="@tools:sample/lorem[3]" />

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="@dimen/list_footer_height"
android:background="?selectableItemBackground"
android:gravity="center"
android:orientation="horizontal"
android:padding="4dp">
<ImageView
android:contentDescription="@null"
android:id="@+id/imageView_icon"
tools:src="@drawable/ic_alert_outline"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:orientation="vertical">
<TextView
android:id="@+id/textView_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="?android:textColorPrimary"
tools:text="@string/error_occurred" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tap_to_try_again"
android:textAppearance="?android:textAppearanceSmall"
android:textColor="?android:textColorSecondary" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/textView_error"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawablePadding="12dp"
android:gravity="center_horizontal"
android:textAppearance="?android:textAppearanceMedium"
android:textColor="?android:textColorSecondary"
app:drawableTopCompat="@drawable/ic_error_large"
tools:text="@tools:sample/lorem[6]" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_retry"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/try_again" />
</LinearLayout>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="@dimen/list_footer_height"
android:padding="6dp">
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="center"
android:indeterminate="true" />
</FrameLayout>

View File

@@ -2,14 +2,13 @@
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="4dp">
android:layout_height="match_parent">
<ProgressBar
android:id="@+id/progressBar"
style="@style/Widget.AppCompat.ProgressBar"
style="?android:progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
android:layout_gravity="center"
android:indeterminate="true" />
</FrameLayout>

View File

@@ -185,4 +185,5 @@
<string name="long_ago">Давно</string>
<string name="group">Группировать</string>
<string name="today">Сегодня</string>
<string name="tap_to_try_again">Tap to try again</string>
</resources>

View File

@@ -7,4 +7,5 @@
<dimen name="preferred_grid_width">120dp</dimen>
<dimen name="header_height">34dp</dimen>
<dimen name="elevation_large">16dp</dimen>
<dimen name="list_footer_height">48dp</dimen>
</resources>

View File

@@ -187,4 +187,5 @@
<string name="long_ago">Long ago</string>
<string name="group">Group</string>
<string name="today">Today</string>
<string name="tap_to_try_again">Tap to try again</string>
</resources>