Some changes
This commit is contained in:
15
.idea/deploymentTargetDropDown.xml
generated
15
.idea/deploymentTargetDropDown.xml
generated
@@ -6,7 +6,20 @@
|
|||||||
<State />
|
<State />
|
||||||
</entry>
|
</entry>
|
||||||
<entry key="app">
|
<entry key="app">
|
||||||
<State />
|
<State>
|
||||||
|
<targetSelectedWithDropDown>
|
||||||
|
<Target>
|
||||||
|
<type value="QUICK_BOOT_TARGET" />
|
||||||
|
<deviceKey>
|
||||||
|
<Key>
|
||||||
|
<type value="VIRTUAL_DEVICE_PATH" />
|
||||||
|
<value value="C:\Users\xtimms\.android\avd\Small_Phone_API_34.avd" />
|
||||||
|
</Key>
|
||||||
|
</deviceKey>
|
||||||
|
</Target>
|
||||||
|
</targetSelectedWithDropDown>
|
||||||
|
<timeTargetWasSelectedWithDropDown value="2024-03-17T20:36:24.681011800Z" />
|
||||||
|
</State>
|
||||||
</entry>
|
</entry>
|
||||||
</value>
|
</value>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ android {
|
|||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
androidResources {
|
||||||
|
generateLocaleConfig = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -92,7 +95,7 @@ dependencies {
|
|||||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-process:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-process:2.7.0")
|
||||||
implementation("androidx.activity:activity-compose:1.8.2")
|
implementation("androidx.activity:activity-compose:1.8.2")
|
||||||
implementation(platform("dev.chrisbanes.compose:compose-bom:2024.02.00-alpha02"))
|
implementation(platform("dev.chrisbanes.compose:compose-bom:2024.03.00-alpha01"))
|
||||||
implementation("androidx.compose.animation:animation-graphics")
|
implementation("androidx.compose.animation:animation-graphics")
|
||||||
implementation("androidx.compose.ui:ui")
|
implementation("androidx.compose.ui:ui")
|
||||||
implementation("androidx.compose.ui:ui-graphics")
|
implementation("androidx.compose.ui:ui-graphics")
|
||||||
@@ -109,13 +112,14 @@ dependencies {
|
|||||||
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||||
ksp("androidx.room:room-compiler:2.6.1")
|
ksp("androidx.room:room-compiler:2.6.1")
|
||||||
implementation("ch.acra:acra-http:5.9.7")
|
implementation("ch.acra:acra-http:5.9.7")
|
||||||
|
implementation("com.github.solkin:disk-lru-cache:1.4")
|
||||||
implementation("com.google.android.material:material:1.11.0")
|
implementation("com.google.android.material:material:1.11.0")
|
||||||
implementation("com.google.accompanist:accompanist-flowlayout:0.32.0")
|
implementation("com.google.accompanist:accompanist-flowlayout:0.32.0")
|
||||||
implementation("com.google.accompanist:accompanist-systemuicontroller:0.32.0")
|
implementation("com.google.accompanist:accompanist-systemuicontroller:0.32.0")
|
||||||
implementation("com.google.accompanist:accompanist-pager:0.32.0")
|
implementation("com.google.accompanist:accompanist-pager:0.32.0")
|
||||||
implementation("com.google.accompanist:accompanist-pager-indicators:0.32.0")
|
implementation("com.google.accompanist:accompanist-pager-indicators:0.32.0")
|
||||||
implementation("com.google.dagger:hilt-android:2.50")
|
implementation("com.google.dagger:hilt-android:2.51")
|
||||||
kapt("com.google.dagger:hilt-compiler:2.50")
|
kapt("com.google.dagger:hilt-compiler:2.51")
|
||||||
implementation("androidx.hilt:hilt-work:1.2.0")
|
implementation("androidx.hilt:hilt-work:1.2.0")
|
||||||
kapt("androidx.hilt:hilt-compiler:1.2.0")
|
kapt("androidx.hilt:hilt-compiler:1.2.0")
|
||||||
implementation("com.github.KotatsuApp:kotatsu-parsers:fec60955ed") {
|
implementation("com.github.KotatsuApp:kotatsu-parsers:fec60955ed") {
|
||||||
@@ -130,6 +134,7 @@ dependencies {
|
|||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.7.3")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
|
||||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||||
|
implementation("me.saket.telephoto:zoomable-image-coil:0.8.0")
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package org.xtimms.tokusho
|
package org.xtimms.tokusho
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.provider.Settings
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -34,26 +38,35 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.os.LocaleListCompat
|
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.core.view.WindowCompat
|
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.xtimms.tokusho.core.Navigation
|
import org.xtimms.tokusho.core.Navigation
|
||||||
import org.xtimms.tokusho.core.components.BottomNavBar
|
import org.xtimms.tokusho.core.components.BottomNavBar
|
||||||
|
import org.xtimms.tokusho.core.components.ContinueReadingButton
|
||||||
|
import org.xtimms.tokusho.core.components.NavigationRail
|
||||||
import org.xtimms.tokusho.core.components.TopAppBar
|
import org.xtimms.tokusho.core.components.TopAppBar
|
||||||
import org.xtimms.tokusho.core.logs.FileLogger
|
import org.xtimms.tokusho.core.logs.FileLogger
|
||||||
|
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||||
|
import org.xtimms.tokusho.core.screens.UpdateDialogImpl
|
||||||
|
import org.xtimms.tokusho.core.updates.Updater
|
||||||
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
||||||
import org.xtimms.tokusho.utils.lang.processLifecycleScope
|
import org.xtimms.tokusho.utils.system.setLanguage
|
||||||
|
import org.xtimms.tokusho.utils.system.suspendToast
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||||
@@ -79,10 +92,48 @@ class MainActivity : ComponentActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
if (Build.VERSION.SDK_INT < 33) {
|
||||||
|
setLanguage(AppSettings.getLocaleFromPreference())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var updateJob: Job? = null
|
||||||
|
var latestRelease by remember { mutableStateOf(Updater.LatestRelease()) }
|
||||||
|
var showUpdateDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var currentDownloadStatus by remember { mutableStateOf(Updater.DownloadStatus.NotYet as Updater.DownloadStatus) }
|
||||||
|
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val windowSizeClass = calculateWindowSizeClass(this)
|
val windowSizeClass = calculateWindowSizeClass(this)
|
||||||
val isCompactScreen = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact
|
val isCompactScreen = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact
|
||||||
|
|
||||||
|
val settings =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||||
|
Updater.installLatestApk(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { result ->
|
||||||
|
if (result) {
|
||||||
|
Updater.installLatestApk(context)
|
||||||
|
} else {
|
||||||
|
if (!context.packageManager.canRequestPackageInstalls())
|
||||||
|
settings.launch(
|
||||||
|
Intent(
|
||||||
|
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||||
|
Uri.parse("package:${context.packageName}"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Updater.installLatestApk(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
isReady.value = true
|
isReady.value = true
|
||||||
}
|
}
|
||||||
@@ -102,6 +153,49 @@ class MainActivity : ComponentActivity() {
|
|||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
isDone.value = true
|
isDone.value = true
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (!AppSettings.isAutoUpdateEnabled())
|
||||||
|
return@LaunchedEffect
|
||||||
|
launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
Updater.checkForUpdate(context)?.let {
|
||||||
|
latestRelease = it
|
||||||
|
showUpdateDialog = true
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
it.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showUpdateDialog) {
|
||||||
|
UpdateDialogImpl(
|
||||||
|
onDismissRequest = {
|
||||||
|
showUpdateDialog = false
|
||||||
|
updateJob?.cancel()
|
||||||
|
},
|
||||||
|
title = latestRelease.name.toString(),
|
||||||
|
onConfirmUpdate = {
|
||||||
|
updateJob = scope.launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
Updater.downloadApk(context, latestRelease)
|
||||||
|
.collect { downloadStatus ->
|
||||||
|
currentDownloadStatus = downloadStatus
|
||||||
|
if (downloadStatus is Updater.DownloadStatus.Finished) {
|
||||||
|
launcher.launch(Manifest.permission.REQUEST_INSTALL_PACKAGES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
it.printStackTrace()
|
||||||
|
currentDownloadStatus = Updater.DownloadStatus.NotYet
|
||||||
|
context.suspendToast(R.string.app_update_failed)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
releaseNote = latestRelease.body.toString(),
|
||||||
|
downloadStatus = currentDownloadStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,6 +261,9 @@ fun MainView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
ContinueReadingButton(navController = navController)
|
||||||
|
},
|
||||||
contentWindowInsets = WindowInsets.systemBars
|
contentWindowInsets = WindowInsets.systemBars
|
||||||
.only(WindowInsetsSides.Horizontal)
|
.only(WindowInsetsSides.Horizontal)
|
||||||
) { padding ->
|
) { padding ->
|
||||||
@@ -175,6 +272,9 @@ fun MainView(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(padding)
|
modifier = Modifier.padding(padding)
|
||||||
) {
|
) {
|
||||||
|
NavigationRail(
|
||||||
|
navController = navController
|
||||||
|
)
|
||||||
Navigation(
|
Navigation(
|
||||||
coil = coil,
|
coil = coil,
|
||||||
loggers = loggers,
|
loggers = loggers,
|
||||||
|
|||||||
@@ -27,11 +27,13 @@ import org.xtimms.tokusho.core.cache.StubContentCache
|
|||||||
import org.xtimms.tokusho.core.database.TokushoDatabase
|
import org.xtimms.tokusho.core.database.TokushoDatabase
|
||||||
import org.xtimms.tokusho.core.model.LocalManga
|
import org.xtimms.tokusho.core.model.LocalManga
|
||||||
import org.xtimms.tokusho.core.network.MangaHttpClient
|
import org.xtimms.tokusho.core.network.MangaHttpClient
|
||||||
|
import org.xtimms.tokusho.core.network.interceptors.ImageProxyInterceptor
|
||||||
import org.xtimms.tokusho.core.os.NetworkState
|
import org.xtimms.tokusho.core.os.NetworkState
|
||||||
import org.xtimms.tokusho.core.parser.MangaLoaderContextImpl
|
import org.xtimms.tokusho.core.parser.MangaLoaderContextImpl
|
||||||
import org.xtimms.tokusho.core.parser.MangaRepository
|
import org.xtimms.tokusho.core.parser.MangaRepository
|
||||||
import org.xtimms.tokusho.core.parser.favicon.FaviconFetcher
|
import org.xtimms.tokusho.core.parser.favicon.FaviconFetcher
|
||||||
import org.xtimms.tokusho.core.parser.local.LocalStorageChanges
|
import org.xtimms.tokusho.core.parser.local.LocalStorageChanges
|
||||||
|
import org.xtimms.tokusho.sections.reader.thumbnails.MangaPageFetcher
|
||||||
import org.xtimms.tokusho.utils.CoilImageGetter
|
import org.xtimms.tokusho.utils.CoilImageGetter
|
||||||
import org.xtimms.tokusho.utils.system.connectivityManager
|
import org.xtimms.tokusho.utils.system.connectivityManager
|
||||||
import org.xtimms.tokusho.utils.system.isLowRamDevice
|
import org.xtimms.tokusho.utils.system.isLowRamDevice
|
||||||
@@ -69,6 +71,8 @@ interface TokushoModule {
|
|||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
@MangaHttpClient okHttpClient: OkHttpClient,
|
@MangaHttpClient okHttpClient: OkHttpClient,
|
||||||
mangaRepositoryFactory: MangaRepository.Factory,
|
mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
imageProxyInterceptor: ImageProxyInterceptor,
|
||||||
|
pageFetcherFactory: MangaPageFetcher.Factory,
|
||||||
): ImageLoader {
|
): ImageLoader {
|
||||||
val diskCacheFactory = {
|
val diskCacheFactory = {
|
||||||
val rootDir = context.externalCacheDir ?: context.cacheDir
|
val rootDir = context.externalCacheDir ?: context.cacheDir
|
||||||
@@ -85,9 +89,12 @@ interface TokushoModule {
|
|||||||
.transformationDispatcher(Dispatchers.Default)
|
.transformationDispatcher(Dispatchers.Default)
|
||||||
.diskCache(diskCacheFactory)
|
.diskCache(diskCacheFactory)
|
||||||
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
||||||
|
.allowRgb565(context.isLowRamDevice())
|
||||||
.components(
|
.components(
|
||||||
ComponentRegistry.Builder()
|
ComponentRegistry.Builder()
|
||||||
.add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory))
|
.add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory))
|
||||||
|
.add(pageFetcherFactory)
|
||||||
|
.add(imageProxyInterceptor)
|
||||||
.build(),
|
.build(),
|
||||||
).build()
|
).build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import androidx.compose.animation.core.Easing
|
|||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -19,8 +21,6 @@ import androidx.navigation.navArgument
|
|||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.xtimms.tokusho.core.logs.FileLogger
|
import org.xtimms.tokusho.core.logs.FileLogger
|
||||||
import org.xtimms.tokusho.core.motion.materialSharedAxisXIn
|
|
||||||
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
|
|
||||||
import org.xtimms.tokusho.sections.details.DETAILS_DESTINATION
|
import org.xtimms.tokusho.sections.details.DETAILS_DESTINATION
|
||||||
import org.xtimms.tokusho.sections.details.DetailsView
|
import org.xtimms.tokusho.sections.details.DetailsView
|
||||||
import org.xtimms.tokusho.sections.details.FULL_POSTER_DESTINATION
|
import org.xtimms.tokusho.sections.details.FULL_POSTER_DESTINATION
|
||||||
@@ -34,6 +34,8 @@ import org.xtimms.tokusho.sections.history.HistoryView
|
|||||||
import org.xtimms.tokusho.sections.list.LIST_DESTINATION
|
import org.xtimms.tokusho.sections.list.LIST_DESTINATION
|
||||||
import org.xtimms.tokusho.sections.list.MangaListView
|
import org.xtimms.tokusho.sections.list.MangaListView
|
||||||
import org.xtimms.tokusho.sections.list.PROVIDER_ARGUMENT
|
import org.xtimms.tokusho.sections.list.PROVIDER_ARGUMENT
|
||||||
|
import org.xtimms.tokusho.sections.reader.READER_DESTINATION
|
||||||
|
import org.xtimms.tokusho.sections.reader.ReaderView
|
||||||
import org.xtimms.tokusho.sections.search.SEARCH_DESTINATION
|
import org.xtimms.tokusho.sections.search.SEARCH_DESTINATION
|
||||||
import org.xtimms.tokusho.sections.search.SearchHostView
|
import org.xtimms.tokusho.sections.search.SearchHostView
|
||||||
import org.xtimms.tokusho.sections.settings.SETTINGS_DESTINATION
|
import org.xtimms.tokusho.sections.settings.SETTINGS_DESTINATION
|
||||||
@@ -131,16 +133,31 @@ fun Navigation(
|
|||||||
val enterTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
|
val enterTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
|
||||||
val exitTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
|
val exitTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
|
||||||
val fadeTween = tween<Float>(durationMillis = DURATION_EXIT)
|
val fadeTween = tween<Float>(durationMillis = DURATION_EXIT)
|
||||||
val fadeSpec = fadeTween
|
|
||||||
|
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = BottomNavDestination.Shelf.route,
|
startDestination = BottomNavDestination.Shelf.route,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
enterTransition = { materialSharedAxisXIn(initialOffsetX = { (it * initialOffset).toInt() }) },
|
enterTransition = {
|
||||||
exitTransition = { materialSharedAxisXOut(targetOffsetX = { -(it * initialOffset).toInt() }) },
|
slideInHorizontally(
|
||||||
popEnterTransition = { materialSharedAxisXIn(initialOffsetX = { -(it * initialOffset).toInt() }) },
|
enterTween,
|
||||||
popExitTransition = { materialSharedAxisXOut(targetOffsetX = { (it * initialOffset).toInt() }) }
|
initialOffsetX = { (it * initialOffset).toInt() }) + fadeIn(fadeTween)
|
||||||
|
},
|
||||||
|
exitTransition = {
|
||||||
|
slideOutHorizontally(
|
||||||
|
exitTween,
|
||||||
|
targetOffsetX = { -(it * initialOffset).toInt() }) + fadeOut(fadeTween)
|
||||||
|
},
|
||||||
|
popEnterTransition = {
|
||||||
|
slideInHorizontally(
|
||||||
|
enterTween,
|
||||||
|
initialOffsetX = { -(it * initialOffset).toInt() }) + fadeIn(fadeTween)
|
||||||
|
},
|
||||||
|
popExitTransition = {
|
||||||
|
slideOutHorizontally(
|
||||||
|
exitTween,
|
||||||
|
targetOffsetX = { (it * initialOffset).toInt() }) + fadeOut(fadeTween)
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
|
|
||||||
composable(BottomNavDestination.Shelf.route) {
|
composable(BottomNavDestination.Shelf.route) {
|
||||||
@@ -157,8 +174,11 @@ fun Navigation(
|
|||||||
|
|
||||||
composable(BottomNavDestination.History.route) {
|
composable(BottomNavDestination.History.route) {
|
||||||
HistoryView(
|
HistoryView(
|
||||||
|
coil = coil,
|
||||||
padding = padding,
|
padding = padding,
|
||||||
topBarHeightPx = topBarHeightPx,
|
topBarHeightPx = topBarHeightPx,
|
||||||
|
navigateToDetails = navigateToDetails,
|
||||||
|
navigateToReader = { navController.navigate(READER_DESTINATION) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,6 +269,7 @@ fun Navigation(
|
|||||||
|
|
||||||
composable(CATALOG_DESTINATION) {
|
composable(CATALOG_DESTINATION) {
|
||||||
SourcesCatalogView(
|
SourcesCatalogView(
|
||||||
|
coil = coil,
|
||||||
navigateBack = navigateBack,
|
navigateBack = navigateBack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -400,7 +421,14 @@ fun Navigation(
|
|||||||
navController.navigate(
|
navController.navigate(
|
||||||
LIST_DESTINATION.replace(PROVIDER_ARGUMENT, it.name)
|
LIST_DESTINATION.replace(PROVIDER_ARGUMENT, it.name)
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
navigateToReader = { navController.navigate(READER_DESTINATION) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(READER_DESTINATION) {
|
||||||
|
ReaderView(
|
||||||
|
navigateBack = navigateBack
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
95
app/src/main/java/org/xtimms/tokusho/core/cache/PagesCache.kt
vendored
Normal file
95
app/src/main/java/org/xtimms/tokusho/core/cache/PagesCache.kt
vendored
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ fun BackgroundProgress(
|
|||||||
color: Color,
|
color: Color,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val percentWithNewSpent = 0.3f
|
val percentWithNewSpent = 0.5f
|
||||||
|
|
||||||
val percentWithNewSpentAnimated = animateFloatAsState(
|
val percentWithNewSpentAnimated = animateFloatAsState(
|
||||||
label = "percentWithNewSpentAnimated",
|
label = "percentWithNewSpentAnimated",
|
||||||
|
|||||||
@@ -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)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,8 +22,9 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|||||||
fun ScaffoldWithTopAppBar(
|
fun ScaffoldWithTopAppBar(
|
||||||
title: String,
|
title: String,
|
||||||
navigateBack: () -> Unit,
|
navigateBack: () -> Unit,
|
||||||
snackbarHost: @Composable (() -> Unit) = {},
|
snackbarHost: @Composable () -> Unit = {},
|
||||||
floatingActionButton: @Composable (() -> Unit) = {},
|
floatingActionButton: @Composable () -> Unit = {},
|
||||||
|
actions: @Composable (RowScope.() -> Unit) = {},
|
||||||
content: @Composable (PaddingValues) -> Unit
|
content: @Composable (PaddingValues) -> Unit
|
||||||
) {
|
) {
|
||||||
val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
|
val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
|
||||||
@@ -39,6 +40,7 @@ fun ScaffoldWithTopAppBar(
|
|||||||
DefaultTopAppBar(
|
DefaultTopAppBar(
|
||||||
title = title,
|
title = title,
|
||||||
scrollBehavior = topAppBarScrollBehavior,
|
scrollBehavior = topAppBarScrollBehavior,
|
||||||
|
actions = actions,
|
||||||
navigateBack = navigateBack
|
navigateBack = navigateBack
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
package org.xtimms.tokusho.core.components
|
package org.xtimms.tokusho.core.components
|
||||||
|
|
||||||
|
import android.graphics.Path
|
||||||
|
import android.view.animation.PathInterpolator
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.basicMarquee
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
@@ -42,15 +50,20 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import org.xtimms.tokusho.R
|
import org.xtimms.tokusho.R
|
||||||
|
import org.xtimms.tokusho.core.DURATION_ENTER
|
||||||
|
import org.xtimms.tokusho.core.DURATION_EXIT
|
||||||
import org.xtimms.tokusho.core.initialOffset
|
import org.xtimms.tokusho.core.initialOffset
|
||||||
import org.xtimms.tokusho.core.motion.materialSharedAxisXIn
|
import org.xtimms.tokusho.core.motion.materialSharedAxisXIn
|
||||||
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
|
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
|
||||||
|
import org.xtimms.tokusho.core.toEasing
|
||||||
import org.xtimms.tokusho.sections.explore.EXPLORE_DESTINATION
|
import org.xtimms.tokusho.sections.explore.EXPLORE_DESTINATION
|
||||||
import org.xtimms.tokusho.sections.feed.FEED_DESTINATION
|
import org.xtimms.tokusho.sections.feed.FEED_DESTINATION
|
||||||
import org.xtimms.tokusho.sections.history.HISTORY_DESTINATION
|
import org.xtimms.tokusho.sections.history.HISTORY_DESTINATION
|
||||||
@@ -78,10 +91,27 @@ fun TopAppBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val path = Path().apply {
|
||||||
|
moveTo(0f, 0f)
|
||||||
|
cubicTo(0.05F, 0F, 0.133333F, 0.06F, 0.166666F, 0.4F)
|
||||||
|
cubicTo(0.208333F, 0.82F, 0.25F, 1F, 1F, 1F)
|
||||||
|
}
|
||||||
|
|
||||||
|
val emphasizePathInterpolator = PathInterpolator(path)
|
||||||
|
val emphasizeEasing = emphasizePathInterpolator.toEasing()
|
||||||
|
|
||||||
|
val enterTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
|
||||||
|
val exitTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
|
||||||
|
val fadeTween = tween<Float>(durationMillis = DURATION_EXIT)
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = isVisible,
|
visible = isVisible,
|
||||||
enter = materialSharedAxisXIn(initialOffsetX = { -(it * initialOffset).toInt() }),
|
enter = slideInHorizontally(
|
||||||
exit = materialSharedAxisXOut(targetOffsetX = { -(it * initialOffset).toInt() })
|
enterTween,
|
||||||
|
initialOffsetX = { -(it * initialOffset).toInt() }) + fadeIn(fadeTween),
|
||||||
|
exit = slideOutHorizontally(
|
||||||
|
exitTween,
|
||||||
|
targetOffsetX = { -(it * initialOffset).toInt() }) + fadeOut(fadeTween)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -158,6 +188,7 @@ fun TopAppBar(
|
|||||||
@Composable
|
@Composable
|
||||||
fun DefaultTopAppBar(
|
fun DefaultTopAppBar(
|
||||||
title: String,
|
title: String,
|
||||||
|
actions: @Composable (RowScope.() -> Unit),
|
||||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||||
navigateBack: () -> Unit,
|
navigateBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -166,6 +197,7 @@ fun DefaultTopAppBar(
|
|||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
BackIconButton(onClick = navigateBack)
|
BackIconButton(onClick = navigateBack)
|
||||||
},
|
},
|
||||||
|
actions = actions,
|
||||||
scrollBehavior = scrollBehavior
|
scrollBehavior = scrollBehavior
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -237,6 +269,34 @@ fun ClassicTopAppBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppBarTitle(
|
||||||
|
title: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
subtitle: String? = null,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
title?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
subtitle?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.basicMarquee(
|
||||||
|
delayMillis = 2_000,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
@@ -244,6 +304,14 @@ fun DefaultTopAppBarPreview() {
|
|||||||
TokushoTheme {
|
TokushoTheme {
|
||||||
DefaultTopAppBar(
|
DefaultTopAppBar(
|
||||||
title = "Tokusho",
|
title = "Tokusho",
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { /*TODO*/ }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Menu,
|
||||||
|
contentDescription = "Localized description"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
navigateBack = {}
|
navigateBack = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.xtimms.tokusho.core.prefs
|
package org.xtimms.tokusho.core.prefs
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import androidx.annotation.DeprecatedSinceApi
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -16,7 +17,8 @@ import org.xtimms.tokusho.ui.theme.SEED
|
|||||||
import org.xtimms.tokusho.R
|
import org.xtimms.tokusho.R
|
||||||
import org.xtimms.tokusho.ui.monet.PaletteStyle
|
import org.xtimms.tokusho.ui.monet.PaletteStyle
|
||||||
import org.xtimms.tokusho.utils.lang.processLifecycleScope
|
import org.xtimms.tokusho.utils.lang.processLifecycleScope
|
||||||
import org.xtimms.tokusho.utils.system.languageMap
|
import org.xtimms.tokusho.utils.system.LocaleLanguageCodeMap
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
private const val DYNAMIC_COLOR = "dynamic_color"
|
private const val DYNAMIC_COLOR = "dynamic_color"
|
||||||
const val DARK_THEME_VALUE = "dark_theme_value"
|
const val DARK_THEME_VALUE = "dark_theme_value"
|
||||||
@@ -27,6 +29,7 @@ private const val THEME_COLOR = "theme_color"
|
|||||||
const val PALETTE_STYLE = "palette_style"
|
const val PALETTE_STYLE = "palette_style"
|
||||||
const val LANGUAGE = "language"
|
const val LANGUAGE = "language"
|
||||||
const val READING_TIME = "reading_time"
|
const val READING_TIME = "reading_time"
|
||||||
|
const val GRID_COLUMNS = "grid_columns"
|
||||||
|
|
||||||
const val SYSTEM_DEFAULT = 0
|
const val SYSTEM_DEFAULT = 0
|
||||||
|
|
||||||
@@ -36,6 +39,8 @@ const val PRE_RELEASE = 1
|
|||||||
const val ACRA = "acra"
|
const val ACRA = "acra"
|
||||||
const val LOGGING = "logging"
|
const val LOGGING = "logging"
|
||||||
|
|
||||||
|
const val SWIPE_TUTORIAL = "swipe_tutorial"
|
||||||
|
const val WSRV = "image_optimization"
|
||||||
const val SSL_BYPASS = "ssl_bypass"
|
const val SSL_BYPASS = "ssl_bypass"
|
||||||
const val NSFW = "nsfw"
|
const val NSFW = "nsfw"
|
||||||
const val TABS_MANGA_COUNT = "tabs_manga_count"
|
const val TABS_MANGA_COUNT = "tabs_manga_count"
|
||||||
@@ -66,6 +71,7 @@ private val BooleanPreferenceDefaults = mapOf(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private val IntPreferenceDefaults = mapOf(
|
private val IntPreferenceDefaults = mapOf(
|
||||||
|
GRID_COLUMNS to 3,
|
||||||
LANGUAGE to SYSTEM_DEFAULT,
|
LANGUAGE to SYSTEM_DEFAULT,
|
||||||
PALETTE_STYLE to 0,
|
PALETTE_STYLE to 0,
|
||||||
DARK_THEME_VALUE to DarkThemePreference.FOLLOW_SYSTEM,
|
DARK_THEME_VALUE to DarkThemePreference.FOLLOW_SYSTEM,
|
||||||
@@ -110,16 +116,33 @@ object AppSettings {
|
|||||||
|
|
||||||
fun isSuggestionsEnabled() = SUGGESTIONS.getBoolean(true)
|
fun isSuggestionsEnabled() = SUGGESTIONS.getBoolean(true)
|
||||||
|
|
||||||
|
fun isSwipeTutorialEnabled() = SWIPE_TUTORIAL.getBoolean(true)
|
||||||
|
|
||||||
private fun getLanguageNumberByCode(languageCode: String): Int =
|
fun isImagesProxyEnabled() = WSRV.getBoolean(false)
|
||||||
languageMap.entries.find { it.value == languageCode }?.key ?: SYSTEM_DEFAULT
|
|
||||||
|
|
||||||
|
fun getGridColumnsCount(columns: Int = GRID_COLUMNS.getInt()): Float {
|
||||||
|
return when (columns) {
|
||||||
|
1 -> 1f
|
||||||
|
2 -> 2f
|
||||||
|
3 -> 3f
|
||||||
|
4 -> 4f
|
||||||
|
5 -> 5f
|
||||||
|
else -> 3f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getLanguageNumber(): Int {
|
@DeprecatedSinceApi(api = 33)
|
||||||
return if (Build.VERSION.SDK_INT >= 33) getLanguageNumberByCode(
|
fun getLocaleFromPreference(): Locale? {
|
||||||
LocaleListCompat.getAdjustedDefault()[0]?.toLanguageTag().toString()
|
val languageCode = LANGUAGE.getInt()
|
||||||
)
|
return LocaleLanguageCodeMap.entries.find { it.value == languageCode }?.key
|
||||||
else LANGUAGE.getInt()
|
}
|
||||||
|
|
||||||
|
fun saveLocalePreference(locale: Locale?) {
|
||||||
|
if (Build.VERSION.SDK_INT >= 33) {
|
||||||
|
// No op
|
||||||
|
} else {
|
||||||
|
LANGUAGE.updateInt(LocaleLanguageCodeMap[locale] ?: SYSTEM_DEFAULT)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Settings(
|
data class Settings(
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class ExploreRepository @Inject constructor(
|
|||||||
list.shuffle()
|
list.shuffle()
|
||||||
list
|
list
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
// TODO
|
it.printStackTrace()
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ import org.xtimms.tokusho.core.database.entity.toManga
|
|||||||
import org.xtimms.tokusho.core.database.entity.toMangaHistory
|
import org.xtimms.tokusho.core.database.entity.toMangaHistory
|
||||||
import org.xtimms.tokusho.core.database.entity.toMangaTags
|
import org.xtimms.tokusho.core.database.entity.toMangaTags
|
||||||
import org.xtimms.tokusho.core.model.MangaHistory
|
import org.xtimms.tokusho.core.model.MangaHistory
|
||||||
|
import org.xtimms.tokusho.core.model.MangaWithHistory
|
||||||
import org.xtimms.tokusho.core.model.findById
|
import org.xtimms.tokusho.core.model.findById
|
||||||
import org.xtimms.tokusho.core.model.isNsfw
|
import org.xtimms.tokusho.core.model.isNsfw
|
||||||
import org.xtimms.tokusho.core.parser.MangaDataRepository
|
import org.xtimms.tokusho.core.parser.MangaDataRepository
|
||||||
@@ -25,6 +26,11 @@ class HistoryRepository @Inject constructor(
|
|||||||
private val mangaRepository: MangaDataRepository,
|
private val mangaRepository: MangaDataRepository,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
suspend fun getList(offset: Int, limit: Int): List<Manga> {
|
||||||
|
val entities = db.getHistoryDao().findAll(offset, limit)
|
||||||
|
return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getLastOrNull(): Manga? {
|
suspend fun getLastOrNull(): Manga? {
|
||||||
val entity = db.getHistoryDao().findAll(0, 1).firstOrNull() ?: return null
|
val entity = db.getHistoryDao().findAll(0, 1).firstOrNull() ?: return null
|
||||||
return entity.manga.toManga(entity.tags.toMangaTags())
|
return entity.manga.toManga(entity.tags.toMangaTags())
|
||||||
@@ -42,10 +48,23 @@ class HistoryRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun observeAllWithHistory(): Flow<List<MangaWithHistory>> {
|
||||||
|
return db.getHistoryDao().observeAll().mapItems {
|
||||||
|
MangaWithHistory(
|
||||||
|
it.manga.toManga(it.tags.toMangaTags()),
|
||||||
|
it.history.toMangaHistory(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getOne(manga: Manga): MangaHistory? {
|
suspend fun getOne(manga: Manga): MangaHistory? {
|
||||||
return db.getHistoryDao().find(manga.id)?.recoverIfNeeded(manga)?.toMangaHistory()
|
return db.getHistoryDao().find(manga.id)?.recoverIfNeeded(manga)?.toMangaHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun delete(manga: Manga) {
|
||||||
|
db.getHistoryDao().delete(manga.id)
|
||||||
|
}
|
||||||
|
|
||||||
fun observeOne(id: Long): Flow<MangaHistory?> {
|
fun observeOne(id: Long): Flow<MangaHistory?> {
|
||||||
return db.getHistoryDao().observe(id).map {
|
return db.getHistoryDao().observe(id).map {
|
||||||
it?.toMangaHistory()
|
it?.toMangaHistory()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.xtimms.tokusho.sections.details
|
package org.xtimms.tokusho.sections.details
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.animateContentSize
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
@@ -15,14 +16,11 @@ import androidx.compose.animation.graphics.vector.AnimatedImageVector
|
|||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -31,40 +29,32 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.sizeIn
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.wrapContentSize
|
import androidx.compose.foundation.layout.wrapContentSize
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.ChipDefaults
|
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.outlined.MenuBook
|
import androidx.compose.material.icons.automirrored.outlined.MenuBook
|
||||||
import androidx.compose.material.icons.outlined.Block
|
import androidx.compose.material.icons.outlined.Block
|
||||||
import androidx.compose.material.icons.outlined.Close
|
import androidx.compose.material.icons.outlined.Close
|
||||||
import androidx.compose.material.icons.outlined.DoneAll
|
import androidx.compose.material.icons.outlined.DoneAll
|
||||||
import androidx.compose.material.icons.outlined.Download
|
|
||||||
import androidx.compose.material.icons.outlined.FileDownload
|
import androidx.compose.material.icons.outlined.FileDownload
|
||||||
import androidx.compose.material.icons.outlined.KeyboardArrowDown
|
import androidx.compose.material.icons.outlined.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.outlined.Language
|
import androidx.compose.material.icons.outlined.Language
|
||||||
import androidx.compose.material.icons.outlined.LocalLibrary
|
import androidx.compose.material.icons.outlined.LocalLibrary
|
||||||
import androidx.compose.material.icons.outlined.Pause
|
import androidx.compose.material.icons.outlined.Pause
|
||||||
import androidx.compose.material.icons.outlined.Person
|
import androidx.compose.material.icons.outlined.Person
|
||||||
import androidx.compose.material.icons.outlined.PlayArrow
|
|
||||||
import androidx.compose.material.icons.outlined.Schedule
|
import androidx.compose.material.icons.outlined.Schedule
|
||||||
import androidx.compose.material.icons.outlined.Sync
|
import androidx.compose.material.icons.outlined.Sync
|
||||||
import androidx.compose.material.icons.outlined.Upcoming
|
import androidx.compose.material.icons.outlined.Upcoming
|
||||||
import androidx.compose.material.icons.outlined.WarningAmber
|
|
||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.material3.AssistChip
|
||||||
import androidx.compose.material3.ChipColors
|
import androidx.compose.material3.AssistChipDefaults
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ElevatedAssistChip
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.InputChip
|
import androidx.compose.material3.InputChip
|
||||||
@@ -103,7 +93,6 @@ import androidx.compose.ui.unit.Dp
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
@@ -112,12 +101,9 @@ import org.xtimms.tokusho.core.AsyncImageImpl
|
|||||||
import org.xtimms.tokusho.core.components.AnimatedButton
|
import org.xtimms.tokusho.core.components.AnimatedButton
|
||||||
import org.xtimms.tokusho.core.components.ButtonType
|
import org.xtimms.tokusho.core.components.ButtonType
|
||||||
import org.xtimms.tokusho.core.components.MangaCover
|
import org.xtimms.tokusho.core.components.MangaCover
|
||||||
import org.xtimms.tokusho.core.components.MangaHorizontalItem
|
|
||||||
import org.xtimms.tokusho.core.components.ReadButton
|
import org.xtimms.tokusho.core.components.ReadButton
|
||||||
import org.xtimms.tokusho.core.parser.favicon.faviconUri
|
import org.xtimms.tokusho.core.parser.favicon.faviconUri
|
||||||
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
||||||
import org.xtimms.tokusho.ui.theme.applyOpacity
|
|
||||||
import org.xtimms.tokusho.ui.theme.disabledIconOpacity
|
|
||||||
import org.xtimms.tokusho.utils.composable.clickableNoIndication
|
import org.xtimms.tokusho.utils.composable.clickableNoIndication
|
||||||
import org.xtimms.tokusho.utils.composable.secondaryItemAlpha
|
import org.xtimms.tokusho.utils.composable.secondaryItemAlpha
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@@ -147,24 +133,41 @@ fun DetailsInfoBox(
|
|||||||
onSourceClicked: () -> Unit,
|
onSourceClicked: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
val backdropGradientColors = listOf(
|
Box(
|
||||||
Color.Transparent,
|
|
||||||
MaterialTheme.colorScheme.background,
|
|
||||||
)
|
|
||||||
AsyncImageImpl(
|
|
||||||
coil = coil,
|
|
||||||
model = imageUrl,
|
|
||||||
contentDescription = null,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(start = 16.dp, end = 16.dp)
|
.fillMaxWidth(),
|
||||||
.aspectRatio(1f)
|
contentAlignment = Alignment.BottomEnd,
|
||||||
.clickable(
|
) {
|
||||||
role = Role.Button,
|
AsyncImageImpl(
|
||||||
onClick = onCoverClick
|
coil = coil,
|
||||||
|
model = imageUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 16.dp, end = 16.dp)
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.clickable(
|
||||||
|
role = Role.Button,
|
||||||
|
onClick = onCoverClick
|
||||||
|
)
|
||||||
|
.clip(MaterialTheme.shapes.large)
|
||||||
|
)
|
||||||
|
if (isNsfw) {
|
||||||
|
ElevatedAssistChip(
|
||||||
|
modifier = Modifier.padding(end = 32.dp, bottom = 8.dp),
|
||||||
|
onClick = { /*TODO*/ },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = "18+",
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
},
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.errorContainer),
|
||||||
|
colors = AssistChipDefaults.elevatedAssistChipColors()
|
||||||
|
.copy(containerColor = MaterialTheme.colorScheme.errorContainer)
|
||||||
)
|
)
|
||||||
.clip(MaterialTheme.shapes.large)
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
||||||
if (!isTabletUi) {
|
if (!isTabletUi) {
|
||||||
@@ -312,7 +315,8 @@ private fun MangaAndSourceTitlesSmall(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class,
|
@OptIn(
|
||||||
|
ExperimentalLayoutApi::class,
|
||||||
ExperimentalMaterial3Api::class
|
ExperimentalMaterial3Api::class
|
||||||
)
|
)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -355,10 +359,9 @@ private fun DetailsContentInfo(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
maxLines = 2
|
maxLines = 2
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
if (author.isNotEmpty()) {
|
if (author.isNotEmpty()) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -380,6 +383,39 @@ private fun DetailsContentInfo(
|
|||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = when (state) {
|
||||||
|
MangaState.ONGOING -> Icons.Outlined.Schedule
|
||||||
|
MangaState.FINISHED -> Icons.Outlined.DoneAll
|
||||||
|
MangaState.ABANDONED -> Icons.Outlined.Close
|
||||||
|
MangaState.PAUSED -> Icons.Outlined.Pause
|
||||||
|
MangaState.UPCOMING -> Icons.Outlined.Upcoming
|
||||||
|
else -> Icons.Outlined.Block
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(MaterialTheme.typography.bodyLarge.fontSize.value.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = when (state) {
|
||||||
|
MangaState.ONGOING -> stringResource(id = R.string.ongoing)
|
||||||
|
MangaState.FINISHED -> stringResource(id = R.string.finished)
|
||||||
|
MangaState.ABANDONED -> stringResource(id = R.string.abandoned)
|
||||||
|
MangaState.PAUSED -> stringResource(id = R.string.paused)
|
||||||
|
MangaState.UPCOMING -> stringResource(id = R.string.upcoming)
|
||||||
|
else -> stringResource(id = R.string.unknown)
|
||||||
|
},
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
FlowRow(
|
FlowRow(
|
||||||
modifier = Modifier.padding(vertical = 8.dp),
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
@@ -442,54 +478,6 @@ private fun DetailsContentInfo(
|
|||||||
},
|
},
|
||||||
label = { Text(text = sourceTitle) },
|
label = { Text(text = sourceTitle) },
|
||||||
)
|
)
|
||||||
AssistChip(
|
|
||||||
onClick = { /*TODO*/ },
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = when (state) {
|
|
||||||
MangaState.ONGOING -> Icons.Outlined.Schedule
|
|
||||||
MangaState.FINISHED -> Icons.Outlined.DoneAll
|
|
||||||
MangaState.ABANDONED -> Icons.Outlined.Close
|
|
||||||
MangaState.PAUSED -> Icons.Outlined.Pause
|
|
||||||
MangaState.UPCOMING -> Icons.Outlined.Upcoming
|
|
||||||
else -> Icons.Outlined.Block
|
|
||||||
},
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(MaterialTheme.typography.bodyLarge.fontSize.value.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
},
|
|
||||||
label = {
|
|
||||||
Text(
|
|
||||||
text = when (state) {
|
|
||||||
MangaState.ONGOING -> stringResource(id = R.string.ongoing)
|
|
||||||
MangaState.FINISHED -> stringResource(id = R.string.finished)
|
|
||||||
MangaState.ABANDONED -> stringResource(id = R.string.abandoned)
|
|
||||||
MangaState.PAUSED -> stringResource(id = R.string.paused)
|
|
||||||
MangaState.UPCOMING -> stringResource(id = R.string.upcoming)
|
|
||||||
else -> stringResource(id = R.string.unknown)
|
|
||||||
},
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
maxLines = 1,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (isNsfw) {
|
|
||||||
AssistChip(
|
|
||||||
onClick = { /*TODO*/ },
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
imageVector = Icons.Outlined.WarningAmber,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
},
|
|
||||||
label = { Text(text = "18+", color = MaterialTheme.colorScheme.error) },
|
|
||||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
OutlinedIconButton(
|
OutlinedIconButton(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.height(32.dp)
|
.height(32.dp)
|
||||||
@@ -677,14 +665,19 @@ fun ExpandableMangaDescription(
|
|||||||
text = stringResource(id = R.string.description),
|
text = stringResource(id = R.string.description),
|
||||||
style = MaterialTheme.typography.titleLarge
|
style = MaterialTheme.typography.titleLarge
|
||||||
)
|
)
|
||||||
MangaSummary(
|
AnimatedContent(
|
||||||
expandedDescription = desc,
|
targetState = desc,
|
||||||
shrunkDescription = trimmedDescription,
|
label = "description"
|
||||||
expanded = expanded,
|
) {
|
||||||
modifier = Modifier
|
MangaSummary(
|
||||||
.padding(top = 8.dp)
|
expandedDescription = it,
|
||||||
.clickableNoIndication { onExpanded(!expanded) },
|
shrunkDescription = trimmedDescription,
|
||||||
)
|
expanded = expanded,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 8.dp)
|
||||||
|
.clickableNoIndication { onExpanded(!expanded) },
|
||||||
|
)
|
||||||
|
}
|
||||||
if (!tags.isNullOrEmpty()) {
|
if (!tags.isNullOrEmpty()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -849,7 +842,7 @@ fun DetailsInfoBoxPreview() {
|
|||||||
author = "Kotoyama",
|
author = "Kotoyama",
|
||||||
artist = null,
|
artist = null,
|
||||||
isNsfw = true,
|
isNsfw = true,
|
||||||
state = null,
|
state = MangaState.UPCOMING,
|
||||||
source = MangaSource.MANGADEX,
|
source = MangaSource.MANGADEX,
|
||||||
chapters = "22",
|
chapters = "22",
|
||||||
isTabletUi = false,
|
isTabletUi = false,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.calculateStartPadding
|
|||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.sizeIn
|
import androidx.compose.foundation.layout.sizeIn
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
@@ -65,7 +64,8 @@ fun DetailsView(
|
|||||||
navigateBack: () -> Unit,
|
navigateBack: () -> Unit,
|
||||||
navigateToFullImage: (String) -> Unit,
|
navigateToFullImage: (String) -> Unit,
|
||||||
navigateToDetails: (Long) -> Unit,
|
navigateToDetails: (Long) -> Unit,
|
||||||
navigateToSource: (MangaSource) -> Unit
|
navigateToSource: (MangaSource) -> Unit,
|
||||||
|
navigateToReader: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -255,7 +255,7 @@ fun DetailsView(
|
|||||||
bookmark = false,
|
bookmark = false,
|
||||||
selected = false,
|
selected = false,
|
||||||
onLongClick = { /*TODO*/ },
|
onLongClick = { /*TODO*/ },
|
||||||
onClick = { /*TODO*/ }
|
onClick = { navigateToReader() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.xtimms.tokusho.sections.details
|
package org.xtimms.tokusho.sections.details
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -30,7 +29,6 @@ import androidx.compose.ui.platform.LocalUriHandler
|
|||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.xtimms.tokusho.core.AsyncImageImpl
|
import org.xtimms.tokusho.core.AsyncImageImpl
|
||||||
import org.xtimms.tokusho.core.components.BackIconButton
|
import org.xtimms.tokusho.core.components.BackIconButton
|
||||||
@@ -40,7 +38,7 @@ import org.xtimms.tokusho.ui.theme.TokushoTheme
|
|||||||
const val PICTURES_ARGUMENT = "{pictures}"
|
const val PICTURES_ARGUMENT = "{pictures}"
|
||||||
const val FULL_POSTER_DESTINATION = "full_poster/$PICTURES_ARGUMENT"
|
const val FULL_POSTER_DESTINATION = "full_poster/$PICTURES_ARGUMENT"
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun FullImageView(
|
fun FullImageView(
|
||||||
coil: ImageLoader,
|
coil: ImageLoader,
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|
||||||
Column(
|
val history by viewModel.content.collectAsStateWithLifecycle(emptyList())
|
||||||
modifier = Modifier
|
|
||||||
.collapsable(
|
DisposableEffect(Unit) {
|
||||||
state = scrollState,
|
onDispose {
|
||||||
topBarHeightPx = topBarHeightPx,
|
if (history.isNotEmpty() && isUserTrySwipe) {
|
||||||
topBarOffsetY = topBarOffsetY
|
AppSettings.updateValue(SWIPE_TUTORIAL, isUserTrySwipe)
|
||||||
)
|
}
|
||||||
.padding(padding)
|
}
|
||||||
) {
|
|
||||||
EmptyScreen(
|
|
||||||
icon = Icons.Outlined.History,
|
|
||||||
title = R.string.empty_history_title,
|
|
||||||
description = R.string.empty_history_description
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
val animatedList = run {
|
||||||
@Composable
|
val list = emptyList<RowEntity>().toMutableList()
|
||||||
fun HistoryPreview() {
|
var readDate: Instant? = null
|
||||||
TokushoTheme {
|
history.forEach { item ->
|
||||||
Surface {
|
|
||||||
HistoryViewContent(
|
if (readDate === null || !isSameDay(
|
||||||
padding = PaddingValues(),
|
item.history.updatedAt.toEpochMilli(),
|
||||||
topBarHeightPx = 0f,
|
readDate!!.toEpochMilli()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
updateAnimatedItemsState(newList = list.toList().map { it })
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.collapsable(
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
package org.xtimms.tokusho.sections.settings.appearance
|
package org.xtimms.tokusho.sections.settings.appearance
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.absoluteOffset
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.sizeIn
|
import androidx.compose.foundation.layout.sizeIn
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
@@ -26,15 +32,14 @@ import androidx.compose.material.icons.outlined.ColorLens
|
|||||||
import androidx.compose.material.icons.outlined.DarkMode
|
import androidx.compose.material.icons.outlined.DarkMode
|
||||||
import androidx.compose.material.icons.outlined.Language
|
import androidx.compose.material.icons.outlined.Language
|
||||||
import androidx.compose.material.icons.outlined.LightMode
|
import androidx.compose.material.icons.outlined.LightMode
|
||||||
import androidx.compose.material.icons.outlined.Numbers
|
|
||||||
import androidx.compose.material.icons.outlined.Timelapse
|
import androidx.compose.material.icons.outlined.Timelapse
|
||||||
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -42,12 +47,16 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil.ImageLoader
|
import androidx.compose.ui.zIndex
|
||||||
import com.google.accompanist.pager.HorizontalPagerIndicator
|
import com.google.accompanist.pager.HorizontalPagerIndicator
|
||||||
import com.google.android.material.color.DynamicColors
|
import com.google.android.material.color.DynamicColors
|
||||||
import org.xtimms.tokusho.LocalDarkTheme
|
import org.xtimms.tokusho.LocalDarkTheme
|
||||||
@@ -66,8 +75,8 @@ import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.ON
|
|||||||
import org.xtimms.tokusho.core.prefs.READING_TIME
|
import org.xtimms.tokusho.core.prefs.READING_TIME
|
||||||
import org.xtimms.tokusho.core.prefs.STYLE_MONOCHROME
|
import org.xtimms.tokusho.core.prefs.STYLE_MONOCHROME
|
||||||
import org.xtimms.tokusho.core.prefs.STYLE_TONAL_SPOT
|
import org.xtimms.tokusho.core.prefs.STYLE_TONAL_SPOT
|
||||||
import org.xtimms.tokusho.core.prefs.TABS_MANGA_COUNT
|
|
||||||
import org.xtimms.tokusho.core.prefs.paletteStyles
|
import org.xtimms.tokusho.core.prefs.paletteStyles
|
||||||
|
import org.xtimms.tokusho.sections.stats.Size
|
||||||
import org.xtimms.tokusho.ui.harmonize.hct.Hct
|
import org.xtimms.tokusho.ui.harmonize.hct.Hct
|
||||||
import org.xtimms.tokusho.ui.monet.LocalTonalPalettes
|
import org.xtimms.tokusho.ui.monet.LocalTonalPalettes
|
||||||
import org.xtimms.tokusho.ui.monet.PaletteStyle
|
import org.xtimms.tokusho.ui.monet.PaletteStyle
|
||||||
@@ -76,26 +85,21 @@ import org.xtimms.tokusho.ui.monet.TonalPalettes.Companion.toTonalPalettes
|
|||||||
import org.xtimms.tokusho.ui.monet.a1
|
import org.xtimms.tokusho.ui.monet.a1
|
||||||
import org.xtimms.tokusho.ui.monet.a2
|
import org.xtimms.tokusho.ui.monet.a2
|
||||||
import org.xtimms.tokusho.ui.monet.a3
|
import org.xtimms.tokusho.ui.monet.a3
|
||||||
import org.xtimms.tokusho.utils.system.getLanguageDesc
|
import org.xtimms.tokusho.utils.material.combineColors
|
||||||
|
import org.xtimms.tokusho.utils.system.toDisplayName
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
const val APPEARANCE_DESTINATION = "appearance"
|
const val APPEARANCE_DESTINATION = "appearance"
|
||||||
|
|
||||||
val colorList = ((4..10) + (1..3)).map { it * 35.0 }.map { Color(Hct.from(it, 40.0, 40.0).toInt()) }
|
val colorList = ((4..10) + (1..3)).map { it * 35.0 }.map { Color(Hct.from(it, 40.0, 40.0).toInt()) }
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppearanceView(
|
fun AppearanceView(
|
||||||
navigateBack: () -> Unit,
|
navigateBack: () -> Unit,
|
||||||
navigateToDarkTheme: () -> Unit,
|
navigateToDarkTheme: () -> Unit,
|
||||||
navigateToLanguages: () -> Unit
|
navigateToLanguages: () -> Unit
|
||||||
) {
|
) {
|
||||||
val image by remember {
|
val localDensity = LocalDensity.current
|
||||||
mutableIntStateOf(
|
|
||||||
listOf(
|
|
||||||
R.drawable.ookami, R.drawable.sample1
|
|
||||||
).random()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var isReadingTimeEstimationEnabled by remember {
|
var isReadingTimeEstimationEnabled by remember {
|
||||||
mutableStateOf(AppSettings.isReadingTimeEstimationEnabled())
|
mutableStateOf(AppSettings.isReadingTimeEstimationEnabled())
|
||||||
@@ -106,21 +110,78 @@ fun AppearanceView(
|
|||||||
navigateBack = navigateBack
|
navigateBack = navigateBack
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
modifier = Modifier
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
MangaCard(
|
Card(
|
||||||
modifier = Modifier.padding(18.dp),
|
modifier = Modifier.padding(18.dp)
|
||||||
thumbnailUrl = image
|
) {
|
||||||
)
|
var headerSize by remember { mutableStateOf(Size(0.dp, 0.dp)) }
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onGloballyPositioned {
|
||||||
|
headerSize = Size(
|
||||||
|
width = with(localDensity) { it.size.width.toDp() },
|
||||||
|
height = with(localDensity) { it.size.height.toDp() }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val halfWidth = headerSize.width / 2
|
||||||
|
val halfHeight = headerSize.height / 2
|
||||||
|
|
||||||
|
val angleStar1 by rememberInfiniteTransition("angleStar1").animateFloat(
|
||||||
|
label = "angleStar1",
|
||||||
|
initialValue = -20f,
|
||||||
|
targetValue = 20f,
|
||||||
|
animationSpec = infiniteRepeatable(tween(5000), RepeatMode.Reverse)
|
||||||
|
)
|
||||||
|
|
||||||
|
val angleStar2 by rememberInfiniteTransition("angleStar2").animateFloat(
|
||||||
|
label = "angleStar2",
|
||||||
|
initialValue = -50f,
|
||||||
|
targetValue = 50f,
|
||||||
|
animationSpec = infiniteRepeatable(tween(9000), RepeatMode.Reverse)
|
||||||
|
)
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier
|
||||||
|
.requiredSize(256.dp)
|
||||||
|
.absoluteOffset(
|
||||||
|
x = halfWidth * 0.7f,
|
||||||
|
y = -halfHeight * 0.6f
|
||||||
|
)
|
||||||
|
.rotate(angleStar1)
|
||||||
|
.zIndex(-1f),
|
||||||
|
painter = painterResource(R.drawable.shape_soft_star_1),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier
|
||||||
|
.requiredSize(256.dp)
|
||||||
|
.absoluteOffset(
|
||||||
|
x = -halfWidth * 0.7f,
|
||||||
|
y = halfHeight * 0.6f
|
||||||
|
)
|
||||||
|
.rotate(angleStar2)
|
||||||
|
.zIndex(-1f),
|
||||||
|
painter = painterResource(R.drawable.shape_soft_star_2),
|
||||||
|
tint = MaterialTheme.colorScheme.secondary,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val pageCount = colorList.size + 1
|
val pageCount = colorList.size + 1
|
||||||
val pagerState = rememberPagerState(initialPage = if (LocalPaletteStyleIndex.current == STYLE_MONOCHROME) pageCount else colorList.indexOf(
|
val pagerState =
|
||||||
Color(LocalSeedColor.current)
|
rememberPagerState(initialPage = if (LocalPaletteStyleIndex.current == STYLE_MONOCHROME) pageCount else colorList.indexOf(
|
||||||
).run { if (this == -1) 0 else this }) {
|
Color(LocalSeedColor.current)
|
||||||
pageCount
|
).run { if (this == -1) 0 else this }) {
|
||||||
}
|
pageCount
|
||||||
|
}
|
||||||
|
|
||||||
HorizontalPager(
|
HorizontalPager(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -187,7 +248,7 @@ fun AppearanceView(
|
|||||||
PreferenceItem(
|
PreferenceItem(
|
||||||
title = stringResource(id = R.string.language),
|
title = stringResource(id = R.string.language),
|
||||||
icon = Icons.Outlined.Language,
|
icon = Icons.Outlined.Language,
|
||||||
description = getLanguageDesc(),
|
description = Locale.getDefault().toDisplayName(),
|
||||||
onClick = { navigateToLanguages() })
|
onClick = { navigateToLanguages() })
|
||||||
PreferenceSubtitle(text = stringResource(id = R.string.details))
|
PreferenceSubtitle(text = stringResource(id = R.string.details))
|
||||||
PreferenceSwitch(
|
PreferenceSwitch(
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -38,19 +38,17 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import org.xtimms.tokusho.MainActivity
|
|
||||||
import org.xtimms.tokusho.R
|
import org.xtimms.tokusho.R
|
||||||
import org.xtimms.tokusho.core.components.PreferenceSingleChoiceItem
|
import org.xtimms.tokusho.core.components.PreferenceSingleChoiceItem
|
||||||
import org.xtimms.tokusho.core.components.PreferencesHintCard
|
import org.xtimms.tokusho.core.components.PreferencesHintCard
|
||||||
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
||||||
import org.xtimms.tokusho.core.prefs.AppSettings
|
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||||
import org.xtimms.tokusho.core.prefs.AppSettings.getLanguageConfiguration
|
|
||||||
import org.xtimms.tokusho.core.prefs.LANGUAGE
|
|
||||||
import org.xtimms.tokusho.core.prefs.SYSTEM_DEFAULT
|
|
||||||
import org.xtimms.tokusho.sections.settings.about.weblate
|
import org.xtimms.tokusho.sections.settings.about.weblate
|
||||||
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
||||||
import org.xtimms.tokusho.utils.system.getLanguageDesc
|
import org.xtimms.tokusho.utils.system.LocaleLanguageCodeMap
|
||||||
import org.xtimms.tokusho.utils.system.languageMap
|
import org.xtimms.tokusho.utils.system.setLanguage
|
||||||
|
import org.xtimms.tokusho.utils.system.toDisplayName
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
const val LANGUAGES_DESTINATION = "languages"
|
const val LANGUAGES_DESTINATION = "languages"
|
||||||
|
|
||||||
@@ -58,7 +56,8 @@ const val LANGUAGES_DESTINATION = "languages"
|
|||||||
fun LanguagesView(
|
fun LanguagesView(
|
||||||
navigateBack: () -> Unit
|
navigateBack: () -> Unit
|
||||||
) {
|
) {
|
||||||
var language by remember { mutableStateOf(AppSettings.getLanguageNumber()) }
|
val selectedLocale by remember { mutableStateOf(Locale.getDefault()) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
Intent(android.provider.Settings.ACTION_APP_LOCALE_SETTINGS).apply {
|
Intent(android.provider.Settings.ACTION_APP_LOCALE_SETTINGS).apply {
|
||||||
@@ -79,29 +78,28 @@ fun LanguagesView(
|
|||||||
}
|
}
|
||||||
LanguageViewImpl(
|
LanguageViewImpl(
|
||||||
navigateBack = navigateBack,
|
navigateBack = navigateBack,
|
||||||
languageMap = languageMap,
|
localeSet = LocaleLanguageCodeMap.keys,
|
||||||
isSystemLocaleSettingsAvailable = isSystemLocaleSettingsAvailable,
|
isSystemLocaleSettingsAvailable = isSystemLocaleSettingsAvailable,
|
||||||
onNavigateToSystemLocaleSettings = {
|
onNavigateToSystemLocaleSettings = {
|
||||||
if (isSystemLocaleSettingsAvailable) {
|
if (isSystemLocaleSettingsAvailable) {
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
selectedLanguage = language,
|
selectedLocale = selectedLocale,
|
||||||
) {
|
) {
|
||||||
language = it
|
AppSettings.saveLocalePreference(it)
|
||||||
AppSettings.encodeInt(LANGUAGE, language)
|
setLanguage(it)
|
||||||
MainActivity.setLanguage(getLanguageConfiguration())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LanguageViewImpl(
|
private fun LanguageViewImpl(
|
||||||
navigateBack: () -> Unit = {},
|
navigateBack: () -> Unit = {},
|
||||||
languageMap: Map<Int, String>,
|
localeSet: Set<Locale>,
|
||||||
isSystemLocaleSettingsAvailable: Boolean = false,
|
isSystemLocaleSettingsAvailable: Boolean = false,
|
||||||
onNavigateToSystemLocaleSettings: () -> Unit,
|
onNavigateToSystemLocaleSettings: () -> Unit,
|
||||||
selectedLanguage: Int,
|
selectedLocale: Locale,
|
||||||
onLanguageSelected: (Int) -> Unit = {}
|
onLanguageSelected: (Locale?) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
@@ -126,17 +124,17 @@ private fun LanguageViewImpl(
|
|||||||
item {
|
item {
|
||||||
PreferenceSingleChoiceItem(
|
PreferenceSingleChoiceItem(
|
||||||
text = stringResource(R.string.follow_system),
|
text = stringResource(R.string.follow_system),
|
||||||
selected = selectedLanguage == SYSTEM_DEFAULT,
|
selected = !localeSet.contains(selectedLocale),
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 18.dp)
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 18.dp)
|
||||||
) { onLanguageSelected(SYSTEM_DEFAULT) }
|
) { onLanguageSelected(null) }
|
||||||
}
|
}
|
||||||
for (languageData in languageMap) {
|
for (locale in localeSet) {
|
||||||
item {
|
item {
|
||||||
PreferenceSingleChoiceItem(
|
PreferenceSingleChoiceItem(
|
||||||
text = getLanguageDesc(languageData.key),
|
text = locale.toDisplayName(),
|
||||||
selected = selectedLanguage == languageData.key,
|
selected = selectedLocale == locale,
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 18.dp)
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 18.dp)
|
||||||
) { onLanguageSelected(languageData.key) }
|
) { onLanguageSelected(locale) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isSystemLocaleSettingsAvailable) {
|
if (isSystemLocaleSettingsAvailable) {
|
||||||
@@ -185,19 +183,15 @@ private fun LanguageViewImpl(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun LanguagePagePreview() {
|
private fun LanguagePagePreview() {
|
||||||
var language by remember {
|
var language by remember {
|
||||||
mutableIntStateOf(1)
|
mutableStateOf(Locale.KOREAN)
|
||||||
}
|
|
||||||
val map = buildMap<Int, String> {
|
|
||||||
repeat(38) {
|
|
||||||
put(it + 1, "")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
val map = setOf(Locale.forLanguageTag("ru"))
|
||||||
TokushoTheme {
|
TokushoTheme {
|
||||||
LanguageViewImpl(
|
LanguageViewImpl(
|
||||||
languageMap = map,
|
localeSet = map,
|
||||||
isSystemLocaleSettingsAvailable = true,
|
isSystemLocaleSettingsAvailable = true,
|
||||||
onNavigateToSystemLocaleSettings = { /*TODO*/ },
|
onNavigateToSystemLocaleSettings = { /*TODO*/ },
|
||||||
selectedLanguage = language
|
selectedLocale = language
|
||||||
) {
|
) {
|
||||||
language = it
|
language = it
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ import org.xtimms.tokusho.core.components.PreferenceSwitchWithContainer
|
|||||||
import org.xtimms.tokusho.core.components.PreferencesHintCard
|
import org.xtimms.tokusho.core.components.PreferencesHintCard
|
||||||
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
||||||
import org.xtimms.tokusho.core.components.icons.Kotatsu
|
import org.xtimms.tokusho.core.components.icons.Kotatsu
|
||||||
import org.xtimms.tokusho.utils.lang.tryLaunch
|
|
||||||
import org.xtimms.tokusho.utils.system.toast
|
import org.xtimms.tokusho.utils.system.toast
|
||||||
|
import org.xtimms.tokusho.utils.system.tryLaunch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
@@ -116,13 +116,13 @@ fun BackupRestoreView(
|
|||||||
|
|
||||||
ScaffoldWithTopAppBar(
|
ScaffoldWithTopAppBar(
|
||||||
title = stringResource(R.string.backup_and_restore),
|
title = stringResource(R.string.backup_and_restore),
|
||||||
|
navigateBack = navigateBack,
|
||||||
snackbarHost = {
|
snackbarHost = {
|
||||||
SnackbarHost(
|
SnackbarHost(
|
||||||
modifier = Modifier.systemBarsPadding(),
|
modifier = Modifier.systemBarsPadding(),
|
||||||
hostState = snackbarHostState
|
hostState = snackbarHostState
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
navigateBack = navigateBack
|
|
||||||
) { padding ->
|
) { padding ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
|
|||||||
@@ -9,12 +9,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.AccessTime
|
import androidx.compose.material.icons.outlined.AccessTime
|
||||||
import androidx.compose.material.icons.outlined.Restore
|
import androidx.compose.material.icons.outlined.Restore
|
||||||
import androidx.compose.material.icons.outlined.Update
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -25,16 +22,11 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.xtimms.tokusho.R
|
import org.xtimms.tokusho.R
|
||||||
import org.xtimms.tokusho.core.components.PreferencesHintCard
|
import org.xtimms.tokusho.core.components.PreferencesHintCard
|
||||||
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
||||||
import org.xtimms.tokusho.core.updates.Updater
|
|
||||||
import org.xtimms.tokusho.sections.settings.about.ProgressIndicatorButton
|
import org.xtimms.tokusho.sections.settings.about.ProgressIndicatorButton
|
||||||
import org.xtimms.tokusho.utils.DeviceUtil
|
import org.xtimms.tokusho.utils.DeviceUtil
|
||||||
import org.xtimms.tokusho.utils.system.suspendToast
|
|
||||||
|
|
||||||
const val RESTORE_ARGUMENT = "{source}"
|
const val RESTORE_ARGUMENT = "{source}"
|
||||||
const val RESTORE_DESTINATION = "restore/?file=${RESTORE_ARGUMENT}"
|
const val RESTORE_DESTINATION = "restore/?file=${RESTORE_ARGUMENT}"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import org.xtimms.tokusho.data.repository.backup.BackupZipInput
|
|||||||
import org.xtimms.tokusho.data.repository.backup.CompositeResult
|
import org.xtimms.tokusho.data.repository.backup.CompositeResult
|
||||||
import org.xtimms.tokusho.utils.lang.MutableEventFlow
|
import org.xtimms.tokusho.utils.lang.MutableEventFlow
|
||||||
import org.xtimms.tokusho.utils.lang.call
|
import org.xtimms.tokusho.utils.lang.call
|
||||||
import org.xtimms.tokusho.utils.lang.toUriOrNull
|
import org.xtimms.tokusho.utils.system.toUriOrNull
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Dns
|
import androidx.compose.material.icons.outlined.Dns
|
||||||
|
import androidx.compose.material.icons.outlined.PhotoSizeSelectSmall
|
||||||
import androidx.compose.material.icons.outlined.VpnLock
|
import androidx.compose.material.icons.outlined.VpnLock
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -23,6 +24,7 @@ import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
|||||||
import org.xtimms.tokusho.core.components.icons.ArrowDecisionOutline
|
import org.xtimms.tokusho.core.components.icons.ArrowDecisionOutline
|
||||||
import org.xtimms.tokusho.core.prefs.AppSettings
|
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||||
import org.xtimms.tokusho.core.prefs.SSL_BYPASS
|
import org.xtimms.tokusho.core.prefs.SSL_BYPASS
|
||||||
|
import org.xtimms.tokusho.core.prefs.WSRV
|
||||||
|
|
||||||
const val NETWORK_DESTINATION = "network"
|
const val NETWORK_DESTINATION = "network"
|
||||||
|
|
||||||
@@ -35,6 +37,10 @@ fun NetworkView(
|
|||||||
mutableStateOf(AppSettings.isSSLBypassEnabled())
|
mutableStateOf(AppSettings.isSSLBypassEnabled())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isImageOptimizationEnabled by remember {
|
||||||
|
mutableStateOf(AppSettings.isImagesProxyEnabled())
|
||||||
|
}
|
||||||
|
|
||||||
ScaffoldWithTopAppBar(
|
ScaffoldWithTopAppBar(
|
||||||
title = stringResource(R.string.network),
|
title = stringResource(R.string.network),
|
||||||
navigateBack = navigateBack
|
navigateBack = navigateBack
|
||||||
@@ -59,6 +65,17 @@ fun NetworkView(
|
|||||||
icon = Icons.Outlined.Dns
|
icon = Icons.Outlined.Dns
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
PreferenceSwitch(
|
||||||
|
title = stringResource(id = R.string.images_optimization_proxy),
|
||||||
|
description = stringResource(id = R.string.images_optimization_proxy_desc),
|
||||||
|
icon = Icons.Outlined.PhotoSizeSelectSmall,
|
||||||
|
isChecked = isImageOptimizationEnabled,
|
||||||
|
) {
|
||||||
|
isImageOptimizationEnabled = !isImageOptimizationEnabled
|
||||||
|
AppSettings.updateValue(WSRV, isImageOptimizationEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
PreferenceSwitch(
|
PreferenceSwitch(
|
||||||
title = stringResource(id = R.string.ignore_ssl_errors),
|
title = stringResource(id = R.string.ignore_ssl_errors),
|
||||||
|
|||||||
@@ -1,23 +1,37 @@
|
|||||||
package org.xtimms.tokusho.sections.settings.shelf
|
package org.xtimms.tokusho.sections.settings.shelf
|
||||||
|
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Category
|
import androidx.compose.material.icons.outlined.Category
|
||||||
|
import androidx.compose.material.icons.outlined.GridView
|
||||||
import androidx.compose.material.icons.outlined.Numbers
|
import androidx.compose.material.icons.outlined.Numbers
|
||||||
import androidx.compose.material.icons.outlined.Update
|
import androidx.compose.material.icons.outlined.Update
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.SliderDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import org.xtimms.tokusho.R
|
import org.xtimms.tokusho.R
|
||||||
@@ -26,6 +40,7 @@ import org.xtimms.tokusho.core.components.PreferenceSubtitle
|
|||||||
import org.xtimms.tokusho.core.components.PreferenceSwitch
|
import org.xtimms.tokusho.core.components.PreferenceSwitch
|
||||||
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
||||||
import org.xtimms.tokusho.core.prefs.AppSettings
|
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||||
|
import org.xtimms.tokusho.core.prefs.GRID_COLUMNS
|
||||||
import org.xtimms.tokusho.core.prefs.TABS_MANGA_COUNT
|
import org.xtimms.tokusho.core.prefs.TABS_MANGA_COUNT
|
||||||
import org.xtimms.tokusho.sections.shelf.ShelfViewModel
|
import org.xtimms.tokusho.sections.shelf.ShelfViewModel
|
||||||
|
|
||||||
@@ -38,6 +53,8 @@ fun ShelfSettingsView(
|
|||||||
navigateToCategories: () -> Unit
|
navigateToCategories: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
var showGridColumnsDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val categories by shelfViewModel.categories.collectAsStateWithLifecycle(emptyList())
|
val categories by shelfViewModel.categories.collectAsStateWithLifecycle(emptyList())
|
||||||
|
|
||||||
var isMangaCountInTabsEnabled by remember {
|
var isMangaCountInTabsEnabled by remember {
|
||||||
@@ -81,6 +98,13 @@ fun ShelfSettingsView(
|
|||||||
AppSettings.updateValue(TABS_MANGA_COUNT, isMangaCountInTabsEnabled)
|
AppSettings.updateValue(TABS_MANGA_COUNT, isMangaCountInTabsEnabled)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
PreferenceItem(
|
||||||
|
title = stringResource(id = R.string.grid_columns_count),
|
||||||
|
description = stringResource(id = R.string.grid_columns_count_desc, AppSettings.getGridColumnsCount().toInt()),
|
||||||
|
icon = Icons.Outlined.GridView
|
||||||
|
) { showGridColumnsDialog = true }
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
PreferenceSubtitle(text = stringResource(id = R.string.updates))
|
PreferenceSubtitle(text = stringResource(id = R.string.updates))
|
||||||
}
|
}
|
||||||
@@ -94,4 +118,55 @@ fun ShelfSettingsView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showGridColumnsDialog) {
|
||||||
|
GridColumnsDialog {
|
||||||
|
showGridColumnsDialog = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun GridColumnsDialog(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
) {
|
||||||
|
var count by remember { mutableFloatStateOf(AppSettings.getGridColumnsCount()) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.dismiss))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
onDismissRequest()
|
||||||
|
AppSettings.encodeInt(GRID_COLUMNS, count.toInt())
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = { Icon(Icons.Outlined.GridView, null) },
|
||||||
|
title = { Text(stringResource(R.string.grid_columns_count)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
Text(text = stringResource(R.string.grid_columns_count_desc, count.toInt()))
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value = count,
|
||||||
|
onValueChange = { count = it },
|
||||||
|
steps = 3,
|
||||||
|
valueRange = 1f..5f,
|
||||||
|
thumb = {
|
||||||
|
SliderDefaults.Thumb(
|
||||||
|
modifier = Modifier,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@@ -12,10 +12,15 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.ImageLoader
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.xtimms.tokusho.core.AsyncImageImpl
|
||||||
|
import org.xtimms.tokusho.core.parser.favicon.faviconUri
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SourceCatalogItem(
|
fun SourceCatalogItem(
|
||||||
source: String,
|
coil: ImageLoader,
|
||||||
|
source: MangaSource,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -32,13 +37,16 @@ fun SourceCatalogItem(
|
|||||||
),
|
),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null)
|
AsyncImageImpl(
|
||||||
|
coil = coil,
|
||||||
|
contentDescription = null,
|
||||||
|
model = source.faviconUri()
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
text = source,
|
text = source.title,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(start = 16.dp),
|
.padding(start = 16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -5,26 +5,12 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.xtimms.tokusho.core.model.ListModel
|
import org.xtimms.tokusho.core.model.ListModel
|
||||||
|
|
||||||
sealed interface SourceCatalogItemModel : ListModel {
|
data class SourceCatalogItemModel(
|
||||||
|
val source: MangaSource,
|
||||||
|
val showSummary: Boolean
|
||||||
|
) : ListModel {
|
||||||
|
|
||||||
data class Source(
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
val source: MangaSource,
|
return other is SourceCatalogItemModel && other.source == source
|
||||||
val showSummary: Boolean,
|
|
||||||
) : SourceCatalogItemModel {
|
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
|
||||||
return other is Source && other.source == source
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Hint(
|
|
||||||
val icon: ImageVector,
|
|
||||||
@StringRes val title: Int,
|
|
||||||
@StringRes val text: Int,
|
|
||||||
) : SourceCatalogItemModel {
|
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
|
||||||
return other is Hint && other.title == title
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,30 +69,12 @@ class SourcesCatalogListProducer @AssistedInject constructor(
|
|||||||
"" -> return emptyList()
|
"" -> return emptyList()
|
||||||
else -> sources.retainAll { it.title.contains(q, ignoreCase = true) }
|
else -> sources.retainAll { it.title.contains(q, ignoreCase = true) }
|
||||||
}
|
}
|
||||||
return if (sources.isEmpty()) {
|
sources.sortBy { it.title }
|
||||||
listOf(
|
return sources.map {
|
||||||
if (query == null) {
|
SourceCatalogItemModel(
|
||||||
SourceCatalogItemModel.Hint(
|
source = it,
|
||||||
icon = Icons.Outlined.SearchOff,
|
showSummary = query != null,
|
||||||
title = R.string.no_manga_sources,
|
|
||||||
text = R.string.no_manga_sources_catalog_text,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
SourceCatalogItemModel.Hint(
|
|
||||||
icon = Icons.Outlined.SearchOff,
|
|
||||||
title = R.string.nothing_found,
|
|
||||||
text = R.string.no_manga_sources_found,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
sources.sortBy { it.title }
|
|
||||||
sources.map {
|
|
||||||
SourceCatalogItemModel.Source(
|
|
||||||
source = it,
|
|
||||||
showSummary = query != null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,15 +11,16 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coil.ImageLoader
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.xtimms.tokusho.R
|
import org.xtimms.tokusho.R
|
||||||
import org.xtimms.tokusho.core.components.ScaffoldWithClassicTopAppBar
|
import org.xtimms.tokusho.core.components.ScaffoldWithClassicTopAppBar
|
||||||
|
|
||||||
const val CATALOG_DESTINATION = "catalog"
|
const val CATALOG_DESTINATION = "catalog"
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SourcesCatalogView(
|
fun SourcesCatalogView(
|
||||||
|
coil: ImageLoader,
|
||||||
sourcesCatalogViewModel: SourcesCatalogViewModel = hiltViewModel(),
|
sourcesCatalogViewModel: SourcesCatalogViewModel = hiltViewModel(),
|
||||||
navigateBack: () -> Unit,
|
navigateBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -41,6 +42,14 @@ fun SourcesCatalogView(
|
|||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
) { scope.launch { pagerState.animateScrollToPage(it) } }
|
) { scope.launch { pagerState.animateScrollToPage(it) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SourcesCatalogPager(
|
||||||
|
coil = coil,
|
||||||
|
state = pagerState,
|
||||||
|
contentPadding = padding,
|
||||||
|
searchQuery = null,
|
||||||
|
getSourcesForPage = { categories }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.xtimms.tokusho.sections.shelf
|
package org.xtimms.tokusho.sections.shelf
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -17,10 +16,10 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import org.xtimms.tokusho.R
|
import org.xtimms.tokusho.R
|
||||||
|
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||||
import org.xtimms.tokusho.core.screens.EmptyScreen
|
import org.xtimms.tokusho.core.screens.EmptyScreen
|
||||||
import org.xtimms.tokusho.utils.system.plus
|
import org.xtimms.tokusho.utils.system.plus
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ShelfPager(
|
fun ShelfPager(
|
||||||
coil: ImageLoader,
|
coil: ImageLoader,
|
||||||
@@ -51,7 +50,7 @@ fun ShelfPager(
|
|||||||
ShelfGrid(
|
ShelfGrid(
|
||||||
coil = coil,
|
coil = coil,
|
||||||
items = library,
|
items = library,
|
||||||
columns = 2,
|
columns = AppSettings.getGridColumnsCount().toInt(),
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
selection = listOf(),
|
selection = listOf(),
|
||||||
onClick = navigateToDetails,
|
onClick = navigateToDetails,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package org.xtimms.tokusho.sections.shelf
|
|||||||
|
|
||||||
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.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -23,8 +22,6 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.xtimms.tokusho.core.collapsable
|
import org.xtimms.tokusho.core.collapsable
|
||||||
import org.xtimms.tokusho.core.components.PullRefresh
|
import org.xtimms.tokusho.core.components.PullRefresh
|
||||||
import org.xtimms.tokusho.core.model.FavouriteCategory
|
|
||||||
import org.xtimms.tokusho.core.model.ShelfCategory
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
const val SHELF_DESTINATION = "shelf"
|
const val SHELF_DESTINATION = "shelf"
|
||||||
@@ -47,11 +44,10 @@ fun ShelfView(
|
|||||||
topBarHeightPx = topBarHeightPx,
|
topBarHeightPx = topBarHeightPx,
|
||||||
padding = padding,
|
padding = padding,
|
||||||
navigateToDetails = navigateToDetails,
|
navigateToDetails = navigateToDetails,
|
||||||
onRefresh = onRefresh
|
onRefresh = onRefresh,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ShelfViewContent(
|
fun ShelfViewContent(
|
||||||
coil: ImageLoader,
|
coil: ImageLoader,
|
||||||
|
|||||||
@@ -5,15 +5,9 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import dagger.hilt.android.lifecycle.RetainedLifecycle
|
import dagger.hilt.android.lifecycle.RetainedLifecycle
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.CoroutineStart
|
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import org.xtimms.tokusho.utils.RetainedLifecycleCoroutineScope
|
import org.xtimms.tokusho.utils.RetainedLifecycleCoroutineScope
|
||||||
@@ -34,4 +28,13 @@ fun <T> Deferred<T>.peek(): T? = if (isCompleted) {
|
|||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
fun <T> Deferred<T>.getCompletionResultOrNull(): Result<T>? = if (isCompleted) {
|
||||||
|
getCompletionExceptionOrNull()?.let { error ->
|
||||||
|
Result.failure(error)
|
||||||
|
} ?: Result.success(getCompleted())
|
||||||
|
} else {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -60,4 +60,6 @@ fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().u
|
|||||||
fun Sequence<File>.filterWith(filter: FileFilter): Sequence<File> = filter { f -> filter.accept(f) }
|
fun Sequence<File>.filterWith(filter: FileFilter): Sequence<File> = filter { f -> filter.accept(f) }
|
||||||
|
|
||||||
fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() }
|
fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() }
|
||||||
fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() }
|
fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() }
|
||||||
|
|
||||||
|
fun File.isNotEmpty() = length() != 0L
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
package org.xtimms.tokusho.utils.system
|
package org.xtimms.tokusho.utils.system
|
||||||
|
|
||||||
import okhttp3.Cookie
|
import okhttp3.Cookie
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.internal.closeQuietly
|
||||||
|
import org.jsoup.HttpStatusException
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
|
||||||
fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
|
fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
|
||||||
c.name(name)
|
c.name(name)
|
||||||
@@ -20,4 +25,17 @@ fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
|
|||||||
if (httpOnly) {
|
if (httpOnly) {
|
||||||
c.httpOnly()
|
c.httpOnly()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val HttpUrl.isHttpOrHttps: Boolean
|
||||||
|
get() {
|
||||||
|
val s = scheme.lowercase()
|
||||||
|
return s == "https" || s == "http"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Response.ensureSuccess() = apply {
|
||||||
|
if (!isSuccessful || code == HttpURLConnection.HTTP_NO_CONTENT) {
|
||||||
|
closeQuietly()
|
||||||
|
throw HttpStatusException(message, code, request.url.toString())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,45 +1,38 @@
|
|||||||
package org.xtimms.tokusho.utils.system
|
package org.xtimms.tokusho.utils.system
|
||||||
|
|
||||||
import android.os.Build
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import org.xtimms.tokusho.R
|
import org.xtimms.tokusho.R
|
||||||
import org.xtimms.tokusho.core.prefs.AppSettings.getInt
|
|
||||||
import org.xtimms.tokusho.core.prefs.LANGUAGE
|
|
||||||
import org.xtimms.tokusho.core.prefs.SYSTEM_DEFAULT
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
fun LocaleListCompat.toList(): List<Locale> = List(size()) { i -> getOrThrow(i) }
|
fun LocaleListCompat.toList(): List<Locale> = List(size()) { i -> getOrThrow(i) }
|
||||||
|
|
||||||
fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException()
|
fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException()
|
||||||
|
|
||||||
private fun getLanguageNumberByCode(languageCode: String) : Int =
|
|
||||||
languageMap.entries.find { it.value == languageCode }?.key ?: SYSTEM_DEFAULT
|
|
||||||
|
|
||||||
fun getLanguageNumber(): Int {
|
|
||||||
return if (Build.VERSION.SDK_INT >= 33)
|
|
||||||
getLanguageNumberByCode(
|
|
||||||
LocaleListCompat.getAdjustedDefault()[0]?.toLanguageTag().toString()
|
|
||||||
)
|
|
||||||
else LANGUAGE.getInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun getLanguageDesc(language: Int = getLanguageNumber()): String {
|
fun Locale?.toDisplayName(): String = this?.getDisplayName(this) ?: stringResource(
|
||||||
return stringResource(
|
id = R.string.follow_system
|
||||||
when (language) {
|
)
|
||||||
ENGLISH -> R.string.la_en_US
|
|
||||||
RUSSIAN -> R.string.la_ru
|
fun setLanguage(locale: Locale?) {
|
||||||
else -> R.string.follow_system
|
val localeList = locale?.let {
|
||||||
}
|
LocaleListCompat.create(it)
|
||||||
)
|
} ?: LocaleListCompat.getEmptyLocaleList()
|
||||||
|
AppCompatDelegate.setApplicationLocales(localeList)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do not modify
|
// Do not modify
|
||||||
private const val ENGLISH = 1
|
private const val ENGLISH = 1
|
||||||
private const val RUSSIAN = 2
|
private const val RUSSIAN = 2
|
||||||
|
|
||||||
|
val LocaleLanguageCodeMap =
|
||||||
|
mapOf(
|
||||||
|
Locale("en", "US") to ENGLISH,
|
||||||
|
Locale("ru") to RUSSIAN
|
||||||
|
)
|
||||||
|
|
||||||
// Sorted alphabetically
|
// Sorted alphabetically
|
||||||
val languageMap: Map<Int, String> = mapOf(
|
val languageMap: Map<Int, String> = mapOf(
|
||||||
RUSSIAN to "ru",
|
RUSSIAN to "ru",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
1
app/src/main/res/resources.properties
Normal file
1
app/src/main/res/resources.properties
Normal file
@@ -0,0 +1 @@
|
|||||||
|
unqualifiedResLocale=en-US
|
||||||
96
app/src/main/res/values-ru/strings.xml
Normal file
96
app/src/main/res/values-ru/strings.xml
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="settings">Настройки</string>
|
||||||
|
<string name="feed">Лента</string>
|
||||||
|
<string name="search">Поиск</string>
|
||||||
|
<string name="nav_explore">Обзор</string>
|
||||||
|
<string name="nav_history">История</string>
|
||||||
|
<string name="nav_shelf">Полка</string>
|
||||||
|
<string name="local_storage">Локальное хранилище</string>
|
||||||
|
<string name="bookmarks">Закладки</string>
|
||||||
|
<string name="random">Рандом</string>
|
||||||
|
<string name="downloads">Загрузки</string>
|
||||||
|
<string name="about">О приложении</string>
|
||||||
|
<string name="appearance">Внешний вид</string>
|
||||||
|
<string name="appearance_page">Тема, вид списка, язык</string>
|
||||||
|
<string name="about_page">Версия, автоматические обновления</string>
|
||||||
|
<string name="nothing_here">Здесь ничего нет</string>
|
||||||
|
<string name="crash_screen_title">Упс!</string>
|
||||||
|
<string name="pref_dump_crash_logs">Отправить логи падения</string>
|
||||||
|
<string name="crash_screen_restart_application">Перезапустить приложение</string>
|
||||||
|
<string name="action_share">Поделиться</string>
|
||||||
|
<string name="follow_system">Следовать системе</string>
|
||||||
|
<string name="on">Включено</string>
|
||||||
|
<string name="off">Выключено</string>
|
||||||
|
<string name="open_settings">Открыть настройки</string>
|
||||||
|
<string name="auto_update">Автоматические обновления</string>
|
||||||
|
<string name="check_for_updates_desc">Автоматически проверять наличие последней версии на Gitea</string>
|
||||||
|
<string name="version">Версия</string>
|
||||||
|
<string name="info_copied">Информация скопирована в буфер обмена</string>
|
||||||
|
<string name="enable_auto_update">Включить автоматическое обновление</string>
|
||||||
|
<string name="update_channel">Канал обновления</string>
|
||||||
|
<string name="stable_channel">Стабильный</string>
|
||||||
|
<string name="pre_release_channel">Бета</string>
|
||||||
|
<string name="check_for_updates">Проверить наличие обновлений</string>
|
||||||
|
<string name="app_up_to_date">Нет обновлений</string>
|
||||||
|
<string name="app_update_failed">Произошла ошибка при проверке обновлений</string>
|
||||||
|
<string name="update_channel_desc">Устанавливайте предварительные сборки для предварительного просмотра новых функций и изменений. В этих версиях будет наблюдаться некоторая нестабильность, поэтому, пожалуйста, не стесняйтесь оставлять отзывы, если у вас возникнут какие-либо проблемы, чтобы помочь нам улучшить приложение в будущем.</string>
|
||||||
|
<string name="language">Язык</string>
|
||||||
|
<string name="dark_theme">Темная тема</string>
|
||||||
|
<string name="dynamic_color">Динамические цвета</string>
|
||||||
|
<string name="dynamic_color_desc">Примените цвета из обоев к теме приложения</string>
|
||||||
|
<string name="cancel">Отмена</string>
|
||||||
|
<string name="additional_settings">Дополнительные настройки</string>
|
||||||
|
<string name="high_contrast">Высококонтрастная темная тема</string>
|
||||||
|
<string name="system_settings">Системные настройки</string>
|
||||||
|
<string name="translate">Перевести</string>
|
||||||
|
<string name="translate_desc">Помогите перевести это приложение на Hosted Weblate</string>
|
||||||
|
<string name="update">Обновить</string>
|
||||||
|
<string name="dismiss">Отклонить</string>
|
||||||
|
<string name="ongoing">Продолжается</string>
|
||||||
|
<string name="finished">Завершено</string>
|
||||||
|
<string name="abandoned">Заброшено</string>
|
||||||
|
<string name="paused">На паузе</string>
|
||||||
|
<string name="upcoming">Скоро выйдет</string>
|
||||||
|
<string name="unknown">Неизвестно</string>
|
||||||
|
<string name="open_in_browser">Открыть в браузере</string>
|
||||||
|
<string name="file_create_backup">Создать файл резервной копии</string>
|
||||||
|
<string name="backup_saved">Резервная копия сохранена</string>
|
||||||
|
<string name="supports_kotatsu_backups">Поддержка бэкапов Kotatsu</string>
|
||||||
|
<string name="supports_kotatsu_backups_desc">Tokusho также может обрабатывать резервные копии Kotatsu</string>
|
||||||
|
<string name="open_source_licenses">Лицензии с открытым исходным кодом</string>
|
||||||
|
<string name="no_manga_sources">Нет источников манги</string>
|
||||||
|
<string name="no_manga_sources_desc">Включите источники манги, чтобы читать мангу онлайн</string>
|
||||||
|
<string name="catalog">Каталог</string>
|
||||||
|
<string name="enabled_d_of_d">%1$d из %2$d включено</string>
|
||||||
|
<string name="sources_catalog">Каталог источников</string>
|
||||||
|
<string name="available_d">Доступно: %1$d</string>
|
||||||
|
<string name="empty_history_title">Прочитанное вами будет отображаться здесь</string>
|
||||||
|
<string name="empty_history_description">Найдите, что почитать в разделе \"Обзор\".</string>
|
||||||
|
<string name="nothing_found">Ничего не найдено</string>
|
||||||
|
<string name="nothing_found_summary">Попробуйте переформулировать запрос</string>
|
||||||
|
<string name="empty_here">Как-то пустовато здесь.</string>
|
||||||
|
<string name="manage_sources">Управление источниками</string>
|
||||||
|
<string name="ignore_ssl_errors">Игнорирование ошибок SSL</string>
|
||||||
|
<string name="ignore_ssl_errors_desc">Это может помочь в некоторых случаях</string>
|
||||||
|
<string name="show_manga_count_in_tabs">Показать количество манги в категориях</string>
|
||||||
|
<string name="clear_all">Очистить все</string>
|
||||||
|
<string name="description">Описание</string>
|
||||||
|
<string name="source_enabled">Источник включен</string>
|
||||||
|
<string name="no_manga_sources_catalog_text">В этом разделе нет источников, или все они могли быть уже добавлены.</string>
|
||||||
|
<string name="no_manga_sources_found">По вашему запросу не найдено ни одного источника манги</string>
|
||||||
|
<string name="unread">Не прочитано</string>
|
||||||
|
<string name="mean_score">Рейтинг</string>
|
||||||
|
<string name="read">Читать</string>
|
||||||
|
<string name="not_in_favourites">Не в избранных</string>
|
||||||
|
<string name="manga">Манга</string>
|
||||||
|
<string name="comics">Комиксы</string>
|
||||||
|
<string name="hentai">Хентай</string>
|
||||||
|
<string name="other">Другое</string>
|
||||||
|
<string name="related_manga">Похожая манга</string>
|
||||||
|
<string name="chapters">Главы</string>
|
||||||
|
<string name="error_occured">Произошла ошибка</string>
|
||||||
|
<string name="in_favourites">В избранных</string>
|
||||||
|
<string name="in_shelf">В полке</string>
|
||||||
|
<string name="add_to_shelf">Добавить в полку</string>
|
||||||
|
</resources>
|
||||||
@@ -16,4 +16,24 @@
|
|||||||
<item quantity="one">%1$d category</item>
|
<item quantity="one">%1$d category</item>
|
||||||
<item quantity="other">%1$d categories</item>
|
<item quantity="other">%1$d categories</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<plurals name="chapters">
|
||||||
|
<item quantity="one">%1$d chapter</item>
|
||||||
|
<item quantity="other">%1$d chapters</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="minutes_ago">
|
||||||
|
<item quantity="one">%1$d minute ago</item>
|
||||||
|
<item quantity="other">%1$d minutes ago</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="hours_ago">
|
||||||
|
<item quantity="one">%1$d hour ago</item>
|
||||||
|
<item quantity="other">%1$d hours ago</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="days_ago">
|
||||||
|
<item quantity="one">%1$d day ago</item>
|
||||||
|
<item quantity="other">%1$d days ago</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="months_ago">
|
||||||
|
<item quantity="one">%1$d month ago</item>
|
||||||
|
<item quantity="other">%1$d months ago</item>
|
||||||
|
</plurals>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Tokusho</string>
|
<string name="app_name" translatable="false">Tokusho</string>
|
||||||
<string name="settings">Settings</string>
|
<string name="settings">Settings</string>
|
||||||
<string name="feed">Feed</string>
|
<string name="feed">Feed</string>
|
||||||
<string name="search">Search</string>
|
<string name="search">Search</string>
|
||||||
@@ -35,13 +35,13 @@
|
|||||||
<string name="check_for_updates">Check for updates</string>
|
<string name="check_for_updates">Check for updates</string>
|
||||||
<string name="app_up_to_date">No new updates</string>
|
<string name="app_up_to_date">No new updates</string>
|
||||||
<string name="app_update_failed">An error occurred while checking updates</string>
|
<string name="app_update_failed">An error occurred while checking updates</string>
|
||||||
<string name="update_channel_desc">Install pre-release builds to preview new features and changes.\n\nThere will be some instability in there versions, so please don\'t hesitate to give us feedback if you experience any problems to help us improve the app for the future.</string>
|
<string name="update_channel_desc">Install pre-release builds to preview new features and changes. There will be some instability in there versions, so please don\'t hesitate to give us feedback if you experience any problems to help us improve the app for the future.</string>
|
||||||
<string name="language">Language</string>
|
<string name="language">Language</string>
|
||||||
<string name="dark_theme">Dark theme</string>
|
<string name="dark_theme">Dark theme</string>
|
||||||
<string name="dynamic_color">Dynamic color</string>
|
<string name="dynamic_color">Dynamic color</string>
|
||||||
<string name="dynamic_color_desc">Apply colors from wallpapers to the app theme</string>
|
<string name="dynamic_color_desc">Apply colors from wallpapers to the app theme</string>
|
||||||
<string name="la_en_US">English (United States)</string>
|
<string name="la_en_US" translatable="false">English (United States)</string>
|
||||||
<string name="la_ru">Русский</string>
|
<string name="la_ru" translatable="false">Русский</string>
|
||||||
<string name="cancel">Cancel</string>
|
<string name="cancel">Cancel</string>
|
||||||
<string name="additional_settings">Additional settings</string>
|
<string name="additional_settings">Additional settings</string>
|
||||||
<string name="high_contrast">High contrast dark theme</string>
|
<string name="high_contrast">High contrast dark theme</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user