Some changes
parent
9e269a9e03
commit
57e490fe28
@ -0,0 +1,95 @@
|
|||||||
|
package org.xtimms.tokusho.core.cache
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.os.StatFs
|
||||||
|
import com.tomclaw.cache.DiskLruCache
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okio.Source
|
||||||
|
import okio.buffer
|
||||||
|
import okio.sink
|
||||||
|
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.xtimms.tokusho.utils.FileSize
|
||||||
|
import org.xtimms.tokusho.utils.lang.longHashCode
|
||||||
|
import org.xtimms.tokusho.utils.lang.writeAllCancellable
|
||||||
|
import org.xtimms.tokusho.utils.system.compressToPNG
|
||||||
|
import org.xtimms.tokusho.utils.system.subdir
|
||||||
|
import org.xtimms.tokusho.utils.system.takeIfReadable
|
||||||
|
import org.xtimms.tokusho.utils.system.takeIfWriteable
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class PagesCache @Inject constructor(@ApplicationContext context: Context) {
|
||||||
|
|
||||||
|
private val cacheDir = SuspendLazy {
|
||||||
|
val dirs = context.externalCacheDirs + context.cacheDir
|
||||||
|
dirs.firstNotNullOf {
|
||||||
|
it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private val lruCache = SuspendLazy {
|
||||||
|
val dir = cacheDir.get()
|
||||||
|
val availableSize = (getAvailableSize() * 0.8).toLong()
|
||||||
|
val size = SIZE_DEFAULT.coerceAtMost(availableSize).coerceAtLeast(SIZE_MIN)
|
||||||
|
runCatchingCancellable {
|
||||||
|
DiskLruCache.create(dir, size)
|
||||||
|
}.recoverCatching { error ->
|
||||||
|
error.printStackTrace()
|
||||||
|
dir.deleteRecursively()
|
||||||
|
dir.mkdir()
|
||||||
|
DiskLruCache.create(dir, size)
|
||||||
|
}.getOrThrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun get(url: String): File? {
|
||||||
|
val cache = lruCache.get()
|
||||||
|
return runInterruptible(Dispatchers.IO) {
|
||||||
|
cache.get(url)?.takeIfReadable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun put(url: String, source: Source): File = withContext(Dispatchers.IO) {
|
||||||
|
val file = File(cacheDir.get().parentFile, url.longHashCode().toString())
|
||||||
|
try {
|
||||||
|
val bytes = file.sink(append = false).buffer().use {
|
||||||
|
it.writeAllCancellable(source)
|
||||||
|
}
|
||||||
|
check(bytes != 0L) { "No data has been written" }
|
||||||
|
lruCache.get().put(url, file)
|
||||||
|
} finally {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun put(url: String, bitmap: Bitmap): File = withContext(Dispatchers.IO) {
|
||||||
|
val file = File(cacheDir.get().parentFile, url.longHashCode().toString())
|
||||||
|
try {
|
||||||
|
bitmap.compressToPNG(file)
|
||||||
|
lruCache.get().put(url, file)
|
||||||
|
} finally {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getAvailableSize(): Long = runCatchingCancellable {
|
||||||
|
val statFs = StatFs(cacheDir.get().absolutePath)
|
||||||
|
statFs.availableBytes
|
||||||
|
}.onFailure {
|
||||||
|
it.printStackTrace()
|
||||||
|
}.getOrDefault(SIZE_DEFAULT)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
|
||||||
|
val SIZE_MIN
|
||||||
|
get() = FileSize.MEGABYTES.convert(20, FileSize.BYTES)
|
||||||
|
|
||||||
|
val SIZE_DEFAULT
|
||||||
|
get() = FileSize.MEGABYTES.convert(200, FileSize.BYTES)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
package org.xtimms.tokusho.core.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.LocalLibrary
|
||||||
|
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||||
|
import androidx.compose.material3.FloatingActionButtonElevation
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import org.xtimms.tokusho.R
|
||||||
|
import org.xtimms.tokusho.sections.history.HISTORY_DESTINATION
|
||||||
|
import org.xtimms.tokusho.sections.reader.READER_DESTINATION
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContinueReadingButton(
|
||||||
|
navController: NavController,
|
||||||
|
) {
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
|
||||||
|
val isVisible by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
when (navBackStackEntry?.destination?.route) {
|
||||||
|
HISTORY_DESTINATION, null -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val fabScale by animateFloatAsState(
|
||||||
|
targetValue = when (navBackStackEntry?.destination?.route) {
|
||||||
|
HISTORY_DESTINATION, null -> 1f
|
||||||
|
else -> 0f
|
||||||
|
},
|
||||||
|
animationSpec = tween(150), label = "elevation"
|
||||||
|
)
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isVisible,
|
||||||
|
enter = fadeIn(animationSpec = tween(300, delayMillis = 150)) +
|
||||||
|
scaleIn(
|
||||||
|
initialScale = 0.92f,
|
||||||
|
animationSpec = tween(300, delayMillis = 150)
|
||||||
|
),
|
||||||
|
exit = fadeOut(animationSpec = tween(0))
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.ExtendedFloatingActionButton(
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(
|
||||||
|
READER_DESTINATION
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
elevation = FloatingActionButtonDefaults.elevation(
|
||||||
|
defaultElevation = 4.dp
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.LocalLibrary,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.continue_reading),
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package org.xtimms.tokusho.core.components
|
||||||
|
|
||||||
|
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.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ListGroupHeader(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier = modifier
|
||||||
|
.padding(
|
||||||
|
horizontal = 16.dp,
|
||||||
|
vertical = 4.dp,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
package org.xtimms.tokusho.core.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Search
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.NavigationRail
|
||||||
|
import androidx.compose.material3.NavigationRailItem
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import org.xtimms.tokusho.core.BottomNavDestination
|
||||||
|
import org.xtimms.tokusho.core.BottomNavDestination.Companion.Icon
|
||||||
|
import org.xtimms.tokusho.sections.search.SEARCH_DESTINATION
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NavigationRail(
|
||||||
|
navController: NavController,
|
||||||
|
) {
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
NavigationRail(
|
||||||
|
header = {
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(SEARCH_DESTINATION) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
|
saveState = true
|
||||||
|
}
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Search,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.Bottom
|
||||||
|
) {
|
||||||
|
BottomNavDestination.railValues.forEachIndexed { index, dest ->
|
||||||
|
val isSelected = navBackStackEntry?.destination?.route == dest.route
|
||||||
|
NavigationRailItem(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(dest.route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
|
saveState = true
|
||||||
|
}
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = { dest.Icon(selected = isSelected) },
|
||||||
|
label = { Text(text = stringResource(dest.title)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,175 @@
|
|||||||
|
package org.xtimms.tokusho.core.components.effects
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.EnterTransition
|
||||||
|
import androidx.compose.animation.ExitTransition
|
||||||
|
import androidx.compose.animation.core.MutableTransitionState
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.lazy.LazyItemScope
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.ListUpdateCallback
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.xtimms.tokusho.sections.history.HistoryItemModel
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
enum class RowEntityType { Header, Item }
|
||||||
|
|
||||||
|
data class RowEntity(
|
||||||
|
val type: RowEntityType,
|
||||||
|
val key: String,
|
||||||
|
var contentHash: String? = null,
|
||||||
|
val day: Instant,
|
||||||
|
var historyItemModel: HistoryItemModel?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@SuppressLint("ComposableNaming", "UnusedTransitionTargetStateParameter")
|
||||||
|
/**
|
||||||
|
* @param state Use [updateAnimatedItemsState].
|
||||||
|
*/
|
||||||
|
inline fun LazyListScope.animatedItemsIndexed(
|
||||||
|
state: List<AnimatedItem<RowEntity>>,
|
||||||
|
enterTransition: EnterTransition = expandVertically() + fadeIn(),
|
||||||
|
exitTransition: ExitTransition = shrinkVertically() + fadeOut(),
|
||||||
|
noinline key: ((item: RowEntity) -> Any)? = null,
|
||||||
|
crossinline itemContent: @Composable LazyItemScope.(index: Int, item: RowEntity) -> Unit
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
state.size,
|
||||||
|
if (key != null) { keyIndex: Int -> key(state[keyIndex].item) } else null
|
||||||
|
) { index ->
|
||||||
|
|
||||||
|
val item = state[index]
|
||||||
|
|
||||||
|
key(key?.invoke(item.item)) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visibleState = item.visibility,
|
||||||
|
enter = enterTransition,
|
||||||
|
exit = exitTransition
|
||||||
|
) {
|
||||||
|
itemContent(index, item.item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun updateAnimatedItemsState(
|
||||||
|
newList: List<RowEntity>
|
||||||
|
): State<List<AnimatedItem<RowEntity>>> {
|
||||||
|
|
||||||
|
val state = remember { mutableStateOf(emptyList<AnimatedItem<RowEntity>>()) }
|
||||||
|
val firstInject = remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
state.value = emptyList()
|
||||||
|
onDispose {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(newList) {
|
||||||
|
if (state.value == newList) {
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
val oldList = state.value.toList()
|
||||||
|
|
||||||
|
val diffCb = object : DiffUtil.Callback() {
|
||||||
|
override fun getOldListSize(): Int = oldList.size
|
||||||
|
override fun getNewListSize(): Int = newList.size
|
||||||
|
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
||||||
|
oldList[oldItemPosition].item.key == newList[newItemPosition].key
|
||||||
|
|
||||||
|
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
||||||
|
(oldList[oldItemPosition].item.contentHash
|
||||||
|
?: oldList[oldItemPosition].item.key) == (newList[newItemPosition].contentHash
|
||||||
|
?: newList[newItemPosition].key)
|
||||||
|
|
||||||
|
override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): RowEntity =
|
||||||
|
newList[newItemPosition]
|
||||||
|
}
|
||||||
|
val diffResult = calculateDiff(false, diffCb)
|
||||||
|
val compositeList = oldList.toMutableList()
|
||||||
|
|
||||||
|
diffResult.dispatchUpdatesTo(object : ListUpdateCallback {
|
||||||
|
override fun onInserted(position: Int, count: Int) {
|
||||||
|
for (i in 0 until count) {
|
||||||
|
val newItem = AnimatedItem(
|
||||||
|
visibility = MutableTransitionState(firstInject.value),
|
||||||
|
newList[position + i]
|
||||||
|
)
|
||||||
|
newItem.visibility.targetState = true
|
||||||
|
compositeList.add(position + i, newItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRemoved(position: Int, count: Int) {
|
||||||
|
for (i in 0 until count) {
|
||||||
|
compositeList[position + i].visibility.targetState = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMoved(fromPosition: Int, toPosition: Int) {
|
||||||
|
// not detecting moves.
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onChanged(position: Int, count: Int, payload: Any?) {
|
||||||
|
for (i in 0 until count) {
|
||||||
|
compositeList[position + i].item.historyItemModel = (payload as RowEntity).historyItemModel
|
||||||
|
compositeList[position + i].item.contentHash = payload.contentHash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (state.value != compositeList) {
|
||||||
|
state.value = compositeList
|
||||||
|
}
|
||||||
|
firstInject.value = false
|
||||||
|
val initialAnimation = androidx.compose.animation.core.Animatable(1.0f)
|
||||||
|
initialAnimation.animateTo(0f)
|
||||||
|
state.value = state.value.filter { it.visibility.targetState }
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AnimatedItem<T>(
|
||||||
|
val visibility: MutableTransitionState<Boolean>,
|
||||||
|
val item: T,
|
||||||
|
) {
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return item?.hashCode() ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as AnimatedItem<*>
|
||||||
|
|
||||||
|
if (item != other.item) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun calculateDiff(
|
||||||
|
detectMoves: Boolean = true,
|
||||||
|
diffCb: DiffUtil.Callback
|
||||||
|
): DiffUtil.DiffResult {
|
||||||
|
return withContext(Dispatchers.Unconfined) {
|
||||||
|
DiffUtil.calculateDiff(diffCb, detectMoves)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package org.xtimms.tokusho.core.model
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
|
data class MangaWithHistory(
|
||||||
|
val manga: Manga,
|
||||||
|
val history: MangaHistory
|
||||||
|
)
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package org.xtimms.tokusho.core.motion.sharedelements
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When [visible] becomes false, if transition is running, delay the exit of the content until
|
||||||
|
* transition finishes. Note that you may need to call [SharedElementsRootScope.prepareTransition]
|
||||||
|
* before [visible] becomes false to start transition immediately.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SharedElementsRootScope.DelayExit(
|
||||||
|
visible: Boolean,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
var state by remember { mutableStateOf(DelayExitState.Invisible) }
|
||||||
|
|
||||||
|
when (state) {
|
||||||
|
DelayExitState.Invisible -> {
|
||||||
|
if (visible) state = DelayExitState.Visible
|
||||||
|
}
|
||||||
|
DelayExitState.Visible -> {
|
||||||
|
if (!visible) {
|
||||||
|
state = if (isRunningTransition) DelayExitState.ExitDelayed else DelayExitState.Invisible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DelayExitState.ExitDelayed -> {
|
||||||
|
if (!isRunningTransition) state = DelayExitState.Invisible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state != DelayExitState.Invisible) content()
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class DelayExitState {
|
||||||
|
Invisible, Visible, ExitDelayed
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
package org.xtimms.tokusho.core.motion.sharedelements
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ElementContainer(
|
||||||
|
modifier: Modifier,
|
||||||
|
relaxMaxSize: Boolean = false,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
Layout(content, modifier) { measurables, constraints ->
|
||||||
|
if (measurables.size > 1) {
|
||||||
|
throw IllegalStateException("SharedElement can have only one direct measurable child!")
|
||||||
|
}
|
||||||
|
val placeable = measurables.firstOrNull()?.measure(
|
||||||
|
Constraints(
|
||||||
|
minWidth = 0,
|
||||||
|
minHeight = 0,
|
||||||
|
maxWidth = if (relaxMaxSize) Constraints.Infinity else constraints.maxWidth,
|
||||||
|
maxHeight = if (relaxMaxSize) Constraints.Infinity else constraints.maxHeight
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val width = min(max(constraints.minWidth, placeable?.width ?: 0), constraints.maxWidth)
|
||||||
|
val height = min(max(constraints.minHeight, placeable?.height ?: 0), constraints.maxHeight)
|
||||||
|
layout(width, height) {
|
||||||
|
placeable?.place(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
package org.xtimms.tokusho.core.motion.sharedelements
|
||||||
|
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.lerp
|
||||||
|
|
||||||
|
abstract class KeyframeBasedMotion : PathMotion {
|
||||||
|
|
||||||
|
private var start = Offset.Unspecified
|
||||||
|
private var end = Offset.Unspecified
|
||||||
|
private var keyframes: Pair<FloatArray, LongArray>? = null
|
||||||
|
|
||||||
|
protected abstract fun getKeyframes(start: Offset, end: Offset): Pair<FloatArray, LongArray>
|
||||||
|
|
||||||
|
private fun LongArray.getOffset(index: Int) =
|
||||||
|
@Suppress("INVISIBLE_MEMBER") Offset(get(index))
|
||||||
|
|
||||||
|
override fun invoke(start: Offset, end: Offset, fraction: Float): Offset {
|
||||||
|
var frac = fraction
|
||||||
|
if (start != this.start || end != this.end) {
|
||||||
|
if (start == this.end && end == this.start) {
|
||||||
|
frac = 1 - frac
|
||||||
|
} else {
|
||||||
|
keyframes = null
|
||||||
|
this.start = start
|
||||||
|
this.end = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val (fractions, offsets) = keyframes ?: getKeyframes(start, end).also { keyframes = it }
|
||||||
|
val count = fractions.size
|
||||||
|
|
||||||
|
return when {
|
||||||
|
frac < 0f -> interpolateInRange(fractions, offsets, frac, 0, 1)
|
||||||
|
frac > 1f -> interpolateInRange(fractions, offsets, frac, count - 2, count - 1)
|
||||||
|
frac == 0f -> offsets.getOffset(0)
|
||||||
|
frac == 1f -> offsets.getOffset(count - 1)
|
||||||
|
else -> {
|
||||||
|
// Binary search for the correct section
|
||||||
|
var low = 0
|
||||||
|
var high = count - 1
|
||||||
|
while (low <= high) {
|
||||||
|
val mid = (low + high) / 2
|
||||||
|
val midFraction = fractions[mid]
|
||||||
|
|
||||||
|
when {
|
||||||
|
frac < midFraction -> high = mid - 1
|
||||||
|
frac > midFraction -> low = mid + 1
|
||||||
|
else -> return offsets.getOffset(mid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// now high is below the fraction and low is above the fraction
|
||||||
|
interpolateInRange(fractions, offsets, frac, high, low)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun interpolateInRange(
|
||||||
|
fractions: FloatArray, offsets: LongArray,
|
||||||
|
fraction: Float, startIndex: Int, endIndex: Int
|
||||||
|
): Offset {
|
||||||
|
val startFraction = fractions[startIndex]
|
||||||
|
val endFraction = fractions[endIndex]
|
||||||
|
val intervalFraction = (fraction - startFraction) / (endFraction - startFraction)
|
||||||
|
val start = offsets.getOffset(startIndex)
|
||||||
|
val end = offsets.getOffset(endIndex)
|
||||||
|
return lerp(start, end, intervalFraction)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
package org.xtimms.tokusho.core.motion.sharedelements
|
||||||
|
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
|
||||||
|
class MaterialArcMotion : KeyframeBasedMotion() {
|
||||||
|
|
||||||
|
override fun getKeyframes(start: Offset, end: Offset): Pair<FloatArray, LongArray> =
|
||||||
|
QuadraticBezier.approximate(
|
||||||
|
start,
|
||||||
|
if (start.y > end.y) Offset(end.x, start.y) else Offset(start.x, end.y),
|
||||||
|
end,
|
||||||
|
0.5f
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
val MaterialArcMotionFactory: PathMotionFactory = { MaterialArcMotion() }
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
package org.xtimms.tokusho.core.motion.sharedelements
|
||||||
|
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.layout.ScaleFactor
|
||||||
|
import androidx.compose.ui.layout.lerp
|
||||||
|
|
||||||
|
internal val Rect.area: Float
|
||||||
|
get() = width * height
|
||||||
|
|
||||||
|
internal operator fun Size.div(operand: Size): ScaleFactor =
|
||||||
|
ScaleFactor(width / operand.width, height / operand.height)
|
||||||
|
|
||||||
|
internal fun calculateDirection(start: Rect, end: Rect): TransitionDirection =
|
||||||
|
if (end.area > start.area) TransitionDirection.Enter else TransitionDirection.Return
|
||||||
|
|
||||||
|
internal fun calculateAlpha(
|
||||||
|
direction: TransitionDirection?,
|
||||||
|
fadeMode: FadeMode?,
|
||||||
|
fraction: Float, // Absolute
|
||||||
|
isStart: Boolean
|
||||||
|
) = when (fadeMode) {
|
||||||
|
FadeMode.In, null -> if (isStart) 1f else fraction
|
||||||
|
FadeMode.Out -> if (isStart) 1 - fraction else 1f
|
||||||
|
FadeMode.Cross -> if (isStart) 1 - fraction else fraction
|
||||||
|
FadeMode.Through -> {
|
||||||
|
val threshold = if (direction == TransitionDirection.Enter)
|
||||||
|
FadeThroughProgressThreshold else 1 - FadeThroughProgressThreshold
|
||||||
|
if (fraction < threshold) {
|
||||||
|
if (isStart) 1 - fraction / threshold else 0f
|
||||||
|
} else {
|
||||||
|
if (isStart) 0f else (fraction - threshold) / (1 - threshold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun calculateOffset(
|
||||||
|
start: Rect,
|
||||||
|
end: Rect?,
|
||||||
|
fraction: Float, // Relative
|
||||||
|
pathMotion: PathMotion?,
|
||||||
|
width: Float
|
||||||
|
): Offset = if (end == null) start.topLeft else {
|
||||||
|
val topCenter = pathMotion!!.invoke(
|
||||||
|
start.topCenter,
|
||||||
|
end.topCenter,
|
||||||
|
fraction
|
||||||
|
)
|
||||||
|
Offset(topCenter.x - width / 2, topCenter.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val Identity = ScaleFactor(1f, 1f)
|
||||||
|
|
||||||
|
internal fun calculateScale(
|
||||||
|
start: Rect,
|
||||||
|
end: Rect?,
|
||||||
|
fraction: Float // Relative
|
||||||
|
): ScaleFactor =
|
||||||
|
if (end == null) Identity else lerp(Identity, end.size / start.size, fraction)
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package org.xtimms.tokusho.core.motion.sharedelements
|
||||||
|
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.lerp
|
||||||
|
|
||||||
|
typealias PathMotion = (start: Offset, end: Offset, fraction: Float) -> Offset
|
||||||
|
|
||||||
|
typealias PathMotionFactory = () -> PathMotion
|
||||||
|
|
||||||
|
val LinearMotion: PathMotion = ::lerp
|
||||||
|
|
||||||
|
val LinearMotionFactory: PathMotionFactory = { LinearMotion }
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
package org.xtimms.tokusho.core.motion.sharedelements
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.ui.util.packFloats
|
||||||
|
import androidx.compose.ui.util.unpackFloat1
|
||||||
|
import androidx.compose.ui.util.unpackFloat2
|
||||||
|
|
||||||
|
@JvmInline
|
||||||
|
@Immutable
|
||||||
|
value class ProgressThresholds(private val packedValue: Long) {
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
val start: Float
|
||||||
|
get() = unpackFloat1(packedValue)
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
val end: Float
|
||||||
|
get() = unpackFloat2(packedValue)
|
||||||
|
|
||||||
|
@Suppress("NOTHING_TO_INLINE")
|
||||||
|
@Stable
|
||||||
|
inline operator fun component1(): Float = start
|
||||||
|
|
||||||
|
@Suppress("NOTHING_TO_INLINE")
|
||||||
|
@Stable
|
||||||
|
inline operator fun component2(): Float = end
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
fun ProgressThresholds(start: Float, end: Float) = ProgressThresholds(packFloats(start, end))
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
internal fun ProgressThresholds.applyTo(fraction: Float): Float = when {
|
||||||
|
fraction < start -> 0f
|
||||||
|
fraction in start..end -> (fraction - start) / (end - start)
|
||||||
|
else -> 1f
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
package org.xtimms.tokusho.core.motion.sharedelements
|
||||||
|
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
|
||||||
|
internal object QuadraticBezier {
|
||||||
|
|
||||||
|
private class PointEntry(
|
||||||
|
val t: Float,
|
||||||
|
val point: Offset
|
||||||
|
) {
|
||||||
|
var next: PointEntry? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculate(t: Float, p0: Float, p1: Float, p2: Float): Float {
|
||||||
|
val oneMinusT = 1 - t
|
||||||
|
return oneMinusT * (oneMinusT * p0 + t * p1) + t * (oneMinusT * p1 + t * p2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun coordinate(t: Float, p0: Offset, p1: Offset, p2: Offset): Offset =
|
||||||
|
Offset(
|
||||||
|
calculate(t, p0.x, p1.x, p2.x),
|
||||||
|
calculate(t, p0.y, p1.y, p2.y)
|
||||||
|
)
|
||||||
|
|
||||||
|
fun approximate(
|
||||||
|
p0: Offset, p1: Offset, p2: Offset,
|
||||||
|
acceptableError: Float
|
||||||
|
): Pair<FloatArray, LongArray> {
|
||||||
|
val errorSquared = acceptableError * acceptableError
|
||||||
|
|
||||||
|
val start = PointEntry(0f, coordinate(0f, p0, p1, p2))
|
||||||
|
var cur = start
|
||||||
|
var next = PointEntry(1f, coordinate(1f, p0, p1, p2))
|
||||||
|
start.next = next
|
||||||
|
var count = 2
|
||||||
|
while (true) {
|
||||||
|
var needsSubdivision: Boolean
|
||||||
|
do {
|
||||||
|
val midT = (cur.t + next.t) / 2
|
||||||
|
val midX = (cur.point.x + next.point.x) / 2
|
||||||
|
val midY = (cur.point.y + next.point.y) / 2
|
||||||
|
|
||||||
|
val midPoint = coordinate(midT, p0, p1, p2)
|
||||||
|
val xError = midPoint.x - midX
|
||||||
|
val yError = midPoint.y - midY
|
||||||
|
val midErrorSquared = (xError * xError) + (yError * yError)
|
||||||
|
needsSubdivision = midErrorSquared > errorSquared
|
||||||
|
|
||||||
|
if (needsSubdivision) {
|
||||||
|
val new = PointEntry(midT, midPoint)
|
||||||
|
cur.next = new
|
||||||
|
new.next = next
|
||||||
|
next = new
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
} while (needsSubdivision)
|
||||||
|
cur = next
|
||||||
|
next = cur.next ?: break
|
||||||
|
}
|
||||||
|
|
||||||
|
cur = start
|
||||||
|
var length = 0f
|
||||||
|
var last = Offset.Unspecified
|
||||||
|
val result = LongArray(count)
|
||||||
|
val lengths = FloatArray(count)
|
||||||
|
for (i in result.indices) {
|
||||||
|
val point = cur.point
|
||||||
|
@Suppress("INVISIBLE_MEMBER")
|
||||||
|
result[i] = point.packedValue
|
||||||
|
if (i > 0) {
|
||||||
|
val distance = (point - last).getDistance()
|
||||||
|
length += distance
|
||||||
|
lengths[i] = length
|
||||||
|
}
|
||||||
|
cur = cur.next ?: break
|
||||||
|
last = point
|
||||||
|
}
|
||||||
|
|
||||||
|
if (length > 0) {
|
||||||
|
for (index in lengths.indices) {
|
||||||
|
lengths[index] /= length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lengths to result
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
package org.xtimms.tokusho.core.motion.sharedelements
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalContext
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.layoutId
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.round
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SharedElement(
|
||||||
|
key: Any,
|
||||||
|
screenKey: Any,
|
||||||
|
isFullscreen: Boolean = false,
|
||||||
|
transitionSpec: SharedElementsTransitionSpec = DefaultSharedElementsTransitionSpec,
|
||||||
|
onFractionChanged: ((Float) -> Unit)? = null,
|
||||||
|
placeholder: @Composable (() -> Unit)? = null,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val elementInfo = remember(key, screenKey, transitionSpec, onFractionChanged) {
|
||||||
|
SharedElementInfo(key, screenKey, transitionSpec, onFractionChanged)
|
||||||
|
}
|
||||||
|
val realPlaceholder = placeholder ?: content
|
||||||
|
BaseSharedElement(
|
||||||
|
elementInfo,
|
||||||
|
isFullscreen,
|
||||||
|
realPlaceholder,
|
||||||
|
{ Placeholder(it) },
|
||||||
|
{ ElementContainer(modifier = it, content = content) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Placeholder(state: SharedElementsTransitionState) {
|
||||||
|
with(LocalDensity.current) {
|
||||||
|
val fraction = state.fraction
|
||||||
|
val startBounds = state.startBounds
|
||||||
|
val endBounds = state.endBounds
|
||||||
|
|
||||||
|
val fadeFraction = state.spec?.fadeProgressThresholds?.applyTo(fraction) ?: fraction
|
||||||
|
val scaleFraction = state.spec?.scaleProgressThresholds?.applyTo(fraction) ?: fraction
|
||||||
|
|
||||||
|
val startScale = if (startBounds == null) Identity else
|
||||||
|
calculateScale(startBounds, endBounds, scaleFraction)
|
||||||
|
val offset = if (startBounds == null) IntOffset.Zero else calculateOffset(
|
||||||
|
startBounds, endBounds,
|
||||||
|
fraction, state.pathMotion,
|
||||||
|
startBounds.width * startScale.scaleX
|
||||||
|
).round()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Container(
|
||||||
|
compositionLocalContext: CompositionLocalContext,
|
||||||
|
bounds: Rect?,
|
||||||
|
scaleX: Float,
|
||||||
|
scaleY: Float,
|
||||||
|
isStart: Boolean,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
zIndex: Float = 0f,
|
||||||
|
) {
|
||||||
|
val alpha = if (bounds == null) 1f else
|
||||||
|
calculateAlpha(state.direction, state.spec?.fadeMode, fadeFraction, isStart)
|
||||||
|
if (alpha > 0) {
|
||||||
|
val modifier = if (bounds == null) {
|
||||||
|
Fullscreen.layoutId(FullscreenLayoutId)
|
||||||
|
} else {
|
||||||
|
Modifier.size(
|
||||||
|
bounds.width.toDp(),
|
||||||
|
bounds.height.toDp()
|
||||||
|
).offset { offset }.graphicsLayer {
|
||||||
|
this.transformOrigin = TopLeft
|
||||||
|
this.scaleX = scaleX
|
||||||
|
this.scaleY = scaleY
|
||||||
|
this.alpha = alpha
|
||||||
|
}.run {
|
||||||
|
if (zIndex == 0f) this else zIndex(zIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(compositionLocalContext) {
|
||||||
|
ElementContainer(
|
||||||
|
modifier = modifier,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i in 0..1) {
|
||||||
|
val info = if (i == 0) state.startInfo else state.endInfo ?: break
|
||||||
|
key(info.screenKey) {
|
||||||
|
val (scaleX, scaleY) = if (i == 0) startScale else
|
||||||
|
calculateScale(endBounds!!, startBounds, 1 - scaleFraction)
|
||||||
|
Container(
|
||||||
|
compositionLocalContext = if (i == 0) {
|
||||||
|
state.startCompositionLocalContext
|
||||||
|
} else {
|
||||||
|
state.endCompositionLocalContext!!
|
||||||
|
},
|
||||||
|
bounds = if (i == 0) startBounds else endBounds,
|
||||||
|
scaleX = scaleX,
|
||||||
|
scaleY = scaleY,
|
||||||
|
isStart = i == 0,
|
||||||
|
content = if (i == 0) state.startPlaceholder else state.endPlaceholder!!,
|
||||||
|
zIndex = if (i == 1 && state.spec?.fadeMode == FadeMode.Out) -1f else 0f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,522 @@
|
|||||||
|
package org.xtimms.tokusho.core.motion.sharedelements
|
||||||
|
|
||||||
|
import android.view.Choreographer
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalContext
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.RecomposeScope
|
||||||
|
import androidx.compose.runtime.currentCompositionLocalContext
|
||||||
|
import androidx.compose.runtime.currentRecomposeScope
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import androidx.compose.ui.layout.layoutId
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.toSize
|
||||||
|
import androidx.compose.ui.util.fastForEach
|
||||||
|
import androidx.compose.ui.util.fastMap
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun BaseSharedElement(
|
||||||
|
elementInfo: SharedElementInfo,
|
||||||
|
isFullscreen: Boolean,
|
||||||
|
placeholder: @Composable () -> Unit,
|
||||||
|
overlay: @Composable (SharedElementsTransitionState) -> Unit,
|
||||||
|
content: @Composable (Modifier) -> Unit
|
||||||
|
) {
|
||||||
|
val (savedShouldHide, setShouldHide) = remember { mutableStateOf(false) }
|
||||||
|
val rootState = LocalSharedElementsRootState.current
|
||||||
|
val shouldHide = rootState.onElementRegistered(elementInfo)
|
||||||
|
setShouldHide(shouldHide)
|
||||||
|
|
||||||
|
val compositionLocalContext = currentCompositionLocalContext
|
||||||
|
if (isFullscreen) {
|
||||||
|
rootState.onElementPositioned(
|
||||||
|
elementInfo,
|
||||||
|
compositionLocalContext,
|
||||||
|
placeholder,
|
||||||
|
overlay,
|
||||||
|
null,
|
||||||
|
setShouldHide
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.fillMaxSize())
|
||||||
|
} else {
|
||||||
|
val contentModifier = Modifier.onGloballyPositioned { coordinates ->
|
||||||
|
rootState.onElementPositioned(
|
||||||
|
elementInfo,
|
||||||
|
compositionLocalContext,
|
||||||
|
placeholder,
|
||||||
|
overlay,
|
||||||
|
coordinates,
|
||||||
|
setShouldHide
|
||||||
|
)
|
||||||
|
}.run {
|
||||||
|
if (shouldHide || savedShouldHide) alpha(0f) else this
|
||||||
|
}
|
||||||
|
|
||||||
|
content(contentModifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(elementInfo) {
|
||||||
|
onDispose {
|
||||||
|
rootState.onElementDisposed(elementInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SharedElementsRoot(
|
||||||
|
content: @Composable SharedElementsRootScope.() -> Unit
|
||||||
|
) {
|
||||||
|
val rootState = remember { SharedElementsRootState() }
|
||||||
|
|
||||||
|
Box(modifier = Modifier.onGloballyPositioned { layoutCoordinates ->
|
||||||
|
rootState.rootCoordinates = layoutCoordinates
|
||||||
|
rootState.rootBounds = Rect(Offset.Zero, layoutCoordinates.size.toSize())
|
||||||
|
}) {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalSharedElementsRootState provides rootState,
|
||||||
|
LocalSharedElementsRootScope provides rootState.scope
|
||||||
|
) {
|
||||||
|
rootState.scope.content()
|
||||||
|
UnboundedBox { SharedElementTransitionsOverlay(rootState) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
rootState.onDispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SharedElementsRootScope {
|
||||||
|
val isRunningTransition: Boolean
|
||||||
|
fun prepareTransition(vararg elements: Any)
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalSharedElementsRootScope = staticCompositionLocalOf<SharedElementsRootScope?> { null }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UnboundedBox(content: @Composable () -> Unit) {
|
||||||
|
Layout(content) { measurables, constraints ->
|
||||||
|
val infiniteConstraints = Constraints()
|
||||||
|
val placeables = measurables.fastMap {
|
||||||
|
val isFullscreen = it.layoutId === FullscreenLayoutId
|
||||||
|
it.measure(if (isFullscreen) constraints else infiniteConstraints)
|
||||||
|
}
|
||||||
|
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||||
|
placeables.fastForEach { it.place(0, 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SharedElementTransitionsOverlay(rootState: SharedElementsRootState) {
|
||||||
|
rootState.recomposeScope = currentRecomposeScope
|
||||||
|
rootState.trackers.forEach { (key, tracker) ->
|
||||||
|
key(key) {
|
||||||
|
val transition = tracker.transition
|
||||||
|
val start = (tracker.state as? SharedElementsTracker.State.StartElementPositioned)?.startElement
|
||||||
|
if (transition != null || (start != null && start.bounds == null)) {
|
||||||
|
val startElement = start ?: transition!!.startElement
|
||||||
|
val startScreenKey = startElement.info.screenKey
|
||||||
|
val endElement = (transition as? SharedElementTransition.InProgress)?.endElement
|
||||||
|
val spec = startElement.info.spec
|
||||||
|
val animated = remember(startScreenKey) { Animatable(0f) }
|
||||||
|
val fraction = animated.value
|
||||||
|
startElement.info.onFractionChanged?.invoke(fraction)
|
||||||
|
endElement?.info?.onFractionChanged?.invoke(1 - fraction)
|
||||||
|
|
||||||
|
val direction = if (endElement == null) null else remember(startScreenKey) {
|
||||||
|
val direction = spec.direction
|
||||||
|
if (direction != TransitionDirection.Auto) direction else
|
||||||
|
calculateDirection(
|
||||||
|
startElement.bounds ?: rootState.rootBounds!!,
|
||||||
|
endElement.bounds ?: rootState.rootBounds!!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
startElement.Placeholder(
|
||||||
|
rootState, fraction, endElement,
|
||||||
|
direction, spec, tracker.pathMotion
|
||||||
|
)
|
||||||
|
|
||||||
|
if (transition is SharedElementTransition.InProgress) {
|
||||||
|
LaunchedEffect(transition, animated) {
|
||||||
|
repeat(spec.waitForFrames) { withFrameNanos {} }
|
||||||
|
animated.animateTo(
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = spec.durationMillis,
|
||||||
|
delayMillis = spec.delayMillis,
|
||||||
|
easing = spec.easing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transition.onTransitionFinished()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PositionedSharedElement.Placeholder(
|
||||||
|
rootState: SharedElementsRootState,
|
||||||
|
fraction: Float,
|
||||||
|
end: PositionedSharedElement? = null,
|
||||||
|
direction: TransitionDirection? = null,
|
||||||
|
spec: SharedElementsTransitionSpec? = null,
|
||||||
|
pathMotion: PathMotion? = null
|
||||||
|
) {
|
||||||
|
overlay(
|
||||||
|
SharedElementsTransitionState(
|
||||||
|
fraction = fraction,
|
||||||
|
startInfo = info,
|
||||||
|
startBounds = if (end == null) bounds else bounds ?: rootState.rootBounds,
|
||||||
|
startCompositionLocalContext = compositionLocalContext,
|
||||||
|
startPlaceholder = placeholder,
|
||||||
|
endInfo = end?.info,
|
||||||
|
endBounds = end?.run { bounds ?: rootState.rootBounds },
|
||||||
|
endCompositionLocalContext = end?.compositionLocalContext,
|
||||||
|
endPlaceholder = end?.placeholder,
|
||||||
|
direction = direction,
|
||||||
|
spec = spec,
|
||||||
|
pathMotion = pathMotion
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val LocalSharedElementsRootState = staticCompositionLocalOf<SharedElementsRootState> {
|
||||||
|
error("SharedElementsRoot not found. SharedElement must be hosted in SharedElementsRoot.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SharedElementsRootState {
|
||||||
|
private val choreographer = ChoreographerWrapper()
|
||||||
|
val scope: SharedElementsRootScope = Scope()
|
||||||
|
var trackers by mutableStateOf(mapOf<Any, SharedElementsTracker>())
|
||||||
|
var recomposeScope: RecomposeScope? = null
|
||||||
|
var rootCoordinates: LayoutCoordinates? = null
|
||||||
|
var rootBounds: Rect? = null
|
||||||
|
|
||||||
|
fun onElementRegistered(elementInfo: SharedElementInfo): Boolean {
|
||||||
|
choreographer.removeCallback(elementInfo)
|
||||||
|
return getTracker(elementInfo).onElementRegistered(elementInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onElementPositioned(
|
||||||
|
elementInfo: SharedElementInfo,
|
||||||
|
compositionLocalContext: CompositionLocalContext,
|
||||||
|
placeholder: @Composable () -> Unit,
|
||||||
|
overlay: @Composable (SharedElementsTransitionState) -> Unit,
|
||||||
|
coordinates: LayoutCoordinates?,
|
||||||
|
setShouldHide: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
val element = PositionedSharedElement(
|
||||||
|
info = elementInfo,
|
||||||
|
compositionLocalContext = compositionLocalContext,
|
||||||
|
placeholder = placeholder,
|
||||||
|
overlay = overlay,
|
||||||
|
bounds = coordinates?.calculateBoundsInRoot()
|
||||||
|
)
|
||||||
|
getTracker(elementInfo).onElementPositioned(element, setShouldHide)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onElementDisposed(elementInfo: SharedElementInfo) {
|
||||||
|
choreographer.postCallback(elementInfo) {
|
||||||
|
val tracker = getTracker(elementInfo)
|
||||||
|
tracker.onElementUnregistered(elementInfo)
|
||||||
|
if (tracker.isEmpty) trackers = trackers - elementInfo.key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDispose() {
|
||||||
|
choreographer.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTracker(elementInfo: SharedElementInfo): SharedElementsTracker {
|
||||||
|
return trackers[elementInfo.key] ?: SharedElementsTracker { transition ->
|
||||||
|
recomposeScope?.invalidate()
|
||||||
|
(scope as Scope).isRunningTransition = if (transition != null) true else
|
||||||
|
trackers.values.any { it.transition != null }
|
||||||
|
}.also { trackers = trackers + (elementInfo.key to it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LayoutCoordinates.calculateBoundsInRoot(): Rect =
|
||||||
|
Rect(
|
||||||
|
rootCoordinates?.localPositionOf(this, Offset.Zero)
|
||||||
|
?: positionInRoot(), size.toSize()
|
||||||
|
)
|
||||||
|
|
||||||
|
private inner class Scope : SharedElementsRootScope {
|
||||||
|
|
||||||
|
override var isRunningTransition: Boolean by mutableStateOf(false)
|
||||||
|
|
||||||
|
override fun prepareTransition(vararg elements: Any) {
|
||||||
|
elements.forEach {
|
||||||
|
trackers[it]?.prepareTransition()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SharedElementsTracker(
|
||||||
|
private val onTransitionChanged: (SharedElementTransition?) -> Unit
|
||||||
|
) {
|
||||||
|
var state: State = State.Empty
|
||||||
|
|
||||||
|
var pathMotion: PathMotion? = null
|
||||||
|
|
||||||
|
// Use snapshot state to trigger recomposition of start element when transition starts
|
||||||
|
private var _transition: SharedElementTransition? by mutableStateOf(null)
|
||||||
|
var transition: SharedElementTransition?
|
||||||
|
get() = _transition
|
||||||
|
set(value) {
|
||||||
|
if (_transition != value) {
|
||||||
|
_transition = value
|
||||||
|
if (value == null) pathMotion = null
|
||||||
|
onTransitionChanged(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val isEmpty: Boolean get() = state is State.Empty
|
||||||
|
|
||||||
|
private fun State.StartElementPositioned.prepareTransition() {
|
||||||
|
if (transition !is SharedElementTransition.WaitingForEndElementPosition) {
|
||||||
|
transition = SharedElementTransition.WaitingForEndElementPosition(startElement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun prepareTransition() {
|
||||||
|
(state as? State.StartElementPositioned)?.prepareTransition()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onElementRegistered(elementInfo: SharedElementInfo): Boolean {
|
||||||
|
var shouldHide = false
|
||||||
|
|
||||||
|
val transition = transition
|
||||||
|
if (transition is SharedElementTransition.InProgress
|
||||||
|
&& elementInfo != transition.startElement.info
|
||||||
|
&& elementInfo != transition.endElement.info
|
||||||
|
) {
|
||||||
|
state = State.StartElementPositioned(startElement = transition.endElement)
|
||||||
|
this.transition = null
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val state = state) {
|
||||||
|
is State.StartElementPositioned -> {
|
||||||
|
if (!state.isRegistered(elementInfo)) {
|
||||||
|
shouldHide = true
|
||||||
|
this.state = State.EndElementRegistered(
|
||||||
|
startElement = state.startElement,
|
||||||
|
endElementInfo = elementInfo
|
||||||
|
)
|
||||||
|
state.prepareTransition()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is State.StartElementRegistered -> {
|
||||||
|
if (elementInfo != state.startElementInfo) {
|
||||||
|
this.state = State.StartElementRegistered(startElementInfo = elementInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is State.Empty -> {
|
||||||
|
this.state = State.StartElementRegistered(startElementInfo = elementInfo)
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
return shouldHide || transition != null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onElementPositioned(element: PositionedSharedElement, setShouldHide: (Boolean) -> Unit) {
|
||||||
|
val state = state
|
||||||
|
if (state is State.StartElementPositioned && element.info == state.startElementInfo) {
|
||||||
|
state.startElement = element
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
when (state) {
|
||||||
|
is State.EndElementRegistered -> {
|
||||||
|
if (element.info == state.endElementInfo) {
|
||||||
|
this.state = State.InTransition
|
||||||
|
val spec = element.info.spec
|
||||||
|
this.pathMotion = spec.pathMotionFactory()
|
||||||
|
transition = SharedElementTransition.InProgress(
|
||||||
|
startElement = state.startElement,
|
||||||
|
endElement = element,
|
||||||
|
onTransitionFinished = {
|
||||||
|
this.state = State.StartElementPositioned(startElement = element)
|
||||||
|
transition = null
|
||||||
|
setShouldHide(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is State.StartElementRegistered -> {
|
||||||
|
if (element.info == state.startElementInfo) {
|
||||||
|
this.state = State.StartElementPositioned(startElement = element)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onElementUnregistered(elementInfo: SharedElementInfo) {
|
||||||
|
when (val state = state) {
|
||||||
|
is State.EndElementRegistered -> {
|
||||||
|
if (elementInfo == state.endElementInfo) {
|
||||||
|
this.state = State.StartElementPositioned(startElement = state.startElement)
|
||||||
|
transition = null
|
||||||
|
} else if (elementInfo == state.startElement.info) {
|
||||||
|
this.state =
|
||||||
|
State.StartElementRegistered(startElementInfo = state.endElementInfo)
|
||||||
|
transition = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is State.StartElementRegistered -> {
|
||||||
|
if (elementInfo == state.startElementInfo) {
|
||||||
|
this.state = State.Empty
|
||||||
|
transition = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class State {
|
||||||
|
object Empty : State()
|
||||||
|
|
||||||
|
open class StartElementRegistered(val startElementInfo: SharedElementInfo) : State() {
|
||||||
|
open fun isRegistered(elementInfo: SharedElementInfo): Boolean {
|
||||||
|
return elementInfo == startElementInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open class StartElementPositioned(var startElement: PositionedSharedElement) :
|
||||||
|
StartElementRegistered(startElement.info)
|
||||||
|
|
||||||
|
class EndElementRegistered(
|
||||||
|
startElement: PositionedSharedElement,
|
||||||
|
val endElementInfo: SharedElementInfo
|
||||||
|
) : StartElementPositioned(startElement) {
|
||||||
|
override fun isRegistered(elementInfo: SharedElementInfo): Boolean {
|
||||||
|
return super.isRegistered(elementInfo) || elementInfo == endElementInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object InTransition : State()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class TransitionDirection {
|
||||||
|
Auto, Enter, Return
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class FadeMode {
|
||||||
|
In, Out, Cross, Through
|
||||||
|
}
|
||||||
|
|
||||||
|
const val FadeThroughProgressThreshold = 0.35f
|
||||||
|
|
||||||
|
internal class SharedElementsTransitionState(
|
||||||
|
val fraction: Float,
|
||||||
|
val startInfo: SharedElementInfo,
|
||||||
|
val startBounds: Rect?,
|
||||||
|
val startCompositionLocalContext: CompositionLocalContext,
|
||||||
|
val startPlaceholder: @Composable () -> Unit,
|
||||||
|
val endInfo: SharedElementInfo?,
|
||||||
|
val endBounds: Rect?,
|
||||||
|
val endCompositionLocalContext: CompositionLocalContext?,
|
||||||
|
val endPlaceholder: (@Composable () -> Unit)?,
|
||||||
|
val direction: TransitionDirection?,
|
||||||
|
val spec: SharedElementsTransitionSpec?,
|
||||||
|
val pathMotion: PathMotion?
|
||||||
|
)
|
||||||
|
|
||||||
|
internal val TopLeft = TransformOrigin(0f, 0f)
|
||||||
|
|
||||||
|
internal open class SharedElementInfo(
|
||||||
|
val key: Any,
|
||||||
|
val screenKey: Any,
|
||||||
|
val spec: SharedElementsTransitionSpec,
|
||||||
|
val onFractionChanged: ((Float) -> Unit)?
|
||||||
|
) {
|
||||||
|
|
||||||
|
final override fun equals(other: Any?): Boolean =
|
||||||
|
other is SharedElementInfo && other.key == key && other.screenKey == screenKey
|
||||||
|
|
||||||
|
final override fun hashCode(): Int = 31 * key.hashCode() + screenKey.hashCode()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private class PositionedSharedElement(
|
||||||
|
val info: SharedElementInfo,
|
||||||
|
val compositionLocalContext: CompositionLocalContext,
|
||||||
|
val placeholder: @Composable () -> Unit,
|
||||||
|
val overlay: @Composable (SharedElementsTransitionState) -> Unit,
|
||||||
|
val bounds: Rect?
|
||||||
|
)
|
||||||
|
|
||||||
|
private sealed class SharedElementTransition(val startElement: PositionedSharedElement) {
|
||||||
|
|
||||||
|
class WaitingForEndElementPosition(startElement: PositionedSharedElement) :
|
||||||
|
SharedElementTransition(startElement)
|
||||||
|
|
||||||
|
class InProgress(
|
||||||
|
startElement: PositionedSharedElement,
|
||||||
|
val endElement: PositionedSharedElement,
|
||||||
|
val onTransitionFinished: () -> Unit
|
||||||
|
) : SharedElementTransition(startElement)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ChoreographerWrapper {
|
||||||
|
private val callbacks = mutableMapOf<SharedElementInfo, Choreographer.FrameCallback>()
|
||||||
|
private val choreographer = Choreographer.getInstance()
|
||||||
|
|
||||||
|
fun postCallback(elementInfo: SharedElementInfo, callback: () -> Unit) {
|
||||||
|
if (callbacks.containsKey(elementInfo)) return
|
||||||
|
|
||||||
|
val frameCallback = Choreographer.FrameCallback {
|
||||||
|
callbacks.remove(elementInfo)
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
callbacks[elementInfo] = frameCallback
|
||||||
|
choreographer.postFrameCallback(frameCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeCallback(elementInfo: SharedElementInfo) {
|
||||||
|
callbacks.remove(elementInfo)?.also(choreographer::removeFrameCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
callbacks.values.forEach(choreographer::removeFrameCallback)
|
||||||
|
callbacks.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val Fullscreen = Modifier.fillMaxSize()
|
||||||
|
internal val FullscreenLayoutId = Any()
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package org.xtimms.tokusho.core.motion.sharedelements
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.AnimationConstants
|
||||||
|
import androidx.compose.animation.core.Easing
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
|
||||||
|
open class SharedElementsTransitionSpec(
|
||||||
|
val pathMotionFactory: PathMotionFactory = LinearMotionFactory,
|
||||||
|
/**
|
||||||
|
* Frames to wait for before starting transition. Useful when the frame skip caused by
|
||||||
|
* rendering the new screen makes the animation not smooth.
|
||||||
|
*/
|
||||||
|
val waitForFrames: Int = 1,
|
||||||
|
val durationMillis: Int = AnimationConstants.DefaultDurationMillis,
|
||||||
|
val delayMillis: Int = 0,
|
||||||
|
val easing: Easing = FastOutSlowInEasing,
|
||||||
|
val direction: TransitionDirection = TransitionDirection.Auto,
|
||||||
|
val fadeMode: FadeMode = FadeMode.Cross,
|
||||||
|
val fadeProgressThresholds: ProgressThresholds? = null,
|
||||||
|
val scaleProgressThresholds: ProgressThresholds? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
val DefaultSharedElementsTransitionSpec = SharedElementsTransitionSpec()
|
||||||
@ -0,0 +1,500 @@
|
|||||||
|
package org.xtimms.tokusho.core.motion.sharedelements
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.AnimationConstants
|
||||||
|
import androidx.compose.animation.core.Easing
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CornerBasedShape
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
|
import androidx.compose.foundation.shape.CutCornerShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.shape.ZeroCornerSize
|
||||||
|
import androidx.compose.material.LocalAbsoluteElevation
|
||||||
|
import androidx.compose.material.LocalElevationOverlay
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.contentColorFor
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalContext
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.layout.layoutId
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.lerp
|
||||||
|
import androidx.compose.ui.unit.round
|
||||||
|
import androidx.compose.ui.util.lerp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SharedMaterialContainer(
|
||||||
|
key: Any,
|
||||||
|
screenKey: Any,
|
||||||
|
isFullscreen: Boolean = false,
|
||||||
|
shape: Shape = RectangleShape,
|
||||||
|
color: Color = MaterialTheme.colorScheme.surface,
|
||||||
|
contentColor: Color = contentColorFor(color),
|
||||||
|
border: BorderStroke? = null,
|
||||||
|
elevation: Dp = 0.dp,
|
||||||
|
transitionSpec: MaterialContainerTransformSpec = DefaultMaterialContainerTransformSpec,
|
||||||
|
onFractionChanged: ((Float) -> Unit)? = null,
|
||||||
|
placeholder: @Composable (() -> Unit)? = null,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val elementInfo = MaterialContainerInfo(
|
||||||
|
key, screenKey, shape, color, contentColor,
|
||||||
|
border, elevation, transitionSpec, onFractionChanged
|
||||||
|
)
|
||||||
|
val realPlaceholder = placeholder ?: content
|
||||||
|
BaseSharedElement(
|
||||||
|
elementInfo,
|
||||||
|
isFullscreen,
|
||||||
|
realPlaceholder,
|
||||||
|
{ Placeholder(it) },
|
||||||
|
{
|
||||||
|
MaterialContainer(
|
||||||
|
modifier = it,
|
||||||
|
shape = shape,
|
||||||
|
color = color,
|
||||||
|
contentColor = contentColor,
|
||||||
|
border = border,
|
||||||
|
elevation = elevation,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MaterialContainer(
|
||||||
|
modifier: Modifier,
|
||||||
|
shape: Shape,
|
||||||
|
color: Color,
|
||||||
|
contentColor: Color,
|
||||||
|
border: BorderStroke?,
|
||||||
|
elevation: Dp,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val elevationOverlay = LocalElevationOverlay.current
|
||||||
|
val absoluteElevation = LocalAbsoluteElevation.current + elevation
|
||||||
|
val backgroundColor = if (color == MaterialTheme.colorScheme.surface && elevationOverlay != null) {
|
||||||
|
elevationOverlay.apply(color, absoluteElevation)
|
||||||
|
} else {
|
||||||
|
color
|
||||||
|
}
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalContentColor provides contentColor,
|
||||||
|
LocalAbsoluteElevation provides absoluteElevation
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.shadow(elevation, shape, clip = false)
|
||||||
|
.then(if (border != null) Modifier.border(border, shape) else Modifier)
|
||||||
|
.background(
|
||||||
|
color = backgroundColor,
|
||||||
|
shape = shape
|
||||||
|
)
|
||||||
|
.clip(shape),
|
||||||
|
propagateMinConstraints = true
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Placeholder(state: SharedElementsTransitionState) {
|
||||||
|
with(LocalDensity.current) {
|
||||||
|
val startInfo = state.startInfo as MaterialContainerInfo
|
||||||
|
val direction = state.direction
|
||||||
|
val spec = state.spec as? MaterialContainerTransformSpec
|
||||||
|
val start = state.startBounds
|
||||||
|
val end = state.endBounds
|
||||||
|
val fraction = state.fraction
|
||||||
|
|
||||||
|
val surfaceModifier: Modifier
|
||||||
|
var startContentModifier = Fullscreen
|
||||||
|
val elements = mutableListOf<ElementCall>()
|
||||||
|
|
||||||
|
var shape = startInfo.shape
|
||||||
|
var color = startInfo.color
|
||||||
|
var contentColor = startInfo.contentColor
|
||||||
|
var border = startInfo.border
|
||||||
|
var elevation = startInfo.elevation
|
||||||
|
var startAlpha = 1f
|
||||||
|
|
||||||
|
if (start == null) {
|
||||||
|
surfaceModifier = Modifier.layoutId(FullscreenLayoutId)
|
||||||
|
} else {
|
||||||
|
val fitMode = if (spec == null || end == null) null else remember {
|
||||||
|
val mode = spec.fitMode
|
||||||
|
if (mode != FitMode.Auto) mode else
|
||||||
|
calculateFitMode(direction == TransitionDirection.Enter, start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
val thresholds =
|
||||||
|
if (spec == null || direction == null) DefaultEnterThresholds else remember {
|
||||||
|
spec.progressThresholdsGroupFor(direction, state.pathMotion!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
val scaleFraction = thresholds.scale.applyTo(fraction)
|
||||||
|
val scale = calculateScale(start, end, scaleFraction)
|
||||||
|
val contentScale = if (fitMode == FitMode.Height) scale.scaleY else scale.scaleX
|
||||||
|
val scaleMaskFraction = thresholds.scaleMask.applyTo(fraction)
|
||||||
|
val (containerWidth, containerHeight) = if (end == null) start.size * contentScale else {
|
||||||
|
if (fitMode == FitMode.Height) Size(
|
||||||
|
width = lerp(
|
||||||
|
start.width * contentScale,
|
||||||
|
start.height * contentScale / end.height * end.width,
|
||||||
|
scaleMaskFraction
|
||||||
|
),
|
||||||
|
height = start.height * contentScale
|
||||||
|
) else Size(
|
||||||
|
width = start.width * contentScale,
|
||||||
|
height = lerp(
|
||||||
|
start.height * contentScale,
|
||||||
|
start.width * contentScale / end.width * end.height,
|
||||||
|
scaleMaskFraction
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val offset =
|
||||||
|
calculateOffset(start, end, fraction, state.pathMotion, containerWidth).round()
|
||||||
|
|
||||||
|
surfaceModifier = Modifier
|
||||||
|
.size(
|
||||||
|
containerWidth.toDp(),
|
||||||
|
containerHeight.toDp()
|
||||||
|
)
|
||||||
|
.offset { offset }
|
||||||
|
|
||||||
|
val endInfo = state.endInfo as? MaterialContainerInfo
|
||||||
|
val fadeFraction = thresholds.fade.applyTo(fraction)
|
||||||
|
if (end != null && endInfo != null) {
|
||||||
|
val endAlpha = calculateAlpha(direction, state.spec?.fadeMode, fadeFraction, false)
|
||||||
|
if (endAlpha > 0) {
|
||||||
|
val endScale = calculateScale(end, start, 1 - scaleFraction).run {
|
||||||
|
if (fitMode == FitMode.Height) scaleY else scaleX
|
||||||
|
}
|
||||||
|
val containerColor = spec?.endContainerColor ?: Color.Transparent
|
||||||
|
val containerModifier = Modifier.fillMaxSize().run {
|
||||||
|
if (containerColor == Color.Transparent) this else
|
||||||
|
background(containerColor.copy(alpha = containerColor.alpha * endAlpha))
|
||||||
|
}.run {
|
||||||
|
if (state.spec?.fadeMode != FadeMode.Out) zIndex(1f) else this
|
||||||
|
}
|
||||||
|
val contentModifier = Modifier
|
||||||
|
.size(
|
||||||
|
end.width.toDp(),
|
||||||
|
end.height.toDp()
|
||||||
|
)
|
||||||
|
.run {
|
||||||
|
if (fitMode == FitMode.Height) offset {
|
||||||
|
IntOffset(
|
||||||
|
((containerWidth - end.width * endScale) / 2).roundToInt(),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
} else this
|
||||||
|
}
|
||||||
|
.graphicsLayer {
|
||||||
|
this.transformOrigin = TopLeft
|
||||||
|
this.scaleX = endScale
|
||||||
|
this.scaleY = endScale
|
||||||
|
this.alpha = endAlpha
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.add(
|
||||||
|
ElementCall(
|
||||||
|
endInfo.screenKey,
|
||||||
|
containerModifier,
|
||||||
|
true,
|
||||||
|
contentModifier,
|
||||||
|
state.endCompositionLocalContext!!,
|
||||||
|
state.endPlaceholder!!
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val shapeFraction = thresholds.shapeMask.applyTo(fraction)
|
||||||
|
shape = lerp(startInfo.shape, endInfo.shape, shapeFraction)
|
||||||
|
color = lerp(startInfo.color, endInfo.color, shapeFraction)
|
||||||
|
contentColor = lerp(startInfo.contentColor, endInfo.contentColor, shapeFraction)
|
||||||
|
border = (startInfo.border ?: endInfo.border)?.copy(
|
||||||
|
width = lerp(
|
||||||
|
startInfo.border?.width ?: 0.dp,
|
||||||
|
endInfo.border?.width ?: 0.dp,
|
||||||
|
shapeFraction
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elevation = lerp(startInfo.elevation, endInfo.elevation, shapeFraction)
|
||||||
|
}
|
||||||
|
|
||||||
|
startAlpha = calculateAlpha(direction, state.spec?.fadeMode, fadeFraction, true)
|
||||||
|
if (startAlpha > 0) {
|
||||||
|
startContentModifier = Modifier
|
||||||
|
.size(
|
||||||
|
start.width.toDp(),
|
||||||
|
start.height.toDp()
|
||||||
|
)
|
||||||
|
.run {
|
||||||
|
if (fitMode == FitMode.Height) offset {
|
||||||
|
IntOffset(
|
||||||
|
((containerWidth - start.width * contentScale) / 2).roundToInt(),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
} else this
|
||||||
|
}
|
||||||
|
.graphicsLayer {
|
||||||
|
this.transformOrigin = TopLeft
|
||||||
|
this.scaleX = contentScale
|
||||||
|
this.scaleY = contentScale
|
||||||
|
this.alpha = startAlpha
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startAlpha > 0) {
|
||||||
|
val containerColor = spec?.startContainerColor ?: Color.Transparent
|
||||||
|
val containerModifier = Modifier.fillMaxSize().run {
|
||||||
|
if (containerColor == Color.Transparent) this else
|
||||||
|
background(containerColor.copy(alpha = containerColor.alpha * startAlpha))
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.add(
|
||||||
|
ElementCall(
|
||||||
|
startInfo.screenKey,
|
||||||
|
containerModifier,
|
||||||
|
start != null,
|
||||||
|
startContentModifier,
|
||||||
|
state.startCompositionLocalContext,
|
||||||
|
state.startPlaceholder
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialContainer(
|
||||||
|
modifier = surfaceModifier,
|
||||||
|
shape = shape,
|
||||||
|
color = color,
|
||||||
|
contentColor = contentColor,
|
||||||
|
border = border,
|
||||||
|
elevation = elevation
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
elements.forEach { call ->
|
||||||
|
key(call.screenKey) {
|
||||||
|
ElementContainer(
|
||||||
|
modifier = call.containerModifier,
|
||||||
|
relaxMaxSize = call.relaxMaxSize
|
||||||
|
) {
|
||||||
|
ElementContainer(modifier = call.contentModifier) {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
call.compositionLocalContext,
|
||||||
|
content = call.content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ElementCall(
|
||||||
|
val screenKey: Any,
|
||||||
|
val containerModifier: Modifier,
|
||||||
|
val relaxMaxSize: Boolean,
|
||||||
|
val contentModifier: Modifier,
|
||||||
|
val compositionLocalContext: CompositionLocalContext,
|
||||||
|
val content: @Composable () -> Unit
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun calculateFitMode(entering: Boolean, start: Rect, end: Rect): FitMode {
|
||||||
|
val startWidth = start.width
|
||||||
|
val startHeight = start.height
|
||||||
|
val endWidth = end.width
|
||||||
|
val endHeight = end.height
|
||||||
|
|
||||||
|
val endHeightFitToWidth = endHeight * startWidth / endWidth
|
||||||
|
val startHeightFitToWidth = startHeight * endWidth / startWidth
|
||||||
|
val fitWidth = if (entering)
|
||||||
|
endHeightFitToWidth >= startHeight else startHeightFitToWidth >= endHeight
|
||||||
|
return if (fitWidth) FitMode.Width else FitMode.Height
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lerp(start: Shape, end: Shape, fraction: Float): Shape {
|
||||||
|
if ((start == RectangleShape && end == RectangleShape) ||
|
||||||
|
(start != RectangleShape && start !is CornerBasedShape) ||
|
||||||
|
(end != RectangleShape && end !is CornerBasedShape)
|
||||||
|
) return start
|
||||||
|
val topStart = lerp(
|
||||||
|
(start as? CornerBasedShape)?.topStart,
|
||||||
|
(end as? CornerBasedShape)?.topStart,
|
||||||
|
fraction
|
||||||
|
) ?: ZeroCornerSize
|
||||||
|
val topEnd = lerp(
|
||||||
|
(start as? CornerBasedShape)?.topEnd,
|
||||||
|
(end as? CornerBasedShape)?.topEnd,
|
||||||
|
fraction
|
||||||
|
) ?: ZeroCornerSize
|
||||||
|
val bottomEnd = lerp(
|
||||||
|
(start as? CornerBasedShape)?.bottomEnd,
|
||||||
|
(end as? CornerBasedShape)?.bottomEnd,
|
||||||
|
fraction
|
||||||
|
) ?: ZeroCornerSize
|
||||||
|
val bottomStart = lerp(
|
||||||
|
(start as? CornerBasedShape)?.bottomStart,
|
||||||
|
(end as? CornerBasedShape)?.bottomStart,
|
||||||
|
fraction
|
||||||
|
) ?: ZeroCornerSize
|
||||||
|
return when {
|
||||||
|
start is RoundedCornerShape || (start == RectangleShape && end is RoundedCornerShape) ->
|
||||||
|
RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart)
|
||||||
|
start is CutCornerShape || (start == RectangleShape && end is CutCornerShape) ->
|
||||||
|
CutCornerShape(topStart, topEnd, bottomEnd, bottomStart)
|
||||||
|
else -> start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lerp(start: CornerSize?, end: CornerSize?, fraction: Float): CornerSize? {
|
||||||
|
if (start == null && end == null) return null
|
||||||
|
return object : CornerSize {
|
||||||
|
override fun toPx(shapeSize: Size, density: Density): Float =
|
||||||
|
lerp(
|
||||||
|
start?.toPx(shapeSize, density) ?: 0f,
|
||||||
|
end?.toPx(shapeSize, density) ?: 0f,
|
||||||
|
fraction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MaterialContainerInfo(
|
||||||
|
key: Any,
|
||||||
|
screenKey: Any,
|
||||||
|
val shape: Shape,
|
||||||
|
val color: Color,
|
||||||
|
val contentColor: Color,
|
||||||
|
val border: BorderStroke?,
|
||||||
|
val elevation: Dp,
|
||||||
|
spec: SharedElementsTransitionSpec,
|
||||||
|
onFractionChanged: ((Float) -> Unit)?,
|
||||||
|
) : SharedElementInfo(key, screenKey, spec, onFractionChanged)
|
||||||
|
|
||||||
|
enum class FitMode {
|
||||||
|
Auto, Width, Height
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
private class ProgressThresholdsGroup(
|
||||||
|
val fade: ProgressThresholds,
|
||||||
|
val scale: ProgressThresholds,
|
||||||
|
val scaleMask: ProgressThresholds,
|
||||||
|
val shapeMask: ProgressThresholds
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default animation thresholds. Will be used by default when the default linear PathMotion is
|
||||||
|
// being used or when no other progress thresholds are appropriate (e.g., the arc thresholds for
|
||||||
|
// an arc path).
|
||||||
|
private val DefaultEnterThresholds = ProgressThresholdsGroup(
|
||||||
|
fade = ProgressThresholds(0f, 0.25f),
|
||||||
|
scale = ProgressThresholds(0f, 1f),
|
||||||
|
scaleMask = ProgressThresholds(0f, 1f),
|
||||||
|
shapeMask = ProgressThresholds(0f, 0.75f)
|
||||||
|
)
|
||||||
|
private val DefaultReturnThresholds = ProgressThresholdsGroup(
|
||||||
|
fade = ProgressThresholds(0.60f, 0.90f),
|
||||||
|
scale = ProgressThresholds(0f, 1f),
|
||||||
|
scaleMask = ProgressThresholds(0f, 0.90f),
|
||||||
|
shapeMask = ProgressThresholds(0.30f, 0.90f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default animation thresholds for an arc path. Will be used by default when the PathMotion is
|
||||||
|
// set to MaterialArcMotion.
|
||||||
|
private val DefaultEnterThresholdsArc = ProgressThresholdsGroup(
|
||||||
|
fade = ProgressThresholds(0.10f, 0.40f),
|
||||||
|
scale = ProgressThresholds(0.10f, 1f),
|
||||||
|
scaleMask = ProgressThresholds(0.10f, 1f),
|
||||||
|
shapeMask = ProgressThresholds(0.10f, 0.90f)
|
||||||
|
)
|
||||||
|
private val DefaultReturnThresholdsArc = ProgressThresholdsGroup(
|
||||||
|
fade = ProgressThresholds(0.60f, 0.90f),
|
||||||
|
scale = ProgressThresholds(0f, 0.90f),
|
||||||
|
scaleMask = ProgressThresholds(0f, 0.90f),
|
||||||
|
shapeMask = ProgressThresholds(0.20f, 0.90f)
|
||||||
|
)
|
||||||
|
|
||||||
|
class MaterialContainerTransformSpec(
|
||||||
|
pathMotionFactory: PathMotionFactory = LinearMotionFactory,
|
||||||
|
/**
|
||||||
|
* Frames to wait for before starting transition. Useful when the frame skip caused by
|
||||||
|
* rendering the new screen makes the animation not smooth.
|
||||||
|
*/
|
||||||
|
waitForFrames: Int = 1,
|
||||||
|
durationMillis: Int = AnimationConstants.DefaultDurationMillis,
|
||||||
|
delayMillis: Int = 0,
|
||||||
|
easing: Easing = FastOutSlowInEasing,
|
||||||
|
direction: TransitionDirection = TransitionDirection.Auto,
|
||||||
|
fadeMode: FadeMode = FadeMode.In,
|
||||||
|
val fitMode: FitMode = FitMode.Auto,
|
||||||
|
val startContainerColor: Color = Color.Transparent,
|
||||||
|
val endContainerColor: Color = Color.Transparent,
|
||||||
|
fadeProgressThresholds: ProgressThresholds? = null,
|
||||||
|
scaleProgressThresholds: ProgressThresholds? = null,
|
||||||
|
val scaleMaskProgressThresholds: ProgressThresholds? = null,
|
||||||
|
val shapeMaskProgressThresholds: ProgressThresholds? = null
|
||||||
|
) : SharedElementsTransitionSpec(
|
||||||
|
pathMotionFactory,
|
||||||
|
waitForFrames,
|
||||||
|
durationMillis,
|
||||||
|
delayMillis,
|
||||||
|
easing,
|
||||||
|
direction,
|
||||||
|
fadeMode,
|
||||||
|
fadeProgressThresholds,
|
||||||
|
scaleProgressThresholds
|
||||||
|
)
|
||||||
|
|
||||||
|
val DefaultMaterialContainerTransformSpec = MaterialContainerTransformSpec()
|
||||||
|
|
||||||
|
private fun MaterialContainerTransformSpec.progressThresholdsGroupFor(
|
||||||
|
direction: TransitionDirection,
|
||||||
|
pathMotion: PathMotion
|
||||||
|
): ProgressThresholdsGroup {
|
||||||
|
val default = if (pathMotion is MaterialArcMotion) {
|
||||||
|
if (direction == TransitionDirection.Enter)
|
||||||
|
DefaultEnterThresholdsArc else DefaultReturnThresholdsArc
|
||||||
|
} else {
|
||||||
|
if (direction == TransitionDirection.Enter)
|
||||||
|
DefaultEnterThresholds else DefaultReturnThresholds
|
||||||
|
}
|
||||||
|
return ProgressThresholdsGroup(
|
||||||
|
fadeProgressThresholds ?: default.fade,
|
||||||
|
scaleProgressThresholds ?: default.scale,
|
||||||
|
scaleMaskProgressThresholds ?: default.scaleMask,
|
||||||
|
shapeMaskProgressThresholds ?: default.shapeMask
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
package org.xtimms.tokusho.core.network.interceptors
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.collection.ArraySet
|
||||||
|
import coil.intercept.Interceptor
|
||||||
|
import coil.request.ErrorResult
|
||||||
|
import coil.request.ImageResult
|
||||||
|
import coil.request.SuccessResult
|
||||||
|
import coil.size.Dimension
|
||||||
|
import coil.size.isOriginal
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.koitharu.kotatsu.parsers.util.await
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.xtimms.tokusho.BuildConfig
|
||||||
|
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||||
|
import org.xtimms.tokusho.utils.system.ensureSuccess
|
||||||
|
import org.xtimms.tokusho.utils.system.isHttpOrHttps
|
||||||
|
import java.util.Collections
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ImageProxyInterceptor @Inject constructor() : Interceptor {
|
||||||
|
|
||||||
|
private val blacklist = Collections.synchronizedSet(ArraySet<String>())
|
||||||
|
|
||||||
|
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||||
|
val request = chain.request
|
||||||
|
if (!AppSettings.isImagesProxyEnabled()) {
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
val url: HttpUrl? = when (val data = request.data) {
|
||||||
|
is HttpUrl -> data
|
||||||
|
is String -> data.toHttpUrlOrNull()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
val newUrl = HttpUrl.Builder()
|
||||||
|
.scheme("https")
|
||||||
|
.host("wsrv.nl")
|
||||||
|
.addQueryParameter("url", url.toString())
|
||||||
|
.addQueryParameter("we", null)
|
||||||
|
val size = request.sizeResolver.size()
|
||||||
|
if (!size.isOriginal) {
|
||||||
|
newUrl.addQueryParameter("crop", "cover")
|
||||||
|
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
|
||||||
|
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val newRequest = request.newBuilder()
|
||||||
|
.data(newUrl.build())
|
||||||
|
.build()
|
||||||
|
val result = chain.proceed(newRequest)
|
||||||
|
return if (result is SuccessResult) {
|
||||||
|
result
|
||||||
|
} else {
|
||||||
|
logDebug((result as? ErrorResult)?.throwable)
|
||||||
|
chain.proceed(request).also {
|
||||||
|
if (it is SuccessResult) {
|
||||||
|
blacklist.add(url.host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
|
||||||
|
if (!AppSettings.isImagesProxyEnabled()) {
|
||||||
|
return okHttp.newCall(request).await()
|
||||||
|
}
|
||||||
|
val sourceUrl = request.url
|
||||||
|
val targetUrl = HttpUrl.Builder()
|
||||||
|
.scheme("https")
|
||||||
|
.host("wsrv.nl")
|
||||||
|
.addQueryParameter("url", sourceUrl.toString())
|
||||||
|
.addQueryParameter("we", null)
|
||||||
|
val newRequest = request.newBuilder()
|
||||||
|
.url(targetUrl.build())
|
||||||
|
.build()
|
||||||
|
return runCatchingCancellable {
|
||||||
|
okHttp.doCall(newRequest)
|
||||||
|
}.recover {
|
||||||
|
logDebug(it)
|
||||||
|
okHttp.doCall(request).also {
|
||||||
|
blacklist.add(sourceUrl.host)
|
||||||
|
}
|
||||||
|
}.getOrThrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun OkHttpClient.doCall(request: Request): Response {
|
||||||
|
return newCall(request).await().ensureSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logDebug(e: Throwable?) {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.w("ImageProxy", e.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
package org.xtimms.tokusho.sections.history
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
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.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.ImageLoader
|
||||||
|
import org.xtimms.tokusho.core.components.MangaCover
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HistoryItem(
|
||||||
|
coil: ImageLoader,
|
||||||
|
history: HistoryItemModel,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.height(IntrinsicSize.Max)
|
||||||
|
.padding(
|
||||||
|
horizontal = 16.dp,
|
||||||
|
vertical = 8.dp
|
||||||
|
),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
MangaCover.Book(
|
||||||
|
coil = coil,
|
||||||
|
modifier = Modifier.height(96.dp),
|
||||||
|
data = history.manga.coverUrl,
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(start = 16.dp, end = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = history.manga.title,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
if (!history.manga.author.isNullOrEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = history.manga.author.let { it.orEmpty() },
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = history.manga.tags.joinToString(separator = ", ") { it.title },
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package org.xtimms.tokusho.sections.history
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.xtimms.tokusho.core.model.ListModel
|
||||||
|
import org.xtimms.tokusho.core.model.MangaHistory
|
||||||
|
|
||||||
|
data class HistoryItemModel(
|
||||||
|
val manga: Manga,
|
||||||
|
val history: MangaHistory,
|
||||||
|
) : ListModel {
|
||||||
|
|
||||||
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
|
return other is HistoryItemModel && other.manga.id == manga.id
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,70 +1,241 @@
|
|||||||
package org.xtimms.tokusho.sections.history
|
package org.xtimms.tokusho.sections.history
|
||||||
|
|
||||||
|
//noinspection UsingMaterialAndMaterial3Libraries
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.AnimationVector1D
|
import androidx.compose.animation.core.AnimationVector1D
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.DismissDirection
|
||||||
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.DeleteForever
|
||||||
import androidx.compose.material.icons.outlined.History
|
import androidx.compose.material.icons.outlined.History
|
||||||
|
import androidx.compose.material.icons.outlined.PlayArrow
|
||||||
|
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.DisposableEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.min
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coil.ImageLoader
|
||||||
import org.xtimms.tokusho.R
|
import org.xtimms.tokusho.R
|
||||||
import org.xtimms.tokusho.core.collapsable
|
import org.xtimms.tokusho.core.collapsable
|
||||||
|
import org.xtimms.tokusho.core.components.ListGroupHeader
|
||||||
|
import org.xtimms.tokusho.core.components.effects.RowEntity
|
||||||
|
import org.xtimms.tokusho.core.components.effects.RowEntityType
|
||||||
|
import org.xtimms.tokusho.core.components.effects.animatedItemsIndexed
|
||||||
|
import org.xtimms.tokusho.core.components.effects.updateAnimatedItemsState
|
||||||
|
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||||
|
import org.xtimms.tokusho.core.prefs.SWIPE_TUTORIAL
|
||||||
import org.xtimms.tokusho.core.screens.EmptyScreen
|
import org.xtimms.tokusho.core.screens.EmptyScreen
|
||||||
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
import org.xtimms.tokusho.utils.lang.calculateTimeAgo
|
||||||
|
import org.xtimms.tokusho.utils.lang.isSameDay
|
||||||
|
import java.time.Instant
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
const val HISTORY_DESTINATION = "history"
|
const val HISTORY_DESTINATION = "history"
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HistoryView(
|
fun HistoryView(
|
||||||
topBarHeightPx: Float,
|
coil: ImageLoader,
|
||||||
padding: PaddingValues,
|
viewModel: HistoryViewModel = hiltViewModel(),
|
||||||
) {
|
|
||||||
HistoryViewContent(
|
|
||||||
topBarHeightPx = topBarHeightPx,
|
|
||||||
padding = padding
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
|
||||||
fun HistoryViewContent(
|
|
||||||
topBarHeightPx: Float,
|
topBarHeightPx: Float,
|
||||||
topBarOffsetY: Animatable<Float, AnimationVector1D> = Animatable(0f),
|
topBarOffsetY: Animatable<Float, AnimationVector1D> = Animatable(0f),
|
||||||
padding: PaddingValues,
|
padding: PaddingValues,
|
||||||
|
navigateToDetails: (Long) -> Unit,
|
||||||
|
navigateToReader: () -> Unit
|
||||||
) {
|
) {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
var isUserTrySwipe by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val history by viewModel.content.collectAsStateWithLifecycle(emptyList())
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
if (history.isNotEmpty() && isUserTrySwipe) {
|
||||||
|
AppSettings.updateValue(SWIPE_TUTORIAL, isUserTrySwipe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val animatedList = run {
|
||||||
|
val list = emptyList<RowEntity>().toMutableList()
|
||||||
|
var readDate: Instant? = null
|
||||||
|
history.forEach { item ->
|
||||||
|
|
||||||
Column(
|
if (readDate === null || !isSameDay(
|
||||||
modifier = Modifier
|
item.history.updatedAt.toEpochMilli(),
|
||||||
.collapsable(
|
readDate!!.toEpochMilli()
|
||||||
state = scrollState,
|
)
|
||||||
topBarHeightPx = topBarHeightPx,
|
) {
|
||||||
topBarOffsetY = topBarOffsetY
|
readDate = item.history.updatedAt
|
||||||
|
|
||||||
|
list.add(
|
||||||
|
RowEntity(
|
||||||
|
type = RowEntityType.Header,
|
||||||
|
key = "header-${readDate}",
|
||||||
|
historyItemModel = null,
|
||||||
|
day = readDate!!,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
list.add(
|
||||||
|
RowEntity(
|
||||||
|
type = RowEntityType.Item,
|
||||||
|
key = "item-${item.manga.id}",
|
||||||
|
day = readDate!!,
|
||||||
|
historyItemModel = item
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.padding(padding)
|
}
|
||||||
) {
|
updateAnimatedItemsState(newList = list.toList().map { it })
|
||||||
EmptyScreen(
|
|
||||||
icon = Icons.Outlined.History,
|
|
||||||
title = R.string.empty_history_title,
|
|
||||||
description = R.string.empty_history_description
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
Box(
|
||||||
@Composable
|
Modifier.fillMaxSize()
|
||||||
fun HistoryPreview() {
|
) {
|
||||||
TokushoTheme {
|
Column(Modifier.fillMaxSize()) {
|
||||||
Surface {
|
LazyColumn(
|
||||||
HistoryViewContent(
|
modifier = Modifier
|
||||||
padding = PaddingValues(),
|
.collapsable(
|
||||||
topBarHeightPx = 0f,
|
state = scrollState,
|
||||||
|
topBarHeightPx = topBarHeightPx,
|
||||||
|
topBarOffsetY = topBarOffsetY
|
||||||
|
)
|
||||||
|
.padding(padding)
|
||||||
|
) {
|
||||||
|
animatedItemsIndexed(
|
||||||
|
state = animatedList.value,
|
||||||
|
key = { rowItem -> rowItem.key },
|
||||||
|
) { index, item ->
|
||||||
|
when (item.type) {
|
||||||
|
RowEntityType.Header -> ListGroupHeader(
|
||||||
|
calculateTimeAgo(item.day).format(
|
||||||
|
LocalContext.current.resources
|
||||||
|
)
|
||||||
|
)
|
||||||
|
RowEntityType.Item -> SwipeActions(
|
||||||
|
startActionsConfig = SwipeActionsConfig(
|
||||||
|
threshold = 0.33f,
|
||||||
|
background = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
backgroundActive = MaterialTheme.colorScheme.error,
|
||||||
|
iconTint = MaterialTheme.colorScheme.onError,
|
||||||
|
icon = Icons.Outlined.DeleteForever,
|
||||||
|
stayDismissed = true,
|
||||||
|
onDismiss = {
|
||||||
|
viewModel.removeFromHistory(item.historyItemModel!!)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
endActionsConfig = SwipeActionsConfig(
|
||||||
|
threshold = 0.33f,
|
||||||
|
background = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
backgroundActive = MaterialTheme.colorScheme.tertiary,
|
||||||
|
iconTint = MaterialTheme.colorScheme.onTertiary,
|
||||||
|
icon = Icons.Outlined.PlayArrow,
|
||||||
|
stayDismissed = false,
|
||||||
|
onDismiss = {
|
||||||
|
navigateToReader()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
onTried = { isUserTrySwipe = true },
|
||||||
|
showTutorial = false,
|
||||||
|
) { state ->
|
||||||
|
val size = with(LocalDensity.current) {
|
||||||
|
java.lang.Float.max(
|
||||||
|
java.lang.Float.min(
|
||||||
|
16.dp.toPx(),
|
||||||
|
abs(state.offset.value)
|
||||||
|
), 0f
|
||||||
|
).toDp()
|
||||||
|
}
|
||||||
|
|
||||||
|
val animateCorners by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
state.offset.value.absoluteValue > 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val startCorners by animateDpAsState(
|
||||||
|
targetValue = when {
|
||||||
|
state.dismissDirection == DismissDirection.StartToEnd &&
|
||||||
|
animateCorners -> 8.dp
|
||||||
|
|
||||||
|
else -> 0.dp
|
||||||
|
}, label = "startCorners"
|
||||||
|
)
|
||||||
|
val endCorners by animateDpAsState(
|
||||||
|
targetValue = when {
|
||||||
|
state.dismissDirection == DismissDirection.EndToStart &&
|
||||||
|
animateCorners -> 8.dp
|
||||||
|
|
||||||
|
else -> 0.dp
|
||||||
|
}, label = "endCorners"
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.height(IntrinsicSize.Min)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(
|
||||||
|
vertical = min(
|
||||||
|
size / 4f,
|
||||||
|
4.dp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clip(RoundedCornerShape(size)),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(
|
||||||
|
topStart = startCorners,
|
||||||
|
bottomStart = startCorners,
|
||||||
|
topEnd = endCorners,
|
||||||
|
bottomEnd = endCorners,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
// nothing
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
HistoryItem(
|
||||||
|
coil = coil,
|
||||||
|
history = item.historyItemModel!!,
|
||||||
|
onClick = { navigateToDetails(item.historyItemModel!!.manga.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (history.isEmpty()) {
|
||||||
|
EmptyScreen(
|
||||||
|
icon = Icons.Outlined.History,
|
||||||
|
title = R.string.empty_history_title,
|
||||||
|
description = R.string.empty_history_description
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
package org.xtimms.tokusho.sections.history
|
||||||
|
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
|
import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel
|
||||||
|
import org.xtimms.tokusho.data.repository.HistoryRepository
|
||||||
|
import org.xtimms.tokusho.utils.lang.mapItems
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class HistoryViewModel @Inject constructor(
|
||||||
|
private val repository: HistoryRepository,
|
||||||
|
) : KotatsuBaseViewModel() {
|
||||||
|
|
||||||
|
private val historyStateFlow = repository.observeAllWithHistory()
|
||||||
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||||
|
|
||||||
|
val content = historyStateFlow
|
||||||
|
.filterNotNull()
|
||||||
|
.mapItems { HistoryItemModel(it.manga, it.history) }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
|
fun removeFromHistory(history: HistoryItemModel) {
|
||||||
|
launchJob(Dispatchers.Default) {
|
||||||
|
repository.delete(history.manga)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,405 @@
|
|||||||
|
package org.xtimms.tokusho.sections.history
|
||||||
|
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.DeleteForever
|
||||||
|
import androidx.compose.material.icons.outlined.Edit
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.geometry.center
|
||||||
|
import androidx.compose.ui.graphics.*
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
import androidx.compose.ui.unit.min
|
||||||
|
import org.xtimms.tokusho.R
|
||||||
|
import org.xtimms.tokusho.ui.theme.SEED
|
||||||
|
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
||||||
|
|
||||||
|
data class SwipeActionsConfig(
|
||||||
|
val threshold: Float,
|
||||||
|
val icon: ImageVector?,
|
||||||
|
val iconTint: Color,
|
||||||
|
val background: Color,
|
||||||
|
val backgroundActive: Color,
|
||||||
|
val stayDismissed: Boolean,
|
||||||
|
val onDismiss: () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
val DefaultSwipeActionsConfig = SwipeActionsConfig(
|
||||||
|
threshold = 0.4f,
|
||||||
|
icon = null,
|
||||||
|
iconTint = Color.Transparent,
|
||||||
|
background = Color.Transparent,
|
||||||
|
backgroundActive = Color.Transparent,
|
||||||
|
stayDismissed = false,
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(
|
||||||
|
ExperimentalMaterialApi::class,
|
||||||
|
ExperimentalComposeUiApi::class,
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
fun SwipeActions(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
startActionsConfig: SwipeActionsConfig = DefaultSwipeActionsConfig,
|
||||||
|
endActionsConfig: SwipeActionsConfig = DefaultSwipeActionsConfig,
|
||||||
|
onTried: () -> Unit = {},
|
||||||
|
showTutorial: Boolean = false,
|
||||||
|
content: @Composable (DismissState) -> Unit,
|
||||||
|
) = BoxWithConstraints(modifier) {
|
||||||
|
val width = constraints.maxWidth.toFloat()
|
||||||
|
val height = constraints.maxHeight.toFloat()
|
||||||
|
|
||||||
|
var willDismissDirection: DismissDirection? by remember {
|
||||||
|
mutableStateOf(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val state = rememberDismissState(
|
||||||
|
confirmStateChange = {
|
||||||
|
onTried()
|
||||||
|
if (willDismissDirection == DismissDirection.StartToEnd
|
||||||
|
&& it == DismissValue.DismissedToEnd
|
||||||
|
) {
|
||||||
|
startActionsConfig.onDismiss()
|
||||||
|
startActionsConfig.stayDismissed
|
||||||
|
} else if (willDismissDirection == DismissDirection.EndToStart &&
|
||||||
|
it == DismissValue.DismissedToStart
|
||||||
|
) {
|
||||||
|
endActionsConfig.onDismiss()
|
||||||
|
endActionsConfig.stayDismissed
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
var showingTutorial by remember {
|
||||||
|
mutableStateOf(showTutorial)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showingTutorial) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition()
|
||||||
|
val x by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = width * (startActionsConfig.threshold) / 2f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(500, easing = FastOutSlowInEasing, delayMillis = 1000),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val dir by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(4000, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(key1 = x, block = {
|
||||||
|
state.performDrag(x * (if (dir > 0.5f) 1f else -1f) - state.offset.value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(key1 = Unit, block = {
|
||||||
|
snapshotFlow { state.offset.value }
|
||||||
|
.collect {
|
||||||
|
willDismissDirection = when {
|
||||||
|
it > width * startActionsConfig.threshold -> DismissDirection.StartToEnd
|
||||||
|
it < -width * endActionsConfig.threshold -> DismissDirection.EndToStart
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
val haptic = LocalHapticFeedback.current
|
||||||
|
LaunchedEffect(key1 = willDismissDirection, block = {
|
||||||
|
if (willDismissDirection != null) {
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
val dismissDirections by remember(startActionsConfig, endActionsConfig) {
|
||||||
|
derivedStateOf {
|
||||||
|
mutableSetOf<DismissDirection>().apply {
|
||||||
|
if (startActionsConfig != DefaultSwipeActionsConfig) add(DismissDirection.StartToEnd)
|
||||||
|
if (endActionsConfig != DefaultSwipeActionsConfig) add(DismissDirection.EndToStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SwipeToDismiss(
|
||||||
|
state = state,
|
||||||
|
modifier = Modifier
|
||||||
|
.pointerInteropFilter {
|
||||||
|
if (it.action == MotionEvent.ACTION_DOWN) {
|
||||||
|
showingTutorial = false
|
||||||
|
}
|
||||||
|
false
|
||||||
|
},
|
||||||
|
directions = dismissDirections,
|
||||||
|
dismissThresholds = {
|
||||||
|
if (it == DismissDirection.StartToEnd)
|
||||||
|
FractionalThreshold(startActionsConfig.threshold)
|
||||||
|
else FractionalThreshold(endActionsConfig.threshold)
|
||||||
|
},
|
||||||
|
background = {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = Pair(state.dismissDirection, willDismissDirection != null),
|
||||||
|
transitionSpec = {
|
||||||
|
fadeIn(
|
||||||
|
tween(0),
|
||||||
|
initialAlpha = if (targetState.second) 1f else 0f,
|
||||||
|
) togetherWith fadeOut(
|
||||||
|
tween(0),
|
||||||
|
targetAlpha = if (targetState.second) .7f else 0f,
|
||||||
|
)
|
||||||
|
}, label = "background"
|
||||||
|
) { (direction, willDismiss) ->
|
||||||
|
val revealSize = remember { Animatable(if (willDismiss) 0f else 0f) }
|
||||||
|
val iconSize = remember { Animatable(if (willDismiss) .8f else 1f) }
|
||||||
|
LaunchedEffect(key1 = Unit, block = {
|
||||||
|
if (willDismiss) {
|
||||||
|
revealSize.snapTo(0f)
|
||||||
|
launch {
|
||||||
|
revealSize.animateTo(1f, animationSpec = tween(500))
|
||||||
|
}
|
||||||
|
iconSize.snapTo(.8f)
|
||||||
|
iconSize.animateTo(
|
||||||
|
1.5f,
|
||||||
|
spring(
|
||||||
|
dampingRatio = Spring.DampingRatioHighBouncy,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
iconSize.animateTo(
|
||||||
|
1f,
|
||||||
|
spring(
|
||||||
|
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
color = when (direction) {
|
||||||
|
DismissDirection.StartToEnd -> startActionsConfig.background
|
||||||
|
DismissDirection.EndToStart -> endActionsConfig.background
|
||||||
|
else -> Color.Transparent
|
||||||
|
},
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clip(
|
||||||
|
CirclePath(
|
||||||
|
revealSize.value,
|
||||||
|
direction == DismissDirection.StartToEnd
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.background(
|
||||||
|
color = when (direction) {
|
||||||
|
DismissDirection.StartToEnd -> startActionsConfig.backgroundActive
|
||||||
|
DismissDirection.EndToStart -> endActionsConfig.backgroundActive
|
||||||
|
else -> Color.Transparent
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(
|
||||||
|
when (direction) {
|
||||||
|
DismissDirection.StartToEnd -> Alignment.CenterStart
|
||||||
|
else -> Alignment.CenterEnd
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.scale(iconSize.value),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
when (direction) {
|
||||||
|
DismissDirection.StartToEnd -> {
|
||||||
|
if (startActionsConfig.icon !== null) {
|
||||||
|
Image(
|
||||||
|
imageVector = startActionsConfig.icon,
|
||||||
|
colorFilter = ColorFilter.tint(if (willDismiss) startActionsConfig.iconTint else startActionsConfig.backgroundActive),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DismissDirection.EndToStart -> {
|
||||||
|
if (endActionsConfig.icon !== null) {
|
||||||
|
Image(
|
||||||
|
imageVector = endActionsConfig.icon,
|
||||||
|
colorFilter = ColorFilter.tint(if (willDismiss) endActionsConfig.iconTint else endActionsConfig.backgroundActive),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
content(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CirclePath(private val progress: Float, private val start: Boolean) : Shape {
|
||||||
|
override fun createOutline(
|
||||||
|
size: Size,
|
||||||
|
layoutDirection: LayoutDirection,
|
||||||
|
density: Density,
|
||||||
|
): Outline {
|
||||||
|
|
||||||
|
val origin = Offset(
|
||||||
|
x = if (start) size.height / 2 else size.width - size.height / 2,
|
||||||
|
y = size.center.y,
|
||||||
|
)
|
||||||
|
|
||||||
|
val radius = (sqrt(
|
||||||
|
size.height * size.height + size.width * size.width
|
||||||
|
) * 1f) * progress
|
||||||
|
|
||||||
|
return Outline.Generic(
|
||||||
|
Path().apply {
|
||||||
|
addOval(
|
||||||
|
Rect(
|
||||||
|
center = origin,
|
||||||
|
radius = radius,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
|
@Preview(widthDp = 300)
|
||||||
|
@Composable
|
||||||
|
private fun PreviewDefault() {
|
||||||
|
TokushoTheme {
|
||||||
|
SwipeActions(
|
||||||
|
startActionsConfig = SwipeActionsConfig(
|
||||||
|
threshold = 0.4f,
|
||||||
|
background = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
backgroundActive = MaterialTheme.colorScheme.tertiary,
|
||||||
|
iconTint = MaterialTheme.colorScheme.onTertiary,
|
||||||
|
icon = Icons.Outlined.Edit,
|
||||||
|
stayDismissed = false,
|
||||||
|
onDismiss = {
|
||||||
|
|
||||||
|
}
|
||||||
|
),
|
||||||
|
endActionsConfig = SwipeActionsConfig(
|
||||||
|
threshold = 0.4f,
|
||||||
|
background = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
backgroundActive = MaterialTheme.colorScheme.error,
|
||||||
|
iconTint = MaterialTheme.colorScheme.onError,
|
||||||
|
icon = Icons.Outlined.DeleteForever,
|
||||||
|
stayDismissed = false,
|
||||||
|
onDismiss = {
|
||||||
|
|
||||||
|
}
|
||||||
|
),
|
||||||
|
) { state ->
|
||||||
|
val size = with(LocalDensity.current) {
|
||||||
|
java.lang.Float.max(
|
||||||
|
java.lang.Float.min(
|
||||||
|
16.dp.toPx(),
|
||||||
|
abs(state.offset.value)
|
||||||
|
), 0f
|
||||||
|
).toDp()
|
||||||
|
}
|
||||||
|
|
||||||
|
val animateCorners by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
state.offset.value.absoluteValue > 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val startCorners by animateDpAsState(
|
||||||
|
targetValue = when {
|
||||||
|
state.dismissDirection == DismissDirection.StartToEnd &&
|
||||||
|
animateCorners -> 8.dp
|
||||||
|
else -> 0.dp
|
||||||
|
}, label = "startCorners"
|
||||||
|
)
|
||||||
|
val endCorners by animateDpAsState(
|
||||||
|
targetValue = when {
|
||||||
|
state.dismissDirection == DismissDirection.EndToStart &&
|
||||||
|
animateCorners -> 8.dp
|
||||||
|
else -> 0.dp
|
||||||
|
}, label = "endCorners"
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.height(IntrinsicSize.Min)
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(
|
||||||
|
vertical = min(
|
||||||
|
size / 4f,
|
||||||
|
4.dp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clip(RoundedCornerShape(size)),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(
|
||||||
|
topStart = startCorners,
|
||||||
|
bottomStart = startCorners,
|
||||||
|
topEnd = endCorners,
|
||||||
|
bottomEnd = endCorners,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Swipe to dismiss",
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(24.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package org.xtimms.tokusho.sections.reader
|
||||||
|
|
||||||
|
import org.xtimms.tokusho.sections.reader.pager.ReaderPage
|
||||||
|
|
||||||
|
data class ReaderContent(
|
||||||
|
val pages: List<ReaderPage>,
|
||||||
|
val state: ReaderState?
|
||||||
|
)
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package org.xtimms.tokusho.sections.reader
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.xtimms.tokusho.core.model.MangaHistory
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class ReaderState(
|
||||||
|
val chapterId: Long,
|
||||||
|
val page: Int,
|
||||||
|
val scroll: Int,
|
||||||
|
) : Parcelable {
|
||||||
|
|
||||||
|
constructor(history: MangaHistory) : this(
|
||||||
|
chapterId = history.chapterId,
|
||||||
|
page = history.page,
|
||||||
|
scroll = history.scroll,
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(manga: Manga, branch: String?) : this(
|
||||||
|
chapterId = manga.chapters?.firstOrNull {
|
||||||
|
it.branch == branch
|
||||||
|
}?.id ?: error("Cannot find first chapter"),
|
||||||
|
page = 0,
|
||||||
|
scroll = 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
package org.xtimms.tokusho.sections.reader
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.material3.BottomAppBar
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.surfaceColorAtElevation
|
||||||
|
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.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import com.google.android.material.slider.Slider
|
||||||
|
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.xtimms.tokusho.core.components.AppBarTitle
|
||||||
|
import org.xtimms.tokusho.core.components.BackIconButton
|
||||||
|
|
||||||
|
const val READER_DESTINATION = "reader"
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ReaderView(
|
||||||
|
readerViewModel: ReaderViewModel = hiltViewModel(),
|
||||||
|
navigateBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
|
||||||
|
var sliderPosition by remember { mutableStateOf(0f) }
|
||||||
|
val pagerState = rememberPagerState { sliderPosition.toInt() }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { AppBarTitle(title = "Test", subtitle = "Test") },
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors()
|
||||||
|
.copy(containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)),
|
||||||
|
navigationIcon = {
|
||||||
|
BackIconButton(onClick = navigateBack)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
BottomAppBar {
|
||||||
|
Slider(
|
||||||
|
value = sliderPosition,
|
||||||
|
valueRange = 0f..3f,
|
||||||
|
steps = 3,
|
||||||
|
onValueChange = { sliderPosition = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
HorizontalPager(
|
||||||
|
modifier = Modifier.padding(padding),
|
||||||
|
state = pagerState
|
||||||
|
) {
|
||||||
|
ZoomableAsyncImage(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data("https://images.unsplash.com/photo-1678465952838-c9d7f5daaa65")
|
||||||
|
.crossfade(1_000)
|
||||||
|
.build(),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Inside
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package org.xtimms.tokusho.sections.reader
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import org.xtimms.tokusho.core.base.viewmodel.KotatsuBaseViewModel
|
||||||
|
import org.xtimms.tokusho.core.parser.MangaDataRepository
|
||||||
|
import org.xtimms.tokusho.core.parser.MangaIntent
|
||||||
|
import org.xtimms.tokusho.data.repository.HistoryRepository
|
||||||
|
import org.xtimms.tokusho.sections.details.data.MangaDetails
|
||||||
|
import org.xtimms.tokusho.sections.details.domain.DetailsLoadUseCase
|
||||||
|
import org.xtimms.tokusho.sections.reader.domain.ChaptersLoader
|
||||||
|
import org.xtimms.tokusho.sections.reader.domain.PageLoader
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ReaderViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
private val dataRepository: MangaDataRepository,
|
||||||
|
private val historyRepository: HistoryRepository,
|
||||||
|
private val detailsLoadUseCase: DetailsLoadUseCase,
|
||||||
|
private val pageLoader: PageLoader,
|
||||||
|
private val chaptersLoader: ChaptersLoader,
|
||||||
|
) : KotatsuBaseViewModel() {
|
||||||
|
|
||||||
|
private val intent = MangaIntent(savedStateHandle)
|
||||||
|
|
||||||
|
private var loadingJob: Job? = null
|
||||||
|
private var pageSaveJob: Job? = null
|
||||||
|
private var bookmarkJob: Job? = null
|
||||||
|
private var stateChangeJob: Job? = null
|
||||||
|
|
||||||
|
private val mangaData = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) })
|
||||||
|
|
||||||
|
val content = MutableStateFlow(ReaderContent(emptyList(), null))
|
||||||
|
val manga: MangaDetails?
|
||||||
|
get() = mangaData.value
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadImpl()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reload() {
|
||||||
|
loadingJob?.cancel()
|
||||||
|
loadImpl()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadImpl() {
|
||||||
|
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
package org.xtimms.tokusho.sections.reader.domain
|
||||||
|
|
||||||
|
import androidx.collection.LongSparseArray
|
||||||
|
import androidx.collection.contains
|
||||||
|
import org.xtimms.tokusho.sections.reader.pager.ReaderPage
|
||||||
|
|
||||||
|
class ChapterPages private constructor(private val pages: ArrayDeque<ReaderPage>) : List<ReaderPage> by pages {
|
||||||
|
|
||||||
|
// map chapterId to index in pages deque
|
||||||
|
private val indices = LongSparseArray<IntRange>()
|
||||||
|
|
||||||
|
constructor() : this(ArrayDeque())
|
||||||
|
|
||||||
|
val chaptersSize: Int
|
||||||
|
get() = indices.size()
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun removeFirst() {
|
||||||
|
val chapterId = pages.first().chapterId
|
||||||
|
indices.remove(chapterId)
|
||||||
|
var delta = 0
|
||||||
|
while (pages.first().chapterId == chapterId) {
|
||||||
|
pages.removeFirst()
|
||||||
|
delta--
|
||||||
|
}
|
||||||
|
shiftIndices(delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun removeLast() {
|
||||||
|
val chapterId = pages.last().chapterId
|
||||||
|
indices.remove(chapterId)
|
||||||
|
while (pages.last().chapterId == chapterId) {
|
||||||
|
pages.removeLast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun addLast(id: Long, newPages: List<ReaderPage>): Boolean {
|
||||||
|
if (id in indices) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
indices.put(id, pages.size until (pages.size + newPages.size))
|
||||||
|
pages.addAll(newPages)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun addFirst(id: Long, newPages: List<ReaderPage>): Boolean {
|
||||||
|
if (id in indices) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
shiftIndices(newPages.size)
|
||||||
|
indices.put(id, newPages.indices)
|
||||||
|
pages.addAll(0, newPages)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun clear() {
|
||||||
|
indices.clear()
|
||||||
|
pages.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun size(id: Long) = indices[id]?.run {
|
||||||
|
endInclusive - start + 1
|
||||||
|
} ?: 0
|
||||||
|
|
||||||
|
fun subList(id: Long): List<ReaderPage> {
|
||||||
|
val range = indices[id] ?: return emptyList()
|
||||||
|
return pages.subList(range.first, range.last + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun contains(chapterId: Long) = chapterId in indices
|
||||||
|
|
||||||
|
private fun shiftIndices(delta: Int) {
|
||||||
|
for (i in 0 until indices.size()) {
|
||||||
|
val range = indices.valueAt(i)
|
||||||
|
indices.setValueAt(i, range + delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private operator fun IntRange.plus(delta: Int): IntRange {
|
||||||
|
return IntRange(start + delta, endInclusive + delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
package org.xtimms.tokusho.sections.reader.domain
|
||||||
|
|
||||||
|
import androidx.collection.LongSparseArray
|
||||||
|
import dagger.hilt.android.scopes.ViewModelScoped
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.xtimms.tokusho.core.parser.MangaRepository
|
||||||
|
import org.xtimms.tokusho.sections.details.data.MangaDetails
|
||||||
|
import org.xtimms.tokusho.sections.reader.pager.ReaderPage
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val PAGES_TRIM_THRESHOLD = 120
|
||||||
|
|
||||||
|
@ViewModelScoped
|
||||||
|
class ChaptersLoader @Inject constructor(
|
||||||
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val chapters = LongSparseArray<MangaChapter>()
|
||||||
|
private val chapterPages = ChapterPages()
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
val size: Int
|
||||||
|
get() = chapters.size()
|
||||||
|
|
||||||
|
suspend fun init(manga: MangaDetails) = mutex.withLock {
|
||||||
|
chapters.clear()
|
||||||
|
manga.allChapters.forEach {
|
||||||
|
chapters.put(it.id, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadPrevNextChapter(manga: MangaDetails, currentId: Long, isNext: Boolean) {
|
||||||
|
val chapters = manga.allChapters
|
||||||
|
val predicate: (MangaChapter) -> Boolean = { it.id == currentId }
|
||||||
|
val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate)
|
||||||
|
if (index == -1) return
|
||||||
|
val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return
|
||||||
|
val newPages = loadChapter(newChapter.id)
|
||||||
|
mutex.withLock {
|
||||||
|
if (chapterPages.chaptersSize > 1) {
|
||||||
|
// trim pages
|
||||||
|
if (chapterPages.size > PAGES_TRIM_THRESHOLD) {
|
||||||
|
if (isNext) {
|
||||||
|
chapterPages.removeFirst()
|
||||||
|
} else {
|
||||||
|
chapterPages.removeLast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isNext) {
|
||||||
|
chapterPages.addLast(newChapter.id, newPages)
|
||||||
|
} else {
|
||||||
|
chapterPages.addFirst(newChapter.id, newPages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadSingleChapter(chapterId: Long) {
|
||||||
|
val pages = loadChapter(chapterId)
|
||||||
|
mutex.withLock {
|
||||||
|
chapterPages.clear()
|
||||||
|
chapterPages.addLast(chapterId, pages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun peekChapter(chapterId: Long): MangaChapter? = chapters[chapterId]
|
||||||
|
|
||||||
|
fun hasPages(chapterId: Long): Boolean {
|
||||||
|
return chapterId in chapterPages
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPages(chapterId: Long): List<ReaderPage> {
|
||||||
|
return chapterPages.subList(chapterId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPagesCount(chapterId: Long): Int {
|
||||||
|
return chapterPages.size(chapterId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun last() = chapterPages.last()
|
||||||
|
|
||||||
|
fun first() = chapterPages.first()
|
||||||
|
|
||||||
|
fun snapshot() = chapterPages.toList()
|
||||||
|
|
||||||
|
private suspend fun loadChapter(chapterId: Long): List<ReaderPage> {
|
||||||
|
val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" }
|
||||||
|
val repo = mangaRepositoryFactory.create(chapter.source)
|
||||||
|
return repo.getPages(chapter).mapIndexed { index, page ->
|
||||||
|
ReaderPage(page, index, chapterId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,255 @@
|
|||||||
|
package org.xtimms.tokusho.sections.reader.domain
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.AnyThread
|
||||||
|
import androidx.collection.LongSparseArray
|
||||||
|
import androidx.collection.set
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import dagger.hilt.android.ActivityRetainedLifecycle
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.android.scopes.ActivityRetainedScoped
|
||||||
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.xtimms.tokusho.core.cache.PagesCache
|
||||||
|
import org.xtimms.tokusho.core.network.CommonHeaders
|
||||||
|
import org.xtimms.tokusho.core.network.MangaHttpClient
|
||||||
|
import org.xtimms.tokusho.core.network.interceptors.ImageProxyInterceptor
|
||||||
|
import org.xtimms.tokusho.core.parser.MangaRepository
|
||||||
|
import org.xtimms.tokusho.core.parser.RemoteMangaRepository
|
||||||
|
import org.xtimms.tokusho.core.parser.local.isFileUri
|
||||||
|
import org.xtimms.tokusho.core.parser.local.isZipUri
|
||||||
|
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||||
|
import org.xtimms.tokusho.sections.reader.pager.ReaderPage
|
||||||
|
import org.xtimms.tokusho.utils.FileSize
|
||||||
|
import org.xtimms.tokusho.utils.RetainedLifecycleCoroutineScope
|
||||||
|
import org.xtimms.tokusho.utils.lang.getCompletionResultOrNull
|
||||||
|
import org.xtimms.tokusho.utils.lang.withProgress
|
||||||
|
import org.xtimms.tokusho.utils.progress.ProgressDeferred
|
||||||
|
import org.xtimms.tokusho.utils.system.URI_SCHEME_ZIP
|
||||||
|
import org.xtimms.tokusho.utils.system.compressToPNG
|
||||||
|
import org.xtimms.tokusho.utils.system.ensureRamAtLeast
|
||||||
|
import org.xtimms.tokusho.utils.system.ensureSuccess
|
||||||
|
import org.xtimms.tokusho.utils.system.exists
|
||||||
|
import org.xtimms.tokusho.utils.system.isPowerSaveMode
|
||||||
|
import org.xtimms.tokusho.utils.system.isTargetNotEmpty
|
||||||
|
import org.xtimms.tokusho.utils.system.ramAvailable
|
||||||
|
import java.util.LinkedList
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
|
@ActivityRetainedScoped
|
||||||
|
class PageLoader @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
lifecycle: ActivityRetainedLifecycle,
|
||||||
|
@MangaHttpClient private val okHttp: OkHttpClient,
|
||||||
|
private val cache: PagesCache,
|
||||||
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
private val imageProxyInterceptor: ImageProxyInterceptor,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default
|
||||||
|
|
||||||
|
private val tasks = LongSparseArray<ProgressDeferred<Uri, Float>>()
|
||||||
|
private val semaphore = Semaphore(3)
|
||||||
|
private val convertLock = Mutex()
|
||||||
|
private val prefetchLock = Mutex()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var repository: MangaRepository? = null
|
||||||
|
private val prefetchQueue = LinkedList<MangaPage>()
|
||||||
|
private val counter = AtomicInteger(0)
|
||||||
|
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
|
||||||
|
|
||||||
|
fun isPrefetchApplicable(): Boolean {
|
||||||
|
return repository is RemoteMangaRepository
|
||||||
|
// && settings.isPagesPreloadEnabled
|
||||||
|
&& !context.isPowerSaveMode()
|
||||||
|
&& !isLowRam()
|
||||||
|
}
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
|
fun prefetch(pages: List<ReaderPage>) = loaderScope.launch {
|
||||||
|
prefetchLock.withLock {
|
||||||
|
for (page in pages.asReversed()) {
|
||||||
|
if (tasks.containsKey(page.id)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prefetchQueue.offerFirst(page.toMangaPage())
|
||||||
|
if (prefetchQueue.size > prefetchQueueLimit) {
|
||||||
|
prefetchQueue.pollLast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (counter.get() == 0) {
|
||||||
|
onIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred<Uri, Float> {
|
||||||
|
var task = tasks[page.id]?.takeIf { it.isValid() }
|
||||||
|
if (force) {
|
||||||
|
task?.cancel()
|
||||||
|
} else if (task?.isCancelled == false) {
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
task = loadPageAsyncImpl(page, force)
|
||||||
|
synchronized(tasks) {
|
||||||
|
tasks[page.id] = task
|
||||||
|
}
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadPage(page: MangaPage, force: Boolean): Uri {
|
||||||
|
return loadPageAsync(page, force).await()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun convertBitmap(uri: Uri): Uri = convertLock.withLock {
|
||||||
|
if (uri.isZipUri()) {
|
||||||
|
val bitmap = runInterruptible(Dispatchers.IO) {
|
||||||
|
ZipFile(uri.schemeSpecificPart).use { zip ->
|
||||||
|
val entry = zip.getEntry(uri.fragment)
|
||||||
|
context.ensureRamAtLeast(entry.size * 2)
|
||||||
|
zip.getInputStream(zip.getEntry(uri.fragment)).use {
|
||||||
|
BitmapFactory.decodeStream(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache.put(uri.toString(), bitmap).toUri()
|
||||||
|
} else {
|
||||||
|
val file = uri.toFile()
|
||||||
|
context.ensureRamAtLeast(file.length() * 2)
|
||||||
|
val image = runInterruptible(Dispatchers.IO) {
|
||||||
|
BitmapFactory.decodeFile(file.absolutePath)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
image.compressToPNG(file)
|
||||||
|
} finally {
|
||||||
|
image.recycle()
|
||||||
|
}
|
||||||
|
uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getPageUrl(page: MangaPage): String {
|
||||||
|
return getRepository(page.source).getPageUrl(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onIdle() = loaderScope.launch {
|
||||||
|
prefetchLock.withLock {
|
||||||
|
while (prefetchQueue.isNotEmpty()) {
|
||||||
|
val page = prefetchQueue.pollFirst() ?: return@launch
|
||||||
|
if (cache.get(page.url) == null) {
|
||||||
|
synchronized(tasks) {
|
||||||
|
tasks[page.id] = loadPageAsyncImpl(page, false)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadPageAsyncImpl(page: MangaPage, skipCache: Boolean): ProgressDeferred<Uri, Float> {
|
||||||
|
val progress = MutableStateFlow(PROGRESS_UNDEFINED)
|
||||||
|
val deferred = loaderScope.async {
|
||||||
|
if (!skipCache) {
|
||||||
|
cache.get(page.url)?.let { return@async it.toUri() }
|
||||||
|
}
|
||||||
|
counter.incrementAndGet()
|
||||||
|
try {
|
||||||
|
loadPageImpl(page, progress)
|
||||||
|
} finally {
|
||||||
|
if (counter.decrementAndGet() == 0) {
|
||||||
|
onIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ProgressDeferred(deferred, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun getRepository(source: MangaSource): MangaRepository {
|
||||||
|
val result = repository
|
||||||
|
return if (result != null && result.source == source) {
|
||||||
|
result
|
||||||
|
} else {
|
||||||
|
mangaRepositoryFactory.create(source).also { repository = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow<Float>): Uri = semaphore.withPermit {
|
||||||
|
val pageUrl = getPageUrl(page)
|
||||||
|
check(pageUrl.isNotBlank()) { "Cannot obtain full image url for $page" }
|
||||||
|
val uri = Uri.parse(pageUrl)
|
||||||
|
return when {
|
||||||
|
uri.isZipUri() -> if (uri.scheme == URI_SCHEME_ZIP) {
|
||||||
|
uri
|
||||||
|
} else { // legacy uri
|
||||||
|
uri.buildUpon().scheme(URI_SCHEME_ZIP).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
uri.isFileUri() -> uri
|
||||||
|
else -> {
|
||||||
|
val request = createPageRequest(page, pageUrl)
|
||||||
|
imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response ->
|
||||||
|
val body = checkNotNull(response.body) { "Null response body" }
|
||||||
|
body.withProgress(progress).use {
|
||||||
|
cache.put(pageUrl, it.source())
|
||||||
|
}
|
||||||
|
}.toUri()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isLowRam(): Boolean {
|
||||||
|
return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Deferred<Uri>.isValid(): Boolean {
|
||||||
|
return getCompletionResultOrNull()?.map { uri ->
|
||||||
|
uri.exists() && uri.isTargetNotEmpty()
|
||||||
|
}?.getOrDefault(false) ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
private class InternalErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler),
|
||||||
|
CoroutineExceptionHandler {
|
||||||
|
|
||||||
|
override fun handleException(context: CoroutineContext, exception: Throwable) {
|
||||||
|
exception.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val PROGRESS_UNDEFINED = -1f
|
||||||
|
private const val PREFETCH_LIMIT_DEFAULT = 6
|
||||||
|
private const val PREFETCH_MIN_RAM_MB = 80L
|
||||||
|
|
||||||
|
fun createPageRequest(page: MangaPage, pageUrl: String) = Request.Builder()
|
||||||
|
.url(pageUrl)
|
||||||
|
.get()
|
||||||
|
.header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8")
|
||||||
|
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
|
||||||
|
.tag(MangaSource::class.java, page.source)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package org.xtimms.tokusho.sections.reader.pager
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class ReaderPage(
|
||||||
|
val id: Long,
|
||||||
|
val url: String,
|
||||||
|
val preview: String?,
|
||||||
|
val chapterId: Long,
|
||||||
|
val index: Int,
|
||||||
|
val source: MangaSource,
|
||||||
|
) : Parcelable {
|
||||||
|
|
||||||
|
constructor(page: MangaPage, index: Int, chapterId: Long) : this(
|
||||||
|
id = page.id,
|
||||||
|
url = page.url,
|
||||||
|
preview = page.preview,
|
||||||
|
chapterId = chapterId,
|
||||||
|
index = index,
|
||||||
|
source = page.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toMangaPage() = MangaPage(
|
||||||
|
id = id,
|
||||||
|
url = url,
|
||||||
|
preview = preview,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,143 @@
|
|||||||
|
package org.xtimms.tokusho.sections.reader.thumbnails
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.annotation.ExperimentalCoilApi
|
||||||
|
import coil.decode.DataSource
|
||||||
|
import coil.decode.ImageSource
|
||||||
|
import coil.fetch.FetchResult
|
||||||
|
import coil.fetch.Fetcher
|
||||||
|
import coil.fetch.SourceResult
|
||||||
|
import coil.request.Options
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okio.Path.Companion.toOkioPath
|
||||||
|
import okio.buffer
|
||||||
|
import okio.source
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mimeType
|
||||||
|
import org.xtimms.tokusho.core.cache.PagesCache
|
||||||
|
import org.xtimms.tokusho.core.network.MangaHttpClient
|
||||||
|
import org.xtimms.tokusho.core.network.interceptors.ImageProxyInterceptor
|
||||||
|
import org.xtimms.tokusho.core.parser.MangaRepository
|
||||||
|
import org.xtimms.tokusho.core.parser.local.isFileUri
|
||||||
|
import org.xtimms.tokusho.core.parser.local.isZipUri
|
||||||
|
import org.xtimms.tokusho.sections.reader.domain.PageLoader
|
||||||
|
import org.xtimms.tokusho.utils.withExtraCloseable
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class MangaPageFetcher(
|
||||||
|
private val context: Context,
|
||||||
|
private val okHttpClient: OkHttpClient,
|
||||||
|
private val pagesCache: PagesCache,
|
||||||
|
private val options: Options,
|
||||||
|
private val page: MangaPage,
|
||||||
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
private val imageProxyInterceptor: ImageProxyInterceptor,
|
||||||
|
) : Fetcher {
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoilApi::class)
|
||||||
|
override suspend fun fetch(): FetchResult {
|
||||||
|
val repo = mangaRepositoryFactory.create(page.source)
|
||||||
|
val pageUrl = repo.getPageUrl(page)
|
||||||
|
pagesCache.get(pageUrl)?.let { file ->
|
||||||
|
return SourceResult(
|
||||||
|
source = ImageSource(
|
||||||
|
file = file.toOkioPath(),
|
||||||
|
metadata = MangaPageMetadata(page),
|
||||||
|
),
|
||||||
|
mimeType = null,
|
||||||
|
dataSource = DataSource.DISK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return loadPage(pageUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoilApi::class)
|
||||||
|
private suspend fun loadPage(pageUrl: String): SourceResult {
|
||||||
|
val uri = pageUrl.toUri()
|
||||||
|
return when {
|
||||||
|
uri.isZipUri() -> runInterruptible(Dispatchers.IO) {
|
||||||
|
val zip = ZipFile(uri.schemeSpecificPart)
|
||||||
|
val entry = zip.getEntry(uri.fragment)
|
||||||
|
SourceResult(
|
||||||
|
source = ImageSource(
|
||||||
|
source = zip.getInputStream(entry).source().withExtraCloseable(zip).buffer(),
|
||||||
|
context = context,
|
||||||
|
metadata = MangaPageMetadata(page),
|
||||||
|
),
|
||||||
|
mimeType = MimeTypeMap.getSingleton()
|
||||||
|
.getMimeTypeFromExtension(entry.name.substringAfterLast('.', "")),
|
||||||
|
dataSource = DataSource.DISK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
uri.isFileUri() -> runInterruptible(Dispatchers.IO) {
|
||||||
|
val file = uri.toFile()
|
||||||
|
SourceResult(
|
||||||
|
source = ImageSource(
|
||||||
|
source = file.source().buffer(),
|
||||||
|
context = context,
|
||||||
|
metadata = MangaPageMetadata(page),
|
||||||
|
),
|
||||||
|
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension),
|
||||||
|
dataSource = DataSource.DISK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val request = PageLoader.createPageRequest(page, pageUrl)
|
||||||
|
imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response ->
|
||||||
|
check(response.isSuccessful) {
|
||||||
|
"Invalid response: ${response.code} ${response.message} at $pageUrl"
|
||||||
|
}
|
||||||
|
val body = checkNotNull(response.body) {
|
||||||
|
"Null response"
|
||||||
|
}
|
||||||
|
val mimeType = response.mimeType
|
||||||
|
val file = body.use {
|
||||||
|
pagesCache.put(pageUrl, it.source())
|
||||||
|
}
|
||||||
|
SourceResult(
|
||||||
|
source = ImageSource(
|
||||||
|
file = file.toOkioPath(),
|
||||||
|
metadata = MangaPageMetadata(page),
|
||||||
|
),
|
||||||
|
mimeType = mimeType,
|
||||||
|
dataSource = DataSource.NETWORK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
@MangaHttpClient private val okHttpClient: OkHttpClient,
|
||||||
|
private val pagesCache: PagesCache,
|
||||||
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
private val imageProxyInterceptor: ImageProxyInterceptor,
|
||||||
|
) : Fetcher.Factory<MangaPage> {
|
||||||
|
|
||||||
|
override fun create(data: MangaPage, options: Options, imageLoader: ImageLoader): Fetcher {
|
||||||
|
return MangaPageFetcher(
|
||||||
|
okHttpClient = okHttpClient,
|
||||||
|
pagesCache = pagesCache,
|
||||||
|
options = options,
|
||||||
|
page = data,
|
||||||
|
context = context,
|
||||||
|
mangaRepositoryFactory = mangaRepositoryFactory,
|
||||||
|
imageProxyInterceptor = imageProxyInterceptor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoilApi::class)
|
||||||
|
class MangaPageMetadata(val page: MangaPage) : ImageSource.Metadata()
|
||||||
|
}
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
package org.xtimms.tokusho.sections.settings.sources.catalog
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Close
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.util.fastAny
|
||||||
|
import coil.ImageLoader
|
||||||
|
import org.xtimms.tokusho.R
|
||||||
|
import org.xtimms.tokusho.core.components.MangaGridItem
|
||||||
|
import org.xtimms.tokusho.core.screens.EmptyScreen
|
||||||
|
import org.xtimms.tokusho.sections.shelf.LazyShelfGrid
|
||||||
|
import org.xtimms.tokusho.sections.shelf.ShelfGrid
|
||||||
|
import org.xtimms.tokusho.sections.shelf.ShelfManga
|
||||||
|
import org.xtimms.tokusho.utils.system.plus
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SourcesCatalogPager(
|
||||||
|
coil: ImageLoader,
|
||||||
|
state: PagerState,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
searchQuery: String?,
|
||||||
|
getSourcesForPage: (Int) -> List<SourceCatalogPage>,
|
||||||
|
) {
|
||||||
|
HorizontalPager(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
state = state,
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
) { page ->
|
||||||
|
|
||||||
|
if (page !in ((state.currentPage - 1)..(state.currentPage + 1))) {
|
||||||
|
// To make sure only one offscreen page is being composed
|
||||||
|
return@HorizontalPager
|
||||||
|
}
|
||||||
|
|
||||||
|
val sources = getSourcesForPage(page)
|
||||||
|
|
||||||
|
if (sources.isEmpty()) {
|
||||||
|
SourcesCatalogPagerEmptyScreen(
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
)
|
||||||
|
return@HorizontalPager
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = sources,
|
||||||
|
) { item ->
|
||||||
|
item.items.forEach { source ->
|
||||||
|
SourceCatalogItem(
|
||||||
|
coil = coil,
|
||||||
|
source = source.source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SourcesCatalogPagerEmptyScreen(
|
||||||
|
searchQuery: String?,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
) {
|
||||||
|
val msg = when {
|
||||||
|
!searchQuery.isNullOrEmpty() -> R.string.no_results_found
|
||||||
|
else -> R.string.information_no_manga_category
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(contentPadding + PaddingValues(8.dp))
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
EmptyScreen(
|
||||||
|
icon = Icons.Outlined.Close,
|
||||||
|
title = R.string.empty_here,
|
||||||
|
description = msg,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +1,170 @@
|
|||||||
package org.xtimms.tokusho.utils.lang
|
package org.xtimms.tokusho.utils.lang
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
|
import org.xtimms.tokusho.R
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
import java.util.Calendar
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
fun Date.toDateTimestampString(dateFormatter: DateFormat): String {
|
fun Date.toDateTimestampString(dateFormatter: DateFormat): String {
|
||||||
val date = dateFormatter.format(this)
|
val date = dateFormatter.format(this)
|
||||||
val time = DateFormat.getTimeInstance(DateFormat.SHORT).format(this)
|
val time = DateFormat.getTimeInstance(DateFormat.SHORT).format(this)
|
||||||
return "$date $time"
|
return "$date $time"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Date.toTimestampString(): String {
|
||||||
|
return DateFormat.getTimeInstance(DateFormat.SHORT).format(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo {
|
||||||
|
// TODO: Use Java 9's LocalDate.ofInstant().
|
||||||
|
val localDate = LocalDateTime.ofInstant(instant, ZoneId.systemDefault()).toLocalDate()
|
||||||
|
val now = LocalDate.now()
|
||||||
|
val diffDays = localDate.until(now, ChronoUnit.DAYS)
|
||||||
|
|
||||||
|
return when {
|
||||||
|
diffDays == 0L -> {
|
||||||
|
if (instant.until(Instant.now(), ChronoUnit.MINUTES) < 3) DateTimeAgo.JustNow
|
||||||
|
else DateTimeAgo.Today
|
||||||
|
}
|
||||||
|
diffDays == 1L -> DateTimeAgo.Yesterday
|
||||||
|
diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays.toInt())
|
||||||
|
else -> {
|
||||||
|
val diffMonths = localDate.until(now, ChronoUnit.MONTHS)
|
||||||
|
if (showMonths && diffMonths <= 6) {
|
||||||
|
DateTimeAgo.MonthsAgo(diffMonths.toInt())
|
||||||
|
} else {
|
||||||
|
DateTimeAgo.Absolute(localDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isSameDay(timestampA: Long, timestampB: Long): Boolean {
|
||||||
|
return isSameDay(Date(timestampA), Date(timestampB))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isSameDay(dateA: Date, dateB: Date): Boolean {
|
||||||
|
return roundToDay(dateA) == roundToDay(dateB)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun roundToDay(date: Date): Date {
|
||||||
|
val calendar = Calendar.getInstance()
|
||||||
|
calendar.time = date
|
||||||
|
|
||||||
|
return Calendar
|
||||||
|
.Builder()
|
||||||
|
.setTimeZone(calendar.timeZone)
|
||||||
|
.setDate(calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH), calendar.get(Calendar.DAY_OF_MONTH))
|
||||||
|
.build()
|
||||||
|
.time
|
||||||
|
}
|
||||||
|
|
||||||
|
fun LocalDate.toDate(): Date = Date(this.atStartOfDay(ZoneId.systemDefault()).toEpochSecond() * 1000)
|
||||||
|
|
||||||
|
fun LocalDateTime.toDate(): Date = Date(this.toEpochSecond(
|
||||||
|
ZoneId.systemDefault().rules.getOffset(this)
|
||||||
|
) * 1000)
|
||||||
|
|
||||||
|
sealed class DateTimeAgo {
|
||||||
|
|
||||||
|
abstract fun format(resources: Resources): String
|
||||||
|
|
||||||
|
object JustNow : DateTimeAgo() {
|
||||||
|
override fun format(resources: Resources): String {
|
||||||
|
return resources.getString(R.string.just_now)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString() = "just_now"
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean = other === JustNow
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MinutesAgo(val minutes: Int) : DateTimeAgo() {
|
||||||
|
override fun format(resources: Resources): String {
|
||||||
|
return resources.getQuantityString(R.plurals.minutes_ago, minutes, minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString() = "minutes_ago_$minutes"
|
||||||
|
}
|
||||||
|
|
||||||
|
data class HoursAgo(val hours: Int) : DateTimeAgo() {
|
||||||
|
override fun format(resources: Resources): String {
|
||||||
|
return resources.getQuantityString(R.plurals.hours_ago, hours, hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString() = "hours_ago_$hours"
|
||||||
|
}
|
||||||
|
|
||||||
|
object Today : DateTimeAgo() {
|
||||||
|
override fun format(resources: Resources): String {
|
||||||
|
return resources.getString(R.string.today)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString() = "today"
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean = other === Today
|
||||||
|
}
|
||||||
|
|
||||||
|
object Yesterday : DateTimeAgo() {
|
||||||
|
override fun format(resources: Resources): String {
|
||||||
|
return resources.getString(R.string.yesterday)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString() = "yesterday"
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean = other === Yesterday
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DaysAgo(val days: Int) : DateTimeAgo() {
|
||||||
|
override fun format(resources: Resources): String {
|
||||||
|
return resources.getQuantityString(R.plurals.days_ago, days, days)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString() = "days_ago_$days"
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MonthsAgo(val months: Int) : DateTimeAgo() {
|
||||||
|
override fun format(resources: Resources): String {
|
||||||
|
return if (months == 0) {
|
||||||
|
resources.getString(R.string.this_month)
|
||||||
|
} else {
|
||||||
|
resources.getQuantityString(R.plurals.months_ago, months, months)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Absolute(private val date: LocalDate) : DateTimeAgo() {
|
||||||
|
override fun format(resources: Resources): String {
|
||||||
|
return if (date == EPOCH_DATE) {
|
||||||
|
resources.getString(R.string.unknown)
|
||||||
|
} else {
|
||||||
|
date.format(formatter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString() = "abs_${date.toEpochDay()}"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// TODO: Use Java 9's LocalDate.EPOCH.
|
||||||
|
private val EPOCH_DATE = LocalDate.of(1970, 1, 1)
|
||||||
|
private val formatter = DateTimeFormatter.ofPattern("d MMMM")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object LongAgo : DateTimeAgo() {
|
||||||
|
override fun format(resources: Resources): String {
|
||||||
|
return resources.getString(R.string.long_ago)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString() = "long_ago"
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean = other === LongAgo
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package org.xtimms.tokusho.utils.progress
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
class ProgressDeferred<T, P>(
|
||||||
|
private val deferred: Deferred<T>,
|
||||||
|
private val progress: StateFlow<P>,
|
||||||
|
) : Deferred<T> by deferred {
|
||||||
|
|
||||||
|
val progressValue: P
|
||||||
|
get() = progress.value
|
||||||
|
|
||||||
|
fun progressAsFlow(): Flow<P> = progress
|
||||||
|
}
|
||||||
@ -1,4 +1,33 @@
|
|||||||
package org.xtimms.tokusho.utils.system
|
package org.xtimms.tokusho.utils.system
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import java.io.File
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
const val URI_SCHEME_FILE = "file"
|
const val URI_SCHEME_FILE = "file"
|
||||||
const val URI_SCHEME_ZIP = "file+zip"
|
const val URI_SCHEME_ZIP = "file+zip"
|
||||||
|
|
||||||
|
fun Uri.exists(): Boolean = when (scheme) {
|
||||||
|
URI_SCHEME_FILE -> toFile().exists()
|
||||||
|
URI_SCHEME_ZIP -> {
|
||||||
|
val file = File(requireNotNull(schemeSpecificPart))
|
||||||
|
file.exists() && ZipFile(file).use { it.getEntry(fragment) != null }
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> unsupportedUri(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Uri.isTargetNotEmpty(): Boolean = when (scheme) {
|
||||||
|
URI_SCHEME_FILE -> toFile().isNotEmpty()
|
||||||
|
URI_SCHEME_ZIP -> {
|
||||||
|
val file = File(requireNotNull(schemeSpecificPart))
|
||||||
|
file.exists() && ZipFile(file).use { (it.getEntry(fragment)?.size ?: 0L) != 0L }
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> unsupportedUri(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unsupportedUri(uri: Uri): Nothing {
|
||||||
|
throw IllegalArgumentException("Bad uri $uri: only schemes $URI_SCHEME_FILE and $URI_SCHEME_ZIP are supported")
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
unqualifiedResLocale=en-US
|
||||||
Loading…
Reference in New Issue