diff --git a/app/build.gradle b/app/build.gradle index 90674a12f..da11b4b0f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,8 +15,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdkVersion 21 targetSdkVersion 33 - versionCode 494 - versionName '4.0-a5' + versionCode 495 + versionName '4.0-a6' generatedDensities = [] testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -82,7 +82,7 @@ afterEvaluate { } } dependencies { - implementation('com.github.KotatsuApp:kotatsu-parsers:f112a06ab6') { + implementation('com.github.KotatsuApp:kotatsu-parsers:551a1d70ae') { exclude group: 'org.json', module: 'json' } @@ -102,7 +102,7 @@ dependencies { implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.work:work-runtime-ktx:2.7.1' 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 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-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.solkin:disk-lru-cache:1.4' diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 7eeb64804..627056742 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -21,6 +21,7 @@ import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.network.DoHProvider 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.observe import org.koitharu.kotatsu.utils.ext.putEnumValue @@ -208,6 +209,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val dnsOverHttps: DoHProvider 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 { return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) { NETWORK_ALWAYS -> true @@ -325,6 +330,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_READER_BAR = "reader_bar" const val KEY_SHORTCUTS = "dynamic_shortcuts" const val KEY_READER_TAPS_LTR = "reader_taps_ltr" + const val KEY_LOCAL_LIST_ORDER = "local_order" // About const val KEY_APP_UPDATE = "app_update" diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalManga.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalManga.kt new file mode 100644 index 000000000..722a70411 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalManga.kt @@ -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.unwrap(): List = 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): Boolean { + return manga.tags.containsAll(tags) +} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt index 7d08cb72b..4cca3afeb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt @@ -45,12 +45,9 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local } val list = getRawList() if (query.isNotEmpty()) { - list.retainAll { x -> - x.title.contains(query, ignoreCase = true) || - x.altTitle?.contains(query, ignoreCase = true) == true - } + list.retainAll { x -> x.isMatchesQuery(query) } } - return list + return list.unwrap() } override suspend fun getList(offset: Int, tags: Set?, sortOrder: SortOrder?): List { @@ -59,15 +56,17 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local } val list = getRawList() if (!tags.isNullOrEmpty()) { - list.retainAll { x -> - x.tags.containsAll(tags) - } + list.retainAll { x -> x.containsTags(tags) } } + @Suppress("NON_EXHAUSTIVE_WHEN_STATEMENT") when (sortOrder) { - SortOrder.ALPHABETICAL -> list.sortBy { it.title } - SortOrder.RATING -> list.sortBy { it.rating } + SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title }) + 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 { @@ -235,9 +234,9 @@ class LocalMangaRepository @Inject constructor(private val storageManager: Local private fun CoroutineScope.getFromFileAsync( file: File, context: CoroutineContext, - ): Deferred = async(context) { + ): Deferred = async(context) { 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) } - private suspend fun getRawList(): ArrayList { + private suspend fun getRawList(): ArrayList { val files = getAllFiles() return coroutineScope { val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM) diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt index 645a3624a..4d88b6663 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt @@ -5,6 +5,7 @@ import android.view.Menu import android.view.MenuItem import android.view.View import androidx.appcompat.view.ActionMode +import androidx.appcompat.widget.PopupMenu import androidx.core.net.toFile import androidx.core.net.toUri 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.base.ui.list.ListSelectionController 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.ext.addMenuProvider -class LocalListFragment : MangaListFragment() { +class LocalListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener { override val viewModel by viewModels() @@ -30,6 +32,14 @@ class LocalListFragment : MangaListFragment() { 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 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, mode: ActionMode) { MaterialAlertDialogBuilder(context ?: return) .setTitle(R.string.delete_manga) diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt index ce9941293..f2be49775 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt @@ -23,4 +23,4 @@ class LocalListMenuProvider( else -> false } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 4d623fbfd..745ac884d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -1,27 +1,31 @@ package org.koitharu.kotatsu.local.ui +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import java.io.IOException +import java.util.* import javax.inject.Inject -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.download.ui.service.DownloadService 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.model.EmptyState -import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.list.ui.model.toErrorState -import org.koitharu.kotatsu.list.ui.model.toUi +import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.local.domain.LocalMangaRepository 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.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.printStackTraceDebug @@ -30,18 +34,24 @@ import org.koitharu.kotatsu.utils.ext.printStackTraceDebug class LocalListViewModel @Inject constructor( private val repository: LocalMangaRepository, private val historyRepository: HistoryRepository, - settings: AppSettings, -) : MangaListViewModel(settings) { + private val trackingRepository: TrackingRepository, + private val settings: AppSettings, +) : MangaListViewModel(settings), ListExtraProvider { val onMangaRemoved = SingleLiveEvent() + val sortOrder = MutableLiveData(settings.localListOrder) private val listError = MutableStateFlow(null) private val mangaList = MutableStateFlow?>(null) + private val selectedTags = MutableStateFlow>(emptySet()) + private var refreshJob: Job? = null override val content = combine( mangaList, createListModeFlow(), + sortOrder.asFlow(), + selectedTags, listError, - ) { list, mode, error -> + ) { list, mode, order, tags, error -> when { error != null -> listOf(error.toErrorState(canRetry = true)) list == null -> listOf(LoadingState) @@ -53,7 +63,10 @@ class LocalListViewModel @Inject constructor( 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)) @@ -63,14 +76,27 @@ class LocalListViewModel @Inject constructor( watchDirectories() } + override fun onUpdateFilter(tags: Set) { + selectedTags.value = tags + onRefresh() + } + override fun onRefresh() { - launchLoadingJob(Dispatchers.Default) { + val prevJob = refreshJob + refreshJob = launchLoadingJob(Dispatchers.Default) { + prevJob?.cancelAndJoin() doRefresh() } } override fun onRetry() = onRefresh() + fun setSortOrder(value: SortOrder) { + sortOrder.value = value + settings.localListOrder = value + onRefresh() + } + fun delete(ids: Set) { launchLoadingJob { withContext(Dispatchers.Default) { @@ -93,7 +119,7 @@ class LocalListViewModel @Inject constructor( private suspend fun doRefresh() { try { listError.value = null - mangaList.value = repository.getList(0, null, null) + mangaList.value = repository.getList(0, selectedTags.value, sortOrder.value) } catch (e: Throwable) { listError.value = e } @@ -119,4 +145,46 @@ class LocalListViewModel @Inject constructor( } } } + + private fun createHeader(mangaList: List, selectedTags: Set, order: SortOrder): ListHeader2 { + val tags = HashMap() + 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() + 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 + } + } } diff --git a/app/src/main/res/menu/popup_order.xml b/app/src/main/res/menu/popup_order.xml new file mode 100644 index 000000000..3125387e7 --- /dev/null +++ b/app/src/main/res/menu/popup_order.xml @@ -0,0 +1,17 @@ + + + + + + + + + +