Move list states to adapter delegates

pull/26/head
Koitharu 5 years ago
parent 90be936c82
commit 9b5510ac59

@ -9,8 +9,11 @@ import org.koitharu.kotatsu.utils.ext.getThemeColor
class ChipsFactory(val context: Context) { class ChipsFactory(val context: Context) {
fun create( fun create(
convertView: Chip? = null, text: CharSequence, @DrawableRes iconRes: Int = 0, convertView: Chip? = null,
tag: Any? = null, onClickListener: View.OnClickListener? = null text: CharSequence,
@DrawableRes iconRes: Int = 0,
tag: Any? = null,
onClickListener: View.OnClickListener? = null
): Chip { ): Chip {
val chip = convertView ?: Chip(context).apply { val chip = convertView ?: Chip(context).apply {
setTextColor(context.getThemeColor(android.R.attr.textColorPrimary)) setTextColor(context.getThemeColor(android.R.attr.textColorPrimary))

@ -2,8 +2,9 @@ package org.koitharu.kotatsu.core.ui
import android.content.res.Resources import android.content.res.Resources
import org.koitharu.kotatsu.R 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 abstract fun format(resources: Resources): String

@ -10,7 +10,6 @@ import androidx.core.text.parseAsHtml
import androidx.core.view.isVisible import androidx.core.view.isVisible
import coil.ImageLoader import coil.ImageLoader
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.chip.Chip
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
import org.koitharu.kotatsu.reader.ui.ReaderActivity 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.FileSizeUtils
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -71,8 +69,7 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
create( create(
text = it, text = it,
iconRes = R.drawable.ic_chip_user, iconRes = R.drawable.ic_chip_user,
tag = it, tag = it
onClickListener = this@DetailsFragment
) )
} }
} }
@ -80,8 +77,7 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
create( create(
text = it.title, text = it.title,
iconRes = R.drawable.ic_chip_tag, iconRes = R.drawable.ic_chip_tag,
tag = it, tag = it
onClickListener = this@DetailsFragment
) )
} }
manga.url.toUri().toFileOrNull()?.let { f -> manga.url.toUri().toFileOrNull()?.let { f ->
@ -93,8 +89,7 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
create( create(
text = FileSizeUtils.formatBytes(context, size), text = FileSizeUtils.formatBytes(context, size),
iconRes = R.drawable.ic_chip_storage, iconRes = R.drawable.ic_chip_storage,
tag = it, tag = it
onClickListener = this@DetailsFragment
) )
} }
} }
@ -134,11 +129,11 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
override fun onClick(v: View) { override fun onClick(v: View) {
val manga = viewModel.manga.value val manga = viewModel.manga.value
when { when (v.id) {
v.id == R.id.imageView_favourite -> { R.id.imageView_favourite -> {
FavouriteCategoriesDialog.show(childFragmentManager, manga ?: return) FavouriteCategoriesDialog.show(childFragmentManager, manga ?: return)
} }
v.id == R.id.button_read -> { R.id.button_read -> {
startActivity( startActivity(
ReaderActivity.newIntent( ReaderActivity.newIntent(
context ?: return, 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
)
}
}
} }
} }

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

@ -23,17 +23,6 @@ class FavouritesListFragment : MangaListFragment() {
override fun onScrolledToEnd() = Unit 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) { override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) {
super.onCreatePopupMenu(inflater, menu, data) super.onCreatePopupMenu(inflater, menu, data)
inflater.inflate(R.menu.popup_favourites, menu) inflater.inflate(R.menu.popup_favourites, menu)

@ -3,17 +3,18 @@ package org.koitharu.kotatsu.favourites.ui.list
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.prefs.AppSettings 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.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.toGridModel import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.toListDetailedModel import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toListModel import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.utils.ext.onFirst import org.koitharu.kotatsu.utils.ext.onFirst
class FavouritesListViewModel( class FavouritesListViewModel(
@ -26,19 +27,30 @@ class FavouritesListViewModel(
if (categoryId == 0L) repository.observeAll() else repository.observeAll(categoryId), if (categoryId == 0L) repository.observeAll() else repository.observeAll(categoryId),
createListModeFlow() createListModeFlow()
) { list, mode -> ) { list, mode ->
when (mode) { when {
ListMode.LIST -> list.map { it.toListModel() } list.isEmpty() -> listOf(
ListMode.DETAILED_LIST -> list.map { it.toListDetailedModel() } EmptyState(
ListMode.GRID -> list.map { it.toGridModel() } 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 { }.onFirst {
isLoading.postValue(false) isLoading.postValue(false)
}.onStart {
emit(listOf(LoadingState))
}.catch {
emit(listOf(it.toErrorState(canRetry = false)))
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
override fun onRefresh() = Unit
override fun onRetry() = Unit
fun removeFromFavourites(manga: Manga) { fun removeFromFavourites(manga: Manga) {
launchJob { launchJob {
if (categoryId == 0L) { if (categoryId == 0L) {

@ -63,11 +63,6 @@ class HistoryListFragment : MangaListFragment() {
return context?.getString(R.string.history) 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) { override fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) {
super.onCreatePopupMenu(inflater, menu, data) super.onCreatePopupMenu(inflater, menu, data)
inflater.inflate(R.menu.popup_history, menu) inflater.inflate(R.menu.popup_history, menu)

@ -7,6 +7,7 @@ import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode 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.HistoryRepository
import org.koitharu.kotatsu.history.domain.MangaWithHistory import org.koitharu.kotatsu.history.domain.MangaWithHistory
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.toGridModel import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.list.ui.model.toListDetailedModel
import org.koitharu.kotatsu.list.ui.model.toListModel
import org.koitharu.kotatsu.utils.MangaShortcut import org.koitharu.kotatsu.utils.MangaShortcut
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.daysDiff import org.koitharu.kotatsu.utils.ext.daysDiff
@ -44,16 +43,24 @@ class HistoryListViewModel(
override val content = combine( override val content = combine(
repository.observeAllWithHistory(), repository.observeAllWithHistory(),
historyGrouping, historyGrouping,
createListModeFlow(), createListModeFlow()
::mapList ) { list, grouped, mode ->
).onEach { when {
isEmptyState.postValue(it.isEmpty()) list.isEmpty() -> listOf(EmptyState(R.string.text_history_holder))
}.onStart { else -> mapList(list, grouped, mode)
isLoading.postValue(true) }
}.onFirst { }.onFirst {
isLoading.postValue(false) isLoading.postValue(false)
}.onStart {
emit(listOf(LoadingState))
}.catch {
it.toErrorState(canRetry = false)
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
override fun onRefresh() = Unit
override fun onRetry() = Unit
fun clearHistory() { fun clearHistory() {
launchLoadingJob { launchLoadingJob {
repository.clear() repository.clear()
@ -77,8 +84,8 @@ class HistoryListViewModel(
settings.historyGrouping = isGroupingEnabled settings.historyGrouping = isGroupingEnabled
} }
private fun mapList(list: List<MangaWithHistory>, grouped: Boolean, mode: ListMode): List<Any> { private fun mapList(list: List<MangaWithHistory>, grouped: Boolean, mode: ListMode): List<ListModel> {
val result = ArrayList<Any>((list.size * 1.4).toInt()) val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size)
var prevDate: DateTimeAgo? = null var prevDate: DateTimeAgo? = null
for ((manga, history) in list) { for ((manga, history) in list) {
if (grouped) { if (grouped) {

@ -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.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.filter.FilterAdapter import org.koitharu.kotatsu.list.ui.filter.FilterAdapter
import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener 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.clearItemDecorations
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.hasItems
import org.koitharu.kotatsu.utils.ext.toggleDrawer import org.koitharu.kotatsu.utils.ext.toggleDrawer
abstract class MangaListFragment : BaseFragment<FragmentListBinding>(), abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
@ -62,7 +62,9 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) binding.drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
listAdapter = MangaListAdapter(get(), viewLifecycleOwner, this) listAdapter = MangaListAdapter(get(), viewLifecycleOwner, this) {
viewModel.onRetry()
}
paginationListener = PaginationScrollListener(4, this) paginationListener = PaginationScrollListener(4, this)
with(binding.recyclerView) { with(binding.recyclerView) {
setHasFixedSize(true) setHasFixedSize(true)
@ -85,7 +87,6 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged) viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged)
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged)
viewModel.isEmptyState.observe(viewLifecycleOwner, ::onEmptyStateChanged)
} }
override fun onDestroyView() { override fun onDestroyView() {
@ -140,9 +141,10 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
@CallSuper @CallSuper
override fun onRefresh() { override fun onRefresh() {
binding.swipeRefreshLayout.isRefreshing = true binding.swipeRefreshLayout.isRefreshing = true
viewModel.onRefresh()
} }
private fun onListChanged(list: List<Any>) { private fun onListChanged(list: List<ListModel>) {
spanSizeLookup.invalidateCache() spanSizeLookup.invalidateCache()
listAdapter?.items = list listAdapter?.items = list
} }
@ -150,45 +152,24 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
private fun onError(e: Throwable) { private fun onError(e: Throwable) {
if (e is CloudFlareProtectedException) { if (e is CloudFlareProtectedException) {
CloudFlareDialog.newInstance(e.url).show(childFragmentManager, CloudFlareDialog.TAG) 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 { } else {
Snackbar.make( Snackbar.make(
binding.recyclerView, binding.recyclerView,
e.getDisplayMessage(resources), e.getDisplayMessage(resources),
Snackbar.LENGTH_SHORT Snackbar.LENGTH_SHORT
) ).show()
.show()
} }
} }
@CallSuper @CallSuper
protected open fun onLoadingStateChanged(isLoading: Boolean) { protected open fun onLoadingStateChanged(isLoading: Boolean) {
val hasItems = binding.recyclerView.hasItems
binding.progressBar.isVisible =
isLoading && !hasItems && viewModel.isEmptyState.value != true
binding.swipeRefreshLayout.isEnabled = binding.swipeRefreshLayout.isEnabled =
isSwipeRefreshEnabled && !binding.progressBar.isVisible isSwipeRefreshEnabled && !isLoading
if (!isLoading) { if (!isLoading) {
binding.swipeRefreshLayout.isRefreshing = false binding.swipeRefreshLayout.isRefreshing = false
} }
} }
private fun onEmptyStateChanged(isEmpty: Boolean) {
if (isEmpty) {
setUpEmptyListHolder()
}
binding.layoutHolder.isVisible = isEmpty
}
protected fun onInitFilter(config: MangaFilterConfig) { protected fun onInitFilter(config: MangaFilterConfig) {
binding.recyclerViewFilter.adapter = FilterAdapter( binding.recyclerViewFilter.adapter = FilterAdapter(
sortOrders = config.sortOrders, sortOrders = config.sortOrders,
@ -214,13 +195,6 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
binding.drawer?.closeDrawers() 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) { private fun onGridScaleChanged(scale: Float) {
spanSizeLookup.invalidateCache() spanSizeLookup.invalidateCache()
spanResolver.setGridSize(scale, binding.recyclerView) spanResolver.setGridSize(scale, binding.recyclerView)
@ -293,9 +267,8 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
val total = val total =
(binding.recyclerView.layoutManager as? GridLayoutManager)?.spanCount ?: return 1 (binding.recyclerView.layoutManager as? GridLayoutManager)?.spanCount ?: return 1
return when (listAdapter?.getItemViewType(position)) { return when (listAdapter?.getItemViewType(position)) {
MangaListAdapter.ITEM_TYPE_DATE, MangaListAdapter.ITEM_TYPE_MANGA_GRID -> 1
MangaListAdapter.ITEM_TYPE_PROGRESS -> total else -> total
else -> 1
} }
} }

@ -1,6 +1,5 @@
package org.koitharu.kotatsu.list.ui package org.koitharu.kotatsu.list.ui
import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem 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.bottomsheet.BottomSheetDialog
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration 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.model.Manga
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.databinding.SheetListBinding import org.koitharu.kotatsu.databinding.SheetListBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter 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.* import org.koitharu.kotatsu.utils.ext.*
abstract class MangaListSheet : BaseBottomSheet<SheetListBinding>(), abstract class MangaListSheet : BaseBottomSheet<SheetListBinding>(),
PaginationScrollListener.Callback, OnListItemClickListener<Manga>, PaginationScrollListener.Callback, OnListItemClickListener<Manga>,
SharedPreferences.OnSharedPreferenceChangeListener, Toolbar.OnMenuItemClickListener { Toolbar.OnMenuItemClickListener {
private val settings by inject<AppSettings>() private var listAdapter: MangaListAdapter? = null
private var paginationListener: PaginationScrollListener? = null
private var adapter: MangaListAdapter? = null private val spanResolver = MangaListSpanResolver()
private val spanSizeLookup = SpanSizeLookup()
open val isSwipeRefreshEnabled = true
protected abstract val viewModel: MangaListViewModel protected abstract val viewModel: MangaListViewModel
@ -47,33 +48,39 @@ abstract class MangaListSheet : BaseBottomSheet<SheetListBinding>(),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
adapter = MangaListAdapter(get(), viewLifecycleOwner, this) listAdapter = MangaListAdapter(get(), viewLifecycleOwner, this) {
initListMode(settings.listMode) viewModel.onRetry()
binding.recyclerView.adapter = adapter }
binding.recyclerView.addOnScrollListener(PaginationScrollListener(4, this)) paginationListener = PaginationScrollListener(4, this)
settings.subscribe(this) with(binding.recyclerView) {
binding.toolbar.inflateMenu(R.menu.opt_list_sheet) setHasFixedSize(true)
binding.toolbar.setOnMenuItemClickListener(this) adapter = listAdapter
binding.toolbar.setNavigationOnClickListener { addOnScrollListener(paginationListener!!)
}
with(binding.toolbar) {
inflateMenu(R.menu.opt_list_sheet)
setOnMenuItemClickListener(this@MangaListSheet)
setNavigationOnClickListener {
dismiss() dismiss()
} }
}
if (dialog !is BottomSheetDialog) { if (dialog !is BottomSheetDialog) {
binding.toolbar.isVisible = true binding.toolbar.isVisible = true
binding.textViewTitle.isVisible = false binding.textViewTitle.isVisible = false
binding.appbar.elevation = resources.getDimension(R.dimen.elevation_large) binding.appbar.elevation = resources.getDimension(R.dimen.elevation_large)
} }
if (savedInstanceState == null) {
onScrolledToEnd()
}
viewModel.content.observe(viewLifecycleOwner, ::onListChanged) viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observe(viewLifecycleOwner, ::onError)
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.listMode.observe(viewLifecycleOwner, ::initListMode) viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged)
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged)
} }
override fun onDestroyView() { override fun onDestroyView() {
settings.unsubscribe(this) listAdapter = null
adapter = null paginationListener = null
spanSizeLookup.invalidateCache()
super.onDestroyView() super.onDestroyView()
} }
@ -117,65 +124,94 @@ abstract class MangaListSheet : BaseBottomSheet<SheetListBinding>(),
else -> false 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) { override fun onItemClick(item: Manga, view: View) {
startActivity(DetailsActivity.newIntent(context ?: return, item)) startActivity(DetailsActivity.newIntent(context ?: return, item))
} }
private fun onListChanged(list: List<Any>) { private fun onListChanged(list: List<ListModel>) {
adapter?.items = list spanSizeLookup.invalidateCache()
binding.textViewHolder.isVisible = list.isEmpty() listAdapter?.items = list
binding.recyclerView.callOnScrollListeners()
} }
private fun onError(e: Throwable) { private fun onError(e: Throwable) {
Snackbar.make(binding.recyclerView, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT) if (e is CloudFlareProtectedException) {
.show() 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) { private fun onLoadingStateChanged(isLoading: Boolean) {
binding.progressBar.isVisible = isLoading && !binding.recyclerView.hasItems binding.progressBar.isVisible =
if (isLoading) { isLoading && !binding.recyclerView.hasItems
binding.textViewHolder.isVisible = false
}
} }
private fun initListMode(mode: ListMode) { private fun onGridScaleChanged(scale: Float) {
val ctx = context ?: return spanSizeLookup.invalidateCache()
val position = binding.recyclerView.firstItem spanResolver.setGridSize(scale, binding.recyclerView)
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)
)
)
} }
else -> LinearLayoutManager(ctx) ListMode.GRID -> {
layoutManager = GridLayoutManager(context, spanResolver.spanCount).also {
it.spanSizeLookup = spanSizeLookup
} }
binding.recyclerView.addItemDecoration( addItemDecoration(
when (mode) { SpacingItemDecoration(
ListMode.LIST -> DividerItemDecoration(ctx, RecyclerView.VERTICAL)
ListMode.DETAILED_LIST,
ListMode.GRID -> SpacingItemDecoration(
resources.getDimensionPixelOffset(R.dimen.grid_spacing) resources.getDimensionPixelOffset(R.dimen.grid_spacing)
) )
}
) )
if (mode == ListMode.GRID) { addOnLayoutChangeListener(spanResolver)
binding.recyclerView.addOnLayoutChangeListener(UiUtils.SpanCountResolver) }
}
}
}
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
}
}
fun invalidateCache() {
invalidateSpanGroupIndexCache()
invalidateSpanIndexCache()
} }
adapter?.notifyDataSetChanged()
binding.recyclerView.firstItem = position
} }
} }

@ -9,13 +9,13 @@ import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.list.ui.model.ListModel
abstract class MangaListViewModel( abstract class MangaListViewModel(
private val settings: AppSettings private val settings: AppSettings
) : BaseViewModel() { ) : BaseViewModel() {
abstract val content: LiveData<List<Any>> abstract val content: LiveData<List<ListModel>>
val isEmptyState = MutableLiveData(false)
val filter = MutableLiveData<MangaFilterConfig>() val filter = MutableLiveData<MangaFilterConfig>()
val listMode = MutableLiveData<ListMode>() val listMode = MutableLiveData<ListMode>()
val gridScale = settings.observe() val gridScale = settings.observe()
@ -30,4 +30,8 @@ abstract class MangaListViewModel(
.onStart { emit(settings.listMode) } .onStart { emit(settings.listMode) }
.distinctUntilChanged() .distinctUntilChanged()
.onEach { listMode.postValue(it) } .onEach { listMode.postValue(it) }
abstract fun onRefresh()
abstract fun onRetry()
} }

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

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

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

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

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

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

@ -9,6 +9,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.databinding.ItemMangaGridBinding 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.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
@ -17,7 +18,7 @@ fun mangaGridItemAD(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga> clickListener: OnListItemClickListener<Manga>
) = adapterDelegateViewBinding<MangaGridModel, Any, ItemMangaGridBinding>( ) = adapterDelegateViewBinding<MangaGridModel, ListModel, ItemMangaGridBinding>(
{ inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) }
) { ) {

@ -6,8 +6,7 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.ui.DateTimeAgo import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.IndeterminateProgress
import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel
@ -16,8 +15,9 @@ import kotlin.jvm.internal.Intrinsics
class MangaListAdapter( class MangaListAdapter(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga> clickListener: OnListItemClickListener<Manga>,
) : AsyncListDifferDelegationAdapter<Any>(DiffCallback()) { onRetryClick: () -> Unit
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init { init {
delegatesManager delegatesManager
@ -30,13 +30,17 @@ class MangaListAdapter(
mangaListDetailedItemAD(coil, lifecycleOwner, clickListener) mangaListDetailedItemAD(coil, lifecycleOwner, clickListener)
) )
.addDelegate(ITEM_TYPE_MANGA_GRID, mangaGridItemAD(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_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 is MangaListModel && newItem is MangaListModel -> {
oldItem.id == newItem.id oldItem.id == newItem.id
} }
@ -46,16 +50,10 @@ class MangaListAdapter(
oldItem is MangaGridModel && newItem is MangaGridModel -> { oldItem is MangaGridModel && newItem is MangaGridModel -> {
oldItem.id == newItem.id oldItem.id == newItem.id
} }
oldItem == IndeterminateProgress && newItem == IndeterminateProgress -> { else -> oldItem.javaClass == newItem.javaClass
true
}
oldItem is DateTimeAgo && newItem is DateTimeAgo -> {
oldItem == newItem
}
else -> false
} }
override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean { override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return Intrinsics.areEqual(oldItem, newItem) return Intrinsics.areEqual(oldItem, newItem)
} }
} }
@ -65,7 +63,11 @@ class MangaListAdapter(
const val ITEM_TYPE_MANGA_LIST = 0 const val ITEM_TYPE_MANGA_LIST = 0
const val ITEM_TYPE_MANGA_LIST_DETAILED = 1 const val ITEM_TYPE_MANGA_LIST_DETAILED = 1
const val ITEM_TYPE_MANGA_GRID = 2 const val ITEM_TYPE_MANGA_GRID = 2
const val ITEM_TYPE_PROGRESS = 3 const val ITEM_TYPE_LOADING_FOOTER = 3
const val ITEM_TYPE_DATE = 4 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
} }
} }

@ -9,6 +9,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding 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.list.ui.model.MangaListDetailedModel
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
@ -18,7 +19,7 @@ fun mangaListDetailedItemAD(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga> clickListener: OnListItemClickListener<Manga>
) = adapterDelegateViewBinding<MangaListDetailedModel, Any, ItemMangaListDetailsBinding>( ) = adapterDelegateViewBinding<MangaListDetailedModel, ListModel, ItemMangaListDetailsBinding>(
{ inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) }
) { ) {

@ -9,6 +9,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.databinding.ItemMangaListBinding 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.list.ui.model.MangaListModel
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
@ -18,7 +19,7 @@ fun mangaListItemAD(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga> clickListener: OnListItemClickListener<Manga>
) = adapterDelegateViewBinding<MangaListModel, Any, ItemMangaListBinding>( ) = adapterDelegateViewBinding<MangaListModel, ListModel, ItemMangaListBinding>(
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) }
) { ) {

@ -4,8 +4,9 @@ import android.widget.TextView
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.DateTimeAgo 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 { bind {
(itemView as TextView).text = item.format(context.resources) (itemView as TextView).text = item.format(context.resources)

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

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

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

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

@ -1,6 +1,8 @@
package org.koitharu.kotatsu.list.ui.model package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.prefs.ListMode
import kotlin.math.roundToInt import kotlin.math.roundToInt
fun Manga.toListModel() = MangaListModel( fun Manga.toListModel() = MangaListModel(
@ -27,3 +29,26 @@ fun Manga.toGridModel() = MangaGridModel(
coverUrl = coverUrl, coverUrl = coverUrl,
manga = this 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
)

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

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

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

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

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

@ -31,11 +31,6 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> {
viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved) viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved)
} }
override fun onRefresh() {
super.onRefresh()
viewModel.onRefresh()
}
override fun onScrolledToEnd() = Unit override fun onScrolledToEnd() = Unit
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@ -68,11 +63,6 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> {
return context?.getString(R.string.local_storage) 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?) { override fun onActivityResult(result: Uri?) {
if (result != null) { if (result != null) {
viewModel.importFile(result) viewModel.importFile(result)

@ -8,16 +8,18 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.prefs.AppSettings 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.history.domain.HistoryRepository
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.toGridModel import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.toListDetailedModel import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toListModel 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.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.utils.MangaShortcut import org.koitharu.kotatsu.utils.MangaShortcut
import org.koitharu.kotatsu.utils.MediaStoreCompat import org.koitharu.kotatsu.utils.MediaStoreCompat
@ -34,30 +36,41 @@ class LocalListViewModel(
) : MangaListViewModel(settings) { ) : MangaListViewModel(settings) {
val onMangaRemoved = SingleLiveEvent<Manga>() 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 -> override val content = combine(
when (mode) { mangaList,
ListMode.LIST -> list.map { it.toListModel() } createListModeFlow(),
ListMode.DETAILED_LIST -> list.map { it.toListDetailedModel() } listError
ListMode.GRID -> list.map { it.toGridModel() } ) { 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) }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
init { init {
onRefresh() onRefresh()
} }
fun onRefresh() { override fun onRefresh() {
launchLoadingJob { launchLoadingJob(Dispatchers.Default) {
withContext(Dispatchers.Default) { try {
val list = repository.getList(0) listError.value = null
mangaList.value = list mangaList.value = repository.getList(0)
isEmptyState.postValue(list.isEmpty()) } catch (e: Throwable) {
listError.value = e
} }
} }
} }
override fun onRetry() = onRefresh()
fun importFile(uri: Uri) { fun importFile(uri: Uri) {
launchLoadingJob { launchLoadingJob {
val contentResolver = context.contentResolver val contentResolver = context.contentResolver

@ -7,6 +7,7 @@ import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment 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.core.model.MangaChapter
import org.koitharu.kotatsu.databinding.DialogChaptersBinding import org.koitharu.kotatsu.databinding.DialogChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter 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 import org.koitharu.kotatsu.utils.ext.withArgs
class ChaptersDialog : AlertDialogFragment<DialogChaptersBinding>(), class ChaptersDialog : AlertDialogFragment<DialogChaptersBinding>(),
@ -32,14 +35,34 @@ class ChaptersDialog : AlertDialogFragment<DialogChaptersBinding>(),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.recyclerViewChapters.addItemDecoration( binding.recyclerViewChapters.addItemDecoration(
DividerItemDecoration( DividerItemDecoration(requireContext(), RecyclerView.VERTICAL)
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 { binding.recyclerViewChapters.adapter = ChaptersAdapter(this).apply {
// arguments?.getParcelableArrayList<MangaChapter>(ARG_CHAPTERS)?.let(this::setItems) setItems(chapters.mapIndexed { index, chapter ->
// currentChapterId = arguments?.getLong(ARG_CURRENT_ID, 0L)?.takeUnless { it == 0L } 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,8 +87,7 @@ class ChaptersDialog : AlertDialogFragment<DialogChaptersBinding>(),
private const val ARG_CURRENT_ID = "current_id" private const val ARG_CURRENT_ID = "current_id"
fun show(fm: FragmentManager, chapters: List<MangaChapter>, currentId: Long = 0L) = fun show(fm: FragmentManager, chapters: List<MangaChapter>, currentId: Long = 0L) =
ChaptersDialog() ChaptersDialog().withArgs(2) {
.withArgs(2) {
putParcelableArrayList(ARG_CHAPTERS, ArrayList(chapters)) putParcelableArrayList(ARG_CHAPTERS, ArrayList(chapters))
putLong(ARG_CURRENT_ID, currentId) putLong(ARG_CURRENT_ID, currentId)
}.show(fm, TAG) }.show(fm, TAG)

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

@ -6,41 +6,51 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaFilter import org.koitharu.kotatsu.core.model.MangaFilter
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings 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.MangaFilterConfig
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.IndeterminateProgress import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.list.ui.model.toGridModel import java.util.*
import org.koitharu.kotatsu.list.ui.model.toListDetailedModel
import org.koitharu.kotatsu.list.ui.model.toListModel
class RemoteListViewModel( class RemoteListViewModel(
private val repository: MangaRepository, private val repository: MangaRepository,
settings: AppSettings settings: AppSettings
) : MangaListViewModel(settings) { ) : MangaListViewModel(settings) {
private val mangaList = MutableStateFlow<List<Manga>>(emptyList()) private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val hasNextPage = MutableStateFlow(false) private val hasNextPage = MutableStateFlow(false)
private val listError = MutableStateFlow<Throwable?>(null)
private var appliedFilter: MangaFilter? = null private var appliedFilter: MangaFilter? = null
private var loadingJob: Job? = null private var loadingJob: Job? = null
override val content = combine(mangaList.drop(1), createListModeFlow()) { list, mode -> override val content = combine(
when (mode) { mangaList,
ListMode.LIST -> list.map { it.toListModel() } createListModeFlow(),
ListMode.DETAILED_LIST -> list.map { it.toListDetailedModel() } listError,
ListMode.GRID -> list.map { it.toGridModel() } hasNextPage
} ) { list, mode, error, hasNext ->
}.onEach { when {
isEmptyState.postValue(it.isEmpty()) list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true))
}.combine(hasNextPage) { list, isHasNextPage -> list == null -> listOf(LoadingState)
if (isHasNextPage && list.isNotEmpty()) list + IndeterminateProgress else list 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
}
}
}.onStart {
emit(listOf(LoadingState))
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
init { init {
@ -48,22 +58,41 @@ class RemoteListViewModel(
loadFilter() 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) { if (loadingJob?.isActive == true) {
return return
} }
loadingJob = launchLoadingJob(Dispatchers.Default) { loadingJob = launchLoadingJob(Dispatchers.Default) {
try {
listError.value = null
val list = repository.getList( val list = repository.getList(
offset = if (append) mangaList.value.size else 0, offset = if (append) mangaList.value?.size ?: 0 else 0,
sortOrder = appliedFilter?.sortOrder, sortOrder = appliedFilter?.sortOrder,
tag = appliedFilter?.tag tag = appliedFilter?.tag
) )
if (!append) { if (!append) {
mangaList.value = list mangaList.value = list
} else if (list.isNotEmpty()) { } else if (list.isNotEmpty()) {
mangaList.value += list mangaList.value = mangaList.value?.plus(list) ?: list
} }
hasNextPage.value = list.isNotEmpty() hasNextPage.value = list.isNotEmpty()
} catch (e: Throwable) {
listError.value = e
}
} }
} }

@ -29,7 +29,7 @@ class MangaSearchSheet : MangaListSheet() {
} }
override fun onScrolledToEnd() { override fun onScrolledToEnd() {
viewModel.loadList(append = true) viewModel.loadNextPage()
} }
companion object { companion object {
@ -39,6 +39,7 @@ class MangaSearchSheet : MangaListSheet() {
private const val TAG = "MangaSearchSheet" private const val TAG = "MangaSearchSheet"
@Deprecated("Not ready for use")
fun show(fm: FragmentManager, source: MangaSource, query: String) { fun show(fm: FragmentManager, source: MangaSource, query: String) {
MangaSearchSheet().withArgs(2) { MangaSearchSheet().withArgs(2) {
putParcelable(ARG_SOURCE, source) putParcelable(ARG_SOURCE, source)

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

@ -6,17 +6,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.onEach import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings 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.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.IndeterminateProgress import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.list.ui.model.toGridModel import java.util.*
import org.koitharu.kotatsu.list.ui.model.toListDetailedModel
import org.koitharu.kotatsu.list.ui.model.toListModel
class SearchViewModel( class SearchViewModel(
private val repository: MangaRepository, private val repository: MangaRepository,
@ -24,41 +21,73 @@ class SearchViewModel(
settings: AppSettings settings: AppSettings
) : MangaListViewModel(settings) { ) : MangaListViewModel(settings) {
private val mangaList = MutableStateFlow<List<Manga>>(emptyList()) private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val hasNextPage = MutableStateFlow(false) private val hasNextPage = MutableStateFlow(false)
private val listError = MutableStateFlow<Throwable?>(null)
private var loadingJob: Job? = null private var loadingJob: Job? = null
override val content = combine(mangaList.drop(1), createListModeFlow()) { list, mode -> override val content = combine(
when (mode) { mangaList,
ListMode.LIST -> list.map { it.toListModel() } createListModeFlow(),
ListMode.DETAILED_LIST -> list.map { it.toListDetailedModel() } listError,
ListMode.GRID -> list.map { it.toGridModel() } 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
} }
}.onEach { result
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) }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
init { init {
loadList(append = false) 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) { if (loadingJob?.isActive == true) {
return return
} }
loadingJob = launchLoadingJob(Dispatchers.Default) { loadingJob = launchLoadingJob(Dispatchers.Default) {
try {
listError.value = null
val list = repository.getList( val list = repository.getList(
offset = if (append) mangaList.value.size else 0, offset = if (append) mangaList.value?.size ?: 0 else 0,
query = query query = query
) )
if (!append) { if (!append) {
mangaList.value = list mangaList.value = list
} else if (list.isNotEmpty()) { } else if (list.isNotEmpty()) {
mangaList.value += list mangaList.value = mangaList.value?.plus(list) ?: list
} }
hasNextPage.value = list.isNotEmpty() hasNextPage.value = list.isNotEmpty()
} catch (e: Throwable) {
listError.value = e
}
} }
} }
} }

@ -15,11 +15,6 @@ class GlobalSearchFragment : MangaListFragment() {
private val query by stringArgument(ARG_QUERY) private val query by stringArgument(ARG_QUERY)
override fun onRefresh() {
super.onRefresh()
viewModel.onRefresh()
}
override fun onScrolledToEnd() = Unit override fun onScrolledToEnd() = Unit
override fun getTitle(): CharSequence? { override fun getTitle(): CharSequence? {

@ -6,16 +6,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.prefs.AppSettings 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.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.IndeterminateProgress import org.koitharu.kotatsu.list.ui.model.*
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.search.domain.MangaSearchRepository import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.utils.ext.onFirst import org.koitharu.kotatsu.utils.ext.onFirst
import java.util.*
class GlobalSearchViewModel( class GlobalSearchViewModel(
private val query: String, private val query: String,
@ -23,48 +21,63 @@ class GlobalSearchViewModel(
settings: AppSettings settings: AppSettings
) : MangaListViewModel(settings) { ) : MangaListViewModel(settings) {
private val mangaList = MutableStateFlow<List<Manga>>(emptyList()) private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val hasNextPage = MutableStateFlow(false) private val hasNextPage = MutableStateFlow(false)
private val listError = MutableStateFlow<Throwable?>(null)
private var searchJob: Job? = null private var searchJob: Job? = null
override val content = combine(mangaList.drop(1), createListModeFlow()) { list, mode -> override val content = combine(
when (mode) { mangaList,
ListMode.LIST -> list.map { it.toListModel() } createListModeFlow(),
ListMode.DETAILED_LIST -> list.map { it.toListDetailedModel() } listError,
ListMode.GRID -> list.map { it.toGridModel() } 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
} }
}.combine(hasNextPage) { list, isHasNextPage -> result
if (isHasNextPage && list.isNotEmpty()) list + IndeterminateProgress else list }
}
}.onStart {
emit(listOf(LoadingState))
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
init { init {
onRefresh() onRefresh()
} }
fun onRefresh() { override fun onRetry() {
onRefresh()
}
override fun onRefresh() {
searchJob?.cancel() searchJob?.cancel()
searchJob = repository.globalSearch(query) searchJob = repository.globalSearch(query)
.flowOn(Dispatchers.Default)
.catch { e -> .catch { e ->
onError.postCall(e) listError.value = e
isLoading.postValue(false) isLoading.postValue(false)
hasNextPage.value = false
}.filterNot { x -> x.isEmpty() } }.filterNot { x -> x.isEmpty() }
.onStart { .onStart {
listError.value = null
isLoading.postValue(true) isLoading.postValue(true)
hasNextPage.value = true
}.onEmpty { }.onEmpty {
mangaList.value = emptyList() mangaList.value = emptyList()
isEmptyState.postValue(true)
isLoading.postValue(false)
}.onCompletion { }.onCompletion {
isLoading.postValue(false) isLoading.postValue(false)
hasNextPage.value = false hasNextPage.value = false
}.onFirst { }.onFirst {
isEmptyState.postValue(false) isLoading.postValue(false)
hasNextPage.value = true
isLoading.value = false
}.onEach { }.onEach {
mangaList.value += it mangaList.value = mangaList.value?.plus(it) ?: it
}.launchIn(viewModelScope + Dispatchers.Default) }.launchIn(viewModelScope + Dispatchers.Default)
} }
} }

@ -14,6 +14,7 @@ import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.databinding.FragmentFeedBinding import org.koitharu.kotatsu.databinding.FragmentFeedBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity 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.ui.adapter.FeedAdapter
import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
@ -79,7 +80,7 @@ class FeedFragment : BaseFragment<FragmentFeedBinding>(), PaginationScrollListen
super.onDestroyView() super.onDestroyView()
} }
private fun onListChanged(list: List<Any>) { private fun onListChanged(list: List<ListModel>) {
feedAdapter?.items = list feedAdapter?.items = list
} }

@ -8,10 +8,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine 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.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.TrackingLogItem 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.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.ui.model.toFeedItem import org.koitharu.kotatsu.tracker.ui.model.toFeedItem
import org.koitharu.kotatsu.utils.ext.mapItems import org.koitharu.kotatsu.utils.ext.mapItems
@ -21,18 +23,20 @@ class FeedViewModel(
private val repository: TrackingRepository private val repository: TrackingRepository
) : BaseViewModel() { ) : BaseViewModel() {
private val logList = MutableStateFlow<List<TrackingLogItem>>(emptyList()) private val logList = MutableStateFlow<List<TrackingLogItem>?>(null)
private val hasNextPage = MutableStateFlow(false) private val hasNextPage = MutableStateFlow(false)
private var loadingJob: Job? = null private var loadingJob: Job? = null
val isEmptyState = MutableLiveData(false) val isEmptyState = MutableLiveData(false)
val content = combine( val content = combine(
logList.drop(1).mapItems { logList.filterNotNull().mapItems {
it.toFeedItem(context.resources) it.toFeedItem(context.resources)
}, },
hasNextPage hasNextPage
) { list, isHasNextPage -> ) { 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) }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
init { init {
@ -44,13 +48,13 @@ class FeedViewModel(
return return
} }
loadingJob = launchLoadingJob(Dispatchers.Default) { 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) val list = repository.getTrackingLog(offset, 20)
if (!append) { if (!append) {
logList.value = list logList.value = list
isEmptyState.postValue(list.isEmpty()) isEmptyState.postValue(list.isEmpty())
} else if (list.isNotEmpty()) { } else if (list.isNotEmpty()) {
logList.value += list logList.value = logList.value?.plus(list) ?: list
} }
hasNextPage.value = list.isNotEmpty() hasNextPage.value = list.isNotEmpty()
} }

@ -6,8 +6,9 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.list.ui.adapter.indeterminateProgressAD import org.koitharu.kotatsu.list.ui.adapter.*
import org.koitharu.kotatsu.list.ui.model.IndeterminateProgress 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 org.koitharu.kotatsu.tracker.ui.model.FeedItem
import kotlin.jvm.internal.Intrinsics import kotlin.jvm.internal.Intrinsics
@ -15,27 +16,31 @@ class FeedAdapter(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga> clickListener: OnListItemClickListener<Manga>
) : AsyncListDifferDelegationAdapter<Any>(DiffCallback()) { ) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init { init {
delegatesManager delegatesManager
.addDelegate(ITEM_TYPE_FEED, feedItemAD(coil, lifecycleOwner, clickListener)) .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 is FeedItem && newItem is FeedItem -> {
oldItem.id == newItem.id oldItem.id == newItem.id
} }
oldItem == IndeterminateProgress && newItem == IndeterminateProgress -> { oldItem == LoadingFooter && newItem == LoadingFooter -> {
true true
} }
else -> false else -> false
} }
override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean { override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return Intrinsics.areEqual(oldItem, newItem) return Intrinsics.areEqual(oldItem, newItem)
} }
} }
@ -43,6 +48,10 @@ class FeedAdapter(
companion object { companion object {
const val ITEM_TYPE_FEED = 0 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
} }
} }

@ -8,6 +8,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.databinding.ItemTracklogBinding 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.tracker.ui.model.FeedItem
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
@ -16,7 +17,7 @@ fun feedItemAD(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga> clickListener: OnListItemClickListener<Manga>
) = adapterDelegateViewBinding<FeedItem, Any, ItemTracklogBinding>( ) = adapterDelegateViewBinding<FeedItem, ListModel, ItemTracklogBinding>(
{ inflater, parent -> ItemTracklogBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemTracklogBinding.inflate(inflater, parent, false) }
) { ) {

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.tracker.ui.model package org.koitharu.kotatsu.tracker.ui.model
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.list.ui.model.ListModel
data class FeedItem( data class FeedItem(
val id: Long, val id: Long,
@ -9,4 +10,4 @@ data class FeedItem(
val subtitle: String, val subtitle: String,
val chapters: CharSequence, val chapters: CharSequence,
val manga: Manga val manga: Manga
) ) : ListModel

@ -43,7 +43,7 @@ fun Throwable.getDisplayMessage(resources: Resources) = when (this) {
is EmptyHistoryException -> resources.getString(R.string.history_is_empty) is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
is SocketTimeoutException -> resources.getString(R.string.network_error) is SocketTimeoutException -> resources.getString(R.string.network_error)
is WrongPasswordException -> resources.getString(R.string.wrong_password) 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 { inline fun <T> measured(tag: String, block: () -> T): T {

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

@ -8,16 +8,12 @@
android:animateLayoutChanges="true" android:animateLayoutChanges="true"
android:orientation="horizontal"> android:orientation="horizontal">
<FrameLayout <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_weight="1"> android:layout_weight="1">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView" android:id="@+id/recyclerView"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -34,43 +30,13 @@
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> </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>
<View <View
android:id="@+id/divider_filter" android:id="@+id/divider_filter"
android:layout_width="1dp" android:layout_width="1dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?dividerVertical" android:background="?dividerVertical"
android:visibility="gone" /> android:visibility="gone"
tools:visibility="visible" />
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView_filter" android:id="@+id/recyclerView_filter"
@ -82,6 +48,7 @@
android:scrollbars="vertical" android:scrollbars="vertical"
android:visibility="gone" android:visibility="gone"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_category_checkable" /> tools:listitem="@layout/item_category_checkable"
tools:visibility="visible" />
</LinearLayout> </LinearLayout>

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

@ -12,10 +12,6 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout" android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -37,37 +33,6 @@
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> </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.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView_filter" android:id="@+id/recyclerView_filter"
android:layout_width="240dp" android:layout_width="240dp"

@ -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]" />

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

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

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

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

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

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

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