|
@@ -1,10 +1,16 @@
|
|
|
package eu.kanade.tachiyomi.ui.updates
|
|
|
|
|
|
-import android.os.Bundle
|
|
|
+import android.app.Application
|
|
|
+import android.content.Context
|
|
|
+import androidx.compose.material3.SnackbarHostState
|
|
|
import androidx.compose.runtime.Immutable
|
|
|
import androidx.compose.runtime.getValue
|
|
|
import androidx.compose.runtime.mutableStateOf
|
|
|
+import cafe.adriel.voyager.core.model.StateScreenModel
|
|
|
+import cafe.adriel.voyager.core.model.coroutineScope
|
|
|
+import eu.kanade.core.prefs.asState
|
|
|
import eu.kanade.core.util.addOrRemove
|
|
|
+import eu.kanade.core.util.insertSeparators
|
|
|
import eu.kanade.domain.base.BasePreferences
|
|
|
import eu.kanade.domain.chapter.interactor.GetChapter
|
|
|
import eu.kanade.domain.chapter.interactor.SetReadStatus
|
|
@@ -16,27 +22,27 @@ import eu.kanade.domain.ui.UiPreferences
|
|
|
import eu.kanade.domain.updates.interactor.GetUpdates
|
|
|
import eu.kanade.domain.updates.model.UpdatesWithRelations
|
|
|
import eu.kanade.presentation.components.ChapterDownloadAction
|
|
|
-import eu.kanade.presentation.updates.UpdatesState
|
|
|
-import eu.kanade.presentation.updates.UpdatesStateImpl
|
|
|
+import eu.kanade.presentation.updates.UpdatesUiModel
|
|
|
import eu.kanade.tachiyomi.data.download.DownloadCache
|
|
|
import eu.kanade.tachiyomi.data.download.DownloadManager
|
|
|
import eu.kanade.tachiyomi.data.download.DownloadService
|
|
|
import eu.kanade.tachiyomi.data.download.model.Download
|
|
|
+import eu.kanade.tachiyomi.data.library.LibraryUpdateService
|
|
|
import eu.kanade.tachiyomi.source.SourceManager
|
|
|
-import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
|
|
import eu.kanade.tachiyomi.util.lang.launchIO
|
|
|
import eu.kanade.tachiyomi.util.lang.launchNonCancellable
|
|
|
-import eu.kanade.tachiyomi.util.lang.withUIContext
|
|
|
+import eu.kanade.tachiyomi.util.lang.toDateKey
|
|
|
+import eu.kanade.tachiyomi.util.lang.toRelativeString
|
|
|
import eu.kanade.tachiyomi.util.system.logcat
|
|
|
import kotlinx.coroutines.channels.Channel
|
|
|
-import kotlinx.coroutines.delay
|
|
|
import kotlinx.coroutines.flow.Flow
|
|
|
import kotlinx.coroutines.flow.catch
|
|
|
import kotlinx.coroutines.flow.collectLatest
|
|
|
import kotlinx.coroutines.flow.combine
|
|
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
|
-import kotlinx.coroutines.flow.onStart
|
|
|
+import kotlinx.coroutines.flow.merge
|
|
|
import kotlinx.coroutines.flow.receiveAsFlow
|
|
|
+import kotlinx.coroutines.flow.update
|
|
|
import kotlinx.coroutines.launch
|
|
|
import logcat.LogPriority
|
|
|
import uy.kohesive.injekt.Injekt
|
|
@@ -45,8 +51,7 @@ import java.text.DateFormat
|
|
|
import java.util.Calendar
|
|
|
import java.util.Date
|
|
|
|
|
|
-class UpdatesPresenter(
|
|
|
- private val state: UpdatesStateImpl = UpdatesState() as UpdatesStateImpl,
|
|
|
+class UpdatesScreenModel(
|
|
|
private val sourceManager: SourceManager = Injekt.get(),
|
|
|
private val downloadManager: DownloadManager = Injekt.get(),
|
|
|
private val downloadCache: DownloadCache = Injekt.get(),
|
|
@@ -55,30 +60,29 @@ class UpdatesPresenter(
|
|
|
private val getUpdates: GetUpdates = Injekt.get(),
|
|
|
private val getManga: GetManga = Injekt.get(),
|
|
|
private val getChapter: GetChapter = Injekt.get(),
|
|
|
+ val snackbarHostState: SnackbarHostState = SnackbarHostState(),
|
|
|
basePreferences: BasePreferences = Injekt.get(),
|
|
|
uiPreferences: UiPreferences = Injekt.get(),
|
|
|
libraryPreferences: LibraryPreferences = Injekt.get(),
|
|
|
-) : BasePresenter<UpdatesController>(), UpdatesState by state {
|
|
|
+) : StateScreenModel<UpdatesState>(UpdatesState()) {
|
|
|
|
|
|
- val isDownloadOnly: Boolean by basePreferences.downloadedOnly().asState()
|
|
|
- val isIncognitoMode: Boolean by basePreferences.incognitoMode().asState()
|
|
|
+ private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
|
|
|
+ val events: Flow<Event> = _events.receiveAsFlow()
|
|
|
|
|
|
- val lastUpdated by libraryPreferences.libraryUpdateLastTimestamp().asState()
|
|
|
+ val isDownloadOnly: Boolean by basePreferences.downloadedOnly().asState(coroutineScope)
|
|
|
+ val isIncognitoMode: Boolean by basePreferences.incognitoMode().asState(coroutineScope)
|
|
|
|
|
|
- val relativeTime: Int by uiPreferences.relativeTime().asState()
|
|
|
- val dateFormat: DateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get()))
|
|
|
+ val lastUpdated by libraryPreferences.libraryUpdateLastTimestamp().asState(coroutineScope)
|
|
|
|
|
|
- private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
|
|
|
- val events: Flow<Event> = _events.receiveAsFlow()
|
|
|
+ val relativeTime: Int by uiPreferences.relativeTime().asState(coroutineScope)
|
|
|
+ val dateFormat: DateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get()))
|
|
|
|
|
|
// First and last selected index in list
|
|
|
private val selectedPositions: Array<Int> = arrayOf(-1, -1)
|
|
|
private val selectedChapterIds: HashSet<Long> = HashSet()
|
|
|
|
|
|
- override fun onCreate(savedState: Bundle?) {
|
|
|
- super.onCreate(savedState)
|
|
|
-
|
|
|
- presenterScope.launchIO {
|
|
|
+ init {
|
|
|
+ coroutineScope.launchIO {
|
|
|
// Set date limit for recent chapters
|
|
|
val calendar = Calendar.getInstance().apply {
|
|
|
time = Date()
|
|
@@ -89,35 +93,24 @@ class UpdatesPresenter(
|
|
|
getUpdates.subscribe(calendar).distinctUntilChanged(),
|
|
|
downloadCache.changes,
|
|
|
) { updates, _ -> updates }
|
|
|
- .onStart { delay(500) } // Defer to avoid crashing on initial render
|
|
|
.catch {
|
|
|
logcat(LogPriority.ERROR, it)
|
|
|
_events.send(Event.InternalError)
|
|
|
}
|
|
|
.collectLatest { updates ->
|
|
|
- state.items = updates.toUpdateItems()
|
|
|
- state.isLoading = false
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- presenterScope.launchIO {
|
|
|
- downloadManager.queue.statusFlow()
|
|
|
- .catch { logcat(LogPriority.ERROR, it) }
|
|
|
- .collect {
|
|
|
- withUIContext {
|
|
|
- updateDownloadState(it)
|
|
|
+ mutableState.update {
|
|
|
+ it.copy(
|
|
|
+ isLoading = false,
|
|
|
+ items = updates.toUpdateItems(),
|
|
|
+ )
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- presenterScope.launchIO {
|
|
|
- downloadManager.queue.progressFlow()
|
|
|
+ coroutineScope.launchIO {
|
|
|
+ merge(downloadManager.queue.statusFlow(), downloadManager.queue.progressFlow())
|
|
|
.catch { logcat(LogPriority.ERROR, it) }
|
|
|
- .collect {
|
|
|
- withUIContext {
|
|
|
- updateDownloadState(it)
|
|
|
- }
|
|
|
- }
|
|
|
+ .collect(this@UpdatesScreenModel::updateDownloadState)
|
|
|
}
|
|
|
}
|
|
|
|
|
@@ -144,37 +137,46 @@ class UpdatesPresenter(
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ fun updateLibrary(): Boolean {
|
|
|
+ val started = LibraryUpdateService.start(Injekt.get<Application>())
|
|
|
+ coroutineScope.launch {
|
|
|
+ _events.send(Event.LibraryUpdateTriggered(started))
|
|
|
+ }
|
|
|
+ return started
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Update status of chapters.
|
|
|
*
|
|
|
* @param download download object containing progress.
|
|
|
*/
|
|
|
private fun updateDownloadState(download: Download) {
|
|
|
- state.items = items.toMutableList().apply {
|
|
|
- val modifiedIndex = indexOfFirst {
|
|
|
- it.update.chapterId == download.chapter.id
|
|
|
+ mutableState.update { state ->
|
|
|
+ val newItems = state.items.toMutableList().apply {
|
|
|
+ val modifiedIndex = indexOfFirst { it.update.chapterId == download.chapter.id }
|
|
|
+ if (modifiedIndex < 0) return@apply
|
|
|
+
|
|
|
+ val item = get(modifiedIndex)
|
|
|
+ set(
|
|
|
+ modifiedIndex,
|
|
|
+ item.copy(
|
|
|
+ downloadStateProvider = { download.status },
|
|
|
+ downloadProgressProvider = { download.progress },
|
|
|
+ ),
|
|
|
+ )
|
|
|
}
|
|
|
- if (modifiedIndex < 0) return@apply
|
|
|
-
|
|
|
- val item = get(modifiedIndex)
|
|
|
- set(
|
|
|
- modifiedIndex,
|
|
|
- item.copy(
|
|
|
- downloadStateProvider = { download.status },
|
|
|
- downloadProgressProvider = { download.progress },
|
|
|
- ),
|
|
|
- )
|
|
|
+ state.copy(items = newItems)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
fun downloadChapters(items: List<UpdatesItem>, action: ChapterDownloadAction) {
|
|
|
if (items.isEmpty()) return
|
|
|
- presenterScope.launch {
|
|
|
+ coroutineScope.launch {
|
|
|
when (action) {
|
|
|
ChapterDownloadAction.START -> {
|
|
|
downloadChapters(items)
|
|
|
if (items.any { it.downloadStateProvider() == Download.State.ERROR }) {
|
|
|
- DownloadService.start(view!!.activity!!)
|
|
|
+ DownloadService.start(Injekt.get<Application>())
|
|
|
}
|
|
|
}
|
|
|
ChapterDownloadAction.START_NOW -> {
|
|
@@ -209,7 +211,7 @@ class UpdatesPresenter(
|
|
|
* @param read whether to mark chapters as read or unread.
|
|
|
*/
|
|
|
fun markUpdatesRead(updates: List<UpdatesItem>, read: Boolean) {
|
|
|
- presenterScope.launchIO {
|
|
|
+ coroutineScope.launchIO {
|
|
|
setReadStatus.await(
|
|
|
read = read,
|
|
|
chapters = updates
|
|
@@ -217,6 +219,7 @@ class UpdatesPresenter(
|
|
|
.toTypedArray(),
|
|
|
)
|
|
|
}
|
|
|
+ toggleAllSelection(false)
|
|
|
}
|
|
|
|
|
|
/**
|
|
@@ -224,20 +227,21 @@ class UpdatesPresenter(
|
|
|
* @param updates the list of chapters to bookmark.
|
|
|
*/
|
|
|
fun bookmarkUpdates(updates: List<UpdatesItem>, bookmark: Boolean) {
|
|
|
- presenterScope.launchIO {
|
|
|
+ coroutineScope.launchIO {
|
|
|
updates
|
|
|
.filterNot { it.update.bookmark == bookmark }
|
|
|
.map { ChapterUpdate(id = it.update.chapterId, bookmark = bookmark) }
|
|
|
.let { updateChapter.awaitAll(it) }
|
|
|
}
|
|
|
+ toggleAllSelection(false)
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Downloads the given list of chapters with the manager.
|
|
|
* @param updatesItem the list of chapters to download.
|
|
|
*/
|
|
|
- fun downloadChapters(updatesItem: List<UpdatesItem>) {
|
|
|
- presenterScope.launchNonCancellable {
|
|
|
+ private fun downloadChapters(updatesItem: List<UpdatesItem>) {
|
|
|
+ coroutineScope.launchNonCancellable {
|
|
|
val groupedUpdates = updatesItem.groupBy { it.update.mangaId }.values
|
|
|
for (updates in groupedUpdates) {
|
|
|
val mangaId = updates.first().update.mangaId
|
|
@@ -256,7 +260,7 @@ class UpdatesPresenter(
|
|
|
* @param updatesItem list of chapters
|
|
|
*/
|
|
|
fun deleteChapters(updatesItem: List<UpdatesItem>) {
|
|
|
- presenterScope.launchNonCancellable {
|
|
|
+ coroutineScope.launchNonCancellable {
|
|
|
updatesItem
|
|
|
.groupBy { it.update.mangaId }
|
|
|
.entries
|
|
@@ -267,6 +271,11 @@ class UpdatesPresenter(
|
|
|
downloadManager.deleteChapters(chapters, manga, source)
|
|
|
}
|
|
|
}
|
|
|
+ toggleAllSelection(false)
|
|
|
+ }
|
|
|
+
|
|
|
+ fun showConfirmDeleteChapters(updatesItem: List<UpdatesItem>) {
|
|
|
+ setDialog(Dialog.DeleteConfirmation(updatesItem))
|
|
|
}
|
|
|
|
|
|
fun toggleSelection(
|
|
@@ -275,85 +284,132 @@ class UpdatesPresenter(
|
|
|
userSelected: Boolean = false,
|
|
|
fromLongPress: Boolean = false,
|
|
|
) {
|
|
|
- state.items = items.toMutableList().apply {
|
|
|
- val selectedIndex = indexOfFirst { it.update.chapterId == item.update.chapterId }
|
|
|
- if (selectedIndex < 0) return@apply
|
|
|
-
|
|
|
- val selectedItem = get(selectedIndex)
|
|
|
- if (selectedItem.selected == selected) return@apply
|
|
|
-
|
|
|
- val firstSelection = none { it.selected }
|
|
|
- set(selectedIndex, selectedItem.copy(selected = selected))
|
|
|
- selectedChapterIds.addOrRemove(item.update.chapterId, selected)
|
|
|
-
|
|
|
- if (selected && userSelected && fromLongPress) {
|
|
|
- if (firstSelection) {
|
|
|
- selectedPositions[0] = selectedIndex
|
|
|
- selectedPositions[1] = selectedIndex
|
|
|
- } else {
|
|
|
- // Try to select the items in-between when possible
|
|
|
- val range: IntRange
|
|
|
- if (selectedIndex < selectedPositions[0]) {
|
|
|
- range = selectedIndex + 1 until selectedPositions[0]
|
|
|
+ mutableState.update { state ->
|
|
|
+ val newItems = state.items.toMutableList().apply {
|
|
|
+ val selectedIndex = indexOfFirst { it.update.chapterId == item.update.chapterId }
|
|
|
+ if (selectedIndex < 0) return@apply
|
|
|
+
|
|
|
+ val selectedItem = get(selectedIndex)
|
|
|
+ if (selectedItem.selected == selected) return@apply
|
|
|
+
|
|
|
+ val firstSelection = none { it.selected }
|
|
|
+ set(selectedIndex, selectedItem.copy(selected = selected))
|
|
|
+ selectedChapterIds.addOrRemove(item.update.chapterId, selected)
|
|
|
+
|
|
|
+ if (selected && userSelected && fromLongPress) {
|
|
|
+ if (firstSelection) {
|
|
|
selectedPositions[0] = selectedIndex
|
|
|
- } else if (selectedIndex > selectedPositions[1]) {
|
|
|
- range = (selectedPositions[1] + 1) until selectedIndex
|
|
|
selectedPositions[1] = selectedIndex
|
|
|
} else {
|
|
|
- // Just select itself
|
|
|
- range = IntRange.EMPTY
|
|
|
- }
|
|
|
+ // Try to select the items in-between when possible
|
|
|
+ val range: IntRange
|
|
|
+ if (selectedIndex < selectedPositions[0]) {
|
|
|
+ range = selectedIndex + 1 until selectedPositions[0]
|
|
|
+ selectedPositions[0] = selectedIndex
|
|
|
+ } else if (selectedIndex > selectedPositions[1]) {
|
|
|
+ range = (selectedPositions[1] + 1) until selectedIndex
|
|
|
+ selectedPositions[1] = selectedIndex
|
|
|
+ } else {
|
|
|
+ // Just select itself
|
|
|
+ range = IntRange.EMPTY
|
|
|
+ }
|
|
|
|
|
|
- range.forEach {
|
|
|
- val inbetweenItem = get(it)
|
|
|
- if (!inbetweenItem.selected) {
|
|
|
- selectedChapterIds.add(inbetweenItem.update.chapterId)
|
|
|
- set(it, inbetweenItem.copy(selected = true))
|
|
|
+ range.forEach {
|
|
|
+ val inbetweenItem = get(it)
|
|
|
+ if (!inbetweenItem.selected) {
|
|
|
+ selectedChapterIds.add(inbetweenItem.update.chapterId)
|
|
|
+ set(it, inbetweenItem.copy(selected = true))
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
- }
|
|
|
- } else if (userSelected && !fromLongPress) {
|
|
|
- if (!selected) {
|
|
|
- if (selectedIndex == selectedPositions[0]) {
|
|
|
- selectedPositions[0] = indexOfFirst { it.selected }
|
|
|
- } else if (selectedIndex == selectedPositions[1]) {
|
|
|
- selectedPositions[1] = indexOfLast { it.selected }
|
|
|
- }
|
|
|
- } else {
|
|
|
- if (selectedIndex < selectedPositions[0]) {
|
|
|
- selectedPositions[0] = selectedIndex
|
|
|
- } else if (selectedIndex > selectedPositions[1]) {
|
|
|
- selectedPositions[1] = selectedIndex
|
|
|
+ } else if (userSelected && !fromLongPress) {
|
|
|
+ if (!selected) {
|
|
|
+ if (selectedIndex == selectedPositions[0]) {
|
|
|
+ selectedPositions[0] = indexOfFirst { it.selected }
|
|
|
+ } else if (selectedIndex == selectedPositions[1]) {
|
|
|
+ selectedPositions[1] = indexOfLast { it.selected }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ if (selectedIndex < selectedPositions[0]) {
|
|
|
+ selectedPositions[0] = selectedIndex
|
|
|
+ } else if (selectedIndex > selectedPositions[1]) {
|
|
|
+ selectedPositions[1] = selectedIndex
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
+ state.copy(items = newItems)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
fun toggleAllSelection(selected: Boolean) {
|
|
|
- state.items = items.map {
|
|
|
- selectedChapterIds.addOrRemove(it.update.chapterId, selected)
|
|
|
- it.copy(selected = selected)
|
|
|
+ mutableState.update { state ->
|
|
|
+ val newItems = state.items.map {
|
|
|
+ selectedChapterIds.addOrRemove(it.update.chapterId, selected)
|
|
|
+ it.copy(selected = selected)
|
|
|
+ }
|
|
|
+ state.copy(items = newItems)
|
|
|
}
|
|
|
+
|
|
|
selectedPositions[0] = -1
|
|
|
selectedPositions[1] = -1
|
|
|
}
|
|
|
|
|
|
fun invertSelection() {
|
|
|
- state.items = items.map {
|
|
|
- selectedChapterIds.addOrRemove(it.update.chapterId, !it.selected)
|
|
|
- it.copy(selected = !it.selected)
|
|
|
+ mutableState.update { state ->
|
|
|
+ val newItems = state.items.map {
|
|
|
+ selectedChapterIds.addOrRemove(it.update.chapterId, !it.selected)
|
|
|
+ it.copy(selected = !it.selected)
|
|
|
+ }
|
|
|
+ state.copy(items = newItems)
|
|
|
}
|
|
|
selectedPositions[0] = -1
|
|
|
selectedPositions[1] = -1
|
|
|
}
|
|
|
|
|
|
+ fun setDialog(dialog: Dialog?) {
|
|
|
+ mutableState.update { it.copy(dialog = dialog) }
|
|
|
+ }
|
|
|
+
|
|
|
sealed class Dialog {
|
|
|
data class DeleteConfirmation(val toDelete: List<UpdatesItem>) : Dialog()
|
|
|
}
|
|
|
|
|
|
sealed class Event {
|
|
|
object InternalError : Event()
|
|
|
+ data class LibraryUpdateTriggered(val started: Boolean) : Event()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@Immutable
|
|
|
+data class UpdatesState(
|
|
|
+ val isLoading: Boolean = true,
|
|
|
+ val items: List<UpdatesItem> = emptyList(),
|
|
|
+ val dialog: UpdatesScreenModel.Dialog? = null,
|
|
|
+) {
|
|
|
+ val selected = items.filter { it.selected }
|
|
|
+ val selectionMode = selected.isNotEmpty()
|
|
|
+
|
|
|
+ fun getUiModel(context: Context, relativeTime: Int): List<UpdatesUiModel> {
|
|
|
+ val dateFormat = UiPreferences.dateFormat(Injekt.get<UiPreferences>().dateFormat().get())
|
|
|
+ return items
|
|
|
+ .map { UpdatesUiModel.Item(it) }
|
|
|
+ .insertSeparators { before, after ->
|
|
|
+ val beforeDate = before?.item?.update?.dateFetch?.toDateKey() ?: Date(0)
|
|
|
+ val afterDate = after?.item?.update?.dateFetch?.toDateKey() ?: Date(0)
|
|
|
+ when {
|
|
|
+ beforeDate.time != afterDate.time && afterDate.time != 0L -> {
|
|
|
+ val text = afterDate.toRelativeString(
|
|
|
+ context = context,
|
|
|
+ range = relativeTime,
|
|
|
+ dateFormat = dateFormat,
|
|
|
+ )
|
|
|
+ UpdatesUiModel.Header(text)
|
|
|
+ }
|
|
|
+ // Return null to avoid adding a separator between two items.
|
|
|
+ else -> null
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|