From 0cc019ef19028164f06699c94493cfbfac2e07b6 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 31 Aug 2024 12:09:22 +0300 Subject: [PATCH] Refactor details and reader ViewModels --- app/build.gradle | 2 +- .../kotlin/org/koitharu/kotatsu/KotatsuApp.kt | 2 + .../kotatsu/details/ui/ChaptersMapper.kt | 14 +- .../kotatsu/details/ui/DetailsActivity.kt | 3 +- .../kotatsu/details/ui/DetailsViewModel.kt | 205 +++------------ .../ui/pager/ChapterPagesMenuProvider.kt | 3 +- .../details/ui/pager/ChaptersPagesSheet.kt | 4 +- .../ui/pager/ChaptersPagesViewModel.kt | 233 ++++++++++++++++++ .../ui/pager/bookmarks/BookmarksFragment.kt | 9 +- .../ui/pager/bookmarks/BookmarksViewModel.kt | 7 +- .../ui/pager/chapters/ChaptersFragment.kt | 9 +- .../chapters/ChaptersSelectionCallback.kt | 6 +- .../details/ui/pager/pages/PagesFragment.kt | 23 +- .../details/ui/pager/pages/PagesViewModel.kt | 16 +- .../kotatsu/reader/ui/ReaderActivity.kt | 2 +- .../kotatsu/reader/ui/ReaderViewModel.kt | 110 +++++---- .../reader/ui/config/ReaderConfigSheet.kt | 4 +- app/src/main/res/values/strings.xml | 1 + 18 files changed, 378 insertions(+), 275 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesViewModel.kt diff --git a/app/build.gradle b/app/build.gradle index f443d4795..8021284a6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -17,7 +17,7 @@ android { minSdk = 21 targetSdk = 35 versionCode = 665 - versionName = '7.5-b1' + versionName = '7.5-b2' generatedDensities = [] testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' ksp { diff --git a/app/src/debug/kotlin/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/debug/kotlin/org/koitharu/kotatsu/KotatsuApp.kt index 1694ddc89..eec41000c 100644 --- a/app/src/debug/kotlin/org/koitharu/kotatsu/KotatsuApp.kt +++ b/app/src/debug/kotlin/org/koitharu/kotatsu/KotatsuApp.kt @@ -8,6 +8,7 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.reader.domain.PageLoader +import org.koitharu.kotatsu.reader.ui.ReaderViewModel class KotatsuApp : BaseApp() { @@ -30,6 +31,7 @@ class KotatsuApp : BaseApp() { .setClassInstanceLimit(PagesCache::class.java, 1) .setClassInstanceLimit(MangaLoaderContext::class.java, 1) .setClassInstanceLimit(PageLoader::class.java, 1) + .setClassInstanceLimit(ReaderViewModel::class.java, 1) .penaltyLog() .build(), ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMapper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMapper.kt index ca5d93ed0..732c5af0e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMapper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMapper.kt @@ -3,7 +3,6 @@ package org.koitharu.kotatsu.details.ui import android.content.Context import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.domain.Bookmark -import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.toListItem @@ -12,7 +11,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.util.mapToSet fun MangaDetails.mapChapters( - history: MangaHistory?, + currentChapterId: Long, newCount: Int, branch: String?, bookmarks: List, @@ -24,7 +23,6 @@ fun MangaDetails.mapChapters( return emptyList() } val bookmarked = bookmarks.mapToSet { it.chapterId } - val currentId = history?.chapterId ?: 0L val newFrom = if (newCount == 0 || remoteChapters.isEmpty()) Int.MAX_VALUE else remoteChapters.size - newCount val ids = buildSet(maxOf(remoteChapters.size, localChapters.size)) { remoteChapters.mapTo(this) { it.id } @@ -36,14 +34,14 @@ fun MangaDetails.mapChapters( } else { null } - var isUnread = currentId !in ids + var isUnread = currentChapterId !in ids for (chapter in remoteChapters) { val local = localMap?.remove(chapter.id) - if (chapter.id == currentId) { + if (chapter.id == currentChapterId) { isUnread = true } result += (local ?: chapter).toListItem( - isCurrent = chapter.id == currentId, + isCurrent = chapter.id == currentChapterId, isUnread = isUnread, isNew = isUnread && result.size >= newFrom, isDownloaded = local != null, @@ -53,11 +51,11 @@ fun MangaDetails.mapChapters( } if (!localMap.isNullOrEmpty()) { for (chapter in localMap.values) { - if (chapter.id == currentId) { + if (chapter.id == currentChapterId) { isUnread = true } result += chapter.toListItem( - isCurrent = chapter.id == currentId, + isCurrent = chapter.id == currentChapterId, isUnread = isUnread, isNew = false, isDownloaded = !isLocal, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index 013a30a7a..0d689804f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -72,7 +72,6 @@ import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.parentView -import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat @@ -162,7 +161,7 @@ class DetailsActivity : } TitleExpandListener(viewBinding.textViewTitle).attach() - viewModel.details.filterNotNull().observe(this, ::onMangaUpdated) + viewModel.mangaDetails.filterNotNull().observe(this, ::onMangaUpdated) viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved) viewModel.onError .filterNot { ChaptersPagesSheet.isShown(supportFragmentManager) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index bb1336682..ca798edbe 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -12,32 +12,23 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.plus -import okio.FileNotFoundException import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.core.model.findById import org.koitharu.kotatsu.core.model.getPreferredBranch import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode -import org.koitharu.kotatsu.core.prefs.observeAsStateFlow -import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction -import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.core.util.ext.combine import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.onEachWhile -import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.domain.BranchComparator import org.koitharu.kotatsu.details.domain.DetailsInteractor @@ -45,9 +36,9 @@ import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase import org.koitharu.kotatsu.details.domain.ReadingTimeUseCase import org.koitharu.kotatsu.details.domain.RelatedMangaUseCase -import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.HistoryInfo import org.koitharu.kotatsu.details.ui.model.MangaBranch +import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.list.domain.MangaListMapper @@ -57,6 +48,7 @@ import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus @@ -66,37 +58,42 @@ import javax.inject.Inject @HiltViewModel class DetailsViewModel @Inject constructor( private val historyRepository: HistoryRepository, - private val bookmarksRepository: BookmarksRepository, - private val settings: AppSettings, + bookmarksRepository: BookmarksRepository, + settings: AppSettings, private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>, - @LocalStorageChanges private val localStorageChanges: SharedFlow, - private val downloadScheduler: DownloadWorker.Scheduler, + @LocalStorageChanges localStorageChanges: SharedFlow, + downloadScheduler: DownloadWorker.Scheduler, private val interactor: DetailsInteractor, savedStateHandle: SavedStateHandle, - private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, + deleteLocalMangaUseCase: DeleteLocalMangaUseCase, private val relatedMangaUseCase: RelatedMangaUseCase, private val mangaListMapper: MangaListMapper, private val detailsLoadUseCase: DetailsLoadUseCase, private val progressUpdateUseCase: ProgressUpdateUseCase, private val readingTimeUseCase: ReadingTimeUseCase, - private val statsRepository: StatsRepository, -) : BaseViewModel() { + statsRepository: StatsRepository, +) : ChaptersPagesViewModel( + settings = settings, + interactor = interactor, + bookmarksRepository = bookmarksRepository, + historyRepository = historyRepository, + downloadScheduler = downloadScheduler, + deleteLocalMangaUseCase = deleteLocalMangaUseCase, + localStorageChanges = localStorageChanges, +) { private val intent = MangaIntent(savedStateHandle) private var loadingJob: Job val mangaId = intent.mangaId - val onActionDone = MutableEventFlow() - val onSelectChapter = MutableEventFlow() - val onDownloadStarted = MutableEventFlow() - - val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) }) - val manga = details.map { x -> x?.toManga() } - .withErrorHandling() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) + init { + mangaDetails.value = intent.manga?.let { MangaDetails(it, null, null, false) } + } val history = historyRepository.observeOne(mangaId) - .withErrorHandling() + .onEach { h -> + readingState.value = h?.let(::ReaderState) + }.withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) val favouriteCategories = interactor.observeFavourite(mangaId) @@ -109,31 +106,8 @@ class DetailsViewModel @Inject constructor( val remoteManga = MutableStateFlow(null) - val newChaptersCount = details.flatMapLatest { d -> - if (d?.isLocal == false) { - interactor.observeNewChapters(mangaId) - } else { - flowOf(0) - } - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) - - private val chaptersQuery = MutableStateFlow("") - val selectedBranch = MutableStateFlow(null) - - val isChaptersReversed = settings.observeAsStateFlow( - scope = viewModelScope + Dispatchers.Default, - key = AppSettings.KEY_REVERSE_CHAPTERS, - valueProducer = { isChaptersReverse }, - ) - - val isChaptersInGridView = settings.observeAsStateFlow( - scope = viewModelScope + Dispatchers.Default, - key = AppSettings.KEY_GRID_VIEW_CHAPTERS, - valueProducer = { isChaptersGridView }, - ) - val historyInfo: StateFlow = combine( - details, + mangaDetails, selectedBranch, history, interactor.observeIncognitoMode(manga), @@ -145,11 +119,7 @@ class DetailsViewModel @Inject constructor( initialValue = HistoryInfo(null, null, null, false), ) - val bookmarks = manga.flatMapLatest { - if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList()) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList()) - - val localSize = details + val localSize = mangaDetails .map { it?.local } .distinctUntilChanged() .combine(localStorageChanges.onStart { emit(null) }) { x, _ -> x } @@ -163,7 +133,6 @@ class DetailsViewModel @Inject constructor( } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), 0L) - val onMangaRemoved = MutableEventFlow() val isScrobblingAvailable: Boolean get() = scrobblers.any { it.isEnabled } @@ -182,7 +151,7 @@ class DetailsViewModel @Inject constructor( }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList()) val branches: StateFlow> = combine( - details, + mangaDetails, selectedBranch, history, ) { m, b, h -> @@ -201,35 +170,8 @@ class DetailsViewModel @Inject constructor( }.sortedWith(BranchComparator()) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) - val isChaptersEmpty: StateFlow = details.map { - it != null && it.isLoaded && it.allChapters.isEmpty() - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) - - val chapters = combine( - combine( - details, - history, - selectedBranch, - newChaptersCount, - bookmarks, - isChaptersInGridView, - ) { manga, history, branch, news, bookmarks, grid -> - manga?.mapChapters( - history, - news, - branch, - bookmarks, - grid, - ).orEmpty() - }, - isChaptersReversed, - chaptersQuery, - ) { list, reversed, query -> - (if (reversed) list.asReversed() else list).filterSearch(query) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) - val readingTime = combine( - details, + mangaDetails, selectedBranch, history, ) { m, b, h -> @@ -242,18 +184,14 @@ class DetailsViewModel @Inject constructor( init { loadingJob = doLoad() launchJob(Dispatchers.Default) { - localStorageChanges - .collect { onDownloadComplete(it) } - } - launchJob(Dispatchers.Default) { - val manga = details.firstOrNull { !it?.chapters.isNullOrEmpty() } ?: return@launchJob + val manga = mangaDetails.firstOrNull { !it?.chapters.isNullOrEmpty() } ?: return@launchJob val h = history.firstOrNull() if (h != null) { progressUpdateUseCase(manga.toManga()) } } launchJob(Dispatchers.Default) { - val manga = details.firstOrNull { it != null && it.isLocal } ?: return@launchJob + val manga = mangaDetails.firstOrNull { it != null && it.isLocal } ?: return@launchJob remoteManga.value = interactor.findRemote(manga.toManga()) } } @@ -263,41 +201,6 @@ class DetailsViewModel @Inject constructor( loadingJob = doLoad() } - fun deleteLocal() { - val m = details.value?.local?.manga - if (m == null) { - errorEvent.call(FileNotFoundException()) - return - } - launchLoadingJob(Dispatchers.Default) { - deleteLocalMangaUseCase(m) - onMangaRemoved.call(m) - } - } - - fun removeBookmark(bookmark: Bookmark) { - launchJob(Dispatchers.Default) { - bookmarksRepository.removeBookmark(bookmark) - onActionDone.call(ReversibleAction(R.string.bookmark_removed, null)) - } - } - - fun setChaptersReversed(newValue: Boolean) { - settings.isChaptersReverse = newValue - } - - fun setChaptersInGridView(newValue: Boolean) { - settings.isChaptersGridView = newValue - } - - fun setSelectedBranch(branch: String?) { - selectedBranch.value = branch - } - - fun performChapterSearch(query: String?) { - chaptersQuery.value = query?.trim().orEmpty() - } - fun updateScrobbling(index: Int, rating: Float, status: ScrobblingStatus?) { val scrobbler = getScrobbler(index) ?: return launchJob(Dispatchers.Default) { @@ -319,34 +222,6 @@ class DetailsViewModel @Inject constructor( } } - fun markChapterAsCurrent(chapterId: Long) { - launchJob(Dispatchers.Default) { - val manga = checkNotNull(details.value) - val chapters = checkNotNull(manga.chapters[selectedBranchValue]) - val chapterIndex = chapters.indexOfFirst { it.id == chapterId } - check(chapterIndex in chapters.indices) { "Chapter not found" } - val percent = chapterIndex / chapters.size.toFloat() - historyRepository.addOrUpdate( - manga = manga.toManga(), - chapterId = chapterId, - page = 0, - scroll = 0, - percent = percent, - force = true, - ) - } - } - - fun download(chaptersIds: Set?) { - launchJob(Dispatchers.Default) { - downloadScheduler.schedule( - details.requireValue().toManga(), - chaptersIds, - ) - onDownloadStarted.call(Unit) - } - } - fun startChaptersSelection() { val chapters = chapters.value val chapter = chapters.find { @@ -374,28 +249,10 @@ class DetailsViewModel @Inject constructor( selectedBranch.value = manga.getPreferredBranch(hist) true }.collect { - details.value = it + mangaDetails.value = it } } - private fun List.filterSearch(query: String): List { - if (query.isEmpty() || this.isEmpty()) { - return this - } - return filter { - it.chapter.name.contains(query, ignoreCase = true) - } - } - - private suspend fun onDownloadComplete(downloadedManga: LocalManga?) { - downloadedManga ?: return - launchJob { - details.update { - interactor.updateLocal(it, downloadedManga) - } - } - } - private fun getScrobbler(index: Int): Scrobbler? { val info = scrobblingInfo.value.getOrNull(index) val scrobbler = if (info != null) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChapterPagesMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChapterPagesMenuProvider.kt index f6857484f..6db0ec2b5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChapterPagesMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChapterPagesMenuProvider.kt @@ -14,14 +14,13 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter -import org.koitharu.kotatsu.details.ui.DetailsViewModel import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_BOOKMARKS import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_CHAPTERS import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_PAGES import java.lang.ref.WeakReference class ChapterPagesMenuProvider( - private val viewModel: DetailsViewModel, + private val viewModel: ChaptersPagesViewModel, private val sheet: BaseAdaptiveSheet<*>, private val pager: ViewPager2, private val settings: AppSettings, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesSheet.kt index 9d93ab73b..9aba33af1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesSheet.kt @@ -7,7 +7,6 @@ import android.view.ViewGroup import androidx.appcompat.view.ActionMode import androidx.core.view.isVisible import androidx.fragment.app.FragmentManager -import androidx.fragment.app.activityViewModels import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver @@ -30,7 +29,6 @@ import org.koitharu.kotatsu.core.util.ext.setTabsEnabled import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.SheetChaptersPagesBinding -import org.koitharu.kotatsu.details.ui.DetailsViewModel import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import javax.inject.Inject @@ -40,7 +38,7 @@ class ChaptersPagesSheet : BaseAdaptiveSheet(), Actio @Inject lateinit var settings: AppSettings - private val viewModel by activityViewModels() + private val viewModel by ChaptersPagesViewModel.ActivityVMLazy(this) override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetChaptersPagesBinding { return SheetChaptersPagesBinding.inflate(inflater, container, false) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesViewModel.kt new file mode 100644 index 000000000..9ef984551 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesViewModel.kt @@ -0,0 +1,233 @@ +package org.koitharu.kotatsu.details.ui.pager + +import android.app.Activity +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.plus +import okio.FileNotFoundException +import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.observeAsStateFlow +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.ui.util.ReversibleAction +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call +import org.koitharu.kotatsu.core.util.ext.combine +import org.koitharu.kotatsu.core.util.ext.requireValue +import org.koitharu.kotatsu.details.data.MangaDetails +import org.koitharu.kotatsu.details.domain.DetailsInteractor +import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.details.ui.DetailsViewModel +import org.koitharu.kotatsu.details.ui.mapChapters +import org.koitharu.kotatsu.details.ui.model.ChapterListItem +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker +import org.koitharu.kotatsu.history.data.HistoryRepository +import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase +import org.koitharu.kotatsu.local.domain.model.LocalManga +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.reader.ui.ReaderActivity +import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.reader.ui.ReaderViewModel + +abstract class ChaptersPagesViewModel( + @JvmField protected val settings: AppSettings, + private val interactor: DetailsInteractor, + private val bookmarksRepository: BookmarksRepository, + private val historyRepository: HistoryRepository, + private val downloadScheduler: DownloadWorker.Scheduler, + private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, + private val localStorageChanges: SharedFlow, +) : BaseViewModel() { + + val mangaDetails = MutableStateFlow(null) + val readingState = MutableStateFlow(null) + + val onActionDone = MutableEventFlow() + val onSelectChapter = MutableEventFlow() + val onDownloadStarted = MutableEventFlow() + val onMangaRemoved = MutableEventFlow() + + private val chaptersQuery = MutableStateFlow("") + val selectedBranch = MutableStateFlow(null) + + val manga = mangaDetails.map { x -> x?.toManga() } + .withErrorHandling() + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) + + val isChaptersReversed = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, + key = AppSettings.KEY_REVERSE_CHAPTERS, + valueProducer = { isChaptersReverse }, + ) + + val isChaptersInGridView = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, + key = AppSettings.KEY_GRID_VIEW_CHAPTERS, + valueProducer = { isChaptersGridView }, + ) + + val newChaptersCount = mangaDetails.flatMapLatest { d -> + if (d?.isLocal == false) { + interactor.observeNewChapters(d.id) + } else { + flowOf(0) + } + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) + + val isChaptersEmpty: StateFlow = mangaDetails.map { + it != null && it.isLoaded && it.allChapters.isEmpty() + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) + + val bookmarks = mangaDetails.flatMapLatest { + if (it != null) bookmarksRepository.observeBookmarks(it.toManga()) else flowOf(emptyList()) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList()) + + val chapters = combine( + combine( + mangaDetails, + readingState.map { it?.chapterId ?: 0L }.distinctUntilChanged(), + selectedBranch, + newChaptersCount, + bookmarks, + isChaptersInGridView, + ) { manga, currentChapterId, branch, news, bookmarks, grid -> + manga?.mapChapters( + currentChapterId, + news, + branch, + bookmarks, + grid, + ).orEmpty() + }, + isChaptersReversed, + chaptersQuery, + ) { list, reversed, query -> + (if (reversed) list.asReversed() else list).filterSearch(query) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) + + init { + launchJob(Dispatchers.Default) { + localStorageChanges + .collect { onDownloadComplete(it) } + } + } + + fun setChaptersReversed(newValue: Boolean) { + settings.isChaptersReverse = newValue + } + + fun setChaptersInGridView(newValue: Boolean) { + settings.isChaptersGridView = newValue + } + + fun setSelectedBranch(branch: String?) { + selectedBranch.value = branch + } + + fun performChapterSearch(query: String?) { + chaptersQuery.value = query?.trim().orEmpty() + } + + fun getMangaOrNull(): Manga? = mangaDetails.value?.toManga() + + fun requireManga() = mangaDetails.requireValue().toManga() + + fun markChapterAsCurrent(chapterId: Long) { + launchJob(Dispatchers.Default) { + val manga = mangaDetails.requireValue() + val chapters = checkNotNull(manga.chapters[selectedBranch.value]) + val chapterIndex = chapters.indexOfFirst { it.id == chapterId } + check(chapterIndex in chapters.indices) { "Chapter not found" } + val percent = chapterIndex / chapters.size.toFloat() + historyRepository.addOrUpdate( + manga = manga.toManga(), + chapterId = chapterId, + page = 0, + scroll = 0, + percent = percent, + force = true, + ) + } + } + + fun download(chaptersIds: Set?) { + launchJob(Dispatchers.Default) { + downloadScheduler.schedule( + requireManga(), + chaptersIds, + ) + onDownloadStarted.call(Unit) + } + } + + fun deleteLocal() { + val m = mangaDetails.value?.local?.manga + if (m == null) { + errorEvent.call(FileNotFoundException()) + return + } + launchLoadingJob(Dispatchers.Default) { + deleteLocalMangaUseCase(m) + onMangaRemoved.call(m) + } + } + + private fun List.filterSearch(query: String): List { + if (query.isEmpty() || this.isEmpty()) { + return this + } + return filter { + it.chapter.name.contains(query, ignoreCase = true) + } + } + + private suspend fun onDownloadComplete(downloadedManga: LocalManga?) { + downloadedManga ?: return + mangaDetails.update { + interactor.updateLocal(it, downloadedManga) + } + } + + class ActivityVMLazy( + private val fragment: Fragment, + ) : Lazy { + private var cached: ChaptersPagesViewModel? = null + + override val value: ChaptersPagesViewModel + get() { + val viewModel = cached + return if (viewModel == null) { + val activity = fragment.requireActivity() + val vmClass = getViewModelClass(activity) + ViewModelProvider.create( + store = activity.viewModelStore, + factory = activity.defaultViewModelProviderFactory, + extras = activity.defaultViewModelCreationExtras, + )[vmClass].also { cached = it } + } else { + viewModel + } + } + + override fun isInitialized(): Boolean = cached != null + + private fun getViewModelClass(activity: Activity) = when (activity) { + is ReaderActivity -> ReaderViewModel::class.java + is DetailsActivity -> DetailsViewModel::class.java + else -> error("Wrong activity ${activity.javaClass.simpleName} for ${ChaptersPagesViewModel::class.java.simpleName}") + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt index 8c68a133f..8da855d10 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt @@ -8,7 +8,6 @@ import android.view.View import android.view.ViewGroup import androidx.appcompat.view.ActionMode import androidx.core.graphics.Insets -import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.recyclerview.widget.GridLayoutManager import coil.ImageLoader @@ -30,7 +29,7 @@ import org.koitharu.kotatsu.core.util.ext.findParentCallback import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.FragmentMangaBookmarksBinding -import org.koitharu.kotatsu.details.ui.DetailsViewModel +import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel import org.koitharu.kotatsu.list.ui.GridSpanResolver import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration @@ -42,7 +41,7 @@ import javax.inject.Inject class BookmarksFragment : BaseFragment(), OnListItemClickListener, ListSelectionController.Callback { - private val activityViewModel by activityViewModels() + private val activityViewModel by ChaptersPagesViewModel.ActivityVMLazy(this) private val viewModel by viewModels() @Inject @@ -62,7 +61,7 @@ class BookmarksFragment : BaseFragment(), override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityViewModel.manga.observe(this, viewModel) + activityViewModel.mangaDetails.observe(this, viewModel) } override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentMangaBookmarksBinding { @@ -125,7 +124,7 @@ class BookmarksFragment : BaseFragment(), dismissParentDialog() } else { val intent = IntentBuilder(view.context) - .manga(activityViewModel.manga.value ?: return) + .manga(activityViewModel.getMangaOrNull() ?: return) .bookmark(item) .incognito(true) .build() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksViewModel.kt index 0970f965f..e6a68d27e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksViewModel.kt @@ -21,6 +21,7 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call +import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListModel @@ -32,7 +33,7 @@ import javax.inject.Inject class BookmarksViewModel @Inject constructor( private val bookmarksRepository: BookmarksRepository, settings: AppSettings, -) : BaseViewModel(), FlowCollector { +) : BaseViewModel(), FlowCollector { private val manga = MutableStateFlow(null) val onActionDone = MutableEventFlow() @@ -50,8 +51,8 @@ class BookmarksViewModel @Inject constructor( .filterNotNull() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState)) - override suspend fun emit(value: Manga?) { - manga.value = value + override suspend fun emit(value: MangaDetails?) { + manga.value = value?.toManga() } fun removeBookmarks(ids: Set) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt index d7c147761..4aa3b5295 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt @@ -7,10 +7,10 @@ import android.view.ViewGroup import androidx.core.graphics.Insets import androidx.core.view.ancestors import androidx.core.view.isVisible -import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.viewpager2.widget.ViewPager2 +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOn @@ -28,10 +28,10 @@ import org.koitharu.kotatsu.core.util.ext.findParentCallback import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.FragmentChaptersBinding -import org.koitharu.kotatsu.details.ui.DetailsViewModel import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration import org.koitharu.kotatsu.details.ui.model.ChapterListItem +import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel import org.koitharu.kotatsu.details.ui.withVolumeHeaders import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListModel @@ -40,11 +40,12 @@ import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback import org.koitharu.kotatsu.reader.ui.ReaderState import kotlin.math.roundToInt +@AndroidEntryPoint class ChaptersFragment : BaseFragment(), OnListItemClickListener { - private val viewModel by activityViewModels() + private val viewModel by ChaptersPagesViewModel.ActivityVMLazy(this) private var chaptersAdapter: ChaptersAdapter? = null private var selectionController: ListSelectionController? = null @@ -107,7 +108,7 @@ class ChaptersFragment : } else { startActivity( IntentBuilder(view.context) - .manga(viewModel.manga.value ?: return) + .manga(viewModel.getMangaOrNull() ?: return) .state(ReaderState(item.chapter.id, 0, 0)) .build(), ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt index 37950dcfc..46b9fdf78 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt @@ -11,11 +11,11 @@ import org.koitharu.kotatsu.core.ui.list.BaseListSelectionCallback import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.util.ext.toCollection import org.koitharu.kotatsu.core.util.ext.toSet -import org.koitharu.kotatsu.details.ui.DetailsViewModel +import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService class ChaptersSelectionCallback( - private val viewModel: DetailsViewModel, + private val viewModel: ChaptersPagesViewModel, recyclerView: RecyclerView, ) : BaseListSelectionCallback(recyclerView) { @@ -60,7 +60,7 @@ class ChaptersSelectionCallback( R.id.action_delete -> { val ids = controller.peekCheckedIds() - val manga = viewModel.manga.value + val manga = viewModel.getMangaOrNull() when { ids.isEmpty() || manga == null -> Unit ids.size == manga.chapters?.size -> viewModel.deleteLocal() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt index 49acedf7b..9ed57da9e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt @@ -7,7 +7,6 @@ import android.view.ViewGroup import androidx.core.graphics.Insets import androidx.core.view.isInvisible import androidx.core.view.isVisible -import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -30,7 +29,7 @@ import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.showOrHide import org.koitharu.kotatsu.databinding.FragmentPagesBinding -import org.koitharu.kotatsu.details.ui.DetailsViewModel +import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel import org.koitharu.kotatsu.list.ui.GridSpanResolver import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration @@ -46,15 +45,15 @@ class PagesFragment : BaseFragment(), OnListItemClickListener { - private val detailsViewModel by activityViewModels() - private val viewModel by viewModels() - @Inject lateinit var coil: ImageLoader @Inject lateinit var settings: AppSettings + private val parentViewModel by ChaptersPagesViewModel.ActivityVMLazy(this) + private val viewModel by viewModels() + private var thumbnailsAdapter: PageThumbnailAdapter? = null private var spanResolver: GridSpanResolver? = null private var scrollListener: ScrollListener? = null @@ -64,12 +63,12 @@ class PagesFragment : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) combine( - detailsViewModel.details, - detailsViewModel.history, - detailsViewModel.selectedBranch, - ) { details, history, branch -> + parentViewModel.mangaDetails, + parentViewModel.readingState, + parentViewModel.selectedBranch, + ) { details, readingState, branch -> if (details != null && (details.isLoaded || details.chapters.isNotEmpty())) { - PagesViewModel.State(details.filterChapters(branch), history, branch) + PagesViewModel.State(details.filterChapters(branch), readingState, branch) } else { null } @@ -102,7 +101,7 @@ class PagesFragment : it.spanCount = checkNotNull(spanResolver).spanCount } } - detailsViewModel.isChaptersEmpty.observe(viewLifecycleOwner, ::onNoChaptersChanged) + parentViewModel.isChaptersEmpty.observe(viewLifecycleOwner, ::onNoChaptersChanged) viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) } @@ -127,7 +126,7 @@ class PagesFragment : } else { startActivity( IntentBuilder(view.context) - .manga(detailsViewModel.manga.value ?: return) + .manga(parentViewModel.getMangaOrNull() ?: return) .state(ReaderState(item.page.chapterId, item.page.index, 0)) .build(), ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesViewModel.kt index 1f9bfa15e..ca0aba9ca 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesViewModel.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.plus -import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.BaseViewModel @@ -16,12 +15,13 @@ import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.reader.domain.ChaptersLoader +import org.koitharu.kotatsu.reader.ui.ReaderState import javax.inject.Inject @HiltViewModel class PagesViewModel @Inject constructor( private val chaptersLoader: ChaptersLoader, - private val settings: AppSettings, + settings: AppSettings, ) : BaseViewModel() { private var loadingJob: Job? = null @@ -75,13 +75,13 @@ class PagesViewModel @Inject constructor( private suspend fun doInit(state: State) { chaptersLoader.init(state.details) - val initialChapterId = state.history?.chapterId?.takeIf { + val initialChapterId = state.readerState?.chapterId?.takeIf { chaptersLoader.peekChapter(it) != null } ?: state.details.allChapters.firstOrNull()?.id ?: return if (!chaptersLoader.hasPages(initialChapterId)) { chaptersLoader.loadSingleChapter(initialChapterId) } - updateList(state.history) + updateList(state.readerState) } private fun loadPrevNextChapter(isNext: Boolean): Job = launchJob(Dispatchers.Default) { @@ -91,13 +91,13 @@ class PagesViewModel @Inject constructor( val currentState = state.firstNotNull() val currentId = (if (isNext) chaptersLoader.last() else chaptersLoader.first()).chapterId chaptersLoader.loadPrevNextChapter(currentState.details, currentId, isNext) - updateList(currentState.history) + updateList(currentState.readerState) } finally { indicator.value = false } } - private fun updateList(history: MangaHistory?) { + private fun updateList(readerState: ReaderState?) { val snapshot = chaptersLoader.snapshot() val pages = buildList(snapshot.size + chaptersLoader.size + 2) { var previousChapterId = 0L @@ -109,7 +109,7 @@ class PagesViewModel @Inject constructor( previousChapterId = page.chapterId } this += PageThumbnail( - isCurrent = history?.let { + isCurrent = readerState?.let { page.chapterId == it.chapterId && page.index == it.page } ?: false, page = page, @@ -121,7 +121,7 @@ class PagesViewModel @Inject constructor( data class State( val details: MangaDetails, - val history: MangaHistory?, + val readerState: ReaderState?, val branch: String? ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index 0af267564..2e02fa6f7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -164,7 +164,7 @@ class ReaderActivity : } override fun getParentActivityIntent(): Intent? { - val manga = viewModel.manga?.toManga() ?: return null + val manga = viewModel.getMangaOrNull() ?: return null return DetailsActivity.newIntent(this, manga) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index ee651bc49..05bffd1bd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter @@ -41,7 +42,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow -import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty @@ -49,11 +49,17 @@ import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.core.util.ext.sizeOrZero import org.koitharu.kotatsu.details.data.MangaDetails +import org.koitharu.kotatsu.details.domain.DetailsInteractor import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_PAGES +import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel +import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryUpdateUseCase import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE +import org.koitharu.kotatsu.local.data.LocalStorageChanges +import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase +import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.assertNotNull @@ -70,14 +76,12 @@ private const val BOUNDS_PAGE_OFFSET = 2 private const val PREFETCH_LIMIT = 10 @HiltViewModel -class ReaderViewModel -@Inject -constructor( +class ReaderViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, private val dataRepository: MangaDataRepository, private val historyRepository: HistoryRepository, private val bookmarksRepository: BookmarksRepository, - private val settings: AppSettings, + settings: AppSettings, private val pageSaveHelper: PageSaveHelper, private val pageLoader: PageLoader, private val chaptersLoader: ChaptersLoader, @@ -86,18 +90,31 @@ constructor( private val historyUpdateUseCase: HistoryUpdateUseCase, private val detectReaderModeUseCase: DetectReaderModeUseCase, private val statsCollector: StatsCollector, -) : BaseViewModel() { + @LocalStorageChanges localStorageChanges: SharedFlow, + interactor: DetailsInteractor, + deleteLocalMangaUseCase: DeleteLocalMangaUseCase, + downloadScheduler: DownloadWorker.Scheduler, +) : ChaptersPagesViewModel( + settings = settings, + interactor = interactor, + bookmarksRepository = bookmarksRepository, + historyRepository = historyRepository, + downloadScheduler = downloadScheduler, + deleteLocalMangaUseCase = deleteLocalMangaUseCase, + localStorageChanges = localStorageChanges, +) { private val intent = MangaIntent(savedStateHandle) - private val preselectedBranch = savedStateHandle.get(ReaderActivity.EXTRA_BRANCH) private var loadingJob: Job? = null private var pageSaveJob: Job? = null private var bookmarkJob: Job? = null private var stateChangeJob: Job? = null - private val currentState = MutableStateFlow(savedStateHandle[ReaderActivity.EXTRA_STATE]) - private val mangaData = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) }) - private val mangaFlow: Flow - get() = mangaData.map { it?.toManga() } + + init { + selectedBranch.value = savedStateHandle.get(ReaderActivity.EXTRA_BRANCH) + readingState.value = savedStateHandle[ReaderActivity.EXTRA_STATE] + mangaDetails.value = intent.manga?.let { MangaDetails(it, null, null, false) } + } val readerMode = MutableStateFlow(null) val onPageSaved = MutableEventFlow() @@ -107,16 +124,13 @@ constructor( val incognitoMode = if (savedStateHandle.get(ReaderActivity.EXTRA_INCOGNITO) == true) { MutableStateFlow(true) } else { - mangaFlow.map { - it != null && historyRepository.shouldSkip(it) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) + interactor.observeIncognitoMode(manga) + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) } val isPagesSheetEnabled = observeIsPagesSheetEnabled() val content = MutableStateFlow(ReaderContent(emptyList(), null)) - val manga: MangaDetails? - get() = mangaData.value val pageAnimation = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, @@ -164,15 +178,15 @@ constructor( val readerSettings = ReaderSettings( parentScope = viewModelScope, settings = settings, - colorFilterFlow = mangaFlow.flatMapLatest { + colorFilterFlow = manga.flatMapLatest { if (it == null) flowOf(null) else dataRepository.observeColorFilter(it.id) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null), ) - val isMangaNsfw = mangaFlow.map { it?.isNsfw == true } + val isMangaNsfw = manga.map { it?.isNsfw == true } - val isBookmarkAdded = currentState.flatMapLatest { state -> - val manga = mangaData.value?.toManga() + val isBookmarkAdded = readingState.flatMapLatest { state -> + val manga = mangaDetails.value?.toManga() if (state == null || manga == null) { flowOf(false) } else { @@ -190,7 +204,7 @@ constructor( if (key == AppSettings.KEY_READER_SLIDER) notifyStateChanged() }.launchIn(viewModelScope + Dispatchers.Default) launchJob(Dispatchers.Default) { - val mangaId = mangaFlow.filterNotNull().first().id + val mangaId = manga.filterNotNull().first().id appShortcutManager.notifyMangaOpened(mangaId) } } @@ -201,14 +215,14 @@ constructor( } fun onPause() { - manga?.let { + getMangaOrNull()?.let { statsCollector.onPause(it.id) } } fun switchMode(newMode: ReaderMode) { launchJob { - val manga = checkNotNull(mangaData.value?.toManga()) + val manga = checkNotNull(getMangaOrNull()) dataRepository.saveReaderMode( manga = manga, mode = newMode, @@ -222,24 +236,24 @@ constructor( fun saveCurrentState(state: ReaderState? = null) { if (state != null) { - currentState.value = state + readingState.value = state savedStateHandle[ReaderActivity.EXTRA_STATE] = state } if (incognitoMode.value) { return } - val readerState = state ?: currentState.value ?: return + val readerState = state ?: readingState.value ?: return historyUpdateUseCase.invokeAsync( - manga = mangaData.value?.toManga() ?: return, + manga = getMangaOrNull() ?: return, readerState = readerState, percent = computePercent(readerState.chapterId, readerState.page), ) } - fun getCurrentState() = currentState.value + fun getCurrentState() = readingState.value fun getCurrentChapterPages(): List? { - val chapterId = currentState.value?.chapterId ?: return null + val chapterId = readingState.value?.chapterId ?: return null return chaptersLoader.getPages(chapterId).map { it.toMangaPage() } } @@ -272,7 +286,7 @@ constructor( } fun getCurrentPage(): MangaPage? { - val state = currentState.value ?: return null + val state = readingState.value ?: return null return content.value.pages.find { it.chapterId == state.chapterId && it.index == state.page }?.toMangaPage() @@ -294,9 +308,9 @@ constructor( val prevJob = loadingJob loadingJob = launchLoadingJob(Dispatchers.Default) { prevJob?.cancelAndJoin() - val prevState = currentState.requireValue() + val prevState = readingState.requireValue() val newChapterId = if (delta != 0) { - val allChapters = checkNotNull(manga).allChapters + val allChapters = mangaDetails.requireValue().allChapters var index = allChapters.indexOfFirst { x -> x.id == prevState.chapterId } if (index < 0) { return@launchLoadingJob @@ -330,7 +344,7 @@ constructor( } val centerPos = (lowerPos + upperPos) / 2 pages.getOrNull(centerPos)?.let { page -> - currentState.update { cs -> + readingState.update { cs -> cs?.copy(chapterId = page.chapterId, page = page.index) } } @@ -357,10 +371,10 @@ constructor( } bookmarkJob = launchJob(Dispatchers.Default) { loadingJob?.join() - val state = checkNotNull(currentState.value) + val state = checkNotNull(readingState.value) val page = checkNotNull(getCurrentPage()) { "Page not found" } val bookmark = Bookmark( - manga = mangaData.requireValue().toManga(), + manga = requireManga(), pageId = page.id, chapterId = state.chapterId, page = state.page, @@ -380,7 +394,7 @@ constructor( } bookmarkJob = launchJob { loadingJob?.join() - val manga = mangaData.requireValue().toManga() + val manga = requireManga() val state = checkNotNull(getCurrentState()) bookmarksRepository.removeBookmark(manga.id, state.chapterId, state.page) onShowToast.call(R.string.bookmark_removed) @@ -390,28 +404,29 @@ constructor( private fun loadImpl() { loadingJob = launchLoadingJob(Dispatchers.Default) { val details = detailsLoadUseCase.invoke(intent).first { x -> x.isLoaded } - mangaData.value = details + mangaDetails.value = details chaptersLoader.init(details) val manga = details.toManga() // obtain state - if (currentState.value == null) { - currentState.value = getStateFromIntent(manga) + if (readingState.value == null) { + readingState.value = getStateFromIntent(manga) } - val mode = detectReaderModeUseCase.invoke(manga, currentState.value) - val branch = chaptersLoader.peekChapter(currentState.value?.chapterId ?: 0L)?.branch - mangaData.value = details.filterChapters(branch) + val mode = detectReaderModeUseCase.invoke(manga, readingState.value) + val branch = chaptersLoader.peekChapter(readingState.value?.chapterId ?: 0L)?.branch + selectedBranch.value = branch + mangaDetails.value = details.filterChapters(branch) readerMode.value = mode - chaptersLoader.loadSingleChapter(requireNotNull(currentState.value).chapterId) + chaptersLoader.loadSingleChapter(requireNotNull(readingState.value).chapterId) // save state if (!incognitoMode.value) { - currentState.value?.let { + readingState.value?.let { val percent = computePercent(it.chapterId, it.page) historyUpdateUseCase.invoke(manga, it, percent) } } notifyStateChanged() - content.value = ReaderContent(chaptersLoader.snapshot(), currentState.value) + content.value = ReaderContent(chaptersLoader.snapshot(), readingState.value) } } @@ -420,7 +435,7 @@ constructor( val prevJob = loadingJob loadingJob = launchLoadingJob(Dispatchers.Default) { prevJob?.join() - chaptersLoader.loadPrevNextChapter(mangaData.requireValue(), currentId, isNext) + chaptersLoader.loadPrevNextChapter(mangaDetails.requireValue(), currentId, isNext) content.value = ReaderContent(chaptersLoader.snapshot(), null) } } @@ -439,7 +454,7 @@ constructor( private fun notifyStateChanged() { val state = getCurrentState().assertNotNull("state") ?: return val chapter = chaptersLoader.peekChapter(state.chapterId).assertNotNull("chapter") ?: return - val m = manga.assertNotNull("manga") ?: return + val m = mangaDetails.value.assertNotNull("manga") ?: return val chapterIndex = m.chapters[chapter.branch]?.indexOfFirst { it.id == chapter.id } ?: -1 val newState = ReaderUiState( mangaName = m.toManga().title, @@ -461,7 +476,7 @@ constructor( private fun computePercent(chapterId: Long, pageIndex: Int): Float { val branch = chaptersLoader.peekChapter(chapterId)?.branch - val chapters = manga?.chapters?.get(branch) ?: return PROGRESS_NONE + val chapters = mangaDetails.value?.chapters?.get(branch) ?: return PROGRESS_NONE val chaptersCount = chapters.size val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId } val pagesCount = chaptersLoader.getPagesCount(chapterId) @@ -495,6 +510,7 @@ constructor( private suspend fun getStateFromIntent(manga: Manga): ReaderState { val history = historyRepository.getOne(manga) + val preselectedBranch = selectedBranch.value val result = if (history != null) { if (preselectedBranch != null && preselectedBranch != manga.findChapter(history.chapterId)?.branch) { null diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt index cfb59eb12..6aad038a9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt @@ -69,7 +69,7 @@ class ReaderConfigSheet : ?: ReaderMode.STANDARD imageServerDelegate = ImageServerDelegate( mangaRepositoryFactory = mangaRepositoryFactory, - mangaSource = viewModel.manga?.toManga()?.source, + mangaSource = viewModel.getMangaOrNull()?.source, ) } @@ -144,7 +144,7 @@ class ReaderConfigSheet : R.id.button_color_filter -> { val page = viewModel.getCurrentPage() ?: return - val manga = viewModel.manga?.toManga() ?: return + val manga = viewModel.getMangaOrNull() ?: return startActivity(ColorFilterConfigActivity.newIntent(v.context, manga, page)) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e1237015b..ad29b5c7a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,6 +19,7 @@ Chapter %1$d of %2$d Close Try again + Retry Clear history Nothing found