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.SourceDataRepositoryImpl
import eu.kanade.data.source.SourceRepositoryImpl import eu.kanade.data.source.SourceRepositoryImpl
import eu.kanade.data.track.TrackRepositoryImpl 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.CreateCategoryWithName
import eu.kanade.domain.category.interactor.DeleteCategory import eu.kanade.domain.category.interactor.DeleteCategory
import eu.kanade.domain.category.interactor.GetCategories 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.GetTracks
import eu.kanade.domain.track.interactor.InsertTrack import eu.kanade.domain.track.interactor.InsertTrack
import eu.kanade.domain.track.repository.TrackRepository 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.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addFactory import uy.kohesive.injekt.api.addFactory
@ -119,6 +122,9 @@ class DomainModule : InjektModule {
addFactory { GetExtensionUpdates(get(), get()) } addFactory { GetExtensionUpdates(get(), get()) }
addFactory { GetExtensionLanguages(get(), get()) } addFactory { GetExtensionLanguages(get(), get()) }
addSingletonFactory<UpdatesRepository> { UpdatesRepositoryImpl(get()) }
addFactory { GetUpdates(get(), get()) }
addSingletonFactory<SourceRepository> { SourceRepositoryImpl(get(), get()) } addSingletonFactory<SourceRepository> { SourceRepositoryImpl(get(), get()) }
addSingletonFactory<SourceDataRepository> { SourceDataRepositoryImpl(get()) } addSingletonFactory<SourceDataRepository> { SourceDataRepositoryImpl(get()) }
addFactory { GetEnabledSources(get(), 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.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.kanade.presentation.manga.ChapterDownloadAction
import eu.kanade.presentation.util.secondaryItemAlpha import eu.kanade.presentation.util.secondaryItemAlpha
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
enum class ChapterDownloadAction {
START,
START_NOW,
CANCEL,
DELETE,
}
@Composable @Composable
fun ChapterDownloadIndicator( fun ChapterDownloadIndicator(
modifier: Modifier = Modifier, 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.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
@ -51,13 +51,13 @@ import kotlinx.coroutines.launch
fun MangaBottomActionMenu( fun MangaBottomActionMenu(
visible: Boolean, visible: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBookmarkClicked: (() -> Unit)?, onBookmarkClicked: (() -> Unit)? = null,
onRemoveBookmarkClicked: (() -> Unit)?, onRemoveBookmarkClicked: (() -> Unit)? = null,
onMarkAsReadClicked: (() -> Unit)?, onMarkAsReadClicked: (() -> Unit)? = null,
onMarkAsUnreadClicked: (() -> Unit)?, onMarkAsUnreadClicked: (() -> Unit)? = null,
onMarkPreviousAsReadClicked: (() -> Unit)?, onMarkPreviousAsReadClicked: (() -> Unit)? = null,
onDownloadClicked: (() -> Unit)?, onDownloadClicked: (() -> Unit)? = null,
onDeleteClicked: (() -> Unit)?, onDeleteClicked: (() -> Unit)? = null,
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = visible, 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.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -15,7 +15,7 @@ import java.text.DateFormat
import java.util.Date import java.util.Date
@Composable @Composable
fun HistoryHeader( fun RelativeDateHeader(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
date: Date, date: Date,
relativeTime: Int, relativeTime: Int,

View File

@ -39,8 +39,8 @@ import androidx.paging.compose.items
import eu.kanade.domain.history.model.HistoryWithRelations import eu.kanade.domain.history.model.HistoryWithRelations
import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.LoadingScreen import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.components.RelativeDateHeader
import eu.kanade.presentation.components.ScrollbarLazyColumn 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.HistoryItem
import eu.kanade.presentation.history.components.HistoryItemShimmer import eu.kanade.presentation.history.components.HistoryItemShimmer
import eu.kanade.presentation.util.plus import eu.kanade.presentation.util.plus
@ -108,7 +108,7 @@ fun HistoryContent(
items(history) { item -> items(history) { item ->
when (item) { when (item) {
is HistoryUiModel.Header -> { is HistoryUiModel.Header -> {
HistoryHeader( RelativeDateHeader(
modifier = Modifier modifier = Modifier
.animateItemPlacement(), .animateItemPlacement(),
date = item.date, 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.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import eu.kanade.domain.chapter.model.Chapter import eu.kanade.domain.chapter.model.Chapter
import eu.kanade.presentation.components.ChapterDownloadAction
import eu.kanade.presentation.components.ExtendedFloatingActionButton import eu.kanade.presentation.components.ExtendedFloatingActionButton
import eu.kanade.presentation.components.LazyColumn import eu.kanade.presentation.components.LazyColumn
import eu.kanade.presentation.components.MangaBottomActionMenu
import eu.kanade.presentation.components.Scaffold import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.components.SwipeRefreshIndicator import eu.kanade.presentation.components.SwipeRefreshIndicator
import eu.kanade.presentation.components.VerticalFastScroller import eu.kanade.presentation.components.VerticalFastScroller
import eu.kanade.presentation.manga.components.ChapterHeader import eu.kanade.presentation.manga.components.ChapterHeader
import eu.kanade.presentation.manga.components.ExpandableMangaDescription import eu.kanade.presentation.manga.components.ExpandableMangaDescription
import eu.kanade.presentation.manga.components.MangaActionRow 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.MangaChapterListItem
import eu.kanade.presentation.manga.components.MangaInfoBox import eu.kanade.presentation.manga.components.MangaInfoBox
import eu.kanade.presentation.manga.components.MangaSmallAppBar import eu.kanade.presentation.manga.components.MangaSmallAppBar

View File

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

View File

@ -29,8 +29,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import eu.kanade.presentation.components.ChapterDownloadAction
import eu.kanade.presentation.components.ChapterDownloadIndicator 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.R
import eu.kanade.tachiyomi.data.download.model.Download 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 package eu.kanade.presentation.manga.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons 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.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallTopAppBar import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults 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.draw.drawBehind
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow 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.DropdownMenu
import eu.kanade.presentation.components.IncognitoModeBanner
import eu.kanade.presentation.manga.DownloadAction import eu.kanade.presentation.manga.DownloadAction
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
@ -210,28 +206,10 @@ fun MangaSmallAppBar(
) )
if (downloadedOnlyMode) { if (downloadedOnlyMode) {
Text( DownloadedOnlyModeBanner()
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,
)
} }
if (incognitoMode) { if (incognitoMode) {
Text( IncognitoModeBanner()
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

@ -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 verticalPadding = vertical
val topPaddingValues = PaddingValues(top = vertical) val topPaddingValues = PaddingValues(top = vertical)
const val ReadItemAlpha = .38f

View File

@ -27,3 +27,21 @@ fun LazyListState.isScrollingUp(): Boolean {
} }
}.value }.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()) { if (!router.hasRootController()) {
// Set start screen // Set start screen
if (!handleIntentAction(intent)) { if (!handleIntentAction(intent)) {
setSelectedNavItem(startScreenId) moveToStartScreen()
} }
} }
syncActivityViewWithController() syncActivityViewWithController()
@ -483,10 +483,15 @@ class MainActivity : BaseActivity() {
} }
override fun onBackPressed() { override fun onBackPressed() {
// Updates screen has custom back handler
if (router.getControllerWithTag("${R.id.nav_updates}") != null) {
router.handleBack()
return
}
val backstackSize = router.backstackSize val backstackSize = router.backstackSize
if (backstackSize == 1 && router.getControllerWithTag("$startScreenId") == null) { if (backstackSize == 1 && router.getControllerWithTag("$startScreenId") == null) {
// Return to start screen // Return to start screen
setSelectedNavItem(startScreenId) moveToStartScreen()
} else if (shouldHandleExitConfirmation()) { } else if (shouldHandleExitConfirmation()) {
// Exit confirmation (resets after 2 seconds) // Exit confirmation (resets after 2 seconds)
lifecycleScope.launchUI { resetExitConfirmation() } lifecycleScope.launchUI { resetExitConfirmation() }
@ -499,6 +504,10 @@ class MainActivity : BaseActivity() {
} }
} }
fun moveToStartScreen() {
setSelectedNavItem(startScreenId)
}
override fun onSupportActionModeStarted(mode: ActionMode) { override fun onSupportActionModeStarted(mode: ActionMode) {
binding.appbar.apply { binding.appbar.apply {
tag = isTransparentWhenNotLifted 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.category.model.Category
import eu.kanade.domain.manga.model.Manga import eu.kanade.domain.manga.model.Manga
import eu.kanade.domain.manga.model.toDbManga 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.DownloadAction
import eu.kanade.presentation.manga.MangaScreen import eu.kanade.presentation.manga.MangaScreen
import eu.kanade.presentation.util.calculateWindowWidthSizeClass 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.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.AbstractComposeView import androidx.compose.ui.platform.AbstractComposeView
import eu.kanade.presentation.components.ChapterDownloadAction
import eu.kanade.presentation.components.ChapterDownloadIndicator import eu.kanade.presentation.components.ChapterDownloadIndicator
import eu.kanade.presentation.manga.ChapterDownloadAction
import eu.kanade.presentation.theme.TachiyomiTheme import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.tachiyomi.data.download.model.Download 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 android.view.View
import eu.davidea.viewholders.FlexibleViewHolder import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.presentation.manga.ChapterDownloadAction import eu.kanade.presentation.components.ChapterDownloadAction
open class BaseChapterHolder( open class BaseChapterHolder(
view: View, 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 package eu.kanade.tachiyomi.ui.recent.updates
import android.view.LayoutInflater import androidx.activity.OnBackPressedDispatcherOwner
import android.view.Menu import androidx.appcompat.app.AlertDialog
import android.view.MenuInflater import androidx.compose.material3.Text
import android.view.MenuItem import androidx.compose.runtime.Composable
import android.view.View import androidx.compose.runtime.collectAsState
import androidx.appcompat.view.ActionMode import androidx.compose.runtime.getValue
import androidx.core.view.isVisible import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.recyclerview.widget.LinearLayoutManager import eu.kanade.presentation.components.ChapterDownloadAction
import dev.chrisbanes.insetter.applyInsetter import eu.kanade.presentation.components.LoadingScreen
import eu.davidea.flexibleadapter.FlexibleAdapter import eu.kanade.presentation.updates.UpdateScreen
import eu.davidea.flexibleadapter.SelectableAdapter import eu.kanade.presentation.util.NavBarVisibility
import eu.kanade.presentation.util.toBoolean
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
import eu.kanade.tachiyomi.databinding.UpdatesControllerBinding
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.RootController import eu.kanade.tachiyomi.ui.base.controller.RootController
import eu.kanade.tachiyomi.ui.base.controller.pushController import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChaptersAdapter
import eu.kanade.tachiyomi.ui.reader.ReaderActivity 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.system.toast
import eu.kanade.tachiyomi.util.view.onAnimationsFinished import eu.kanade.tachiyomi.widget.materialdialogs.await
import eu.kanade.tachiyomi.widget.ActionModeWithToolbar
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import logcat.LogPriority
import reactivecircus.flowbinding.recyclerview.scrollStateChanges
import reactivecircus.flowbinding.swiperefreshlayout.refreshes
/** /**
* Fragment that shows recent chapters. * Fragment that shows recent chapters.
*/ */
class UpdatesController : class UpdatesController :
NucleusController<UpdatesControllerBinding, UpdatesPresenter>(), FullComposeController<UpdatesPresenter>(),
RootController, RootController {
ActionModeWithToolbar.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
FlexibleAdapter.OnUpdateListener,
BaseChaptersAdapter.OnChapterClickListener,
ConfirmDeleteChaptersDialog.Listener,
UpdatesAdapter.OnCoverClickListener {
/** override fun createPresenter() = UpdatesPresenter()
* Action mode for multiple selection.
*/
private var actionMode: ActionModeWithToolbar? = null
/** @Composable
* Adapter containing the recent chapters. override fun ComposeContent() {
*/ val state by presenter.state.collectAsState()
var adapter: UpdatesAdapter? = null when (state) {
private set is UpdatesState.Loading -> LoadingScreen()
is UpdatesState.Error -> Text(text = (state as UpdatesState.Error).error.message.orEmpty())
init { is UpdatesState.Success ->
setHasOptionsMenu(true) 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,
)
} }
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()
}
}
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() { private fun updateLibrary() {
@ -154,262 +70,67 @@ class UpdatesController :
} }
} }
/** // Let compose view handle this
* Returns selected chapters override fun handleBack(): Boolean {
* @return list of selected chapters (activity as? OnBackPressedDispatcherOwner)?.onBackPressedDispatcher?.onBackPressed()
*/ return true
private fun getSelectedChapters(): List<UpdatesItem> {
val adapter = adapter ?: return emptyList()
return adapter.selectedPositions.mapNotNull { adapter.getItem(it) as? UpdatesItem }
} }
/** private fun onBackClicked() {
* Called when item in list is clicked (activity as? MainActivity)?.moveToStartScreen()
* @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 toggleNavBarVisibility(navBarVisibility: NavBarVisibility) {
* Called when item in list is long clicked val showNavBar = navBarVisibility.toBoolean()
* @param position position of clicked item (activity as? MainActivity)?.showBottomNav(showNavBar)
*/
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)
} }
/** /**
* Download selected items * Download selected items
* @param chapters list of selected [UpdatesItem]s * @param items list of selected [UpdatesItem]s
*/ */
private fun downloadChapters(chapters: List<UpdatesItem>) { private fun downloadChapters(items: List<UpdatesItem>, action: ChapterDownloadAction) {
presenter.downloadChapters(chapters) if (items.isEmpty()) return
destroyActionModeIfNeeded() viewScope.launch {
} when (action) {
ChapterDownloadAction.START -> {
override fun onUpdateEmptyView(size: Int) { presenter.downloadChapters(items)
if (size > 0) { if (items.any { it.downloadStateProvider() == Download.State.ERROR }) {
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)
}
}
/**
* 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!!) DownloadService.start(activity!!)
} else {
downloadChapters(listOf(item))
} }
adapter?.updateItem(item) }
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)
}
}
}
} }
override fun deleteChapter(position: Int) { private fun deleteChaptersWithConfirmation(items: List<UpdatesItem>) {
val item = adapter?.getItem(position) as? UpdatesItem ?: return if (items.isEmpty()) return
deleteChapters(listOf(item)) viewScope.launch {
adapter?.updateItem(item) 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)
}
} }
override fun startDownloadNow(position: Int) { private fun openChapter(item: UpdatesItem) {
val item = adapter?.getItem(position) as? UpdatesItem ?: return val activity = activity ?: return
presenter.startDownloadingNow(item.chapter) val intent = ReaderActivity.newIntent(activity, item.update.mangaId, item.update.chapterId)
startActivity(intent)
} }
private fun bookmarkChapters(chapters: List<UpdatesItem>, bookmarked: Boolean) { private fun openManga(item: UpdatesItem) {
presenter.bookmarkChapters(chapters, bookmarked) router.pushController(MangaController(item.update.mangaId))
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()
}
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 }
}
/**
* 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()
} }
} }

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,134 +1,177 @@
package eu.kanade.tachiyomi.ui.recent.updates package eu.kanade.tachiyomi.ui.recent.updates
import android.os.Bundle import android.os.Bundle
import eu.kanade.data.DatabaseHandler import androidx.compose.runtime.Immutable
import eu.kanade.data.manga.mangaChapterMapper 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.SetReadStatus
import eu.kanade.domain.chapter.interactor.UpdateChapter 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.ChapterUpdate
import eu.kanade.domain.chapter.model.toDbChapter 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.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter 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.launchIO
import eu.kanade.tachiyomi.util.lang.toDateKey import eu.kanade.tachiyomi.util.lang.toDateKey
import eu.kanade.tachiyomi.util.lang.withUIContext import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.preference.asHotFlow
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.update
import logcat.LogPriority import logcat.LogPriority
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.text.DateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Date import java.util.Date
import java.util.TreeMap
class UpdatesPresenter( 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 updateChapter: UpdateChapter = Injekt.get(),
private val setReadStatus: SetReadStatus = 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>() { ) : BasePresenter<UpdatesController>() {
private val relativeTime: Int = preferences.relativeTime().get() private val _state: MutableStateFlow<UpdatesState> = MutableStateFlow(UpdatesState.Loading)
private val dateFormat: DateFormat = preferences.dateFormat() 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?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
presenterScope.launchIO { presenterScope.launchIO {
subscribeToUpdates()
downloadManager.queue.getStatusAsFlow()
.catch { error -> logcat(LogPriority.ERROR, error) }
.collectLatest {
withUIContext {
onDownloadStatusChange(it)
view?.onChapterDownloadUpdate(it)
}
}
downloadManager.queue.getProgressAsFlow()
.catch { error -> logcat(LogPriority.ERROR, error) }
.collectLatest {
withUIContext {
view?.onChapterDownloadUpdate(it)
}
}
}
}
/**
* Get observable containing recent chapters and date
*/
private suspend fun subscribeToUpdates() {
// Set date limit for recent chapters // Set date limit for recent chapters
val cal = Calendar.getInstance().apply { val calendar = Calendar.getInstance().apply {
time = Date() time = Date()
add(Calendar.MONTH, -3) add(Calendar.MONTH, -3)
} }
handler getUpdates.subscribe(calendar)
.subscribeToList { .catch { exception ->
mangasQueries.getRecentlyUpdated(after = cal.timeInMillis, mangaChapterMapper) _state.value = UpdatesState.Error(exception)
} }
.map { mangaChapter -> .collectLatest { updates ->
val map = TreeMap<Date, MutableList<Pair<Manga, Chapter>>> { d1, d2 -> d2.compareTo(d1) } val uiModels = updates.toUpdateUiModels()
val byDate = mangaChapter.groupByTo(map) { it.second.dateFetch.toDateKey() } _state.update { currentState ->
byDate.flatMap { entry -> when (currentState) {
val dateItem = DateSectionItem(entry.key, relativeTime, dateFormat) is UpdatesState.Success -> currentState.copy(uiModels)
entry.value is UpdatesState.Loading, is UpdatesState.Error ->
.sortedWith(compareBy({ it.second.dateFetch }, { it.second.chapterNumber })).asReversed() UpdatesState.Success(
.map { UpdatesItem(it.second, it.first, dateItem) } uiModels = uiModels,
} isIncognitoMode = incognitoMode,
} isDownloadedOnlyMode = downloadOnlyMode,
.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 })
} }
} }
/** observeDownloads()
* 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)) { preferences.incognitoMode()
item.status = Download.State.DOWNLOADED .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 {
updateDownloadState(it)
}
}
}
observeDownloadsPageJob?.cancel()
observeDownloadsPageJob = presenterScope.launchIO {
downloadManager.queue.getProgressAsFlow()
.catch { error -> logcat(LogPriority.ERROR, error) }
.collectLatest {
withUIContext {
updateDownloadState(it)
}
} }
} }
} }
@ -138,96 +181,141 @@ class UpdatesPresenter(
* *
* @param download download object containing progress. * @param download download object containing progress.
*/ */
private fun onDownloadStatusChange(download: Download) { private fun updateDownloadState(download: Download) {
// Assign the download to the model object. updateSuccessState { successState ->
if (download.status == Download.State.QUEUE) { val modifiedIndex = successState.uiModels.indexOfFirst {
val chapters = (view?.adapter?.currentItems ?: emptyList()).filterIsInstance<UpdatesItem>() it is UpdatesUiModel.Item && it.item.update.chapterId == download.chapter.id
val chapter = chapters.find { it.chapter.id == download.chapter.id }
if (chapter != null && chapter.download == null) {
chapter.download = download
} }
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) { fun startDownloadingNow(chapterId: Long) {
downloadManager.startDownloadNow(chapter.id) 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 * Mark the selected updates list as read/unread.
* * @param updates the list of selected updates.
* @param items list of selected chapters * @param read whether to mark chapters as read or unread.
* @param read read status
*/ */
fun markChapterRead(items: List<UpdatesItem>, read: Boolean) { fun markUpdatesRead(updates: List<UpdatesItem>, read: Boolean) {
presenterScope.launchIO { presenterScope.launchIO {
setReadStatus.await( setReadStatus.await(
read = read, read = read,
values = items values = updates
.map { it.chapter } .mapNotNull { getChapter.await(it.update.chapterId) }
.toTypedArray(), .toTypedArray(),
) )
} }
} }
/** /**
* Delete selected chapters * Bookmarks the given list of chapters.
* * @param updates the list of chapters to bookmark.
* @param chapters list of chapters
*/ */
fun deleteChapters(chapters: List<UpdatesItem>) { fun bookmarkUpdates(updates: List<UpdatesItem>, bookmark: Boolean) {
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) {
presenterScope.launchIO { presenterScope.launchIO {
val toUpdate = items.map { updates
ChapterUpdate( .filterNot { it.update.bookmark == bookmark }
bookmark = bookmarked, .map { ChapterUpdate(id = it.update.chapterId, bookmark = bookmark) }
id = it.chapter.id, .let { updateChapter.awaitAll(it) }
)
}
updateChapter.awaitAll(toUpdate)
} }
} }
/** /**
* Download selected chapters * Downloads the given list of chapters with the manager.
* @param items list of recent chapters seleted. * @param updatesItem the list of chapters to download.
*/ */
fun downloadChapters(items: List<UpdatesItem>) { fun downloadChapters(updatesItem: List<UpdatesItem>) {
items.forEach { downloadManager.downloadChapters(it.manga, listOf(it.chapter.toDbChapter())) } 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 * Delete selected chapters
* *
* @param items chapters selected * @param updatesItem list of chapters
*/ */
private fun deleteChaptersInternal(chapterItems: List<UpdatesItem>) { fun deleteChapters(updatesItem: List<UpdatesItem>) {
val itemsByManga = chapterItems.groupBy { it.manga.id } launchIO {
for ((_, items) in itemsByManga) { val groupedUpdates = updatesItem.groupBy { it.update.mangaId }.values
val manga = items.first().manga val deletedIds = groupedUpdates.flatMap { updates ->
val source = sourceManager.get(manga.source) ?: continue val mangaId = updates.first().update.mangaId
val chapters = items.map { it.chapter.toDbChapter() } 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) // TODO: Don't do this fake status update
items.forEach { val newUiModels = successState.uiModels.toMutableList().apply {
it.status = Download.State.NOT_DOWNLOADED deletedUpdates.forEach { deletedUpdate ->
it.download = null 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 WHERE favorite = 0
GROUP BY source; 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: getLibrary:
SELECT M.*, COALESCE(MC.category_id, 0) AS category SELECT M.*, COALESCE(MC.category_id, 0) AS category
FROM ( 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;