diff --git a/data/src/jvmMain/kotlin/ca/gosyer/data/library/LibraryPreferences.kt b/data/src/jvmMain/kotlin/ca/gosyer/data/library/LibraryPreferences.kt index 38bd3a51..d3eb885d 100644 --- a/data/src/jvmMain/kotlin/ca/gosyer/data/library/LibraryPreferences.kt +++ b/data/src/jvmMain/kotlin/ca/gosyer/data/library/LibraryPreferences.kt @@ -9,6 +9,7 @@ package ca.gosyer.data.library import ca.gosyer.core.prefs.Preference import ca.gosyer.core.prefs.PreferenceStore import ca.gosyer.data.library.model.DisplayMode +import ca.gosyer.data.library.model.Sort class LibraryPreferences(private val preferenceStore: PreferenceStore) { @@ -16,6 +17,14 @@ class LibraryPreferences(private val preferenceStore: PreferenceStore) { return preferenceStore.getJsonObject("display_mode", DisplayMode.CompactGrid, DisplayMode.serializer()) } + fun sortMode(): Preference { + return preferenceStore.getJsonObject("sort_mode", Sort.ALPHABETICAL, Sort.serializer()) + } + + fun sortAscending(): Preference { + return preferenceStore.getBoolean("sort_ascending", true) + } + fun gridColumns(): Preference { return preferenceStore.getInt("grid_columns", 0) } diff --git a/data/src/jvmMain/kotlin/ca/gosyer/data/library/model/Sort.kt b/data/src/jvmMain/kotlin/ca/gosyer/data/library/model/Sort.kt new file mode 100644 index 00000000..1ac3ba96 --- /dev/null +++ b/data/src/jvmMain/kotlin/ca/gosyer/data/library/model/Sort.kt @@ -0,0 +1,21 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.data.library.model + +import kotlinx.serialization.Serializable + +@Serializable +enum class Sort { + ALPHABETICAL, + // LAST_READ, + // LAST_CHECKED, + UNREAD, + // TOTAL_CHAPTERS, + // LATEST_CHAPTER, + // DATE_FETCHED, + DATE_ADDED; +} \ No newline at end of file diff --git a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/library/LibraryScreenViewModel.kt b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/library/LibraryScreenViewModel.kt index b75484ad..c464285a 100644 --- a/presentation/src/jvmMain/kotlin/ca/gosyer/ui/library/LibraryScreenViewModel.kt +++ b/presentation/src/jvmMain/kotlin/ca/gosyer/ui/library/LibraryScreenViewModel.kt @@ -9,6 +9,7 @@ package ca.gosyer.ui.library import ca.gosyer.core.lang.withDefaultContext import ca.gosyer.core.logging.CKLogger import ca.gosyer.data.library.LibraryPreferences +import ca.gosyer.data.library.model.Sort import ca.gosyer.data.models.Category import ca.gosyer.data.models.Manga import ca.gosyer.data.server.interactions.CategoryInteractionHandler @@ -19,55 +20,36 @@ import ca.gosyer.uicore.vm.ViewModel import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.toList import me.tatarka.inject.annotations.Inject +import java.text.Collator +import java.util.Collections +import java.util.Locale -private typealias CategoryItems = Pair>, MutableStateFlow>> +private typealias CategoryItems = Pair>, MutableStateFlow>> private typealias LibraryMap = MutableMap private data class Library(val categories: MutableStateFlow>, val mangaMap: LibraryMap) -private fun LibraryMap.getManga(id: Long) = - getOrPut(id) { MutableStateFlow(emptyList()) to MutableStateFlow(emptyList()) } -private suspend fun LibraryMap.setManga(query: String?, id: Long, manga: List) { - getManga(id).let { (items, unfilteredItems) -> - items.value = filterManga(query, manga) - unfilteredItems.value = manga +private fun LibraryMap.getManga(id: Long, getItemsFlow: (StateFlow>) -> StateFlow>) = + getOrPut(id) { + val unfilteredItems = MutableStateFlow>(emptyList()) + getItemsFlow(unfilteredItems) to unfilteredItems } -} -private suspend fun LibraryMap.updateMangaFilter(query: String?) { - values.forEach { (items, unfilteredItems) -> - items.value = filterManga(query, unfilteredItems.value) - } -} - -private suspend fun filterManga(query: String?, mangaList: List): List { - if (query.isNullOrBlank()) return mangaList - val queries = query.split(" ") - return mangaList.asFlow() - .filter { manga -> - queries.all { query -> - manga.title.contains(query, true) || - manga.author.orEmpty().contains(query, true) || - manga.artist.orEmpty().contains(query, true) || - manga.genre.any { it.contains(query, true) } || - manga.description.orEmpty().contains(query, true) || - manga.status.name.contains(query, true) - } - } - .cancellable() - .buffer() - .toList() +private fun LibraryMap.setManga(id: Long, manga: List, getItemsFlow: (StateFlow>) -> StateFlow>) { + getManga(id, getItemsFlow).second.value = manga } class LibraryScreenViewModel @Inject constructor( @@ -87,6 +69,9 @@ class LibraryScreenViewModel @Inject constructor( val gridColumns = libraryPreferences.gridColumns().stateIn(scope) val gridSize = libraryPreferences.gridSize().stateIn(scope) + private val sortMode = libraryPreferences.sortMode().stateIn(scope) + private val sortAscending = libraryPreferences.sortAscending().stateIn(scope) + private val _isLoading = MutableStateFlow(true) val isLoading = _isLoading.asStateFlow() @@ -96,12 +81,12 @@ class LibraryScreenViewModel @Inject constructor( private val _query = MutableStateFlow("") val query = _query.asStateFlow() + private val comparator = combine(sortMode, sortAscending) { sortMode, sortAscending -> + getComparator(sortMode, sortAscending) + }.stateIn(scope, SharingStarted.Eagerly, compareBy { it.title }) + init { getLibrary() - - _query.mapLatest { - library.mangaMap.updateMangaFilter(it) - }.launchIn(scope) } private fun getLibrary() { @@ -127,8 +112,64 @@ class LibraryScreenViewModel @Inject constructor( _selectedCategoryIndex.value = page } + private fun getComparator(sortMode: Sort, ascending: Boolean): Comparator { + val sortFn = when (sortMode) { + Sort.ALPHABETICAL -> { + val locale = Locale.getDefault() + val collator = Collator.getInstance(locale).apply { + strength = Collator.PRIMARY + }; + + { a: Manga, b: Manga -> + collator.compare(a.title.lowercase(locale), b.title.lowercase(locale)) + } + } + Sort.UNREAD -> { + { a: Manga, b: Manga -> + (a.unreadCount ?: 0).compareTo(b.unreadCount ?: 0) + } + } + Sort.DATE_ADDED -> { + { a: Manga, b: Manga -> + a.inLibraryAt.compareTo(b.inLibraryAt) + } + } + } + return if (ascending) { + Comparator(sortFn) + } else { + Collections.reverseOrder(sortFn) + } + } + + private suspend fun filterManga(query: String, mangaList: List): List { + if (query.isBlank()) return mangaList + val queries = query.split(" ") + return mangaList.asFlow() + .filter { manga -> + queries.all { query -> + manga.title.contains(query, true) || + manga.author.orEmpty().contains(query, true) || + manga.artist.orEmpty().contains(query, true) || + manga.genre.any { it.contains(query, true) } || + manga.description.orEmpty().contains(query, true) || + manga.status.name.contains(query, true) + } + } + .cancellable() + .buffer() + .toList() + } + + private fun getMangaItemsFlow(unfilteredItemsFlow: StateFlow>): StateFlow> { + return combine(unfilteredItemsFlow, query, comparator) { unfilteredItems, query, comparator -> + filterManga(query, unfilteredItems) + .sortedWith(comparator) + }.stateIn(scope, SharingStarted.Eagerly, emptyList()) + } + fun getLibraryForCategoryId(id: Long): StateFlow> { - return library.mangaMap.getManga(id).first.asStateFlow() + return library.mangaMap.getManga(id, ::getMangaItemsFlow).first } private suspend fun updateCategories(categories: List) { @@ -136,14 +177,14 @@ class LibraryScreenViewModel @Inject constructor( categories.map { category -> async { library.mangaMap.setManga( - query.value, - category.id, - categoryHandler.getMangaFromCategory(category) + id = category.id, + manga = categoryHandler.getMangaFromCategory(category) .catch { info { "Error getting manga for category $category" } emit(emptyList()) } - .single() + .single(), + getItemsFlow = ::getMangaItemsFlow ) } }.awaitAll()