Show loading indicator during migration

Closes #8862
This commit is contained in:
arkon 2023-01-13 23:01:52 -05:00
parent c54d77333f
commit 2df0236669
2 changed files with 306 additions and 270 deletions

View file

@ -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<Int>()
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<MigrateDialogScreenModel.State>(State()) {
val migrateFlags: Preference<Int> by lazy {
preferenceStore.getInt("migrate_flags", Int.MAX_VALUE)
}
private val enhancedServices by lazy {
Injekt.get<TrackManager>().services.filterIsInstance<EnhancedTrackService>()
}
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<SChapter>,
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,
)
}

View file

@ -1,67 +1,21 @@
package eu.kanade.tachiyomi.ui.browse.migration.search 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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow 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.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.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 { class MigrateSearchScreen(private val mangaId: Long) : Screen {
@Composable @Composable
override fun Content() { override fun Content() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { MigrateSearchScreenModel(mangaId = mangaId) } val screenModel = rememberScreenModel { MigrateSearchScreenModel(mangaId = mangaId) }
val state by screenModel.state.collectAsState() val state by screenModel.state.collectAsState()
@ -84,7 +38,6 @@ class MigrateSearchScreen(private val mangaId: Long) : Screen {
) )
when (val dialog = state.dialog) { when (val dialog = state.dialog) {
null -> {}
is MigrateSearchDialog.Migrate -> { is MigrateSearchDialog.Migrate -> {
MigrateDialog( MigrateDialog(
oldManga = state.manga!!, 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<Int>()
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<Int> by lazy {
preferenceStore.getInt("migrate_flags", Int.MAX_VALUE)
}
private val enhancedServices by lazy { Injekt.get<TrackManager>().services.filterIsInstance<EnhancedTrackService>() }
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<SChapter>,
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,
),
)
}
}