mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 14:27:57 +01:00 
			
		
		
		
	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:
		
							
								
								
									
										16
									
								
								app/src/main/java/eu/kanade/core/util/ListUtils.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								app/src/main/java/eu/kanade/core/util/ListUtils.kt
									
									
									
									
									
										Normal 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 | ||||
| } | ||||
							
								
								
									
										26
									
								
								app/src/main/java/eu/kanade/data/updates/UpdatesMapper.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								app/src/main/java/eu/kanade/data/updates/UpdatesMapper.kt
									
									
									
									
									
										Normal 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, | ||||
|         ), | ||||
|     ) | ||||
| } | ||||
| @@ -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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -7,6 +7,7 @@ import eu.kanade.data.manga.MangaRepositoryImpl | ||||
| import eu.kanade.data.source.SourceDataRepositoryImpl | ||||
| import eu.kanade.data.source.SourceRepositoryImpl | ||||
| import eu.kanade.data.track.TrackRepositoryImpl | ||||
| import eu.kanade.data.updates.UpdatesRepositoryImpl | ||||
| import eu.kanade.domain.category.interactor.CreateCategoryWithName | ||||
| import eu.kanade.domain.category.interactor.DeleteCategory | ||||
| import eu.kanade.domain.category.interactor.GetCategories | ||||
| @@ -60,6 +61,8 @@ import eu.kanade.domain.track.interactor.DeleteTrack | ||||
| import eu.kanade.domain.track.interactor.GetTracks | ||||
| import eu.kanade.domain.track.interactor.InsertTrack | ||||
| import eu.kanade.domain.track.repository.TrackRepository | ||||
| import eu.kanade.domain.updates.interactor.GetUpdates | ||||
| import eu.kanade.domain.updates.repository.UpdatesRepository | ||||
| import uy.kohesive.injekt.api.InjektModule | ||||
| import uy.kohesive.injekt.api.InjektRegistrar | ||||
| import uy.kohesive.injekt.api.addFactory | ||||
| @@ -119,6 +122,9 @@ class DomainModule : InjektModule { | ||||
|         addFactory { GetExtensionUpdates(get(), get()) } | ||||
|         addFactory { GetExtensionLanguages(get(), get()) } | ||||
|  | ||||
|         addSingletonFactory<UpdatesRepository> { UpdatesRepositoryImpl(get()) } | ||||
|         addFactory { GetUpdates(get(), get()) } | ||||
|  | ||||
|         addSingletonFactory<SourceRepository> { SourceRepositoryImpl(get(), get()) } | ||||
|         addSingletonFactory<SourceDataRepository> { SourceDataRepositoryImpl(get()) } | ||||
|         addFactory { GetEnabledSources(get(), get()) } | ||||
|   | ||||
| @@ -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() }) | ||||
|             } | ||||
|     } | ||||
| } | ||||
| @@ -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, | ||||
| ) | ||||
| @@ -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>> | ||||
| } | ||||
| @@ -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, | ||||
|     ) | ||||
| } | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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, | ||||
| @@ -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, | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -9,13 +9,6 @@ enum class DownloadAction { | ||||
|     ALL_CHAPTERS | ||||
| } | ||||
|  | ||||
| enum class ChapterDownloadAction { | ||||
|     START, | ||||
|     START_NOW, | ||||
|     CANCEL, | ||||
|     DELETE, | ||||
| } | ||||
|  | ||||
| enum class EditCoverAction { | ||||
|     EDIT, | ||||
|     DELETE, | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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() | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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() | ||||
| } | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -12,3 +12,5 @@ val horizontalPadding = horizontal | ||||
| val verticalPadding = vertical | ||||
|  | ||||
| val topPaddingValues = PaddingValues(top = vertical) | ||||
|  | ||||
| const val ReadItemAlpha = .38f | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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>) | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -1,149 +1,65 @@ | ||||
| package eu.kanade.tachiyomi.ui.recent.updates | ||||
|  | ||||
| import android.view.LayoutInflater | ||||
| import android.view.Menu | ||||
| import android.view.MenuInflater | ||||
| import android.view.MenuItem | ||||
| import android.view.View | ||||
| import androidx.appcompat.view.ActionMode | ||||
| import androidx.core.view.isVisible | ||||
| import androidx.recyclerview.widget.LinearLayoutManager | ||||
| import dev.chrisbanes.insetter.applyInsetter | ||||
| import eu.davidea.flexibleadapter.FlexibleAdapter | ||||
| import eu.davidea.flexibleadapter.SelectableAdapter | ||||
| import androidx.activity.OnBackPressedDispatcherOwner | ||||
| import androidx.appcompat.app.AlertDialog | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.collectAsState | ||||
| import androidx.compose.runtime.getValue | ||||
| import com.google.android.material.dialog.MaterialAlertDialogBuilder | ||||
| import eu.kanade.presentation.components.ChapterDownloadAction | ||||
| import eu.kanade.presentation.components.LoadingScreen | ||||
| import eu.kanade.presentation.updates.UpdateScreen | ||||
| import eu.kanade.presentation.util.NavBarVisibility | ||||
| import eu.kanade.presentation.util.toBoolean | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.download.DownloadService | ||||
| import eu.kanade.tachiyomi.data.download.model.Download | ||||
| import eu.kanade.tachiyomi.data.library.LibraryUpdateService | ||||
| import eu.kanade.tachiyomi.data.notification.Notifications | ||||
| import eu.kanade.tachiyomi.databinding.UpdatesControllerBinding | ||||
| import eu.kanade.tachiyomi.ui.base.controller.NucleusController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.FullComposeController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.RootController | ||||
| import eu.kanade.tachiyomi.ui.base.controller.pushController | ||||
| import eu.kanade.tachiyomi.ui.main.MainActivity | ||||
| import eu.kanade.tachiyomi.ui.manga.MangaController | ||||
| import eu.kanade.tachiyomi.ui.manga.chapter.base.BaseChaptersAdapter | ||||
| import eu.kanade.tachiyomi.ui.reader.ReaderActivity | ||||
| import eu.kanade.tachiyomi.util.system.logcat | ||||
| import eu.kanade.tachiyomi.util.system.notificationManager | ||||
| import eu.kanade.tachiyomi.util.system.toast | ||||
| import eu.kanade.tachiyomi.util.view.onAnimationsFinished | ||||
| import eu.kanade.tachiyomi.widget.ActionModeWithToolbar | ||||
| import kotlinx.coroutines.flow.collectLatest | ||||
| import kotlinx.coroutines.flow.launchIn | ||||
| import kotlinx.coroutines.flow.onEach | ||||
| import eu.kanade.tachiyomi.widget.materialdialogs.await | ||||
| import kotlinx.coroutines.launch | ||||
| import logcat.LogPriority | ||||
| import reactivecircus.flowbinding.recyclerview.scrollStateChanges | ||||
| import reactivecircus.flowbinding.swiperefreshlayout.refreshes | ||||
|  | ||||
| /** | ||||
|  * Fragment that shows recent chapters. | ||||
|  */ | ||||
| class UpdatesController : | ||||
|     NucleusController<UpdatesControllerBinding, UpdatesPresenter>(), | ||||
|     RootController, | ||||
|     ActionModeWithToolbar.Callback, | ||||
|     FlexibleAdapter.OnItemClickListener, | ||||
|     FlexibleAdapter.OnItemLongClickListener, | ||||
|     FlexibleAdapter.OnUpdateListener, | ||||
|     BaseChaptersAdapter.OnChapterClickListener, | ||||
|     ConfirmDeleteChaptersDialog.Listener, | ||||
|     UpdatesAdapter.OnCoverClickListener { | ||||
|     FullComposeController<UpdatesPresenter>(), | ||||
|     RootController { | ||||
|  | ||||
|     /** | ||||
|      * Action mode for multiple selection. | ||||
|      */ | ||||
|     private var actionMode: ActionModeWithToolbar? = null | ||||
|     override fun createPresenter() = UpdatesPresenter() | ||||
|  | ||||
|     /** | ||||
|      * Adapter containing the recent chapters. | ||||
|      */ | ||||
|     var adapter: UpdatesAdapter? = null | ||||
|         private set | ||||
|  | ||||
|     init { | ||||
|         setHasOptionsMenu(true) | ||||
|     } | ||||
|  | ||||
|     override fun getTitle(): String? { | ||||
|         return resources?.getString(R.string.label_recent_updates) | ||||
|     } | ||||
|  | ||||
|     override fun createPresenter(): UpdatesPresenter { | ||||
|         return UpdatesPresenter() | ||||
|     } | ||||
|  | ||||
|     override fun createBinding(inflater: LayoutInflater) = UpdatesControllerBinding.inflate(inflater) | ||||
|  | ||||
|     override fun onViewCreated(view: View) { | ||||
|         super.onViewCreated(view) | ||||
|         binding.recycler.applyInsetter { | ||||
|             type(navigationBars = true) { | ||||
|                 padding() | ||||
|             } | ||||
|     @Composable | ||||
|     override fun ComposeContent() { | ||||
|         val state by presenter.state.collectAsState() | ||||
|         when (state) { | ||||
|             is UpdatesState.Loading -> LoadingScreen() | ||||
|             is UpdatesState.Error -> Text(text = (state as UpdatesState.Error).error.message.orEmpty()) | ||||
|             is UpdatesState.Success -> | ||||
|                 UpdateScreen( | ||||
|                     state = (state as UpdatesState.Success), | ||||
|                     onClickCover = this::openManga, | ||||
|                     onClickUpdate = this::openChapter, | ||||
|                     onDownloadChapter = this::downloadChapters, | ||||
|                     onUpdateLibrary = this::updateLibrary, | ||||
|                     onBackClicked = this::onBackClicked, | ||||
|                     toggleNavBarVisibility = this::toggleNavBarVisibility, | ||||
|                     // For bottom action menu | ||||
|                     onMultiBookmarkClicked = { updatesItems, bookmark -> | ||||
|                         presenter.bookmarkUpdates(updatesItems, bookmark) | ||||
|                     }, | ||||
|                     onMultiMarkAsReadClicked = { updatesItems, read -> | ||||
|                         presenter.markUpdatesRead(updatesItems, read) | ||||
|                     }, | ||||
|                     onMultiDeleteClicked = this::deleteChaptersWithConfirmation, | ||||
|                 ) | ||||
|         } | ||||
|  | ||||
|         view.context.notificationManager.cancel(Notifications.ID_NEW_CHAPTERS) | ||||
|  | ||||
|         // Init RecyclerView and adapter | ||||
|         val layoutManager = LinearLayoutManager(view.context) | ||||
|         binding.recycler.layoutManager = layoutManager | ||||
|         binding.recycler.setHasFixedSize(true) | ||||
|         binding.recycler.scrollStateChanges() | ||||
|             .onEach { | ||||
|                 // Disable swipe refresh when view is not at the top | ||||
|                 val firstPos = layoutManager.findFirstCompletelyVisibleItemPosition() | ||||
|                 binding.swipeRefresh.isEnabled = firstPos <= 0 | ||||
|             } | ||||
|             .launchIn(viewScope) | ||||
|  | ||||
|         binding.swipeRefresh.isRefreshing = true | ||||
|         binding.swipeRefresh.setDistanceToTriggerSync((2 * 64 * view.resources.displayMetrics.density).toInt()) | ||||
|         binding.swipeRefresh.refreshes() | ||||
|             .onEach { | ||||
|                 updateLibrary() | ||||
|  | ||||
|                 // It can be a very long operation, so we disable swipe refresh and show a toast. | ||||
|                 binding.swipeRefresh.isRefreshing = false | ||||
|             } | ||||
|             .launchIn(viewScope) | ||||
|  | ||||
|         viewScope.launch { | ||||
|             presenter.updates.collectLatest { updatesItems -> | ||||
|                 destroyActionModeIfNeeded() | ||||
|                 if (adapter == null) { | ||||
|                     adapter = UpdatesAdapter(this@UpdatesController, binding.recycler.context, updatesItems) | ||||
|                     binding.recycler.adapter = adapter | ||||
|                     adapter!!.fastScroller = binding.fastScroller | ||||
|                 } else { | ||||
|                     adapter?.updateDataSet(updatesItems) | ||||
|                 } | ||||
|                 binding.swipeRefresh.isRefreshing = false | ||||
|                 binding.fastScroller.isVisible = true | ||||
|                 binding.recycler.onAnimationsFinished { | ||||
|                     (activity as? MainActivity)?.ready = true | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView(view: View) { | ||||
|         destroyActionModeIfNeeded() | ||||
|         adapter = null | ||||
|         super.onDestroyView(view) | ||||
|     } | ||||
|  | ||||
|     override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { | ||||
|         inflater.inflate(R.menu.updates, menu) | ||||
|     } | ||||
|  | ||||
|     override fun onOptionsItemSelected(item: MenuItem): Boolean { | ||||
|         when (item.itemId) { | ||||
|             R.id.action_update_library -> updateLibrary() | ||||
|         } | ||||
|  | ||||
|         return super.onOptionsItemSelected(item) | ||||
|     } | ||||
|  | ||||
|     private fun updateLibrary() { | ||||
| @@ -154,262 +70,67 @@ class UpdatesController : | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns selected chapters | ||||
|      * @return list of selected chapters | ||||
|      */ | ||||
|     private fun getSelectedChapters(): List<UpdatesItem> { | ||||
|         val adapter = adapter ?: return emptyList() | ||||
|         return adapter.selectedPositions.mapNotNull { adapter.getItem(it) as? UpdatesItem } | ||||
|     // Let compose view handle this | ||||
|     override fun handleBack(): Boolean { | ||||
|         (activity as? OnBackPressedDispatcherOwner)?.onBackPressedDispatcher?.onBackPressed() | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when item in list is clicked | ||||
|      * @param position position of clicked item | ||||
|      */ | ||||
|     override fun onItemClick(view: View, position: Int): Boolean { | ||||
|         val adapter = adapter ?: return false | ||||
|  | ||||
|         // Get item from position | ||||
|         val item = adapter.getItem(position) as? UpdatesItem ?: return false | ||||
|         return if (actionMode != null && adapter.mode == SelectableAdapter.Mode.MULTI) { | ||||
|             toggleSelection(position) | ||||
|             true | ||||
|         } else { | ||||
|             openChapter(item) | ||||
|             false | ||||
|         } | ||||
|     private fun onBackClicked() { | ||||
|         (activity as? MainActivity)?.moveToStartScreen() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when item in list is long clicked | ||||
|      * @param position position of clicked item | ||||
|      */ | ||||
|     override fun onItemLongClick(position: Int) { | ||||
|         val activity = activity | ||||
|         if (actionMode == null && activity is MainActivity) { | ||||
|             actionMode = activity.startActionModeAndToolbar(this) | ||||
|             activity.showBottomNav(false) | ||||
|         } | ||||
|         toggleSelection(position) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called to toggle selection | ||||
|      * @param position position of selected item | ||||
|      */ | ||||
|     private fun toggleSelection(position: Int) { | ||||
|         val adapter = adapter ?: return | ||||
|         adapter.toggleSelection(position) | ||||
|         actionMode?.invalidate() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Open chapter in reader | ||||
|      * @param chapter selected chapter | ||||
|      */ | ||||
|     private fun openChapter(item: UpdatesItem) { | ||||
|         val activity = activity ?: return | ||||
|         val intent = ReaderActivity.newIntent(activity, item.manga.id, item.chapter.id) | ||||
|         startActivity(intent) | ||||
|     private fun toggleNavBarVisibility(navBarVisibility: NavBarVisibility) { | ||||
|         val showNavBar = navBarVisibility.toBoolean() | ||||
|         (activity as? MainActivity)?.showBottomNav(showNavBar) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Download selected items | ||||
|      * @param chapters list of selected [UpdatesItem]s | ||||
|      * @param items list of selected [UpdatesItem]s | ||||
|      */ | ||||
|     private fun downloadChapters(chapters: List<UpdatesItem>) { | ||||
|         presenter.downloadChapters(chapters) | ||||
|         destroyActionModeIfNeeded() | ||||
|     } | ||||
|  | ||||
|     override fun onUpdateEmptyView(size: Int) { | ||||
|         if (size > 0) { | ||||
|             binding.emptyView.hide() | ||||
|         } else { | ||||
|             binding.emptyView.show(R.string.information_no_recent) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Update download status of chapter | ||||
|      * @param download [Download] object containing download progress. | ||||
|      */ | ||||
|     fun onChapterDownloadUpdate(download: Download) { | ||||
|         adapter?.currentItems | ||||
|             ?.filterIsInstance<UpdatesItem>() | ||||
|             ?.find { it.chapter.id == download.chapter.id }?.let { | ||||
|                 adapter?.updateItem(it, it.status) | ||||
|     private fun downloadChapters(items: List<UpdatesItem>, action: ChapterDownloadAction) { | ||||
|         if (items.isEmpty()) return | ||||
|         viewScope.launch { | ||||
|             when (action) { | ||||
|                 ChapterDownloadAction.START -> { | ||||
|                     presenter.downloadChapters(items) | ||||
|                     if (items.any { it.downloadStateProvider() == Download.State.ERROR }) { | ||||
|                         DownloadService.start(activity!!) | ||||
|                     } | ||||
|                 } | ||||
|                 ChapterDownloadAction.START_NOW -> { | ||||
|                     val chapterId = items.singleOrNull()?.update?.chapterId ?: return@launch | ||||
|                     presenter.startDownloadingNow(chapterId) | ||||
|                 } | ||||
|                 ChapterDownloadAction.CANCEL -> { | ||||
|                     val chapterId = items.singleOrNull()?.update?.chapterId ?: return@launch | ||||
|                     presenter.cancelDownload(chapterId) | ||||
|                 } | ||||
|                 ChapterDownloadAction.DELETE -> { | ||||
|                     presenter.deleteChapters(items) | ||||
|                 } | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Mark chapter as read | ||||
|      * @param chapters list of chapters | ||||
|      */ | ||||
|     private fun markAsRead(chapters: List<UpdatesItem>) { | ||||
|         presenter.markChapterRead(chapters, true) | ||||
|         destroyActionModeIfNeeded() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Mark chapter as unread | ||||
|      * @param chapters list of selected [UpdatesItem] | ||||
|      */ | ||||
|     private fun markAsUnread(chapters: List<UpdatesItem>) { | ||||
|         presenter.markChapterRead(chapters, false) | ||||
|         destroyActionModeIfNeeded() | ||||
|     } | ||||
|  | ||||
|     override fun deleteChapters(chaptersToDelete: List<UpdatesItem>) { | ||||
|         presenter.deleteChapters(chaptersToDelete) | ||||
|         destroyActionModeIfNeeded() | ||||
|     } | ||||
|  | ||||
|     private fun destroyActionModeIfNeeded() { | ||||
|         actionMode?.finish() | ||||
|     } | ||||
|  | ||||
|     override fun onCoverClick(position: Int) { | ||||
|         destroyActionModeIfNeeded() | ||||
|  | ||||
|         val chapterClicked = adapter?.getItem(position) as? UpdatesItem ?: return | ||||
|         openManga(chapterClicked) | ||||
|     } | ||||
|  | ||||
|     private fun openManga(chapter: UpdatesItem) { | ||||
|         router.pushController(MangaController(chapter.manga.id!!)) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when chapters are deleted | ||||
|      */ | ||||
|     fun onChaptersDeleted() { | ||||
|         adapter?.notifyDataSetChanged() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when error while deleting | ||||
|      * @param error error message | ||||
|      */ | ||||
|     fun onChaptersDeletedError(error: Throwable) { | ||||
|         logcat(LogPriority.ERROR, error) | ||||
|     } | ||||
|  | ||||
|     override fun downloadChapter(position: Int) { | ||||
|         val item = adapter?.getItem(position) as? UpdatesItem ?: return | ||||
|         if (item.status == Download.State.ERROR) { | ||||
|             DownloadService.start(activity!!) | ||||
|         } else { | ||||
|             downloadChapters(listOf(item)) | ||||
|         } | ||||
|         adapter?.updateItem(item) | ||||
|     } | ||||
|  | ||||
|     override fun deleteChapter(position: Int) { | ||||
|         val item = adapter?.getItem(position) as? UpdatesItem ?: return | ||||
|         deleteChapters(listOf(item)) | ||||
|         adapter?.updateItem(item) | ||||
|     } | ||||
|  | ||||
|     override fun startDownloadNow(position: Int) { | ||||
|         val item = adapter?.getItem(position) as? UpdatesItem ?: return | ||||
|         presenter.startDownloadingNow(item.chapter) | ||||
|     } | ||||
|  | ||||
|     private fun bookmarkChapters(chapters: List<UpdatesItem>, bookmarked: Boolean) { | ||||
|         presenter.bookmarkChapters(chapters, bookmarked) | ||||
|         destroyActionModeIfNeeded() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when ActionMode created. | ||||
|      * @param mode the ActionMode object | ||||
|      * @param menu menu object of ActionMode | ||||
|      */ | ||||
|     override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { | ||||
|         mode.menuInflater.inflate(R.menu.generic_selection, menu) | ||||
|         adapter?.mode = SelectableAdapter.Mode.MULTI | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     override fun onCreateActionToolbar(menuInflater: MenuInflater, menu: Menu) { | ||||
|         menuInflater.inflate(R.menu.updates_chapter_selection, menu) | ||||
|     } | ||||
|  | ||||
|     override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { | ||||
|         val count = adapter?.selectedItemCount ?: 0 | ||||
|         if (count == 0) { | ||||
|             // Destroy action mode if there are no items selected. | ||||
|             destroyActionModeIfNeeded() | ||||
|         } else { | ||||
|             mode.title = count.toString() | ||||
|     private fun deleteChaptersWithConfirmation(items: List<UpdatesItem>) { | ||||
|         if (items.isEmpty()) return | ||||
|         viewScope.launch { | ||||
|             val result = MaterialAlertDialogBuilder(activity!!) | ||||
|                 .setMessage(R.string.confirm_delete_chapters) | ||||
|                 .await(android.R.string.ok, android.R.string.cancel) | ||||
|             if (result == AlertDialog.BUTTON_POSITIVE) presenter.deleteChapters(items) | ||||
|         } | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     override fun onPrepareActionToolbar(toolbar: ActionModeWithToolbar, menu: Menu) { | ||||
|         val chapters = getSelectedChapters() | ||||
|         if (chapters.isEmpty()) return | ||||
|         toolbar.findToolbarItem(R.id.action_download)?.isVisible = chapters.any { !it.isDownloaded } | ||||
|         toolbar.findToolbarItem(R.id.action_delete)?.isVisible = chapters.any { it.isDownloaded } | ||||
|         toolbar.findToolbarItem(R.id.action_bookmark)?.isVisible = chapters.any { !it.chapter.bookmark } | ||||
|         toolbar.findToolbarItem(R.id.action_remove_bookmark)?.isVisible = chapters.all { it.chapter.bookmark } | ||||
|         toolbar.findToolbarItem(R.id.action_mark_as_read)?.isVisible = chapters.any { !it.chapter.read } | ||||
|         toolbar.findToolbarItem(R.id.action_mark_as_unread)?.isVisible = chapters.all { it.chapter.read } | ||||
|     private fun openChapter(item: UpdatesItem) { | ||||
|         val activity = activity ?: return | ||||
|         val intent = ReaderActivity.newIntent(activity, item.update.mangaId, item.update.chapterId) | ||||
|         startActivity(intent) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when ActionMode item clicked | ||||
|      * @param mode the ActionMode object | ||||
|      * @param item item from ActionMode. | ||||
|      */ | ||||
|     override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { | ||||
|         return onActionItemClicked(item) | ||||
|     } | ||||
|  | ||||
|     private fun onActionItemClicked(item: MenuItem): Boolean { | ||||
|         when (item.itemId) { | ||||
|             R.id.action_select_all -> selectAll() | ||||
|             R.id.action_select_inverse -> selectInverse() | ||||
|             R.id.action_download -> downloadChapters(getSelectedChapters()) | ||||
|             R.id.action_delete -> | ||||
|                 ConfirmDeleteChaptersDialog(this, getSelectedChapters()) | ||||
|                     .showDialog(router) | ||||
|             R.id.action_bookmark -> bookmarkChapters(getSelectedChapters(), true) | ||||
|             R.id.action_remove_bookmark -> bookmarkChapters(getSelectedChapters(), false) | ||||
|             R.id.action_mark_as_read -> markAsRead(getSelectedChapters()) | ||||
|             R.id.action_mark_as_unread -> markAsUnread(getSelectedChapters()) | ||||
|             else -> return false | ||||
|         } | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when ActionMode destroyed | ||||
|      * @param mode the ActionMode object | ||||
|      */ | ||||
|     override fun onDestroyActionMode(mode: ActionMode) { | ||||
|         adapter?.mode = SelectableAdapter.Mode.IDLE | ||||
|         adapter?.clearSelection() | ||||
|  | ||||
|         (activity as? MainActivity)?.showBottomNav(true) | ||||
|  | ||||
|         actionMode = null | ||||
|     } | ||||
|  | ||||
|     private fun selectAll() { | ||||
|         val adapter = adapter ?: return | ||||
|         adapter.selectAll() | ||||
|         actionMode?.invalidate() | ||||
|     } | ||||
|  | ||||
|     private fun selectInverse() { | ||||
|         val adapter = adapter ?: return | ||||
|         for (i in 0..adapter.itemCount) { | ||||
|             adapter.toggleSelection(i) | ||||
|         } | ||||
|         actionMode?.invalidate() | ||||
|         adapter.notifyDataSetChanged() | ||||
|     private fun openManga(item: UpdatesItem) { | ||||
|         router.pushController(MangaController(item.update.mangaId)) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -1,233 +1,321 @@ | ||||
| package eu.kanade.tachiyomi.ui.recent.updates | ||||
|  | ||||
| import android.os.Bundle | ||||
| import eu.kanade.data.DatabaseHandler | ||||
| import eu.kanade.data.manga.mangaChapterMapper | ||||
| import androidx.compose.runtime.Immutable | ||||
| import eu.kanade.core.util.insertSeparators | ||||
| import eu.kanade.domain.chapter.interactor.GetChapter | ||||
| import eu.kanade.domain.chapter.interactor.SetReadStatus | ||||
| import eu.kanade.domain.chapter.interactor.UpdateChapter | ||||
| import eu.kanade.domain.chapter.model.Chapter | ||||
| import eu.kanade.domain.chapter.model.ChapterUpdate | ||||
| import eu.kanade.domain.chapter.model.toDbChapter | ||||
| import eu.kanade.domain.manga.model.Manga | ||||
| import eu.kanade.domain.manga.interactor.GetManga | ||||
| import eu.kanade.domain.updates.interactor.GetUpdates | ||||
| import eu.kanade.domain.updates.model.UpdatesWithRelations | ||||
| import eu.kanade.presentation.updates.UpdatesUiModel | ||||
| import eu.kanade.tachiyomi.data.download.DownloadManager | ||||
| import eu.kanade.tachiyomi.data.download.model.Download | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.source.SourceManager | ||||
| import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter | ||||
| import eu.kanade.tachiyomi.ui.recent.DateSectionItem | ||||
| import eu.kanade.tachiyomi.util.lang.launchIO | ||||
| import eu.kanade.tachiyomi.util.lang.toDateKey | ||||
| import eu.kanade.tachiyomi.util.lang.withUIContext | ||||
| import eu.kanade.tachiyomi.util.preference.asHotFlow | ||||
| import eu.kanade.tachiyomi.util.system.logcat | ||||
| import kotlinx.coroutines.Job | ||||
| import kotlinx.coroutines.flow.MutableStateFlow | ||||
| import kotlinx.coroutines.flow.StateFlow | ||||
| import kotlinx.coroutines.flow.asStateFlow | ||||
| import kotlinx.coroutines.flow.catch | ||||
| import kotlinx.coroutines.flow.collectLatest | ||||
| import kotlinx.coroutines.flow.map | ||||
| import kotlinx.coroutines.flow.launchIn | ||||
| import kotlinx.coroutines.flow.update | ||||
| import logcat.LogPriority | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.text.DateFormat | ||||
| import java.util.Calendar | ||||
| import java.util.Date | ||||
| import java.util.TreeMap | ||||
|  | ||||
| class UpdatesPresenter( | ||||
|     private val preferences: PreferencesHelper = Injekt.get(), | ||||
|     private val downloadManager: DownloadManager = Injekt.get(), | ||||
|     private val sourceManager: SourceManager = Injekt.get(), | ||||
|     private val handler: DatabaseHandler = Injekt.get(), | ||||
|     private val updateChapter: UpdateChapter = Injekt.get(), | ||||
|     private val setReadStatus: SetReadStatus = Injekt.get(), | ||||
|     private val getUpdates: GetUpdates = Injekt.get(), | ||||
|     private val getManga: GetManga = Injekt.get(), | ||||
|     private val sourceManager: SourceManager = Injekt.get(), | ||||
|     private val downloadManager: DownloadManager = Injekt.get(), | ||||
|     private val getChapter: GetChapter = Injekt.get(), | ||||
|     private val preferences: PreferencesHelper = Injekt.get(), | ||||
| ) : BasePresenter<UpdatesController>() { | ||||
|  | ||||
|     private val relativeTime: Int = preferences.relativeTime().get() | ||||
|     private val dateFormat: DateFormat = preferences.dateFormat() | ||||
|     private val _state: MutableStateFlow<UpdatesState> = MutableStateFlow(UpdatesState.Loading) | ||||
|     val state: StateFlow<UpdatesState> = _state.asStateFlow() | ||||
|  | ||||
|     private val _updates: MutableStateFlow<List<UpdatesItem>> = MutableStateFlow(listOf()) | ||||
|     val updates: StateFlow<List<UpdatesItem>> = _updates.asStateFlow() | ||||
|     /** | ||||
|      * Helper function to update the UI state only if it's currently in success state | ||||
|      */ | ||||
|     private fun updateSuccessState(func: (UpdatesState.Success) -> UpdatesState.Success) { | ||||
|         _state.update { if (it is UpdatesState.Success) func(it) else it } | ||||
|     } | ||||
|  | ||||
|     private var incognitoMode = false | ||||
|         set(value) { | ||||
|             updateSuccessState { it.copy(isIncognitoMode = value) } | ||||
|             field = value | ||||
|         } | ||||
|     private var downloadOnlyMode = false | ||||
|         set(value) { | ||||
|             updateSuccessState { it.copy(isDownloadedOnlyMode = value) } | ||||
|             field = value | ||||
|         } | ||||
|  | ||||
|     /** | ||||
|      * Subscription to observe download status changes. | ||||
|      */ | ||||
|     private var observeDownloadsStatusJob: Job? = null | ||||
|     private var observeDownloadsPageJob: Job? = null | ||||
|  | ||||
|     override fun onCreate(savedState: Bundle?) { | ||||
|         super.onCreate(savedState) | ||||
|  | ||||
|         presenterScope.launchIO { | ||||
|             subscribeToUpdates() | ||||
|             // Set date limit for recent chapters | ||||
|             val calendar = Calendar.getInstance().apply { | ||||
|                 time = Date() | ||||
|                 add(Calendar.MONTH, -3) | ||||
|             } | ||||
|  | ||||
|             getUpdates.subscribe(calendar) | ||||
|                 .catch { exception -> | ||||
|                     _state.value = UpdatesState.Error(exception) | ||||
|                 } | ||||
|                 .collectLatest { updates -> | ||||
|                     val uiModels = updates.toUpdateUiModels() | ||||
|                     _state.update { currentState -> | ||||
|                         when (currentState) { | ||||
|                             is UpdatesState.Success -> currentState.copy(uiModels) | ||||
|                             is UpdatesState.Loading, is UpdatesState.Error -> | ||||
|                                 UpdatesState.Success( | ||||
|                                     uiModels = uiModels, | ||||
|                                     isIncognitoMode = incognitoMode, | ||||
|                                     isDownloadedOnlyMode = downloadOnlyMode, | ||||
|                                 ) | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     observeDownloads() | ||||
|                 } | ||||
|         } | ||||
|  | ||||
|         preferences.incognitoMode() | ||||
|             .asHotFlow { incognito -> | ||||
|                 incognitoMode = incognito | ||||
|             } | ||||
|             .launchIn(presenterScope) | ||||
|  | ||||
|         preferences.downloadedOnly() | ||||
|             .asHotFlow { downloadedOnly -> | ||||
|                 downloadOnlyMode = downloadedOnly | ||||
|             } | ||||
|             .launchIn(presenterScope) | ||||
|     } | ||||
|  | ||||
|     private fun List<UpdatesWithRelations>.toUpdateUiModels(): List<UpdatesUiModel> { | ||||
|         return this.map { update -> | ||||
|             val activeDownload = downloadManager.queue.find { update.chapterId == it.chapter.id } | ||||
|             val downloaded = downloadManager.isChapterDownloaded( | ||||
|                 update.chapterName, | ||||
|                 update.scanlator, | ||||
|                 update.mangaTitle, | ||||
|                 update.sourceId, | ||||
|             ) | ||||
|             val downloadState = when { | ||||
|                 activeDownload != null -> activeDownload.status | ||||
|                 downloaded -> Download.State.DOWNLOADED | ||||
|                 else -> Download.State.NOT_DOWNLOADED | ||||
|             } | ||||
|             val item = UpdatesItem( | ||||
|                 update = update, | ||||
|                 downloadStateProvider = { downloadState }, | ||||
|                 downloadProgressProvider = { activeDownload?.progress ?: 0 }, | ||||
|             ) | ||||
|             UpdatesUiModel.Item(item) | ||||
|         } | ||||
|             .insertSeparators { before, after -> | ||||
|                 val beforeDate = before?.item?.update?.dateFetch?.toDateKey() ?: Date(0) | ||||
|                 val afterDate = after?.item?.update?.dateFetch?.toDateKey() ?: Date(0) | ||||
|                 when { | ||||
|                     beforeDate.time != afterDate.time && afterDate.time != 0L -> | ||||
|                         UpdatesUiModel.Header(afterDate) | ||||
|                     // Return null to avoid adding a separator between two items. | ||||
|                     else -> null | ||||
|                 } | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     private suspend fun observeDownloads() { | ||||
|         observeDownloadsStatusJob?.cancel() | ||||
|         observeDownloadsStatusJob = presenterScope.launchIO { | ||||
|             downloadManager.queue.getStatusAsFlow() | ||||
|                 .catch { error -> logcat(LogPriority.ERROR, error) } | ||||
|                 .collectLatest { | ||||
|                     withUIContext { | ||||
|                         onDownloadStatusChange(it) | ||||
|                         view?.onChapterDownloadUpdate(it) | ||||
|                         updateDownloadState(it) | ||||
|                     } | ||||
|                 } | ||||
|         } | ||||
|  | ||||
|         observeDownloadsPageJob?.cancel() | ||||
|         observeDownloadsPageJob = presenterScope.launchIO { | ||||
|             downloadManager.queue.getProgressAsFlow() | ||||
|                 .catch { error -> logcat(LogPriority.ERROR, error) } | ||||
|                 .collectLatest { | ||||
|                     withUIContext { | ||||
|                         view?.onChapterDownloadUpdate(it) | ||||
|                         updateDownloadState(it) | ||||
|                     } | ||||
|                 } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get observable containing recent chapters and date | ||||
|      */ | ||||
|     private suspend fun subscribeToUpdates() { | ||||
|         // Set date limit for recent chapters | ||||
|         val cal = Calendar.getInstance().apply { | ||||
|             time = Date() | ||||
|             add(Calendar.MONTH, -3) | ||||
|         } | ||||
|  | ||||
|         handler | ||||
|             .subscribeToList { | ||||
|                 mangasQueries.getRecentlyUpdated(after = cal.timeInMillis, mangaChapterMapper) | ||||
|             } | ||||
|             .map { mangaChapter -> | ||||
|                 val map = TreeMap<Date, MutableList<Pair<Manga, Chapter>>> { d1, d2 -> d2.compareTo(d1) } | ||||
|                 val byDate = mangaChapter.groupByTo(map) { it.second.dateFetch.toDateKey() } | ||||
|                 byDate.flatMap { entry -> | ||||
|                     val dateItem = DateSectionItem(entry.key, relativeTime, dateFormat) | ||||
|                     entry.value | ||||
|                         .sortedWith(compareBy({ it.second.dateFetch }, { it.second.chapterNumber })).asReversed() | ||||
|                         .map { UpdatesItem(it.second, it.first, dateItem) } | ||||
|                 } | ||||
|             } | ||||
|             .collectLatest { list -> | ||||
|                 list.forEach { item -> | ||||
|                     // Find an active download for this chapter. | ||||
|                     val download = downloadManager.queue.find { it.chapter.id == item.chapter.id } | ||||
|  | ||||
|                     // If there's an active download, assign it, otherwise ask the manager if | ||||
|                     // the chapter is downloaded and assign it to the status. | ||||
|                     if (download != null) { | ||||
|                         item.download = download | ||||
|                     } | ||||
|                 } | ||||
|                 setDownloadedChapters(list) | ||||
|  | ||||
|                 _updates.value = list | ||||
|  | ||||
|                 // Set unread chapter count for bottom bar badge | ||||
|                 preferences.unreadUpdatesCount().set(list.count { !it.chapter.read }) | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Finds and assigns the list of downloaded chapters. | ||||
|      * | ||||
|      * @param items the list of chapter from the database. | ||||
|      */ | ||||
|     private fun setDownloadedChapters(items: List<UpdatesItem>) { | ||||
|         for (item in items) { | ||||
|             val manga = item.manga | ||||
|             val chapter = item.chapter | ||||
|  | ||||
|             if (downloadManager.isChapterDownloaded(chapter.name, chapter.scanlator, manga.title, manga.source)) { | ||||
|                 item.status = Download.State.DOWNLOADED | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Update status of chapters. | ||||
|      * | ||||
|      * @param download download object containing progress. | ||||
|      */ | ||||
|     private fun onDownloadStatusChange(download: Download) { | ||||
|         // Assign the download to the model object. | ||||
|         if (download.status == Download.State.QUEUE) { | ||||
|             val chapters = (view?.adapter?.currentItems ?: emptyList()).filterIsInstance<UpdatesItem>() | ||||
|             val chapter = chapters.find { it.chapter.id == download.chapter.id } | ||||
|             if (chapter != null && chapter.download == null) { | ||||
|                 chapter.download = download | ||||
|     private fun updateDownloadState(download: Download) { | ||||
|         updateSuccessState { successState -> | ||||
|             val modifiedIndex = successState.uiModels.indexOfFirst { | ||||
|                 it is UpdatesUiModel.Item && it.item.update.chapterId == download.chapter.id | ||||
|             } | ||||
|             if (modifiedIndex < 0) return@updateSuccessState successState | ||||
|  | ||||
|             val newUiModels = successState.uiModels.toMutableList().apply { | ||||
|                 var uiModel = removeAt(modifiedIndex) | ||||
|                 if (uiModel is UpdatesUiModel.Item) { | ||||
|                     val item = uiModel.item.copy( | ||||
|                         downloadStateProvider = { download.status }, | ||||
|                         downloadProgressProvider = { download.progress }, | ||||
|                     ) | ||||
|                     uiModel = UpdatesUiModel.Item(item) | ||||
|                 } | ||||
|                 add(modifiedIndex, uiModel) | ||||
|             } | ||||
|             successState.copy(uiModels = newUiModels) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun startDownloadingNow(chapter: Chapter) { | ||||
|         downloadManager.startDownloadNow(chapter.id) | ||||
|     fun startDownloadingNow(chapterId: Long) { | ||||
|         downloadManager.startDownloadNow(chapterId) | ||||
|     } | ||||
|  | ||||
|     fun cancelDownload(chapterId: Long) { | ||||
|         val activeDownload = downloadManager.queue.find { chapterId == it.chapter.id } ?: return | ||||
|         downloadManager.deletePendingDownload(activeDownload) | ||||
|         updateDownloadState(activeDownload.apply { status = Download.State.NOT_DOWNLOADED }) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Mark selected chapter as read | ||||
|      * | ||||
|      * @param items list of selected chapters | ||||
|      * @param read read status | ||||
|      * Mark the selected updates list as read/unread. | ||||
|      * @param updates the list of selected updates. | ||||
|      * @param read whether to mark chapters as read or unread. | ||||
|      */ | ||||
|     fun markChapterRead(items: List<UpdatesItem>, read: Boolean) { | ||||
|     fun markUpdatesRead(updates: List<UpdatesItem>, read: Boolean) { | ||||
|         presenterScope.launchIO { | ||||
|             setReadStatus.await( | ||||
|                 read = read, | ||||
|                 values = items | ||||
|                     .map { it.chapter } | ||||
|                 values = updates | ||||
|                     .mapNotNull { getChapter.await(it.update.chapterId) } | ||||
|                     .toTypedArray(), | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Delete selected chapters | ||||
|      * | ||||
|      * @param chapters list of chapters | ||||
|      * Bookmarks the given list of chapters. | ||||
|      * @param updates the list of chapters to bookmark. | ||||
|      */ | ||||
|     fun deleteChapters(chapters: List<UpdatesItem>) { | ||||
|         launchIO { | ||||
|             try { | ||||
|                 deleteChaptersInternal(chapters) | ||||
|                 withUIContext { view?.onChaptersDeleted() } | ||||
|             } catch (e: Throwable) { | ||||
|                 withUIContext { view?.onChaptersDeletedError(e) } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Mark selected chapters as bookmarked | ||||
|      * @param items list of selected chapters | ||||
|      * @param bookmarked bookmark status | ||||
|      */ | ||||
|     fun bookmarkChapters(items: List<UpdatesItem>, bookmarked: Boolean) { | ||||
|     fun bookmarkUpdates(updates: List<UpdatesItem>, bookmark: Boolean) { | ||||
|         presenterScope.launchIO { | ||||
|             val toUpdate = items.map { | ||||
|                 ChapterUpdate( | ||||
|                     bookmark = bookmarked, | ||||
|                     id = it.chapter.id, | ||||
|                 ) | ||||
|             } | ||||
|             updateChapter.awaitAll(toUpdate) | ||||
|             updates | ||||
|                 .filterNot { it.update.bookmark == bookmark } | ||||
|                 .map { ChapterUpdate(id = it.update.chapterId, bookmark = bookmark) } | ||||
|                 .let { updateChapter.awaitAll(it) } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Download selected chapters | ||||
|      * @param items list of recent chapters seleted. | ||||
|      * Downloads the given list of chapters with the manager. | ||||
|      * @param updatesItem the list of chapters to download. | ||||
|      */ | ||||
|     fun downloadChapters(items: List<UpdatesItem>) { | ||||
|         items.forEach { downloadManager.downloadChapters(it.manga, listOf(it.chapter.toDbChapter())) } | ||||
|     fun downloadChapters(updatesItem: List<UpdatesItem>) { | ||||
|         launchIO { | ||||
|             val groupedUpdates = updatesItem.groupBy { it.update.mangaId }.values | ||||
|             for (updates in groupedUpdates) { | ||||
|                 val mangaId = updates.first().update.mangaId | ||||
|                 val manga = getManga.await(mangaId) ?: continue | ||||
|                 // Don't download if source isn't available | ||||
|                 sourceManager.get(manga.source) ?: continue | ||||
|                 val chapters = updates.mapNotNull { getChapter.await(it.update.chapterId)?.toDbChapter() } | ||||
|                 downloadManager.downloadChapters(manga, chapters) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Delete selected chapters | ||||
|      * | ||||
|      * @param items chapters selected | ||||
|      * @param updatesItem list of chapters | ||||
|      */ | ||||
|     private fun deleteChaptersInternal(chapterItems: List<UpdatesItem>) { | ||||
|         val itemsByManga = chapterItems.groupBy { it.manga.id } | ||||
|         for ((_, items) in itemsByManga) { | ||||
|             val manga = items.first().manga | ||||
|             val source = sourceManager.get(manga.source) ?: continue | ||||
|             val chapters = items.map { it.chapter.toDbChapter() } | ||||
|     fun deleteChapters(updatesItem: List<UpdatesItem>) { | ||||
|         launchIO { | ||||
|             val groupedUpdates = updatesItem.groupBy { it.update.mangaId }.values | ||||
|             val deletedIds = groupedUpdates.flatMap { updates -> | ||||
|                 val mangaId = updates.first().update.mangaId | ||||
|                 val manga = getManga.await(mangaId) ?: return@flatMap emptyList() | ||||
|                 val source = sourceManager.get(manga.source) ?: return@flatMap emptyList() | ||||
|                 val chapters = updates.mapNotNull { getChapter.await(it.update.chapterId)?.toDbChapter() } | ||||
|                 downloadManager.deleteChapters(chapters, manga, source).mapNotNull { it.id } | ||||
|             } | ||||
|             updateSuccessState { successState -> | ||||
|                 val deletedUpdates = successState.uiModels.filter { | ||||
|                     it is UpdatesUiModel.Item && deletedIds.contains(it.item.update.chapterId) | ||||
|                 } | ||||
|                 if (deletedUpdates.isEmpty()) return@updateSuccessState successState | ||||
|  | ||||
|             downloadManager.deleteChapters(chapters, manga, source) | ||||
|             items.forEach { | ||||
|                 it.status = Download.State.NOT_DOWNLOADED | ||||
|                 it.download = null | ||||
|                 // TODO: Don't do this fake status update | ||||
|                 val newUiModels = successState.uiModels.toMutableList().apply { | ||||
|                     deletedUpdates.forEach { deletedUpdate -> | ||||
|                         val modifiedIndex = indexOf(deletedUpdate) | ||||
|                         var uiModel = removeAt(modifiedIndex) | ||||
|                         if (uiModel is UpdatesUiModel.Item) { | ||||
|                             val item = uiModel.item.copy( | ||||
|                                 downloadStateProvider = { Download.State.NOT_DOWNLOADED }, | ||||
|                                 downloadProgressProvider = { 0 }, | ||||
|                             ) | ||||
|                             uiModel = UpdatesUiModel.Item(item) | ||||
|                         } | ||||
|                         add(modifiedIndex, uiModel) | ||||
|                     } | ||||
|                 } | ||||
|                 successState.copy(uiModels = newUiModels) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| sealed class UpdatesState { | ||||
|     object Loading : UpdatesState() | ||||
|     data class Error(val error: Throwable) : UpdatesState() | ||||
|     data class Success( | ||||
|         val uiModels: List<UpdatesUiModel>, | ||||
|         val isIncognitoMode: Boolean = false, | ||||
|         val isDownloadedOnlyMode: Boolean = false, | ||||
|         val showSwipeRefreshIndicator: Boolean = false, | ||||
|     ) : UpdatesState() | ||||
| } | ||||
|  | ||||
| @Immutable | ||||
| data class UpdatesItem( | ||||
|     val update: UpdatesWithRelations, | ||||
|     val downloadStateProvider: () -> Download.State, | ||||
|     val downloadProgressProvider: () -> Int, | ||||
| ) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user