Sorting local manga #219 #217

pull/222/head v4.0-a6
Koitharu 4 years ago
parent f74e865b06
commit 6243fc88c9
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -15,8 +15,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 33 targetSdkVersion 33
versionCode 494 versionCode 495
versionName '4.0-a5' versionName '4.0-a6'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -82,7 +82,7 @@ afterEvaluate {
} }
} }
dependencies { dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:f112a06ab6') { implementation('com.github.KotatsuApp:kotatsu-parsers:551a1d70ae') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
@ -102,7 +102,7 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1' implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04'
implementation 'com.google.android.material:material:1.7.0-beta01' implementation 'com.google.android.material:material:1.7.0-rc01'
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1' kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1'
@ -124,7 +124,6 @@ dependencies {
implementation 'io.coil-kt:coil-base:2.2.0' implementation 'io.coil-kt:coil-base:2.2.0'
implementation 'io.coil-kt:coil-svg:2.2.0' implementation 'io.coil-kt:coil-svg:2.2.0'
// implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:2942b797a2' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:2942b797a2'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'

@ -21,6 +21,7 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getEnumValue import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.observe import org.koitharu.kotatsu.utils.ext.observe
import org.koitharu.kotatsu.utils.ext.putEnumValue import org.koitharu.kotatsu.utils.ext.putEnumValue
@ -208,6 +209,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val dnsOverHttps: DoHProvider val dnsOverHttps: DoHProvider
get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE) get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE)
var localListOrder: SortOrder
get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST)
set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) }
fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean { fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean {
return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) { return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) {
NETWORK_ALWAYS -> true NETWORK_ALWAYS -> true
@ -325,6 +330,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_BAR = "reader_bar" const val KEY_READER_BAR = "reader_bar"
const val KEY_SHORTCUTS = "dynamic_shortcuts" const val KEY_SHORTCUTS = "dynamic_shortcuts"
const val KEY_READER_TAPS_LTR = "reader_taps_ltr" const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
const val KEY_LOCAL_LIST_ORDER = "local_order"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

@ -0,0 +1,48 @@
package org.koitharu.kotatsu.local.domain
import java.io.File
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
class LocalManga(
val manga: Manga,
val file: File,
) {
var createdAt: Long = -1L
get() {
if (field == -1L) {
field = file.lastModified()
}
return field
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as LocalManga
if (manga != other.manga) return false
if (file != other.file) return false
return true
}
override fun hashCode(): Int {
var result = manga.hashCode()
result = 31 * result + file.hashCode()
return result
}
}
fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga }
fun LocalManga.isMatchesQuery(query: String): Boolean {
return manga.title.contains(query, ignoreCase = true) ||
manga.altTitle?.contains(query, ignoreCase = true) == true
}
fun LocalManga.containsTags(tags: Set<MangaTag>): Boolean {
return manga.tags.containsAll(tags)
}

@ -45,12 +45,9 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
} }
val list = getRawList() val list = getRawList()
if (query.isNotEmpty()) { if (query.isNotEmpty()) {
list.retainAll { x -> list.retainAll { x -> x.isMatchesQuery(query) }
x.title.contains(query, ignoreCase = true) ||
x.altTitle?.contains(query, ignoreCase = true) == true
}
} }
return list return list.unwrap()
} }
override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> { override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> {
@ -59,15 +56,17 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
} }
val list = getRawList() val list = getRawList()
if (!tags.isNullOrEmpty()) { if (!tags.isNullOrEmpty()) {
list.retainAll { x -> list.retainAll { x -> x.containsTags(tags) }
x.tags.containsAll(tags)
}
} }
@Suppress("NON_EXHAUSTIVE_WHEN_STATEMENT")
when (sortOrder) { when (sortOrder) {
SortOrder.ALPHABETICAL -> list.sortBy { it.title } SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title })
SortOrder.RATING -> list.sortBy { it.rating } SortOrder.RATING -> list.sortByDescending { it.manga.rating }
SortOrder.NEWEST,
SortOrder.UPDATED,
-> list.sortByDescending { it.createdAt }
} }
return list return list.unwrap()
} }
override suspend fun getDetails(manga: Manga) = when { override suspend fun getDetails(manga: Manga) = when {
@ -235,9 +234,9 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
private fun CoroutineScope.getFromFileAsync( private fun CoroutineScope.getFromFileAsync(
file: File, file: File,
context: CoroutineContext, context: CoroutineContext,
): Deferred<Manga?> = async(context) { ): Deferred<LocalManga?> = async(context) {
runInterruptible { runInterruptible {
runCatching { getFromFile(file) }.getOrNull() runCatching { LocalManga(getFromFile(file), file) }.getOrNull()
} }
} }
@ -283,7 +282,7 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local
locks.unlock(id) locks.unlock(id)
} }
private suspend fun getRawList(): ArrayList<Manga> { private suspend fun getRawList(): ArrayList<LocalManga> {
val files = getAllFiles() val files = getAllFiles()
return coroutineScope { return coroutineScope {
val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM) val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)

@ -5,6 +5,7 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
@ -13,10 +14,11 @@ import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.ListSelectionController import org.koitharu.kotatsu.base.ui.list.ListSelectionController
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.addMenuProvider import org.koitharu.kotatsu.utils.ext.addMenuProvider
class LocalListFragment : MangaListFragment() { class LocalListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener {
override val viewModel by viewModels<LocalListViewModel>() override val viewModel by viewModels<LocalListViewModel>()
@ -30,6 +32,14 @@ class LocalListFragment : MangaListFragment() {
ImportDialogFragment.show(childFragmentManager) ImportDialogFragment.show(childFragmentManager)
} }
override fun onFilterClick(view: View?) {
super.onFilterClick(view)
val menu = PopupMenu(requireContext(), view ?: binding.recyclerView)
menu.inflate(R.menu.popup_order)
menu.setOnMenuItemClickListener(this)
menu.show()
}
override fun onScrolledToEnd() = Unit override fun onScrolledToEnd() = Unit
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
@ -53,6 +63,17 @@ class LocalListFragment : MangaListFragment() {
} }
} }
override fun onMenuItemClick(item: MenuItem): Boolean {
val order = when (item.itemId) {
R.id.action_order_new -> SortOrder.NEWEST
R.id.action_order_abs -> SortOrder.ALPHABETICAL
R.id.action_order_rating -> SortOrder.RATING
else -> return false
}
viewModel.setSortOrder(order)
return true
}
private fun showDeletionConfirm(ids: Set<Long>, mode: ActionMode) { private fun showDeletionConfirm(ids: Set<Long>, mode: ActionMode) {
MaterialAlertDialogBuilder(context ?: return) MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.delete_manga) .setTitle(R.string.delete_manga)

@ -23,4 +23,4 @@ class LocalListMenuProvider(
else -> false else -> false
} }
} }
} }

@ -1,27 +1,31 @@
package org.koitharu.kotatsu.local.ui package org.koitharu.kotatsu.local.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.IOException import java.io.IOException
import java.util.*
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
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.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
@ -30,18 +34,24 @@ import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
class LocalListViewModel @Inject constructor( class LocalListViewModel @Inject constructor(
private val repository: LocalMangaRepository, private val repository: LocalMangaRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
settings: AppSettings, private val trackingRepository: TrackingRepository,
) : MangaListViewModel(settings) { private val settings: AppSettings,
) : MangaListViewModel(settings), ListExtraProvider {
val onMangaRemoved = SingleLiveEvent<Unit>() val onMangaRemoved = SingleLiveEvent<Unit>()
val sortOrder = MutableLiveData(settings.localListOrder)
private val listError = MutableStateFlow<Throwable?>(null) private val listError = MutableStateFlow<Throwable?>(null)
private val mangaList = MutableStateFlow<List<Manga>?>(null) private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val selectedTags = MutableStateFlow<Set<MangaTag>>(emptySet())
private var refreshJob: Job? = null
override val content = combine( override val content = combine(
mangaList, mangaList,
createListModeFlow(), createListModeFlow(),
sortOrder.asFlow(),
selectedTags,
listError, listError,
) { list, mode, error -> ) { list, mode, order, tags, error ->
when { when {
error != null -> listOf(error.toErrorState(canRetry = true)) error != null -> listOf(error.toErrorState(canRetry = true))
list == null -> listOf(LoadingState) list == null -> listOf(LoadingState)
@ -53,7 +63,10 @@ class LocalListViewModel @Inject constructor(
actionStringRes = R.string._import, actionStringRes = R.string._import,
), ),
) )
else -> list.toUi(mode) else -> buildList(list.size + 1) {
add(createHeader(list, tags, order))
list.toUi(this, mode, this@LocalListViewModel)
}
} }
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
@ -63,14 +76,27 @@ class LocalListViewModel @Inject constructor(
watchDirectories() watchDirectories()
} }
override fun onUpdateFilter(tags: Set<MangaTag>) {
selectedTags.value = tags
onRefresh()
}
override fun onRefresh() { override fun onRefresh() {
launchLoadingJob(Dispatchers.Default) { val prevJob = refreshJob
refreshJob = launchLoadingJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
doRefresh() doRefresh()
} }
} }
override fun onRetry() = onRefresh() override fun onRetry() = onRefresh()
fun setSortOrder(value: SortOrder) {
sortOrder.value = value
settings.localListOrder = value
onRefresh()
}
fun delete(ids: Set<Long>) { fun delete(ids: Set<Long>) {
launchLoadingJob { launchLoadingJob {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
@ -93,7 +119,7 @@ class LocalListViewModel @Inject constructor(
private suspend fun doRefresh() { private suspend fun doRefresh() {
try { try {
listError.value = null listError.value = null
mangaList.value = repository.getList(0, null, null) mangaList.value = repository.getList(0, selectedTags.value, sortOrder.value)
} catch (e: Throwable) { } catch (e: Throwable) {
listError.value = e listError.value = e
} }
@ -119,4 +145,46 @@ class LocalListViewModel @Inject constructor(
} }
} }
} }
private fun createHeader(mangaList: List<Manga>, selectedTags: Set<MangaTag>, order: SortOrder): ListHeader2 {
val tags = HashMap<MangaTag, Int>()
for (item in mangaList) {
for (tag in item.tags) {
tags[tag] = tags[tag]?.plus(1) ?: 1
}
}
val topTags = tags.entries.sortedByDescending { it.value }.take(6)
val chips = LinkedList<ChipsView.ChipModel>()
for ((tag, _) in topTags) {
val model = ChipsView.ChipModel(
icon = 0,
title = tag.title,
isCheckable = true,
isChecked = tag in selectedTags,
data = tag,
)
if (model.isChecked) {
chips.addFirst(model)
} else {
chips.addLast(model)
}
}
return ListHeader2(
chips = chips,
sortOrder = order,
hasSelectedTags = selectedTags.isNotEmpty(),
)
}
override suspend fun getCounter(mangaId: Long): Int {
return trackingRepository.getNewChaptersCount(mangaId)
}
override suspend fun getProgress(mangaId: Long): Float {
return if (settings.isReadingIndicatorsEnabled) {
historyRepository.getProgress(mangaId)
} else {
PROGRESS_NONE
}
}
} }

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_order_new"
android:title="@string/newest" />
<item
android:id="@+id/action_order_abs"
android:title="@string/by_name" />
<item
android:id="@+id/action_order_rating"
android:title="@string/by_rating" />
</menu>
Loading…
Cancel
Save