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

* Use Stable interface for Updates screen + Cleanup

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

* Disable swipe refresh in selection mode

* Review Changes

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

* Review Changes 2

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

View File

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

View File

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

View File

@ -0,0 +1,28 @@
package eu.kanade.presentation.updates
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.tachiyomi.ui.recent.updates.UpdatesPresenter
@Stable
interface UpdatesState {
val isLoading: Boolean
val uiModels: List<UpdatesUiModel>
val selected: List<UpdatesUiModel.Item>
val selectionMode: Boolean
var dialog: UpdatesPresenter.Dialog?
}
fun UpdatesState(): UpdatesState = UpdatesStateImpl()
class UpdatesStateImpl : UpdatesState {
override var isLoading: Boolean by mutableStateOf(true)
override var uiModels: List<UpdatesUiModel> by mutableStateOf(emptyList())
override val selected: List<UpdatesUiModel.Item> by derivedStateOf {
uiModels.filterIsInstance<UpdatesUiModel.Item>()
.filter { it.item.selected }
}
override val selectionMode: Boolean by derivedStateOf { selected.isNotEmpty() }
override var dialog: UpdatesPresenter.Dialog? by mutableStateOf(null)
}

View File

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

View File

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

View File

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