|
@@ -1,18 +1,15 @@
|
|
|
package eu.kanade.tachiyomi.ui.library
|
|
|
|
|
|
-import android.os.Bundle
|
|
|
-import androidx.compose.runtime.Composable
|
|
|
-import androidx.compose.runtime.derivedStateOf
|
|
|
+import androidx.compose.runtime.Immutable
|
|
|
import androidx.compose.runtime.getValue
|
|
|
-import androidx.compose.runtime.mutableStateOf
|
|
|
-import androidx.compose.runtime.produceState
|
|
|
-import androidx.compose.runtime.remember
|
|
|
import androidx.compose.runtime.setValue
|
|
|
-import androidx.compose.ui.res.stringResource
|
|
|
import androidx.compose.ui.util.fastAny
|
|
|
import androidx.compose.ui.util.fastMap
|
|
|
+import cafe.adriel.voyager.core.model.StateScreenModel
|
|
|
+import cafe.adriel.voyager.core.model.coroutineScope
|
|
|
import eu.kanade.core.prefs.CheckboxState
|
|
|
import eu.kanade.core.prefs.PreferenceMutableState
|
|
|
+import eu.kanade.core.prefs.asState
|
|
|
import eu.kanade.core.util.fastFilter
|
|
|
import eu.kanade.core.util.fastFilterNot
|
|
|
import eu.kanade.core.util.fastMapNotNull
|
|
@@ -35,11 +32,8 @@ import eu.kanade.domain.manga.model.Manga
|
|
|
import eu.kanade.domain.manga.model.MangaUpdate
|
|
|
import eu.kanade.domain.manga.model.isLocal
|
|
|
import eu.kanade.domain.track.interactor.GetTracksPerManga
|
|
|
-import eu.kanade.presentation.category.visualName
|
|
|
-import eu.kanade.presentation.library.LibraryState
|
|
|
-import eu.kanade.presentation.library.LibraryStateImpl
|
|
|
import eu.kanade.presentation.library.components.LibraryToolbarTitle
|
|
|
-import eu.kanade.tachiyomi.R
|
|
|
+import eu.kanade.presentation.manga.DownloadAction
|
|
|
import eu.kanade.tachiyomi.data.cache.CoverCache
|
|
|
import eu.kanade.tachiyomi.data.download.DownloadCache
|
|
|
import eu.kanade.tachiyomi.data.download.DownloadManager
|
|
@@ -47,38 +41,33 @@ import eu.kanade.tachiyomi.data.track.TrackManager
|
|
|
import eu.kanade.tachiyomi.source.SourceManager
|
|
|
import eu.kanade.tachiyomi.source.model.SManga
|
|
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
|
|
-import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
|
|
import eu.kanade.tachiyomi.util.chapter.getNextUnread
|
|
|
import eu.kanade.tachiyomi.util.lang.launchIO
|
|
|
import eu.kanade.tachiyomi.util.lang.launchNonCancellable
|
|
|
import eu.kanade.tachiyomi.util.lang.withIOContext
|
|
|
import eu.kanade.tachiyomi.util.removeCovers
|
|
|
-import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup.State
|
|
|
-import kotlinx.coroutines.Job
|
|
|
-import kotlinx.coroutines.channels.Channel
|
|
|
+import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.TriStateGroup
|
|
|
import kotlinx.coroutines.flow.Flow
|
|
|
import kotlinx.coroutines.flow.collectLatest
|
|
|
import kotlinx.coroutines.flow.combine
|
|
|
-import kotlinx.coroutines.flow.onStart
|
|
|
-import kotlinx.coroutines.flow.receiveAsFlow
|
|
|
+import kotlinx.coroutines.flow.distinctUntilChanged
|
|
|
+import kotlinx.coroutines.flow.first
|
|
|
+import kotlinx.coroutines.flow.launchIn
|
|
|
+import kotlinx.coroutines.flow.map
|
|
|
+import kotlinx.coroutines.flow.onEach
|
|
|
+import kotlinx.coroutines.flow.update
|
|
|
import uy.kohesive.injekt.Injekt
|
|
|
import uy.kohesive.injekt.api.get
|
|
|
import java.text.Collator
|
|
|
import java.util.Collections
|
|
|
import java.util.Locale
|
|
|
|
|
|
-/**
|
|
|
- * Class containing library information.
|
|
|
- */
|
|
|
-private data class Library(val categories: List<Category>, val mangaMap: LibraryMap)
|
|
|
-
|
|
|
/**
|
|
|
* Typealias for the library manga, using the category as keys, and list of manga as values.
|
|
|
*/
|
|
|
-typealias LibraryMap = Map<Long, List<LibraryItem>>
|
|
|
+typealias LibraryMap = Map<Category, List<LibraryItem>>
|
|
|
|
|
|
-class LibraryPresenter(
|
|
|
- private val state: LibraryStateImpl = LibraryState() as LibraryStateImpl,
|
|
|
+class LibraryScreenModel(
|
|
|
private val getLibraryManga: GetLibraryManga = Injekt.get(),
|
|
|
private val getCategories: GetCategories = Injekt.get(),
|
|
|
private val getTracksPerManga: GetTracksPerManga = Injekt.get(),
|
|
@@ -94,90 +83,114 @@ class LibraryPresenter(
|
|
|
private val downloadManager: DownloadManager = Injekt.get(),
|
|
|
private val downloadCache: DownloadCache = Injekt.get(),
|
|
|
private val trackManager: TrackManager = Injekt.get(),
|
|
|
-) : BasePresenter<LibraryController>(), LibraryState by state {
|
|
|
-
|
|
|
- private var loadedManga by mutableStateOf(emptyMap<Long, List<LibraryItem>>())
|
|
|
-
|
|
|
- val isLibraryEmpty by derivedStateOf { loadedManga.isEmpty() }
|
|
|
-
|
|
|
- val tabVisibility by libraryPreferences.categoryTabs().asState()
|
|
|
- val mangaCountVisibility by libraryPreferences.categoryNumberOfItems().asState()
|
|
|
-
|
|
|
- val showDownloadBadges by libraryPreferences.downloadBadge().asState()
|
|
|
- val showUnreadBadges by libraryPreferences.unreadBadge().asState()
|
|
|
- val showLocalBadges by libraryPreferences.localBadge().asState()
|
|
|
- val showLanguageBadges by libraryPreferences.languageBadge().asState()
|
|
|
-
|
|
|
- var activeCategory: Int by libraryPreferences.lastUsedCategory().asState()
|
|
|
-
|
|
|
- val showContinueReadingButton by libraryPreferences.showContinueReadingButton().asState()
|
|
|
-
|
|
|
- val isDownloadOnly: Boolean by preferences.downloadedOnly().asState()
|
|
|
- val isIncognitoMode: Boolean by preferences.incognitoMode().asState()
|
|
|
-
|
|
|
- private val _filterChanges: Channel<Unit> = Channel(Int.MAX_VALUE)
|
|
|
- private val filterChanges = _filterChanges.receiveAsFlow().onStart { emit(Unit) }
|
|
|
-
|
|
|
- private var librarySubscription: Job? = null
|
|
|
-
|
|
|
- override fun onCreate(savedState: Bundle?) {
|
|
|
- super.onCreate(savedState)
|
|
|
-
|
|
|
- subscribeLibrary()
|
|
|
- }
|
|
|
+) : StateScreenModel<LibraryScreenModel.State>(State()) {
|
|
|
+
|
|
|
+ // This is active category INDEX NUMBER
|
|
|
+ var activeCategory: Int by libraryPreferences.lastUsedCategory().asState(coroutineScope)
|
|
|
+
|
|
|
+ val isDownloadOnly: Boolean by preferences.downloadedOnly().asState(coroutineScope)
|
|
|
+ val isIncognitoMode: Boolean by preferences.incognitoMode().asState(coroutineScope)
|
|
|
+
|
|
|
+ init {
|
|
|
+ coroutineScope.launchIO {
|
|
|
+ combine(
|
|
|
+ state.map { it.searchQuery }.distinctUntilChanged(),
|
|
|
+ getLibraryFlow(),
|
|
|
+ getTracksPerManga.subscribe(),
|
|
|
+ getTrackingFilterFlow(),
|
|
|
+ ) { searchQuery, library, tracks, loggedInTrackServices ->
|
|
|
+ library
|
|
|
+ .applyFilters(tracks, loggedInTrackServices)
|
|
|
+ .applySort()
|
|
|
+ .mapValues { (_, value) ->
|
|
|
+ if (searchQuery != null) {
|
|
|
+ // Filter query
|
|
|
+ value.filter { it.matches(searchQuery) }
|
|
|
+ } else {
|
|
|
+ // Don't do anything
|
|
|
+ value
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .collectLatest {
|
|
|
+ mutableState.update { state ->
|
|
|
+ state.copy(
|
|
|
+ isLoading = false,
|
|
|
+ library = it,
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- fun subscribeLibrary() {
|
|
|
- /**
|
|
|
- * TODO:
|
|
|
- * - Move filter and sort to getMangaForCategory and only filter and sort the current display category instead of whole library as some has 5000+ items in the library
|
|
|
- * - Create new db view and new query to just fetch the current category save as needed to instance variable
|
|
|
- * - Fetch badges to maps and retrieve as needed instead of fetching all of them at once
|
|
|
- */
|
|
|
- if (librarySubscription == null || librarySubscription!!.isCancelled) {
|
|
|
- librarySubscription = presenterScope.launchIO {
|
|
|
- combine(getLibraryFlow(), getTracksPerManga.subscribe(), filterChanges) { library, tracks, _ ->
|
|
|
- library.mangaMap
|
|
|
- .applyFilters(tracks)
|
|
|
- .applySort(library.categories)
|
|
|
+ combine(
|
|
|
+ libraryPreferences.categoryTabs().changes(),
|
|
|
+ libraryPreferences.categoryNumberOfItems().changes(),
|
|
|
+ libraryPreferences.showContinueReadingButton().changes(),
|
|
|
+ ) { a, b, c -> arrayOf(a, b, c) }
|
|
|
+ .onEach { (showCategoryTabs, showMangaCount, showMangaContinueButton) ->
|
|
|
+ mutableState.update { state ->
|
|
|
+ state.copy(
|
|
|
+ showCategoryTabs = showCategoryTabs,
|
|
|
+ showMangaCount = showMangaCount,
|
|
|
+ showMangaContinueButton = showMangaContinueButton,
|
|
|
+ )
|
|
|
}
|
|
|
- .collectLatest {
|
|
|
- state.isLoading = false
|
|
|
- loadedManga = it
|
|
|
- }
|
|
|
}
|
|
|
+ .launchIn(coroutineScope)
|
|
|
+
|
|
|
+ combine(
|
|
|
+ getLibraryItemPreferencesFlow(),
|
|
|
+ getTrackingFilterFlow(),
|
|
|
+ ) { prefs, trackFilter ->
|
|
|
+ val a = (
|
|
|
+ prefs.filterDownloaded or
|
|
|
+ prefs.filterUnread or
|
|
|
+ prefs.filterStarted or
|
|
|
+ prefs.filterBookmarked or
|
|
|
+ prefs.filterCompleted
|
|
|
+ ) != TriStateGroup.State.IGNORE.value
|
|
|
+ val b = trackFilter.values.any { it != TriStateGroup.State.IGNORE.value }
|
|
|
+ a || b
|
|
|
}
|
|
|
+ .distinctUntilChanged()
|
|
|
+ .onEach {
|
|
|
+ mutableState.update { state ->
|
|
|
+ state.copy(hasActiveFilters = it)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .launchIn(coroutineScope)
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Applies library filters to the given map of manga.
|
|
|
*/
|
|
|
- private fun LibraryMap.applyFilters(trackMap: Map<Long, List<Long>>): LibraryMap {
|
|
|
- val downloadedOnly = preferences.downloadedOnly().get()
|
|
|
- val filterDownloaded = libraryPreferences.filterDownloaded().get()
|
|
|
- val filterUnread = libraryPreferences.filterUnread().get()
|
|
|
- val filterStarted = libraryPreferences.filterStarted().get()
|
|
|
- val filterBookmarked = libraryPreferences.filterBookmarked().get()
|
|
|
- val filterCompleted = libraryPreferences.filterCompleted().get()
|
|
|
-
|
|
|
- val loggedInTrackServices = trackManager.services.fastFilter { trackService -> trackService.isLogged }
|
|
|
- .associate { trackService ->
|
|
|
- trackService.id to libraryPreferences.filterTracking(trackService.id.toInt()).get()
|
|
|
- }
|
|
|
+ private suspend fun LibraryMap.applyFilters(
|
|
|
+ trackMap: Map<Long, List<Long>>,
|
|
|
+ loggedInTrackServices: Map<Long, Int>,
|
|
|
+ ): LibraryMap {
|
|
|
+ val prefs = getLibraryItemPreferencesFlow().first()
|
|
|
+ val downloadedOnly = prefs.globalFilterDownloaded
|
|
|
+ val filterDownloaded = prefs.filterDownloaded
|
|
|
+ val filterUnread = prefs.filterUnread
|
|
|
+ val filterStarted = prefs.filterStarted
|
|
|
+ val filterBookmarked = prefs.filterBookmarked
|
|
|
+ val filterCompleted = prefs.filterCompleted
|
|
|
+
|
|
|
val isNotLoggedInAnyTrack = loggedInTrackServices.isEmpty()
|
|
|
|
|
|
- val excludedTracks = loggedInTrackServices.mapNotNull { if (it.value == State.EXCLUDE.value) it.key else null }
|
|
|
- val includedTracks = loggedInTrackServices.mapNotNull { if (it.value == State.INCLUDE.value) it.key else null }
|
|
|
+ val excludedTracks = loggedInTrackServices.mapNotNull { if (it.value == TriStateGroup.State.EXCLUDE.value) it.key else null }
|
|
|
+ val includedTracks = loggedInTrackServices.mapNotNull { if (it.value == TriStateGroup.State.INCLUDE.value) it.key else null }
|
|
|
val trackFiltersIsIgnored = includedTracks.isEmpty() && excludedTracks.isEmpty()
|
|
|
|
|
|
val filterFnDownloaded: (LibraryItem) -> Boolean = downloaded@{ item ->
|
|
|
- if (!downloadedOnly && filterDownloaded == State.IGNORE.value) return@downloaded true
|
|
|
+ if (!downloadedOnly && filterDownloaded == TriStateGroup.State.IGNORE.value) return@downloaded true
|
|
|
val isDownloaded = when {
|
|
|
item.libraryManga.manga.isLocal() -> true
|
|
|
item.downloadCount != -1L -> item.downloadCount > 0
|
|
|
else -> downloadManager.getDownloadCount(item.libraryManga.manga) > 0
|
|
|
}
|
|
|
|
|
|
- return@downloaded if (downloadedOnly || filterDownloaded == State.INCLUDE.value) {
|
|
|
+ return@downloaded if (downloadedOnly || filterDownloaded == TriStateGroup.State.INCLUDE.value) {
|
|
|
isDownloaded
|
|
|
} else {
|
|
|
!isDownloaded
|
|
@@ -185,10 +198,10 @@ class LibraryPresenter(
|
|
|
}
|
|
|
|
|
|
val filterFnUnread: (LibraryItem) -> Boolean = unread@{ item ->
|
|
|
- if (filterUnread == State.IGNORE.value) return@unread true
|
|
|
+ if (filterUnread == TriStateGroup.State.IGNORE.value) return@unread true
|
|
|
val isUnread = item.libraryManga.unreadCount > 0
|
|
|
|
|
|
- return@unread if (filterUnread == State.INCLUDE.value) {
|
|
|
+ return@unread if (filterUnread == TriStateGroup.State.INCLUDE.value) {
|
|
|
isUnread
|
|
|
} else {
|
|
|
!isUnread
|
|
@@ -196,10 +209,10 @@ class LibraryPresenter(
|
|
|
}
|
|
|
|
|
|
val filterFnStarted: (LibraryItem) -> Boolean = started@{ item ->
|
|
|
- if (filterStarted == State.IGNORE.value) return@started true
|
|
|
+ if (filterStarted == TriStateGroup.State.IGNORE.value) return@started true
|
|
|
val hasStarted = item.libraryManga.hasStarted
|
|
|
|
|
|
- return@started if (filterStarted == State.INCLUDE.value) {
|
|
|
+ return@started if (filterStarted == TriStateGroup.State.INCLUDE.value) {
|
|
|
hasStarted
|
|
|
} else {
|
|
|
!hasStarted
|
|
@@ -207,11 +220,11 @@ class LibraryPresenter(
|
|
|
}
|
|
|
|
|
|
val filterFnBookmarked: (LibraryItem) -> Boolean = bookmarked@{ item ->
|
|
|
- if (filterBookmarked == State.IGNORE.value) return@bookmarked true
|
|
|
+ if (filterBookmarked == TriStateGroup.State.IGNORE.value) return@bookmarked true
|
|
|
|
|
|
val hasBookmarks = item.libraryManga.hasBookmarks
|
|
|
|
|
|
- return@bookmarked if (filterBookmarked == State.INCLUDE.value) {
|
|
|
+ return@bookmarked if (filterBookmarked == TriStateGroup.State.INCLUDE.value) {
|
|
|
hasBookmarks
|
|
|
} else {
|
|
|
!hasBookmarks
|
|
@@ -219,10 +232,10 @@ class LibraryPresenter(
|
|
|
}
|
|
|
|
|
|
val filterFnCompleted: (LibraryItem) -> Boolean = completed@{ item ->
|
|
|
- if (filterCompleted == State.IGNORE.value) return@completed true
|
|
|
+ if (filterCompleted == TriStateGroup.State.IGNORE.value) return@completed true
|
|
|
val isCompleted = item.libraryManga.manga.status.toInt() == SManga.COMPLETED
|
|
|
|
|
|
- return@completed if (filterCompleted == State.INCLUDE.value) {
|
|
|
+ return@completed if (filterCompleted == TriStateGroup.State.INCLUDE.value) {
|
|
|
isCompleted
|
|
|
} else {
|
|
|
!isCompleted
|
|
@@ -266,9 +279,7 @@ class LibraryPresenter(
|
|
|
/**
|
|
|
* Applies library sorting to the given map of manga.
|
|
|
*/
|
|
|
- private fun LibraryMap.applySort(categories: List<Category>): LibraryMap {
|
|
|
- val sortModes = categories.associate { it.id to it.sort }
|
|
|
-
|
|
|
+ private fun LibraryMap.applySort(): LibraryMap {
|
|
|
val locale = Locale.getDefault()
|
|
|
val collator = Collator.getInstance(locale).apply {
|
|
|
strength = Collator.PRIMARY
|
|
@@ -278,7 +289,7 @@ class LibraryPresenter(
|
|
|
}
|
|
|
|
|
|
val sortFn: (LibraryItem, LibraryItem) -> Int = { i1, i2 ->
|
|
|
- val sort = sortModes[i1.libraryManga.category]!!
|
|
|
+ val sort = keys.find { it.id == i1.libraryManga.category }!!.sort
|
|
|
when (sort.type) {
|
|
|
LibrarySort.Type.Alphabetical -> {
|
|
|
sortAlphabetically(i1, i2)
|
|
@@ -308,12 +319,11 @@ class LibraryPresenter(
|
|
|
LibrarySort.Type.DateAdded -> {
|
|
|
i1.libraryManga.manga.dateAdded.compareTo(i2.libraryManga.manga.dateAdded)
|
|
|
}
|
|
|
- else -> throw IllegalStateException("Invalid SortModeSetting: ${sort.type}")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return this.mapValues { entry ->
|
|
|
- val comparator = if (sortModes[entry.key]!!.isAscending) {
|
|
|
+ val comparator = if (keys.find { it.id == entry.key.id }!!.sort.isAscending) {
|
|
|
Comparator(sortFn)
|
|
|
} else {
|
|
|
Collections.reverseOrder(sortFn)
|
|
@@ -323,24 +333,52 @@ class LibraryPresenter(
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ private fun getLibraryItemPreferencesFlow(): Flow<ItemPreferences> {
|
|
|
+ return combine(
|
|
|
+ libraryPreferences.downloadBadge().changes(),
|
|
|
+ libraryPreferences.unreadBadge().changes(),
|
|
|
+ libraryPreferences.localBadge().changes(),
|
|
|
+ libraryPreferences.languageBadge().changes(),
|
|
|
+
|
|
|
+ preferences.downloadedOnly().changes(),
|
|
|
+ libraryPreferences.filterDownloaded().changes(),
|
|
|
+ libraryPreferences.filterUnread().changes(),
|
|
|
+ libraryPreferences.filterStarted().changes(),
|
|
|
+ libraryPreferences.filterBookmarked().changes(),
|
|
|
+ libraryPreferences.filterCompleted().changes(),
|
|
|
+ transform = {
|
|
|
+ ItemPreferences(
|
|
|
+ downloadBadge = it[0] as Boolean,
|
|
|
+ unreadBadge = it[1] as Boolean,
|
|
|
+ localBadge = it[2] as Boolean,
|
|
|
+ languageBadge = it[3] as Boolean,
|
|
|
+ globalFilterDownloaded = it[4] as Boolean,
|
|
|
+ filterDownloaded = it[5] as Int,
|
|
|
+ filterUnread = it[6] as Int,
|
|
|
+ filterStarted = it[7] as Int,
|
|
|
+ filterBookmarked = it[8] as Int,
|
|
|
+ filterCompleted = it[9] as Int,
|
|
|
+ )
|
|
|
+ },
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Get the categories and all its manga from the database.
|
|
|
*
|
|
|
* @return an observable of the categories and its manga.
|
|
|
*/
|
|
|
- private fun getLibraryFlow(): Flow<Library> {
|
|
|
+ private fun getLibraryFlow(): Flow<LibraryMap> {
|
|
|
val libraryMangasFlow = combine(
|
|
|
getLibraryManga.subscribe(),
|
|
|
- libraryPreferences.downloadBadge().changes(),
|
|
|
- libraryPreferences.filterDownloaded().changes(),
|
|
|
- preferences.downloadedOnly().changes(),
|
|
|
+ getLibraryItemPreferencesFlow(),
|
|
|
downloadCache.changes,
|
|
|
- ) { libraryMangaList, downloadBadgePref, filterDownloadedPref, downloadedOnly, _ ->
|
|
|
+ ) { libraryMangaList, prefs, _ ->
|
|
|
libraryMangaList
|
|
|
.map { libraryManga ->
|
|
|
- val needsDownloadCounts = downloadBadgePref ||
|
|
|
- filterDownloadedPref != State.IGNORE.value ||
|
|
|
- downloadedOnly
|
|
|
+ val needsDownloadCounts = prefs.downloadBadge ||
|
|
|
+ prefs.filterDownloaded != TriStateGroup.State.IGNORE.value ||
|
|
|
+ prefs.globalFilterDownloaded
|
|
|
|
|
|
// Display mode based on user preference: take it from global library setting or category
|
|
|
LibraryItem(libraryManga).apply {
|
|
@@ -349,39 +387,44 @@ class LibraryPresenter(
|
|
|
} else {
|
|
|
0
|
|
|
}
|
|
|
- unreadCount = libraryManga.unreadCount
|
|
|
- isLocal = libraryManga.manga.isLocal()
|
|
|
- sourceLanguage = sourceManager.getOrStub(libraryManga.manga.source).lang
|
|
|
+ unreadCount = if (prefs.unreadBadge) libraryManga.unreadCount else 0
|
|
|
+ isLocal = if (prefs.localBadge) libraryManga.manga.isLocal() else false
|
|
|
+ sourceLanguage = if (prefs.languageBadge) {
|
|
|
+ sourceManager.getOrStub(libraryManga.manga.source).lang
|
|
|
+ } else {
|
|
|
+ ""
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
.groupBy { it.libraryManga.category }
|
|
|
}
|
|
|
|
|
|
return combine(getCategories.subscribe(), libraryMangasFlow) { categories, libraryManga ->
|
|
|
- val displayCategories = if (libraryManga.isNotEmpty() && libraryManga.containsKey(0).not()) {
|
|
|
+ val displayCategories = if (libraryManga.isNotEmpty() && !libraryManga.containsKey(0)) {
|
|
|
categories.fastFilterNot { it.isSystemCategory }
|
|
|
} else {
|
|
|
categories
|
|
|
}
|
|
|
|
|
|
- state.categories = displayCategories
|
|
|
- Library(categories, libraryManga)
|
|
|
+ displayCategories.associateWith { libraryManga[it.id] ?: emptyList() }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Requests the library to be filtered.
|
|
|
- */
|
|
|
- suspend fun requestFilterUpdate() = withIOContext {
|
|
|
- _filterChanges.send(Unit)
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * Called when a manga is opened.
|
|
|
+ * Flow of tracking filter preferences
|
|
|
+ *
|
|
|
+ * @return map of track id with the filter value
|
|
|
*/
|
|
|
- fun onOpenManga() {
|
|
|
- // Avoid further db updates for the library when it's not needed
|
|
|
- librarySubscription?.cancel()
|
|
|
+ private fun getTrackingFilterFlow(): Flow<Map<Long, Int>> {
|
|
|
+ val loggedServices = trackManager.services.filter { it.isLogged }
|
|
|
+ val a = loggedServices
|
|
|
+ .map { libraryPreferences.filterTracking(it.id.toInt()).changes() }
|
|
|
+ .toTypedArray()
|
|
|
+ return combine(*a) {
|
|
|
+ loggedServices
|
|
|
+ .mapIndexed { index, trackService -> trackService.id to it[index] }
|
|
|
+ .toMap()
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
/**
|
|
@@ -389,7 +432,7 @@ class LibraryPresenter(
|
|
|
*
|
|
|
* @param mangas the list of manga.
|
|
|
*/
|
|
|
- suspend fun getCommonCategories(mangas: List<Manga>): Collection<Category> {
|
|
|
+ private suspend fun getCommonCategories(mangas: List<Manga>): Collection<Category> {
|
|
|
if (mangas.isEmpty()) return emptyList()
|
|
|
return mangas
|
|
|
.map { getCategories.await(it.id).toSet() }
|
|
@@ -405,13 +448,37 @@ class LibraryPresenter(
|
|
|
*
|
|
|
* @param mangas the list of manga.
|
|
|
*/
|
|
|
- suspend fun getMixCategories(mangas: List<Manga>): Collection<Category> {
|
|
|
+ private suspend fun getMixCategories(mangas: List<Manga>): Collection<Category> {
|
|
|
if (mangas.isEmpty()) return emptyList()
|
|
|
val mangaCategories = mangas.map { getCategories.await(it.id).toSet() }
|
|
|
val common = mangaCategories.reduce { set1, set2 -> set1.intersect(set2) }
|
|
|
return mangaCategories.flatten().distinct().subtract(common)
|
|
|
}
|
|
|
|
|
|
+ fun runDownloadActionSelection(action: DownloadAction) {
|
|
|
+ val selection = state.value.selection
|
|
|
+ val mangas = selection.map { it.manga }.toList()
|
|
|
+ when (action) {
|
|
|
+ DownloadAction.NEXT_1_CHAPTER -> downloadUnreadChapters(mangas, 1)
|
|
|
+ DownloadAction.NEXT_5_CHAPTERS -> downloadUnreadChapters(mangas, 5)
|
|
|
+ DownloadAction.NEXT_10_CHAPTERS -> downloadUnreadChapters(mangas, 10)
|
|
|
+ DownloadAction.UNREAD_CHAPTERS -> downloadUnreadChapters(mangas, null)
|
|
|
+ DownloadAction.CUSTOM -> {
|
|
|
+ mutableState.update { state ->
|
|
|
+ state.copy(
|
|
|
+ dialog = Dialog.DownloadCustomAmount(
|
|
|
+ mangas,
|
|
|
+ selection.maxOf { it.unreadCount }.toInt(),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
+ else -> {}
|
|
|
+ }
|
|
|
+ clearSelection()
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Queues the amount specified of unread chapters from the list of mangas given.
|
|
|
*
|
|
@@ -419,7 +486,7 @@ class LibraryPresenter(
|
|
|
* @param amount the amount to queue or null to queue all
|
|
|
*/
|
|
|
fun downloadUnreadChapters(mangas: List<Manga>, amount: Int?) {
|
|
|
- presenterScope.launchNonCancellable {
|
|
|
+ coroutineScope.launchNonCancellable {
|
|
|
mangas.forEach { manga ->
|
|
|
val chapters = getNextChapters.await(manga.id)
|
|
|
.fastFilterNot { chapter ->
|
|
@@ -440,18 +507,18 @@ class LibraryPresenter(
|
|
|
|
|
|
/**
|
|
|
* Marks mangas' chapters read status.
|
|
|
- *
|
|
|
- * @param mangas the list of manga.
|
|
|
*/
|
|
|
- fun markReadStatus(mangas: List<Manga>, read: Boolean) {
|
|
|
- presenterScope.launchNonCancellable {
|
|
|
+ fun markReadSelection(read: Boolean) {
|
|
|
+ val mangas = state.value.selection.toList()
|
|
|
+ coroutineScope.launchNonCancellable {
|
|
|
mangas.forEach { manga ->
|
|
|
setReadStatus.await(
|
|
|
- manga = manga,
|
|
|
+ manga = manga.manga,
|
|
|
read = read,
|
|
|
)
|
|
|
}
|
|
|
}
|
|
|
+ clearSelection()
|
|
|
}
|
|
|
|
|
|
/**
|
|
@@ -462,7 +529,7 @@ class LibraryPresenter(
|
|
|
* @param deleteChapters whether to delete downloaded chapters.
|
|
|
*/
|
|
|
fun removeMangas(mangaList: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
|
|
|
- presenterScope.launchNonCancellable {
|
|
|
+ coroutineScope.launchNonCancellable {
|
|
|
val mangaToDelete = mangaList.distinctBy { it.id }
|
|
|
|
|
|
if (deleteFromLibrary) {
|
|
@@ -495,7 +562,7 @@ class LibraryPresenter(
|
|
|
* @param removeCategories the categories to remove in all mangas.
|
|
|
*/
|
|
|
fun setMangaCategories(mangaList: List<Manga>, addCategories: List<Long>, removeCategories: List<Long>) {
|
|
|
- presenterScope.launchNonCancellable {
|
|
|
+ coroutineScope.launchNonCancellable {
|
|
|
mangaList.forEach { manga ->
|
|
|
val categoryIds = getCategories.await(manga.id)
|
|
|
.map { it.id }
|
|
@@ -508,148 +575,215 @@ class LibraryPresenter(
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- @Composable
|
|
|
- fun getMangaCountForCategory(categoryId: Long): androidx.compose.runtime.State<Int?> {
|
|
|
- return produceState<Int?>(initialValue = null, loadedManga) {
|
|
|
- value = loadedManga[categoryId]?.size
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
fun getColumnsPreferenceForCurrentOrientation(isLandscape: Boolean): PreferenceMutableState<Int> {
|
|
|
- return (if (isLandscape) libraryPreferences.landscapeColumns() else libraryPreferences.portraitColumns()).asState()
|
|
|
+ return (if (isLandscape) libraryPreferences.landscapeColumns() else libraryPreferences.portraitColumns()).asState(coroutineScope)
|
|
|
}
|
|
|
|
|
|
- // TODO: This is good but should we separate title from count or get categories with count from db
|
|
|
- @Composable
|
|
|
- fun getToolbarTitle(): androidx.compose.runtime.State<LibraryToolbarTitle> {
|
|
|
- val category = categories.getOrNull(activeCategory)
|
|
|
-
|
|
|
- val defaultTitle = stringResource(R.string.label_library)
|
|
|
- val categoryName = category?.visualName ?: defaultTitle
|
|
|
-
|
|
|
- val default = remember { LibraryToolbarTitle(defaultTitle) }
|
|
|
-
|
|
|
- return produceState(initialValue = default, category, loadedManga, mangaCountVisibility, tabVisibility) {
|
|
|
- val title = if (tabVisibility.not()) categoryName else defaultTitle
|
|
|
- val count = when {
|
|
|
- category == null || mangaCountVisibility.not() -> null
|
|
|
- tabVisibility.not() -> loadedManga[category.id]?.size
|
|
|
- else -> loadedManga.values.flatten().distinctBy { it.libraryManga.manga.id }.size
|
|
|
- }
|
|
|
-
|
|
|
- value = when (category) {
|
|
|
- null -> default
|
|
|
- else -> LibraryToolbarTitle(title, count)
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- @Composable
|
|
|
- fun getMangaForCategory(page: Int): List<LibraryItem> {
|
|
|
- val categoryId = remember(categories, page) {
|
|
|
- categories.getOrNull(page)?.id ?: -1
|
|
|
- }
|
|
|
- val unfiltered = remember(loadedManga, categoryId) {
|
|
|
- loadedManga[categoryId] ?: emptyList()
|
|
|
- }
|
|
|
- return remember(unfiltered, searchQuery) {
|
|
|
- if (searchQuery.isNullOrBlank()) {
|
|
|
- queriedMangaMap.clear()
|
|
|
- unfiltered
|
|
|
- } else {
|
|
|
- unfiltered.fastFilter { it.matches(searchQuery!!) }
|
|
|
- .also { queriedMangaMap[categoryId] = it }
|
|
|
- }
|
|
|
+ suspend fun getRandomLibraryItemForCurrentCategory(): LibraryItem? {
|
|
|
+ return withIOContext {
|
|
|
+ state.value
|
|
|
+ .getLibraryItemsByCategoryId(activeCategory.toLong())
|
|
|
+ .randomOrNull()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
fun clearSelection() {
|
|
|
- state.selection = emptyList()
|
|
|
+ mutableState.update { it.copy(selection = emptyList()) }
|
|
|
}
|
|
|
|
|
|
fun toggleSelection(manga: LibraryManga) {
|
|
|
- state.selection = selection.toMutableList().apply {
|
|
|
- if (fastAny { it.id == manga.id }) {
|
|
|
- removeAll { it.id == manga.id }
|
|
|
- } else {
|
|
|
- add(manga)
|
|
|
+ mutableState.update { state ->
|
|
|
+ val newSelection = state.selection.toMutableList().apply {
|
|
|
+ if (fastAny { it.id == manga.id }) {
|
|
|
+ removeAll { it.id == manga.id }
|
|
|
+ } else {
|
|
|
+ add(manga)
|
|
|
+ }
|
|
|
}
|
|
|
+ state.copy(selection = newSelection)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * Map is cleared out via [getMangaForCategory] when [searchQuery] is null or blank
|
|
|
- */
|
|
|
- private val queriedMangaMap: MutableMap<Long, List<LibraryItem>> = mutableMapOf()
|
|
|
-
|
|
|
- /**
|
|
|
- * Used by select all, inverse and range selection.
|
|
|
- *
|
|
|
- * If current query is empty then we get manga list from [loadedManga] otherwise from [queriedMangaMap]
|
|
|
- */
|
|
|
- private fun getMangaForCategoryWithQuery(categoryId: Long, query: String?): List<LibraryItem> {
|
|
|
- return if (query.isNullOrBlank()) loadedManga[categoryId].orEmpty() else queriedMangaMap[categoryId].orEmpty()
|
|
|
- }
|
|
|
-
|
|
|
/**
|
|
|
* Selects all mangas between and including the given manga and the last pressed manga from the
|
|
|
* same category as the given manga
|
|
|
*/
|
|
|
fun toggleRangeSelection(manga: LibraryManga) {
|
|
|
- state.selection = selection.toMutableList().apply {
|
|
|
- val lastSelected = lastOrNull()
|
|
|
- if (lastSelected?.category != manga.category) {
|
|
|
- add(manga)
|
|
|
- return@apply
|
|
|
- }
|
|
|
+ mutableState.update { state ->
|
|
|
+ val newSelection = state.selection.toMutableList().apply {
|
|
|
+ val lastSelected = lastOrNull()
|
|
|
+ if (lastSelected?.category != manga.category) {
|
|
|
+ add(manga)
|
|
|
+ return@apply
|
|
|
+ }
|
|
|
|
|
|
- val items = getMangaForCategoryWithQuery(manga.category, searchQuery)
|
|
|
- .fastMap { it.libraryManga }
|
|
|
- val lastMangaIndex = items.indexOf(lastSelected)
|
|
|
- val curMangaIndex = items.indexOf(manga)
|
|
|
-
|
|
|
- val selectedIds = fastMap { it.id }
|
|
|
- val selectionRange = when {
|
|
|
- lastMangaIndex < curMangaIndex -> IntRange(lastMangaIndex, curMangaIndex)
|
|
|
- curMangaIndex < lastMangaIndex -> IntRange(curMangaIndex, lastMangaIndex)
|
|
|
- // We shouldn't reach this point
|
|
|
- else -> return@apply
|
|
|
- }
|
|
|
- val newSelections = selectionRange.mapNotNull { index ->
|
|
|
- items[index].takeUnless { it.id in selectedIds }
|
|
|
+ val items = state.getLibraryItemsByCategoryId(manga.category)
|
|
|
+ .fastMap { it.libraryManga }
|
|
|
+ val lastMangaIndex = items.indexOf(lastSelected)
|
|
|
+ val curMangaIndex = items.indexOf(manga)
|
|
|
+
|
|
|
+ val selectedIds = fastMap { it.id }
|
|
|
+ val selectionRange = when {
|
|
|
+ lastMangaIndex < curMangaIndex -> IntRange(lastMangaIndex, curMangaIndex)
|
|
|
+ curMangaIndex < lastMangaIndex -> IntRange(curMangaIndex, lastMangaIndex)
|
|
|
+ // We shouldn't reach this point
|
|
|
+ else -> return@apply
|
|
|
+ }
|
|
|
+ val newSelections = selectionRange.mapNotNull { index ->
|
|
|
+ items[index].takeUnless { it.id in selectedIds }
|
|
|
+ }
|
|
|
+ addAll(newSelections)
|
|
|
}
|
|
|
- addAll(newSelections)
|
|
|
+ state.copy(selection = newSelection)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
fun selectAll(index: Int) {
|
|
|
- state.selection = state.selection.toMutableList().apply {
|
|
|
- val categoryId = categories.getOrNull(index)?.id ?: -1
|
|
|
- val selectedIds = fastMap { it.id }
|
|
|
- val newSelections = getMangaForCategoryWithQuery(categoryId, searchQuery)
|
|
|
- .fastMapNotNull { item ->
|
|
|
- item.libraryManga.takeUnless { it.id in selectedIds }
|
|
|
- }
|
|
|
+ mutableState.update { state ->
|
|
|
+ val newSelection = state.selection.toMutableList().apply {
|
|
|
+ val categoryId = state.categories.getOrNull(index)?.id ?: -1
|
|
|
+ val selectedIds = fastMap { it.id }
|
|
|
+ val newSelections = state.getLibraryItemsByCategoryId(categoryId)
|
|
|
+ .fastMapNotNull { item ->
|
|
|
+ item.libraryManga.takeUnless { it.id in selectedIds }
|
|
|
+ }
|
|
|
|
|
|
- addAll(newSelections)
|
|
|
+ addAll(newSelections)
|
|
|
+ }
|
|
|
+ state.copy(selection = newSelection)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
fun invertSelection(index: Int) {
|
|
|
- state.selection = selection.toMutableList().apply {
|
|
|
- val categoryId = categories[index].id
|
|
|
- val items = getMangaForCategoryWithQuery(categoryId, searchQuery).fastMap { it.libraryManga }
|
|
|
- val selectedIds = fastMap { it.id }
|
|
|
- val (toRemove, toAdd) = items.fastPartition { it.id in selectedIds }
|
|
|
- val toRemoveIds = toRemove.fastMap { it.id }
|
|
|
- removeAll { it.id in toRemoveIds }
|
|
|
- addAll(toAdd)
|
|
|
+ mutableState.update { state ->
|
|
|
+ val newSelection = state.selection.toMutableList().apply {
|
|
|
+ val categoryId = state.categories[index].id
|
|
|
+ val items = state.getLibraryItemsByCategoryId(categoryId).fastMap { it.libraryManga }
|
|
|
+ val selectedIds = fastMap { it.id }
|
|
|
+ val (toRemove, toAdd) = items.fastPartition { it.id in selectedIds }
|
|
|
+ val toRemoveIds = toRemove.fastMap { it.id }
|
|
|
+ removeAll { it.id in toRemoveIds }
|
|
|
+ addAll(toAdd)
|
|
|
+ }
|
|
|
+ state.copy(selection = newSelection)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ fun search(query: String?) {
|
|
|
+ mutableState.update { it.copy(searchQuery = query) }
|
|
|
+ }
|
|
|
+
|
|
|
+ fun openChangeCategoryDialog() {
|
|
|
+ coroutineScope.launchIO {
|
|
|
+ // Create a copy of selected manga
|
|
|
+ val mangaList = state.value.selection.map { it.manga }
|
|
|
+
|
|
|
+ // Hide the default category because it has a different behavior than the ones from db.
|
|
|
+ val categories = state.value.categories.filter { it.id != 0L }
|
|
|
+
|
|
|
+ // Get indexes of the common categories to preselect.
|
|
|
+ val common = getCommonCategories(mangaList)
|
|
|
+ // Get indexes of the mix categories to preselect.
|
|
|
+ val mix = getMixCategories(mangaList)
|
|
|
+ val preselected = categories.map {
|
|
|
+ when (it) {
|
|
|
+ in common -> CheckboxState.State.Checked(it)
|
|
|
+ in mix -> CheckboxState.TriState.Exclude(it)
|
|
|
+ else -> CheckboxState.State.None(it)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ mutableState.update { it.copy(dialog = Dialog.ChangeCategory(mangaList, preselected)) }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ fun openDeleteMangaDialog() {
|
|
|
+ val mangaList = state.value.selection.map { it.manga }
|
|
|
+ mutableState.update { it.copy(dialog = Dialog.DeleteManga(mangaList)) }
|
|
|
+ }
|
|
|
+
|
|
|
+ fun closeDialog() {
|
|
|
+ mutableState.update { it.copy(dialog = null) }
|
|
|
+ }
|
|
|
+
|
|
|
sealed class Dialog {
|
|
|
data class ChangeCategory(val manga: List<Manga>, val initialSelection: List<CheckboxState<Category>>) : Dialog()
|
|
|
data class DeleteManga(val manga: List<Manga>) : Dialog()
|
|
|
data class DownloadCustomAmount(val manga: List<Manga>, val max: Int) : Dialog()
|
|
|
}
|
|
|
+
|
|
|
+ @Immutable
|
|
|
+ private data class ItemPreferences(
|
|
|
+ val downloadBadge: Boolean,
|
|
|
+ val unreadBadge: Boolean,
|
|
|
+ val localBadge: Boolean,
|
|
|
+ val languageBadge: Boolean,
|
|
|
+
|
|
|
+ val globalFilterDownloaded: Boolean,
|
|
|
+ val filterDownloaded: Int,
|
|
|
+ val filterUnread: Int,
|
|
|
+ val filterStarted: Int,
|
|
|
+ val filterBookmarked: Int,
|
|
|
+ val filterCompleted: Int,
|
|
|
+ )
|
|
|
+
|
|
|
+ @Immutable
|
|
|
+ data class State(
|
|
|
+ val isLoading: Boolean = true,
|
|
|
+ val library: LibraryMap = emptyMap(),
|
|
|
+ val searchQuery: String? = null,
|
|
|
+ val selection: List<LibraryManga> = emptyList(),
|
|
|
+ val hasActiveFilters: Boolean = false,
|
|
|
+ val showCategoryTabs: Boolean = false,
|
|
|
+ val showMangaCount: Boolean = false,
|
|
|
+ val showMangaContinueButton: Boolean = false,
|
|
|
+ val dialog: Dialog? = null,
|
|
|
+ ) {
|
|
|
+ val selectionMode = selection.isNotEmpty()
|
|
|
+
|
|
|
+ val categories = library.keys.toList()
|
|
|
+
|
|
|
+ val libraryCount by lazy {
|
|
|
+ library
|
|
|
+ .flatMap { (_, v) -> v }
|
|
|
+ .distinctBy { it.libraryManga.manga.id }
|
|
|
+ .size
|
|
|
+ }
|
|
|
+
|
|
|
+ fun getLibraryItemsByCategoryId(categoryId: Long): List<LibraryItem> {
|
|
|
+ return library.firstNotNullOf { (k, v) -> v.takeIf { k.id == categoryId } }
|
|
|
+ }
|
|
|
+
|
|
|
+ fun getLibraryItemsByPage(page: Int): List<LibraryItem> {
|
|
|
+ return library.values.toTypedArray().getOrNull(page) ?: emptyList()
|
|
|
+ }
|
|
|
+
|
|
|
+ fun getMangaCountForCategory(category: Category): Int? {
|
|
|
+ return library[category]?.size?.takeIf { showMangaCount }
|
|
|
+ }
|
|
|
+
|
|
|
+ fun getToolbarTitle(
|
|
|
+ defaultTitle: String,
|
|
|
+ defaultCategoryTitle: String,
|
|
|
+ page: Int,
|
|
|
+ ): LibraryToolbarTitle {
|
|
|
+ val category = categories.getOrNull(page) ?: return LibraryToolbarTitle(defaultTitle)
|
|
|
+ val categoryName = category.let {
|
|
|
+ if (it.isSystemCategory) {
|
|
|
+ defaultCategoryTitle
|
|
|
+ } else {
|
|
|
+ it.name
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ val title = if (showCategoryTabs) defaultTitle else categoryName
|
|
|
+ val count = when {
|
|
|
+ !showMangaCount -> null
|
|
|
+ !showCategoryTabs -> getMangaCountForCategory(category)
|
|
|
+ // Whole library count
|
|
|
+ else -> libraryCount
|
|
|
+ }
|
|
|
+
|
|
|
+ return LibraryToolbarTitle(title, count)
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|