Use Stable interface for Updates screen + Cleanup (#7627)

* Use Stable interface for Updates screen + Cleanup

Co-Authored-By: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com>

* Disable swipe refresh in selection mode

* Review Changes

Co-Authored-By: Andreas <6576096+ghostbear@users.noreply.github.com>

* Review Changes 2

Co-authored-by: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com>
Co-authored-by: Andreas <6576096+ghostbear@users.noreply.github.com>
This commit is contained in:
AntsyLich 2022-07-30 21:50:00 +06:00 committed by GitHub
parent d49ec41f3a
commit 4774deb1ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 337 additions and 326 deletions

View File

@ -0,0 +1,34 @@
package eu.kanade.presentation.updates
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import eu.kanade.tachiyomi.R
@Composable
fun UpdatesDeleteConfirmationDialog(
onDismissRequest: () -> Unit,
onConfirm: () -> Unit,
) {
AlertDialog(
text = {
Text(text = stringResource(R.string.confirm_delete_chapters))
},
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = {
onConfirm()
onDismissRequest()
},) {
Text(text = stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(android.R.string.cancel))
}
},
)
}

View File

@ -1,6 +1,7 @@
package eu.kanade.presentation.updates
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
@ -20,9 +21,9 @@ import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import com.google.accompanist.swiperefresh.SwipeRefresh
@ -38,97 +39,78 @@ import eu.kanade.presentation.util.bottomNavPaddingValues
import eu.kanade.presentation.util.plus
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.recent.updates.UpdatesItem
import eu.kanade.tachiyomi.ui.recent.updates.UpdatesState
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.DateFormat
import eu.kanade.tachiyomi.ui.recent.updates.UpdatesPresenter
import eu.kanade.tachiyomi.ui.recent.updates.UpdatesPresenter.Dialog
import eu.kanade.tachiyomi.ui.recent.updates.UpdatesPresenter.Event
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest
import java.util.Date
@Composable
fun UpdateScreen(
state: UpdatesState.Success,
presenter: UpdatesPresenter,
onClickCover: (UpdatesItem) -> Unit,
onClickUpdate: (UpdatesItem) -> Unit,
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
onUpdateLibrary: () -> Unit,
onBackClicked: () -> Unit,
// For bottom action menu
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
onMultiMarkAsReadClicked: (List<UpdatesItem>, read: Boolean) -> Unit,
onMultiDeleteClicked: (List<UpdatesItem>) -> Unit,
// Miscellaneous
preferences: PreferencesHelper = Injekt.get(),
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
) {
val updatesListState = rememberLazyListState()
val insetPaddingValue = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
val relativeTime: Int = remember { preferences.relativeTime().get() }
val dateFormat: DateFormat = remember { preferences.dateFormat() }
val uiModels = remember(state) {
state.uiModels
}
val itemUiModels = remember(uiModels) {
uiModels.filterIsInstance<UpdatesUiModel.Item>()
}
// To prevent selection from getting removed during an update to a item in list
val updateIdList = remember(itemUiModels) {
itemUiModels.map { it.item.update.chapterId }
}
val selected = remember(updateIdList) {
emptyList<UpdatesUiModel.Item>().toMutableStateList()
}
// First and last selected index in list
val selectedPositions = remember(uiModels) { arrayOf(-1, -1) }
val internalOnBackPressed = {
if (selected.isNotEmpty()) {
selected.clear()
if (presenter.selectionMode) {
presenter.toggleAllSelection(false)
} else {
onBackClicked()
}
}
BackHandler(onBack = internalOnBackPressed)
val context = LocalContext.current
val onUpdateLibrary = {
if (LibraryUpdateService.start(context)) {
context.toast(R.string.updating_library)
}
}
Scaffold(
modifier = Modifier
.padding(insetPaddingValue),
topBar = {
UpdatesAppBar(
selected = selected,
incognitoMode = state.isIncognitoMode,
downloadedOnlyMode = state.isDownloadedOnlyMode,
incognitoMode = presenter.isIncognitoMode,
downloadedOnlyMode = presenter.isDownloadOnly,
onUpdateLibrary = onUpdateLibrary,
actionModeCounter = selected.size,
onSelectAll = {
selected.clear()
selected.addAll(itemUiModels)
},
onInvertSelection = {
val toSelect = itemUiModels - selected
selected.clear()
selected.addAll(toSelect)
},
actionModeCounter = presenter.selected.size,
onSelectAll = { presenter.toggleAllSelection(true) },
onInvertSelection = { presenter.invertSelection() },
onCancelActionMode = { presenter.toggleAllSelection(false) },
)
},
bottomBar = {
UpdatesBottomBar(
selected = selected,
selected = presenter.selected,
onDownloadChapter = onDownloadChapter,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMultiDeleteClicked = onMultiDeleteClicked,
onMultiBookmarkClicked = presenter::bookmarkUpdates,
onMultiMarkAsReadClicked = presenter::markUpdatesRead,
onMultiDeleteClicked = {
val updateItems = presenter.selected.map { it.item }
presenter.dialog = Dialog.DeleteConfirmation(updateItems)
},
)
},
) { contentPadding ->
val contentPaddingWithNavBar = bottomNavPaddingValues + contentPadding +
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()
// During selection mode bottom nav is not visible
val contentPaddingWithNavBar = (if (presenter.selectionMode) PaddingValues() else bottomNavPaddingValues) +
contentPadding + WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()
SwipeRefresh(
state = rememberSwipeRefreshState(state.showSwipeRefreshIndicator),
state = rememberSwipeRefreshState(isRefreshing = false),
onRefresh = onUpdateLibrary,
swipeEnabled = presenter.selectionMode.not(),
indicatorPadding = contentPaddingWithNavBar,
indicator = { s, trigger ->
SwipeRefreshIndicator(
@ -137,7 +119,7 @@ fun UpdateScreen(
)
},
) {
if (uiModels.isEmpty()) {
if (presenter.uiModels.isEmpty()) {
EmptyScreen(textResource = R.string.information_no_recent)
} else {
VerticalFastScroller(
@ -152,27 +134,49 @@ fun UpdateScreen(
contentPadding = contentPaddingWithNavBar,
) {
updatesUiItems(
uiModels = uiModels,
itemUiModels = itemUiModels,
selected = selected,
selectedPositions = selectedPositions,
uiModels = presenter.uiModels,
selectionMode = presenter.selectionMode,
onUpdateSelected = presenter::toggleSelection,
onClickCover = onClickCover,
onClickUpdate = onClickUpdate,
onClickUpdate = {
val intent = ReaderActivity.newIntent(context, it.update.mangaId, it.update.chapterId)
context.startActivity(intent)
},
onDownloadChapter = onDownloadChapter,
relativeTime = relativeTime,
dateFormat = dateFormat,
relativeTime = presenter.relativeTime,
dateFormat = presenter.dateFormat,
)
}
}
}
}
}
val onDismissDialog = { presenter.dialog = null }
when (val dialog = presenter.dialog) {
is Dialog.DeleteConfirmation -> {
UpdatesDeleteConfirmationDialog(
onDismissRequest = onDismissDialog,
onConfirm = {
presenter.deleteChapters(dialog.toDelete)
presenter.toggleAllSelection(false)
},
)
}
null -> {}
}
LaunchedEffect(Unit) {
presenter.events.collectLatest { event ->
when (event) {
Event.InternalError -> context.toast(R.string.internal_error)
}
}
}
}
@Composable
fun UpdatesAppBar(
modifier: Modifier = Modifier,
selected: MutableList<UpdatesUiModel.Item>,
incognitoMode: Boolean,
downloadedOnlyMode: Boolean,
onUpdateLibrary: () -> Unit,
@ -180,6 +184,7 @@ fun UpdatesAppBar(
actionModeCounter: Int,
onSelectAll: () -> Unit,
onInvertSelection: () -> Unit,
onCancelActionMode: () -> Unit,
) {
AppBar(
modifier = modifier,
@ -193,7 +198,7 @@ fun UpdatesAppBar(
}
},
actionModeCounter = actionModeCounter,
onCancelActionMode = { selected.clear() },
onCancelActionMode = onCancelActionMode,
actionModeActions = {
IconButton(onClick = onSelectAll) {
Icon(
@ -215,7 +220,7 @@ fun UpdatesAppBar(
@Composable
fun UpdatesBottomBar(
selected: MutableList<UpdatesUiModel.Item>,
selected: List<UpdatesUiModel.Item>,
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
onMultiMarkAsReadClicked: (List<UpdatesItem>, read: Boolean) -> Unit,
@ -226,29 +231,23 @@ fun UpdatesBottomBar(
modifier = Modifier.fillMaxWidth(),
onBookmarkClicked = {
onMultiBookmarkClicked.invoke(selected.map { it.item }, true)
selected.clear()
}.takeIf { selected.any { !it.item.update.bookmark } },
onRemoveBookmarkClicked = {
onMultiBookmarkClicked.invoke(selected.map { it.item }, false)
selected.clear()
}.takeIf { selected.all { it.item.update.bookmark } },
onMarkAsReadClicked = {
onMultiMarkAsReadClicked(selected.map { it.item }, true)
selected.clear()
}.takeIf { selected.any { !it.item.update.read } },
onMarkAsUnreadClicked = {
onMultiMarkAsReadClicked(selected.map { it.item }, false)
selected.clear()
}.takeIf { selected.any { it.item.update.read } },
onDownloadClicked = {
onDownloadChapter(selected.map { it.item }, ChapterDownloadAction.START)
selected.clear()
}.takeIf {
selected.any { it.item.downloadStateProvider() != Download.State.DOWNLOADED }
},
onDeleteClicked = {
onMultiDeleteClicked(selected.map { it.item })
selected.clear()
}.takeIf { selected.any { it.item.downloadStateProvider() == Download.State.DOWNLOADED } },
)
}

View File

@ -0,0 +1,28 @@
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.tachiyomi.ui.recent.updates.UpdatesPresenter
@Stable
interface UpdatesState {
val isLoading: Boolean
val uiModels: List<UpdatesUiModel>
val selected: List<UpdatesUiModel.Item>
val selectionMode: Boolean
var dialog: UpdatesPresenter.Dialog?
}
fun UpdatesState(): UpdatesState = UpdatesStateImpl()
class UpdatesStateImpl : UpdatesState {
override var isLoading: Boolean by mutableStateOf(true)
override var uiModels: List<UpdatesUiModel> by mutableStateOf(emptyList())
override val selected: List<UpdatesUiModel.Item> by derivedStateOf {
uiModels.filterIsInstance<UpdatesUiModel.Item>()
.filter { it.item.selected }
}
override val selectionMode: Boolean by derivedStateOf { selected.isNotEmpty() }
override var dialog: UpdatesPresenter.Dialog? by mutableStateOf(null)
}

View File

@ -26,7 +26,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -44,9 +46,8 @@ import java.text.DateFormat
fun LazyListScope.updatesUiItems(
uiModels: List<UpdatesUiModel>,
itemUiModels: List<UpdatesUiModel.Item>,
selected: MutableList<UpdatesUiModel.Item>,
selectedPositions: Array<Int>,
selectionMode: Boolean,
onUpdateSelected: (UpdatesItem, Boolean, Boolean, Boolean) -> Unit,
onClickCover: (UpdatesItem) -> Unit,
onClickUpdate: (UpdatesItem) -> Unit,
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
@ -78,35 +79,27 @@ fun LazyListScope.updatesUiItems(
)
}
is UpdatesUiModel.Item -> {
val value = item.item
val update = value.update
val updatesItem = item.item
val update = updatesItem.update
UpdatesUiItem(
modifier = Modifier.animateItemPlacement(),
update = update,
selected = selected.contains(item),
onClick = {
onUpdatesItemClick(
updatesItem = item,
selected = selected,
updates = itemUiModels,
selectedPositions = selectedPositions,
onUpdateClicked = onClickUpdate,
)
},
selected = updatesItem.selected,
onLongClick = {
onUpdatesItemLongClick(
updatesItem = item,
selected = selected,
updates = itemUiModels,
selectedPositions = selectedPositions,
)
onUpdateSelected(updatesItem, !updatesItem.selected, true, true)
},
onClickCover = { if (selected.size == 0) onClickCover(value) },
onClick = {
when {
selectionMode -> onUpdateSelected(updatesItem, !updatesItem.selected, true, false)
else -> onClickUpdate(updatesItem)
}
},
onClickCover = { if (selectionMode.not()) onClickCover(updatesItem) },
onDownloadChapter = {
if (selected.size == 0) onDownloadChapter(listOf(value), it)
if (selectionMode.not()) onDownloadChapter(listOf(updatesItem), it)
},
downloadStateProvider = value.downloadStateProvider,
downloadProgressProvider = value.downloadProgressProvider,
downloadStateProvider = updatesItem.downloadStateProvider,
downloadProgressProvider = updatesItem.downloadProgressProvider,
)
}
}
@ -126,12 +119,16 @@ fun UpdatesUiItem(
downloadStateProvider: () -> Download.State,
downloadProgressProvider: () -> Int,
) {
val haptic = LocalHapticFeedback.current
Row(
modifier = modifier
.background(if (selected) MaterialTheme.colorScheme.surfaceVariant else Color.Transparent)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
onLongClick = {
onLongClick()
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
)
.height(56.dp)
.padding(horizontal = horizontalPadding),
@ -198,73 +195,3 @@ fun UpdatesUiItem(
)
}
}
private fun onUpdatesItemLongClick(
updatesItem: UpdatesUiModel.Item,
selected: MutableList<UpdatesUiModel.Item>,
updates: List<UpdatesUiModel.Item>,
selectedPositions: Array<Int>,
): Boolean {
if (!selected.contains(updatesItem)) {
val selectedIndex = updates.indexOf(updatesItem)
if (selected.isEmpty()) {
selected.add(updatesItem)
selectedPositions[0] = selectedIndex
selectedPositions[1] = selectedIndex
return true
}
// Try to select the items in-between when possible
val range: IntRange
if (selectedIndex < selectedPositions[0]) {
range = selectedIndex until selectedPositions[0]
selectedPositions[0] = selectedIndex
} else if (selectedIndex > selectedPositions[1]) {
range = (selectedPositions[1] + 1)..selectedIndex
selectedPositions[1] = selectedIndex
} else {
// Just select itself
range = selectedIndex..selectedIndex
}
range.forEach {
val toAdd = updates[it]
if (!selected.contains(toAdd)) {
selected.add(toAdd)
}
}
return true
}
return false
}
private fun onUpdatesItemClick(
updatesItem: UpdatesUiModel.Item,
selected: MutableList<UpdatesUiModel.Item>,
updates: List<UpdatesUiModel.Item>,
selectedPositions: Array<Int>,
onUpdateClicked: (UpdatesItem) -> Unit,
) {
val selectedIndex = updates.indexOf(updatesItem)
when {
selected.contains(updatesItem) -> {
val removedIndex = updates.indexOf(updatesItem)
selected.remove(updatesItem)
if (removedIndex == selectedPositions[0]) {
selectedPositions[0] = updates.indexOfFirst { selected.contains(it) }
} else if (removedIndex == selectedPositions[1]) {
selectedPositions[1] = updates.indexOfLast { selected.contains(it) }
}
}
selected.isNotEmpty() -> {
if (selectedIndex < selectedPositions[0]) {
selectedPositions[0] = selectedIndex
} else if (selectedIndex > selectedPositions[1]) {
selectedPositions[1] = selectedIndex
}
selected.add(updatesItem)
}
else -> onUpdateClicked(updatesItem.item)
}
}

View File

@ -1,28 +1,19 @@
package eu.kanade.tachiyomi.ui.recent.updates
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.appcompat.app.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.animation.Crossfade
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.presentation.components.ChapterDownloadAction
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.updates.UpdateScreen
import eu.kanade.tachiyomi.R
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.ui.base.controller.FullComposeController
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
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.widget.materialdialogs.await
import kotlinx.coroutines.launch
/**
@ -36,39 +27,27 @@ class UpdatesController :
@Composable
override fun ComposeContent() {
val state by presenter.state.collectAsState()
when (state) {
is UpdatesState.Loading -> LoadingScreen()
is UpdatesState.Error -> Text(text = (state as UpdatesState.Error).error.message.orEmpty())
is UpdatesState.Success ->
Crossfade(targetState = presenter.isLoading) { isLoading ->
if (isLoading) {
LoadingScreen()
} else {
UpdateScreen(
state = (state as UpdatesState.Success),
onClickCover = this::openManga,
onClickUpdate = this::openChapter,
onDownloadChapter = this::downloadChapters,
onUpdateLibrary = this::updateLibrary,
presenter = presenter,
onClickCover = { item ->
router.pushController(MangaController(item.update.mangaId))
},
onBackClicked = this::onBackClicked,
// For bottom action menu
onMultiBookmarkClicked = { updatesItems, bookmark ->
presenter.bookmarkUpdates(updatesItems, bookmark)
},
onMultiMarkAsReadClicked = { updatesItems, read ->
presenter.markUpdatesRead(updatesItems, read)
},
onMultiDeleteClicked = this::deleteChaptersWithConfirmation,
onDownloadChapter = this::downloadChapters,
)
}
LaunchedEffect(state) {
if (state !is UpdatesState.Loading) {
(activity as? MainActivity)?.ready = true
}
}
}
private fun updateLibrary() {
activity?.let {
if (LibraryUpdateService.start(it)) {
it.toast(R.string.updating_library)
LaunchedEffect(presenter.selectionMode) {
val activity = (activity as? MainActivity) ?: return@LaunchedEffect
activity.showBottomNav(presenter.selectionMode.not())
}
LaunchedEffect(presenter.isLoading) {
if (presenter.isLoading.not()) {
(activity as? MainActivity)?.ready = true
}
}
}
@ -105,26 +84,7 @@ class UpdatesController :
presenter.deleteChapters(items)
}
}
presenter.toggleAllSelection(false)
}
}
private fun deleteChaptersWithConfirmation(items: List<UpdatesItem>) {
if (items.isEmpty()) return
viewScope.launch {
val result = MaterialAlertDialogBuilder(activity!!)
.setMessage(R.string.confirm_delete_chapters)
.await(android.R.string.ok, android.R.string.cancel)
if (result == AlertDialog.BUTTON_POSITIVE) presenter.deleteChapters(items)
}
}
private fun openChapter(item: UpdatesItem) {
val activity = activity ?: return
val intent = ReaderActivity.newIntent(activity, item.update.mangaId, item.update.chapterId)
startActivity(intent)
}
private fun openManga(item: UpdatesItem) {
router.pushController(MangaController(item.update.mangaId))
}
}

View File

@ -2,6 +2,8 @@ package eu.kanade.tachiyomi.ui.recent.updates
import android.os.Bundle
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import eu.kanade.core.util.insertSeparators
import eu.kanade.domain.chapter.interactor.GetChapter
import eu.kanade.domain.chapter.interactor.SetReadStatus
@ -11,6 +13,8 @@ import eu.kanade.domain.chapter.model.toDbChapter
import eu.kanade.domain.manga.interactor.GetManga
import eu.kanade.domain.updates.interactor.GetUpdates
import eu.kanade.domain.updates.model.UpdatesWithRelations
import eu.kanade.presentation.updates.UpdatesState
import eu.kanade.presentation.updates.UpdatesStateImpl
import eu.kanade.presentation.updates.UpdatesUiModel
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download
@ -20,23 +24,22 @@ import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.toDateKey
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.preference.asHotFlow
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.receiveAsFlow
import logcat.LogPriority
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.DateFormat
import java.util.Calendar
import java.util.Date
class UpdatesPresenter(
private val state: UpdatesStateImpl = UpdatesState() as UpdatesStateImpl,
private val updateChapter: UpdateChapter = Injekt.get(),
private val setReadStatus: SetReadStatus = Injekt.get(),
private val getUpdates: GetUpdates = Injekt.get(),
@ -44,29 +47,22 @@ class UpdatesPresenter(
private val sourceManager: SourceManager = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val getChapter: GetChapter = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get(),
) : BasePresenter<UpdatesController>() {
preferences: PreferencesHelper = Injekt.get(),
) : BasePresenter<UpdatesController>(), UpdatesState by state {
private val _state: MutableStateFlow<UpdatesState> = MutableStateFlow(UpdatesState.Loading)
val state: StateFlow<UpdatesState> = _state.asStateFlow()
val isDownloadOnly: Boolean by preferences.downloadedOnly().asState()
/**
* Helper function to update the UI state only if it's currently in success state
*/
private fun updateSuccessState(func: (UpdatesState.Success) -> UpdatesState.Success) {
_state.update { if (it is UpdatesState.Success) func(it) else it }
}
val isIncognitoMode: Boolean by preferences.incognitoMode().asState()
private var incognitoMode = false
set(value) {
updateSuccessState { it.copy(isIncognitoMode = value) }
field = value
}
private var downloadOnlyMode = false
set(value) {
updateSuccessState { it.copy(isDownloadedOnlyMode = value) }
field = value
}
val relativeTime: Int by preferences.relativeTime().asState()
val dateFormat: DateFormat by mutableStateOf(preferences.dateFormat())
private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
val events: Flow<Event> = _events.receiveAsFlow()
// First and last selected index in list
private val selectedPositions: Array<Int> = arrayOf(-1, -1)
/**
* Subscription to observe download status changes.
@ -85,38 +81,17 @@ class UpdatesPresenter(
}
getUpdates.subscribe(calendar)
.catch { exception ->
_state.value = UpdatesState.Error(exception)
.catch {
logcat(LogPriority.ERROR, it)
_events.send(Event.InternalError)
}
.collectLatest { updates ->
val uiModels = updates.toUpdateUiModels()
_state.update { currentState ->
when (currentState) {
is UpdatesState.Success -> currentState.copy(uiModels)
is UpdatesState.Loading, is UpdatesState.Error ->
UpdatesState.Success(
uiModels = uiModels,
isIncognitoMode = incognitoMode,
isDownloadedOnlyMode = downloadOnlyMode,
)
}
}
state.uiModels = updates.toUpdateUiModels()
state.isLoading = false
observeDownloads()
}
}
preferences.incognitoMode()
.asHotFlow { incognito ->
incognitoMode = incognito
}
.launchIn(presenterScope)
preferences.downloadedOnly()
.asHotFlow { downloadedOnly ->
downloadOnlyMode = downloadedOnly
}
.launchIn(presenterScope)
}
private fun List<UpdatesWithRelations>.toUpdateUiModels(): List<UpdatesUiModel> {
@ -182,24 +157,22 @@ class UpdatesPresenter(
* @param download download object containing progress.
*/
private fun updateDownloadState(download: Download) {
updateSuccessState { successState ->
val modifiedIndex = successState.uiModels.indexOfFirst {
it is UpdatesUiModel.Item && it.item.update.chapterId == download.chapter.id
}
if (modifiedIndex < 0) return@updateSuccessState successState
val uiModels = state.uiModels
val modifiedIndex = uiModels.indexOfFirst {
it is UpdatesUiModel.Item && it.item.update.chapterId == download.chapter.id
}
if (modifiedIndex < 0) return
val newUiModels = successState.uiModels.toMutableList().apply {
var uiModel = removeAt(modifiedIndex)
if (uiModel is UpdatesUiModel.Item) {
val item = uiModel.item.copy(
downloadStateProvider = { download.status },
downloadProgressProvider = { download.progress },
)
uiModel = UpdatesUiModel.Item(item)
}
add(modifiedIndex, uiModel)
state.uiModels = uiModels.toMutableList().apply {
var uiModel = removeAt(modifiedIndex)
if (uiModel is UpdatesUiModel.Item) {
val item = uiModel.item.copy(
downloadStateProvider = { download.status },
downloadProgressProvider = { download.progress },
)
uiModel = UpdatesUiModel.Item(item)
}
successState.copy(uiModels = newUiModels)
add(modifiedIndex, uiModel)
}
}
@ -275,42 +248,131 @@ class UpdatesPresenter(
val chapters = updates.mapNotNull { getChapter.await(it.update.chapterId)?.toDbChapter() }
downloadManager.deleteChapters(chapters, manga, source).mapNotNull { it.id }
}
updateSuccessState { successState ->
val deletedUpdates = successState.uiModels.filter {
it is UpdatesUiModel.Item && deletedIds.contains(it.item.update.chapterId)
}
if (deletedUpdates.isEmpty()) return@updateSuccessState successState
// TODO: Don't do this fake status update
val newUiModels = successState.uiModels.toMutableList().apply {
deletedUpdates.forEach { deletedUpdate ->
val modifiedIndex = indexOf(deletedUpdate)
var uiModel = removeAt(modifiedIndex)
if (uiModel is UpdatesUiModel.Item) {
val item = uiModel.item.copy(
downloadStateProvider = { Download.State.NOT_DOWNLOADED },
downloadProgressProvider = { 0 },
)
uiModel = UpdatesUiModel.Item(item)
}
add(modifiedIndex, uiModel)
val uiModels = state.uiModels
val deletedUpdates = uiModels.filter {
it is UpdatesUiModel.Item && deletedIds.contains(it.item.update.chapterId)
}
if (deletedUpdates.isEmpty()) return@launchIO
// TODO: Don't do this fake status update
state.uiModels = uiModels.toMutableList().apply {
deletedUpdates.forEach { deletedUpdate ->
val modifiedIndex = indexOf(deletedUpdate)
var uiModel = removeAt(modifiedIndex)
if (uiModel is UpdatesUiModel.Item) {
val item = uiModel.item.copy(
downloadStateProvider = { Download.State.NOT_DOWNLOADED },
downloadProgressProvider = { 0 },
)
uiModel = UpdatesUiModel.Item(item)
}
add(modifiedIndex, uiModel)
}
successState.copy(uiModels = newUiModels)
}
}
}
}
sealed class UpdatesState {
object Loading : UpdatesState()
data class Error(val error: Throwable) : UpdatesState()
data class Success(
val uiModels: List<UpdatesUiModel>,
val isIncognitoMode: Boolean = false,
val isDownloadedOnlyMode: Boolean = false,
val showSwipeRefreshIndicator: Boolean = false,
) : UpdatesState()
fun toggleSelection(
item: UpdatesItem,
selected: Boolean,
userSelected: Boolean = false,
fromLongPress: Boolean = false,
) {
val uiModels = state.uiModels
val modifiedIndex = uiModels.indexOfFirst {
it is UpdatesUiModel.Item && it.item.update.chapterId == item.update.chapterId
}
if (modifiedIndex < 0) return
val oldItem = (uiModels[modifiedIndex] as? UpdatesUiModel.Item)?.item ?: return
if ((oldItem.selected && selected) || (!oldItem.selected && !selected)) return
state.uiModels = uiModels.toMutableList().apply {
val firstSelection = none { it is UpdatesUiModel.Item && it.item.selected }
var newItem = (removeAt(modifiedIndex) as? UpdatesUiModel.Item)?.item?.copy(selected = selected) ?: return@apply
add(modifiedIndex, UpdatesUiModel.Item(newItem))
if (selected && userSelected && fromLongPress) {
if (firstSelection) {
selectedPositions[0] = modifiedIndex
selectedPositions[1] = modifiedIndex
} else {
// Try to select the items in-between when possible
val range: IntRange
if (modifiedIndex < selectedPositions[0]) {
range = modifiedIndex + 1 until selectedPositions[0]
selectedPositions[0] = modifiedIndex
} else if (modifiedIndex > selectedPositions[1]) {
range = (selectedPositions[1] + 1) until modifiedIndex
selectedPositions[1] = modifiedIndex
} else {
// Just select itself
range = IntRange.EMPTY
}
range.forEach {
var uiModel = removeAt(it)
if (uiModel is UpdatesUiModel.Item) {
newItem = uiModel.item.copy(selected = true)
uiModel = UpdatesUiModel.Item(newItem)
}
add(it, uiModel)
}
}
} else if (userSelected && !fromLongPress) {
if (!selected) {
if (modifiedIndex == selectedPositions[0]) {
selectedPositions[0] = indexOfFirst { it is UpdatesUiModel.Item && it.item.selected }
} else if (modifiedIndex == selectedPositions[1]) {
selectedPositions[1] = indexOfLast { it is UpdatesUiModel.Item && it.item.selected }
}
} else {
if (modifiedIndex < selectedPositions[0]) {
selectedPositions[0] = modifiedIndex
} else if (modifiedIndex > selectedPositions[1]) {
selectedPositions[1] = modifiedIndex
}
}
}
}
}
fun toggleAllSelection(selected: Boolean) {
state.uiModels = state.uiModels.map {
when (it) {
is UpdatesUiModel.Header -> it
is UpdatesUiModel.Item -> {
val newItem = it.item.copy(selected = selected)
UpdatesUiModel.Item(newItem)
}
}
}
selectedPositions[0] = -1
selectedPositions[1] = -1
}
fun invertSelection() {
state.uiModels = state.uiModels.map {
when (it) {
is UpdatesUiModel.Header -> it
is UpdatesUiModel.Item -> {
val newItem = it.item.let { item -> item.copy(selected = !item.selected) }
UpdatesUiModel.Item(newItem)
}
}
}
selectedPositions[0] = -1
selectedPositions[1] = -1
}
sealed class Dialog {
data class DeleteConfirmation(val toDelete: List<UpdatesItem>) : Dialog()
}
sealed class Event {
object InternalError : Event()
}
}
@Immutable
@ -318,4 +380,5 @@ data class UpdatesItem(
val update: UpdatesWithRelations,
val downloadStateProvider: () -> Download.State,
val downloadProgressProvider: () -> Int,
val selected: Boolean = false,
)