From 4774deb1efe023ecff4fd41708dd224edc216c0b Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Sat, 30 Jul 2022 21:50:00 +0600 Subject: [PATCH] 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> --- .../presentation/updates/UpdatesDialog.kt | 34 +++ .../presentation/updates/UpdatesScreen.kt | 149 +++++----- .../presentation/updates/UpdatesState.kt | 28 ++ .../presentation/updates/UpdatesUiItem.kt | 119 ++------ .../ui/recent/updates/UpdatesController.kt | 76 ++---- .../ui/recent/updates/UpdatesPresenter.kt | 257 +++++++++++------- 6 files changed, 337 insertions(+), 326 deletions(-) create mode 100644 app/src/main/java/eu/kanade/presentation/updates/UpdatesDialog.kt create mode 100644 app/src/main/java/eu/kanade/presentation/updates/UpdatesState.kt diff --git a/app/src/main/java/eu/kanade/presentation/updates/UpdatesDialog.kt b/app/src/main/java/eu/kanade/presentation/updates/UpdatesDialog.kt new file mode 100644 index 0000000000..a25d31a1fc --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/updates/UpdatesDialog.kt @@ -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)) + } + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt b/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt index 8b8233876d..477d1063ab 100644 --- a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt @@ -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, ChapterDownloadAction) -> Unit, - onUpdateLibrary: () -> Unit, onBackClicked: () -> Unit, - // For bottom action menu - onMultiBookmarkClicked: (List, bookmark: Boolean) -> Unit, - onMultiMarkAsReadClicked: (List, read: Boolean) -> Unit, - onMultiDeleteClicked: (List) -> Unit, - // Miscellaneous - preferences: PreferencesHelper = Injekt.get(), + onDownloadChapter: (List, 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() - } - // 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().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, 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, + selected: List, onDownloadChapter: (List, ChapterDownloadAction) -> Unit, onMultiBookmarkClicked: (List, bookmark: Boolean) -> Unit, onMultiMarkAsReadClicked: (List, 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 } }, ) } diff --git a/app/src/main/java/eu/kanade/presentation/updates/UpdatesState.kt b/app/src/main/java/eu/kanade/presentation/updates/UpdatesState.kt new file mode 100644 index 0000000000..fb0ff30511 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/updates/UpdatesState.kt @@ -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 + val selected: List + 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 by mutableStateOf(emptyList()) + override val selected: List by derivedStateOf { + uiModels.filterIsInstance() + .filter { it.item.selected } + } + override val selectionMode: Boolean by derivedStateOf { selected.isNotEmpty() } + override var dialog: UpdatesPresenter.Dialog? by mutableStateOf(null) +} diff --git a/app/src/main/java/eu/kanade/presentation/updates/UpdatesUiItem.kt b/app/src/main/java/eu/kanade/presentation/updates/UpdatesUiItem.kt index 0921316a12..7b64f1ed3d 100644 --- a/app/src/main/java/eu/kanade/presentation/updates/UpdatesUiItem.kt +++ b/app/src/main/java/eu/kanade/presentation/updates/UpdatesUiItem.kt @@ -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, - itemUiModels: List, - selected: MutableList, - selectedPositions: Array, + selectionMode: Boolean, + onUpdateSelected: (UpdatesItem, Boolean, Boolean, Boolean) -> Unit, onClickCover: (UpdatesItem) -> Unit, onClickUpdate: (UpdatesItem) -> Unit, onDownloadChapter: (List, 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, - updates: List, - selectedPositions: Array, -): 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, - updates: List, - selectedPositions: Array, - 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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt index 17d8ee3a28..96b6ab40a9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt @@ -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) { - 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)) - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesPresenter.kt index 6e34daeb5f..9a3da5a171 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesPresenter.kt @@ -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() { + preferences: PreferencesHelper = Injekt.get(), +) : BasePresenter(), UpdatesState by state { - private val _state: MutableStateFlow = MutableStateFlow(UpdatesState.Loading) - val state: StateFlow = _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 = Channel(Int.MAX_VALUE) + val events: Flow = _events.receiveAsFlow() + + // First and last selected index in list + private val selectedPositions: Array = 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.toUpdateUiModels(): List { @@ -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, - 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) : 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, )