diff --git a/app/src/main/java/eu/kanade/presentation/components/ListGroupHeader.kt b/app/src/main/java/eu/kanade/presentation/components/ListGroupHeader.kt new file mode 100644 index 0000000000..dd3b7f973c --- /dev/null +++ b/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, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/components/RelativeDateHeader.kt b/app/src/main/java/eu/kanade/presentation/components/RelativeDateHeader.kt index 261906f1b1..0b525e59c7 100644 --- a/app/src/main/java/eu/kanade/presentation/components/RelativeDateHeader.kt +++ b/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, - ), ) } 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 7a8148a26d..b10dadd6d7 100644 --- a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt +++ b/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, ChapterDownloadAction) -> Unit, + onMultiBookmarkClicked: (List, bookmark: Boolean) -> Unit, + onMultiMarkAsReadClicked: (List, read: Boolean) -> Unit, + onMultiDeleteClicked: (List) -> Unit, + onUpdateSelected: (UpdatesItem, Boolean, Boolean, Boolean) -> Unit, + onOpenChapter: (UpdatesItem) -> Unit, ) { - val internalOnBackPressed = { - if (presenter.selectionMode) { - presenter.toggleAllSelection(false) - } else { - onBackClicked() - } - } - BackHandler(onBack = internalOnBackPressed) + BackHandler(enabled = state.selectionMode, onBack = { onSelectAll(false) }) val context = LocalContext.current - val onUpdateLibrary = { - val started = LibraryUpdateService.start(context) - context.toast(if (started) R.string.updating_library else R.string.update_already_running) - started - } Scaffold( topBar = { scrollBehavior -> UpdatesAppBar( - incognitoMode = presenter.isIncognitoMode, - downloadedOnlyMode = presenter.isDownloadOnly, + incognitoMode = incognitoMode, + downloadedOnlyMode = downloadedOnlyMode, onUpdateLibrary = { onUpdateLibrary() }, - actionModeCounter = presenter.selected.size, - onSelectAll = { presenter.toggleAllSelection(true) }, - onInvertSelection = { presenter.invertSelection() }, - onCancelActionMode = { presenter.toggleAllSelection(false) }, + actionModeCounter = state.selected.size, + onSelectAll = { onSelectAll(true) }, + onInvertSelection = { onInvertSelection() }, + onCancelActionMode = { onSelectAll(false) }, scrollBehavior = scrollBehavior, ) }, bottomBar = { UpdatesBottomBar( - selected = presenter.selected, - onDownloadChapter = presenter::downloadChapters, - onMultiBookmarkClicked = presenter::bookmarkUpdates, - onMultiMarkAsReadClicked = presenter::markUpdatesRead, - onMultiDeleteClicked = { - presenter.dialog = Dialog.DeleteConfirmation(it) - }, + selected = state.selected, + onDownloadChapter = onDownloadChapter, + onMultiBookmarkClicked = onMultiBookmarkClicked, + onMultiMarkAsReadClicked = onMultiMarkAsReadClicked, + onMultiDeleteClicked = onMultiDeleteClicked, ) }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + contentWindowInsets = TachiyomiBottomNavigationView.withBottomNavInset(ScaffoldDefaults.contentWindowInsets), ) { contentPadding -> - val contentPaddingWithNavBar = TachiyomiBottomNavigationView.withBottomNavPadding(contentPadding) when { - presenter.isLoading -> LoadingScreen() - presenter.uiModels.isEmpty() -> EmptyScreen( + state.isLoading -> LoadingScreen(modifier = Modifier.padding(contentPadding)) + state.items.isEmpty() -> EmptyScreen( textResource = R.string.information_no_recent, - modifier = Modifier.padding(contentPaddingWithNavBar), + modifier = Modifier.padding(contentPadding), ) else -> { - UpdateScreenContent( - presenter = presenter, - contentPadding = contentPaddingWithNavBar, - onUpdateLibrary = onUpdateLibrary, - onClickCover = onClickCover, - ) - } - } - } -} + val scope = rememberCoroutineScope() + var isRefreshing by remember { mutableStateOf(false) } -@Composable -private fun UpdateScreenContent( - presenter: UpdatesPresenter, - contentPadding: PaddingValues, - onUpdateLibrary: () -> Boolean, - onClickCover: (UpdatesItem) -> Unit, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - var isRefreshing by remember { mutableStateOf(false) } + SwipeRefresh( + refreshing = isRefreshing, + onRefresh = { + val started = onUpdateLibrary() + if (!started) return@SwipeRefresh + scope.launch { + // Fake refresh status but hide it after a second as it's a long running task + isRefreshing = true + delay(1.seconds) + isRefreshing = false + } + }, + enabled = !state.selectionMode, + indicatorPadding = contentPadding, + ) { + FastScrollLazyColumn( + contentPadding = contentPadding, + ) { + if (lastUpdated > 0L) { + updatesLastUpdatedItem(lastUpdated) + } - SwipeRefresh( - refreshing = isRefreshing, - onRefresh = { - val started = onUpdateLibrary() - if (!started) return@SwipeRefresh - scope.launch { - // Fake refresh status but hide it after a second as it's a long running task - isRefreshing = true - delay(1.seconds) - isRefreshing = false - } - }, - enabled = presenter.selectionMode.not(), - indicatorPadding = contentPadding, - ) { - FastScrollLazyColumn( - contentPadding = contentPadding, - ) { - if (presenter.lastUpdated > 0L) { - updatesLastUpdatedItem(presenter.lastUpdated) - } - - updatesUiItems( - uiModels = presenter.uiModels, - selectionMode = presenter.selectionMode, - onUpdateSelected = presenter::toggleSelection, - onClickCover = onClickCover, - onClickUpdate = { - val intent = ReaderActivity.newIntent(context, it.update.mangaId, it.update.chapterId) - context.startActivity(intent) - }, - onDownloadChapter = presenter::downloadChapters, - relativeTime = presenter.relativeTime, - dateFormat = presenter.dateFormat, - ) - } - } - - val onDismissDialog = { presenter.dialog = null } - when (val dialog = presenter.dialog) { - is Dialog.DeleteConfirmation -> { - UpdatesDeleteConfirmationDialog( - onDismissRequest = onDismissDialog, - onConfirm = { - presenter.toggleAllSelection(false) - presenter.deleteChapters(dialog.toDelete) - }, - ) - } - null -> {} - } - LaunchedEffect(Unit) { - presenter.events.collectLatest { event -> - when (event) { - Event.InternalError -> context.toast(R.string.internal_error) + updatesUiItems( + uiModels = state.getUiModel(context, relativeTime), + selectionMode = state.selectionMode, + onUpdateSelected = onUpdateSelected, + onClickCover = onClickCover, + onClickUpdate = onOpenChapter, + onDownloadChapter = onDownloadChapter, + ) + } + } } } } @@ -265,6 +217,6 @@ private fun UpdatesBottomBar( } sealed class UpdatesUiModel { - data class Header(val date: Date) : UpdatesUiModel() + data class Header(val date: String) : UpdatesUiModel() data class Item(val item: UpdatesItem) : UpdatesUiModel() } diff --git a/app/src/main/java/eu/kanade/presentation/updates/UpdatesState.kt b/app/src/main/java/eu/kanade/presentation/updates/UpdatesState.kt deleted file mode 100644 index 4e1a9bdd94..0000000000 --- a/app/src/main/java/eu/kanade/presentation/updates/UpdatesState.kt +++ /dev/null @@ -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 - val selected: List - val selectionMode: Boolean - val uiModels: List - var dialog: UpdatesPresenter.Dialog? -} -fun UpdatesState(): UpdatesState = UpdatesStateImpl() -class UpdatesStateImpl : UpdatesState { - override var isLoading: Boolean by mutableStateOf(true) - override var items: List by mutableStateOf(emptyList()) - override val selected: List by derivedStateOf { - items.filter { it.selected } - } - override val selectionMode: Boolean by derivedStateOf { selected.isNotEmpty() } - override val uiModels: List by derivedStateOf { - items.toUpdateUiModel() - } - override var dialog: UpdatesPresenter.Dialog? by mutableStateOf(null) -} - -fun List.toUpdateUiModel(): List { - 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 - } - } -} 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 410b05542d..7f7dee5016 100644 --- a/app/src/main/java/eu/kanade/presentation/updates/UpdatesUiItem.kt +++ b/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, 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) }, ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 9770df95fe..df4ec45703 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/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() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesController.kt index 780351b0d0..e26ed191c7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesController.kt +++ b/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(), - 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) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreen.kt new file mode 100644 index 0000000000..304d3df663 --- /dev/null +++ b/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 + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt similarity index 57% rename from app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesPresenter.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesScreenModel.kt index c0488bb45d..a6ea7aeef6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesPresenter.kt +++ b/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(), UpdatesState by state { - - val isDownloadOnly: Boolean by basePreferences.downloadedOnly().asState() - val isIncognitoMode: Boolean by basePreferences.incognitoMode().asState() - - val lastUpdated by libraryPreferences.libraryUpdateLastTimestamp().asState() - - val relativeTime: Int by uiPreferences.relativeTime().asState() - val dateFormat: DateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get())) +) : StateScreenModel(UpdatesState()) { private val _events: Channel = Channel(Int.MAX_VALUE) val events: Flow = _events.receiveAsFlow() + val isDownloadOnly: Boolean by basePreferences.downloadedOnly().asState(coroutineScope) + val isIncognitoMode: Boolean by basePreferences.incognitoMode().asState(coroutineScope) + + val lastUpdated by libraryPreferences.libraryUpdateLastTimestamp().asState(coroutineScope) + + val relativeTime: Int by uiPreferences.relativeTime().asState(coroutineScope) + val dateFormat: DateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get())) + // First and last selected index in list private val selectedPositions: Array = arrayOf(-1, -1) private val selectedChapterIds: HashSet = 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()) + coroutineScope.launch { + _events.send(Event.LibraryUpdateTriggered(started)) + } + return started + } + /** * Update status of chapters. * * @param download download object containing progress. */ private fun updateDownloadState(download: Download) { - state.items = items.toMutableList().apply { - val modifiedIndex = indexOfFirst { - it.update.chapterId == download.chapter.id - } - if (modifiedIndex < 0) return@apply + mutableState.update { state -> + val newItems = state.items.toMutableList().apply { + val modifiedIndex = indexOfFirst { it.update.chapterId == download.chapter.id } + if (modifiedIndex < 0) return@apply - val item = get(modifiedIndex) - set( - modifiedIndex, - item.copy( - downloadStateProvider = { download.status }, - downloadProgressProvider = { download.progress }, - ), - ) + val item = get(modifiedIndex) + set( + modifiedIndex, + item.copy( + downloadStateProvider = { download.status }, + downloadProgressProvider = { download.progress }, + ), + ) + } + state.copy(items = newItems) } } fun downloadChapters(items: List, 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()) } } ChapterDownloadAction.START_NOW -> { @@ -209,7 +211,7 @@ class UpdatesPresenter( * @param read whether to mark chapters as read or unread. */ fun markUpdatesRead(updates: List, 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, 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) { - presenterScope.launchNonCancellable { + private fun downloadChapters(updatesItem: List) { + 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) { - 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) { + setDialog(Dialog.DeleteConfirmation(updatesItem)) } fun toggleSelection( @@ -275,85 +284,132 @@ class UpdatesPresenter( userSelected: Boolean = false, fromLongPress: Boolean = false, ) { - state.items = items.toMutableList().apply { - val selectedIndex = indexOfFirst { it.update.chapterId == item.update.chapterId } - if (selectedIndex < 0) return@apply + mutableState.update { state -> + val newItems = state.items.toMutableList().apply { + val selectedIndex = indexOfFirst { it.update.chapterId == item.update.chapterId } + if (selectedIndex < 0) return@apply - val selectedItem = get(selectedIndex) - if (selectedItem.selected == selected) return@apply + val selectedItem = get(selectedIndex) + if (selectedItem.selected == selected) return@apply - val firstSelection = none { it.selected } - set(selectedIndex, selectedItem.copy(selected = selected)) - selectedChapterIds.addOrRemove(item.update.chapterId, selected) + val firstSelection = none { it.selected } + set(selectedIndex, selectedItem.copy(selected = selected)) + selectedChapterIds.addOrRemove(item.update.chapterId, selected) - if (selected && userSelected && fromLongPress) { - if (firstSelection) { - selectedPositions[0] = selectedIndex - selectedPositions[1] = selectedIndex - } else { - // Try to select the items in-between when possible - val range: IntRange - if (selectedIndex < selectedPositions[0]) { - range = selectedIndex + 1 until selectedPositions[0] + if (selected && userSelected && fromLongPress) { + if (firstSelection) { selectedPositions[0] = selectedIndex - } else if (selectedIndex > selectedPositions[1]) { - range = (selectedPositions[1] + 1) until selectedIndex selectedPositions[1] = selectedIndex } else { - // Just select itself - range = IntRange.EMPTY - } + // Try to select the items in-between when possible + val range: IntRange + if (selectedIndex < selectedPositions[0]) { + range = selectedIndex + 1 until selectedPositions[0] + selectedPositions[0] = selectedIndex + } else if (selectedIndex > selectedPositions[1]) { + range = (selectedPositions[1] + 1) until selectedIndex + selectedPositions[1] = selectedIndex + } else { + // Just select itself + range = IntRange.EMPTY + } - range.forEach { - val inbetweenItem = get(it) - if (!inbetweenItem.selected) { - selectedChapterIds.add(inbetweenItem.update.chapterId) - set(it, inbetweenItem.copy(selected = true)) + range.forEach { + val inbetweenItem = get(it) + if (!inbetweenItem.selected) { + selectedChapterIds.add(inbetweenItem.update.chapterId) + set(it, inbetweenItem.copy(selected = true)) + } + } + } + } else if (userSelected && !fromLongPress) { + if (!selected) { + if (selectedIndex == selectedPositions[0]) { + selectedPositions[0] = indexOfFirst { it.selected } + } else if (selectedIndex == selectedPositions[1]) { + selectedPositions[1] = indexOfLast { it.selected } + } + } else { + if (selectedIndex < selectedPositions[0]) { + selectedPositions[0] = selectedIndex + } else if (selectedIndex > selectedPositions[1]) { + selectedPositions[1] = selectedIndex } } } - } else if (userSelected && !fromLongPress) { - if (!selected) { - if (selectedIndex == selectedPositions[0]) { - selectedPositions[0] = indexOfFirst { it.selected } - } else if (selectedIndex == selectedPositions[1]) { - selectedPositions[1] = indexOfLast { it.selected } - } - } else { - if (selectedIndex < selectedPositions[0]) { - selectedPositions[0] = selectedIndex - } else if (selectedIndex > selectedPositions[1]) { - selectedPositions[1] = selectedIndex - } - } } + state.copy(items = newItems) } } fun toggleAllSelection(selected: Boolean) { - state.items = items.map { - selectedChapterIds.addOrRemove(it.update.chapterId, selected) - it.copy(selected = selected) + mutableState.update { state -> + val newItems = state.items.map { + selectedChapterIds.addOrRemove(it.update.chapterId, selected) + it.copy(selected = selected) + } + state.copy(items = newItems) } + selectedPositions[0] = -1 selectedPositions[1] = -1 } fun invertSelection() { - state.items = items.map { - selectedChapterIds.addOrRemove(it.update.chapterId, !it.selected) - it.copy(selected = !it.selected) + mutableState.update { state -> + val newItems = state.items.map { + selectedChapterIds.addOrRemove(it.update.chapterId, !it.selected) + it.copy(selected = !it.selected) + } + state.copy(items = newItems) } selectedPositions[0] = -1 selectedPositions[1] = -1 } + fun setDialog(dialog: Dialog?) { + mutableState.update { it.copy(dialog = dialog) } + } + sealed class Dialog { data class DeleteConfirmation(val toDelete: List) : 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 = emptyList(), + val dialog: UpdatesScreenModel.Dialog? = null, +) { + val selected = items.filter { it.selected } + val selectionMode = selected.isNotEmpty() + + fun getUiModel(context: Context, relativeTime: Int): List { + val dateFormat = UiPreferences.dateFormat(Injekt.get().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 + } + } } }