diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml index 93a3134..229e66d 100644 --- a/.idea/deploymentTargetDropDown.xml +++ b/.idea/deploymentTargetDropDown.xml @@ -6,7 +6,20 @@ - + + + + + + + + + + + + + + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 120ebed..7f03dca 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,6 +83,9 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + androidResources { + generateLocaleConfig = true + } } dependencies { @@ -92,7 +95,7 @@ dependencies { implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") implementation("androidx.lifecycle:lifecycle-process:2.7.0") 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.ui:ui") implementation("androidx.compose.ui:ui-graphics") @@ -109,13 +112,14 @@ dependencies { implementation("androidx.work:work-runtime-ktx:2.9.0") ksp("androidx.room:room-compiler:2.6.1") 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.accompanist:accompanist-flowlayout: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-indicators:0.32.0") - implementation("com.google.dagger:hilt-android:2.50") - kapt("com.google.dagger:hilt-compiler:2.50") + implementation("com.google.dagger:hilt-android:2.51") + kapt("com.google.dagger:hilt-compiler:2.51") implementation("androidx.hilt:hilt-work:1.2.0") kapt("androidx.hilt:hilt-compiler:1.2.0") 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-serialization-json:1.6.2") 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") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") diff --git a/app/src/main/java/org/xtimms/tokusho/MainActivity.kt b/app/src/main/java/org/xtimms/tokusho/MainActivity.kt index ebcecd4..87b9688 100644 --- a/app/src/main/java/org/xtimms/tokusho/MainActivity.kt +++ b/app/src/main/java/org/xtimms/tokusho/MainActivity.kt @@ -1,12 +1,16 @@ package org.xtimms.tokusho +import android.Manifest import android.content.Intent +import android.net.Uri +import android.os.Build import android.os.Bundle -import android.util.Log +import android.provider.Settings import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent 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.animateFloatAsState import androidx.compose.foundation.layout.PaddingValues @@ -34,26 +38,35 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.dp -import androidx.core.os.LocaleListCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.core.view.WindowCompat import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import coil.ImageLoader import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.xtimms.tokusho.core.Navigation 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.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.utils.lang.processLifecycleScope +import org.xtimms.tokusho.utils.system.setLanguage +import org.xtimms.tokusho.utils.system.suspendToast import javax.inject.Inject @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @@ -79,10 +92,48 @@ class MainActivity : ComponentActivity() { return } + runBlocking { + if (Build.VERSION.SDK_INT < 33) { + setLanguage(AppSettings.getLocaleFromPreference()) + } + } + 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 windowSizeClass = calculateWindowSizeClass(this) 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) { isReady.value = true } @@ -102,6 +153,49 @@ class MainActivity : ComponentActivity() { LaunchedEffect(Unit) { 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 .only(WindowInsetsSides.Horizontal) ) { padding -> @@ -175,6 +272,9 @@ fun MainView( Row( modifier = Modifier.padding(padding) ) { + NavigationRail( + navController = navController + ) Navigation( coil = coil, loggers = loggers, diff --git a/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt b/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt index 23aaec6..b8d09e9 100644 --- a/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt +++ b/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt @@ -27,11 +27,13 @@ import org.xtimms.tokusho.core.cache.StubContentCache import org.xtimms.tokusho.core.database.TokushoDatabase import org.xtimms.tokusho.core.model.LocalManga 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.parser.MangaLoaderContextImpl import org.xtimms.tokusho.core.parser.MangaRepository import org.xtimms.tokusho.core.parser.favicon.FaviconFetcher 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.system.connectivityManager import org.xtimms.tokusho.utils.system.isLowRamDevice @@ -69,6 +71,8 @@ interface TokushoModule { @ApplicationContext context: Context, @MangaHttpClient okHttpClient: OkHttpClient, mangaRepositoryFactory: MangaRepository.Factory, + imageProxyInterceptor: ImageProxyInterceptor, + pageFetcherFactory: MangaPageFetcher.Factory, ): ImageLoader { val diskCacheFactory = { val rootDir = context.externalCacheDir ?: context.cacheDir @@ -85,9 +89,12 @@ interface TokushoModule { .transformationDispatcher(Dispatchers.Default) .diskCache(diskCacheFactory) .logger(if (BuildConfig.DEBUG) DebugLogger() else null) + .allowRgb565(context.isLowRamDevice()) .components( ComponentRegistry.Builder() .add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory)) + .add(pageFetcherFactory) + .add(imageProxyInterceptor) .build(), ).build() } diff --git a/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt b/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt index ea98fe2..f50096f 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt @@ -6,6 +6,8 @@ import androidx.compose.animation.core.Easing 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.layout.PaddingValues import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.Composable @@ -19,8 +21,6 @@ import androidx.navigation.navArgument import coil.ImageLoader import org.koitharu.kotatsu.parsers.model.MangaSource 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.DetailsView 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.MangaListView 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.SearchHostView import org.xtimms.tokusho.sections.settings.SETTINGS_DESTINATION @@ -131,16 +133,31 @@ fun Navigation( val enterTween = tween(durationMillis = DURATION_ENTER, easing = emphasizeEasing) val exitTween = tween(durationMillis = DURATION_ENTER, easing = emphasizeEasing) val fadeTween = tween(durationMillis = DURATION_EXIT) - val fadeSpec = fadeTween NavHost( navController = navController, startDestination = BottomNavDestination.Shelf.route, modifier = modifier, - enterTransition = { materialSharedAxisXIn(initialOffsetX = { (it * initialOffset).toInt() }) }, - exitTransition = { materialSharedAxisXOut(targetOffsetX = { -(it * initialOffset).toInt() }) }, - popEnterTransition = { materialSharedAxisXIn(initialOffsetX = { -(it * initialOffset).toInt() }) }, - popExitTransition = { materialSharedAxisXOut(targetOffsetX = { (it * initialOffset).toInt() }) } + enterTransition = { + slideInHorizontally( + enterTween, + 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) { @@ -157,8 +174,11 @@ fun Navigation( composable(BottomNavDestination.History.route) { HistoryView( + coil = coil, padding = padding, topBarHeightPx = topBarHeightPx, + navigateToDetails = navigateToDetails, + navigateToReader = { navController.navigate(READER_DESTINATION) } ) } @@ -249,6 +269,7 @@ fun Navigation( composable(CATALOG_DESTINATION) { SourcesCatalogView( + coil = coil, navigateBack = navigateBack, ) } @@ -400,7 +421,14 @@ fun Navigation( navController.navigate( LIST_DESTINATION.replace(PROVIDER_ARGUMENT, it.name) ) - } + }, + navigateToReader = { navController.navigate(READER_DESTINATION) } + ) + } + + composable(READER_DESTINATION) { + ReaderView( + navigateBack = navigateBack ) } diff --git a/app/src/main/java/org/xtimms/tokusho/core/cache/PagesCache.kt b/app/src/main/java/org/xtimms/tokusho/core/cache/PagesCache.kt new file mode 100644 index 0000000..e6635d8 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/cache/PagesCache.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/BackgroundProgress.kt b/app/src/main/java/org/xtimms/tokusho/core/components/BackgroundProgress.kt index 6d6c4bc..a115470 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/components/BackgroundProgress.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/components/BackgroundProgress.kt @@ -37,7 +37,7 @@ fun BackgroundProgress( color: Color, ) { - val percentWithNewSpent = 0.3f + val percentWithNewSpent = 0.5f val percentWithNewSpentAnimated = animateFloatAsState( label = "percentWithNewSpentAnimated", diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/ContinueReadingButton.kt b/app/src/main/java/org/xtimms/tokusho/core/components/ContinueReadingButton.kt new file mode 100644 index 0000000..b19ad78 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/ContinueReadingButton.kt @@ -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) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/ListGroupHeader.kt b/app/src/main/java/org/xtimms/tokusho/core/components/ListGroupHeader.kt new file mode 100644 index 0000000..030ebf0 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/ListGroupHeader.kt @@ -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, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/NavigationRail.kt b/app/src/main/java/org/xtimms/tokusho/core/components/NavigationRail.kt new file mode 100644 index 0000000..6007439 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/NavigationRail.kt @@ -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)) } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt b/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt index fb806ee..0f2fe15 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt @@ -22,8 +22,9 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll fun ScaffoldWithTopAppBar( title: String, navigateBack: () -> Unit, - snackbarHost: @Composable (() -> Unit) = {}, - floatingActionButton: @Composable (() -> Unit) = {}, + snackbarHost: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + actions: @Composable (RowScope.() -> Unit) = {}, content: @Composable (PaddingValues) -> Unit ) { val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( @@ -39,6 +40,7 @@ fun ScaffoldWithTopAppBar( DefaultTopAppBar( title = title, scrollBehavior = topAppBarScrollBehavior, + actions = actions, navigateBack = navigateBack ) }, diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/TopAppBar.kt b/app/src/main/java/org/xtimms/tokusho/core/components/TopAppBar.kt index 0d8e2f4..b902d39 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/components/TopAppBar.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/components/TopAppBar.kt @@ -1,7 +1,15 @@ package org.xtimms.tokusho.core.components +import android.graphics.Path +import android.view.animation.PathInterpolator 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.basicMarquee import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -42,15 +50,20 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState import kotlinx.collections.immutable.persistentListOf 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.motion.materialSharedAxisXIn 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.feed.FEED_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(durationMillis = DURATION_ENTER, easing = emphasizeEasing) + val exitTween = tween(durationMillis = DURATION_ENTER, easing = emphasizeEasing) + val fadeTween = tween(durationMillis = DURATION_EXIT) + AnimatedVisibility( visible = isVisible, - enter = materialSharedAxisXIn(initialOffsetX = { -(it * initialOffset).toInt() }), - exit = materialSharedAxisXOut(targetOffsetX = { -(it * initialOffset).toInt() }) + enter = slideInHorizontally( + enterTween, + initialOffsetX = { -(it * initialOffset).toInt() }) + fadeIn(fadeTween), + exit = slideOutHorizontally( + exitTween, + targetOffsetX = { -(it * initialOffset).toInt() }) + fadeOut(fadeTween) ) { Row( modifier = Modifier @@ -158,6 +188,7 @@ fun TopAppBar( @Composable fun DefaultTopAppBar( title: String, + actions: @Composable (RowScope.() -> Unit), scrollBehavior: TopAppBarScrollBehavior? = null, navigateBack: () -> Unit, ) { @@ -166,6 +197,7 @@ fun DefaultTopAppBar( navigationIcon = { BackIconButton(onClick = navigateBack) }, + actions = actions, 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) @Preview @Composable @@ -244,6 +304,14 @@ fun DefaultTopAppBarPreview() { TokushoTheme { DefaultTopAppBar( title = "Tokusho", + actions = { + IconButton(onClick = { /*TODO*/ }) { + Icon( + imageVector = Icons.Filled.Menu, + contentDescription = "Localized description" + ) + } + }, navigateBack = {} ) } diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/effects/ListAnimation.kt b/app/src/main/java/org/xtimms/tokusho/core/components/effects/ListAnimation.kt new file mode 100644 index 0000000..88293f3 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/effects/ListAnimation.kt @@ -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>, + 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 +): State>> { + + val state = remember { mutableStateOf(emptyList>()) } + 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( + val visibility: MutableTransitionState, + 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/MangaWithHistory.kt b/app/src/main/java/org/xtimms/tokusho/core/model/MangaWithHistory.kt new file mode 100644 index 0000000..31b9908 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/model/MangaWithHistory.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/DelayExit.kt b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/DelayExit.kt new file mode 100644 index 0000000..8a5f3fe --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/DelayExit.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/ElementContainer.kt b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/ElementContainer.kt new file mode 100644 index 0000000..9d81365 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/ElementContainer.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/KeyframeBasedMotion.kt b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/KeyframeBasedMotion.kt new file mode 100644 index 0000000..e13e65f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/KeyframeBasedMotion.kt @@ -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? = null + + protected abstract fun getKeyframes(start: Offset, end: Offset): Pair + + 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) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/MaterialArcMotion.kt b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/MaterialArcMotion.kt new file mode 100644 index 0000000..69149e7 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/MaterialArcMotion.kt @@ -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 = + 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() } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/MathUtils.kt b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/MathUtils.kt new file mode 100644 index 0000000..42d8bb3 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/MathUtils.kt @@ -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) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/PathMotion.kt b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/PathMotion.kt new file mode 100644 index 0000000..99f68fb --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/PathMotion.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/ProgressThresholds.kt b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/ProgressThresholds.kt new file mode 100644 index 0000000..bff3b60 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/ProgressThresholds.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/QuadraticBezier.kt b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/QuadraticBezier.kt new file mode 100644 index 0000000..902cbb5 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/QuadraticBezier.kt @@ -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 { + 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 + } + +} diff --git a/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/SharedElement.kt b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/SharedElement.kt new file mode 100644 index 0000000..97bd2fa --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/SharedElement.kt @@ -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 + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/SharedElementsRoot.kt b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/SharedElementsRoot.kt new file mode 100644 index 0000000..6eb68e2 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/SharedElementsRoot.kt @@ -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 { 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 { + 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()) + 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() + 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() \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/SharedElementsTransitionSpec.kt b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/SharedElementsTransitionSpec.kt new file mode 100644 index 0000000..8a58355 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/SharedElementsTransitionSpec.kt @@ -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() diff --git a/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/SharedMaterialContainer.kt b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/SharedMaterialContainer.kt new file mode 100644 index 0000000..14faba1 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/motion/sharedelements/SharedMaterialContainer.kt @@ -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() + + 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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/ImageProxyInterceptor.kt b/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/ImageProxyInterceptor.kt new file mode 100644 index 0000000..e09f2a6 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/network/interceptors/ImageProxyInterceptor.kt @@ -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()) + + 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()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt b/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt index 7668057..9304605 100644 --- a/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt @@ -1,6 +1,7 @@ package org.xtimms.tokusho.core.prefs import android.os.Build +import androidx.annotation.DeprecatedSinceApi import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable 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.ui.monet.PaletteStyle 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" 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 LANGUAGE = "language" const val READING_TIME = "reading_time" +const val GRID_COLUMNS = "grid_columns" const val SYSTEM_DEFAULT = 0 @@ -36,6 +39,8 @@ const val PRE_RELEASE = 1 const val ACRA = "acra" const val LOGGING = "logging" +const val SWIPE_TUTORIAL = "swipe_tutorial" +const val WSRV = "image_optimization" const val SSL_BYPASS = "ssl_bypass" const val NSFW = "nsfw" const val TABS_MANGA_COUNT = "tabs_manga_count" @@ -66,6 +71,7 @@ private val BooleanPreferenceDefaults = mapOf( ) private val IntPreferenceDefaults = mapOf( + GRID_COLUMNS to 3, LANGUAGE to SYSTEM_DEFAULT, PALETTE_STYLE to 0, DARK_THEME_VALUE to DarkThemePreference.FOLLOW_SYSTEM, @@ -110,16 +116,33 @@ object AppSettings { fun isSuggestionsEnabled() = SUGGESTIONS.getBoolean(true) + fun isSwipeTutorialEnabled() = SWIPE_TUTORIAL.getBoolean(true) - private fun getLanguageNumberByCode(languageCode: String): Int = - languageMap.entries.find { it.value == languageCode }?.key ?: SYSTEM_DEFAULT + fun isImagesProxyEnabled() = WSRV.getBoolean(false) + 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 { - return if (Build.VERSION.SDK_INT >= 33) getLanguageNumberByCode( - LocaleListCompat.getAdjustedDefault()[0]?.toLanguageTag().toString() - ) - else LANGUAGE.getInt() + @DeprecatedSinceApi(api = 33) + fun getLocaleFromPreference(): Locale? { + val languageCode = LANGUAGE.getInt() + return LocaleLanguageCodeMap.entries.find { it.value == languageCode }?.key + } + + fun saveLocalePreference(locale: Locale?) { + if (Build.VERSION.SDK_INT >= 33) { + // No op + } else { + LANGUAGE.updateInt(LocaleLanguageCodeMap[locale] ?: SYSTEM_DEFAULT) + } } data class Settings( diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/ExploreRepository.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/ExploreRepository.kt index 6152648..6f09220 100644 --- a/app/src/main/java/org/xtimms/tokusho/data/repository/ExploreRepository.kt +++ b/app/src/main/java/org/xtimms/tokusho/data/repository/ExploreRepository.kt @@ -34,7 +34,7 @@ class ExploreRepository @Inject constructor( list.shuffle() list }.onFailure { - // TODO + it.printStackTrace() }.getOrDefault(emptyList()) } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/HistoryRepository.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/HistoryRepository.kt index 44df9c0..0d45e55 100644 --- a/app/src/main/java/org/xtimms/tokusho/data/repository/HistoryRepository.kt +++ b/app/src/main/java/org/xtimms/tokusho/data/repository/HistoryRepository.kt @@ -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.toMangaTags 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.isNsfw import org.xtimms.tokusho.core.parser.MangaDataRepository @@ -25,6 +26,11 @@ class HistoryRepository @Inject constructor( private val mangaRepository: MangaDataRepository, ) { + suspend fun getList(offset: Int, limit: Int): List { + val entities = db.getHistoryDao().findAll(offset, limit) + return entities.map { it.manga.toManga(it.tags.toMangaTags()) } + } + suspend fun getLastOrNull(): Manga? { val entity = db.getHistoryDao().findAll(0, 1).firstOrNull() ?: return null return entity.manga.toManga(entity.tags.toMangaTags()) @@ -42,10 +48,23 @@ class HistoryRepository @Inject constructor( } } + fun observeAllWithHistory(): Flow> { + return db.getHistoryDao().observeAll().mapItems { + MangaWithHistory( + it.manga.toManga(it.tags.toMangaTags()), + it.history.toMangaHistory(), + ) + } + } + suspend fun getOne(manga: Manga): MangaHistory? { return db.getHistoryDao().find(manga.id)?.recoverIfNeeded(manga)?.toMangaHistory() } + suspend fun delete(manga: Manga) { + db.getHistoryDao().delete(manga.id) + } + fun observeOne(id: Long): Flow { return db.getHistoryDao().observe(id).map { it?.toMangaHistory() diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsInfoHeader.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsInfoHeader.kt index e4ca197..32c70a6 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsInfoHeader.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsInfoHeader.kt @@ -1,6 +1,7 @@ package org.xtimms.tokusho.sections.details import android.net.Uri +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.RepeatMode 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.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi 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.RowScope 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.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize 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.text.selection.SelectionContainer -import androidx.compose.material.ChipDefaults import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.MenuBook import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.Close 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.KeyboardArrowDown import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.LocalLibrary import androidx.compose.material.icons.outlined.Pause 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.Sync import androidx.compose.material.icons.outlined.Upcoming -import androidx.compose.material.icons.outlined.WarningAmber import androidx.compose.material3.AssistChip -import androidx.compose.material3.ChipColors +import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedAssistChip import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon 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.sp import coil.ImageLoader -import coil.compose.AsyncImage import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaState 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.ButtonType 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.parser.favicon.faviconUri 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.secondaryItemAlpha import kotlin.math.roundToInt @@ -147,24 +133,41 @@ fun DetailsInfoBox( onSourceClicked: () -> Unit, ) { Column(modifier = modifier) { - val backdropGradientColors = listOf( - Color.Transparent, - MaterialTheme.colorScheme.background, - ) - AsyncImageImpl( - coil = coil, - model = imageUrl, - contentDescription = null, - contentScale = ContentScale.Crop, + Box( modifier = Modifier - .padding(start = 16.dp, end = 16.dp) - .aspectRatio(1f) - .clickable( - role = Role.Button, - onClick = onCoverClick + .fillMaxWidth(), + contentAlignment = Alignment.BottomEnd, + ) { + AsyncImageImpl( + coil = coil, + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .padding(start = 16.dp, end = 16.dp) + .aspectRatio(1f) + .clickable( + role = Role.Button, + onClick = onCoverClick + ) + .clip(MaterialTheme.shapes.large) + ) + if (isNsfw) { + ElevatedAssistChip( + modifier = Modifier.padding(end = 32.dp, bottom = 8.dp), + onClick = { /*TODO*/ }, + label = { + Text( + text = "18+", + color = MaterialTheme.colorScheme.onErrorContainer + ) + }, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.errorContainer), + colors = AssistChipDefaults.elevatedAssistChipColors() + .copy(containerColor = MaterialTheme.colorScheme.errorContainer) ) - .clip(MaterialTheme.shapes.large) - ) + } + } CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { if (!isTabletUi) { @@ -312,7 +315,8 @@ private fun MangaAndSourceTitlesSmall( } } -@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class, +@OptIn( + ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class ) @Composable @@ -355,10 +359,9 @@ private fun DetailsContentInfo( overflow = TextOverflow.Ellipsis, maxLines = 2 ) + Spacer(modifier = Modifier.height(4.dp)) } - Spacer(modifier = Modifier.height(4.dp)) - if (author.isNotEmpty()) { Row( verticalAlignment = Alignment.CenterVertically, @@ -380,6 +383,39 @@ private fun DetailsContentInfo( 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( modifier = Modifier.padding(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), @@ -442,54 +478,6 @@ private fun DetailsContentInfo( }, 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( modifier = Modifier .height(32.dp) @@ -677,14 +665,19 @@ fun ExpandableMangaDescription( text = stringResource(id = R.string.description), style = MaterialTheme.typography.titleLarge ) - MangaSummary( - expandedDescription = desc, - shrunkDescription = trimmedDescription, - expanded = expanded, - modifier = Modifier - .padding(top = 8.dp) - .clickableNoIndication { onExpanded(!expanded) }, - ) + AnimatedContent( + targetState = desc, + label = "description" + ) { + MangaSummary( + expandedDescription = it, + shrunkDescription = trimmedDescription, + expanded = expanded, + modifier = Modifier + .padding(top = 8.dp) + .clickableNoIndication { onExpanded(!expanded) }, + ) + } if (!tags.isNullOrEmpty()) { Box( modifier = Modifier @@ -849,7 +842,7 @@ fun DetailsInfoBoxPreview() { author = "Kotoyama", artist = null, isNsfw = true, - state = null, + state = MangaState.UPCOMING, source = MangaSource.MANGADEX, chapters = "22", isTabletUi = false, diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt index e8c46df..80ce9e5 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/DetailsView.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow @@ -65,7 +64,8 @@ fun DetailsView( navigateBack: () -> Unit, navigateToFullImage: (String) -> Unit, navigateToDetails: (Long) -> Unit, - navigateToSource: (MangaSource) -> Unit + navigateToSource: (MangaSource) -> Unit, + navigateToReader: () -> Unit ) { val context = LocalContext.current @@ -255,7 +255,7 @@ fun DetailsView( bookmark = false, selected = false, onLongClick = { /*TODO*/ }, - onClick = { /*TODO*/ } + onClick = { navigateToReader() } ) } } diff --git a/app/src/main/java/org/xtimms/tokusho/sections/details/FullImageView.kt b/app/src/main/java/org/xtimms/tokusho/sections/details/FullImageView.kt index ecb5bb5..f233a42 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/details/FullImageView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/details/FullImageView.kt @@ -1,6 +1,5 @@ package org.xtimms.tokusho.sections.details -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable 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.unit.dp import coil.ImageLoader -import coil.compose.AsyncImage import kotlinx.coroutines.launch import org.xtimms.tokusho.core.AsyncImageImpl 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 FULL_POSTER_DESTINATION = "full_poster/$PICTURES_ARGUMENT" -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun FullImageView( coil: ImageLoader, diff --git a/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryItem.kt b/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryItem.kt new file mode 100644 index 0000000..eb6d4c7 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryItem.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryItemModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryItemModel.kt new file mode 100644 index 0000000..9d02e1d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryItemModel.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryView.kt b/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryView.kt index c8ce003..bd7399f 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryView.kt @@ -1,70 +1,241 @@ package org.xtimms.tokusho.sections.history +//noinspection UsingMaterialAndMaterial3Libraries import androidx.compose.animation.core.Animatable 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.IntrinsicSize 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.lazy.LazyColumn 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.outlined.DeleteForever 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.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.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.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.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" +@OptIn(ExperimentalMaterialApi::class) @Composable fun HistoryView( - topBarHeightPx: Float, - padding: PaddingValues, -) { - HistoryViewContent( - topBarHeightPx = topBarHeightPx, - padding = padding - ) -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun HistoryViewContent( + coil: ImageLoader, + viewModel: HistoryViewModel = hiltViewModel(), topBarHeightPx: Float, topBarOffsetY: Animatable = Animatable(0f), padding: PaddingValues, + navigateToDetails: (Long) -> Unit, + navigateToReader: () -> Unit ) { 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().toMutableList() + var readDate: Instant? = null + history.forEach { item -> - Column( - modifier = Modifier - .collapsable( - state = scrollState, - topBarHeightPx = topBarHeightPx, - topBarOffsetY = topBarOffsetY + if (readDate === null || !isSameDay( + item.history.updatedAt.toEpochMilli(), + readDate!!.toEpochMilli() + ) + ) { + readDate = item.history.updatedAt + + list.add( + RowEntity( + type = RowEntityType.Header, + key = "header-${readDate}", + historyItemModel = null, + day = readDate!!, + ) + ) + } + list.add( + RowEntity( + type = RowEntityType.Item, + key = "item-${item.manga.id}", + day = readDate!!, + historyItemModel = item + ) ) - .padding(padding) - ) { - EmptyScreen( - icon = Icons.Outlined.History, - title = R.string.empty_history_title, - description = R.string.empty_history_description - ) + } + updateAnimatedItemsState(newList = list.toList().map { it }) } -} -@Preview -@Composable -fun HistoryPreview() { - TokushoTheme { - Surface { - HistoryViewContent( - padding = PaddingValues(), - topBarHeightPx = 0f, + Box( + Modifier.fillMaxSize() + ) { + Column(Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .collapsable( + state = scrollState, + topBarHeightPx = topBarHeightPx, + topBarOffsetY = topBarOffsetY + ) + .padding(padding) + ) { + animatedItemsIndexed( + state = animatedList.value, + key = { rowItem -> rowItem.key }, + ) { index, item -> + when (item.type) { + RowEntityType.Header -> ListGroupHeader( + calculateTimeAgo(item.day).format( + LocalContext.current.resources + ) + ) + RowEntityType.Item -> SwipeActions( + startActionsConfig = SwipeActionsConfig( + threshold = 0.33f, + background = MaterialTheme.colorScheme.errorContainer, + backgroundActive = MaterialTheme.colorScheme.error, + iconTint = MaterialTheme.colorScheme.onError, + icon = Icons.Outlined.DeleteForever, + stayDismissed = true, + onDismiss = { + viewModel.removeFromHistory(item.historyItemModel!!) + } + ), + endActionsConfig = SwipeActionsConfig( + threshold = 0.33f, + background = MaterialTheme.colorScheme.tertiaryContainer, + backgroundActive = MaterialTheme.colorScheme.tertiary, + iconTint = MaterialTheme.colorScheme.onTertiary, + icon = Icons.Outlined.PlayArrow, + stayDismissed = false, + onDismiss = { + navigateToReader() + } + ), + onTried = { isUserTrySwipe = true }, + showTutorial = false, + ) { state -> + val size = with(LocalDensity.current) { + java.lang.Float.max( + java.lang.Float.min( + 16.dp.toPx(), + abs(state.offset.value) + ), 0f + ).toDp() + } + + val animateCorners by remember { + derivedStateOf { + state.offset.value.absoluteValue > 30 + } + } + val startCorners by animateDpAsState( + targetValue = when { + state.dismissDirection == DismissDirection.StartToEnd && + animateCorners -> 8.dp + + else -> 0.dp + }, label = "startCorners" + ) + val endCorners by animateDpAsState( + targetValue = when { + state.dismissDirection == DismissDirection.EndToStart && + animateCorners -> 8.dp + + else -> 0.dp + }, label = "endCorners" + ) + + Box( + modifier = Modifier.height(IntrinsicSize.Min) + ) { + Surface( + modifier = Modifier + .fillMaxSize() + .padding( + vertical = min( + size / 4f, + 4.dp + ) + ) + .clip(RoundedCornerShape(size)), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape( + topStart = startCorners, + bottomStart = startCorners, + topEnd = endCorners, + bottomEnd = endCorners, + ), + ) { + // nothing + } + Box( + modifier = Modifier.padding(vertical = 4.dp) + ) { + HistoryItem( + coil = coil, + history = item.historyItemModel!!, + onClick = { navigateToDetails(item.historyItemModel!!.manga.id) }, + ) + } + } + } + } + } + } + } + if (history.isEmpty()) { + EmptyScreen( + icon = Icons.Outlined.History, + title = R.string.empty_history_title, + description = R.string.empty_history_description ) } } diff --git a/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryViewModel.kt new file mode 100644 index 0000000..7afef74 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryViewModel.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/history/SwipeActions.kt b/app/src/main/java/org/xtimms/tokusho/sections/history/SwipeActions.kt new file mode 100644 index 0000000..69260f9 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/history/SwipeActions.kt @@ -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().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() + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/reader/ReaderContent.kt b/app/src/main/java/org/xtimms/tokusho/sections/reader/ReaderContent.kt new file mode 100644 index 0000000..0c2a5a3 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/reader/ReaderContent.kt @@ -0,0 +1,8 @@ +package org.xtimms.tokusho.sections.reader + +import org.xtimms.tokusho.sections.reader.pager.ReaderPage + +data class ReaderContent( + val pages: List, + val state: ReaderState? +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/reader/ReaderState.kt b/app/src/main/java/org/xtimms/tokusho/sections/reader/ReaderState.kt new file mode 100644 index 0000000..1366983 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/reader/ReaderState.kt @@ -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, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/reader/ReaderView.kt b/app/src/main/java/org/xtimms/tokusho/sections/reader/ReaderView.kt new file mode 100644 index 0000000..34b23e2 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/reader/ReaderView.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/reader/ReaderViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/reader/ReaderViewModel.kt new file mode 100644 index 0000000..2954a84 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/reader/ReaderViewModel.kt @@ -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) { + + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/reader/domain/ChapterPages.kt b/app/src/main/java/org/xtimms/tokusho/sections/reader/domain/ChapterPages.kt new file mode 100644 index 0000000..3cc6f79 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/reader/domain/ChapterPages.kt @@ -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) : List by pages { + + // map chapterId to index in pages deque + private val indices = LongSparseArray() + + 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): 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): 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 { + 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/reader/domain/ChaptersLoader.kt b/app/src/main/java/org/xtimms/tokusho/sections/reader/domain/ChaptersLoader.kt new file mode 100644 index 0000000..f9e5522 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/reader/domain/ChaptersLoader.kt @@ -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() + 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 { + 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 { + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/reader/domain/PageLoader.kt b/app/src/main/java/org/xtimms/tokusho/sections/reader/domain/PageLoader.kt new file mode 100644 index 0000000..3c55ab6 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/reader/domain/PageLoader.kt @@ -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>() + private val semaphore = Semaphore(3) + private val convertLock = Mutex() + private val prefetchLock = Mutex() + + @Volatile + private var repository: MangaRepository? = null + private val prefetchQueue = LinkedList() + 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) = 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 { + 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 { + 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): 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.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() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/reader/pager/ReaderPage.kt b/app/src/main/java/org/xtimms/tokusho/sections/reader/pager/ReaderPage.kt new file mode 100644 index 0000000..2e00b4f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/reader/pager/ReaderPage.kt @@ -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, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/reader/thumbnails/MangaPageFetcher.kt b/app/src/main/java/org/xtimms/tokusho/sections/reader/thumbnails/MangaPageFetcher.kt new file mode 100644 index 0000000..913c5ab --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/reader/thumbnails/MangaPageFetcher.kt @@ -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 { + + 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() +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt index bdaa30c..084c4e8 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt @@ -1,17 +1,23 @@ package org.xtimms.tokusho.sections.settings.appearance +import androidx.compose.animation.core.RepeatMode 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.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.absoluteOffset import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn 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.Language import androidx.compose.material.icons.outlined.LightMode -import androidx.compose.material.icons.outlined.Numbers import androidx.compose.material.icons.outlined.Timelapse +import androidx.compose.material3.Card import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -42,12 +47,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color 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.semantics.clearAndSetSemantics import androidx.compose.ui.unit.dp -import coil.ImageLoader +import androidx.compose.ui.zIndex import com.google.accompanist.pager.HorizontalPagerIndicator import com.google.android.material.color.DynamicColors 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.STYLE_MONOCHROME 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.sections.stats.Size import org.xtimms.tokusho.ui.harmonize.hct.Hct import org.xtimms.tokusho.ui.monet.LocalTonalPalettes 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.a2 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" val colorList = ((4..10) + (1..3)).map { it * 35.0 }.map { Color(Hct.from(it, 40.0, 40.0).toInt()) } -@OptIn(ExperimentalFoundationApi::class) @Composable fun AppearanceView( navigateBack: () -> Unit, navigateToDarkTheme: () -> Unit, navigateToLanguages: () -> Unit ) { - val image by remember { - mutableIntStateOf( - listOf( - R.drawable.ookami, R.drawable.sample1 - ).random() - ) - } + val localDensity = LocalDensity.current var isReadingTimeEstimationEnabled by remember { mutableStateOf(AppSettings.isReadingTimeEstimationEnabled()) @@ -106,22 +110,79 @@ fun AppearanceView( navigateBack = navigateBack ) { padding -> Column( - Modifier + modifier = Modifier .padding(padding) .verticalScroll(rememberScrollState()), ) { - MangaCard( - modifier = Modifier.padding(18.dp), - thumbnailUrl = image - ) + Card( + modifier = Modifier.padding(18.dp) + ) { + 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 pageCount = colorList.size + 1 - val pagerState = rememberPagerState(initialPage = if (LocalPaletteStyleIndex.current == STYLE_MONOCHROME) pageCount else colorList.indexOf( - Color(LocalSeedColor.current) - ).run { if (this == -1) 0 else this }) { - pageCount + 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 pagerState = + rememberPagerState(initialPage = if (LocalPaletteStyleIndex.current == STYLE_MONOCHROME) pageCount else colorList.indexOf( + Color(LocalSeedColor.current) + ).run { if (this == -1) 0 else this }) { + pageCount + } + HorizontalPager( modifier = Modifier .fillMaxWidth() @@ -187,7 +248,7 @@ fun AppearanceView( PreferenceItem( title = stringResource(id = R.string.language), icon = Icons.Outlined.Language, - description = getLanguageDesc(), + description = Locale.getDefault().toDisplayName(), onClick = { navigateToLanguages() }) PreferenceSubtitle(text = stringResource(id = R.string.details)) PreferenceSwitch( diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/LanguagesView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/LanguagesView.kt index 15bcd51..d6c0550 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/LanguagesView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/LanguagesView.kt @@ -25,9 +25,9 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment 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.unit.dp import androidx.compose.ui.unit.sp -import org.xtimms.tokusho.MainActivity import org.xtimms.tokusho.R import org.xtimms.tokusho.core.components.PreferenceSingleChoiceItem import org.xtimms.tokusho.core.components.PreferencesHintCard import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar 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.ui.theme.TokushoTheme -import org.xtimms.tokusho.utils.system.getLanguageDesc -import org.xtimms.tokusho.utils.system.languageMap +import org.xtimms.tokusho.utils.system.LocaleLanguageCodeMap +import org.xtimms.tokusho.utils.system.setLanguage +import org.xtimms.tokusho.utils.system.toDisplayName +import java.util.Locale const val LANGUAGES_DESTINATION = "languages" @@ -58,7 +56,8 @@ const val LANGUAGES_DESTINATION = "languages" fun LanguagesView( navigateBack: () -> Unit ) { - var language by remember { mutableStateOf(AppSettings.getLanguageNumber()) } + val selectedLocale by remember { mutableStateOf(Locale.getDefault()) } + val scope = rememberCoroutineScope() val context = LocalContext.current val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Intent(android.provider.Settings.ACTION_APP_LOCALE_SETTINGS).apply { @@ -79,29 +78,28 @@ fun LanguagesView( } LanguageViewImpl( navigateBack = navigateBack, - languageMap = languageMap, + localeSet = LocaleLanguageCodeMap.keys, isSystemLocaleSettingsAvailable = isSystemLocaleSettingsAvailable, onNavigateToSystemLocaleSettings = { if (isSystemLocaleSettingsAvailable) { context.startActivity(intent) } }, - selectedLanguage = language, + selectedLocale = selectedLocale, ) { - language = it - AppSettings.encodeInt(LANGUAGE, language) - MainActivity.setLanguage(getLanguageConfiguration()) + AppSettings.saveLocalePreference(it) + setLanguage(it) } } @Composable private fun LanguageViewImpl( navigateBack: () -> Unit = {}, - languageMap: Map, + localeSet: Set, isSystemLocaleSettingsAvailable: Boolean = false, onNavigateToSystemLocaleSettings: () -> Unit, - selectedLanguage: Int, - onLanguageSelected: (Int) -> Unit = {} + selectedLocale: Locale, + onLanguageSelected: (Locale?) -> Unit = {} ) { val uriHandler = LocalUriHandler.current @@ -126,17 +124,17 @@ private fun LanguageViewImpl( item { PreferenceSingleChoiceItem( text = stringResource(R.string.follow_system), - selected = selectedLanguage == SYSTEM_DEFAULT, + selected = !localeSet.contains(selectedLocale), contentPadding = PaddingValues(horizontal = 12.dp, vertical = 18.dp) - ) { onLanguageSelected(SYSTEM_DEFAULT) } + ) { onLanguageSelected(null) } } - for (languageData in languageMap) { + for (locale in localeSet) { item { PreferenceSingleChoiceItem( - text = getLanguageDesc(languageData.key), - selected = selectedLanguage == languageData.key, + text = locale.toDisplayName(), + selected = selectedLocale == locale, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 18.dp) - ) { onLanguageSelected(languageData.key) } + ) { onLanguageSelected(locale) } } } if (isSystemLocaleSettingsAvailable) { @@ -185,19 +183,15 @@ private fun LanguageViewImpl( @Composable private fun LanguagePagePreview() { var language by remember { - mutableIntStateOf(1) - } - val map = buildMap { - repeat(38) { - put(it + 1, "") - } + mutableStateOf(Locale.KOREAN) } + val map = setOf(Locale.forLanguageTag("ru")) TokushoTheme { LanguageViewImpl( - languageMap = map, + localeSet = map, isSystemLocaleSettingsAvailable = true, onNavigateToSystemLocaleSettings = { /*TODO*/ }, - selectedLanguage = language + selectedLocale = language ) { language = it } diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupRestoreView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupRestoreView.kt index c77ef9d..6c4a3b2 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupRestoreView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/BackupRestoreView.kt @@ -43,8 +43,8 @@ import org.xtimms.tokusho.core.components.PreferenceSwitchWithContainer import org.xtimms.tokusho.core.components.PreferencesHintCard import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar 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.tryLaunch import java.io.File import java.io.FileOutputStream @@ -116,13 +116,13 @@ fun BackupRestoreView( ScaffoldWithTopAppBar( title = stringResource(R.string.backup_and_restore), + navigateBack = navigateBack, snackbarHost = { SnackbarHost( modifier = Modifier.systemBarsPadding(), hostState = snackbarHostState ) - }, - navigateBack = navigateBack + } ) { padding -> LazyColumn( modifier = Modifier.padding(padding), diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreItemsView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreItemsView.kt index 7470d69..6fc6749 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreItemsView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreItemsView.kt @@ -9,12 +9,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AccessTime 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.getValue import androidx.compose.runtime.mutableStateOf @@ -25,16 +22,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel 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.core.components.PreferencesHintCard 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.utils.DeviceUtil -import org.xtimms.tokusho.utils.system.suspendToast const val RESTORE_ARGUMENT = "{source}" const val RESTORE_DESTINATION = "restore/?file=${RESTORE_ARGUMENT}" diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreViewModel.kt index 1329497..6dc9b10 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreViewModel.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/backup/RestoreViewModel.kt @@ -15,7 +15,7 @@ import org.xtimms.tokusho.data.repository.backup.BackupZipInput import org.xtimms.tokusho.data.repository.backup.CompositeResult import org.xtimms.tokusho.utils.lang.MutableEventFlow 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.FileNotFoundException import java.util.Date diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/network/NetworkView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/network/NetworkView.kt index b332d6d..3a55385 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/settings/network/NetworkView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/network/NetworkView.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Dns +import androidx.compose.material.icons.outlined.PhotoSizeSelectSmall import androidx.compose.material.icons.outlined.VpnLock import androidx.compose.runtime.Composable 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.prefs.AppSettings import org.xtimms.tokusho.core.prefs.SSL_BYPASS +import org.xtimms.tokusho.core.prefs.WSRV const val NETWORK_DESTINATION = "network" @@ -35,6 +37,10 @@ fun NetworkView( mutableStateOf(AppSettings.isSSLBypassEnabled()) } + var isImageOptimizationEnabled by remember { + mutableStateOf(AppSettings.isImagesProxyEnabled()) + } + ScaffoldWithTopAppBar( title = stringResource(R.string.network), navigateBack = navigateBack @@ -59,6 +65,17 @@ fun NetworkView( 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 { PreferenceSwitch( title = stringResource(id = R.string.ignore_ssl_errors), diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/ShelfSettingsView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/ShelfSettingsView.kt index d2fd8b7..167aca4 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/ShelfSettingsView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/shelf/ShelfSettingsView.kt @@ -1,23 +1,37 @@ 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.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons 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.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.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.ScaffoldWithTopAppBar 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.sections.shelf.ShelfViewModel @@ -38,6 +53,8 @@ fun ShelfSettingsView( navigateToCategories: () -> Unit ) { + var showGridColumnsDialog by remember { mutableStateOf(false) } + val categories by shelfViewModel.categories.collectAsStateWithLifecycle(emptyList()) var isMangaCountInTabsEnabled by remember { @@ -81,6 +98,13 @@ fun ShelfSettingsView( 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 { 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, + ) + } + ) + } + }) } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItem.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItem.kt index 70a0d97..8f5407b 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItem.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItem.kt @@ -12,10 +12,15 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 fun SourceCatalogItem( - source: String, + coil: ImageLoader, + source: MangaSource, modifier: Modifier = Modifier, ) { @@ -32,13 +37,16 @@ fun SourceCatalogItem( ), verticalAlignment = Alignment.CenterVertically, ) { - Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null) + AsyncImageImpl( + coil = coil, + contentDescription = null, + model = source.faviconUri() + ) Text( - text = source, + text = source.title, modifier = Modifier .padding(start = 16.dp), ) } } - } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItemModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItemModel.kt index 277ab21..8a68c6b 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItemModel.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourceCatalogItemModel.kt @@ -5,26 +5,12 @@ import androidx.compose.ui.graphics.vector.ImageVector import org.koitharu.kotatsu.parsers.model.MangaSource import org.xtimms.tokusho.core.model.ListModel -sealed interface SourceCatalogItemModel : ListModel { +data class SourceCatalogItemModel( + val source: MangaSource, + val showSummary: Boolean +) : ListModel { - data class Source( - val source: MangaSource, - val showSummary: Boolean, - ) : SourceCatalogItemModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is Source && other.source == source - } - } - - data class Hint( - val icon: ImageVector, - @StringRes val title: Int, - @StringRes val text: Int, - ) : SourceCatalogItemModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is Hint && other.title == title - } + override fun areItemsTheSame(other: ListModel): Boolean { + return other is SourceCatalogItemModel && other.source == source } } diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogListProducer.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogListProducer.kt index 7d0721b..43ab282 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogListProducer.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogListProducer.kt @@ -69,30 +69,12 @@ class SourcesCatalogListProducer @AssistedInject constructor( "" -> return emptyList() 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, - ) - }, + sources.sortBy { it.title } + return sources.map { + SourceCatalogItemModel( + source = it, + showSummary = query != null, ) - } else { - sources.sortBy { it.title } - sources.map { - SourceCatalogItemModel.Source( - source = it, - showSummary = query != null, - ) - } } } diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogPager.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogPager.kt new file mode 100644 index 0000000..32d20f0 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogPager.kt @@ -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, +) { + 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), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogView.kt index e88f836..a2dc516 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/sources/catalog/SourcesCatalogView.kt @@ -11,15 +11,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.ImageLoader import kotlinx.coroutines.launch import org.xtimms.tokusho.R import org.xtimms.tokusho.core.components.ScaffoldWithClassicTopAppBar const val CATALOG_DESTINATION = "catalog" -@OptIn(ExperimentalFoundationApi::class) @Composable fun SourcesCatalogView( + coil: ImageLoader, sourcesCatalogViewModel: SourcesCatalogViewModel = hiltViewModel(), navigateBack: () -> Unit, ) { @@ -41,6 +42,14 @@ fun SourcesCatalogView( pagerState = pagerState, ) { scope.launch { pagerState.animateScrollToPage(it) } } } + + SourcesCatalogPager( + coil = coil, + state = pagerState, + contentPadding = padding, + searchQuery = null, + getSourcesForPage = { categories } + ) } } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfPager.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfPager.kt index a13e66e..5b5684a 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfPager.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfPager.kt @@ -1,6 +1,5 @@ package org.xtimms.tokusho.sections.shelf -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -17,10 +16,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import coil.ImageLoader import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.prefs.AppSettings import org.xtimms.tokusho.core.screens.EmptyScreen import org.xtimms.tokusho.utils.system.plus -@OptIn(ExperimentalFoundationApi::class) @Composable fun ShelfPager( coil: ImageLoader, @@ -51,7 +50,7 @@ fun ShelfPager( ShelfGrid( coil = coil, items = library, - columns = 2, + columns = AppSettings.getGridColumnsCount().toInt(), contentPadding = contentPadding, selection = listOf(), onClick = navigateToDetails, diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt index 919528e..b1af137 100644 --- a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt @@ -2,7 +2,6 @@ package org.xtimms.tokusho.sections.shelf import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationVector1D -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding @@ -23,8 +22,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.xtimms.tokusho.core.collapsable 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 const val SHELF_DESTINATION = "shelf" @@ -47,11 +44,10 @@ fun ShelfView( topBarHeightPx = topBarHeightPx, padding = padding, navigateToDetails = navigateToDetails, - onRefresh = onRefresh + onRefresh = onRefresh, ) } -@OptIn(ExperimentalFoundationApi::class) @Composable fun ShelfViewContent( coil: ImageLoader, diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/Coroutines.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/Coroutines.kt index 722da55..0be528e 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/lang/Coroutines.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Coroutines.kt @@ -5,15 +5,9 @@ import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.lifecycleScope import dagger.hilt.android.lifecycle.RetainedLifecycle import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.xtimms.tokusho.utils.RetainedLifecycleCoroutineScope @@ -34,4 +28,13 @@ fun Deferred.peek(): T? = if (isCompleted) { }.getOrNull() } else { null +} + +@OptIn(ExperimentalCoroutinesApi::class) +fun Deferred.getCompletionResultOrNull(): Result? = if (isCompleted) { + getCompletionExceptionOrNull()?.let { error -> + Result.failure(error) + } ?: Result.success(getCompleted()) +} else { + null } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/Date.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/Date.kt index 8a7e36a..4fe6628 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/lang/Date.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Date.kt @@ -1,10 +1,170 @@ package org.xtimms.tokusho.utils.lang +import android.content.res.Resources +import org.xtimms.tokusho.R 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 fun Date.toDateTimestampString(dateFormatter: DateFormat): String { val date = dateFormatter.format(this) val time = DateFormat.getTimeInstance(DateFormat.SHORT).format(this) 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 + } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/progress/ProgressDeferred.kt b/app/src/main/java/org/xtimms/tokusho/utils/progress/ProgressDeferred.kt new file mode 100644 index 0000000..c9e3f40 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/progress/ProgressDeferred.kt @@ -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( + private val deferred: Deferred, + private val progress: StateFlow

, +) : Deferred by deferred { + + val progressValue: P + get() = progress.value + + fun progressAsFlow(): Flow

= progress +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt index e343b21..478b97c 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt @@ -60,4 +60,6 @@ fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).bufferedReader().u fun Sequence.filterWith(filter: FileFilter): Sequence = filter { f -> filter.accept(f) } fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() } -fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() } \ No newline at end of file +fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() } + +fun File.isNotEmpty() = length() != 0L \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Http.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Http.kt index b200afc..0d4bed6 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/system/Http.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Http.kt @@ -1,6 +1,11 @@ package org.xtimms.tokusho.utils.system 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 -> c.name(name) @@ -20,4 +25,17 @@ fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c -> if (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()) + } } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Locale.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Locale.kt index 5208d0c..348548e 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/system/Locale.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Locale.kt @@ -1,45 +1,38 @@ package org.xtimms.tokusho.utils.system -import android.os.Build +import androidx.appcompat.app.AppCompatDelegate import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.core.os.LocaleListCompat 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 fun LocaleListCompat.toList(): List = List(size()) { i -> getOrThrow(i) } 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 -fun getLanguageDesc(language: Int = getLanguageNumber()): String { - return stringResource( - when (language) { - ENGLISH -> R.string.la_en_US - RUSSIAN -> R.string.la_ru - else -> R.string.follow_system - } - ) +fun Locale?.toDisplayName(): String = this?.getDisplayName(this) ?: stringResource( + id = R.string.follow_system +) + +fun setLanguage(locale: Locale?) { + val localeList = locale?.let { + LocaleListCompat.create(it) + } ?: LocaleListCompat.getEmptyLocaleList() + AppCompatDelegate.setApplicationLocales(localeList) } // Do not modify private const val ENGLISH = 1 private const val RUSSIAN = 2 +val LocaleLanguageCodeMap = + mapOf( + Locale("en", "US") to ENGLISH, + Locale("ru") to RUSSIAN + ) + // Sorted alphabetically val languageMap: Map = mapOf( RUSSIAN to "ru", diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Uri.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Uri.kt index f55c270..5d9040d 100644 --- a/app/src/main/java/org/xtimms/tokusho/utils/system/Uri.kt +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Uri.kt @@ -1,4 +1,33 @@ 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_ZIP = "file+zip" \ No newline at end of file +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") +} \ No newline at end of file diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties new file mode 100644 index 0000000..d5a3ddc --- /dev/null +++ b/app/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=en-US \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..01f5bc0 --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,96 @@ + + + Настройки + Лента + Поиск + Обзор + История + Полка + Локальное хранилище + Закладки + Рандом + Загрузки + О приложении + Внешний вид + Тема, вид списка, язык + Версия, автоматические обновления + Здесь ничего нет + Упс! + Отправить логи падения + Перезапустить приложение + Поделиться + Следовать системе + Включено + Выключено + Открыть настройки + Автоматические обновления + Автоматически проверять наличие последней версии на Gitea + Версия + Информация скопирована в буфер обмена + Включить автоматическое обновление + Канал обновления + Стабильный + Бета + Проверить наличие обновлений + Нет обновлений + Произошла ошибка при проверке обновлений + Устанавливайте предварительные сборки для предварительного просмотра новых функций и изменений. В этих версиях будет наблюдаться некоторая нестабильность, поэтому, пожалуйста, не стесняйтесь оставлять отзывы, если у вас возникнут какие-либо проблемы, чтобы помочь нам улучшить приложение в будущем. + Язык + Темная тема + Динамические цвета + Примените цвета из обоев к теме приложения + Отмена + Дополнительные настройки + Высококонтрастная темная тема + Системные настройки + Перевести + Помогите перевести это приложение на Hosted Weblate + Обновить + Отклонить + Продолжается + Завершено + Заброшено + На паузе + Скоро выйдет + Неизвестно + Открыть в браузере + Создать файл резервной копии + Резервная копия сохранена + Поддержка бэкапов Kotatsu + Tokusho также может обрабатывать резервные копии Kotatsu + Лицензии с открытым исходным кодом + Нет источников манги + Включите источники манги, чтобы читать мангу онлайн + Каталог + %1$d из %2$d включено + Каталог источников + Доступно: %1$d + Прочитанное вами будет отображаться здесь + Найдите, что почитать в разделе \"Обзор\". + Ничего не найдено + Попробуйте переформулировать запрос + Как-то пустовато здесь. + Управление источниками + Игнорирование ошибок SSL + Это может помочь в некоторых случаях + Показать количество манги в категориях + Очистить все + Описание + Источник включен + В этом разделе нет источников, или все они могли быть уже добавлены. + По вашему запросу не найдено ни одного источника манги + Не прочитано + Рейтинг + Читать + Не в избранных + Манга + Комиксы + Хентай + Другое + Похожая манга + Главы + Произошла ошибка + В избранных + В полке + Добавить в полку + \ No newline at end of file diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml index 64b1623..511237b 100644 --- a/app/src/main/res/values/plurals.xml +++ b/app/src/main/res/values/plurals.xml @@ -16,4 +16,24 @@ %1$d category %1$d categories + + %1$d chapter + %1$d chapters + + + %1$d minute ago + %1$d minutes ago + + + %1$d hour ago + %1$d hours ago + + + %1$d day ago + %1$d days ago + + + %1$d month ago + %1$d months ago + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e5f9081..72f7a57 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - Tokusho + Tokusho Settings Feed Search @@ -35,13 +35,13 @@ Check for updates No new updates An error occurred while checking updates - 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. + 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. Language Dark theme Dynamic color Apply colors from wallpapers to the app theme - English (United States) - Русский + English (United States) + Русский Cancel Additional settings High contrast dark theme