Use Voyager on Updates tab (#8603)

* Use Voyager on Updates tab

* Fix back press

* Fix selection
This commit is contained in:
Ivan Iskandar
2022-11-23 21:22:20 +07:00
committed by GitHub
parent 7d34ff214c
commit acc2312384
9 changed files with 372 additions and 349 deletions

View File

@@ -485,9 +485,8 @@ class MainActivity : BaseActivity() {
}
override fun onBackPressed() {
// Updates screen has custom back handler
if (router.getControllerWithTag("${R.id.nav_updates}") != null) {
router.handleBack()
if (router.handleBack()) {
// A Router is consuming back press
return
}
val backstackSize = router.backstackSize
@@ -495,12 +494,10 @@ class MainActivity : BaseActivity() {
if (backstackSize == 1 && startScreen == null) {
// Return to start screen
moveToStartScreen()
} else if (startScreen != null && router.handleBack()) {
// Clear selection for Library screen
} else if (shouldHandleExitConfirmation()) {
// Exit confirmation (resets after 2 seconds)
lifecycleScope.launchUI { resetExitConfirmation() }
} else if (backstackSize == 1 || !router.handleBack()) {
} else if (backstackSize == 1) {
// Regular back (i.e. closing the app)
if (libraryPreferences.autoClearChapterCache().get()) {
chapterCache.clear()

View File

@@ -1,39 +1,13 @@
package eu.kanade.tachiyomi.ui.updates
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import eu.kanade.presentation.updates.UpdateScreen
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
import cafe.adriel.voyager.navigator.Navigator
import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
import eu.kanade.tachiyomi.ui.base.controller.RootController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaController
class UpdatesController :
FullComposeController<UpdatesPresenter>(),
RootController {
override fun createPresenter() = UpdatesPresenter()
class UpdatesController : BasicFullComposeController(), RootController {
@Composable
override fun ComposeContent() {
UpdateScreen(
presenter = presenter,
onClickCover = { item ->
router.pushController(MangaController(item.update.mangaId))
},
onBackClicked = {
(activity as? MainActivity)?.moveToStartScreen()
},
)
LaunchedEffect(presenter.selectionMode) {
(activity as? MainActivity)?.showBottomNav(presenter.selectionMode.not())
}
LaunchedEffect(presenter.isLoading) {
if (!presenter.isLoading) {
(activity as? MainActivity)?.ready = true
}
}
Navigator(screen = UpdatesScreen)
}
}

View File

@@ -0,0 +1,88 @@
package eu.kanade.tachiyomi.ui.updates
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.updates.UpdateScreen
import eu.kanade.presentation.updates.UpdatesDeleteConfirmationDialog
import eu.kanade.presentation.util.LocalRouter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.updates.UpdatesScreenModel.Event
import kotlinx.coroutines.flow.collectLatest
object UpdatesScreen : Screen {
@Composable
override fun Content() {
val context = LocalContext.current
val router = LocalRouter.currentOrThrow
val screenModel = rememberScreenModel { UpdatesScreenModel() }
val state by screenModel.state.collectAsState()
UpdateScreen(
state = state,
snackbarHostState = screenModel.snackbarHostState,
incognitoMode = screenModel.isIncognitoMode,
downloadedOnlyMode = screenModel.isDownloadOnly,
lastUpdated = screenModel.lastUpdated,
relativeTime = screenModel.relativeTime,
onClickCover = { item -> router.pushController(MangaController(item.update.mangaId)) },
onSelectAll = screenModel::toggleAllSelection,
onInvertSelection = screenModel::invertSelection,
onUpdateLibrary = screenModel::updateLibrary,
onDownloadChapter = screenModel::downloadChapters,
onMultiBookmarkClicked = screenModel::bookmarkUpdates,
onMultiMarkAsReadClicked = screenModel::markUpdatesRead,
onMultiDeleteClicked = screenModel::showConfirmDeleteChapters,
onUpdateSelected = screenModel::toggleSelection,
onOpenChapter = {
val intent = ReaderActivity.newIntent(context, it.update.mangaId, it.update.chapterId)
context.startActivity(intent)
},
)
val onDismissDialog = { screenModel.setDialog(null) }
when (val dialog = state.dialog) {
is UpdatesScreenModel.Dialog.DeleteConfirmation -> {
UpdatesDeleteConfirmationDialog(
onDismissRequest = onDismissDialog,
onConfirm = { screenModel.deleteChapters(dialog.toDelete) },
)
}
null -> {}
}
LaunchedEffect(Unit) {
screenModel.events.collectLatest { event ->
when (event) {
Event.InternalError -> screenModel.snackbarHostState.showSnackbar(context.getString(R.string.internal_error))
is Event.LibraryUpdateTriggered -> {
val msg = if (event.started) {
R.string.updating_library
} else {
R.string.update_already_running
}
screenModel.snackbarHostState.showSnackbar(context.getString(msg))
}
}
}
}
LaunchedEffect(state.selectionMode) {
(context as? MainActivity)?.showBottomNav(!state.selectionMode)
}
LaunchedEffect(state.isLoading) {
if (!state.isLoading) {
(context as? MainActivity)?.ready = true
}
}
}
}

View File

@@ -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 {
val isDownloadOnly: Boolean by basePreferences.downloadedOnly().asState()
val isIncognitoMode: Boolean by basePreferences.incognitoMode().asState()
val lastUpdated by libraryPreferences.libraryUpdateLastTimestamp().asState()
val relativeTime: Int by uiPreferences.relativeTime().asState()
val dateFormat: DateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get()))
) : StateScreenModel<UpdatesState>(UpdatesState()) {
private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
val events: Flow<Event> = _events.receiveAsFlow()
val isDownloadOnly: Boolean by basePreferences.downloadedOnly().asState(coroutineScope)
val isIncognitoMode: Boolean by basePreferences.incognitoMode().asState(coroutineScope)
val lastUpdated by libraryPreferences.libraryUpdateLastTimestamp().asState(coroutineScope)
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
}
if (modifiedIndex < 0) return@apply
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 },
),
)
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
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 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)
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]
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
}
}
}
}