diff --git a/app/src/main/java/eu/kanade/core/util/ListUtils.kt b/app/src/main/java/eu/kanade/core/util/ListUtils.kt new file mode 100644 index 0000000000..702f3d0f06 --- /dev/null +++ b/app/src/main/java/eu/kanade/core/util/ListUtils.kt @@ -0,0 +1,16 @@ +package eu.kanade.core.util + +fun List.insertSeparators( + generator: (T?, T?) -> R?, +): List { + if (isEmpty()) return emptyList() + val newList = mutableListOf() + 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 +} diff --git a/app/src/main/java/eu/kanade/data/updates/UpdatesMapper.kt b/app/src/main/java/eu/kanade/data/updates/UpdatesMapper.kt new file mode 100644 index 0000000000..4870096746 --- /dev/null +++ b/app/src/main/java/eu/kanade/data/updates/UpdatesMapper.kt @@ -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, + ), + ) +} diff --git a/app/src/main/java/eu/kanade/data/updates/UpdatesRepositoryImpl.kt b/app/src/main/java/eu/kanade/data/updates/UpdatesRepositoryImpl.kt new file mode 100644 index 0000000000..e9bccf92d8 --- /dev/null +++ b/app/src/main/java/eu/kanade/data/updates/UpdatesRepositoryImpl.kt @@ -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> { + return databaseHandler.subscribeToList { + updatesViewQueries.updates(after, updateWithRelationMapper) + } + } +} diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 4775b8be31..4c0858bfb5 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -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 { UpdatesRepositoryImpl(get()) } + addFactory { GetUpdates(get(), get()) } + addSingletonFactory { SourceRepositoryImpl(get(), get()) } addSingletonFactory { SourceDataRepositoryImpl(get()) } addFactory { GetEnabledSources(get(), get()) } diff --git a/app/src/main/java/eu/kanade/domain/updates/interactor/GetUpdates.kt b/app/src/main/java/eu/kanade/domain/updates/interactor/GetUpdates.kt new file mode 100644 index 0000000000..ce39d6e521 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/updates/interactor/GetUpdates.kt @@ -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> = subscribe(calendar.time.time) + + fun subscribe(after: Long): Flow> { + return repository.subscribeAll(after) + .onEach { updates -> + // Set unread chapter count for bottom bar badge + preferences.unreadUpdatesCount().set(updates.count { it.read.not() }) + } + } +} diff --git a/app/src/main/java/eu/kanade/domain/updates/model/UpdatesWithRelations.kt b/app/src/main/java/eu/kanade/domain/updates/model/UpdatesWithRelations.kt new file mode 100644 index 0000000000..a1386a7608 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/updates/model/UpdatesWithRelations.kt @@ -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, +) diff --git a/app/src/main/java/eu/kanade/domain/updates/repository/UpdatesRepository.kt b/app/src/main/java/eu/kanade/domain/updates/repository/UpdatesRepository.kt new file mode 100644 index 0000000000..5b63662c03 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/updates/repository/UpdatesRepository.kt @@ -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> +} diff --git a/app/src/main/java/eu/kanade/presentation/components/Banners.kt b/app/src/main/java/eu/kanade/presentation/components/Banners.kt new file mode 100644 index 0000000000..fa5ee4620f --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/components/Banners.kt @@ -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, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/components/ChapterDownloadIndicator.kt b/app/src/main/java/eu/kanade/presentation/components/ChapterDownloadIndicator.kt index 375bb2565b..a3e1de788b 100644 --- a/app/src/main/java/eu/kanade/presentation/components/ChapterDownloadIndicator.kt +++ b/app/src/main/java/eu/kanade/presentation/components/ChapterDownloadIndicator.kt @@ -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, diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt b/app/src/main/java/eu/kanade/presentation/components/MangaBottomActionMenu.kt similarity index 95% rename from app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt rename to app/src/main/java/eu/kanade/presentation/components/MangaBottomActionMenu.kt index 83e64764c5..b5166a6895 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt +++ b/app/src/main/java/eu/kanade/presentation/components/MangaBottomActionMenu.kt @@ -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, diff --git a/app/src/main/java/eu/kanade/presentation/history/components/HistoryHeader.kt b/app/src/main/java/eu/kanade/presentation/components/RelativeDateHeader.kt similarity index 94% rename from app/src/main/java/eu/kanade/presentation/history/components/HistoryHeader.kt rename to app/src/main/java/eu/kanade/presentation/components/RelativeDateHeader.kt index 747955fe15..bf94b3bcce 100644 --- a/app/src/main/java/eu/kanade/presentation/history/components/HistoryHeader.kt +++ b/app/src/main/java/eu/kanade/presentation/components/RelativeDateHeader.kt @@ -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, diff --git a/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt b/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt index 69e70a59f1..15f95f4f13 100644 --- a/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt @@ -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, diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt index 0368f37590..5d1bb16f98 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt index 0e3b3bcdcd..d86748824d 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt @@ -9,13 +9,6 @@ enum class DownloadAction { ALL_CHAPTERS } -enum class ChapterDownloadAction { - START, - START_NOW, - CANCEL, - DELETE, -} - enum class EditCoverAction { EDIT, DELETE, diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaChapterListItem.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaChapterListItem.kt index 80a42dc31e..d81f740b1e 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaChapterListItem.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaChapterListItem.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaSmallAppBar.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaSmallAppBar.kt index 1e74d18750..825dc8b310 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaSmallAppBar.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaSmallAppBar.kt @@ -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() } } } diff --git a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt b/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt new file mode 100644 index 0000000000..fa0a6004c5 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt @@ -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, ChapterDownloadAction) -> Unit, + onUpdateLibrary: () -> Unit, + onBackClicked: () -> Unit, + toggleNavBarVisibility: (NavBarVisibility) -> Unit, + // For bottom action menu + onMultiBookmarkClicked: (List, bookmark: Boolean) -> Unit, + onMultiMarkAsReadClicked: (List, read: Boolean) -> Unit, + onMultiDeleteClicked: (List) -> 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() + } + // 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().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, + 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, + onDownloadChapter: (List, ChapterDownloadAction) -> Unit, + onMultiBookmarkClicked: (List, bookmark: Boolean) -> Unit, + onMultiMarkAsReadClicked: (List, read: Boolean) -> Unit, + onMultiDeleteClicked: (List) -> 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() +} diff --git a/app/src/main/java/eu/kanade/presentation/updates/UpdatesUiItem.kt b/app/src/main/java/eu/kanade/presentation/updates/UpdatesUiItem.kt new file mode 100644 index 0000000000..0921316a12 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/updates/UpdatesUiItem.kt @@ -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, + itemUiModels: List, + selected: MutableList, + selectedPositions: Array, + onClickCover: (UpdatesItem) -> Unit, + onClickUpdate: (UpdatesItem) -> Unit, + onDownloadChapter: (List, 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, + updates: List, + selectedPositions: Array, +): 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, + updates: List, + selectedPositions: Array, + 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) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/util/Constants.kt b/app/src/main/java/eu/kanade/presentation/util/Constants.kt index 06b9c12143..4938b4b95d 100644 --- a/app/src/main/java/eu/kanade/presentation/util/Constants.kt +++ b/app/src/main/java/eu/kanade/presentation/util/Constants.kt @@ -12,3 +12,5 @@ val horizontalPadding = horizontal val verticalPadding = vertical val topPaddingValues = PaddingValues(top = vertical) + +const val ReadItemAlpha = .38f diff --git a/app/src/main/java/eu/kanade/presentation/util/LazyListState.kt b/app/src/main/java/eu/kanade/presentation/util/LazyListState.kt index 7fb9235c5f..5783232d86 100644 --- a/app/src/main/java/eu/kanade/presentation/util/LazyListState.kt +++ b/app/src/main/java/eu/kanade/presentation/util/LazyListState.kt @@ -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 +} diff --git a/app/src/main/java/eu/kanade/presentation/util/NavBarVisibility.kt b/app/src/main/java/eu/kanade/presentation/util/NavBarVisibility.kt new file mode 100644 index 0000000000..28d79e0dfc --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/util/NavBarVisibility.kt @@ -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 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index ff3501b494..442b7c2708 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt index d7d5c74af6..357c497bc4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterDownloadView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterDownloadView.kt index 049102931e..591536e074 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterDownloadView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterDownloadView.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/base/BaseChapterHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/base/BaseChapterHolder.kt index cba5fefb2b..d90d61451f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/base/BaseChapterHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/base/BaseChapterHolder.kt @@ -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, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/DateSectionItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/DateSectionItem.kt deleted file mode 100644 index 2a05ce607c..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/DateSectionItem.kt +++ /dev/null @@ -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() { - - override fun getLayoutRes(): Int { - return R.layout.section_header_item - } - - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): DateSectionItemHolder { - return DateSectionItemHolder(view, adapter) - } - - override fun bindViewHolder(adapter: FlexibleAdapter>, holder: DateSectionItemHolder, position: Int, payloads: List?) { - 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) - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/ConfirmDeleteChaptersDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/ConfirmDeleteChaptersDialog.kt deleted file mode 100644 index 221037e450..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/ConfirmDeleteChaptersDialog.kt +++ /dev/null @@ -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(bundle: Bundle? = null) : DialogController(bundle) - where T : Controller, T : ConfirmDeleteChaptersDialog.Listener { - - private var chaptersToDelete = emptyList() - - constructor(target: T, chaptersToDelete: List) : 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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesAdapter.kt deleted file mode 100644 index c0b9f7eb26..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesAdapter.kt +++ /dev/null @@ -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>?, -) : BaseChaptersAdapter>(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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt index 3636c69830..4518fe157c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesController.kt @@ -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(), - RootController, - ActionModeWithToolbar.Callback, - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnItemLongClickListener, - FlexibleAdapter.OnUpdateListener, - BaseChaptersAdapter.OnChapterClickListener, - ConfirmDeleteChaptersDialog.Listener, - UpdatesAdapter.OnCoverClickListener { + FullComposeController(), + 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 { - 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) { - 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() - ?.find { it.chapter.id == download.chapter.id }?.let { - adapter?.updateItem(it, it.status) + private fun downloadChapters(items: List, 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) { - presenter.markChapterRead(chapters, true) - destroyActionModeIfNeeded() - } - - /** - * Mark chapter as unread - * @param chapters list of selected [UpdatesItem] - */ - private fun markAsUnread(chapters: List) { - presenter.markChapterRead(chapters, false) - destroyActionModeIfNeeded() - } - - override fun deleteChapters(chaptersToDelete: List) { - 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, 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) { + 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)) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesHolder.kt deleted file mode 100644 index a6af17986a..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesHolder.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesItem.kt deleted file mode 100644 index de3d77952e..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesItem.kt +++ /dev/null @@ -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(chapter, header) { - - override fun getLayoutRes(): Int { - return R.layout.updates_item - } - - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): UpdatesHolder { - return UpdatesHolder(view, adapter as UpdatesAdapter) - } - - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: UpdatesHolder, - position: Int, - payloads: List?, - ) { - holder.bind(this) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesPresenter.kt index ddfd65a5fb..4b43498b1b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent/updates/UpdatesPresenter.kt @@ -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() { - private val relativeTime: Int = preferences.relativeTime().get() - private val dateFormat: DateFormat = preferences.dateFormat() + private val _state: MutableStateFlow = MutableStateFlow(UpdatesState.Loading) + val state: StateFlow = _state.asStateFlow() - private val _updates: MutableStateFlow> = MutableStateFlow(listOf()) - val updates: StateFlow> = _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.toUpdateUiModels(): List { + 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>> { 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) { - 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() - 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, read: Boolean) { + fun markUpdatesRead(updates: List, 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) { - 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, bookmarked: Boolean) { + fun bookmarkUpdates(updates: List, 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) { - items.forEach { downloadManager.downloadChapters(it.manga, listOf(it.chapter.toDbChapter())) } + fun downloadChapters(updatesItem: List) { + 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) { - 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) { + 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, + 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, +) diff --git a/app/src/main/res/layout/updates_controller.xml b/app/src/main/res/layout/updates_controller.xml deleted file mode 100644 index ab95c93e98..0000000000 --- a/app/src/main/res/layout/updates_controller.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/updates_item.xml b/app/src/main/res/layout/updates_item.xml deleted file mode 100644 index 4131866512..0000000000 --- a/app/src/main/res/layout/updates_item.xml +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/sqldelight/data/mangas.sq b/app/src/main/sqldelight/data/mangas.sq index 797b1e6b12..debb772273 100644 --- a/app/src/main/sqldelight/data/mangas.sq +++ b/app/src/main/sqldelight/data/mangas.sq @@ -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 ( diff --git a/app/src/main/sqldelight/migrations/18.sqm b/app/src/main/sqldelight/migrations/18.sqm new file mode 100644 index 0000000000..6a12320aae --- /dev/null +++ b/app/src/main/sqldelight/migrations/18.sqm @@ -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; \ No newline at end of file diff --git a/app/src/main/sqldelight/view/updatesView.sq b/app/src/main/sqldelight/view/updatesView.sq new file mode 100644 index 0000000000..6a4323a066 --- /dev/null +++ b/app/src/main/sqldelight/view/updatesView.sq @@ -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;