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
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.remember
import androidx.compose.ui.Modifier
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 java.text.DateFormat
import java.util.Date
@ -21,9 +16,8 @@ fun RelativeDateHeader(
dateFormat: DateFormat,
) {
val context = LocalContext.current
Text(
modifier = modifier
.padding(horizontal = MaterialTheme.padding.medium, vertical = MaterialTheme.padding.small),
ListGroupHeader(
modifier = modifier,
text = remember {
date.toRelativeString(
context,
@ -31,9 +25,5 @@ fun RelativeDateHeader(
dateFormat,
)
},
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold,
),
)
}

View File

@ -1,7 +1,6 @@
package eu.kanade.presentation.updates
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.material3.Icon
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -33,152 +34,103 @@ import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.components.SwipeRefresh
import eu.kanade.tachiyomi.R
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.UpdatesPresenter
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.ui.updates.UpdatesState
import eu.kanade.tachiyomi.widget.TachiyomiBottomNavigationView
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.util.Date
import kotlin.time.Duration.Companion.seconds
@Composable
fun UpdateScreen(
presenter: UpdatesPresenter,
state: UpdatesState,
snackbarHostState: SnackbarHostState,
incognitoMode: Boolean,
downloadedOnlyMode: Boolean,
lastUpdated: Long,
relativeTime: Int,
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 = {
if (presenter.selectionMode) {
presenter.toggleAllSelection(false)
} else {
onBackClicked()
}
}
BackHandler(onBack = internalOnBackPressed)
BackHandler(enabled = state.selectionMode, onBack = { onSelectAll(false) })
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(
topBar = { scrollBehavior ->
UpdatesAppBar(
incognitoMode = presenter.isIncognitoMode,
downloadedOnlyMode = presenter.isDownloadOnly,
incognitoMode = incognitoMode,
downloadedOnlyMode = downloadedOnlyMode,
onUpdateLibrary = { onUpdateLibrary() },
actionModeCounter = presenter.selected.size,
onSelectAll = { presenter.toggleAllSelection(true) },
onInvertSelection = { presenter.invertSelection() },
onCancelActionMode = { presenter.toggleAllSelection(false) },
actionModeCounter = state.selected.size,
onSelectAll = { onSelectAll(true) },
onInvertSelection = { onInvertSelection() },
onCancelActionMode = { onSelectAll(false) },
scrollBehavior = scrollBehavior,
)
},
bottomBar = {
UpdatesBottomBar(
selected = presenter.selected,
onDownloadChapter = presenter::downloadChapters,
onMultiBookmarkClicked = presenter::bookmarkUpdates,
onMultiMarkAsReadClicked = presenter::markUpdatesRead,
onMultiDeleteClicked = {
presenter.dialog = Dialog.DeleteConfirmation(it)
},
selected = state.selected,
onDownloadChapter = onDownloadChapter,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMultiDeleteClicked = onMultiDeleteClicked,
)
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
contentWindowInsets = TachiyomiBottomNavigationView.withBottomNavInset(ScaffoldDefaults.contentWindowInsets),
) { contentPadding ->
val contentPaddingWithNavBar = TachiyomiBottomNavigationView.withBottomNavPadding(contentPadding)
when {
presenter.isLoading -> LoadingScreen()
presenter.uiModels.isEmpty() -> EmptyScreen(
state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding))
state.items.isEmpty() -> EmptyScreen(
textResource = R.string.information_no_recent,
modifier = Modifier.padding(contentPaddingWithNavBar),
modifier = Modifier.padding(contentPadding),
)
else -> {
UpdateScreenContent(
presenter = presenter,
contentPadding = contentPaddingWithNavBar,
onUpdateLibrary = onUpdateLibrary,
onClickCover = onClickCover,
)
}
}
}
}
val scope = rememberCoroutineScope()
var isRefreshing by remember { mutableStateOf(false) }
@Composable
private fun UpdateScreenContent(
presenter: UpdatesPresenter,
contentPadding: PaddingValues,
onUpdateLibrary: () -> Boolean,
onClickCover: (UpdatesItem) -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var isRefreshing by remember { mutableStateOf(false) }
SwipeRefresh(
refreshing = isRefreshing,
onRefresh = {
val started = onUpdateLibrary()
if (!started) return@SwipeRefresh
scope.launch {
// Fake refresh status but hide it after a second as it's a long running task
isRefreshing = true
delay(1.seconds)
isRefreshing = false
}
},
enabled = !state.selectionMode,
indicatorPadding = contentPadding,
) {
FastScrollLazyColumn(
contentPadding = contentPadding,
) {
if (lastUpdated > 0L) {
updatesLastUpdatedItem(lastUpdated)
}
SwipeRefresh(
refreshing = isRefreshing,
onRefresh = {
val started = onUpdateLibrary()
if (!started) return@SwipeRefresh
scope.launch {
// Fake refresh status but hide it after a second as it's a long running task
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)
updatesUiItems(
uiModels = state.getUiModel(context, relativeTime),
selectionMode = state.selectionMode,
onUpdateSelected = onUpdateSelected,
onClickCover = onClickCover,
onClickUpdate = onOpenChapter,
onDownloadChapter = onDownloadChapter,
)
}
}
}
}
}
@ -265,6 +217,6 @@ private fun UpdatesBottomBar(
}
sealed class UpdatesUiModel {
data class Header(val date: Date) : UpdatesUiModel()
data class Header(val date: String) : 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.filled.Bookmark
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.presentation.components.ChapterDownloadAction
import eu.kanade.presentation.components.ChapterDownloadIndicator
import eu.kanade.presentation.components.ListGroupHeader
import eu.kanade.presentation.components.MangaCover
import eu.kanade.presentation.components.RelativeDateHeader
import eu.kanade.presentation.util.ReadItemAlpha
import eu.kanade.presentation.util.padding
import eu.kanade.presentation.util.selectedBackground
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.ui.updates.UpdatesItem
import java.text.DateFormat
import java.util.Date
import kotlin.time.Duration.Companion.minutes
@ -73,9 +71,7 @@ fun LazyListScope.updatesLastUpdatedItem(
} else {
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,
onClickUpdate: (UpdatesItem) -> Unit,
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
relativeTime: Int,
dateFormat: DateFormat,
) {
items(
items = uiModels,
@ -108,11 +102,9 @@ fun LazyListScope.updatesUiItems(
) { item ->
when (item) {
is UpdatesUiModel.Header -> {
RelativeDateHeader(
ListGroupHeader(
modifier = Modifier.animateItemPlacement(),
date = item.date,
relativeTime = relativeTime,
dateFormat = dateFormat,
text = item.date,
)
}
is UpdatesUiModel.Item -> {
@ -130,11 +122,10 @@ fun LazyListScope.updatesUiItems(
else -> onClickUpdate(updatesItem)
}
},
onClickCover = { if (selectionMode.not()) onClickCover(updatesItem) },
onDownloadChapter = {
if (selectionMode.not()) onDownloadChapter(listOf(updatesItem), it)
},
downloadIndicatorEnabled = selectionMode.not(),
onClickCover = { onClickCover(updatesItem) }.takeIf { !selectionMode },
onDownloadChapter = { action: ChapterDownloadAction ->
onDownloadChapter(listOf(updatesItem), action)
}.takeIf { !selectionMode },
downloadStateProvider = updatesItem.downloadStateProvider,
downloadProgressProvider = updatesItem.downloadProgressProvider,
)
@ -150,10 +141,9 @@ fun UpdatesUiItem(
selected: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
onClickCover: () -> Unit,
onDownloadChapter: (ChapterDownloadAction) -> Unit,
onClickCover: (() -> Unit)?,
onDownloadChapter: ((ChapterDownloadAction) -> Unit)?,
// Download Indicator
downloadIndicatorEnabled: Boolean,
downloadStateProvider: () -> Download.State,
downloadProgressProvider: () -> Int,
) {
@ -217,8 +207,8 @@ fun UpdatesUiItem(
Text(
text = update.chapterName,
maxLines = 1,
style = MaterialTheme.typography.bodySmall
.copy(color = secondaryTextColor),
color = secondaryTextColor,
style = MaterialTheme.typography.bodySmall,
overflow = TextOverflow.Ellipsis,
onTextLayout = { textHeight = it.size.height },
modifier = Modifier.alpha(textAlpha),
@ -226,11 +216,11 @@ fun UpdatesUiItem(
}
}
ChapterDownloadIndicator(
enabled = downloadIndicatorEnabled,
enabled = onDownloadChapter != null,
modifier = Modifier.padding(start = 4.dp),
downloadStateProvider = downloadStateProvider,
downloadProgressProvider = downloadProgressProvider,
onClick = onDownloadChapter,
onClick = { onDownloadChapter?.invoke(it) },
)
}
}

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
}
}
}
}