From 2df0236669f08e5d12e3f345881ac87eda9e3e42 Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 13 Jan 2023 23:01:52 -0500 Subject: [PATCH] Show loading indicator during migration Closes #8862 --- .../browse/migration/search/MigrateDialog.kt | 304 ++++++++++++++++++ .../migration/search/MigrateSearchScreen.kt | 272 +--------------- 2 files changed, 306 insertions(+), 270 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateDialog.kt diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateDialog.kt new file mode 100644 index 000000000..9137fd00c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateDialog.kt @@ -0,0 +1,304 @@ +package eu.kanade.tachiyomi.ui.browse.migration.search + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.util.fastForEachIndexed +import cafe.adriel.voyager.core.model.StateScreenModel +import eu.kanade.domain.category.interactor.GetCategories +import eu.kanade.domain.category.interactor.SetMangaCategories +import eu.kanade.domain.chapter.interactor.GetChapterByMangaId +import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource +import eu.kanade.domain.chapter.interactor.UpdateChapter +import eu.kanade.domain.chapter.model.toChapterUpdate +import eu.kanade.domain.manga.interactor.UpdateManga +import eu.kanade.domain.manga.model.Manga +import eu.kanade.domain.manga.model.MangaUpdate +import eu.kanade.domain.manga.model.hasCustomCover +import eu.kanade.domain.track.interactor.GetTracks +import eu.kanade.domain.track.interactor.InsertTrack +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.core.preference.Preference +import eu.kanade.tachiyomi.core.preference.PreferenceStore +import eu.kanade.tachiyomi.data.cache.CoverCache +import eu.kanade.tachiyomi.data.track.EnhancedTrackService +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags +import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.util.lang.withUIContext +import kotlinx.coroutines.flow.update +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.Date + +@Composable +internal fun MigrateDialog( + oldManga: Manga, + newManga: Manga, + screenModel: MigrateDialogScreenModel, + onDismissRequest: () -> Unit, + onClickTitle: () -> Unit, + onPopScreen: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val state by screenModel.state.collectAsState() + + val activeFlags = remember { MigrationFlags.getEnabledFlagsPositions(screenModel.migrateFlags.get()) } + val items = remember { + MigrationFlags.titles(oldManga) + .map { context.getString(it) } + .toList() + } + val selected = remember { + mutableStateListOf(*List(items.size) { i -> activeFlags.contains(i) }.toTypedArray()) + } + + if (state.isMigrating) { + LoadingScreen( + modifier = Modifier + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)), + ) + } else { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text(text = stringResource(R.string.migration_dialog_what_to_include)) + }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + items.forEachIndexed { index, title -> + val onChange: () -> Unit = { + selected[index] = !selected[index] + } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onChange), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = selected[index], onCheckedChange = { onChange() }) + Text(text = title) + } + } + } + }, + confirmButton = { + Row { + TextButton( + onClick = { + onClickTitle() + onDismissRequest() + }, + ) { + Text(text = stringResource(R.string.action_show_manga)) + } + + Spacer(modifier = Modifier.weight(1f)) + + TextButton( + onClick = { + scope.launchIO { + screenModel.migrateManga(oldManga, newManga, false) + withUIContext { onPopScreen() } + } + }, + ) { + Text(text = stringResource(R.string.copy)) + } + TextButton( + onClick = { + scope.launchIO { + val selectedIndices = mutableListOf() + selected.fastForEachIndexed { i, b -> if (b) selectedIndices.add(i) } + val newValue = + MigrationFlags.getFlagsFromPositions(selectedIndices.toTypedArray()) + screenModel.migrateFlags.set(newValue) + screenModel.migrateManga(oldManga, newManga, true) + withUIContext { onPopScreen() } + } + }, + ) { + Text(text = stringResource(R.string.migrate)) + } + } + }, + ) + } +} + +internal class MigrateDialogScreenModel( + private val sourceManager: SourceManager = Injekt.get(), + private val updateManga: UpdateManga = Injekt.get(), + private val getChapterByMangaId: GetChapterByMangaId = Injekt.get(), + private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(), + private val updateChapter: UpdateChapter = Injekt.get(), + private val getCategories: GetCategories = Injekt.get(), + private val setMangaCategories: SetMangaCategories = Injekt.get(), + private val getTracks: GetTracks = Injekt.get(), + private val insertTrack: InsertTrack = Injekt.get(), + private val coverCache: CoverCache = Injekt.get(), + private val preferenceStore: PreferenceStore = Injekt.get(), +) : StateScreenModel(State()) { + + val migrateFlags: Preference by lazy { + preferenceStore.getInt("migrate_flags", Int.MAX_VALUE) + } + + private val enhancedServices by lazy { + Injekt.get().services.filterIsInstance() + } + + suspend fun migrateManga(oldManga: Manga, newManga: Manga, replace: Boolean) { + val source = sourceManager.get(newManga.source) ?: return + val prevSource = sourceManager.get(oldManga.source) + + mutableState.update { it.copy(isMigrating = true) } + + try { + val chapters = source.getChapterList(newManga.toSManga()) + + migrateMangaInternal( + oldSource = prevSource, + newSource = source, + oldManga = oldManga, + newManga = newManga, + sourceChapters = chapters, + replace = replace, + ) + } catch (_: Throwable) { + // Explicitly stop if an error occurred; the dialog normally gets popped at the end + // anyway + mutableState.update { it.copy(isMigrating = false) } + } + } + + private suspend fun migrateMangaInternal( + oldSource: Source?, + newSource: Source, + oldManga: Manga, + newManga: Manga, + sourceChapters: List, + replace: Boolean, + ) { + val flags = migrateFlags.get() + + val migrateChapters = MigrationFlags.hasChapters(flags) + val migrateCategories = MigrationFlags.hasCategories(flags) + val migrateTracks = MigrationFlags.hasTracks(flags) + val migrateCustomCover = MigrationFlags.hasCustomCover(flags) + + try { + syncChaptersWithSource.await(sourceChapters, newManga, newSource) + } catch (_: Exception) { + // Worst case, chapters won't be synced + } + + // Update chapters read, bookmark and dateFetch + if (migrateChapters) { + val prevMangaChapters = getChapterByMangaId.await(oldManga.id) + val mangaChapters = getChapterByMangaId.await(newManga.id) + + val maxChapterRead = prevMangaChapters + .filter { it.read } + .maxOfOrNull { it.chapterNumber } + + val updatedMangaChapters = mangaChapters.map { mangaChapter -> + var updatedChapter = mangaChapter + if (updatedChapter.isRecognizedNumber) { + val prevChapter = prevMangaChapters + .find { it.isRecognizedNumber && it.chapterNumber == updatedChapter.chapterNumber } + + if (prevChapter != null) { + updatedChapter = updatedChapter.copy( + dateFetch = prevChapter.dateFetch, + bookmark = prevChapter.bookmark, + ) + } + + if (maxChapterRead != null && updatedChapter.chapterNumber <= maxChapterRead) { + updatedChapter = updatedChapter.copy(read = true) + } + } + + updatedChapter + } + + val chapterUpdates = updatedMangaChapters.map { it.toChapterUpdate() } + updateChapter.awaitAll(chapterUpdates) + } + + // Update categories + if (migrateCategories) { + val categoryIds = getCategories.await(oldManga.id).map { it.id } + setMangaCategories.await(newManga.id, categoryIds) + } + + // Update track + if (migrateTracks) { + val tracks = getTracks.await(oldManga.id).mapNotNull { track -> + val updatedTrack = track.copy(mangaId = newManga.id) + + val service = enhancedServices + .firstOrNull { it.isTrackFrom(updatedTrack, oldManga, oldSource) } + + if (service != null) { + service.migrateTrack(updatedTrack, newManga, newSource) + } else { + updatedTrack + } + } + insertTrack.awaitAll(tracks) + } + + if (replace) { + updateManga.await(MangaUpdate(oldManga.id, favorite = false, dateAdded = 0)) + } + + // Update custom cover (recheck if custom cover exists) + if (migrateCustomCover && oldManga.hasCustomCover()) { + @Suppress("BlockingMethodInNonBlockingContext") + coverCache.setCustomCoverToCache(newManga, coverCache.getCustomCoverFile(oldManga.id).inputStream()) + } + + updateManga.await( + MangaUpdate( + id = newManga.id, + favorite = true, + chapterFlags = oldManga.chapterFlags, + viewerFlags = oldManga.viewerFlags, + dateAdded = if (replace) oldManga.dateAdded else Date().time, + ), + ) + } + + data class State( + val isMigrating: Boolean = false, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt index ba4148a36..baed936bb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt @@ -1,67 +1,21 @@ package eu.kanade.tachiyomi.ui.browse.migration.search -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Checkbox -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.util.fastForEachIndexed -import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow -import eu.kanade.domain.category.interactor.GetCategories -import eu.kanade.domain.category.interactor.SetMangaCategories -import eu.kanade.domain.chapter.interactor.GetChapterByMangaId -import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource -import eu.kanade.domain.chapter.interactor.UpdateChapter -import eu.kanade.domain.chapter.model.toChapterUpdate -import eu.kanade.domain.manga.interactor.UpdateManga -import eu.kanade.domain.manga.model.Manga -import eu.kanade.domain.manga.model.MangaUpdate -import eu.kanade.domain.manga.model.hasCustomCover -import eu.kanade.domain.track.interactor.GetTracks -import eu.kanade.domain.track.interactor.InsertTrack import eu.kanade.presentation.browse.MigrateSearchScreen -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.core.preference.Preference -import eu.kanade.tachiyomi.core.preference.PreferenceStore -import eu.kanade.tachiyomi.data.cache.CoverCache -import eu.kanade.tachiyomi.data.track.EnhancedTrackService -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.source.SourceManager -import eu.kanade.tachiyomi.source.model.SChapter -import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags import eu.kanade.tachiyomi.ui.manga.MangaScreen -import eu.kanade.tachiyomi.util.lang.launchIO -import eu.kanade.tachiyomi.util.lang.launchUI -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.util.Date class MigrateSearchScreen(private val mangaId: Long) : Screen { @Composable override fun Content() { val navigator = LocalNavigator.currentOrThrow + val screenModel = rememberScreenModel { MigrateSearchScreenModel(mangaId = mangaId) } val state by screenModel.state.collectAsState() @@ -84,7 +38,6 @@ class MigrateSearchScreen(private val mangaId: Long) : Screen { ) when (val dialog = state.dialog) { - null -> {} is MigrateSearchDialog.Migrate -> { MigrateDialog( oldManga = state.manga!!, @@ -105,228 +58,7 @@ class MigrateSearchScreen(private val mangaId: Long) : Screen { }, ) } + else -> {} } } } - -@Composable -fun MigrateDialog( - oldManga: Manga, - newManga: Manga, - screenModel: MigrateDialogScreenModel, - onDismissRequest: () -> Unit, - onClickTitle: () -> Unit, - onPopScreen: () -> Unit, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val activeFlags = remember { MigrationFlags.getEnabledFlagsPositions(screenModel.migrateFlags.get()) } - val items = remember { - MigrationFlags.titles(oldManga) - .map { context.getString(it) } - .toList() - } - val selected = remember { - mutableStateListOf(*List(items.size) { i -> activeFlags.contains(i) }.toTypedArray()) - } - AlertDialog( - onDismissRequest = onDismissRequest, - title = { - Text(text = stringResource(R.string.migration_dialog_what_to_include)) - }, - text = { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - ) { - items.forEachIndexed { index, title -> - val onChange: () -> Unit = { - selected[index] = !selected[index] - } - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onChange), - verticalAlignment = Alignment.CenterVertically, - ) { - Checkbox(checked = selected[index], onCheckedChange = { onChange() }) - Text(text = title) - } - } - } - }, - confirmButton = { - Row { - TextButton(onClick = { - onClickTitle() - onDismissRequest() - },) { - Text(text = stringResource(R.string.action_show_manga)) - } - Spacer(modifier = Modifier.weight(1f)) - TextButton(onClick = { - scope.launchIO { - screenModel.migrateManga(oldManga, newManga, false) - launchUI { - onPopScreen() - } - } - },) { - Text(text = stringResource(R.string.copy)) - } - TextButton(onClick = { - scope.launchIO { - val selectedIndices = mutableListOf() - selected.fastForEachIndexed { i, b -> if (b) selectedIndices.add(i) } - val newValue = MigrationFlags.getFlagsFromPositions(selectedIndices.toTypedArray()) - screenModel.migrateFlags.set(newValue) - screenModel.migrateManga(oldManga, newManga, true) - launchUI { - onPopScreen() - } - } - },) { - Text(text = stringResource(R.string.migrate)) - } - } - }, - ) -} - -class MigrateDialogScreenModel( - private val sourceManager: SourceManager = Injekt.get(), - private val updateManga: UpdateManga = Injekt.get(), - private val getChapterByMangaId: GetChapterByMangaId = Injekt.get(), - private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(), - private val updateChapter: UpdateChapter = Injekt.get(), - private val getCategories: GetCategories = Injekt.get(), - private val setMangaCategories: SetMangaCategories = Injekt.get(), - private val getTracks: GetTracks = Injekt.get(), - private val insertTrack: InsertTrack = Injekt.get(), - private val coverCache: CoverCache = Injekt.get(), - private val preferenceStore: PreferenceStore = Injekt.get(), -) : ScreenModel { - - val migrateFlags: Preference by lazy { - preferenceStore.getInt("migrate_flags", Int.MAX_VALUE) - } - - private val enhancedServices by lazy { Injekt.get().services.filterIsInstance() } - - suspend fun migrateManga(oldManga: Manga, newManga: Manga, replace: Boolean) { - val source = sourceManager.get(newManga.source) ?: return - val prevSource = sourceManager.get(oldManga.source) - - try { - val chapters = source.getChapterList(newManga.toSManga()) - - migrateMangaInternal( - oldSource = prevSource, - newSource = source, - oldManga = oldManga, - newManga = newManga, - sourceChapters = chapters, - replace = replace, - ) - } catch (e: Throwable) { - } - } - - private suspend fun migrateMangaInternal( - oldSource: Source?, - newSource: Source, - oldManga: Manga, - newManga: Manga, - sourceChapters: List, - replace: Boolean, - ) { - val flags = migrateFlags.get() - - val migrateChapters = MigrationFlags.hasChapters(flags) - val migrateCategories = MigrationFlags.hasCategories(flags) - val migrateTracks = MigrationFlags.hasTracks(flags) - val migrateCustomCover = MigrationFlags.hasCustomCover(flags) - - try { - syncChaptersWithSource.await(sourceChapters, newManga, newSource) - } catch (e: Exception) { - // Worst case, chapters won't be synced - } - - // Update chapters read, bookmark and dateFetch - if (migrateChapters) { - val prevMangaChapters = getChapterByMangaId.await(oldManga.id) - val mangaChapters = getChapterByMangaId.await(newManga.id) - - val maxChapterRead = prevMangaChapters - .filter { it.read } - .maxOfOrNull { it.chapterNumber } - - val updatedMangaChapters = mangaChapters.map { mangaChapter -> - var updatedChapter = mangaChapter - if (updatedChapter.isRecognizedNumber) { - val prevChapter = prevMangaChapters - .find { it.isRecognizedNumber && it.chapterNumber == updatedChapter.chapterNumber } - - if (prevChapter != null) { - updatedChapter = updatedChapter.copy( - dateFetch = prevChapter.dateFetch, - bookmark = prevChapter.bookmark, - ) - } - - if (maxChapterRead != null && updatedChapter.chapterNumber <= maxChapterRead) { - updatedChapter = updatedChapter.copy(read = true) - } - } - - updatedChapter - } - - val chapterUpdates = updatedMangaChapters.map { it.toChapterUpdate() } - updateChapter.awaitAll(chapterUpdates) - } - - // Update categories - if (migrateCategories) { - val categoryIds = getCategories.await(oldManga.id).map { it.id } - setMangaCategories.await(newManga.id, categoryIds) - } - - // Update track - if (migrateTracks) { - val tracks = getTracks.await(oldManga.id).mapNotNull { track -> - val updatedTrack = track.copy(mangaId = newManga.id) - - val service = enhancedServices - .firstOrNull { it.isTrackFrom(updatedTrack, oldManga, oldSource) } - - if (service != null) { - service.migrateTrack(updatedTrack, newManga, newSource) - } else { - updatedTrack - } - } - insertTrack.awaitAll(tracks) - } - - if (replace) { - updateManga.await(MangaUpdate(oldManga.id, favorite = false, dateAdded = 0)) - } - - // Update custom cover (recheck if custom cover exists) - if (migrateCustomCover && oldManga.hasCustomCover()) { - @Suppress("BlockingMethodInNonBlockingContext") - coverCache.setCustomCoverToCache(newManga, coverCache.getCustomCoverFile(oldManga.id).inputStream()) - } - - updateManga.await( - MangaUpdate( - id = newManga.id, - favorite = true, - chapterFlags = oldManga.chapterFlags, - viewerFlags = oldManga.viewerFlags, - dateAdded = if (replace) oldManga.dateAdded else Date().time, - ), - ) - } -}