Some changes

master
Zakhar Timoshenko 2 years ago
parent 9e269a9e03
commit 57e490fe28
Signed by: Xtimms
SSH Key Fingerprint: SHA256:wH6spYepK/A5erBh7ZyAnr1ru9H4eaMVBEuiw6DSpxI

@ -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
) )
} }

@ -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,10 +133,11 @@ fun DetailsInfoBox(
onSourceClicked: () -> Unit, onSourceClicked: () -> Unit,
) { ) {
Column(modifier = modifier) { Column(modifier = modifier) {
val backdropGradientColors = listOf( Box(
Color.Transparent, modifier = Modifier
MaterialTheme.colorScheme.background, .fillMaxWidth(),
) contentAlignment = Alignment.BottomEnd,
) {
AsyncImageImpl( AsyncImageImpl(
coil = coil, coil = coil,
model = imageUrl, model = imageUrl,
@ -165,6 +152,22 @@ fun DetailsInfoBox(
) )
.clip(MaterialTheme.shapes.large) .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)
)
}
}
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,9 +359,8 @@ 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(
@ -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
) )
AnimatedContent(
targetState = desc,
label = "description"
) {
MangaSummary( MangaSummary(
expandedDescription = desc, expandedDescription = it,
shrunkDescription = trimmedDescription, shrunkDescription = trimmedDescription,
expanded = expanded, expanded = expanded,
modifier = Modifier modifier = Modifier
.padding(top = 8.dp) .padding(top = 8.dp)
.clickableNoIndication { onExpanded(!expanded) }, .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,46 +1,123 @@
package org.xtimms.tokusho.sections.history package org.xtimms.tokusho.sections.history
//noinspection UsingMaterialAndMaterial3Libraries
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.DismissDirection
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DeleteForever
import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.ImageLoader
import org.xtimms.tokusho.R import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.collapsable import org.xtimms.tokusho.core.collapsable
import org.xtimms.tokusho.core.components.ListGroupHeader
import org.xtimms.tokusho.core.components.effects.RowEntity
import org.xtimms.tokusho.core.components.effects.RowEntityType
import org.xtimms.tokusho.core.components.effects.animatedItemsIndexed
import org.xtimms.tokusho.core.components.effects.updateAnimatedItemsState
import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.core.prefs.SWIPE_TUTORIAL
import org.xtimms.tokusho.core.screens.EmptyScreen import org.xtimms.tokusho.core.screens.EmptyScreen
import org.xtimms.tokusho.ui.theme.TokushoTheme import org.xtimms.tokusho.utils.lang.calculateTimeAgo
import org.xtimms.tokusho.utils.lang.isSameDay
import java.time.Instant
import kotlin.math.abs
import kotlin.math.absoluteValue
const val HISTORY_DESTINATION = "history" const val HISTORY_DESTINATION = "history"
@OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun HistoryView( fun HistoryView(
topBarHeightPx: Float, coil: ImageLoader,
padding: PaddingValues, viewModel: HistoryViewModel = hiltViewModel(),
) {
HistoryViewContent(
topBarHeightPx = topBarHeightPx,
padding = padding
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HistoryViewContent(
topBarHeightPx: Float, topBarHeightPx: Float,
topBarOffsetY: Animatable<Float, AnimationVector1D> = Animatable(0f), topBarOffsetY: Animatable<Float, AnimationVector1D> = Animatable(0f),
padding: PaddingValues, padding: PaddingValues,
navigateToDetails: (Long) -> Unit,
navigateToReader: () -> Unit
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
var isUserTrySwipe by remember { mutableStateOf(false) }
val history by viewModel.content.collectAsStateWithLifecycle(emptyList())
DisposableEffect(Unit) {
onDispose {
if (history.isNotEmpty() && isUserTrySwipe) {
AppSettings.updateValue(SWIPE_TUTORIAL, isUserTrySwipe)
}
}
}
val animatedList = run {
val list = emptyList<RowEntity>().toMutableList()
var readDate: Instant? = null
history.forEach { item ->
if (readDate === null || !isSameDay(
item.history.updatedAt.toEpochMilli(),
readDate!!.toEpochMilli()
)
) {
readDate = item.history.updatedAt
Column( 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 modifier = Modifier
.collapsable( .collapsable(
state = scrollState, state = scrollState,
@ -49,23 +126,117 @@ fun HistoryViewContent(
) )
.padding(padding) .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( EmptyScreen(
icon = Icons.Outlined.History, icon = Icons.Outlined.History,
title = R.string.empty_history_title, title = R.string.empty_history_title,
description = R.string.empty_history_description description = R.string.empty_history_description
) )
} }
}
@Preview
@Composable
fun HistoryPreview() {
TokushoTheme {
Surface {
HistoryViewContent(
padding = PaddingValues(),
topBarHeightPx = 0f,
)
}
} }
} }

@ -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,17 +110,74 @@ 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 =
rememberPagerState(initialPage = if (LocalPaletteStyleIndex.current == STYLE_MONOCHROME) pageCount else colorList.indexOf(
Color(LocalSeedColor.current) Color(LocalSeedColor.current)
).run { if (this == -1) 0 else this }) { ).run { if (this == -1) 0 else this }) {
pageCount pageCount
@ -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(
data class Source(
val source: MangaSource, val source: MangaSource,
val showSummary: Boolean, val showSummary: Boolean
) : SourceCatalogItemModel { ) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {
return other is Source && other.source == source return other is SourceCatalogItemModel && 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,32 +69,14 @@ 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()) {
listOf(
if (query == null) {
SourceCatalogItemModel.Hint(
icon = Icons.Outlined.SearchOff,
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.sortBy { it.title }
sources.map { return sources.map {
SourceCatalogItemModel.Source( SourceCatalogItemModel(
source = it, source = it,
showSummary = query != null, showSummary = query != null,
) )
} }
} }
}
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {

@ -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
@ -35,3 +29,12 @@ fun <T> Deferred<T>.peek(): T? = if (isCompleted) {
} 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,6 +1,15 @@
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 {
@ -8,3 +17,154 @@ fun Date.toDateTimestampString(dateFormatter: DateFormat): String {
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
}

@ -61,3 +61,5 @@ fun Sequence<File>.filterWith(filter: FileFilter): Sequence<File> = filter { 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)
@ -21,3 +26,16 @@ fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
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")
}

@ -0,0 +1 @@
unqualifiedResLocale=en-US

@ -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>

Loading…
Cancel
Save