Update library fragment

This commit is contained in:
Koitharu
2022-07-21 17:09:20 +03:00
parent 81df005655
commit 300d365d8b
29 changed files with 282 additions and 243 deletions

View File

@@ -9,11 +9,12 @@ insert_final_newline = true
max_line_length = 120
tab_width = 4
# noinspection EditorConfigKeyCorrectness
disabled_rules=no-wildcard-imports,no-unused-imports
disabled_rules = no-wildcard-imports, no-unused-imports
[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul}]
ij_continuation_indent_size = 4
[{*.kt,*.kts}]
ij_kotlin_allow_trailing_comma_on_call_site = true
ij_kotlin_allow_trailing_comma = true
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL

View File

@@ -12,8 +12,8 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding
import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding
import org.koitharu.kotatsu.databinding.ItemExploreHeaderBinding
import org.koitharu.kotatsu.databinding.ItemExploreSourceBinding
import org.koitharu.kotatsu.databinding.ItemHeaderButtonBinding
import org.koitharu.kotatsu.explore.ui.model.ExploreItem
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
@@ -42,8 +42,8 @@ fun exploreButtonsAD(
fun exploreSourcesHeaderAD(
listener: ExploreListEventListener,
) = adapterDelegateViewBinding<ExploreItem.Header, ExploreItem, ItemExploreHeaderBinding>(
{ layoutInflater, parent -> ItemExploreHeaderBinding.inflate(layoutInflater, parent, false) }
) = adapterDelegateViewBinding<ExploreItem.Header, ExploreItem, ItemHeaderButtonBinding>(
{ layoutInflater, parent -> ItemHeaderButtonBinding.inflate(layoutInflater, parent, false) }
) {
val listenerAdapter = View.OnClickListener {
@@ -105,4 +105,4 @@ fun exploreEmptyHintListAD(
}
}
fun exploreLoadingAD() = adapterDelegate<ExploreItem.Loading, ExploreItem>(R.layout.item_loading_state) {}
fun exploreLoadingAD() = adapterDelegate<ExploreItem.Loading, ExploreItem>(R.layout.item_loading_state) {}

View File

@@ -19,7 +19,9 @@ abstract class FavouritesDao {
fun observeAll(order: SortOrder): Flow<List<FavouriteManga>> {
val orderBy = getOrderBy(order)
@Language("RoomSql") val query = SimpleSQLiteQuery(
@Language("RoomSql")
val query = SimpleSQLiteQuery(
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
"WHERE favourites.deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy",
)
@@ -29,20 +31,22 @@ abstract class FavouritesDao {
@Transaction
@Query(
"SELECT * FROM favourites WHERE deleted_at = 0 " +
"GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
"GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
)
abstract suspend fun findAll(offset: Int, limit: Int): List<FavouriteManga>
@Transaction
@Query(
"SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " +
"GROUP BY manga_id ORDER BY created_at DESC"
"GROUP BY manga_id ORDER BY created_at DESC",
)
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
fun observeAll(categoryId: Long, order: SortOrder): Flow<List<FavouriteManga>> {
val orderBy = getOrderBy(order)
@Language("RoomSql") val query = SimpleSQLiteQuery(
@Language("RoomSql")
val query = SimpleSQLiteQuery(
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
"WHERE category_id = ? AND deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy",
arrayOf<Any>(categoryId),
@@ -53,19 +57,21 @@ abstract class FavouritesDao {
@Transaction
@Query(
"SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " +
"GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
"GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
)
abstract suspend fun findAll(categoryId: Long, offset: Int, limit: Int): List<FavouriteManga>
@Query(
"SELECT * FROM manga WHERE manga_id IN " +
"(SELECT manga_id FROM favourites WHERE category_id = :categoryId AND deleted_at = 0)"
"(SELECT manga_id FROM favourites WHERE category_id = :categoryId AND deleted_at = 0)",
)
abstract suspend fun findAllManga(categoryId: Int): List<MangaEntity>
suspend fun findCovers(categoryId: Long, order: SortOrder): List<String> {
val orderBy = getOrderBy(order)
@Language("RoomSql") val query = SimpleSQLiteQuery(
@Language("RoomSql")
val query = SimpleSQLiteQuery(
"SELECT m.cover_url FROM favourites AS f LEFT JOIN manga AS m ON f.manga_id = m.manga_id " +
"WHERE f.category_id = ? AND deleted_at = 0 ORDER BY $orderBy",
arrayOf<Any>(categoryId),
@@ -81,6 +87,7 @@ abstract class FavouritesDao {
abstract suspend fun find(id: Long): FavouriteManga?
@Transaction
@Deprecated("Ignores order")
@Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id")
abstract fun observe(id: Long): Flow<FavouriteManga?>
@@ -140,7 +147,8 @@ abstract class FavouritesDao {
private fun getOrderBy(sortOrder: SortOrder) = when (sortOrder) {
SortOrder.RATING -> "rating DESC"
SortOrder.NEWEST,
SortOrder.UPDATED -> "created_at DESC"
SortOrder.UPDATED,
-> "created_at DESC"
SortOrder.ALPHABETICAL -> "title ASC"
else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
}

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.favourites.domain
import android.util.ArrayMap
import androidx.room.withTransaction
import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.base.domain.ReversibleHandle
@@ -55,7 +54,7 @@ class FavouritesRepository(
return db.favouriteCategoriesDao.observeAll()
.map {
db.withTransaction {
val res = ArrayMap<FavouriteCategory, List<String>>()
val res = LinkedHashMap<FavouriteCategory, List<String>>()
for (entity in it) {
val cat = entity.toFavouriteCategory()
res[cat] = db.favouritesDao.findCovers(

View File

@@ -6,12 +6,9 @@ import android.view.MenuItem
import android.view.View
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu
import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.list.ui.MangaListFragment
@@ -32,7 +29,6 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.sortOrder.observe(viewLifecycleOwner) { activity?.invalidateOptionsMenu() }
viewModel.onItemsRemoved.observe(viewLifecycleOwner, ::onItemsRemoved)
}
override fun onScrolledToEnd() = Unit
@@ -75,15 +71,6 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
}
}
private fun onItemsRemoved(reversibleHandle: ReversibleHandle) {
val message = viewModel.categoryName?.let {
getString(R.string.removed_from_s, it)
} ?: getString(R.string.removed_from_favourites)
Snackbar.make(binding.recyclerView, message, Snackbar.LENGTH_LONG)
.setAction(R.string.undo) { reversibleHandle.reverseAsync() }
.show()
}
companion object {
const val NO_ID = 0L

View File

@@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
@@ -23,7 +23,6 @@ import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
class FavouritesListViewModel(
@@ -72,8 +71,6 @@ class FavouritesListViewModel(
emit(listOf(it.toErrorState(canRetry = false)))
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
val onItemsRemoved = SingleLiveEvent<ReversibleHandle>()
init {
if (categoryId != NO_ID) {
launchJob {
@@ -100,7 +97,7 @@ class FavouritesListViewModel(
} else {
repository.removeFromCategory(categoryId, ids)
}
onItemsRemoved.postCall(handle)
onActionDone.postCall(ReversibleAction(R.string.removed_from_favourites, handle))
}
}

View File

@@ -5,12 +5,9 @@ import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.appcompat.view.ActionMode
import com.google.android.material.snackbar.Snackbar
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.addMenuProvider
@@ -26,7 +23,6 @@ class HistoryListFragment : MangaListFragment() {
viewModel.isGroupingEnabled.observe(viewLifecycleOwner) {
activity?.invalidateOptionsMenu()
}
viewModel.onItemsRemoved.observe(viewLifecycleOwner, ::onItemsRemoved)
}
override fun onScrolledToEnd() = Unit
@@ -56,14 +52,8 @@ class HistoryListFragment : MangaListFragment() {
override fun onCreateAdapter() = HistoryListAdapter(get(), viewLifecycleOwner, this)
private fun onItemsRemoved(reversibleHandle: ReversibleHandle) {
Snackbar.make(binding.recyclerView, R.string.removed_from_history, Snackbar.LENGTH_LONG)
.setAction(R.string.undo) { reversibleHandle.reverseAsync() }
.show()
}
companion object {
fun newInstance() = HistoryListFragment()
}
}
}

View File

@@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow
@@ -19,7 +19,6 @@ import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst
@@ -33,7 +32,6 @@ class HistoryListViewModel(
) : MangaListViewModel(settings) {
val isGroupingEnabled = MutableLiveData<Boolean>()
val onItemsRemoved = SingleLiveEvent<ReversibleHandle>()
private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { isHistoryGroupingEnabled }
.onEach { isGroupingEnabled.postValue(it) }
@@ -78,7 +76,7 @@ class HistoryListViewModel(
}
launchJob(Dispatchers.Default) {
val handle = repository.delete(ids)
onItemsRemoved.postCall(handle)
onActionDone.postCall(ReversibleAction(R.string.removed_from_history, handle))
}
}

View File

@@ -1,40 +1,30 @@
package org.koitharu.kotatsu.library.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.data.FavouriteManga
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder
class LibraryRepository(
private val db: MangaDatabase,
) {
fun observeFavourites(order: SortOrder): Flow<Map<FavouriteCategory, List<Manga>>> {
return db.favouritesDao.observeAll(order)
.map { list -> groupByCategory(list) }
}
private fun groupByCategory(list: List<FavouriteManga>): Map<FavouriteCategory, List<Manga>> {
val map = HashMap<FavouriteCategory, MutableList<Manga>>()
for (item in list) {
val manga = item.manga.toManga(item.tags.toMangaTags())
for (category in item.categories) {
if (!category.isVisibleInLibrary) {
continue
}
map.getOrPut(category.toFavouriteCategory()) { ArrayList() }
.add(manga)
fun observeFavourites(): Flow<Map<FavouriteCategory, List<Manga>>> {
return db.favouriteCategoriesDao.observeAll()
.flatMapLatest { categories ->
combine(
categories.map { cat ->
val category = cat.toFavouriteCategory()
db.favouritesDao.observeAll(category.id, category.order)
.map { category to it.map { x -> x.manga.toManga(x.tags.toMangaTags()) } }
},
) { array -> array.toMap() }
}
}
return map
}
}
}

View File

@@ -33,7 +33,9 @@ import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.invalidateNestedItemDecorations
class LibraryFragment : BaseFragment<FragmentLibraryBinding>(), LibraryListEventListener,
class LibraryFragment :
BaseFragment<FragmentLibraryBinding>(),
LibraryListEventListener,
SectionedSelectionController.Callback<LibrarySectionModel> {
private val viewModel by viewModel<LibraryViewModel>()
@@ -109,7 +111,7 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(), LibraryListEvent
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_remote, menu)
mode.menuInflater.inflate(R.menu.mode_library, menu)
return true
}
@@ -172,7 +174,7 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(), LibraryListEvent
Snackbar.make(
binding.recyclerView,
e.getDisplayMessage(resources),
Snackbar.LENGTH_SHORT
Snackbar.LENGTH_SHORT,
).show()
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.library.ui
import androidx.collection.ArraySet
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import java.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
@@ -21,17 +22,15 @@ import org.koitharu.kotatsu.library.ui.model.LibrarySectionModel
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.daysDiff
import java.util.*
private const val HISTORY_MAX_SEGMENTS = 2
class LibraryViewModel(
private val repository: LibraryRepository,
repository: LibraryRepository,
private val historyRepository: HistoryRepository,
private val trackingRepository: TrackingRepository,
private val settings: AppSettings,
@@ -41,7 +40,7 @@ class LibraryViewModel(
val content: LiveData<List<ListModel>> = combine(
historyRepository.observeAllWithHistory(),
repository.observeFavourites(SortOrder.NEWEST),
repository.observeFavourites(),
) { history, favourites ->
mapList(history, favourites)
}.catch { e ->
@@ -60,25 +59,6 @@ class LibraryViewModel(
}
}
fun getManga(ids: Set<Long>): Set<Manga> {
val snapshot = content.value ?: return emptySet()
val result = ArraySet<Manga>(ids.size)
for (section in snapshot) {
if (section !is LibrarySectionModel) {
continue
}
for (item in section.items) {
if (item.id in ids) {
result.add(item.manga)
if (result.size == ids.size) {
return result
}
}
}
}
return result
}
fun removeFromHistory(ids: Set<Long>) {
if (ids.isEmpty()) {
return
@@ -102,16 +82,35 @@ class LibraryViewModel(
}
}
fun getManga(ids: Set<Long>): Set<Manga> {
val snapshot = content.value ?: return emptySet()
val result = ArraySet<Manga>(ids.size)
for (section in snapshot) {
if (section !is LibrarySectionModel) {
continue
}
for (item in section.items) {
if (item.id in ids) {
result.add(item.manga)
if (result.size == ids.size) {
return result
}
}
}
}
return result
}
private suspend fun mapList(
history: List<MangaWithHistory>,
favourites: Map<FavouriteCategory, List<Manga>>,
): List<ListModel> {
val result = ArrayList<ListModel>(favourites.keys.size + 1)
if (history.isNotEmpty()) {
result += mapHistory(history)
mapHistory(result, history)
}
for ((category, list) in favourites) {
result += LibrarySectionModel.Favourites(list.toUi(ListMode.GRID, this), category, R.string.show_all)
if (favourites.isNotEmpty()) {
mapFavourites(result, favourites)
}
if (result.isEmpty()) {
result += EmptyState(
@@ -121,40 +120,45 @@ class LibraryViewModel(
actionStringRes = 0,
)
}
result.trimToSize()
return result
}
private suspend fun mapHistory(list: List<MangaWithHistory>): List<LibrarySectionModel.History> {
private suspend fun mapHistory(
destination: MutableList<in LibrarySectionModel.History>,
list: List<MangaWithHistory>,
) {
val showPercent = settings.isReadingIndicatorsEnabled
val groups = ArrayList<DateTimeAgo>()
val map = HashMap<DateTimeAgo, ArrayList<MangaItemModel>>()
for ((manga, history) in list) {
val date = timeAgo(history.updatedAt)
val counter = trackingRepository.getNewChaptersCount(manga.id)
val percent = if (showPercent) history.percent else PROGRESS_NONE
if (groups.lastOrNull() != date) {
groups.add(date)
}
map.getOrPut(date) { ArrayList() }.add(manga.toGridModel(counter, percent))
val groups = list.groupByTo(LinkedHashMap()) { timeAgo(it.history.updatedAt) }
while (groups.size > HISTORY_MAX_SEGMENTS) {
val lastKey = groups.keys.last()
val subList = groups.remove(lastKey) ?: continue
groups[groups.keys.last()]?.addAll(subList)
}
val result = ArrayList<LibrarySectionModel.History>(HISTORY_MAX_SEGMENTS)
repeat(minOf(HISTORY_MAX_SEGMENTS - 1, groups.size - 1)) { i ->
val key = groups[i]
val values = map.remove(key)
if (!values.isNullOrEmpty()) {
result.add(LibrarySectionModel.History(values, key, 0))
}
for ((timeAgo, subList) in groups) {
destination += LibrarySectionModel.History(
items = subList.map { (manga, history) ->
val counter = trackingRepository.getNewChaptersCount(manga.id)
val percent = if (showPercent) history.percent else PROGRESS_NONE
manga.toGridModel(counter, percent)
},
timeAgo = timeAgo,
showAllButtonText = R.string.show_all,
)
}
val values = map.values.flatten()
if (values.isNotEmpty()) {
val key = if (result.isEmpty()) {
map.keys.singleOrNull()?.takeUnless { it == DateTimeAgo.LongAgo }
} else {
map.keys.singleOrNull() ?: DateTimeAgo.LongAgo
}
result.add(LibrarySectionModel.History(values, key, R.string.show_all))
}
private suspend fun mapFavourites(
destination: MutableList<in LibrarySectionModel.Favourites>,
favourites: Map<FavouriteCategory, List<Manga>>,
) {
for ((category, list) in favourites) {
destination += LibrarySectionModel.Favourites(
items = list.toUi(ListMode.GRID, this),
category = category,
showAllButtonText = R.string.show_all,
)
}
return result
}
private fun timeAgo(date: Date): DateTimeAgo {

View File

@@ -1,11 +1,14 @@
package org.koitharu.kotatsu.library.ui.adapter
import android.content.Context
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlin.jvm.internal.Intrinsics
import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.library.ui.model.LibrarySectionModel
import org.koitharu.kotatsu.list.ui.ItemSizeResolver
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
@@ -13,7 +16,6 @@ import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import kotlin.jvm.internal.Intrinsics
class LibraryAdapter(
lifecycleOwner: LifecycleOwner,
@@ -21,7 +23,7 @@ class LibraryAdapter(
listener: LibraryListEventListener,
sizeResolver: ItemSizeResolver,
selectionController: SectionedSelectionController<LibrarySectionModel>,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()), FastScroller.SectionIndexer {
init {
val pool = RecyclerView.RecycledViewPool()
@@ -34,7 +36,7 @@ class LibraryAdapter(
sizeResolver = sizeResolver,
selectionController = selectionController,
listener = listener,
)
),
)
.addDelegate(loadingStateAD())
.addDelegate(loadingFooterAD())
@@ -42,6 +44,11 @@ class LibraryAdapter(
.addDelegate(errorStateListAD(listener))
}
override fun getSectionText(context: Context, position: Int): CharSequence {
val item = items.getOrNull(position) as? LibrarySectionModel
return item?.getTitle(context.resources) ?: ""
}
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
@@ -64,4 +71,4 @@ class LibraryAdapter(
}
}
}
}
}

View File

@@ -91,4 +91,4 @@ sealed class LibrarySectionModel(
return "fav_${category.id}"
}
}
}
}

View File

@@ -16,6 +16,7 @@ import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import org.koin.android.ext.android.get
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.FitHeightGridLayoutManager
import org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager
@@ -24,6 +25,7 @@ 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.TypedSpacingItemDecoration
import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
@@ -35,6 +37,7 @@ import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesB
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter.Companion.ITEM_TYPE_MANGA_GRID
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.main.ui.AppBarOwner
@@ -49,7 +52,8 @@ abstract class MangaListFragment :
PaginationScrollListener.Callback,
MangaListListener,
SwipeRefreshLayout.OnRefreshListener,
ListSelectionController.Callback, FastScroller.FastScrollListener {
ListSelectionController.Callback,
FastScroller.FastScrollListener {
private var listAdapter: MangaListAdapter? = null
private var paginationListener: PaginationScrollListener? = null
@@ -71,7 +75,7 @@ abstract class MangaListFragment :
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
container: ViewGroup?,
) = FragmentListBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -104,6 +108,7 @@ abstract class MangaListFragment :
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError)
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
}
override fun onDestroyView() {
@@ -141,11 +146,21 @@ abstract class MangaListFragment :
Snackbar.make(
binding.recyclerView,
e.getDisplayMessage(resources),
Snackbar.LENGTH_SHORT
Snackbar.LENGTH_SHORT,
).show()
}
}
private fun onActionDone(action: ReversibleAction) {
val handle = action.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
val snackbar = Snackbar.make(binding.recyclerView, action.stringResId, length)
if (handle != null) {
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
}
snackbar.show()
}
private fun resolveException(e: Throwable) {
if (ExceptionResolver.canResolve(e)) {
viewLifecycleScope.launch {
@@ -201,6 +216,8 @@ abstract class MangaListFragment :
override fun onEmptyActionClick() = Unit
override fun onListHeaderClick(item: ListHeader, view: View) = Unit
override fun onRetryClick(error: Throwable) {
resolveException(error)
}
@@ -225,7 +242,7 @@ abstract class MangaListFragment :
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
val decoration = TypedSpacingItemDecoration(
MangaListAdapter.ITEM_TYPE_MANGA_LIST to 0,
fallbackSpacing = spacing
fallbackSpacing = spacing,
)
addItemDecoration(decoration)
}
@@ -332,4 +349,4 @@ abstract class MangaListFragment :
invalidateSpanIndexCache()
}
}
}
}

View File

@@ -6,12 +6,14 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.SingleLiveEvent
abstract class MangaListViewModel(
private val settings: AppSettings,
@@ -19,6 +21,7 @@ abstract class MangaListViewModel(
abstract val content: LiveData<List<ListModel>>
val listMode = MutableLiveData<ListMode>()
val onActionDone = SingleLiveEvent<ReversibleAction>()
val gridScale = settings.observeAsLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default,
key = AppSettings.KEY_GRID_SIZE,
@@ -37,4 +40,4 @@ abstract class MangaListViewModel(
abstract fun onRefresh()
abstract fun onRetry()
}
}

View File

@@ -1,46 +1,22 @@
package org.koitharu.kotatsu.list.ui.adapter
import android.widget.TextView
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.databinding.ItemHeaderWithFilterBinding
import org.koitharu.kotatsu.databinding.ItemHeaderButtonBinding
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.utils.ext.setTextAndVisible
fun listHeaderAD() = adapterDelegate<ListHeader, ListModel>(
layout = R.layout.item_header,
on = { item, _, _ -> item is ListHeader && item.sortOrder == null },
fun listHeaderAD(
listener: ListHeaderClickListener,
) = adapterDelegateViewBinding<ListHeader, ListModel, ItemHeaderButtonBinding>(
{ inflater, parent -> ItemHeaderButtonBinding.inflate(inflater, parent, false) },
) {
binding.buttonMore.setOnClickListener {
listener.onListHeaderClick(item, it)
}
bind {
val textView = (itemView as TextView)
if (item.text != null) {
textView.text = item.text
} else {
textView.setText(item.textRes)
}
binding.textViewTitle.text = item.getText(context)
binding.buttonMore.setTextAndVisible(item.buttonTextRes)
}
}
fun listHeaderWithFilterAD(
listener: MangaListListener,
) = adapterDelegateViewBinding<ListHeader, ListModel, ItemHeaderWithFilterBinding>(
viewBinding = { inflater, parent -> ItemHeaderWithFilterBinding.inflate(inflater, parent, false) },
on = { item, _, _ -> item is ListHeader && item.sortOrder != null },
) {
binding.textViewFilter.setOnClickListener {
listener.onFilterClick(it)
}
bind {
if (item.text != null) {
binding.textViewTitle.text = item.text
} else {
binding.textViewTitle.setText(item.textRes)
}
binding.textViewFilter.setText(requireNotNull(item.sortOrder).titleRes)
}
}

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.list.ui.adapter
import android.view.View
import org.koitharu.kotatsu.list.ui.model.ListHeader
interface ListHeaderClickListener {
fun onListHeaderClick(item: ListHeader, view: View)
}

View File

@@ -25,9 +25,8 @@ open class MangaListAdapter(
.addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(listener))
.addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(listener))
.addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(listener))
.addDelegate(ITEM_TYPE_HEADER, listHeaderAD())
.addDelegate(ITEM_TYPE_HEADER, listHeaderAD(listener))
.addDelegate(ITEM_TYPE_HEADER_2, listHeader2AD(listener))
.addDelegate(ITEM_TYPE_HEADER_FILTER, listHeaderWithFilterAD(listener))
}
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
@@ -45,6 +44,11 @@ open class MangaListAdapter(
oldItem is DateTimeAgo && newItem is DateTimeAgo -> {
oldItem == newItem
}
oldItem is ListHeader && newItem is ListHeader -> {
oldItem.textRes == newItem.textRes &&
oldItem.text == newItem.text &&
oldItem.dateTimeAgo == newItem.dateTimeAgo
}
else -> oldItem.javaClass == newItem.javaClass
}
@@ -59,7 +63,6 @@ open class MangaListAdapter(
if (oldItem.progress != newItem.progress) {
PAYLOAD_PROGRESS
} else {
Unit
}
}
is ListHeader2 -> Unit
@@ -81,8 +84,7 @@ open class MangaListAdapter(
const val ITEM_TYPE_EMPTY = 8
const val ITEM_TYPE_HEADER = 9
const val ITEM_TYPE_HEADER_2 = 10
const val ITEM_TYPE_HEADER_FILTER = 11
val PAYLOAD_PROGRESS = Any()
}
}
}

View File

@@ -5,9 +5,9 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
interface MangaListListener : OnListItemClickListener<Manga>, ListStateHolderListener {
interface MangaListListener : OnListItemClickListener<Manga>, ListStateHolderListener, ListHeaderClickListener {
fun onUpdateFilter(tags: Set<MangaTag>)
fun onFilterClick(view: View?)
}
}

View File

@@ -1,11 +1,62 @@
package org.koitharu.kotatsu.list.ui.model
import android.content.Context
import androidx.annotation.StringRes
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.core.ui.DateTimeAgo
@Deprecated("")
data class ListHeader(
class ListHeader private constructor(
val text: CharSequence?,
@StringRes val textRes: Int,
val sortOrder: SortOrder?,
) : ListModel
val dateTimeAgo: DateTimeAgo?,
@StringRes val buttonTextRes: Int,
val payload: Any?,
) : ListModel {
constructor(
text: CharSequence,
@StringRes buttonTextRes: Int,
payload: Any?,
) : this(text, 0, null, buttonTextRes, payload)
constructor(
@StringRes textRes: Int,
@StringRes buttonTextRes: Int,
payload: Any?,
) : this(null, textRes, null, buttonTextRes, payload)
constructor(
dateTimeAgo: DateTimeAgo,
@StringRes buttonTextRes: Int,
payload: Any?,
) : this(null, 0, dateTimeAgo, buttonTextRes, payload)
fun getText(context: Context): CharSequence? = when {
text != null -> text
textRes != 0 -> context.getString(textRes)
else -> dateTimeAgo?.format(context.resources)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ListHeader
if (text != other.text) return false
if (textRes != other.textRes) return false
if (dateTimeAgo != other.dateTimeAgo) return false
if (buttonTextRes != other.buttonTextRes) return false
if (payload != other.payload) return false
return true
}
override fun hashCode(): Int {
var result = text?.hashCode() ?: 0
result = 31 * result + textRes
result = 31 * result + (dateTimeAgo?.hashCode() ?: 0)
result = 31 * result + buttonTextRes
result = 31 * result + (payload?.hashCode() ?: 0)
return result
}
}

View File

@@ -23,6 +23,7 @@ import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesB
import org.koitharu.kotatsu.list.ui.ItemSizeResolver
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.search.ui.SearchActivity
@@ -109,6 +110,8 @@ class MultiSearchActivity : BaseActivity<ActivitySearchMultiBinding>(), MangaLis
override fun onEmptyActionClick() = Unit
override fun onListHeaderClick(item: ListHeader, view: View) = Unit
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_remote, menu)
return true
@@ -156,4 +159,4 @@ class MultiSearchActivity : BaseActivity<ActivitySearchMultiBinding>(), MangaLis
Intent(context, MultiSearchActivity::class.java)
.putExtra(EXTRA_QUERY, query)
}
}
}

View File

@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration
import org.koitharu.kotatsu.databinding.FragmentFeedBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
@@ -93,6 +94,8 @@ class FeedFragment :
override fun onEmptyActionClick() = Unit
override fun onListHeaderClick(item: ListHeader, view: View) = Unit
private fun onListChanged(list: List<ListModel>) {
feedAdapter?.items = list
}
@@ -129,4 +132,4 @@ class FeedFragment :
fun newInstance() = FeedFragment()
}
}
}

View File

@@ -1,8 +1,6 @@
package org.koitharu.kotatsu.tracker.ui
import androidx.lifecycle.viewModelScope
import java.util.*
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
@@ -12,13 +10,18 @@ import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import org.koitharu.kotatsu.tracker.ui.model.toFeedItem
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.daysDiff
import java.util.*
import java.util.concurrent.TimeUnit
class FeedViewModel(
private val repository: TrackingRepository
@@ -27,7 +30,6 @@ class FeedViewModel(
private val logList = MutableStateFlow<List<TrackingLogItem>?>(null)
private val hasNextPage = MutableStateFlow(false)
private var loadingJob: Job? = null
private val header = ListHeader(null, R.string.updates, null)
val onFeedCleared = SingleLiveEvent<Unit>()
val content = combine(
@@ -36,7 +38,6 @@ class FeedViewModel(
) { list, isHasNextPage ->
buildList(list.size + 2) {
if (list.isEmpty()) {
add(header)
add(
EmptyState(
icon = R.drawable.ic_empty_feed,
@@ -52,10 +53,7 @@ class FeedViewModel(
}
}
}
}.asLiveDataDistinct(
viewModelScope.coroutineContext + Dispatchers.Default,
listOf(header, LoadingState)
)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
init {
loadList(append = false)
@@ -114,4 +112,4 @@ class FeedViewModel(
else -> DateTimeAgo.Absolute(date)
}
}
}
}

View File

@@ -24,7 +24,6 @@ class FeedAdapter(
.addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(listener))
.addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(listener))
.addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(listener))
.addDelegate(ITEM_TYPE_HEADER, listHeaderAD())
.addDelegate(ITEM_TYPE_DATE_HEADER, relatedDateItemAD())
}
@@ -56,4 +55,4 @@ class FeedAdapter(
const val ITEM_TYPE_HEADER = 6
const val ITEM_TYPE_DATE_HEADER = 7
}
}
}

View File

@@ -4,16 +4,16 @@
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:clipToPadding="false"
android:paddingHorizontal="@dimen/list_spacing">
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
<org.koitharu.kotatsu.base.ui.list.fastscroll.FastScrollRecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
app:bubbleSize="small"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_feed" />
</FrameLayout>
</FrameLayout>

View File

@@ -15,6 +15,7 @@
android:id="@+id/imageView_icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:background="?colorControlHighlight"
android:labelFor="@id/textView_title"
android:scaleType="fitCenter"
app:shapeAppearance="?shapeAppearanceCornerSmall"
@@ -30,4 +31,4 @@
android:textAppearance="?attr/textAppearanceBodyMedium"
tools:text="@tools:sample/lorem[2]" />
</LinearLayout>
</LinearLayout>

View File

@@ -9,8 +9,8 @@
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_vertical|start"
android:padding="@dimen/grid_spacing"
android:singleLine="true"
@@ -24,4 +24,4 @@
android:layout_height="wrap_content"
android:text="@string/manage" />
</LinearLayout>
</LinearLayout>

View File

@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
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="wrap_content">
<TextView
android:id="@+id/textView_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_toStartOf="@id/textView_filter"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
tools:text="@tools:sample/lorem[21]" />
<TextView
android:id="@+id/textView_filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="@drawable/list_selector"
android:gravity="center_vertical"
android:paddingStart="6dp"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Kotatsu.SectionHeader"
app:drawableEndCompat="@drawable/ic_expand_more"
app:drawableTint="?android:attr/textColorSecondary"
tools:ignore="RtlSymmetry"
tools:text="@string/popular" />
</RelativeLayout>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_share"
android:icon="?actionModeShareDrawable"
android:title="@string/share"
app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_remove"
android:icon="@drawable/ic_delete"
android:title="@string/remove"
app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_favourite"
android:icon="@drawable/ic_heart_outline"
android:title="@string/add_to_favourites"
app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_save"
android:icon="@drawable/ic_save"
android:title="@string/save"
app:showAsAction="ifRoom|withText" />
</menu>