Migrate feed to adapter delegates

This commit is contained in:
Koitharu
2020-11-23 19:16:02 +02:00
parent 12c8cdfd70
commit b9f35f34ad
21 changed files with 237 additions and 158 deletions

View File

@@ -1,34 +1,18 @@
package org.koitharu.kotatsu.base.ui.list
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
class PaginationScrollListener(offset: Int, private val callback: Callback) :
BoundsScrollListener(0, offset) {
private var lastTotalCount = 0
override fun onScrolledToStart(recyclerView: RecyclerView) = Unit
override fun onScrolledToEnd(recyclerView: RecyclerView) {
val total = (recyclerView.layoutManager as? LinearLayoutManager)?.itemCount ?: return
if (total > lastTotalCount) {
lastTotalCount = total
callback.onRequestMoreItems(total)
} else if (total < lastTotalCount) {
lastTotalCount = total
}
}
fun reset() {
lastTotalCount = 0
callback.onScrolledToEnd()
}
interface Callback {
fun onRequestMoreItems(offset: Int)
@Deprecated("Not in use")
fun getItemsCount(): Int = 0
fun onScrolledToEnd()
}
}

View File

@@ -20,7 +20,9 @@ class FavouritesListFragment : MangaListFragment() {
private val categoryId: Long
get() = arguments?.getLong(ARG_CATEGORY_ID) ?: 0L
override fun onRequestMoreItems(offset: Int) = Unit
override val isSwipeRefreshEnabled = false
override fun onScrolledToEnd() = Unit
override fun setUpEmptyListHolder() {
textView_holder.setText(

View File

@@ -17,17 +17,14 @@ import org.koitharu.kotatsu.utils.ext.ellipsize
class HistoryListFragment : MangaListFragment() {
override val viewModel by viewModel<HistoryListViewModel>()
init {
isSwipeRefreshEnabled = false
}
override val isSwipeRefreshEnabled = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.onItemRemoved.observe(viewLifecycleOwner, ::onItemRemoved)
}
override fun onRequestMoreItems(offset: Int) = Unit
override fun onScrolledToEnd() = Unit
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.opt_history, menu)

View File

@@ -45,7 +45,7 @@ abstract class MangaListFragment : BaseFragment(R.layout.fragment_list),
private var paginationListener: PaginationScrollListener? = null
private val spanResolver = MangaListSpanResolver()
private val spanSizeLookup = SpanSizeLookup()
protected var isSwipeRefreshEnabled = true
open val isSwipeRefreshEnabled = true
protected abstract val viewModel: MangaListViewModel
@@ -63,6 +63,7 @@ abstract class MangaListFragment : BaseFragment(R.layout.fragment_list),
recyclerView.adapter = adapter
recyclerView.addOnScrollListener(paginationListener!!)
swipeRefreshLayout.setOnRefreshListener(this)
swipeRefreshLayout.isEnabled = isSwipeRefreshEnabled
recyclerView_filter.setHasFixedSize(true)
recyclerView_filter.addItemDecoration(ItemTypeDividerDecoration(view.context))
recyclerView_filter.addItemDecoration(SectionItemDecoration(false, this))
@@ -125,9 +126,9 @@ abstract class MangaListFragment : BaseFragment(R.layout.fragment_list),
}
}
final override fun onRefresh() {
@CallSuper
override fun onRefresh() {
swipeRefreshLayout.isRefreshing = true
onRequestMoreItems(0)
}
private fun onListChanged(list: List<Any>) {

View File

@@ -57,7 +57,7 @@ abstract class MangaListSheet : BaseBottomSheet(R.layout.sheet_list),
appbar.elevation = resources.getDimension(R.dimen.elevation_large)
}
if (savedInstanceState == null) {
onRequestMoreItems(0)
onScrolledToEnd()
}
viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError)
@@ -128,8 +128,6 @@ abstract class MangaListSheet : BaseBottomSheet(R.layout.sheet_list),
recyclerView.callOnScrollListeners()
}
override fun getItemsCount() = adapter?.itemCount ?: 0
private fun onError(e: Throwable) {
Snackbar.make(recyclerView, e.getDisplayMessage(resources), Snackbar.LENGTH_SHORT).show()
}
@@ -151,7 +149,7 @@ abstract class MangaListSheet : BaseBottomSheet(R.layout.sheet_list),
ListMode.GRID -> {
GridLayoutManager(ctx, UiUtils.resolveGridSpanCount(ctx)).apply {
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int) = if (position < getItemsCount())
override fun getSpanSize(position: Int) = if (position < TODO() as Int)
1 else this@apply.spanCount
}
}

View File

@@ -32,12 +32,13 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> {
viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved)
}
override fun onRequestMoreItems(offset: Int) {
if (offset == 0) {
viewModel.onRefresh()
}
override fun onRefresh() {
super.onRefresh()
viewModel.onRefresh()
}
override fun onScrolledToEnd() = Unit
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.opt_local, menu)
super.onCreateOptionsMenu(menu, inflater)

View File

@@ -45,11 +45,17 @@ class LocalListViewModel(
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
init {
loadList()
onRefresh()
}
fun onRefresh() {
loadList()
launchLoadingJob {
withContext(Dispatchers.Default) {
val list = repository.getList(0)
mangaList.value = list
isEmptyState.postValue(list.isEmpty())
}
}
}
fun importFile(uri: Uri) {
@@ -69,7 +75,7 @@ class LocalListViewModel(
}
} ?: throw IOException("Cannot open input stream: $uri")
}
loadList()
onRefresh()
}
}
@@ -88,14 +94,4 @@ class LocalListViewModel(
onMangaRemoved.call(manga)
}
}
private fun loadList() {
launchLoadingJob {
withContext(Dispatchers.Default) {
val list = repository.getList(0)
mangaList.value = list
isEmptyState.postValue(list.isEmpty())
}
}
}
}

View File

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

View File

@@ -45,22 +45,22 @@ class RemoteListViewModel(
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
init {
loadList(0)
loadList(false)
loadFilter()
}
fun loadList(offset: Int) {
fun loadList(append: Boolean) {
if (loadingJob?.isActive == true) {
return
}
loadingJob = launchLoadingJob {
withContext(Dispatchers.Default) {
val list = repository.getList(
offset = offset,
offset = if (append) mangaList.value.size else 0,
sortOrder = appliedFilter?.sortOrder,
tag = appliedFilter?.tag
)
if (offset == 0) {
if (!append) {
mangaList.value = list
} else if (list.isNotEmpty()) {
mangaList.value += list
@@ -74,7 +74,7 @@ class RemoteListViewModel(
appliedFilter = newFilter
mangaList.value = emptyList()
hasNextPage.value = false
loadList(0)
loadList(false)
}
private fun loadFilter() {

View File

@@ -28,8 +28,8 @@ class MangaSearchSheet : MangaListSheet() {
setSubtitle(getString(R.string.search_results_on_s, source.title))
}
override fun onRequestMoreItems(offset: Int) {
viewModel.loadList(query.orEmpty(), offset)
override fun onScrolledToEnd() {
viewModel.loadList(query.orEmpty(), append = true)
}
companion object {

View File

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

View File

@@ -14,12 +14,12 @@ class SearchViewModel(
override val content = MutableLiveData<List<Any>>()
fun loadList(query: String, offset: Int) {
fun loadList(query: String, append: Boolean) {
launchLoadingJob {
val list = withContext(Dispatchers.Default) {
repository.getList(offset, query = query)
repository.getList(TODO(), query = query)
}
if (offset == 0) {
if (!append) {
content.value = list
} else {
content.value = content.value.orEmpty() + list

View File

@@ -12,12 +12,13 @@ class GlobalSearchFragment : MangaListFragment() {
private val query by stringArgument(ARG_QUERY)
override fun onRequestMoreItems(offset: Int) {
if (offset == 0) {
viewModel.startSearch(query.orEmpty())
}
override fun onRefresh() {
super.onRefresh()
viewModel.startSearch(query.orEmpty())
}
override fun onScrolledToEnd() = Unit
override fun getTitle(): CharSequence? {
return query
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.tracker
import org.koin.android.ext.koin.androidContext
import org.koin.android.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
@@ -10,5 +11,5 @@ val trackerModule
single { TrackingRepository(get(), get()) }
viewModel { FeedViewModel(get()) }
viewModel { FeedViewModel(androidContext(), get()) }
}

View File

@@ -1,19 +1,46 @@
package org.koitharu.kotatsu.tracker.ui
import android.view.ViewGroup
import org.koitharu.kotatsu.base.ui.list.BaseRecyclerAdapter
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
import org.koitharu.kotatsu.base.ui.list.OnRecyclerItemClickListener
import org.koitharu.kotatsu.core.model.TrackingLogItem
import androidx.recyclerview.widget.DiffUtil
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.tracker.ui.adapter.feedItemAD
import org.koitharu.kotatsu.tracker.ui.model.FeedItem
import kotlin.jvm.internal.Intrinsics
class FeedAdapter(onItemClickListener: OnRecyclerItemClickListener<TrackingLogItem>? = null) :
BaseRecyclerAdapter<TrackingLogItem, Unit>(onItemClickListener) {
class FeedAdapter(
coil: ImageLoader,
clickListener: OnListItemClickListener<Manga>
) : AsyncListDifferDelegationAdapter<Any>(DiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup): BaseViewHolder<TrackingLogItem, Unit> {
return FeedHolder(parent)
init {
delegatesManager.addDelegate(ITEM_TYPE_FEED, feedItemAD(coil, clickListener))
.addDelegate(ITEM_TYPE_PROGRESS, indeterminateProgressAD())
}
override fun onGetItemId(item: TrackingLogItem) = item.id
private class DiffCallback : DiffUtil.ItemCallback<Any>() {
override fun getExtra(item: TrackingLogItem, position: Int) = Unit
override fun areItemsTheSame(oldItem: Any, newItem: Any) = when {
oldItem is FeedItem && newItem is FeedItem -> {
oldItem.id == newItem.id
}
oldItem == IndeterminateProgress && newItem == IndeterminateProgress -> {
true
}
else -> false
}
override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean {
return Intrinsics.areEqual(oldItem, newItem)
}
}
companion object {
const val ITEM_TYPE_FEED = 0
const val ITEM_TYPE_PROGRESS = 1
}
}

View File

@@ -8,21 +8,21 @@ import android.view.View
import androidx.core.view.isVisible
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_tracklogs.*
import org.koin.android.ext.android.get
import org.koin.android.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnRecyclerItemClickListener
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.core.model.TrackingLogItem
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.callOnScrollListeners
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.hasItems
class FeedFragment : BaseFragment(R.layout.fragment_tracklogs), PaginationScrollListener.Callback,
OnRecyclerItemClickListener<TrackingLogItem> {
OnListItemClickListener<Manga> {
private val viewModel by viewModel<FeedViewModel>()
@@ -37,7 +37,7 @@ class FeedFragment : BaseFragment(R.layout.fragment_tracklogs), PaginationScroll
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = FeedAdapter(this)
adapter = FeedAdapter(get(), this)
recyclerView.adapter = adapter
recyclerView.addItemDecoration(
SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing))
@@ -45,12 +45,13 @@ class FeedFragment : BaseFragment(R.layout.fragment_tracklogs), PaginationScroll
recyclerView.setHasFixedSize(true)
recyclerView.addOnScrollListener(PaginationScrollListener(4, this))
if (savedInstanceState == null) {
onRequestMoreItems(0)
onScrolledToEnd()
}
viewModel.content.observe(viewLifecycleOwner, this::onListChanged)
viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged)
viewModel.onError.observe(viewLifecycleOwner, this::onError)
viewModel.isEmptyState.observe(viewLifecycleOwner, this::onEmptyStateChanged)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@@ -72,15 +73,8 @@ class FeedFragment : BaseFragment(R.layout.fragment_tracklogs), PaginationScroll
super.onDestroyView()
}
private fun onListChanged(list: List<TrackingLogItem>) {
adapter?.replaceData(list)
if (list.isEmpty()) {
setUpEmptyListHolder()
layout_holder.isVisible = true
} else {
layout_holder.isVisible = false
}
recyclerView.callOnScrollListeners()
private fun onListChanged(list: List<Any>) {
adapter?.items = list
}
private fun onError(e: Throwable) {
@@ -102,21 +96,21 @@ class FeedFragment : BaseFragment(R.layout.fragment_tracklogs), PaginationScroll
private fun onLoadingStateChanged(isLoading: Boolean) {
val hasItems = recyclerView.hasItems
progressBar.isVisible = isLoading && !hasItems
if (isLoading) {
layout_holder.isVisible = false
}
private fun onEmptyStateChanged(isEmpty: Boolean) {
if (isEmpty) {
setUpEmptyListHolder()
}
layout_holder.isVisible = isEmpty
}
override fun getItemsCount(): Int {
return adapter?.itemCount ?: 0
override fun onScrolledToEnd() {
viewModel.loadList(append = true)
}
override fun onRequestMoreItems(offset: Int) {
viewModel.loadList(offset)
}
override fun onItemClick(item: TrackingLogItem, position: Int, view: View) {
startActivity(DetailsActivity.newIntent(context ?: return, item.manga))
override fun onItemClick(item: Manga, view: View) {
startActivity(DetailsActivity.newIntent(context ?: return, item))
}
private fun setUpEmptyListHolder() {

View File

@@ -1,48 +0,0 @@
package org.koitharu.kotatsu.tracker.ui
import android.text.format.DateUtils
import android.view.ViewGroup
import coil.ImageLoader
import coil.request.Disposable
import kotlinx.android.synthetic.main.item_tracklog.*
import org.koin.core.component.inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.formatRelative
import org.koitharu.kotatsu.utils.ext.newImageRequest
class FeedHolder(parent: ViewGroup) :
BaseViewHolder<TrackingLogItem, Unit>(parent, R.layout.item_tracklog) {
private val coil by inject<ImageLoader>()
private var imageRequest: Disposable? = null
override fun onBind(data: TrackingLogItem, extra: Unit) {
imageRequest?.dispose()
imageRequest = imageView_cover.newImageRequest(data.manga.coverUrl)
.placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder)
.enqueueWith(coil)
textView_title.text = data.manga.title
textView_subtitle.text = buildString {
append(data.createdAt.formatRelative(DateUtils.DAY_IN_MILLIS))
append(" ")
append(
context.resources.getQuantityString(
R.plurals.new_chapters,
data.chapters.size,
data.chapters.size
)
)
}
textView_chapters.text = data.chapters.joinToString("\n")
}
override fun onRecycled() {
imageRequest?.dispose()
imageView_cover.setImageDrawable(null)
}
}

View File

@@ -1,24 +1,60 @@
package org.koitharu.kotatsu.tracker.ui
import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
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 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.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.ui.model.toFeedItem
import org.koitharu.kotatsu.utils.ext.mapItems
class FeedViewModel(
context: Context,
private val repository: TrackingRepository
) : BaseViewModel() {
val content = MutableLiveData<List<TrackingLogItem>>()
private val logList = MutableStateFlow<List<TrackingLogItem>>(emptyList())
private val hasNextPage = MutableStateFlow(false)
private var loadingJob: Job? = null
fun loadList(offset: Int) {
launchLoadingJob {
val isEmptyState = MutableLiveData(false)
val content = combine(
logList.drop(1).onEach {
isEmptyState.postValue(it.isEmpty())
}.mapItems {
it.toFeedItem(context.resources)
},
hasNextPage
) { list, isHasNextPage ->
if (isHasNextPage && list.isNotEmpty()) list + IndeterminateProgress else list
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
init {
loadList(append = false)
}
fun loadList(append: Boolean) {
if (loadingJob?.isActive == true) {
return
}
loadingJob = launchLoadingJob {
val offset = if (append) logList.value.size else 0
val list = repository.getTrackingLog(offset, 20)
if (offset == 0) {
content.value = list
} else {
content.value = content.value.orEmpty() + list
if (!append) {
logList.value = list
} else if (list.isNotEmpty()) {
logList.value += list
}
hasNextPage.value = list.isNotEmpty()
}
}
}

View File

@@ -0,0 +1,41 @@
package org.koitharu.kotatsu.tracker.ui.adapter
import coil.ImageLoader
import coil.request.Disposable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateLayoutContainer
import kotlinx.android.synthetic.main.item_tracklog.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.tracker.ui.model.FeedItem
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
fun feedItemAD(
coil: ImageLoader,
clickListener: OnListItemClickListener<Manga>
) = adapterDelegateLayoutContainer<FeedItem, Any>(R.layout.item_tracklog) {
var imageRequest: Disposable? = null
itemView.setOnClickListener {
clickListener.onItemClick(item.manga, it)
}
bind {
imageRequest?.dispose()
imageRequest = imageView_cover.newImageRequest(item.imageUrl)
.placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder)
.enqueueWith(coil)
textView_title.text = item.title
textView_subtitle.text = item.subtitle
textView_chapters.text = item.chapters
}
onViewRecycled {
imageRequest?.dispose()
imageView_cover.setImageDrawable(null)
}
}

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.tracker.ui.model
import org.koitharu.kotatsu.core.model.Manga
data class FeedItem(
val id: Long,
val imageUrl: String,
val title: String,
val subtitle: String,
val chapters: CharSequence,
val manga: Manga
)

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.tracker.ui.model
import android.content.res.Resources
import android.text.format.DateUtils
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.utils.ext.formatRelative
fun TrackingLogItem.toFeedItem(resources: Resources) = FeedItem(
id = id,
imageUrl = manga.coverUrl,
title = manga.title,
subtitle = buildString {
append(createdAt.formatRelative(DateUtils.DAY_IN_MILLIS))
append(" ")
append(
resources.getQuantityString(
R.plurals.new_chapters,
chapters.size,
chapters.size
)
)
},
chapters = chapters.joinToString("\n"),
manga = manga
)