Merge branch 'sync-part-final' into feat/add-sync-triggers-experimental

This commit is contained in:
KaiserBh 2023-12-29 05:59:03 +11:00
commit 5426af878e
No known key found for this signature in database
GPG Key ID: 14D73B142042BBA9
10 changed files with 588 additions and 131 deletions

View File

@ -282,7 +282,10 @@ private fun MangaScreenSmallImpl(
onBackClicked() onBackClicked()
} }
} }
BackHandler(onBack = internalOnBackPressed) BackHandler(
enabled = isAnySelected,
onBack = { onAllChapterSelected(false) },
)
Scaffold( Scaffold(
topBar = { topBar = {
@ -540,7 +543,10 @@ fun MangaScreenLargeImpl(
onBackClicked() onBackClicked()
} }
} }
BackHandler(onBack = internalOnBackPressed) BackHandler(
enabled = isAnySelected,
onBack = { onAllChapterSelected(false) },
)
Scaffold( Scaffold(
topBar = { topBar = {

View File

@ -3,6 +3,10 @@ package eu.kanade.presentation.manga.components
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.BitmapDrawable
import android.os.Build import android.os.Build
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -25,15 +29,18 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
@ -48,11 +55,13 @@ import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.presentation.manga.EditCoverAction import eu.kanade.presentation.manga.EditCoverAction
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import soup.compose.material.motion.MotionConstants
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.clickableNoIndication import tachiyomi.presentation.core.util.clickableNoIndication
import kotlin.coroutines.cancellation.CancellationException
@Composable @Composable
fun MangaCoverDialog( fun MangaCoverDialog(
@ -151,10 +160,32 @@ fun MangaCoverDialog(
val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() } val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() }
val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() } val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() }
var scale by remember { mutableFloatStateOf(1f) }
PredictiveBackHandler { progress ->
try {
progress.collect { backEvent ->
scale = lerp(1f, 0.8f, LinearOutSlowInEasing.transform(backEvent.progress))
}
onDismissRequest()
} catch (e: CancellationException) {
animate(
initialValue = scale,
targetValue = 1f,
animationSpec = tween(durationMillis = MotionConstants.DefaultMotionDuration),
) { value, _ ->
scale = value
}
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clickableNoIndication(onClick = onDismissRequest), .clickableNoIndication(onClick = onDismissRequest)
.graphicsLayer {
scaleX = scale
scaleY = scale
},
) { ) {
AndroidView( AndroidView(
factory = { factory = {

View File

@ -1,13 +1,54 @@
package eu.kanade.presentation.util package eu.kanade.presentation.util
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlurEffect
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.util.lerp
import androidx.compose.ui.zIndex
import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.ScreenModelStore import cafe.adriel.voyager.core.model.ScreenModelStore
import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.Screen
@ -16,14 +57,25 @@ import cafe.adriel.voyager.core.screen.uniqueScreenKey
import cafe.adriel.voyager.core.stack.StackEvent import cafe.adriel.voyager.core.stack.StackEvent
import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.transitions.ScreenTransitionContent import cafe.adriel.voyager.transitions.ScreenTransitionContent
import eu.kanade.tachiyomi.util.view.getWindowRadius
import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import soup.compose.material.motion.MotionConstants
import soup.compose.material.motion.animation.materialSharedAxisX import soup.compose.material.motion.animation.materialSharedAxisX
import soup.compose.material.motion.animation.rememberSlideDistance import soup.compose.material.motion.animation.rememberSlideDistance
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.PI
import kotlin.math.sin
/** /**
* For invoking back press to the parent activity * For invoking back press to the parent activity
@ -57,17 +109,299 @@ interface AssistContentScreen {
} }
@Composable @Composable
fun DefaultNavigatorScreenTransition(navigator: Navigator) { fun DefaultNavigatorScreenTransition(
navigator: Navigator,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val view = LocalView.current
val handler = remember {
OnBackHandler(
scope = scope,
windowCornerRadius = view.getWindowRadius(),
onBackPressed = navigator::pop,
)
}
PredictiveBackHandler(enabled = navigator.canPop) { progress ->
progress
.onStart { handler.reset() }
.onCompletion { e ->
if (e == null) {
handler.onBackConfirmed()
} else {
handler.onBackCancelled()
}
}
.collect(handler::onBackEvent)
}
Box(modifier = modifier.onSizeChanged { handler.updateContainerSize(it.toSize()) }) {
val currentSceneEntry = navigator.lastItem
val showPrev by remember {
derivedStateOf { handler.scale < 1f || handler.translationY != 0f }
}
val visibleItems = remember(currentSceneEntry, showPrev) {
if (showPrev) {
val prevSceneEntry = navigator.items.getOrNull(navigator.size - 2)
listOfNotNull(currentSceneEntry, prevSceneEntry)
} else {
listOfNotNull(currentSceneEntry)
}
}
val slideDistance = rememberSlideDistance() val slideDistance = rememberSlideDistance()
ScreenTransition(
navigator = navigator, val screenContent = remember {
transition = { movableContentOf<Screen> { screen ->
navigator.saveableState("transition", screen) {
screen.Content()
}
}
}
visibleItems.forEachIndexed { index, backStackEntry ->
val isPrev = index == 1 && visibleItems.size > 1
if (!isPrev) {
AnimatedContent(
targetState = backStackEntry,
transitionSpec = {
val forward = navigator.lastEvent != StackEvent.Pop
if (!forward && !handler.isReady) {
// Pop screen without animation when predictive back is in use
EnterTransition.None togetherWith ExitTransition.None
} else {
materialSharedAxisX( materialSharedAxisX(
forward = navigator.lastEvent != StackEvent.Pop, forward = forward,
slideDistance = slideDistance, slideDistance = slideDistance,
) )
}
},
modifier = Modifier
.zIndex(1f)
.graphicsLayer {
this.alpha = handler.alpha
this.transformOrigin = TransformOrigin(
pivotFractionX = if (handler.swipeEdge == BackEventCompat.EDGE_LEFT) 0.8f else 0.2f,
pivotFractionY = 0.5f,
)
this.scaleX = handler.scale
this.scaleY = handler.scale
this.translationY = handler.translationY
this.clip = true
this.shape = if (showPrev) {
RoundedCornerShape(handler.windowCornerRadius.toFloat())
} else {
RectangleShape
}
}
.then(
if (showPrev) {
Modifier.pointerInput(Unit) {
// Animated content should not be interactive
}
} else {
Modifier
},
),
content = {
if (visibleItems.size == 2 && visibleItems.getOrNull(1) == it) {
// Avoid drawing previous screen
return@AnimatedContent
}
screenContent(it)
}, },
) )
} else {
Box(
modifier = Modifier
.zIndex(0f)
.drawWithCache {
val bounds = Rect(Offset.Zero, size)
val matrix = ColorMatrix().apply {
// Reduce saturation and brightness
setToSaturation(lerp(1f, 0.95f, handler.alpha))
set(0, 4, lerp(0f, -25f, handler.alpha))
set(1, 4, lerp(0f, -25f, handler.alpha))
set(2, 4, lerp(0f, -25f, handler.alpha))
}
val paint = Paint().apply { colorFilter = ColorFilter.colorMatrix(matrix) }
onDrawWithContent {
drawIntoCanvas {
it.saveLayer(bounds, paint)
drawContent()
it.restore()
}
}
}
.graphicsLayer {
val blurRadius = 5.dp.toPx() * handler.alpha
renderEffect = if (blurRadius > 0f) {
BlurEffect(blurRadius, blurRadius)
} else {
null
}
}
.pointerInput(Unit) {
// bg content should not be interactive
},
content = { screenContent(backStackEntry) },
)
}
}
LaunchedEffect(currentSceneEntry) {
// Reset *after* the screen is popped successfully
// so that the correct transition is applied
handler.setReady()
}
}
}
@Stable
private class OnBackHandler(
private val scope: CoroutineScope,
val windowCornerRadius: Int,
private val onBackPressed: () -> Unit,
) {
var isReady = true
private set
var alpha by mutableFloatStateOf(1f)
private set
var scale by mutableFloatStateOf(1f)
private set
var translationY by mutableFloatStateOf(0f)
private set
var swipeEdge by mutableIntStateOf(BackEventCompat.EDGE_LEFT)
private set
private var containerSize = Size.Zero
private var startPointY = Float.NaN
var isPredictiveBack by mutableStateOf(false)
private set
private var animationJob: Job? = null
set(value) {
isReady = false
field = value
}
fun updateContainerSize(size: Size) {
containerSize = size
}
fun setReady() {
reset()
animationJob?.cancel()
animationJob = null
isReady = true
isPredictiveBack = false
}
fun reset() {
startPointY = Float.NaN
}
fun onBackEvent(backEvent: BackEventCompat) {
if (!isReady) return
isPredictiveBack = true
swipeEdge = backEvent.swipeEdge
val progress = LinearOutSlowInEasing.transform(backEvent.progress)
scale = lerp(1f, 0.85f, progress)
if (startPointY.isNaN()) {
startPointY = backEvent.touchY
}
val deltaYRatio = (backEvent.touchY - startPointY) / containerSize.height
val translateYDistance = containerSize.height / 20
translationY = sin(deltaYRatio * PI * 0.5).toFloat() * translateYDistance * progress
}
fun onBackConfirmed() {
if (!isReady) return
if (isPredictiveBack) {
// Continue predictive animation and pop the screen
val animationSpec = tween<Float>(
durationMillis = MotionConstants.DefaultMotionDuration,
easing = FastOutSlowInEasing,
)
animationJob = scope.launch {
try {
listOf(
async {
animate(
initialValue = alpha,
targetValue = 0f,
animationSpec = animationSpec,
) { value, _ ->
alpha = value
}
},
async {
animate(
initialValue = scale,
targetValue = scale - 0.05f,
animationSpec = animationSpec,
) { value, _ ->
scale = value
}
},
).awaitAll()
} catch (e: CancellationException) {
// no-op
} finally {
onBackPressed()
alpha = 1f
translationY = 0f
scale = 1f
}
}
} else {
// Pop right away and use default transition
onBackPressed()
}
}
fun onBackCancelled() {
// Reset states
isPredictiveBack = false
animationJob = scope.launch {
listOf(
async {
animate(
initialValue = scale,
targetValue = 1f,
) { value, _ ->
scale = value
}
},
async {
animate(
initialValue = alpha,
targetValue = 1f,
) { value, _ ->
alpha = value
}
},
async {
animate(
initialValue = translationY,
targetValue = 0f,
) { value, _ ->
translationY = value
}
},
).awaitAll()
isReady = true
}
}
} }
@Composable @Composable

View File

@ -168,47 +168,28 @@ class SyncManager(
return handler.awaitList { mangasQueries.getAllManga(::mapManga) } return handler.awaitList { mangasQueries.getAllManga(::mapManga) }
} }
/** private suspend fun isMangaDifferent(localManga: Manga, remoteManga: BackupManga): Boolean {
* Determines if there are differences between the local manga details and categories and their corresponding
* remote backup versions. This is used to identify changes or updates that might have occurred.
*
* @param localManga The manga object from the local database of type [Manga].
* @param backupManga The manga object from the remote backup of type [BackupManga].
*
* @return Boolean indicating whether there are differences. Returns true if any of the following is true:
* - The local manga details differ from the remote backup manga details.
* - The chapters of the local manga differ from the chapters of the remote backup manga.
* - The categories of the local manga differ from the categories of the remote backup manga.
*
* This function uses a combination of direct property comparisons and delegated comparison functions
* to assess differences across manga details, chapters, and categories. It relies on proper conversion
* of remote manga and chapters to the local format for accurate comparison.
*/
private suspend fun isMangaDifferent(localManga: Manga, backupManga: BackupManga): Boolean {
val remoteManga = backupManga.getMangaImpl()
val localChapters = handler.await { chaptersQueries.getChaptersByMangaId(localManga.id, 0).executeAsList() } val localChapters = handler.await { chaptersQueries.getChaptersByMangaId(localManga.id, 0).executeAsList() }
val localCategories = getCategories.await(localManga.id).map { it.order } val localCategories = getCategories.await(localManga.id).map { it.order }
return localManga != remoteManga || areChaptersDifferent(localChapters, backupManga.chapters) || localCategories != backupManga.categories return localManga.source != remoteManga.source ||
localManga.url != remoteManga.url ||
localManga.title != remoteManga.title ||
localManga.artist != remoteManga.artist ||
localManga.author != remoteManga.author ||
localManga.description != remoteManga.description ||
localManga.genre != remoteManga.genre ||
localManga.status.toInt() != remoteManga.status ||
localManga.thumbnailUrl != remoteManga.thumbnailUrl ||
localManga.dateAdded != remoteManga.dateAdded ||
localManga.chapterFlags.toInt() != remoteManga.chapterFlags ||
localManga.favorite != remoteManga.favorite ||
localManga.viewerFlags.toInt() != remoteManga.viewer_flags ||
localManga.updateStrategy != remoteManga.updateStrategy ||
areChaptersDifferent(localChapters, remoteManga.chapters) ||
localCategories != remoteManga.categories
} }
/**
* Checks if there are any differences between a list of local chapters and a list of backup chapters.
* This function is used to determine if updates or changes have occurred between the two sets of chapters.
*
* @param localChapters The list of chapters from the local source, of type [Chapters].
* @param remoteChapters The list of chapters from the remote backup source, of type [BackupChapter].
*
* @return Boolean indicating whether there are differences. Returns true if any of the following is true:
* - The count of local and remote chapters differs.
* - Any corresponding chapters (matched by URL) have differing attributes including name, scanlator,
* read status, bookmark status, last page read, chapter number, source order, fetch date, upload date,
* or last modified date.
*
* Each chapter is compared based on a set of fields that define its content and state. If any of these fields
* differ between the local chapter and its corresponding remote chapter, it is considered a difference.
*
*/
private fun areChaptersDifferent(localChapters: List<Chapters>, remoteChapters: List<BackupChapter>): Boolean { private fun areChaptersDifferent(localChapters: List<Chapters>, remoteChapters: List<BackupChapter>): Boolean {
// Early return if the sizes are different // Early return if the sizes are different
if (localChapters.size != remoteChapters.size) { if (localChapters.size != remoteChapters.size) {
@ -234,8 +215,7 @@ class SyncManager(
localChapter.chapter_number != remoteChapter.chapterNumber || localChapter.chapter_number != remoteChapter.chapterNumber ||
localChapter.source_order != remoteChapter.sourceOrder || localChapter.source_order != remoteChapter.sourceOrder ||
localChapter.date_fetch != remoteChapter.dateFetch || localChapter.date_fetch != remoteChapter.dateFetch ||
localChapter.date_upload != remoteChapter.dateUpload || localChapter.date_upload != remoteChapter.dateUpload
localChapter.last_modified_at != remoteChapter.lastModifiedAt
} }
} }

View File

@ -80,94 +80,93 @@ abstract class SyncService(
} }
/** /**
* Merges two lists of SyncManga objects, prioritizing the manga with the most recent lastModifiedAt value. * Merges two lists of BackupManga objects, selecting the most recent manga based on the lastModifiedAt value.
* If lastModifiedAt is null, the function defaults to Instant.MIN for comparison purposes. * If lastModifiedAt is null for a manga, it treats that manga as the oldest possible for comparison purposes.
* This function is designed to reconcile local and remote manga lists, ensuring the most up-to-date manga is retained.
* *
* @param localMangaList The list of local SyncManga objects. * @param localMangaList The list of local BackupManga objects or null.
* @param remoteMangaList The list of remote SyncManga objects. * @param remoteMangaList The list of remote BackupManga objects or null.
* @return The merged list of SyncManga objects. * @return A list of BackupManga objects, each representing the most recent version of the manga from either local or remote sources.
*/ */
private fun mergeMangaLists( private fun mergeMangaLists(
localMangaList: List<BackupManga>?, localMangaList: List<BackupManga>?,
remoteMangaList: List<BackupManga>?, remoteMangaList: List<BackupManga>?,
): List<BackupManga> { ): List<BackupManga> {
if (localMangaList == null) return remoteMangaList ?: emptyList() // Convert null lists to empty to simplify logic
if (remoteMangaList == null) return localMangaList val localMangaListSafe = localMangaList.orEmpty()
val remoteMangaListSafe = remoteMangaList.orEmpty()
val localMangaMap = localMangaList.associateBy { Pair(it.source, it.url) } // Associate both local and remote manga by their unique keys (source and url)
val remoteMangaMap = remoteMangaList.associateBy { Pair(it.source, it.url) } val localMangaMap = localMangaListSafe.associateBy { Pair(it.source, it.url) }
val remoteMangaMap = remoteMangaListSafe.associateBy { Pair(it.source, it.url) }
val mergedMangaMap = mutableMapOf<Pair<Long, String>, BackupManga>() // Prepare to merge both sets of manga
return (localMangaMap.keys + remoteMangaMap.keys).mapNotNull { key ->
val local = localMangaMap[key]
val remote = remoteMangaMap[key]
localMangaMap.forEach { (key, localManga) -> when {
val remoteManga = remoteMangaMap[key] local != null && remote == null -> local
if (remoteManga != null) { local == null && remote != null -> remote
val localMangaLastModifiedAtInstant = Instant.ofEpochMilli(localManga.lastModifiedAt) local != null && remote != null -> {
val remoteMangaLastModifiedAtInstant = Instant.ofEpochMilli(remoteManga.lastModifiedAt) // Compare last modified times and merge chapters
val mergedChapters = mergeChapters(localManga.chapters, remoteManga.chapters) val localTime = Instant.ofEpochMilli(local.lastModifiedAt)
// Keep the more recent manga val remoteTime = Instant.ofEpochMilli(remote.lastModifiedAt)
if (localMangaLastModifiedAtInstant.isAfter(remoteMangaLastModifiedAtInstant)) { val mergedChapters = mergeChapters(local.chapters, remote.chapters)
mergedMangaMap[key] = localManga.copy(chapters = mergedChapters)
if (localTime >= remoteTime) {
local.copy(chapters = mergedChapters)
} else { } else {
mergedMangaMap[key] = remoteManga.copy(chapters = mergedChapters) remote.copy(chapters = mergedChapters)
}
} else {
// If there is no corresponding remote manga, keep the local one
mergedMangaMap[key] = localManga
} }
} }
else -> null // This case occurs if both are null, which shouldn't happen but is handled for completeness.
// Add any remote manga that doesn't exist locally
remoteMangaMap.forEach { (key, remoteManga) ->
if (!mergedMangaMap.containsKey(key)) {
mergedMangaMap[key] = remoteManga
} }
} }
return mergedMangaMap.values.toList()
} }
/** /**
* Merges two lists of SyncChapter objects, prioritizing the chapter with the most recent lastModifiedAt value. * Merges two lists of BackupChapter objects, selecting the most recent chapter based on the lastModifiedAt value.
* If lastModifiedAt is null, the function defaults to Instant.MIN for comparison purposes. * If lastModifiedAt is null for a chapter, it treats that chapter as the oldest possible for comparison purposes.
* This function is designed to reconcile local and remote chapter lists, ensuring the most up-to-date chapter is retained.
* *
* @param localChapters The list of local SyncChapter objects. * @param localChapters The list of local BackupChapter objects.
* @param remoteChapters The list of remote SyncChapter objects. * @param remoteChapters The list of remote BackupChapter objects.
* @return The merged list of SyncChapter objects. * @return A list of BackupChapter objects, each representing the most recent version of the chapter from either local or remote sources.
*
* - This function is used in scenarios where local and remote chapter lists need to be synchronized.
* - It iterates over the union of the URLs from both local and remote chapters.
* - For each URL, it compares the corresponding local and remote chapters based on the lastModifiedAt value.
* - If only one source (local or remote) has the chapter for a URL, that chapter is used.
* - If both sources have the chapter, the one with the more recent lastModifiedAt value is chosen.
* - If lastModifiedAt is null or missing, the chapter is considered the oldest for safety, ensuring that any chapter with a valid timestamp is preferred.
* - The resulting list contains the most recent chapters from the combined set of local and remote chapters.
*/ */
private fun mergeChapters( private fun mergeChapters(
localChapters: List<BackupChapter>, localChapters: List<BackupChapter>,
remoteChapters: List<BackupChapter>, remoteChapters: List<BackupChapter>,
): List<BackupChapter> { ): List<BackupChapter> {
// Associate chapters by URL for both local and remote
val localChapterMap = localChapters.associateBy { it.url } val localChapterMap = localChapters.associateBy { it.url }
val remoteChapterMap = remoteChapters.associateBy { it.url } val remoteChapterMap = remoteChapters.associateBy { it.url }
val mergedChapterMap = mutableMapOf<String, BackupChapter>()
localChapterMap.forEach { (url, localChapter) -> // Merge both chapter maps
return (localChapterMap.keys + remoteChapterMap.keys).mapNotNull { url ->
// Determine the most recent chapter by comparing lastModifiedAt, considering null as Instant.MIN
val localChapter = localChapterMap[url]
val remoteChapter = remoteChapterMap[url] val remoteChapter = remoteChapterMap[url]
if (remoteChapter != null) {
val localInstant = localChapter.lastModifiedAt.let { Instant.ofEpochMilli(it) }
val remoteInstant = remoteChapter.lastModifiedAt.let { Instant.ofEpochMilli(it) }
val mergedChapter = when {
if (localInstant > remoteInstant) { localChapter != null && remoteChapter == null -> localChapter
localChapter localChapter == null && remoteChapter != null -> remoteChapter
} else { localChapter != null && remoteChapter != null -> {
remoteChapter val localInstant = localChapter.lastModifiedAt.let { Instant.ofEpochMilli(it) } ?: Instant.MIN
val remoteInstant = remoteChapter.lastModifiedAt.let { Instant.ofEpochMilli(it) } ?: Instant.MIN
if (localInstant >= remoteInstant) localChapter else remoteChapter
} }
mergedChapterMap[url] = mergedChapter else -> null
} else {
mergedChapterMap[url] = localChapter
} }
} }
remoteChapterMap.forEach { (url, remoteChapter) ->
if (!mergedChapterMap.containsKey(url)) {
mergedChapterMap[url] = remoteChapter
}
}
return mergedChapterMap.values.toList()
} }
/** /**

View File

@ -1,8 +1,11 @@
package eu.kanade.tachiyomi.ui.home package eu.kanade.tachiyomi.ui.home
import androidx.activity.compose.BackHandler import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
@ -23,13 +26,20 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.lerp
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 cafe.adriel.voyager.navigator.tab.LocalTabNavigator import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
@ -49,6 +59,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import soup.compose.material.motion.MotionConstants
import soup.compose.material.motion.animation.materialFadeThroughIn import soup.compose.material.motion.animation.materialFadeThroughIn
import soup.compose.material.motion.animation.materialFadeThroughOut import soup.compose.material.motion.animation.materialFadeThroughOut
import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.library.service.LibraryPreferences
@ -59,6 +70,7 @@ import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.i18n.pluralStringResource import tachiyomi.presentation.core.i18n.pluralStringResource
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import kotlin.coroutines.cancellation.CancellationException
object HomeScreen : Screen() { object HomeScreen : Screen() {
@ -80,6 +92,8 @@ object HomeScreen : Screen() {
@Composable @Composable
override fun Content() { override fun Content() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
var scale by remember { mutableFloatStateOf(1f) }
TabNavigator( TabNavigator(
tab = LibraryTab, tab = LibraryTab,
key = TabNavigatorKey, key = TabNavigatorKey,
@ -118,6 +132,11 @@ object HomeScreen : Screen() {
) { contentPadding -> ) { contentPadding ->
Box( Box(
modifier = Modifier modifier = Modifier
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(0.5f, 1f)
}
.padding(contentPadding) .padding(contentPadding)
.consumeWindowInsets(contentPadding), .consumeWindowInsets(contentPadding),
) { ) {
@ -138,10 +157,30 @@ object HomeScreen : Screen() {
} }
val goToLibraryTab = { tabNavigator.current = LibraryTab } val goToLibraryTab = { tabNavigator.current = LibraryTab }
BackHandler(
enabled = tabNavigator.current != LibraryTab, var handlingBack by remember { mutableStateOf(false) }
onBack = goToLibraryTab, PredictiveBackHandler(enabled = handlingBack || tabNavigator.current != LibraryTab) { progress ->
) handlingBack = true
val currentTab = tabNavigator.current
try {
progress.collect { backEvent ->
scale = lerp(1f, 0.92f, LinearOutSlowInEasing.transform(backEvent.progress))
tabNavigator.current = if (backEvent.progress > 0.25f) tabs[0] else currentTab
}
goToLibraryTab()
} catch (e: CancellationException) {
tabNavigator.current = currentTab
} finally {
animate(
initialValue = scale,
targetValue = 1f,
animationSpec = tween(durationMillis = MotionConstants.DefaultMotionDuration),
) { value, _ ->
scale = value
}
handlingBack = false
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
launch { launch {

View File

@ -11,7 +11,6 @@ import android.os.Bundle
import android.view.View import android.view.View
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
@ -223,14 +222,13 @@ class MainActivity : BaseActivity() {
contentWindowInsets = scaffoldInsets, contentWindowInsets = scaffoldInsets,
) { contentPadding -> ) { contentPadding ->
// Consume insets already used by app state banners // Consume insets already used by app state banners
Box( // Shows current screen
DefaultNavigatorScreenTransition(
navigator = navigator,
modifier = Modifier modifier = Modifier
.padding(contentPadding) .padding(contentPadding)
.consumeWindowInsets(contentPadding), .consumeWindowInsets(contentPadding),
) { )
// Shows current screen
DefaultNavigatorScreenTransition(navigator = navigator)
}
} }
// Pop source-related screens when incognito mode is turned off // Pop source-related screens when incognito mode is turned off

View File

@ -40,6 +40,7 @@ class SettingsScreen(
Destination.Tracking.id -> SettingsTrackingScreen Destination.Tracking.id -> SettingsTrackingScreen
else -> SettingsMainScreen else -> SettingsMainScreen
}, },
onBackPressed = null,
content = { content = {
val pop: () -> Unit = { val pop: () -> Unit = {
if (it.canPop) { if (it.canPop) {
@ -61,6 +62,7 @@ class SettingsScreen(
Destination.Tracking.id -> SettingsTrackingScreen Destination.Tracking.id -> SettingsTrackingScreen
else -> SettingsAppearanceScreen else -> SettingsAppearanceScreen
}, },
onBackPressed = null,
) { ) {
val insets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal) val insets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)
TwoPanelBox( TwoPanelBox(

View File

@ -4,9 +4,11 @@ package eu.kanade.tachiyomi.util.view
import android.content.res.Resources import android.content.res.Resources
import android.graphics.Rect import android.graphics.Rect
import android.os.Build
import android.view.Gravity import android.view.Gravity
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.RoundedCorner
import android.view.View import android.view.View
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@ -95,3 +97,22 @@ fun View?.isVisibleOnScreen(): Boolean {
Rect(0, 0, Resources.getSystem().displayMetrics.widthPixels, Resources.getSystem().displayMetrics.heightPixels) Rect(0, 0, Resources.getSystem().displayMetrics.widthPixels, Resources.getSystem().displayMetrics.heightPixels)
return actualPosition.intersect(screen) return actualPosition.intersect(screen)
} }
/**
* Returns window radius (in pixel) applied to this view
*/
fun View.getWindowRadius(): Int {
val rad = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val windowInsets = rootWindowInsets
listOfNotNull(
windowInsets.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT),
windowInsets.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT),
windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT),
windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT),
)
.minOfOrNull { it.radius }
} else {
null
}
return rad ?: 0
}

View File

@ -1,7 +1,11 @@
package tachiyomi.presentation.core.components package tachiyomi.presentation.core.components
import androidx.activity.compose.BackHandler import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.AnchoredDraggableState import androidx.compose.foundation.gestures.AnchoredDraggableState
@ -26,6 +30,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -34,8 +39,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
@ -45,14 +53,14 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.roundToInt import kotlin.math.roundToInt
private val sheetAnimationSpec = tween<Float>(durationMillis = 350)
@Composable @Composable
fun AdaptiveSheet( fun AdaptiveSheet(
isTabletUi: Boolean, isTabletUi: Boolean,
@ -91,6 +99,11 @@ fun AdaptiveSheet(
) { ) {
Surface( Surface(
modifier = Modifier modifier = Modifier
.predictiveBackAnimation(
enabled = remember { derivedStateOf { alpha > 0f } }.value,
transformOrigin = TransformOrigin.Center,
onBack = internalOnDismissRequest,
)
.requiredWidthIn(max = 460.dp) .requiredWidthIn(max = 460.dp)
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
@ -103,7 +116,6 @@ fun AdaptiveSheet(
shape = MaterialTheme.shapes.extraLarge, shape = MaterialTheme.shapes.extraLarge,
tonalElevation = tonalElevation, tonalElevation = tonalElevation,
content = { content = {
BackHandler(enabled = alpha > 0f, onBack = internalOnDismissRequest)
content() content()
}, },
) )
@ -145,6 +157,11 @@ fun AdaptiveSheet(
) { ) {
Surface( Surface(
modifier = Modifier modifier = Modifier
.predictiveBackAnimation(
enabled = anchoredDraggableState.targetValue == 0,
transformOrigin = TransformOrigin(0.5f, 1f),
onBack = internalOnDismissRequest,
)
.widthIn(max = 460.dp) .widthIn(max = 460.dp)
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
@ -184,10 +201,6 @@ fun AdaptiveSheet(
shape = MaterialTheme.shapes.extraLarge, shape = MaterialTheme.shapes.extraLarge,
tonalElevation = tonalElevation, tonalElevation = tonalElevation,
content = { content = {
BackHandler(
enabled = anchoredDraggableState.targetValue == 0,
onBack = internalOnDismissRequest,
)
content() content()
}, },
) )
@ -257,3 +270,37 @@ private fun <T> AnchoredDraggableState<T>.preUpPostDownNestedScrollConnection()
@JvmName("offsetToFloat") @JvmName("offsetToFloat")
private fun Offset.toFloat(): Float = this.y private fun Offset.toFloat(): Float = this.y
} }
private fun Modifier.predictiveBackAnimation(
enabled: Boolean,
transformOrigin: TransformOrigin,
onBack: () -> Unit,
) = composed {
var scale by remember { mutableFloatStateOf(1f) }
PredictiveBackHandler(enabled = enabled) { progress ->
try {
progress.collect { backEvent ->
scale = lerp(1f, 0.85f, LinearOutSlowInEasing.transform(backEvent.progress))
}
// Completion
onBack()
} catch (e: CancellationException) {
// Cancellation
} finally {
animate(
initialValue = scale,
targetValue = 1f,
animationSpec = spring(stiffness = Spring.StiffnessLow),
) { value, _ ->
scale = value
}
}
}
Modifier.graphicsLayer {
this.scaleX = scale
this.scaleY = scale
this.transformOrigin = transformOrigin
}
}
private val sheetAnimationSpec = tween<Float>(durationMillis = 350)