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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 372 additions and 349 deletions

View file

@ -0,0 +1,27 @@
package eu.kanade.presentation.components
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import eu.kanade.presentation.util.padding
@Composable
fun ListGroupHeader(
modifier: Modifier = Modifier,
text: String,
) {
Text(
text = text,
modifier = modifier
.padding(
horizontal = MaterialTheme.padding.medium,
vertical = MaterialTheme.padding.small,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodyMedium,
)
}

View file

@ -1,14 +1,9 @@
package eu.kanade.presentation.components package eu.kanade.presentation.components
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import eu.kanade.presentation.util.padding
import eu.kanade.tachiyomi.util.lang.toRelativeString import eu.kanade.tachiyomi.util.lang.toRelativeString
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
@ -21,9 +16,8 @@ fun RelativeDateHeader(
dateFormat: DateFormat, dateFormat: DateFormat,
) { ) {
val context = LocalContext.current val context = LocalContext.current
Text( ListGroupHeader(
modifier = modifier modifier = modifier,
.padding(horizontal = MaterialTheme.padding.medium, vertical = MaterialTheme.padding.small),
text = remember { text = remember {
date.toRelativeString( date.toRelativeString(
context, context,
@ -31,9 +25,5 @@ fun RelativeDateHeader(
dateFormat, dateFormat,
) )
}, },
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold,
),
) )
} }

View file

@ -1,7 +1,6 @@
package eu.kanade.presentation.updates package eu.kanade.presentation.updates
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -10,9 +9,11 @@ import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.SelectAll import androidx.compose.material.icons.outlined.SelectAll
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -33,152 +34,103 @@ import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.components.SwipeRefresh import eu.kanade.presentation.components.SwipeRefresh
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.updates.UpdatesItem import eu.kanade.tachiyomi.ui.updates.UpdatesItem
import eu.kanade.tachiyomi.ui.updates.UpdatesPresenter import eu.kanade.tachiyomi.ui.updates.UpdatesState
import eu.kanade.tachiyomi.ui.updates.UpdatesPresenter.Dialog
import eu.kanade.tachiyomi.ui.updates.UpdatesPresenter.Event
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.widget.TachiyomiBottomNavigationView import eu.kanade.tachiyomi.widget.TachiyomiBottomNavigationView
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Date
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Composable @Composable
fun UpdateScreen( fun UpdateScreen(
presenter: UpdatesPresenter, state: UpdatesState,
snackbarHostState: SnackbarHostState,
incognitoMode: Boolean,
downloadedOnlyMode: Boolean,
lastUpdated: Long,
relativeTime: Int,
onClickCover: (UpdatesItem) -> Unit, onClickCover: (UpdatesItem) -> Unit,
onBackClicked: () -> Unit, onSelectAll: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
onUpdateLibrary: () -> Boolean,
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
onMultiMarkAsReadClicked: (List<UpdatesItem>, read: Boolean) -> Unit,
onMultiDeleteClicked: (List<UpdatesItem>) -> Unit,
onUpdateSelected: (UpdatesItem, Boolean, Boolean, Boolean) -> Unit,
onOpenChapter: (UpdatesItem) -> Unit,
) { ) {
val internalOnBackPressed = { BackHandler(enabled = state.selectionMode, onBack = { onSelectAll(false) })
if (presenter.selectionMode) {
presenter.toggleAllSelection(false)
} else {
onBackClicked()
}
}
BackHandler(onBack = internalOnBackPressed)
val context = LocalContext.current val context = LocalContext.current
val onUpdateLibrary = {
val started = LibraryUpdateService.start(context)
context.toast(if (started) R.string.updating_library else R.string.update_already_running)
started
}
Scaffold( Scaffold(
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
UpdatesAppBar( UpdatesAppBar(
incognitoMode = presenter.isIncognitoMode, incognitoMode = incognitoMode,
downloadedOnlyMode = presenter.isDownloadOnly, downloadedOnlyMode = downloadedOnlyMode,
onUpdateLibrary = { onUpdateLibrary() }, onUpdateLibrary = { onUpdateLibrary() },
actionModeCounter = presenter.selected.size, actionModeCounter = state.selected.size,
onSelectAll = { presenter.toggleAllSelection(true) }, onSelectAll = { onSelectAll(true) },
onInvertSelection = { presenter.invertSelection() }, onInvertSelection = { onInvertSelection() },
onCancelActionMode = { presenter.toggleAllSelection(false) }, onCancelActionMode = { onSelectAll(false) },
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) )
}, },
bottomBar = { bottomBar = {
UpdatesBottomBar( UpdatesBottomBar(
selected = presenter.selected, selected = state.selected,
onDownloadChapter = presenter::downloadChapters, onDownloadChapter = onDownloadChapter,
onMultiBookmarkClicked = presenter::bookmarkUpdates, onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = presenter::markUpdatesRead, onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMultiDeleteClicked = { onMultiDeleteClicked = onMultiDeleteClicked,
presenter.dialog = Dialog.DeleteConfirmation(it)
},
) )
}, },
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
contentWindowInsets = TachiyomiBottomNavigationView.withBottomNavInset(ScaffoldDefaults.contentWindowInsets),
) { contentPadding -> ) { contentPadding ->
val contentPaddingWithNavBar = TachiyomiBottomNavigationView.withBottomNavPadding(contentPadding)
when { when {
presenter.isLoading -> LoadingScreen() state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
presenter.uiModels.isEmpty() -> EmptyScreen( state.items.isEmpty() -> EmptyScreen(
textResource = R.string.information_no_recent, textResource = R.string.information_no_recent,
modifier = Modifier.padding(contentPaddingWithNavBar), modifier = Modifier.padding(contentPadding),
) )
else -> { else -> {
UpdateScreenContent( val scope = rememberCoroutineScope()
presenter = presenter, var isRefreshing by remember { mutableStateOf(false) }
contentPadding = contentPaddingWithNavBar,
onUpdateLibrary = onUpdateLibrary,
onClickCover = onClickCover,
)
}
}
}
}
@Composable SwipeRefresh(
private fun UpdateScreenContent( refreshing = isRefreshing,
presenter: UpdatesPresenter, onRefresh = {
contentPadding: PaddingValues, val started = onUpdateLibrary()
onUpdateLibrary: () -> Boolean, if (!started) return@SwipeRefresh
onClickCover: (UpdatesItem) -> Unit, scope.launch {
) { // Fake refresh status but hide it after a second as it's a long running task
val context = LocalContext.current isRefreshing = true
val scope = rememberCoroutineScope() delay(1.seconds)
var isRefreshing by remember { mutableStateOf(false) } isRefreshing = false
}
},
enabled = !state.selectionMode,
indicatorPadding = contentPadding,
) {
FastScrollLazyColumn(
contentPadding = contentPadding,
) {
if (lastUpdated > 0L) {
updatesLastUpdatedItem(lastUpdated)
}
SwipeRefresh( updatesUiItems(
refreshing = isRefreshing, uiModels = state.getUiModel(context, relativeTime),
onRefresh = { selectionMode = state.selectionMode,
val started = onUpdateLibrary() onUpdateSelected = onUpdateSelected,
if (!started) return@SwipeRefresh onClickCover = onClickCover,
scope.launch { onClickUpdate = onOpenChapter,
// Fake refresh status but hide it after a second as it's a long running task onDownloadChapter = onDownloadChapter,
isRefreshing = true )
delay(1.seconds) }
isRefreshing = false }
}
},
enabled = presenter.selectionMode.not(),
indicatorPadding = contentPadding,
) {
FastScrollLazyColumn(
contentPadding = contentPadding,
) {
if (presenter.lastUpdated > 0L) {
updatesLastUpdatedItem(presenter.lastUpdated)
}
updatesUiItems(
uiModels = presenter.uiModels,
selectionMode = presenter.selectionMode,
onUpdateSelected = presenter::toggleSelection,
onClickCover = onClickCover,
onClickUpdate = {
val intent = ReaderActivity.newIntent(context, it.update.mangaId, it.update.chapterId)
context.startActivity(intent)
},
onDownloadChapter = presenter::downloadChapters,
relativeTime = presenter.relativeTime,
dateFormat = presenter.dateFormat,
)
}
}
val onDismissDialog = { presenter.dialog = null }
when (val dialog = presenter.dialog) {
is Dialog.DeleteConfirmation -> {
UpdatesDeleteConfirmationDialog(
onDismissRequest = onDismissDialog,
onConfirm = {
presenter.toggleAllSelection(false)
presenter.deleteChapters(dialog.toDelete)
},
)
}
null -> {}
}
LaunchedEffect(Unit) {
presenter.events.collectLatest { event ->
when (event) {
Event.InternalError -> context.toast(R.string.internal_error)
} }
} }
} }
@ -265,6 +217,6 @@ private fun UpdatesBottomBar(
} }
sealed class UpdatesUiModel { sealed class UpdatesUiModel {
data class Header(val date: Date) : UpdatesUiModel() data class Header(val date: String) : UpdatesUiModel()
data class Item(val item: UpdatesItem) : UpdatesUiModel() data class Item(val item: UpdatesItem) : UpdatesUiModel()
} }

View file

@ -1,51 +0,0 @@
package eu.kanade.presentation.updates
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.core.util.insertSeparators
import eu.kanade.tachiyomi.ui.updates.UpdatesItem
import eu.kanade.tachiyomi.ui.updates.UpdatesPresenter
import eu.kanade.tachiyomi.util.lang.toDateKey
import java.util.Date
@Stable
interface UpdatesState {
val isLoading: Boolean
val items: List<UpdatesItem>
val selected: List<UpdatesItem>
val selectionMode: Boolean
val uiModels: List<UpdatesUiModel>
var dialog: UpdatesPresenter.Dialog?
}
fun UpdatesState(): UpdatesState = UpdatesStateImpl()
class UpdatesStateImpl : UpdatesState {
override var isLoading: Boolean by mutableStateOf(true)
override var items: List<UpdatesItem> by mutableStateOf(emptyList())
override val selected: List<UpdatesItem> by derivedStateOf {
items.filter { it.selected }
}
override val selectionMode: Boolean by derivedStateOf { selected.isNotEmpty() }
override val uiModels: List<UpdatesUiModel> by derivedStateOf {
items.toUpdateUiModel()
}
override var dialog: UpdatesPresenter.Dialog? by mutableStateOf(null)
}
fun List<UpdatesItem>.toUpdateUiModel(): List<UpdatesUiModel> {
return this.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 ->
UpdatesUiModel.Header(afterDate)
// Return null to avoid adding a separator between two items.
else -> null
}
}
}

View file

@ -16,7 +16,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -37,15 +36,14 @@ import androidx.compose.ui.unit.dp
import eu.kanade.domain.updates.model.UpdatesWithRelations import eu.kanade.domain.updates.model.UpdatesWithRelations
import eu.kanade.presentation.components.ChapterDownloadAction import eu.kanade.presentation.components.ChapterDownloadAction
import eu.kanade.presentation.components.ChapterDownloadIndicator import eu.kanade.presentation.components.ChapterDownloadIndicator
import eu.kanade.presentation.components.ListGroupHeader
import eu.kanade.presentation.components.MangaCover import eu.kanade.presentation.components.MangaCover
import eu.kanade.presentation.components.RelativeDateHeader
import eu.kanade.presentation.util.ReadItemAlpha import eu.kanade.presentation.util.ReadItemAlpha
import eu.kanade.presentation.util.padding import eu.kanade.presentation.util.padding
import eu.kanade.presentation.util.selectedBackground import eu.kanade.presentation.util.selectedBackground
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.ui.updates.UpdatesItem import eu.kanade.tachiyomi.ui.updates.UpdatesItem
import java.text.DateFormat
import java.util.Date import java.util.Date
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
@ -73,9 +71,7 @@ fun LazyListScope.updatesLastUpdatedItem(
} else { } else {
stringResource(R.string.updates_last_update_info, time) stringResource(R.string.updates_last_update_info, time)
}, },
style = LocalTextStyle.current.copy( fontStyle = FontStyle.Italic,
fontStyle = FontStyle.Italic,
),
) )
} }
} }
@ -88,8 +84,6 @@ fun LazyListScope.updatesUiItems(
onClickCover: (UpdatesItem) -> Unit, onClickCover: (UpdatesItem) -> Unit,
onClickUpdate: (UpdatesItem) -> Unit, onClickUpdate: (UpdatesItem) -> Unit,
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit, onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
relativeTime: Int,
dateFormat: DateFormat,
) { ) {
items( items(
items = uiModels, items = uiModels,
@ -108,11 +102,9 @@ fun LazyListScope.updatesUiItems(
) { item -> ) { item ->
when (item) { when (item) {
is UpdatesUiModel.Header -> { is UpdatesUiModel.Header -> {
RelativeDateHeader( ListGroupHeader(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItemPlacement(),
date = item.date, text = item.date,
relativeTime = relativeTime,
dateFormat = dateFormat,
) )
} }
is UpdatesUiModel.Item -> { is UpdatesUiModel.Item -> {
@ -130,11 +122,10 @@ fun LazyListScope.updatesUiItems(
else -> onClickUpdate(updatesItem) else -> onClickUpdate(updatesItem)
} }
}, },
onClickCover = { if (selectionMode.not()) onClickCover(updatesItem) }, onClickCover = { onClickCover(updatesItem) }.takeIf { !selectionMode },
onDownloadChapter = { onDownloadChapter = { action: ChapterDownloadAction ->
if (selectionMode.not()) onDownloadChapter(listOf(updatesItem), it) onDownloadChapter(listOf(updatesItem), action)
}, }.takeIf { !selectionMode },
downloadIndicatorEnabled = selectionMode.not(),
downloadStateProvider = updatesItem.downloadStateProvider, downloadStateProvider = updatesItem.downloadStateProvider,
downloadProgressProvider = updatesItem.downloadProgressProvider, downloadProgressProvider = updatesItem.downloadProgressProvider,
) )
@ -150,10 +141,9 @@ fun UpdatesUiItem(
selected: Boolean, selected: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onClickCover: () -> Unit, onClickCover: (() -> Unit)?,
onDownloadChapter: (ChapterDownloadAction) -> Unit, onDownloadChapter: ((ChapterDownloadAction) -> Unit)?,
// Download Indicator // Download Indicator
downloadIndicatorEnabled: Boolean,
downloadStateProvider: () -> Download.State, downloadStateProvider: () -> Download.State,
downloadProgressProvider: () -> Int, downloadProgressProvider: () -> Int,
) { ) {
@ -217,8 +207,8 @@ fun UpdatesUiItem(
Text( Text(
text = update.chapterName, text = update.chapterName,
maxLines = 1, maxLines = 1,
style = MaterialTheme.typography.bodySmall color = secondaryTextColor,
.copy(color = secondaryTextColor), style = MaterialTheme.typography.bodySmall,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
onTextLayout = { textHeight = it.size.height }, onTextLayout = { textHeight = it.size.height },
modifier = Modifier.alpha(textAlpha), modifier = Modifier.alpha(textAlpha),
@ -226,11 +216,11 @@ fun UpdatesUiItem(
} }
} }
ChapterDownloadIndicator( ChapterDownloadIndicator(
enabled = downloadIndicatorEnabled, enabled = onDownloadChapter != null,
modifier = Modifier.padding(start = 4.dp), modifier = Modifier.padding(start = 4.dp),
downloadStateProvider = downloadStateProvider, downloadStateProvider = downloadStateProvider,
downloadProgressProvider = downloadProgressProvider, downloadProgressProvider = downloadProgressProvider,
onClick = onDownloadChapter, onClick = { onDownloadChapter?.invoke(it) },
) )
} }
} }

View file

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

View file

@ -1,39 +1,13 @@
package eu.kanade.tachiyomi.ui.updates package eu.kanade.tachiyomi.ui.updates
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import cafe.adriel.voyager.navigator.Navigator
import eu.kanade.presentation.updates.UpdateScreen import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
import eu.kanade.tachiyomi.ui.base.controller.RootController 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 @Composable
override fun ComposeContent() { override fun ComposeContent() {
UpdateScreen( Navigator(screen = UpdatesScreen)
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
}
}
} }
} }

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