Эх сурвалжийг харах

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

* Use Stable interface for Updates screen + Cleanup

Co-Authored-By: Ivan Iskandar <[email protected]>

* Disable swipe refresh in selection mode

* Review Changes

Co-Authored-By: Andreas <[email protected]>

* Review Changes 2

Co-authored-by: Ivan Iskandar <[email protected]>
Co-authored-by: Andreas <[email protected]>
AntsyLich 2 жил өмнө
parent
commit
4774deb1ef

+ 34 - 0
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))
+            }
+        },
+    )
+}

+ 74 - 75
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<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 } },
     )
 }

+ 28 - 0
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<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)
+}

+ 23 - 96
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<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)
+                    },
+                    onClick = {
+                        when {
+                            selectionMode -> onUpdateSelected(updatesItem, !updatesItem.selected, true, false)
+                            else -> onClickUpdate(updatesItem)
+                        }
                     },
-                    onClickCover = { if (selected.size == 0) onClickCover(value) },
+                    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)
-    }
-}

+ 18 - 58
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,
-                    onBackClicked = this::onBackClicked,
-                    // For bottom action menu
-                    onMultiBookmarkClicked = { updatesItems, bookmark ->
-                        presenter.bookmarkUpdates(updatesItems, bookmark)
-                    },
-                    onMultiMarkAsReadClicked = { updatesItems, read ->
-                        presenter.markUpdatesRead(updatesItems, read)
+                    presenter = presenter,
+                    onClickCover = { item ->
+                        router.pushController(MangaController(item.update.mangaId))
                     },
-                    onMultiDeleteClicked = this::deleteChaptersWithConfirmation,
+                    onBackClicked = this::onBackClicked,
+                    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))
-    }
 }

+ 158 - 95
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<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 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)
+        val uiModels = state.uiModels
+        val modifiedIndex = uiModels.indexOfFirst {
+            it is UpdatesUiModel.Item && it.item.update.chapterId == download.chapter.id
+        }
+        if (modifiedIndex < 0) return
+
+        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)
+
+            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)
                 }
-                if (deletedUpdates.isEmpty()) return@updateSuccessState successState
+            }
+        }
+    }
+
+    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
+                    }
 
-                // 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)
+                    range.forEach {
+                        var uiModel = removeAt(it)
                         if (uiModel is UpdatesUiModel.Item) {
-                            val item = uiModel.item.copy(
-                                downloadStateProvider = { Download.State.NOT_DOWNLOADED },
-                                downloadProgressProvider = { 0 },
-                            )
-                            uiModel = UpdatesUiModel.Item(item)
+                            newItem = uiModel.item.copy(selected = true)
+                            uiModel = UpdatesUiModel.Item(newItem)
                         }
-                        add(modifiedIndex, uiModel)
+                        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
                     }
                 }
-                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 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,
 )