Bladeren bron

Use Voyager on Updates tab (#8603)

* Use Voyager on Updates tab

* Fix back press

* Fix selection
Ivan Iskandar 2 jaren geleden
bovenliggende
commit
acc2312384

+ 27 - 0
app/src/main/java/eu/kanade/presentation/components/ListGroupHeader.kt

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

+ 2 - 12
app/src/main/java/eu/kanade/presentation/components/RelativeDateHeader.kt

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

+ 70 - 118
app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt

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

+ 0 - 51
app/src/main/java/eu/kanade/presentation/updates/UpdatesState.kt

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

+ 14 - 24
app/src/main/java/eu/kanade/presentation/updates/UpdatesUiItem.kt

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

+ 3 - 6
app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt

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

+ 4 - 30
app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesController.kt

@@ -1,39 +1,13 @@
 package eu.kanade.tachiyomi.ui.updates
 
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import eu.kanade.presentation.updates.UpdateScreen
-import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
+import cafe.adriel.voyager.navigator.Navigator
+import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
 import eu.kanade.tachiyomi.ui.base.controller.RootController
-import eu.kanade.tachiyomi.ui.base.controller.pushController
-import eu.kanade.tachiyomi.ui.main.MainActivity
-import eu.kanade.tachiyomi.ui.manga.MangaController
-
-class UpdatesController :
-    FullComposeController<UpdatesPresenter>(),
-    RootController {
-
-    override fun createPresenter() = UpdatesPresenter()
 
+class UpdatesController : BasicFullComposeController(), RootController {
     @Composable
     override fun ComposeContent() {
-        UpdateScreen(
-            presenter = presenter,
-            onClickCover = { item ->
-                router.pushController(MangaController(item.update.mangaId))
-            },
-            onBackClicked = {
-                (activity as? MainActivity)?.moveToStartScreen()
-            },
-        )
-
-        LaunchedEffect(presenter.selectionMode) {
-            (activity as? MainActivity)?.showBottomNav(presenter.selectionMode.not())
-        }
-        LaunchedEffect(presenter.isLoading) {
-            if (!presenter.isLoading) {
-                (activity as? MainActivity)?.ready = true
-            }
-        }
+        Navigator(screen = UpdatesScreen)
     }
 }

+ 88 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreen.kt

@@ -0,0 +1,88 @@
+package eu.kanade.tachiyomi.ui.updates
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.LocalContext
+import cafe.adriel.voyager.core.model.rememberScreenModel
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.navigator.currentOrThrow
+import eu.kanade.presentation.updates.UpdateScreen
+import eu.kanade.presentation.updates.UpdatesDeleteConfirmationDialog
+import eu.kanade.presentation.util.LocalRouter
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.ui.base.controller.pushController
+import eu.kanade.tachiyomi.ui.main.MainActivity
+import eu.kanade.tachiyomi.ui.manga.MangaController
+import eu.kanade.tachiyomi.ui.reader.ReaderActivity
+import eu.kanade.tachiyomi.ui.updates.UpdatesScreenModel.Event
+import kotlinx.coroutines.flow.collectLatest
+
+object UpdatesScreen : Screen {
+    @Composable
+    override fun Content() {
+        val context = LocalContext.current
+        val router = LocalRouter.currentOrThrow
+        val screenModel = rememberScreenModel { UpdatesScreenModel() }
+        val state by screenModel.state.collectAsState()
+
+        UpdateScreen(
+            state = state,
+            snackbarHostState = screenModel.snackbarHostState,
+            incognitoMode = screenModel.isIncognitoMode,
+            downloadedOnlyMode = screenModel.isDownloadOnly,
+            lastUpdated = screenModel.lastUpdated,
+            relativeTime = screenModel.relativeTime,
+            onClickCover = { item -> router.pushController(MangaController(item.update.mangaId)) },
+            onSelectAll = screenModel::toggleAllSelection,
+            onInvertSelection = screenModel::invertSelection,
+            onUpdateLibrary = screenModel::updateLibrary,
+            onDownloadChapter = screenModel::downloadChapters,
+            onMultiBookmarkClicked = screenModel::bookmarkUpdates,
+            onMultiMarkAsReadClicked = screenModel::markUpdatesRead,
+            onMultiDeleteClicked = screenModel::showConfirmDeleteChapters,
+            onUpdateSelected = screenModel::toggleSelection,
+            onOpenChapter = {
+                val intent = ReaderActivity.newIntent(context, it.update.mangaId, it.update.chapterId)
+                context.startActivity(intent)
+            },
+        )
+
+        val onDismissDialog = { screenModel.setDialog(null) }
+        when (val dialog = state.dialog) {
+            is UpdatesScreenModel.Dialog.DeleteConfirmation -> {
+                UpdatesDeleteConfirmationDialog(
+                    onDismissRequest = onDismissDialog,
+                    onConfirm = { screenModel.deleteChapters(dialog.toDelete) },
+                )
+            }
+            null -> {}
+        }
+
+        LaunchedEffect(Unit) {
+            screenModel.events.collectLatest { event ->
+                when (event) {
+                    Event.InternalError -> screenModel.snackbarHostState.showSnackbar(context.getString(R.string.internal_error))
+                    is Event.LibraryUpdateTriggered -> {
+                        val msg = if (event.started) {
+                            R.string.updating_library
+                        } else {
+                            R.string.update_already_running
+                        }
+                        screenModel.snackbarHostState.showSnackbar(context.getString(msg))
+                    }
+                }
+            }
+        }
+
+        LaunchedEffect(state.selectionMode) {
+            (context as? MainActivity)?.showBottomNav(!state.selectionMode)
+        }
+        LaunchedEffect(state.isLoading) {
+            if (!state.isLoading) {
+                (context as? MainActivity)?.ready = true
+            }
+        }
+    }
+}

+ 165 - 109
app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesPresenter.kt → app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt

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