Migrate Updates screen to compose (#7534)

* Migrate Updates screen to compose

* Review Changes + Cleanup

Remove more unused stuff and show confirmation dialog when mass deleting chapters

* Review Changes 2 + Rebase
This commit is contained in:
AntsyLich
2022-07-18 08:17:40 +06:00
committed by GitHub
parent bdc5d557d1
commit d8fb6b893f
37 changed files with 1170 additions and 894 deletions

View File

@@ -0,0 +1,315 @@
package eu.kanade.presentation.updates
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FlipToBack
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import eu.kanade.presentation.components.ChapterDownloadAction
import eu.kanade.presentation.components.DownloadedOnlyModeBanner
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.IncognitoModeBanner
import eu.kanade.presentation.components.MangaBottomActionMenu
import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.components.SwipeRefreshIndicator
import eu.kanade.presentation.components.VerticalFastScroller
import eu.kanade.presentation.util.NavBarVisibility
import eu.kanade.presentation.util.isScrollingDown
import eu.kanade.presentation.util.isScrollingUp
import eu.kanade.presentation.util.plus
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.recent.updates.UpdatesItem
import eu.kanade.tachiyomi.ui.recent.updates.UpdatesState
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.DateFormat
import java.util.Date
@Composable
fun UpdateScreen(
state: UpdatesState.Success,
onClickCover: (UpdatesItem) -> Unit,
onClickUpdate: (UpdatesItem) -> Unit,
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
onUpdateLibrary: () -> Unit,
onBackClicked: () -> Unit,
toggleNavBarVisibility: (NavBarVisibility) -> Unit,
// For bottom action menu
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
onMultiMarkAsReadClicked: (List<UpdatesItem>, read: Boolean) -> Unit,
onMultiDeleteClicked: (List<UpdatesItem>) -> Unit,
// Miscellaneous
preferences: PreferencesHelper = Injekt.get(),
) {
val updatesListState = rememberLazyListState()
val insetPaddingValue = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
val relativeTime: Int = remember { preferences.relativeTime().get() }
val dateFormat: DateFormat = remember { preferences.dateFormat() }
val uiModels = remember(state) {
state.uiModels
}
val itemUiModels = remember(uiModels) {
uiModels.filterIsInstance<UpdatesUiModel.Item>()
}
// To prevent selection from getting removed during an update to a item in list
val updateIdList = remember(itemUiModels) {
itemUiModels.map { it.item.update.chapterId }
}
val selected = remember(updateIdList) {
emptyList<UpdatesUiModel.Item>().toMutableStateList()
}
// First and last selected index in list
val selectedPositions = remember(uiModels) { arrayOf(-1, -1) }
when {
selected.isEmpty() &&
updatesListState.isScrollingUp() -> toggleNavBarVisibility(NavBarVisibility.SHOW)
selected.isNotEmpty() ||
updatesListState.isScrollingDown() -> toggleNavBarVisibility(NavBarVisibility.HIDE)
}
val internalOnBackPressed = {
if (selected.isNotEmpty()) {
selected.clear()
} else {
onBackClicked()
}
}
BackHandler(onBack = internalOnBackPressed)
Scaffold(
modifier = Modifier
.padding(insetPaddingValue),
topBar = {
UpdatesAppBar(
selected = selected,
incognitoMode = state.isIncognitoMode,
downloadedOnlyMode = state.isDownloadedOnlyMode,
onUpdateLibrary = onUpdateLibrary,
actionModeCounter = selected.size,
onSelectAll = {
selected.clear()
selected.addAll(itemUiModels)
},
onInvertSelection = {
val toSelect = itemUiModels - selected
selected.clear()
selected.addAll(toSelect)
},
)
},
bottomBar = {
UpdatesBottomBar(
selected = selected,
onDownloadChapter = onDownloadChapter,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
onMultiDeleteClicked = onMultiDeleteClicked,
)
},
) { contentPadding ->
val contentPaddingWithNavBar = contentPadding +
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()
SwipeRefresh(
state = rememberSwipeRefreshState(state.showSwipeRefreshIndicator),
onRefresh = onUpdateLibrary,
indicatorPadding = contentPaddingWithNavBar,
indicator = { s, trigger ->
SwipeRefreshIndicator(
state = s,
refreshTriggerDistance = trigger,
)
},
) {
if (uiModels.isEmpty()) {
EmptyScreen(textResource = R.string.information_no_recent)
} else {
VerticalFastScroller(
listState = updatesListState,
topContentPadding = contentPaddingWithNavBar.calculateTopPadding(),
endContentPadding = contentPaddingWithNavBar.calculateEndPadding(LocalLayoutDirection.current),
) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
state = updatesListState,
contentPadding = contentPaddingWithNavBar,
) {
updatesUiItems(
uiModels = uiModels,
itemUiModels = itemUiModels,
selected = selected,
selectedPositions = selectedPositions,
onClickCover = onClickCover,
onClickUpdate = onClickUpdate,
onDownloadChapter = onDownloadChapter,
relativeTime = relativeTime,
dateFormat = dateFormat,
)
}
}
}
}
}
}
@Composable
fun UpdatesAppBar(
modifier: Modifier = Modifier,
selected: MutableList<UpdatesUiModel.Item>,
incognitoMode: Boolean,
downloadedOnlyMode: Boolean,
onUpdateLibrary: () -> Unit,
// For action mode
actionModeCounter: Int,
onSelectAll: () -> Unit,
onInvertSelection: () -> Unit,
) {
val isActionMode = actionModeCounter > 0
val backgroundColor = if (isActionMode) {
TopAppBarDefaults.centerAlignedTopAppBarColors().containerColor(1f).value
} else {
MaterialTheme.colorScheme.surface
}
Column(
modifier = modifier.drawBehind { drawRect(backgroundColor) },
) {
SmallTopAppBar(
modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)),
navigationIcon = {
if (isActionMode) {
IconButton(onClick = { selected.clear() }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(id = R.string.action_cancel),
)
}
}
},
title = {
Text(
text = if (isActionMode) actionModeCounter.toString() else stringResource(R.string.label_recent_updates),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
actions = {
if (isActionMode) {
IconButton(onClick = onSelectAll) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = stringResource(R.string.action_select_all),
)
}
IconButton(onClick = onInvertSelection) {
Icon(
imageVector = Icons.Default.FlipToBack,
contentDescription = stringResource(R.string.action_select_inverse),
)
}
} else {
IconButton(onClick = onUpdateLibrary) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = stringResource(R.string.action_update_library),
)
}
}
},
// Background handled by parent
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
),
)
if (downloadedOnlyMode) {
DownloadedOnlyModeBanner()
}
if (incognitoMode) {
IncognitoModeBanner()
}
}
}
@Composable
fun UpdatesBottomBar(
selected: MutableList<UpdatesUiModel.Item>,
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
onMultiBookmarkClicked: (List<UpdatesItem>, bookmark: Boolean) -> Unit,
onMultiMarkAsReadClicked: (List<UpdatesItem>, read: Boolean) -> Unit,
onMultiDeleteClicked: (List<UpdatesItem>) -> Unit,
) {
MangaBottomActionMenu(
visible = selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
onBookmarkClicked = {
onMultiBookmarkClicked.invoke(selected.map { it.item }, true)
selected.clear()
}.takeIf { selected.any { !it.item.update.bookmark } },
onRemoveBookmarkClicked = {
onMultiBookmarkClicked.invoke(selected.map { it.item }, false)
selected.clear()
}.takeIf { selected.all { it.item.update.bookmark } },
onMarkAsReadClicked = {
onMultiMarkAsReadClicked(selected.map { it.item }, true)
selected.clear()
}.takeIf { selected.any { !it.item.update.read } },
onMarkAsUnreadClicked = {
onMultiMarkAsReadClicked(selected.map { it.item }, false)
selected.clear()
}.takeIf { selected.any { it.item.update.read } },
onDownloadClicked = {
onDownloadChapter(selected.map { it.item }, ChapterDownloadAction.START)
selected.clear()
}.takeIf {
selected.any { it.item.downloadStateProvider() != Download.State.DOWNLOADED }
},
onDeleteClicked = {
onMultiDeleteClicked(selected.map { it.item })
selected.clear()
}.takeIf { selected.any { it.item.downloadStateProvider() == Download.State.DOWNLOADED } },
)
}
sealed class UpdatesUiModel {
data class Header(val date: Date) : UpdatesUiModel()
data class Item(val item: UpdatesItem) : UpdatesUiModel()
}

View File

@@ -0,0 +1,270 @@
package eu.kanade.presentation.updates
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListScope
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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
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.MangaCover
import eu.kanade.presentation.components.RelativeDateHeader
import eu.kanade.presentation.util.ReadItemAlpha
import eu.kanade.presentation.util.horizontalPadding
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.ui.recent.updates.UpdatesItem
import java.text.DateFormat
fun LazyListScope.updatesUiItems(
uiModels: List<UpdatesUiModel>,
itemUiModels: List<UpdatesUiModel.Item>,
selected: MutableList<UpdatesUiModel.Item>,
selectedPositions: Array<Int>,
onClickCover: (UpdatesItem) -> Unit,
onClickUpdate: (UpdatesItem) -> Unit,
onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit,
relativeTime: Int,
dateFormat: DateFormat,
) {
items(
items = uiModels,
contentType = {
when (it) {
is UpdatesUiModel.Header -> "header"
is UpdatesUiModel.Item -> "item"
}
},
key = {
when (it) {
is UpdatesUiModel.Header -> it.hashCode()
is UpdatesUiModel.Item -> it.item.update.chapterId
}
},
) { item ->
when (item) {
is UpdatesUiModel.Header -> {
RelativeDateHeader(
modifier = Modifier.animateItemPlacement(),
date = item.date,
relativeTime = relativeTime,
dateFormat = dateFormat,
)
}
is UpdatesUiModel.Item -> {
val value = item.item
val update = value.update
UpdatesUiItem(
modifier = Modifier.animateItemPlacement(),
update = update,
selected = selected.contains(item),
onClick = {
onUpdatesItemClick(
updatesItem = item,
selected = selected,
updates = itemUiModels,
selectedPositions = selectedPositions,
onUpdateClicked = onClickUpdate,
)
},
onLongClick = {
onUpdatesItemLongClick(
updatesItem = item,
selected = selected,
updates = itemUiModels,
selectedPositions = selectedPositions,
)
},
onClickCover = { if (selected.size == 0) onClickCover(value) },
onDownloadChapter = {
if (selected.size == 0) onDownloadChapter(listOf(value), it)
},
downloadStateProvider = value.downloadStateProvider,
downloadProgressProvider = value.downloadProgressProvider,
)
}
}
}
}
@Composable
fun UpdatesUiItem(
modifier: Modifier,
update: UpdatesWithRelations,
selected: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
onClickCover: () -> Unit,
onDownloadChapter: (ChapterDownloadAction) -> Unit,
// Download Indicator
downloadStateProvider: () -> Download.State,
downloadProgressProvider: () -> Int,
) {
Row(
modifier = modifier
.background(if (selected) MaterialTheme.colorScheme.surfaceVariant else Color.Transparent)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
)
.height(56.dp)
.padding(horizontal = horizontalPadding),
verticalAlignment = Alignment.CenterVertically,
) {
MangaCover.Square(
modifier = Modifier
.padding(vertical = 6.dp)
.fillMaxHeight(),
data = update.coverData,
onClick = onClickCover,
)
Column(
modifier = Modifier
.padding(horizontal = horizontalPadding)
.weight(1f),
) {
val bookmark = remember(update.bookmark) { update.bookmark }
val read = remember(update.read) { update.read }
val textAlpha = remember(read) { if (read) ReadItemAlpha else 1f }
val secondaryTextColor = if (bookmark && !read) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
}
Text(
text = update.mangaTitle,
maxLines = 1,
style = MaterialTheme.typography.bodyMedium,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.alpha(textAlpha),
)
Row(verticalAlignment = Alignment.CenterVertically) {
var textHeight by remember { mutableStateOf(0) }
if (bookmark) {
Icon(
imageVector = Icons.Default.Bookmark,
contentDescription = stringResource(R.string.action_filter_bookmarked),
modifier = Modifier
.sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(2.dp))
}
Text(
text = update.chapterName,
maxLines = 1,
style = MaterialTheme.typography.bodySmall
.copy(color = secondaryTextColor),
overflow = TextOverflow.Ellipsis,
onTextLayout = { textHeight = it.size.height },
modifier = Modifier.alpha(textAlpha),
)
}
}
ChapterDownloadIndicator(
modifier = Modifier.padding(start = 4.dp),
downloadStateProvider = downloadStateProvider,
downloadProgressProvider = downloadProgressProvider,
onClick = onDownloadChapter,
)
}
}
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)
}
}