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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1170 additions and 894 deletions

View file

@ -0,0 +1,16 @@
package eu.kanade.core.util
fun <T : R, R : Any> List<T>.insertSeparators(
generator: (T?, T?) -> R?,
): List<R> {
if (isEmpty()) return emptyList()
val newList = mutableListOf<R>()
for (i in -1..lastIndex) {
val before = getOrNull(i)
before?.let { newList.add(it) }
val after = getOrNull(i + 1)
val separator = generator.invoke(before, after)
separator?.let { newList.add(it) }
}
return newList
}

View file

@ -0,0 +1,26 @@
package eu.kanade.data.updates
import eu.kanade.domain.manga.model.MangaCover
import eu.kanade.domain.updates.model.UpdatesWithRelations
val updateWithRelationMapper: (Long, String, Long, String, String?, Boolean, Boolean, Long, Boolean, String?, Long, Long, Long) -> UpdatesWithRelations = {
mangaId, mangaTitle, chapterId, chapterName, scanlator, read, bookmark, sourceId, favorite, thumbnailUrl, coverLastModified, _, dateFetch ->
UpdatesWithRelations(
mangaId = mangaId,
mangaTitle = mangaTitle,
chapterId = chapterId,
chapterName = chapterName,
scanlator = scanlator,
read = read,
bookmark = bookmark,
sourceId = sourceId,
dateFetch = dateFetch,
coverData = MangaCover(
mangaId = mangaId,
sourceId = sourceId,
isMangaFavorite = favorite,
url = thumbnailUrl,
lastModified = coverLastModified,
),
)
}

View file

@ -0,0 +1,17 @@
package eu.kanade.data.updates
import eu.kanade.data.DatabaseHandler
import eu.kanade.domain.updates.model.UpdatesWithRelations
import eu.kanade.domain.updates.repository.UpdatesRepository
import kotlinx.coroutines.flow.Flow
class UpdatesRepositoryImpl(
val databaseHandler: DatabaseHandler,
) : UpdatesRepository {
override fun subscribeAll(after: Long): Flow<List<UpdatesWithRelations>> {
return databaseHandler.subscribeToList {
updatesViewQueries.updates(after, updateWithRelationMapper)
}
}
}

View file

@ -7,6 +7,7 @@ import eu.kanade.data.manga.MangaRepositoryImpl
import eu.kanade.data.source.SourceDataRepositoryImpl
import eu.kanade.data.source.SourceRepositoryImpl
import eu.kanade.data.track.TrackRepositoryImpl
import eu.kanade.data.updates.UpdatesRepositoryImpl
import eu.kanade.domain.category.interactor.CreateCategoryWithName
import eu.kanade.domain.category.interactor.DeleteCategory
import eu.kanade.domain.category.interactor.GetCategories
@ -60,6 +61,8 @@ import eu.kanade.domain.track.interactor.DeleteTrack
import eu.kanade.domain.track.interactor.GetTracks
import eu.kanade.domain.track.interactor.InsertTrack
import eu.kanade.domain.track.repository.TrackRepository
import eu.kanade.domain.updates.interactor.GetUpdates
import eu.kanade.domain.updates.repository.UpdatesRepository
import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addFactory
@ -119,6 +122,9 @@ class DomainModule : InjektModule {
addFactory { GetExtensionUpdates(get(), get()) }
addFactory { GetExtensionLanguages(get(), get()) }
addSingletonFactory<UpdatesRepository> { UpdatesRepositoryImpl(get()) }
addFactory { GetUpdates(get(), get()) }
addSingletonFactory<SourceRepository> { SourceRepositoryImpl(get(), get()) }
addSingletonFactory<SourceDataRepository> { SourceDataRepositoryImpl(get()) }
addFactory { GetEnabledSources(get(), get()) }

View file

@ -0,0 +1,24 @@
package eu.kanade.domain.updates.interactor
import eu.kanade.domain.updates.model.UpdatesWithRelations
import eu.kanade.domain.updates.repository.UpdatesRepository
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onEach
import java.util.Calendar
class GetUpdates(
private val repository: UpdatesRepository,
private val preferences: PreferencesHelper,
) {
fun subscribe(calendar: Calendar): Flow<List<UpdatesWithRelations>> = subscribe(calendar.time.time)
fun subscribe(after: Long): Flow<List<UpdatesWithRelations>> {
return repository.subscribeAll(after)
.onEach { updates ->
// Set unread chapter count for bottom bar badge
preferences.unreadUpdatesCount().set(updates.count { it.read.not() })
}
}
}

View file

@ -0,0 +1,16 @@
package eu.kanade.domain.updates.model
import eu.kanade.domain.manga.model.MangaCover
data class UpdatesWithRelations(
val mangaId: Long,
val mangaTitle: String,
val chapterId: Long,
val chapterName: String,
val scanlator: String?,
val read: Boolean,
val bookmark: Boolean,
val sourceId: Long,
val dateFetch: Long,
val coverData: MangaCover,
)

View file

@ -0,0 +1,9 @@
package eu.kanade.domain.updates.repository
import eu.kanade.domain.updates.model.UpdatesWithRelations
import kotlinx.coroutines.flow.Flow
interface UpdatesRepository {
fun subscribeAll(after: Long): Flow<List<UpdatesWithRelations>>
}

View file

@ -0,0 +1,41 @@
package eu.kanade.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
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.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.R
@Composable
fun DownloadedOnlyModeBanner() {
Text(
text = stringResource(R.string.label_downloaded_only),
modifier = Modifier
.background(color = MaterialTheme.colorScheme.tertiary)
.fillMaxWidth()
.padding(4.dp),
color = MaterialTheme.colorScheme.onTertiary,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
)
}
@Composable
fun IncognitoModeBanner() {
Text(
text = stringResource(R.string.pref_incognito_mode),
modifier = Modifier
.background(color = MaterialTheme.colorScheme.primary)
.fillMaxWidth()
.padding(4.dp),
color = MaterialTheme.colorScheme.onPrimary,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
)
}

View file

@ -27,11 +27,17 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.manga.ChapterDownloadAction
import eu.kanade.presentation.util.secondaryItemAlpha
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download
enum class ChapterDownloadAction {
START,
START_NOW,
CANCEL,
DELETE,
}
@Composable
fun ChapterDownloadIndicator(
modifier: Modifier = Modifier,

View file

@ -1,4 +1,4 @@
package eu.kanade.presentation.manga.components
package eu.kanade.presentation.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
@ -51,13 +51,13 @@ import kotlinx.coroutines.launch
fun MangaBottomActionMenu(
visible: Boolean,
modifier: Modifier = Modifier,
onBookmarkClicked: (() -> Unit)?,
onRemoveBookmarkClicked: (() -> Unit)?,
onMarkAsReadClicked: (() -> Unit)?,
onMarkAsUnreadClicked: (() -> Unit)?,
onMarkPreviousAsReadClicked: (() -> Unit)?,
onDownloadClicked: (() -> Unit)?,
onDeleteClicked: (() -> Unit)?,
onBookmarkClicked: (() -> Unit)? = null,
onRemoveBookmarkClicked: (() -> Unit)? = null,
onMarkAsReadClicked: (() -> Unit)? = null,
onMarkAsUnreadClicked: (() -> Unit)? = null,
onMarkPreviousAsReadClicked: (() -> Unit)? = null,
onDownloadClicked: (() -> Unit)? = null,
onDeleteClicked: (() -> Unit)? = null,
) {
AnimatedVisibility(
visible = visible,

View file

@ -1,4 +1,4 @@
package eu.kanade.presentation.history.components
package eu.kanade.presentation.components
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
@ -15,7 +15,7 @@ import java.text.DateFormat
import java.util.Date
@Composable
fun HistoryHeader(
fun RelativeDateHeader(
modifier: Modifier = Modifier,
date: Date,
relativeTime: Int,

View file

@ -39,8 +39,8 @@ import androidx.paging.compose.items
import eu.kanade.domain.history.model.HistoryWithRelations
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.components.RelativeDateHeader
import eu.kanade.presentation.components.ScrollbarLazyColumn
import eu.kanade.presentation.history.components.HistoryHeader
import eu.kanade.presentation.history.components.HistoryItem
import eu.kanade.presentation.history.components.HistoryItemShimmer
import eu.kanade.presentation.util.plus
@ -108,7 +108,7 @@ fun HistoryContent(
items(history) { item ->
when (item) {
is HistoryUiModel.Header -> {
HistoryHeader(
RelativeDateHeader(
modifier = Modifier
.animateItemPlacement(),
date = item.date,

View file

@ -52,15 +52,16 @@ import androidx.compose.ui.unit.dp
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import eu.kanade.domain.chapter.model.Chapter
import eu.kanade.presentation.components.ChapterDownloadAction
import eu.kanade.presentation.components.ExtendedFloatingActionButton
import eu.kanade.presentation.components.LazyColumn
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.manga.components.ChapterHeader
import eu.kanade.presentation.manga.components.ExpandableMangaDescription
import eu.kanade.presentation.manga.components.MangaActionRow
import eu.kanade.presentation.manga.components.MangaBottomActionMenu
import eu.kanade.presentation.manga.components.MangaChapterListItem
import eu.kanade.presentation.manga.components.MangaInfoBox
import eu.kanade.presentation.manga.components.MangaSmallAppBar

View file

@ -9,13 +9,6 @@ enum class DownloadAction {
ALL_CHAPTERS
}
enum class ChapterDownloadAction {
START,
START_NOW,
CANCEL,
DELETE,
}
enum class EditCoverAction {
EDIT,
DELETE,

View file

@ -29,8 +29,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import eu.kanade.presentation.components.ChapterDownloadAction
import eu.kanade.presentation.components.ChapterDownloadIndicator
import eu.kanade.presentation.manga.ChapterDownloadAction
import eu.kanade.presentation.util.ReadItemAlpha
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download
@ -134,5 +135,3 @@ fun MangaChapterListItem(
}
}
}
private const val ReadItemAlpha = .38f

View file

@ -1,13 +1,10 @@
package eu.kanade.presentation.manga.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
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.material.icons.Icons
@ -21,7 +18,6 @@ import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.DropdownMenuItem
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
@ -34,10 +30,10 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.components.DownloadedOnlyModeBanner
import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.presentation.components.IncognitoModeBanner
import eu.kanade.presentation.manga.DownloadAction
import eu.kanade.tachiyomi.R
@ -210,28 +206,10 @@ fun MangaSmallAppBar(
)
if (downloadedOnlyMode) {
Text(
text = stringResource(R.string.label_downloaded_only),
modifier = Modifier
.background(color = MaterialTheme.colorScheme.tertiary)
.fillMaxWidth()
.padding(4.dp),
color = MaterialTheme.colorScheme.onTertiary,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
)
DownloadedOnlyModeBanner()
}
if (incognitoMode) {
Text(
text = stringResource(R.string.pref_incognito_mode),
modifier = Modifier
.background(color = MaterialTheme.colorScheme.primary)
.fillMaxWidth()
.padding(4.dp),
color = MaterialTheme.colorScheme.onPrimary,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
)
IncognitoModeBanner()
}
}
}

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)
}
}

View file

@ -12,3 +12,5 @@ val horizontalPadding = horizontal
val verticalPadding = vertical
val topPaddingValues = PaddingValues(top = vertical)
const val ReadItemAlpha = .38f

View file

@ -27,3 +27,21 @@ fun LazyListState.isScrollingUp(): Boolean {
}
}.value
}
@Composable
fun LazyListState.isScrollingDown(): Boolean {
var previousIndex by remember { mutableStateOf(firstVisibleItemIndex) }
var previousScrollOffset by remember { mutableStateOf(firstVisibleItemScrollOffset) }
return remember {
derivedStateOf {
if (previousIndex != firstVisibleItemIndex) {
previousIndex < firstVisibleItemIndex
} else {
previousScrollOffset <= firstVisibleItemScrollOffset
}.also {
previousIndex = firstVisibleItemIndex
previousScrollOffset = firstVisibleItemScrollOffset
}
}
}.value
}

View file

@ -0,0 +1,13 @@
package eu.kanade.presentation.util
enum class NavBarVisibility {
SHOW,
HIDE
}
fun NavBarVisibility.toBoolean(): Boolean {
return when (this) {
NavBarVisibility.SHOW -> true
NavBarVisibility.HIDE -> false
}
}

View file

@ -226,7 +226,7 @@ class MainActivity : BaseActivity() {
if (!router.hasRootController()) {
// Set start screen
if (!handleIntentAction(intent)) {
setSelectedNavItem(startScreenId)
moveToStartScreen()
}
}
syncActivityViewWithController()
@ -483,10 +483,15 @@ class MainActivity : BaseActivity() {
}
override fun onBackPressed() {
// Updates screen has custom back handler
if (router.getControllerWithTag("${R.id.nav_updates}") != null) {
router.handleBack()
return
}
val backstackSize = router.backstackSize
if (backstackSize == 1 && router.getControllerWithTag("$startScreenId") == null) {
// Return to start screen
setSelectedNavItem(startScreenId)
moveToStartScreen()
} else if (shouldHandleExitConfirmation()) {
// Exit confirmation (resets after 2 seconds)
lifecycleScope.launchUI { resetExitConfirmation() }
@ -499,6 +504,10 @@ class MainActivity : BaseActivity() {
}
}
fun moveToStartScreen() {
setSelectedNavItem(startScreenId)
}
override fun onSupportActionModeStarted(mode: ActionMode) {
binding.appbar.apply {
tag = isTransparentWhenNotLifted

View file

@ -27,7 +27,7 @@ import eu.kanade.data.chapter.NoChaptersException
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.toDbManga
import eu.kanade.presentation.manga.ChapterDownloadAction
import eu.kanade.presentation.components.ChapterDownloadAction
import eu.kanade.presentation.manga.DownloadAction
import eu.kanade.presentation.manga.MangaScreen
import eu.kanade.presentation.util.calculateWindowWidthSizeClass

View file

@ -7,8 +7,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.AbstractComposeView
import eu.kanade.presentation.components.ChapterDownloadAction
import eu.kanade.presentation.components.ChapterDownloadIndicator
import eu.kanade.presentation.manga.ChapterDownloadAction
import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.tachiyomi.data.download.model.Download

View file

@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.ui.manga.chapter.base
import android.view.View
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.presentation.manga.ChapterDownloadAction
import eu.kanade.presentation.components.ChapterDownloadAction
open class BaseChapterHolder(
view: View,

View file

@ -1,53 +0,0 @@
package eu.kanade.tachiyomi.ui.recent
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractHeaderItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.SectionHeaderItemBinding
import eu.kanade.tachiyomi.util.lang.toRelativeString
import java.text.DateFormat
import java.util.Date
class DateSectionItem(
private val date: Date,
private val range: Int,
private val dateFormat: DateFormat,
) : AbstractHeaderItem<DateSectionItem.DateSectionItemHolder>() {
override fun getLayoutRes(): Int {
return R.layout.section_header_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): DateSectionItemHolder {
return DateSectionItemHolder(view, adapter)
}
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>, holder: DateSectionItemHolder, position: Int, payloads: List<Any?>?) {
holder.bind(this)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is DateSectionItem) {
return date == other.date
}
return false
}
override fun hashCode(): Int {
return date.hashCode()
}
inner class DateSectionItemHolder(private val view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter, true) {
private val binding = SectionHeaderItemBinding.bind(view)
fun bind(item: DateSectionItem) {
binding.title.text = item.date.toRelativeString(view.context, range, dateFormat)
}
}
}

View file

@ -1,33 +0,0 @@
package eu.kanade.tachiyomi.ui.recent.updates
import android.app.Dialog
import android.os.Bundle
import com.bluelinelabs.conductor.Controller
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.DialogController
class ConfirmDeleteChaptersDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
where T : Controller, T : ConfirmDeleteChaptersDialog.Listener {
private var chaptersToDelete = emptyList<UpdatesItem>()
constructor(target: T, chaptersToDelete: List<UpdatesItem>) : this() {
this.chaptersToDelete = chaptersToDelete
targetController = target
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(activity!!)
.setMessage(R.string.confirm_delete_chapters)
.setPositiveButton(android.R.string.ok) { _, _ ->
(targetController as? Listener)?.deleteChapters(chaptersToDelete)
}
.setNegativeButton(android.R.string.cancel, null)
.create()
}
interface Listener {
fun deleteChapters(chaptersToDelete: List<UpdatesItem>)
}
}

View file

@ -1,29 +0,0 @@
package eu.kanade.tachiyomi.ui.recent.updates
import android.content.Context
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChaptersAdapter
import eu.kanade.tachiyomi.util.system.getResourceColor
class UpdatesAdapter(
val controller: UpdatesController,
context: Context,
val items: List<IFlexible<*>>?,
) : BaseChaptersAdapter<IFlexible<*>>(controller, items) {
var readColor = context.getResourceColor(R.attr.colorOnSurface, 0.38f)
var unreadColor = context.getResourceColor(R.attr.colorOnSurface)
val unreadColorSecondary = context.getResourceColor(android.R.attr.textColorSecondary)
var bookmarkedColor = context.getResourceColor(R.attr.colorAccent)
val coverClickListener: OnCoverClickListener = controller
init {
setDisplayHeadersAtStartUp(true)
}
interface OnCoverClickListener {
fun onCoverClick(position: Int)
}
}

View file

@ -1,149 +1,65 @@
package eu.kanade.tachiyomi.ui.recent.updates
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.view.ActionMode
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import dev.chrisbanes.insetter.applyInsetter
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.SelectableAdapter
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.appcompat.app.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import eu.kanade.presentation.components.ChapterDownloadAction
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.updates.UpdateScreen
import eu.kanade.presentation.util.NavBarVisibility
import eu.kanade.presentation.util.toBoolean
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.databinding.UpdatesControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
import eu.kanade.tachiyomi.ui.base.controller.RootController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChaptersAdapter
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.notificationManager
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.util.view.onAnimationsFinished
import eu.kanade.tachiyomi.widget.ActionModeWithToolbar
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import eu.kanade.tachiyomi.widget.materialdialogs.await
import kotlinx.coroutines.launch
import logcat.LogPriority
import reactivecircus.flowbinding.recyclerview.scrollStateChanges
import reactivecircus.flowbinding.swiperefreshlayout.refreshes
/**
* Fragment that shows recent chapters.
*/
class UpdatesController :
NucleusController<UpdatesControllerBinding, UpdatesPresenter>(),
RootController,
ActionModeWithToolbar.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
FlexibleAdapter.OnUpdateListener,
BaseChaptersAdapter.OnChapterClickListener,
ConfirmDeleteChaptersDialog.Listener,
UpdatesAdapter.OnCoverClickListener {
FullComposeController<UpdatesPresenter>(),
RootController {
/**
* Action mode for multiple selection.
*/
private var actionMode: ActionModeWithToolbar? = null
override fun createPresenter() = UpdatesPresenter()
/**
* Adapter containing the recent chapters.
*/
var adapter: UpdatesAdapter? = null
private set
init {
setHasOptionsMenu(true)
}
override fun getTitle(): String? {
return resources?.getString(R.string.label_recent_updates)
}
override fun createPresenter(): UpdatesPresenter {
return UpdatesPresenter()
}
override fun createBinding(inflater: LayoutInflater) = UpdatesControllerBinding.inflate(inflater)
override fun onViewCreated(view: View) {
super.onViewCreated(view)
binding.recycler.applyInsetter {
type(navigationBars = true) {
padding()
}
@Composable
override fun ComposeContent() {
val state by presenter.state.collectAsState()
when (state) {
is UpdatesState.Loading -> LoadingScreen()
is UpdatesState.Error -> Text(text = (state as UpdatesState.Error).error.message.orEmpty())
is UpdatesState.Success ->
UpdateScreen(
state = (state as UpdatesState.Success),
onClickCover = this::openManga,
onClickUpdate = this::openChapter,
onDownloadChapter = this::downloadChapters,
onUpdateLibrary = this::updateLibrary,
onBackClicked = this::onBackClicked,
toggleNavBarVisibility = this::toggleNavBarVisibility,
// For bottom action menu
onMultiBookmarkClicked = { updatesItems, bookmark ->
presenter.bookmarkUpdates(updatesItems, bookmark)
},
onMultiMarkAsReadClicked = { updatesItems, read ->
presenter.markUpdatesRead(updatesItems, read)
},
onMultiDeleteClicked = this::deleteChaptersWithConfirmation,
)
}
view.context.notificationManager.cancel(Notifications.ID_NEW_CHAPTERS)
// Init RecyclerView and adapter
val layoutManager = LinearLayoutManager(view.context)
binding.recycler.layoutManager = layoutManager
binding.recycler.setHasFixedSize(true)
binding.recycler.scrollStateChanges()
.onEach {
// Disable swipe refresh when view is not at the top
val firstPos = layoutManager.findFirstCompletelyVisibleItemPosition()
binding.swipeRefresh.isEnabled = firstPos <= 0
}
.launchIn(viewScope)
binding.swipeRefresh.isRefreshing = true
binding.swipeRefresh.setDistanceToTriggerSync((2 * 64 * view.resources.displayMetrics.density).toInt())
binding.swipeRefresh.refreshes()
.onEach {
updateLibrary()
// It can be a very long operation, so we disable swipe refresh and show a toast.
binding.swipeRefresh.isRefreshing = false
}
.launchIn(viewScope)
viewScope.launch {
presenter.updates.collectLatest { updatesItems ->
destroyActionModeIfNeeded()
if (adapter == null) {
adapter = UpdatesAdapter(this@UpdatesController, binding.recycler.context, updatesItems)
binding.recycler.adapter = adapter
adapter!!.fastScroller = binding.fastScroller
} else {
adapter?.updateDataSet(updatesItems)
}
binding.swipeRefresh.isRefreshing = false
binding.fastScroller.isVisible = true
binding.recycler.onAnimationsFinished {
(activity as? MainActivity)?.ready = true
}
}
}
}
override fun onDestroyView(view: View) {
destroyActionModeIfNeeded()
adapter = null
super.onDestroyView(view)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.updates, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_update_library -> updateLibrary()
}
return super.onOptionsItemSelected(item)
}
private fun updateLibrary() {
@ -154,262 +70,67 @@ class UpdatesController :
}
}
/**
* Returns selected chapters
* @return list of selected chapters
*/
private fun getSelectedChapters(): List<UpdatesItem> {
val adapter = adapter ?: return emptyList()
return adapter.selectedPositions.mapNotNull { adapter.getItem(it) as? UpdatesItem }
// Let compose view handle this
override fun handleBack(): Boolean {
(activity as? OnBackPressedDispatcherOwner)?.onBackPressedDispatcher?.onBackPressed()
return true
}
/**
* Called when item in list is clicked
* @param position position of clicked item
*/
override fun onItemClick(view: View, position: Int): Boolean {
val adapter = adapter ?: return false
// Get item from position
val item = adapter.getItem(position) as? UpdatesItem ?: return false
return if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) {
toggleSelection(position)
true
} else {
openChapter(item)
false
}
private fun onBackClicked() {
(activity as? MainActivity)?.moveToStartScreen()
}
/**
* Called when item in list is long clicked
* @param position position of clicked item
*/
override fun onItemLongClick(position: Int) {
val activity = activity
if (actionMode == null && activity is MainActivity) {
actionMode = activity.startActionModeAndToolbar(this)
activity.showBottomNav(false)
}
toggleSelection(position)
}
/**
* Called to toggle selection
* @param position position of selected item
*/
private fun toggleSelection(position: Int) {
val adapter = adapter ?: return
adapter.toggleSelection(position)
actionMode?.invalidate()
}
/**
* Open chapter in reader
* @param chapter selected chapter
*/
private fun openChapter(item: UpdatesItem) {
val activity = activity ?: return
val intent = ReaderActivity.newIntent(activity, item.manga.id, item.chapter.id)
startActivity(intent)
private fun toggleNavBarVisibility(navBarVisibility: NavBarVisibility) {
val showNavBar = navBarVisibility.toBoolean()
(activity as? MainActivity)?.showBottomNav(showNavBar)
}
/**
* Download selected items
* @param chapters list of selected [UpdatesItem]s
* @param items list of selected [UpdatesItem]s
*/
private fun downloadChapters(chapters: List<UpdatesItem>) {
presenter.downloadChapters(chapters)
destroyActionModeIfNeeded()
}
override fun onUpdateEmptyView(size: Int) {
if (size > 0) {
binding.emptyView.hide()
} else {
binding.emptyView.show(R.string.information_no_recent)
}
}
/**
* Update download status of chapter
* @param download [Download] object containing download progress.
*/
fun onChapterDownloadUpdate(download: Download) {
adapter?.currentItems
?.filterIsInstance<UpdatesItem>()
?.find { it.chapter.id == download.chapter.id }?.let {
adapter?.updateItem(it, it.status)
private fun downloadChapters(items: List<UpdatesItem>, action: ChapterDownloadAction) {
if (items.isEmpty()) return
viewScope.launch {
when (action) {
ChapterDownloadAction.START -> {
presenter.downloadChapters(items)
if (items.any { it.downloadStateProvider() == Download.State.ERROR }) {
DownloadService.start(activity!!)
}
}
ChapterDownloadAction.START_NOW -> {
val chapterId = items.singleOrNull()?.update?.chapterId ?: return@launch
presenter.startDownloadingNow(chapterId)
}
ChapterDownloadAction.CANCEL -> {
val chapterId = items.singleOrNull()?.update?.chapterId ?: return@launch
presenter.cancelDownload(chapterId)
}
ChapterDownloadAction.DELETE -> {
presenter.deleteChapters(items)
}
}
}
/**
* Mark chapter as read
* @param chapters list of chapters
*/
private fun markAsRead(chapters: List<UpdatesItem>) {
presenter.markChapterRead(chapters, true)
destroyActionModeIfNeeded()
}
/**
* Mark chapter as unread
* @param chapters list of selected [UpdatesItem]
*/
private fun markAsUnread(chapters: List<UpdatesItem>) {
presenter.markChapterRead(chapters, false)
destroyActionModeIfNeeded()
}
override fun deleteChapters(chaptersToDelete: List<UpdatesItem>) {
presenter.deleteChapters(chaptersToDelete)
destroyActionModeIfNeeded()
}
private fun destroyActionModeIfNeeded() {
actionMode?.finish()
}
override fun onCoverClick(position: Int) {
destroyActionModeIfNeeded()
val chapterClicked = adapter?.getItem(position) as? UpdatesItem ?: return
openManga(chapterClicked)
}
private fun openManga(chapter: UpdatesItem) {
router.pushController(MangaController(chapter.manga.id!!))
}
/**
* Called when chapters are deleted
*/
fun onChaptersDeleted() {
adapter?.notifyDataSetChanged()
}
/**
* Called when error while deleting
* @param error error message
*/
fun onChaptersDeletedError(error: Throwable) {
logcat(LogPriority.ERROR, error)
}
override fun downloadChapter(position: Int) {
val item = adapter?.getItem(position) as? UpdatesItem ?: return
if (item.status == Download.State.ERROR) {
DownloadService.start(activity!!)
} else {
downloadChapters(listOf(item))
}
adapter?.updateItem(item)
}
override fun deleteChapter(position: Int) {
val item = adapter?.getItem(position) as? UpdatesItem ?: return
deleteChapters(listOf(item))
adapter?.updateItem(item)
}
override fun startDownloadNow(position: Int) {
val item = adapter?.getItem(position) as? UpdatesItem ?: return
presenter.startDownloadingNow(item.chapter)
}
private fun bookmarkChapters(chapters: List<UpdatesItem>, bookmarked: Boolean) {
presenter.bookmarkChapters(chapters, bookmarked)
destroyActionModeIfNeeded()
}
/**
* Called when ActionMode created.
* @param mode the ActionMode object
* @param menu menu object of ActionMode
*/
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.generic_selection, menu)
adapter?.mode = SelectableAdapter.Mode.MULTI
return true
}
override fun onCreateActionToolbar(menuInflater: MenuInflater, menu: Menu) {
menuInflater.inflate(R.menu.updates_chapter_selection, menu)
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val count = adapter?.selectedItemCount ?: 0
if (count == 0) {
// Destroy action mode if there are no items selected.
destroyActionModeIfNeeded()
} else {
mode.title = count.toString()
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)
}
return true
}
override fun onPrepareActionToolbar(toolbar: ActionModeWithToolbar, menu: Menu) {
val chapters = getSelectedChapters()
if (chapters.isEmpty()) return
toolbar.findToolbarItem(R.id.action_download)?.isVisible = chapters.any { !it.isDownloaded }
toolbar.findToolbarItem(R.id.action_delete)?.isVisible = chapters.any { it.isDownloaded }
toolbar.findToolbarItem(R.id.action_bookmark)?.isVisible = chapters.any { !it.chapter.bookmark }
toolbar.findToolbarItem(R.id.action_remove_bookmark)?.isVisible = chapters.all { it.chapter.bookmark }
toolbar.findToolbarItem(R.id.action_mark_as_read)?.isVisible = chapters.any { !it.chapter.read }
toolbar.findToolbarItem(R.id.action_mark_as_unread)?.isVisible = chapters.all { it.chapter.read }
private fun openChapter(item: UpdatesItem) {
val activity = activity ?: return
val intent = ReaderActivity.newIntent(activity, item.update.mangaId, item.update.chapterId)
startActivity(intent)
}
/**
* Called when ActionMode item clicked
* @param mode the ActionMode object
* @param item item from ActionMode.
*/
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return onActionItemClicked(item)
}
private fun onActionItemClicked(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_select_all -> selectAll()
R.id.action_select_inverse -> selectInverse()
R.id.action_download -> downloadChapters(getSelectedChapters())
R.id.action_delete ->
ConfirmDeleteChaptersDialog(this, getSelectedChapters())
.showDialog(router)
R.id.action_bookmark -> bookmarkChapters(getSelectedChapters(), true)
R.id.action_remove_bookmark -> bookmarkChapters(getSelectedChapters(), false)
R.id.action_mark_as_read -> markAsRead(getSelectedChapters())
R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters())
else -> return false
}
return true
}
/**
* Called when ActionMode destroyed
* @param mode the ActionMode object
*/
override fun onDestroyActionMode(mode: ActionMode) {
adapter?.mode = SelectableAdapter.Mode.IDLE
adapter?.clearSelection()
(activity as? MainActivity)?.showBottomNav(true)
actionMode = null
}
private fun selectAll() {
val adapter = adapter ?: return
adapter.selectAll()
actionMode?.invalidate()
}
private fun selectInverse() {
val adapter = adapter ?: return
for (i in 0..adapter.itemCount) {
adapter.toggleSelection(i)
}
actionMode?.invalidate()
adapter.notifyDataSetChanged()
private fun openManga(item: UpdatesItem) {
router.pushController(MangaController(item.update.mangaId))
}
}

View file

@ -1,62 +0,0 @@
package eu.kanade.tachiyomi.ui.recent.updates
import android.view.View
import androidx.core.view.isVisible
import coil.dispose
import coil.load
import eu.kanade.tachiyomi.databinding.UpdatesItemBinding
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChapterHolder
/**
* Holder that contains chapter item
* UI related actions should be called from here.
*
* @param view the inflated view for this holder.
* @param adapter the adapter handling this holder.
* @param listener a listener to react to single tap and long tap events.
* @constructor creates a new recent chapter holder.
*/
class UpdatesHolder(private val view: View, private val adapter: UpdatesAdapter) :
BaseChapterHolder(view, adapter) {
private val binding = UpdatesItemBinding.bind(view)
init {
binding.mangaCover.setOnClickListener {
adapter.coverClickListener.onCoverClick(bindingAdapterPosition)
}
binding.download.listener = downloadActionListener
}
fun bind(item: UpdatesItem) {
// Set chapter title
binding.chapterTitle.text = item.chapter.name
// Set manga title
binding.mangaTitle.text = item.manga.title
// Check if chapter is read and/or bookmarked and set correct color
if (item.chapter.read) {
binding.chapterTitle.setTextColor(adapter.readColor)
binding.mangaTitle.setTextColor(adapter.readColor)
} else {
binding.mangaTitle.setTextColor(adapter.unreadColor)
binding.chapterTitle.setTextColor(
if (item.chapter.bookmark) adapter.bookmarkedColor else adapter.unreadColorSecondary,
)
}
// Set bookmark status
binding.bookmarkIcon.isVisible = item.chapter.bookmark
// Set chapter status
binding.download.isVisible = item.manga.source != LocalSource.ID
binding.download.setState(item.status, item.progress)
// Set cover
binding.mangaCover.dispose()
binding.mangaCover.load(item.manga)
}
}

View file

@ -1,32 +0,0 @@
package eu.kanade.tachiyomi.ui.recent.updates
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.domain.chapter.model.Chapter
import eu.kanade.domain.manga.model.Manga
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChapterItem
import eu.kanade.tachiyomi.ui.recent.DateSectionItem
class UpdatesItem(chapter: Chapter, val manga: Manga, header: DateSectionItem) :
BaseChapterItem<UpdatesHolder, DateSectionItem>(chapter, header) {
override fun getLayoutRes(): Int {
return R.layout.updates_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): UpdatesHolder {
return UpdatesHolder(view, adapter as UpdatesAdapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: UpdatesHolder,
position: Int,
payloads: List<Any?>?,
) {
holder.bind(this)
}
}

View file

@ -1,233 +1,321 @@
package eu.kanade.tachiyomi.ui.recent.updates
import android.os.Bundle
import eu.kanade.data.DatabaseHandler
import eu.kanade.data.manga.mangaChapterMapper
import androidx.compose.runtime.Immutable
import eu.kanade.core.util.insertSeparators
import eu.kanade.domain.chapter.interactor.GetChapter
import eu.kanade.domain.chapter.interactor.SetReadStatus
import eu.kanade.domain.chapter.interactor.UpdateChapter
import eu.kanade.domain.chapter.model.Chapter
import eu.kanade.domain.chapter.model.ChapterUpdate
import eu.kanade.domain.chapter.model.toDbChapter
import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.interactor.GetManga
import eu.kanade.domain.updates.interactor.GetUpdates
import eu.kanade.domain.updates.model.UpdatesWithRelations
import eu.kanade.presentation.updates.UpdatesUiModel
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.recent.DateSectionItem
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.toDateKey
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.preference.asHotFlow
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.update
import logcat.LogPriority
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.DateFormat
import java.util.Calendar
import java.util.Date
import java.util.TreeMap
class UpdatesPresenter(
private val preferences: PreferencesHelper = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
private val handler: DatabaseHandler = Injekt.get(),
private val updateChapter: UpdateChapter = Injekt.get(),
private val setReadStatus: SetReadStatus = Injekt.get(),
private val getUpdates: GetUpdates = Injekt.get(),
private val getManga: GetManga = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val getChapter: GetChapter = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get(),
) : BasePresenter<UpdatesController>() {
private val relativeTime: Int = preferences.relativeTime().get()
private val dateFormat: DateFormat = preferences.dateFormat()
private val _state: MutableStateFlow<UpdatesState> = MutableStateFlow(UpdatesState.Loading)
val state: StateFlow<UpdatesState> = _state.asStateFlow()
private val _updates: MutableStateFlow<List<UpdatesItem>> = MutableStateFlow(listOf())
val updates: StateFlow<List<UpdatesItem>> = _updates.asStateFlow()
/**
* 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
set(value) {
updateSuccessState { it.copy(isIncognitoMode = value) }
field = value
}
private var downloadOnlyMode = false
set(value) {
updateSuccessState { it.copy(isDownloadedOnlyMode = value) }
field = value
}
/**
* Subscription to observe download status changes.
*/
private var observeDownloadsStatusJob: Job? = null
private var observeDownloadsPageJob: Job? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
presenterScope.launchIO {
subscribeToUpdates()
// Set date limit for recent chapters
val calendar = Calendar.getInstance().apply {
time = Date()
add(Calendar.MONTH, -3)
}
getUpdates.subscribe(calendar)
.catch { exception ->
_state.value = UpdatesState.Error(exception)
}
.collectLatest { updates ->
val uiModels = updates.toUpdateUiModels()
_state.update { currentState ->
when (currentState) {
is UpdatesState.Success -> currentState.copy(uiModels)
is UpdatesState.Loading, is UpdatesState.Error ->
UpdatesState.Success(
uiModels = uiModels,
isIncognitoMode = incognitoMode,
isDownloadedOnlyMode = downloadOnlyMode,
)
}
}
observeDownloads()
}
}
preferences.incognitoMode()
.asHotFlow { incognito ->
incognitoMode = incognito
}
.launchIn(presenterScope)
preferences.downloadedOnly()
.asHotFlow { downloadedOnly ->
downloadOnlyMode = downloadedOnly
}
.launchIn(presenterScope)
}
private fun List<UpdatesWithRelations>.toUpdateUiModels(): List<UpdatesUiModel> {
return this.map { update ->
val activeDownload = downloadManager.queue.find { update.chapterId == it.chapter.id }
val downloaded = downloadManager.isChapterDownloaded(
update.chapterName,
update.scanlator,
update.mangaTitle,
update.sourceId,
)
val downloadState = when {
activeDownload != null -> activeDownload.status
downloaded -> Download.State.DOWNLOADED
else -> Download.State.NOT_DOWNLOADED
}
val item = UpdatesItem(
update = update,
downloadStateProvider = { downloadState },
downloadProgressProvider = { activeDownload?.progress ?: 0 },
)
UpdatesUiModel.Item(item)
}
.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
}
}
}
private suspend fun observeDownloads() {
observeDownloadsStatusJob?.cancel()
observeDownloadsStatusJob = presenterScope.launchIO {
downloadManager.queue.getStatusAsFlow()
.catch { error -> logcat(LogPriority.ERROR, error) }
.collectLatest {
withUIContext {
onDownloadStatusChange(it)
view?.onChapterDownloadUpdate(it)
updateDownloadState(it)
}
}
}
observeDownloadsPageJob?.cancel()
observeDownloadsPageJob = presenterScope.launchIO {
downloadManager.queue.getProgressAsFlow()
.catch { error -> logcat(LogPriority.ERROR, error) }
.collectLatest {
withUIContext {
view?.onChapterDownloadUpdate(it)
updateDownloadState(it)
}
}
}
}
/**
* Get observable containing recent chapters and date
*/
private suspend fun subscribeToUpdates() {
// Set date limit for recent chapters
val cal = Calendar.getInstance().apply {
time = Date()
add(Calendar.MONTH, -3)
}
handler
.subscribeToList {
mangasQueries.getRecentlyUpdated(after = cal.timeInMillis, mangaChapterMapper)
}
.map { mangaChapter ->
val map = TreeMap<Date, MutableList<Pair<Manga, Chapter>>> { d1, d2 -> d2.compareTo(d1) }
val byDate = mangaChapter.groupByTo(map) { it.second.dateFetch.toDateKey() }
byDate.flatMap { entry ->
val dateItem = DateSectionItem(entry.key, relativeTime, dateFormat)
entry.value
.sortedWith(compareBy({ it.second.dateFetch }, { it.second.chapterNumber })).asReversed()
.map { UpdatesItem(it.second, it.first, dateItem) }
}
}
.collectLatest { list ->
list.forEach { item ->
// Find an active download for this chapter.
val download = downloadManager.queue.find { it.chapter.id == item.chapter.id }
// If there's an active download, assign it, otherwise ask the manager if
// the chapter is downloaded and assign it to the status.
if (download != null) {
item.download = download
}
}
setDownloadedChapters(list)
_updates.value = list
// Set unread chapter count for bottom bar badge
preferences.unreadUpdatesCount().set(list.count { !it.chapter.read })
}
}
/**
* Finds and assigns the list of downloaded chapters.
*
* @param items the list of chapter from the database.
*/
private fun setDownloadedChapters(items: List<UpdatesItem>) {
for (item in items) {
val manga = item.manga
val chapter = item.chapter
if (downloadManager.isChapterDownloaded(chapter.name, chapter.scanlator, manga.title, manga.source)) {
item.status = Download.State.DOWNLOADED
}
}
}
/**
* Update status of chapters.
*
* @param download download object containing progress.
*/
private fun onDownloadStatusChange(download: Download) {
// Assign the download to the model object.
if (download.status == Download.State.QUEUE) {
val chapters = (view?.adapter?.currentItems ?: emptyList()).filterIsInstance<UpdatesItem>()
val chapter = chapters.find { it.chapter.id == download.chapter.id }
if (chapter != null && chapter.download == null) {
chapter.download = download
private fun updateDownloadState(download: Download) {
updateSuccessState { successState ->
val modifiedIndex = successState.uiModels.indexOfFirst {
it is UpdatesUiModel.Item && it.item.update.chapterId == download.chapter.id
}
if (modifiedIndex < 0) return@updateSuccessState successState
val newUiModels = successState.uiModels.toMutableList().apply {
var uiModel = removeAt(modifiedIndex)
if (uiModel is UpdatesUiModel.Item) {
val item = uiModel.item.copy(
downloadStateProvider = { download.status },
downloadProgressProvider = { download.progress },
)
uiModel = UpdatesUiModel.Item(item)
}
add(modifiedIndex, uiModel)
}
successState.copy(uiModels = newUiModels)
}
}
fun startDownloadingNow(chapter: Chapter) {
downloadManager.startDownloadNow(chapter.id)
fun startDownloadingNow(chapterId: Long) {
downloadManager.startDownloadNow(chapterId)
}
fun cancelDownload(chapterId: Long) {
val activeDownload = downloadManager.queue.find { chapterId == it.chapter.id } ?: return
downloadManager.deletePendingDownload(activeDownload)
updateDownloadState(activeDownload.apply { status = Download.State.NOT_DOWNLOADED })
}
/**
* Mark selected chapter as read
*
* @param items list of selected chapters
* @param read read status
* Mark the selected updates list as read/unread.
* @param updates the list of selected updates.
* @param read whether to mark chapters as read or unread.
*/
fun markChapterRead(items: List<UpdatesItem>, read: Boolean) {
fun markUpdatesRead(updates: List<UpdatesItem>, read: Boolean) {
presenterScope.launchIO {
setReadStatus.await(
read = read,
values = items
.map { it.chapter }
values = updates
.mapNotNull { getChapter.await(it.update.chapterId) }
.toTypedArray(),
)
}
}
/**
* Delete selected chapters
*
* @param chapters list of chapters
* Bookmarks the given list of chapters.
* @param updates the list of chapters to bookmark.
*/
fun deleteChapters(chapters: List<UpdatesItem>) {
launchIO {
try {
deleteChaptersInternal(chapters)
withUIContext { view?.onChaptersDeleted() }
} catch (e: Throwable) {
withUIContext { view?.onChaptersDeletedError(e) }
}
}
}
/**
* Mark selected chapters as bookmarked
* @param items list of selected chapters
* @param bookmarked bookmark status
*/
fun bookmarkChapters(items: List<UpdatesItem>, bookmarked: Boolean) {
fun bookmarkUpdates(updates: List<UpdatesItem>, bookmark: Boolean) {
presenterScope.launchIO {
val toUpdate = items.map {
ChapterUpdate(
bookmark = bookmarked,
id = it.chapter.id,
)
}
updateChapter.awaitAll(toUpdate)
updates
.filterNot { it.update.bookmark == bookmark }
.map { ChapterUpdate(id = it.update.chapterId, bookmark = bookmark) }
.let { updateChapter.awaitAll(it) }
}
}
/**
* Download selected chapters
* @param items list of recent chapters seleted.
* Downloads the given list of chapters with the manager.
* @param updatesItem the list of chapters to download.
*/
fun downloadChapters(items: List<UpdatesItem>) {
items.forEach { downloadManager.downloadChapters(it.manga, listOf(it.chapter.toDbChapter())) }
fun downloadChapters(updatesItem: List<UpdatesItem>) {
launchIO {
val groupedUpdates = updatesItem.groupBy { it.update.mangaId }.values
for (updates in groupedUpdates) {
val mangaId = updates.first().update.mangaId
val manga = getManga.await(mangaId) ?: continue
// Don't download if source isn't available
sourceManager.get(manga.source) ?: continue
val chapters = updates.mapNotNull { getChapter.await(it.update.chapterId)?.toDbChapter() }
downloadManager.downloadChapters(manga, chapters)
}
}
}
/**
* Delete selected chapters
*
* @param items chapters selected
* @param updatesItem list of chapters
*/
private fun deleteChaptersInternal(chapterItems: List<UpdatesItem>) {
val itemsByManga = chapterItems.groupBy { it.manga.id }
for ((_, items) in itemsByManga) {
val manga = items.first().manga
val source = sourceManager.get(manga.source) ?: continue
val chapters = items.map { it.chapter.toDbChapter() }
fun deleteChapters(updatesItem: List<UpdatesItem>) {
launchIO {
val groupedUpdates = updatesItem.groupBy { it.update.mangaId }.values
val deletedIds = groupedUpdates.flatMap { updates ->
val mangaId = updates.first().update.mangaId
val manga = getManga.await(mangaId) ?: return@flatMap emptyList()
val source = sourceManager.get(manga.source) ?: return@flatMap emptyList()
val chapters = updates.mapNotNull { getChapter.await(it.update.chapterId)?.toDbChapter() }
downloadManager.deleteChapters(chapters, manga, source).mapNotNull { it.id }
}
updateSuccessState { successState ->
val deletedUpdates = successState.uiModels.filter {
it is UpdatesUiModel.Item && deletedIds.contains(it.item.update.chapterId)
}
if (deletedUpdates.isEmpty()) return@updateSuccessState successState
downloadManager.deleteChapters(chapters, manga, source)
items.forEach {
it.status = Download.State.NOT_DOWNLOADED
it.download = null
// TODO: Don't do this fake status update
val newUiModels = successState.uiModels.toMutableList().apply {
deletedUpdates.forEach { deletedUpdate ->
val modifiedIndex = indexOf(deletedUpdate)
var uiModel = removeAt(modifiedIndex)
if (uiModel is UpdatesUiModel.Item) {
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 {
object Loading : UpdatesState()
data class Error(val error: Throwable) : UpdatesState()
data class Success(
val uiModels: List<UpdatesUiModel>,
val isIncognitoMode: Boolean = false,
val isDownloadedOnlyMode: Boolean = false,
val showSwipeRefreshIndicator: Boolean = false,
) : UpdatesState()
}
@Immutable
data class UpdatesItem(
val update: UpdatesWithRelations,
val downloadStateProvider: () -> Download.State,
val downloadProgressProvider: () -> Int,
)

View file

@ -1,40 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="4dp"
android:paddingBottom="@dimen/action_toolbar_list_padding"
tools:listitem="@layout/updates_item" />
<eu.kanade.tachiyomi.widget.MaterialFastScroll
android:id="@+id/fast_scroller"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="end"
android:visibility="gone"
app:fastScrollerBubbleEnabled="false"
tools:visibility="visible" />
<eu.kanade.tachiyomi.widget.EmptyView
android:id="@+id/empty_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>
</eu.kanade.tachiyomi.widget.ThemedSwipeRefreshLayout>

View file

@ -1,78 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="@drawable/list_item_selector_background"
android:paddingStart="16dp"
android:paddingEnd="4dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/manga_cover"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="h,1:1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearance="@style/ShapeAppearanceOverlay.Cover"
tools:src="@mipmap/ic_launcher" />
<TextView
android:id="@+id/manga_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintBottom_toTopOf="@+id/chapter_title"
app:layout_constraintEnd_toStartOf="@+id/download"
app:layout_constraintStart_toEndOf="@+id/manga_cover"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Manga title" />
<ImageView
android:id="@+id/bookmark_icon"
android:layout_width="16dp"
android:layout_height="0dp"
android:visibility="gone"
android:layout_marginEnd="4dp"
app:layout_constraintStart_toStartOf="@id/manga_title"
app:layout_constraintTop_toBottomOf="@id/manga_title"
app:layout_constraintBottom_toBottomOf="@id/chapter_title"
app:layout_constraintEnd_toStartOf="@id/chapter_title"
app:srcCompat="@drawable/ic_bookmark_24dp"
app:tint="?attr/colorAccent"
tools:visibility="visible" />
<TextView
android:id="@+id/chapter_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?android:attr/textColorSecondary"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/download"
app:layout_constraintStart_toEndOf="@id/bookmark_icon"
app:layout_constraintTop_toBottomOf="@+id/manga_title"
tools:text="Chapter title" />
<eu.kanade.tachiyomi.ui.manga.chapter.ChapterDownloadView
android:id="@+id/download"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -72,16 +72,6 @@ FROM mangas
WHERE favorite = 0
GROUP BY source;
getRecentlyUpdated:
SELECT *
FROM mangas M
JOIN chapters C
ON M._id = C.manga_id
WHERE M.favorite = 1
AND C.date_upload > :after
AND C.date_fetch > M.date_added
ORDER BY C.date_upload DESC;
getLibrary:
SELECT M.*, COALESCE(MC.category_id, 0) AS category
FROM (

View file

@ -0,0 +1,20 @@
CREATE VIEW updatesView AS
SELECT
mangas._id AS mangaId,
mangas.title AS mangaTitle,
chapters._id AS chapterId,
chapters.name AS chapterName,
chapters.scanlator,
chapters.read,
chapters.bookmark,
mangas.source,
mangas.favorite,
mangas.thumbnail_url AS thumbnailUrl,
mangas.cover_last_modified AS coverLastModified,
chapters.date_upload AS dateUpload,
chapters.date_fetch AS datefetch
FROM mangas JOIN chapters
ON mangas._id = chapters.manga_id
WHERE favorite = 1
AND date_fetch > date_added
ORDER BY date_fetch DESC;

View file

@ -0,0 +1,25 @@
CREATE VIEW updatesView AS
SELECT
mangas._id AS mangaId,
mangas.title AS mangaTitle,
chapters._id AS chapterId,
chapters.name AS chapterName,
chapters.scanlator,
chapters.read,
chapters.bookmark,
mangas.source,
mangas.favorite,
mangas.thumbnail_url AS thumbnailUrl,
mangas.cover_last_modified AS coverLastModified,
chapters.date_upload AS dateUpload,
chapters.date_fetch AS datefetch
FROM mangas JOIN chapters
ON mangas._id = chapters.manga_id
WHERE favorite = 1
AND date_fetch > date_added
ORDER BY date_fetch DESC;
updates:
SELECT *
FROM updatesView
WHERE dateUpload > :after;