Initial commit

This commit is contained in:
2024-02-03 13:32:24 +03:00
commit 8617f18f90
169 changed files with 10605 additions and 0 deletions

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

106
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,106 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
id("org.jetbrains.kotlin.kapt")
id("com.google.devtools.ksp")
id("dagger.hilt.android.plugin")
}
android {
namespace = "org.xtimms.tokusho"
compileSdk = 34
defaultConfig {
applicationId = "org.xtimms.tokusho"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
javaCompileOptions {
annotationProcessorOptions {
arguments += mapOf(
"room.generateKotlin" to "true"
)
}
}
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
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("androidx.compose:compose-bom:2024.01.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material:material-icons-extended:1.6.0")
implementation("androidx.compose.material3:material3-android:1.2.0-rc01")
implementation("androidx.compose.material3:material3-window-size-class:1.2.0-rc01")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
implementation("androidx.navigation:navigation-compose:2.7.6")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
implementation("androidx.work:work-runtime-ktx:2.9.0")
ksp("androidx.room:room-compiler:2.6.1")
implementation("com.google.android.material:material:1.11.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("androidx.hilt:hilt-work:1.1.0")
kapt("androidx.hilt:hilt-compiler:1.1.0")
implementation("com.github.KotatsuApp:kotatsu-parsers:a8f9423307")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0")
implementation("com.squareup.okio:okio:3.7.0")
implementation("com.tencent:mmkv:1.3.2")
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
implementation("io.coil-kt:coil-compose:2.5.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

View File

@@ -0,0 +1,34 @@
-dontusemixedcaseclassnames
-ignorewarnings
-verbose
-keepattributes *Annotation*
-keepclasseswithmembernames,includedescriptorclasses class * {
native <methods>;
}
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
-keepclassmembers class * implements android.os.Parcelable {
public static final ** CREATOR;
}
-keep class androidx.annotation.Keep
-keep @androidx.annotation.Keep class * {*;}
-keepclasseswithmembers class * {
@androidx.annotation.Keep <methods>;
}
-keepclasseswithmembers class * {
@androidx.annotation.Keep <fields>;
}
-keepclasseswithmembers class * {
@androidx.annotation.Keep <init>(...);
}

3
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,3 @@
-dontobfuscate
-keep,allowoptimization class org.xtimms.**

View File

@@ -0,0 +1,24 @@
package org.xtimms.tokusho
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("org.xtimms.tokusho", appContext.packageName)
}
}

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".App"
android:allowBackup="false"
android:enableOnBackInvokedCallback="true"
android:hardwareAccelerated="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Tokusho"
tools:targetApi="tiramisu">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/Theme.Tokusho">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".crash.CrashActivity"
android:exported="false"
android:process=":error_handler" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="org.xtimms.tokusho.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,103 @@
package org.xtimms.tokusho
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import android.os.StrictMode
import com.google.android.material.color.DynamicColors
import com.tencent.mmkv.MMKV
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.xtimms.tokusho.core.database.MangaDatabase
import org.xtimms.tokusho.core.updates.Updater
import org.xtimms.tokusho.crash.CrashActivity
import org.xtimms.tokusho.crash.GlobalExceptionHandler
import javax.inject.Inject
import javax.inject.Provider
@HiltAndroidApp
class App : Application() {
@Inject
lateinit var database: Provider<MangaDatabase>
override fun onCreate() {
super.onCreate()
MMKV.initialize(this)
context = applicationContext
packageInfo = packageManager.run {
if (Build.VERSION.SDK_INT >= 33) getPackageInfo(
packageName, PackageManager.PackageInfoFlags.of(0)
) else getPackageInfo(packageName, 0)
}
applicationScope = CoroutineScope(SupervisorJob())
DynamicColors.applyToActivitiesIfAvailable(this)
applicationScope.launch((Dispatchers.IO)) {
try {
Updater.deleteOutdatedApk()
} catch (_: Throwable) {
}
}
GlobalExceptionHandler.initialize(applicationContext, CrashActivity::class.java)
}
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
if (BuildConfig.DEBUG) {
enableStrictMode()
}
}
private fun enableStrictMode() {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.build(),
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectAll()
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
.penaltyLog()
.build(),
)
}
companion object {
lateinit var applicationScope: CoroutineScope
lateinit var packageInfo: PackageInfo
fun getVersionReport(): String {
val versionName = packageInfo.versionName
val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode
} else {
packageInfo.versionCode.toLong()
}
val release = if (Build.VERSION.SDK_INT >= 30) {
Build.VERSION.RELEASE_OR_CODENAME
} else {
Build.VERSION.RELEASE
}
return StringBuilder().append("App version: $versionName ($versionCode)\n")
.append("Device information: Android $release (API ${Build.VERSION.SDK_INT})\n")
.append("Supported ABIs: ${Build.SUPPORTED_ABIS.contentToString()}\n").toString()
}
@SuppressLint("StaticFieldLeak")
lateinit var context: Context
}
}

View File

@@ -0,0 +1,41 @@
package org.xtimms.tokusho
import android.os.Build
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import org.xtimms.shiki.ui.theme.SEED
import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.core.prefs.DarkThemePreference
import org.xtimms.tokusho.core.prefs.paletteStyles
import org.xtimms.tokusho.ui.monet.LocalTonalPalettes
import org.xtimms.tokusho.ui.monet.PaletteStyle
import org.xtimms.tokusho.ui.monet.TonalPalettes.Companion.toTonalPalettes
val LocalDarkTheme = compositionLocalOf { DarkThemePreference() }
val LocalSeedColor = compositionLocalOf { SEED }
val LocalDynamicColorSwitch = compositionLocalOf { false }
val LocalPaletteStyleIndex = compositionLocalOf { 0 }
@Composable
fun SettingsProvider(content: @Composable () -> Unit) {
AppSettings.AppSettingsStateFlow.collectAsState().value.run {
CompositionLocalProvider(
LocalDarkTheme provides darkTheme,
LocalSeedColor provides seedColor,
LocalPaletteStyleIndex provides paletteStyleIndex,
LocalTonalPalettes provides if (isDynamicColorEnabled && Build.VERSION.SDK_INT >= 31) dynamicDarkColorScheme(
LocalContext.current
).toTonalPalettes()
else Color(seedColor).toTonalPalettes(
paletteStyles.getOrElse(paletteStyleIndex) { PaletteStyle.TonalSpot }
),
LocalDynamicColorSwitch provides isDynamicColorEnabled,
content = content
)
}
}

View File

@@ -0,0 +1,168 @@
package org.xtimms.tokusho
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.material3.Scaffold
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.graphics.graphicsLayer
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.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.xtimms.tokusho.core.Navigation
import org.xtimms.tokusho.core.components.BottomNavBar
import org.xtimms.tokusho.core.components.TopAppBar
import org.xtimms.tokusho.ui.theme.TokushoTheme
import javax.inject.Inject
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var coil: ImageLoader
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
val windowSizeClass = calculateWindowSizeClass(this)
val isCompactScreen = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact
SettingsProvider {
TokushoTheme(
darkTheme = LocalDarkTheme.current.isDarkTheme(),
isDynamicColorEnabled = LocalDynamicColorSwitch.current,
isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled,
) {
MainView(
coil = coil,
isCompactScreen = isCompactScreen,
navController = navController
)
}
}
}
}
companion object {
private const val TAG = "MainActivity"
fun setLanguage(locale: String) {
Log.d(TAG, "setLanguage: $locale")
val localeListCompat =
if (locale.isEmpty()) LocaleListCompat.getEmptyLocaleList()
else LocaleListCompat.forLanguageTags(locale)
App.applicationScope.launch(Dispatchers.Main) {
AppCompatDelegate.setApplicationLocales(localeListCompat)
}
}
}
}
@Composable
fun MainView(
coil: ImageLoader,
isCompactScreen: Boolean,
navController: NavHostController,
) {
val density = LocalDensity.current
val bottomBarState = remember { mutableStateOf(true) }
var topBarHeightPx by remember { mutableFloatStateOf(0f) }
val topBarOffsetY = remember { Animatable(0f) }
Scaffold(
topBar = {
if (isCompactScreen) {
TopAppBar(
navController = navController,
modifier = Modifier
.padding(0.dp, 8.dp)
.graphicsLayer {
translationY = topBarOffsetY.value
}
)
}
},
bottomBar = {
if (isCompactScreen) {
BottomNavBar(
navController = navController,
bottomBarState = bottomBarState,
)
}
},
contentWindowInsets = WindowInsets.systemBars
.only(WindowInsetsSides.Horizontal)
) { padding ->
if (!isCompactScreen) {
val systemBarsPadding = WindowInsets.systemBars.asPaddingValues()
Row(
modifier = Modifier.padding(padding)
) {
Navigation(
coil = coil,
navController = navController,
isCompactScreen = false,
modifier = Modifier,
padding = PaddingValues(
start = padding.calculateStartPadding(LocalLayoutDirection.current),
top = systemBarsPadding.calculateTopPadding(),
end = padding.calculateEndPadding(LocalLayoutDirection.current),
bottom = systemBarsPadding.calculateBottomPadding()
),
topBarHeightPx = topBarHeightPx,
topBarOffsetY = topBarOffsetY
)
}
} else {
LaunchedEffect(padding) {
topBarHeightPx = density.run { padding.calculateTopPadding().toPx() }
}
Navigation(
coil = coil,
navController = navController,
isCompactScreen = true,
modifier = Modifier.padding(
start = padding.calculateStartPadding(LocalLayoutDirection.current),
end = padding.calculateEndPadding(LocalLayoutDirection.current),
),
padding = padding,
topBarHeightPx = topBarHeightPx,
topBarOffsetY = topBarOffsetY
)
}
}
}

View File

@@ -0,0 +1,103 @@
package org.xtimms.tokusho
import android.app.Application
import android.content.Context
import android.text.Html
import coil.ComponentRegistry
import coil.ImageLoader
import coil.disk.DiskCache
import coil.util.DebugLogger
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.Dispatchers
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.xtimms.tokusho.core.cache.CacheDir
import org.xtimms.tokusho.core.cache.ContentCache
import org.xtimms.tokusho.core.cache.MemoryContentCache
import org.xtimms.tokusho.core.cache.StubContentCache
import org.xtimms.tokusho.core.database.MangaDatabase
import org.xtimms.tokusho.core.network.MangaHttpClient
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.utils.CoilImageGetter
import org.xtimms.tokusho.utils.system.connectivityManager
import org.xtimms.tokusho.utils.system.isLowRamDevice
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
interface TokushoModule {
@Binds
fun bindMangaLoaderContext(mangaLoaderContextImpl: MangaLoaderContextImpl): MangaLoaderContext
@Binds
fun bindImageGetter(coilImageGetter: CoilImageGetter): Html.ImageGetter
companion object {
@Provides
@Singleton
fun provideNetworkState(
@ApplicationContext context: Context
) = NetworkState(context.connectivityManager)
@Provides
@Singleton
fun provideMangaDatabase(
@ApplicationContext context: Context,
): MangaDatabase {
return MangaDatabase(context)
}
@Provides
@Singleton
fun provideCoil(
@ApplicationContext context: Context,
@MangaHttpClient okHttpClient: OkHttpClient,
mangaRepositoryFactory: MangaRepository.Factory,
): ImageLoader {
val diskCacheFactory = {
val rootDir = context.externalCacheDir ?: context.cacheDir
DiskCache.Builder()
.directory(rootDir.resolve(CacheDir.THUMBS.dir))
.build()
}
return ImageLoader.Builder(context)
.crossfade(500)
.okHttpClient(okHttpClient.newBuilder().cache(null).build())
.interceptorDispatcher(Dispatchers.Default)
.fetcherDispatcher(Dispatchers.IO)
.decoderDispatcher(Dispatchers.Default)
.transformationDispatcher(Dispatchers.Default)
.diskCache(diskCacheFactory)
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
.components(
ComponentRegistry.Builder()
.add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory))
.build(),
).build()
}
@Provides
@Singleton
fun provideContentCache(
application: Application,
): ContentCache {
return if (application.isLowRamDevice()) {
StubContentCache()
} else {
MemoryContentCache(application)
}
}
}
}

View File

@@ -0,0 +1,51 @@
package org.xtimms.tokusho.core
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import org.xtimms.tokusho.R
@Composable
fun AsyncImageImpl(
coil: ImageLoader,
model: Any? = null,
contentDescription: String?,
modifier: Modifier = Modifier,
transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform,
onState: ((AsyncImagePainter.State) -> Unit)? = null,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Crop,
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
isPreview: Boolean = false,
) {
if (isPreview) Image(
painter = painterResource(R.drawable.sample),
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
colorFilter = colorFilter,
)
else AsyncImage(
imageLoader = coil,
model = model?.takeUnless { it == "" },
contentDescription = contentDescription,
modifier = modifier,
transform = transform,
onState = onState,
alignment = alignment,
contentScale = contentScale,
colorFilter = colorFilter,
filterQuality = filterQuality
)
}

View File

@@ -0,0 +1,72 @@
package org.xtimms.tokusho.core
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.LocalLibrary
import androidx.compose.material.icons.outlined.Explore
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.LocalLibrary
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import org.xtimms.tokusho.R
import org.xtimms.tokusho.sections.explore.EXPLORE_DESTINATION
import org.xtimms.tokusho.sections.history.HISTORY_DESTINATION
import org.xtimms.tokusho.sections.shelf.SHELF_DESTINATION
sealed class BottomNavDestination(
val value: String,
val route: String,
@StringRes val title: Int,
val icon: ImageVector,
val iconSelected: ImageVector,
) {
data object Shelf : BottomNavDestination(
value = "shelf",
route = SHELF_DESTINATION,
title = R.string.nav_shelf,
icon = Icons.Outlined.LocalLibrary,
iconSelected = Icons.Filled.LocalLibrary
)
data object History : BottomNavDestination(
value = "history",
route = HISTORY_DESTINATION,
title = R.string.nav_history,
icon = Icons.Outlined.History,
iconSelected = Icons.Filled.History
)
data object Explore : BottomNavDestination(
value = "explore",
route = EXPLORE_DESTINATION,
title = R.string.nav_explore,
icon = Icons.Outlined.Explore,
iconSelected = Icons.Filled.Explore
)
companion object {
val values = listOf(Shelf, History, Explore)
val railValues = listOf(Shelf, History, Explore)
val routes = values.map { it.route }
fun String.toBottomDestinationIndex() = when (this) {
Shelf.value -> 0
History.value -> 1
Explore.value -> 2
else -> null
}
@Composable
fun BottomNavDestination.Icon(selected: Boolean) {
androidx.compose.material3.Icon(
imageVector = if (selected) iconSelected else icon,
contentDescription = stringResource(title)
)
}
}
}

View File

@@ -0,0 +1,63 @@
package org.xtimms.tokusho.core
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import kotlinx.coroutines.launch
import kotlin.math.abs
fun Modifier.collapsable(
state: ScrollableState,
topBarHeightPx: Float,
topBarOffsetY: Animatable<Float, AnimationVector1D>,
) = composed {
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = state.isScrollInProgress) {
if (!state.isScrollInProgress && topBarOffsetY.value != 0f && topBarOffsetY.value != -topBarHeightPx) {
val half = topBarHeightPx / 2
val oldOffsetY = topBarOffsetY.value
val targetOffsetY = when {
abs(topBarOffsetY.value) >= half -> -topBarHeightPx
else -> 0f
}
launch {
state.animateScrollBy(oldOffsetY - targetOffsetY)
}
launch {
topBarOffsetY.animateTo(targetOffsetY)
}
}
}
nestedScroll(
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
scope.launch {
if (state.canScrollForward) {
topBarOffsetY.snapTo(
targetValue = (topBarOffsetY.value + available.y).coerceIn(
minimumValue = -topBarHeightPx,
maximumValue = 0f,
)
)
}
}
return Offset.Zero
}
}
)
}

View File

@@ -0,0 +1,174 @@
package org.xtimms.tokusho.core
import android.graphics.Path
import android.view.animation.PathInterpolator
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import coil.ImageLoader
import org.xtimms.tokusho.core.model.ShelfCategory
import org.xtimms.tokusho.core.motion.materialSharedAxisXIn
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
import org.xtimms.tokusho.sections.explore.ExploreView
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.search.SEARCH_DESTINATION
import org.xtimms.tokusho.sections.search.SearchHostView
import org.xtimms.tokusho.sections.settings.SETTINGS_DESTINATION
import org.xtimms.tokusho.sections.settings.SettingsView
import org.xtimms.tokusho.sections.settings.about.ABOUT_DESTINATION
import org.xtimms.tokusho.sections.settings.about.AboutView
import org.xtimms.tokusho.sections.settings.about.UPDATES_DESTINATION
import org.xtimms.tokusho.sections.settings.about.UpdateView
import org.xtimms.tokusho.sections.settings.appearance.APPEARANCE_DESTINATION
import org.xtimms.tokusho.sections.settings.appearance.AppearanceView
import org.xtimms.tokusho.sections.settings.appearance.DARK_THEME_DESTINATION
import org.xtimms.tokusho.sections.settings.appearance.DarkThemeView
import org.xtimms.tokusho.sections.settings.appearance.LANGUAGES_DESTINATION
import org.xtimms.tokusho.sections.settings.appearance.LanguagesView
import org.xtimms.tokusho.sections.shelf.ShelfMap
import org.xtimms.tokusho.sections.shelf.ShelfView
const val DURATION_ENTER = 400
const val DURATION_EXIT = 200
const val initialOffset = 0.10f
fun PathInterpolator.toEasing(): Easing {
return Easing { f -> this.getInterpolation(f) }
}
@Composable
fun Navigation(
coil: ImageLoader,
navController: NavHostController,
isCompactScreen: Boolean,
modifier: Modifier,
padding: PaddingValues,
topBarHeightPx: Float,
topBarOffsetY: Animatable<Float, AnimationVector1D>,
) {
val navigateBack: () -> Unit = { navController.popBackStack() }
val path = Path().apply {
moveTo(0f, 0f)
cubicTo(0.05F, 0F, 0.133333F, 0.06F, 0.166666F, 0.4F)
cubicTo(0.208333F, 0.82F, 0.25F, 1F, 1F, 1F)
}
val emphasizePathInterpolator = PathInterpolator(path)
val emphasizeEasing = emphasizePathInterpolator.toEasing()
val enterTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
val exitTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
val fadeTween = tween<Float>(durationMillis = DURATION_EXIT)
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() }) }
) {
composable(BottomNavDestination.Shelf.route) {
val library: ShelfMap = emptyMap()
ShelfView(
categories = listOf(ShelfCategory(1, "Test 1", 1L, 1L), ShelfCategory(2, "Test 2", 2L, 2L)),
currentPage = { 0 },
showPageTabs = true,
getNumberOfMangaForCategory = { 2 },
getLibraryForPage = { library.values.toTypedArray().getOrNull(0).orEmpty() },
padding = padding,
topBarHeightPx = topBarHeightPx,
)
}
composable(BottomNavDestination.History.route) {
HistoryView(
padding = padding,
topBarHeightPx = topBarHeightPx,
)
}
composable(BottomNavDestination.Explore.route) {
ExploreView(
coil = coil,
navController = navController,
padding = padding,
topBarHeightPx = topBarHeightPx,
topBarOffsetY = topBarOffsetY
)
}
composable(SEARCH_DESTINATION) {
SearchHostView(
isCompactScreen = isCompactScreen,
padding = if (isCompactScreen) PaddingValues() else padding,
navigateBack = navigateBack,
)
}
composable(SETTINGS_DESTINATION) {
SettingsView(
navigateBack = navigateBack,
navigateToAppearance = { navController.navigate(APPEARANCE_DESTINATION) },
navigateToAbout = { navController.navigate(ABOUT_DESTINATION) }
)
}
composable(APPEARANCE_DESTINATION) {
AppearanceView(
coil = coil,
navigateBack = navigateBack,
navigateToDarkTheme = { navController.navigate(DARK_THEME_DESTINATION) },
navigateToLanguages = { navController.navigate(LANGUAGES_DESTINATION) }
)
}
composable(DARK_THEME_DESTINATION) {
DarkThemeView(
navigateBack = navigateBack
)
}
composable(LANGUAGES_DESTINATION) {
LanguagesView(
navigateBack = navigateBack
)
}
composable(LIST_DESTINATION) {
MangaListView(
sourceName = "Source",
navigateBack = navigateBack,
)
}
composable(ABOUT_DESTINATION) {
AboutView(
navigateBack = navigateBack,
navigateToUpdatePage = { navController.navigate(UPDATES_DESTINATION) }
)
}
composable(UPDATES_DESTINATION) {
UpdateView(
navigateBack = navigateBack,
)
}
}
}

View File

@@ -0,0 +1,6 @@
package org.xtimms.tokusho.core.base.event
interface UiEvent {
fun showMessage(message: String?)
fun onMessageDisplayed()
}

View File

@@ -0,0 +1,19 @@
package org.xtimms.tokusho.core.base.state
abstract class UiState {
abstract val isLoading: Boolean
abstract val message: String?
// These methods are required because we can't have an abstract data class
// so we need to manually implement the copy() method
/**
* copy(isLoading = value)
*/
abstract fun setLoading(value: Boolean): UiState
/**
* copy(message = value)
*/
abstract fun setMessage(value: String?): UiState
}

View File

@@ -0,0 +1,35 @@
package org.xtimms.tokusho.core.base.viewmodel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import org.xtimms.tokusho.core.base.event.UiEvent
import org.xtimms.tokusho.core.base.state.UiState
abstract class BaseViewModel<S : UiState> : ViewModel(), UiEvent {
protected abstract val mutableUiState: MutableStateFlow<S>
val uiState: StateFlow<S> by lazy { mutableUiState.asStateFlow() }
@Suppress("UNCHECKED_CAST")
fun setLoading(value: Boolean) {
mutableUiState.update { it.setLoading(value) as S }
}
@Suppress("UNCHECKED_CAST")
override fun showMessage(message: String?) {
mutableUiState.update { it.setMessage(message ?: GENERIC_ERROR) as S }
}
@Suppress("UNCHECKED_CAST")
override fun onMessageDisplayed() {
mutableUiState.update { it.setMessage(null) as S }
}
companion object {
private const val GENERIC_ERROR = "Generic Error"
const val FLOW_TIMEOUT = 5_000L
}
}

View File

@@ -0,0 +1,8 @@
package org.xtimms.tokusho.core.cache
enum class CacheDir(val dir: String) {
THUMBS("image_cache"),
FAVICONS("favicons"),
PAGES("pages");
}

View File

@@ -0,0 +1,27 @@
package org.xtimms.tokusho.core.cache
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
interface ContentCache {
val isCachingEnabled: Boolean
suspend fun getDetails(source: MangaSource, url: String): Manga?
fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>)
suspend fun getPages(source: MangaSource, url: String): List<MangaPage>?
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>)
suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>?
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>)
data class Key(
val source: MangaSource,
val url: String,
)
}

View File

@@ -0,0 +1,33 @@
package org.xtimms.tokusho.core.cache
import androidx.collection.LruCache
import java.util.concurrent.TimeUnit
class ExpiringLruCache<T>(
val maxSize: Int,
private val lifetime: Long,
private val timeUnit: TimeUnit,
) {
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
operator fun get(key: ContentCache.Key): T? {
val value = cache[key] ?: return null
if (value.isExpired) {
cache.remove(key)
}
return value.get()
}
operator fun set(key: ContentCache.Key, value: T) {
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
}
fun clear() {
cache.evictAll()
}
fun trimToSize(size: Int) {
cache.trimToSize(size)
}
}

View File

@@ -0,0 +1,34 @@
package org.xtimms.tokusho.core.cache
import android.os.SystemClock
import java.util.concurrent.TimeUnit
class ExpiringValue<T>(
private val value: T,
lifetime: Long,
timeUnit: TimeUnit,
) {
private val expiresAt = SystemClock.elapsedRealtime() + timeUnit.toMillis(lifetime)
val isExpired: Boolean
get() = SystemClock.elapsedRealtime() >= expiresAt
fun get(): T? = if (isExpired) null else value
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ExpiringValue<*>
if (value != other.value) return false
return expiresAt == other.expiresAt
}
override fun hashCode(): Int {
var result = value?.hashCode() ?: 0
result = 31 * result + expiresAt.hashCode()
return result
}
}

View File

@@ -0,0 +1,70 @@
package org.xtimms.tokusho.core.cache
import android.app.Application
import android.content.ComponentCallbacks2
import android.content.res.Configuration
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.concurrent.TimeUnit
class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 {
init {
application.registerComponentCallbacks(this)
}
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(4, 5, TimeUnit.MINUTES)
private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(4, 10, TimeUnit.MINUTES)
private val relatedMangaCache = ExpiringLruCache<SafeDeferred<List<Manga>>>(4, 10, TimeUnit.MINUTES)
override val isCachingEnabled: Boolean = true
override suspend fun getDetails(source: MangaSource, url: String): Manga? {
return detailsCache[ContentCache.Key(source, url)]?.awaitOrNull()
}
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
detailsCache[ContentCache.Key(source, url)] = details
}
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
return pagesCache[ContentCache.Key(source, url)]?.awaitOrNull()
}
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
pagesCache[ContentCache.Key(source, url)] = pages
}
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
return relatedMangaCache[ContentCache.Key(source, url)]?.awaitOrNull()
}
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
relatedMangaCache[ContentCache.Key(source, url)] = related
}
override fun onConfigurationChanged(newConfig: Configuration) = Unit
override fun onLowMemory() = Unit
override fun onTrimMemory(level: Int) {
trimCache(detailsCache, level)
trimCache(pagesCache, level)
trimCache(relatedMangaCache, level)
}
private fun trimCache(cache: ExpiringLruCache<*>, level: Int) {
when (level) {
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL,
ComponentCallbacks2.TRIM_MEMORY_COMPLETE,
ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.clear()
ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN,
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW,
ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> cache.trimToSize(1)
else -> cache.trimToSize(cache.maxSize / 2)
}
}
}

View File

@@ -0,0 +1,20 @@
package org.xtimms.tokusho.core.cache
import kotlinx.coroutines.Deferred
class SafeDeferred<T>(
private val delegate: Deferred<Result<T>>,
) {
suspend fun await(): T {
return delegate.await().getOrThrow()
}
suspend fun awaitOrNull(): T? {
return delegate.await().getOrNull()
}
fun cancel() {
delegate.cancel()
}
}

View File

@@ -0,0 +1,22 @@
package org.xtimms.tokusho.core.cache
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
class StubContentCache : ContentCache {
override val isCachingEnabled: Boolean = false
override suspend fun getDetails(source: MangaSource, url: String): Manga? = null
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) = Unit
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? = null
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) = Unit
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? = null
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) = Unit
}

View File

@@ -0,0 +1,40 @@
package org.xtimms.tokusho.core.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun ActionButton(
title: String,
icon: ImageVector,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TextButton(
modifier = modifier,
onClick = onClick,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = icon,
contentDescription = null,
)
Text(
text = title,
textAlign = TextAlign.Center,
)
}
}
}

View File

@@ -0,0 +1,66 @@
package org.xtimms.tokusho.core.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.explore.EXPLORE_DESTINATION
import org.xtimms.tokusho.sections.history.HISTORY_DESTINATION
import org.xtimms.tokusho.sections.shelf.SHELF_DESTINATION
@Composable
fun BottomNavBar(
navController: NavController,
bottomBarState: State<Boolean>,
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val isVisible by remember {
derivedStateOf {
when (navBackStackEntry?.destination?.route) {
SHELF_DESTINATION, HISTORY_DESTINATION, EXPLORE_DESTINATION, null -> bottomBarState.value
else -> false
}
}
}
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it })
) {
NavigationBar {
BottomNavDestination.values.forEachIndexed { _, dest ->
val isSelected = navBackStackEntry?.destination?.route == dest.route
NavigationBarItem(
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)) }
)
}
}
}
}

View File

@@ -0,0 +1,63 @@
package org.xtimms.tokusho.core.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExploreButton(
text: String,
icon: ImageVector,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Card(
onClick = onClick,
modifier = modifier.padding(start = 8.dp, end = 8.dp),
shape = RoundedCornerShape(50),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)
)
) {
Row(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.height(40.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = text,
modifier = Modifier
.padding(end = 16.dp)
.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = text,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 14.sp,
overflow = TextOverflow.Ellipsis,
maxLines = 2,
lineHeight = 15.sp
)
}
}
}

View File

@@ -0,0 +1,19 @@
package org.xtimms.tokusho.core.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
@Composable
fun BackIconButton(
onClick: () -> Unit
) {
IconButton(onClick = onClick) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "arrow_back"
)
}
}

View File

@@ -0,0 +1,46 @@
package org.xtimms.tokusho.core.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
@Composable
fun Pill(
text: String,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.background,
contentColor: Color = MaterialTheme.colorScheme.onBackground,
elevation: Dp = 1.dp,
fontSize: TextUnit = LocalTextStyle.current.fontSize,
) {
Surface(
modifier = modifier
.padding(start = 4.dp),
shape = MaterialTheme.shapes.extraLarge,
color = color,
contentColor = contentColor,
tonalElevation = elevation,
) {
Box(
modifier = Modifier
.padding(6.dp, 1.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
fontSize = fontSize,
maxLines = 1,
)
}
}
}

View File

@@ -0,0 +1,602 @@
package org.xtimms.tokusho.core.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.TipsAndUpdates
import androidx.compose.material.icons.outlined.Call
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.ToggleOn
import androidx.compose.material.icons.outlined.Translate
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
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.shiki.ui.theme.FixedAccentColors
import org.xtimms.tokusho.R
import org.xtimms.tokusho.ui.monet.LocalTonalPalettes
import org.xtimms.tokusho.ui.monet.TonalPalettes.Companion.toTonalPalettes
import org.xtimms.tokusho.ui.theme.PreviewThemeLight
import org.xtimms.tokusho.ui.theme.applyOpacity
import org.xtimms.tokusho.ui.theme.preferenceTitle
private const val horizontal = 8
private const val vertical = 16
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PreferenceItem(
title: String,
description: String? = null,
icon: Any? = null,
enabled: Boolean = true,
onLongClickLabel: String? = null,
onLongClick: (() -> Unit)? = null,
onClickLabel: String? = null,
leadingIcon: (@Composable () -> Unit)? = null,
trailingIcon: (@Composable () -> Unit)? = null,
onClick: () -> Unit = {},
) {
Surface(
modifier = Modifier.combinedClickable(
onClick = onClick,
onClickLabel = onClickLabel,
enabled = enabled,
onLongClickLabel = onLongClickLabel,
onLongClick = onLongClick
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal.dp, vertical.dp),
verticalAlignment = Alignment.CenterVertically,
) {
leadingIcon?.invoke()
when (icon) {
is ImageVector -> {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier
.padding(start = 8.dp, end = 16.dp)
.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(enabled)
)
}
is Painter -> {
Icon(
painter = icon,
contentDescription = null,
modifier = Modifier
.padding(start = 8.dp, end = 16.dp)
.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(enabled)
)
}
}
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp)
.padding(end = 8.dp)
) {
PreferenceItemTitle(text = title, enabled = enabled)
if (!description.isNullOrEmpty()) PreferenceItemDescription(
text = description,
enabled = enabled
)
}
trailingIcon?.let {
VerticalDivider(
modifier = Modifier
.height(32.dp)
.padding(horizontal = 8.dp)
.align(Alignment.CenterVertically),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
thickness = 1.dp
)
trailingIcon.invoke()
}
}
}
}
@Composable
internal fun PreferenceItemTitle(
modifier: Modifier = Modifier,
text: String,
maxLines: Int = 2,
style: TextStyle = preferenceTitle,
enabled: Boolean,
color: Color = MaterialTheme.colorScheme.onBackground,
overflow: TextOverflow = TextOverflow.Ellipsis
) {
Text(
modifier = modifier,
text = text,
maxLines = maxLines,
style = style,
color = color.applyOpacity(enabled),
overflow = overflow
)
}
@Composable
internal fun PreferenceItemDescription(
modifier: Modifier = Modifier,
text: String,
maxLines: Int = Int.MAX_VALUE,
style: TextStyle = MaterialTheme.typography.bodyMedium,
enabled: Boolean,
color: Color = MaterialTheme.colorScheme.onSurfaceVariant,
overflow: TextOverflow = TextOverflow.Ellipsis
) {
Text(
modifier = modifier.padding(top = 2.dp),
text = text,
maxLines = maxLines,
style = style,
color = color.applyOpacity(enabled),
overflow = overflow
)
}
@Composable
fun PreferenceSwitchWithDivider(
title: String,
description: String? = null,
icon: ImageVector? = null,
enabled: Boolean = true,
isSwitchEnabled: Boolean = enabled,
isChecked: Boolean = true,
checkedIcon: ImageVector = Icons.Outlined.Check,
onClick: (() -> Unit) = {},
onChecked: () -> Unit = {}
) {
val thumbContent: (@Composable () -> Unit)? = if (isChecked) {
{
Icon(
imageVector = checkedIcon,
contentDescription = null,
modifier = Modifier.size(SwitchDefaults.IconSize),
)
}
} else {
null
}
Surface(
modifier = Modifier.clickable(
enabled = enabled,
onClick = onClick,
onClickLabel = stringResource(id = R.string.open_settings)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal.dp, vertical.dp)
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically,
) {
icon?.let {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier
.padding(start = 8.dp, end = 16.dp)
.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(enabled)
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp)
) {
PreferenceItemTitle(text = title, enabled = enabled)
if (!description.isNullOrEmpty()) PreferenceItemDescription(
text = description,
enabled = enabled
)
}
VerticalDivider(
modifier = Modifier
.height(32.dp)
.padding(horizontal = 8.dp)
.width(1f.dp)
.align(Alignment.CenterVertically),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
)
Switch(
checked = isChecked,
onCheckedChange = { onChecked() },
modifier = Modifier
.padding(horizontal = 6.dp)
.semantics {
contentDescription = title
},
enabled = isSwitchEnabled,
thumbContent = thumbContent
)
}
}
}
@Composable
fun PreferenceSwitchWithContainer(
title: String,
icon: ImageVector? = null,
isChecked: Boolean,
onClick: () -> Unit,
) {
val thumbContent: (@Composable () -> Unit)? = if (isChecked) {
{
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = null,
modifier = Modifier.size(SwitchDefaults.IconSize),
)
}
} else {
null
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.clip(MaterialTheme.shapes.extraLarge)
.background(
if (isChecked) FixedAccentColors.primaryFixed else MaterialTheme.colorScheme.outline
)
.toggleable(value = isChecked) { onClick() }
.padding(horizontal = 16.dp, vertical = 20.dp),
verticalAlignment = Alignment.CenterVertically,
) {
icon?.let {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier
.padding(start = 8.dp, end = 16.dp)
.size(24.dp),
tint = if (isChecked) FixedAccentColors.onPrimaryFixed else MaterialTheme.colorScheme.surface
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(start = if (icon == null) 12.dp else 0.dp, end = 12.dp)
) {
with(MaterialTheme) {
Text(
text = title,
maxLines = 2,
style = preferenceTitle,
color = if (isChecked) FixedAccentColors.onPrimaryFixed else colorScheme.surface
)
}
}
Switch(
checked = isChecked,
onCheckedChange = null,
modifier = Modifier.padding(start = 12.dp, end = 6.dp),
thumbContent = thumbContent,
colors = SwitchDefaults.colors(
checkedIconColor = FixedAccentColors.onPrimaryFixed,
checkedThumbColor = FixedAccentColors.primaryFixed,
checkedTrackColor = FixedAccentColors.onPrimaryFixedVariant,
uncheckedBorderColor = Color.Transparent
)
)
}
}
@Composable
fun PreferenceSubtitle(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(start = 18.dp, top = 24.dp, bottom = 12.dp),
text: String,
color: Color = MaterialTheme.colorScheme.primary,
) {
Text(
text = text,
modifier = modifier
.fillMaxWidth()
.padding(contentPadding),
color = color,
style = MaterialTheme.typography.labelLarge
)
}
@Composable
fun PreferenceSingleChoiceItem(
modifier: Modifier = Modifier,
text: String,
selected: Boolean,
contentPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 18.dp),
onClick: () -> Unit
) {
Surface(
modifier = Modifier.selectable(
selected = selected, onClick = onClick
)
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(contentPadding),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier
.weight(1f)
.padding(start = 10.dp)
) {
Text(
text = text,
maxLines = 1,
style = MaterialTheme.typography.titleLarge.copy(fontSize = 20.sp),
color = MaterialTheme.colorScheme.onSurface,
overflow = TextOverflow.Ellipsis
)
}
RadioButton(
selected = selected,
onClick = onClick,
modifier = Modifier
.padding()
.clearAndSetSemantics { },
)
}
}
}
@Composable
fun PreferenceInfo(
modifier: Modifier = Modifier,
text: String,
icon: ImageVector = Icons.Outlined.Info,
applyPaddings: Boolean = true
) {
Row(modifier = modifier
.fillMaxWidth()
.run {
if (applyPaddings) padding(horizontal = 16.dp, vertical = 16.dp)
else this
}) {
Icon(
modifier = Modifier.padding(), imageVector = icon, contentDescription = null
)
Text(
modifier = Modifier
.padding(start = 16.dp)
.align(Alignment.CenterVertically),
text = text,
style = MaterialTheme.typography.bodyMedium
)
}
}
@Composable
fun PreferenceSwitch(
title: String,
description: String? = null,
icon: ImageVector? = null,
enabled: Boolean = true,
isChecked: Boolean = true,
checkedIcon: ImageVector = Icons.Outlined.Check,
onClick: (() -> Unit) = {},
) {
val thumbContent: (@Composable () -> Unit)? = if (isChecked) {
{
Icon(
imageVector = checkedIcon,
contentDescription = null,
modifier = Modifier.size(SwitchDefaults.IconSize),
)
}
} else {
null
}
Surface(
modifier = Modifier.toggleable(value = isChecked,
enabled = enabled,
onValueChange = { onClick() })
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal.dp, vertical.dp)
.padding(start = if (icon == null) 12.dp else 0.dp),
verticalAlignment = Alignment.CenterVertically,
) {
icon?.let {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier
.padding(start = 8.dp, end = 16.dp)
.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(enabled)
)
}
Column(
modifier = Modifier.weight(1f)
.padding(horizontal = 16.dp)
) {
PreferenceItemTitle(
text = title, enabled = enabled
)
if (!description.isNullOrEmpty()) PreferenceItemDescription(
text = description, enabled = enabled
)
}
Switch(
checked = isChecked,
onCheckedChange = null,
modifier = Modifier.padding(start = 20.dp, end = 6.dp),
enabled = enabled,
thumbContent = thumbContent
)
}
}
}
@Composable
fun PreferencesHintCard(
title: String = "Title ".repeat(2),
description: String? = "Description text ".repeat(3),
icon: ImageVector? = Icons.Outlined.Translate,
containerColor: Color = FixedAccentColors.secondaryFixed,
contentColor: Color = FixedAccentColors.onSecondaryFixed,
onClick: () -> Unit = {},
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.clip(MaterialTheme.shapes.extraLarge)
.background(containerColor)
.clickable { onClick() }
.padding(horizontal = 12.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
icon?.let {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier
.padding(start = 8.dp, end = 16.dp)
.size(24.dp),
tint = contentColor
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(start = if (icon == null) 12.dp else 0.dp, end = 12.dp)
) {
with(MaterialTheme) {
Text(
text = title,
maxLines = 1,
style = typography.titleLarge.copy(fontSize = 20.sp),
color = contentColor
)
if (description != null) Text(
text = description,
color = contentColor,
maxLines = 2, overflow = TextOverflow.Ellipsis,
style = typography.bodyMedium,
)
}
}
}
}
@Composable
@Preview
fun PreferenceItemPreview() {
Column {
PreferenceItem(title = "title", description = "description", icon = 0)
PreferenceItem(title = "title", description = "description", icon = Icons.Outlined.Update)
}
}
@Composable
@Preview
fun PreferenceSwitchPreview() {
PreferenceSwitch(
title = "PreferenceSwitch",
description = "Supporting text",
icon = Icons.Outlined.ToggleOn,
)
}
@Composable
@Preview
fun PreferenceSwitchWithDividerPreview() {
PreferenceSwitchWithDivider(
title = "PreferenceSwitch",
description = "Supporting text",
icon = Icons.Outlined.Call,
)
}
@Composable
@Preview
private fun PreferenceSwitchWithContainerPreview() {
var isChecked by remember { mutableStateOf(true) }
PreviewThemeLight {
PreferenceSwitchWithContainer(
title = "Title ".repeat(2),
isChecked = isChecked,
onClick = { isChecked = !isChecked },
icon = null
)
}
}
@Composable
@Preview(showBackground = true)
fun PreferenceInfoPreview() {
PreferenceInfo(text = "Title")
}
@Preview
@Composable
fun PreferencesHintCardPreview() {
CompositionLocalProvider(LocalTonalPalettes provides Color.Green.toTonalPalettes()) {
PreferencesHintCard(
title = "Explore new features",
icon = Icons.Outlined.TipsAndUpdates,
description = "Find out what's new in this version",
containerColor = FixedAccentColors.primaryFixed,
contentColor = FixedAccentColors.onPrimaryFixed,
)
}
}

View File

@@ -0,0 +1,103 @@
package org.xtimms.tokusho.core.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.systemBars
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScaffoldWithTopAppBar(
title: String,
navigateBack: () -> Unit,
floatingActionButton: @Composable (() -> Unit) = {},
contentWindowInsets: WindowInsets = WindowInsets.systemBars,
content: @Composable (PaddingValues) -> Unit
) {
val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
rememberTopAppBarState(),
canScroll = { true }
)
Scaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection),
topBar = {
DefaultTopAppBar(
title = title,
scrollBehavior = topAppBarScrollBehavior,
navigateBack = navigateBack
)
},
floatingActionButton = floatingActionButton,
contentWindowInsets = contentWindowInsets,
content = content
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScaffoldWithSmallTopAppBar(
title: String,
navigateBack: () -> Unit,
floatingActionButton: @Composable (() -> Unit) = {},
contentWindowInsets: WindowInsets = WindowInsets.systemBars,
content: @Composable (PaddingValues) -> Unit
) {
val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
rememberTopAppBarState(),
canScroll = { true }
)
Scaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection),
topBar = {
SmallTopAppBar(
title = title,
scrollBehavior = topAppBarScrollBehavior,
navigateBack = navigateBack
)
},
floatingActionButton = floatingActionButton,
contentWindowInsets = contentWindowInsets,
content = content
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScaffoldWithClassicTopAppBar(
title: String,
navigateBack: () -> Unit,
floatingActionButton: @Composable (() -> Unit) = {},
contentWindowInsets: WindowInsets = WindowInsets.systemBars,
content: @Composable (PaddingValues) -> Unit
) {
val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
rememberTopAppBarState(),
canScroll = { true }
)
Scaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection),
topBar = {
ClassicTopAppBar(
title = title,
scrollBehavior = topAppBarScrollBehavior,
navigateBack = navigateBack
)
},
floatingActionButton = floatingActionButton,
contentWindowInsets = contentWindowInsets,
content = content
)
}

View File

@@ -0,0 +1,72 @@
package org.xtimms.tokusho.core.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import org.xtimms.tokusho.ui.theme.applyOpacity
@Composable
fun SettingTitle(text: String) {
Text(
modifier = Modifier
.padding(top = 32.dp)
.padding(horizontal = 20.dp, vertical = 16.dp),
text = text,
style = MaterialTheme.typography.displaySmall
)
}
@Composable
fun SettingItem(title: String, description: String, icon: ImageVector?, onClick: () -> Unit) {
Surface(
modifier = Modifier.clickable { onClick() }
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp, 20.dp),
verticalAlignment = Alignment.CenterVertically,
) {
icon?.let {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier
.padding(start = 8.dp, end = 16.dp)
.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(true)
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp)
) {
Text(
text = title,
maxLines = 1,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = description,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}

View File

@@ -0,0 +1,63 @@
package org.xtimms.tokusho.core.components
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.ImageLoader
import org.xtimms.tokusho.core.AsyncImageImpl
@Composable
fun SourceItem(
coil: ImageLoader,
faviconUrl: Uri,
title: String,
modifier: Modifier = Modifier,
maxLines: Int = 1,
onClick: () -> Unit,
) {
Column(
modifier = modifier
.width(96.dp)
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
.padding(start = 8.dp, end = 8.dp),
horizontalAlignment = Alignment.Start
) {
AsyncImageImpl(
coil = coil,
model = faviconUrl,
contentDescription = "favicon",
contentScale = ContentScale.Crop,
modifier = modifier
.size(96.dp)
.clip(RoundedCornerShape(8.dp))
.aspectRatio(1f)
)
Text(
text = title,
modifier = Modifier
.padding(top = 4.dp, bottom = 4.dp)
.align(Alignment.CenterHorizontally),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp,
lineHeight = 18.sp,
overflow = TextOverflow.Ellipsis,
maxLines = maxLines
)
}
}

View File

@@ -0,0 +1,42 @@
package org.xtimms.tokusho.core.components
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
@Composable
fun TabText(text: String, badgeCount: Int? = null) {
val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (badgeCount != null) {
Pill(
text = "$badgeCount",
color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha),
fontSize = 10.sp,
)
}
}
}
@Composable
@Preview(showBackground = true)
fun TabTextPreview() {
TabText(
text = "Title",
badgeCount = 5
)
}

View File

@@ -0,0 +1,218 @@
package org.xtimms.tokusho.core.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.RssFeed
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.initialOffset
import org.xtimms.tokusho.core.motion.materialSharedAxisXIn
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
import org.xtimms.tokusho.sections.explore.EXPLORE_DESTINATION
import org.xtimms.tokusho.sections.history.HISTORY_DESTINATION
import org.xtimms.tokusho.sections.search.SEARCH_DESTINATION
import org.xtimms.tokusho.sections.settings.SETTINGS_DESTINATION
import org.xtimms.tokusho.sections.shelf.SHELF_DESTINATION
import org.xtimms.tokusho.ui.theme.TokushoTheme
@Composable
fun TopAppBar(
navController: NavController,
modifier: Modifier = Modifier,
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val isVisible by remember {
derivedStateOf {
when (navBackStackEntry?.destination?.route) {
SHELF_DESTINATION, HISTORY_DESTINATION, EXPLORE_DESTINATION,
null -> true
else -> false
}
}
}
AnimatedVisibility(
visible = isVisible,
enter = materialSharedAxisXIn(initialOffsetX = { -(it * initialOffset).toInt() }),
exit = materialSharedAxisXOut(targetOffsetX = { -(it * initialOffset).toInt() })
) {
Row(
modifier = modifier
.statusBarsPadding()
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Card(
onClick = { navController.navigate(SEARCH_DESTINATION) },
modifier = modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(50),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)
),
) {
Row(
modifier = modifier
.padding(horizontal = 16.dp)
.fillMaxHeight(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = "search",
tint = MaterialTheme.colorScheme.outline
)
Text(
text = stringResource(R.string.search),
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Row(
modifier = modifier,
) {
IconButton(
onClick = { },
modifier = modifier.padding(0.dp),
) {
Icon(
Icons.Outlined.RssFeed,
contentDescription = stringResource(id = R.string.feed),
tint = MaterialTheme.colorScheme.outline
)
}
IconButton(
onClick = { navController.navigate(SETTINGS_DESTINATION) },
modifier = modifier.padding(0.dp),
) {
Icon(
Icons.Outlined.Settings,
contentDescription = stringResource(id = R.string.settings),
tint = MaterialTheme.colorScheme.outline
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DefaultTopAppBar(
title: String,
scrollBehavior: TopAppBarScrollBehavior? = null,
navigateBack: () -> Unit,
) {
LargeTopAppBar(
title = { Text(text = title) },
navigationIcon = {
BackIconButton(onClick = navigateBack)
},
scrollBehavior = scrollBehavior
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SmallTopAppBar(
title: String,
scrollBehavior: TopAppBarScrollBehavior? = null,
navigateBack: () -> Unit,
) {
MediumTopAppBar(
title = { Text(text = title) },
navigationIcon = {
BackIconButton(onClick = navigateBack)
},
scrollBehavior = scrollBehavior
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ClassicTopAppBar(
title: String,
scrollBehavior: TopAppBarScrollBehavior? = null,
navigateBack: () -> Unit,
) {
androidx.compose.material3.TopAppBar(
title = { Text(text = title) },
navigationIcon = {
BackIconButton(onClick = navigateBack)
},
scrollBehavior = scrollBehavior
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
fun DefaultTopAppBarPreview() {
TokushoTheme {
DefaultTopAppBar(
title = "Tokusho",
navigateBack = {}
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
fun SmallTopAppBarPreview() {
TokushoTheme {
SmallTopAppBar(
title = "Tokusho",
navigateBack = {}
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
fun ClassicTopAppBarPreview() {
TokushoTheme {
ClassicTopAppBar(
title = "Tokusho",
navigateBack = {}
)
}
}

View File

@@ -0,0 +1,60 @@
package org.xtimms.tokusho.core.components.icons
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.ui.graphics.vector.ImageVector
public val Icons.Outlined.Dice: ImageVector
get() {
if (_dice != null) {
return _dice!!
}
_dice = materialIcon(name = "Outlined.Dice") {
materialPath {
moveTo(19.0f, 5.0f)
verticalLineTo(19.0f)
horizontalLineTo(5.0f)
verticalLineTo(5.0f)
horizontalLineTo(19.0f)
moveTo(19.0f, 3.0f)
horizontalLineTo(5.0f)
curveTo(3.9f, 3.0f, 3.0f, 3.9f, 3.0f, 5.0f)
verticalLineTo(19.0f)
curveTo(3.0f, 20.1f, 3.9f, 21.0f, 5.0f, 21.0f)
horizontalLineTo(19.0f)
curveTo(20.1f, 21.0f, 21.0f, 20.1f, 21.0f, 19.0f)
verticalLineTo(5.0f)
curveTo(21.0f, 3.9f, 20.1f, 3.0f, 19.0f, 3.0f)
moveTo(7.5f, 6.0f)
curveTo(6.7f, 6.0f, 6.0f, 6.7f, 6.0f, 7.5f)
reflectiveCurveTo(6.7f, 9.0f, 7.5f, 9.0f)
reflectiveCurveTo(9.0f, 8.3f, 9.0f, 7.5f)
reflectiveCurveTo(8.3f, 6.0f, 7.5f, 6.0f)
moveTo(16.5f, 15.0f)
curveTo(15.7f, 15.0f, 15.0f, 15.7f, 15.0f, 16.5f)
curveTo(15.0f, 17.3f, 15.7f, 18.0f, 16.5f, 18.0f)
curveTo(17.3f, 18.0f, 18.0f, 17.3f, 18.0f, 16.5f)
curveTo(18.0f, 15.7f, 17.3f, 15.0f, 16.5f, 15.0f)
moveTo(16.5f, 6.0f)
curveTo(15.7f, 6.0f, 15.0f, 6.7f, 15.0f, 7.5f)
reflectiveCurveTo(15.7f, 9.0f, 16.5f, 9.0f)
curveTo(17.3f, 9.0f, 18.0f, 8.3f, 18.0f, 7.5f)
reflectiveCurveTo(17.3f, 6.0f, 16.5f, 6.0f)
moveTo(12.0f, 10.5f)
curveTo(11.2f, 10.5f, 10.5f, 11.2f, 10.5f, 12.0f)
reflectiveCurveTo(11.2f, 13.5f, 12.0f, 13.5f)
reflectiveCurveTo(13.5f, 12.8f, 13.5f, 12.0f)
reflectiveCurveTo(12.8f, 10.5f, 12.0f, 10.5f)
moveTo(7.5f, 15.0f)
curveTo(6.7f, 15.0f, 6.0f, 15.7f, 6.0f, 16.5f)
curveTo(6.0f, 17.3f, 6.7f, 18.0f, 7.5f, 18.0f)
reflectiveCurveTo(9.0f, 17.3f, 9.0f, 16.5f)
curveTo(9.0f, 15.7f, 8.3f, 15.0f, 7.5f, 15.0f)
close()
}
}
return _dice!!
}
private var _dice: ImageVector? = null

View File

@@ -0,0 +1,3 @@
package org.xtimms.tokusho.core.database
const val TABLE_SOURCES = "sources"

View File

@@ -0,0 +1,27 @@
package org.xtimms.tokusho.core.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import org.xtimms.tokusho.core.database.dao.MangaSourcesDao
import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
const val DATABASE_VERSION = 1
@Database(
entities = [
MangaSourceEntity::class
],
version = DATABASE_VERSION,
exportSchema = false
)
abstract class MangaDatabase : RoomDatabase() {
abstract fun getSourcesDao(): MangaSourcesDao
}
fun MangaDatabase(context: Context): MangaDatabase = Room
.databaseBuilder(context, MangaDatabase::class.java, "tokusho-db")
.build()

View File

@@ -0,0 +1,29 @@
package org.xtimms.tokusho.core.database.dao
import androidx.room.Dao
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
@Dao
abstract class MangaSourcesDao {
@Query("SELECT * FROM sources ORDER BY sort_key")
abstract suspend fun findAll(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 0 ORDER BY sort_key")
abstract suspend fun findAllDisabled(): List<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 0")
abstract fun observeDisabled(): Flow<List<MangaSourceEntity>>
@Query("SELECT * FROM sources ORDER BY sort_key")
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
@Query("SELECT IFNULL(MAX(sort_key),0) FROM sources")
abstract suspend fun getMaxSortKey(): Int
@Query("UPDATE sources SET enabled = 0")
abstract suspend fun disableAllSources()
}

View File

@@ -0,0 +1,17 @@
package org.xtimms.tokusho.core.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.xtimms.tokusho.core.database.TABLE_SOURCES
@Entity(
tableName = TABLE_SOURCES,
)
data class MangaSourceEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "source")
val source: String,
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
)

View File

@@ -0,0 +1,10 @@
package org.xtimms.tokusho.core.model
interface ListModel {
override fun equals(other: Any?): Boolean
fun areItemsTheSame(other: ListModel): Boolean
fun getChangePayload(previousState: ListModel): Any? = null
}

View File

@@ -0,0 +1,25 @@
package org.xtimms.tokusho.core.model
import androidx.annotation.StringRes
import org.xtimms.tokusho.R
import org.koitharu.kotatsu.parsers.util.find
import java.util.EnumSet
enum class ListSortOrder(
@StringRes val titleResId: Int,
) {
NEWEST(R.string.order_added),
PROGRESS(R.string.progress),
ALPHABETIC(R.string.by_name),
;
fun isGroupingSupported() = this == NEWEST || this == PROGRESS
companion object {
val SHELF: Set<ListSortOrder> = EnumSet.of(NEWEST, PROGRESS, ALPHABETIC)
operator fun invoke(value: String, fallback: ListSortOrder) = entries.find(value) ?: fallback
}
}

View File

@@ -0,0 +1,10 @@
package org.xtimms.tokusho.core.model
import org.koitharu.kotatsu.parsers.model.MangaSource
fun MangaSource(name: String): MangaSource {
MangaSource.entries.forEach {
if (it.name == name) return it
}
return MangaSource.DUMMY
}

View File

@@ -0,0 +1,17 @@
package org.xtimms.tokusho.core.model
import java.io.Serializable
data class ShelfCategory(
val id: Long,
val name: String,
val order: Long,
val flags: Long,
) : Serializable {
val isSystemCategory: Boolean = id == UNCATEGORIZED_ID
companion object {
const val UNCATEGORIZED_ID = 0L
}
}

View File

@@ -0,0 +1,79 @@
package org.xtimms.tokusho.core.motion
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
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.animation.togetherWith
private const val ProgressThreshold = 0.35f
private val Int.ForOutgoing: Int
get() = (this * ProgressThreshold).toInt()
private val Int.ForIncoming: Int
get() = this - this.ForOutgoing
/**
* [materialSharedAxisX] allows to switch a layout with shared X-axis transition.
*
*/
public fun materialSharedAxisX(
initialOffsetX: (fullWidth: Int) -> Int,
targetOffsetX: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration,
): ContentTransform = materialSharedAxisXIn(
initialOffsetX = initialOffsetX,
durationMillis = durationMillis
) togetherWith materialSharedAxisXOut(
targetOffsetX = targetOffsetX,
durationMillis = durationMillis
)
/**
* [materialSharedAxisXIn] allows to switch a layout with shared X-axis enter transition.
*/
public fun materialSharedAxisXIn(
initialOffsetX: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration,
): EnterTransition = slideInHorizontally(
animationSpec = tween(
durationMillis = durationMillis,
easing = FastOutSlowInEasing
),
initialOffsetX = initialOffsetX
) + fadeIn(
animationSpec = tween(
durationMillis = durationMillis.ForIncoming,
delayMillis = durationMillis.ForOutgoing,
easing = LinearOutSlowInEasing
)
)
/**
* [materialSharedAxisXOut] allows to switch a layout with shared X-axis exit transition.
*
*/
public fun materialSharedAxisXOut(
targetOffsetX: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration,
): ExitTransition = slideOutHorizontally(
animationSpec = tween(
durationMillis = durationMillis,
easing = FastOutSlowInEasing
),
targetOffsetX = targetOffsetX
) + fadeOut(
animationSpec = tween(
durationMillis = durationMillis.ForOutgoing,
delayMillis = 0,
easing = FastOutLinearInEasing
)
)

View File

@@ -0,0 +1,11 @@
package org.xtimms.tokusho.core.motion
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
public object MotionConstants {
public const val DefaultMotionDuration: Int = 300
public const val DefaultFadeInDuration: Int = 150
public const val DefaultFadeOutDuration: Int = 75
public val DefaultSlideDistance: Dp = 30.dp
}

View File

@@ -0,0 +1,11 @@
package org.xtimms.tokusho.core.network
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class BaseHttpClient
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class MangaHttpClient

View File

@@ -0,0 +1,69 @@
package org.xtimms.tokusho.core.network
import android.content.Context
import android.util.AndroidRuntimeException
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import okhttp3.Cache
import okhttp3.CookieJar
import okhttp3.OkHttpClient
import org.xtimms.tokusho.core.network.cookies.AndroidCookieJar
import org.xtimms.tokusho.core.network.cookies.MutableCookieJar
import org.xtimms.tokusho.core.network.cookies.PreferencesCookieJar
import org.xtimms.tokusho.data.LocalStorageManager
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
interface NetworkModule {
@Binds
fun bindCookieJar(androidCookieJar: MutableCookieJar): CookieJar
companion object {
@Provides
@Singleton
fun provideCookieJar(
@ApplicationContext context: Context
): MutableCookieJar = try {
AndroidCookieJar()
} catch (e: AndroidRuntimeException) {
PreferencesCookieJar(context)
}
@Provides
@Singleton
fun provideHttpCache(
localStorageManager: LocalStorageManager,
): Cache = localStorageManager.createHttpCache()
@Provides
@Singleton
@BaseHttpClient
fun provideBaseHttpClient(
cache: Cache,
cookieJar: CookieJar,
): OkHttpClient = OkHttpClient.Builder().apply {
connectTimeout(20, TimeUnit.SECONDS)
readTimeout(60, TimeUnit.SECONDS)
writeTimeout(20, TimeUnit.SECONDS)
cookieJar(cookieJar)
cache(cache)
}.build()
@Provides
@Singleton
@MangaHttpClient
fun provideMangaHttpClient(
@BaseHttpClient baseClient: OkHttpClient,
): OkHttpClient = baseClient.newBuilder().build()
}
}

View File

@@ -0,0 +1,55 @@
package org.xtimms.tokusho.core.network.cookies
import android.webkit.CookieManager
import androidx.annotation.WorkerThread
import androidx.core.util.Predicate
import okhttp3.Cookie
import okhttp3.HttpUrl
import org.xtimms.tokusho.utils.system.newBuilder
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class AndroidCookieJar : MutableCookieJar {
private val cookieManager = CookieManager.getInstance()
@WorkerThread
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val rawCookie = cookieManager.getCookie(url.toString()) ?: return emptyList()
return rawCookie.split(';').mapNotNull {
Cookie.parse(url, it)
}
}
@WorkerThread
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
if (cookies.isEmpty()) {
return
}
val urlString = url.toString()
for (cookie in cookies) {
cookieManager.setCookie(urlString, cookie.toString())
}
}
override fun removeCookies(url: HttpUrl, predicate: Predicate<Cookie>?) {
val cookies = loadForRequest(url)
if (cookies.isEmpty()) {
return
}
val urlString = url.toString()
for (c in cookies) {
if (predicate != null && !predicate.test(c)) {
continue
}
val nc = c.newBuilder()
.expiresAt(System.currentTimeMillis() - 100000)
.build()
cookieManager.setCookie(urlString, nc.toString())
}
}
override suspend fun clear() = suspendCoroutine<Boolean> { continuation ->
cookieManager.removeAllCookies(continuation::resume)
}
}

View File

@@ -0,0 +1,68 @@
package org.xtimms.tokusho.core.network.cookies
import android.util.Base64
import okhttp3.Cookie
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
data class CookieWrapper(
val cookie: Cookie,
) {
constructor(encodedString: String) : this(
ObjectInputStream(ByteArrayInputStream(Base64.decode(encodedString, Base64.NO_WRAP))).use {
val name = it.readUTF()
val value = it.readUTF()
val expiresAt = it.readLong()
val domain = it.readUTF()
val path = it.readUTF()
val secure = it.readBoolean()
val httpOnly = it.readBoolean()
val persistent = it.readBoolean()
val hostOnly = it.readBoolean()
Cookie.Builder().also { c ->
c.name(name)
c.value(value)
if (persistent) {
c.expiresAt(expiresAt)
}
if (hostOnly) {
c.hostOnlyDomain(domain)
} else {
c.domain(domain)
}
c.path(path)
if (secure) {
c.secure()
}
if (httpOnly) {
c.httpOnly()
}
}.build()
},
)
fun encode(): String {
val output = ByteArrayOutputStream()
ObjectOutputStream(output).use {
it.writeUTF(cookie.name)
it.writeUTF(cookie.value)
it.writeLong(cookie.expiresAt)
it.writeUTF(cookie.domain)
it.writeUTF(cookie.path)
it.writeBoolean(cookie.secure)
it.writeBoolean(cookie.httpOnly)
it.writeBoolean(cookie.persistent)
it.writeBoolean(cookie.hostOnly)
}
return Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP)
}
fun isExpired() = cookie.expiresAt < System.currentTimeMillis()
fun key(): String {
return (if (cookie.secure) "https" else "http") + "://" + cookie.domain + cookie.path + "|" + cookie.name
}
}

View File

@@ -0,0 +1,21 @@
package org.xtimms.tokusho.core.network.cookies
import androidx.annotation.WorkerThread
import androidx.core.util.Predicate
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
interface MutableCookieJar : CookieJar {
@WorkerThread
override fun loadForRequest(url: HttpUrl): List<Cookie>
@WorkerThread
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>)
@WorkerThread
fun removeCookies(url: HttpUrl, predicate: Predicate<Cookie>?)
suspend fun clear(): Boolean
}

View File

@@ -0,0 +1,108 @@
package org.xtimms.tokusho.core.network.cookies
import android.content.Context
import androidx.annotation.WorkerThread
import androidx.collection.ArrayMap
import androidx.core.content.edit
import androidx.core.util.Predicate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Cookie
import okhttp3.HttpUrl
private const val PREFS_NAME = "cookies"
class PreferencesCookieJar(
context: Context,
) : MutableCookieJar {
private val cache = ArrayMap<String, CookieWrapper>()
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private var isLoaded = false
@WorkerThread
@Synchronized
override fun loadForRequest(url: HttpUrl): List<Cookie> {
loadPersistent()
val expired = HashSet<String>()
val result = ArrayList<Cookie>()
for ((key, cookie) in cache) {
if (cookie.isExpired()) {
expired += key
} else if (cookie.cookie.matches(url)) {
result += cookie.cookie
}
}
if (expired.isNotEmpty()) {
cache.removeAll(expired)
removePersistent(expired)
}
return result
}
@WorkerThread
@Synchronized
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val wrapped = cookies.map { CookieWrapper(it) }
prefs.edit(commit = true) {
for (cookie in wrapped) {
val key = cookie.key()
cache[key] = cookie
if (cookie.cookie.persistent) {
putString(key, cookie.encode())
}
}
}
}
@Synchronized
@WorkerThread
override fun removeCookies(url: HttpUrl, predicate: Predicate<Cookie>?) {
loadPersistent()
val toRemove = HashSet<String>()
for ((key, cookie) in cache) {
if (cookie.isExpired() || cookie.cookie.matches(url)) {
if (predicate == null || predicate.test(cookie.cookie)) {
toRemove += key
}
}
}
if (toRemove.isNotEmpty()) {
cache.removeAll(toRemove)
removePersistent(toRemove)
}
}
override suspend fun clear(): Boolean {
cache.clear()
withContext(Dispatchers.IO) {
prefs.edit(commit = true) { clear() }
}
return true
}
@Synchronized
private fun loadPersistent() {
if (!isLoaded) {
val map = prefs.all
cache.ensureCapacity(map.size)
for ((k, v) in map) {
val cookie = try {
CookieWrapper(v as String)
} catch (e: Exception) {
continue
}
cache[k] = cookie
}
isLoaded = true
}
}
private fun removePersistent(keys: Collection<String>) {
prefs.edit(commit = true) {
for (key in keys) {
remove(key)
}
}
}
}

View File

@@ -0,0 +1,50 @@
package org.xtimms.tokusho.core.os
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.flow.first
import org.xtimms.tokusho.utils.MediatorStateFlow
import org.xtimms.tokusho.utils.system.isOnline
class NetworkState(
private val connectivityManager: ConnectivityManager,
) : MediatorStateFlow<Boolean>(connectivityManager.isOnline()) {
private val callback = NetworkCallbackImpl()
@Synchronized
override fun onActive() {
invalidate()
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
}
@Synchronized
override fun onInactive() {
connectivityManager.unregisterNetworkCallback(callback)
}
suspend fun awaitForConnection() {
if (value) {
return
}
first { it }
}
private fun invalidate() {
publishValue(connectivityManager.isOnline())
}
private inner class NetworkCallbackImpl : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = invalidate()
override fun onLost(network: Network) = invalidate()
override fun onUnavailable() = invalidate()
}
}

View File

@@ -0,0 +1,4 @@
package org.xtimms.tokusho.core.parser
private const val MAX_PARALLELISM = 4

View File

@@ -0,0 +1,63 @@
package org.xtimms.tokusho.core.parser
import android.annotation.SuppressLint
import android.content.Context
import android.util.Base64
import android.webkit.WebView
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.tokusho.core.network.MangaHttpClient
import org.xtimms.tokusho.core.network.cookies.MutableCookieJar
import org.xtimms.tokusho.core.prefs.SourceSettings
import org.xtimms.tokusho.utils.system.toList
import java.lang.ref.WeakReference
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Singleton
class MangaLoaderContextImpl @Inject constructor(
@MangaHttpClient override val httpClient: OkHttpClient,
override val cookieJar: MutableCookieJar,
@ApplicationContext private val androidContext: Context,
) : MangaLoaderContext() {
private var webViewCached: WeakReference<WebView>? = null
@SuppressLint("SetJavaScriptEnabled")
override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) {
val webView = webViewCached?.get() ?: WebView(androidContext).also {
it.settings.javaScriptEnabled = true
webViewCached = WeakReference(it)
}
suspendCoroutine { cont ->
webView.evaluateJavascript(script) { result ->
cont.resume(result?.takeUnless { it == "null" })
}
}
}
override fun getConfig(source: MangaSource): MangaSourceConfig {
return SourceSettings(androidContext, source)
}
override fun encodeBase64(data: ByteArray): String {
return Base64.encodeToString(data, Base64.NO_WRAP)
}
override fun decodeBase64(data: String): ByteArray {
return Base64.decode(data, Base64.DEFAULT)
}
override fun getPreferredLocales(): List<Locale> {
return LocaleListCompat.getAdjustedDefault().toList()
}
}

View File

@@ -0,0 +1,9 @@
package org.xtimms.tokusho.core.parser
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.model.MangaSource
fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser {
return loaderContext.newParserInstance(source)
}

View File

@@ -0,0 +1,73 @@
package org.xtimms.tokusho.core.parser
import androidx.annotation.AnyThread
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.xtimms.tokusho.core.cache.ContentCache
import java.lang.ref.WeakReference
import java.util.EnumMap
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
interface MangaRepository {
val source: MangaSource
val sortOrders: Set<SortOrder>
val states: Set<MangaState>
val contentRatings: Set<ContentRating>
val isMultipleTagsSupported: Boolean
val isTagsExclusionSupported: Boolean
val isSearchSupported: Boolean
suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga>
suspend fun getDetails(manga: Manga): Manga
suspend fun getPages(chapter: MangaChapter): List<MangaPage>
suspend fun getPageUrl(page: MangaPage): String
suspend fun getTags(): Set<MangaTag>
suspend fun getLocales(): Set<Locale>
suspend fun getRelated(seed: Manga): List<Manga>
@Singleton
class Factory @Inject constructor(
private val loaderContext: MangaLoaderContext,
private val contentCache: ContentCache,
) {
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
@AnyThread
fun create(source: MangaSource): MangaRepository {
cache[source]?.get()?.let { return it }
return synchronized(cache) {
cache[source]?.get()?.let { return it }
val repository = RemoteMangaRepository(
parser = MangaParser(source, loaderContext),
cache = contentCache,
)
cache[source] = WeakReference(repository)
repository
}
}
}
}

View File

@@ -0,0 +1,144 @@
package org.xtimms.tokusho.core.parser
import android.util.Log
import coil.request.CachePolicy
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Favicons
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.tokusho.BuildConfig
import org.xtimms.tokusho.core.cache.ContentCache
import org.xtimms.tokusho.core.cache.SafeDeferred
import org.xtimms.tokusho.utils.lang.processLifecycleScope
import java.util.Locale
class RemoteMangaRepository(
private val parser: MangaParser,
private val cache: ContentCache,
) : MangaRepository, Interceptor {
override val source: MangaSource
get() = parser.source
override val sortOrders: Set<SortOrder>
get() = parser.availableSortOrders
override val states: Set<MangaState>
get() = parser.availableStates
override val contentRatings: Set<ContentRating>
get() = parser.availableContentRating
override val isMultipleTagsSupported: Boolean
get() = parser.isMultipleTagsSupported
override val isSearchSupported: Boolean
get() = parser.isSearchSupported
override val isTagsExclusionSupported: Boolean
get() = parser.isTagsExclusionSupported
override fun intercept(chain: Interceptor.Chain): Response {
return if (parser is Interceptor) {
parser.intercept(chain)
} else {
chain.proceed(chain.request())
}
}
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
return parser.getList(offset, filter)
}
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
cache.getPages(source, chapter.url)?.let { return it }
val pages = asyncSafe {
parser.getPages(chapter).distinctById()
}
cache.putPages(source, chapter.url, pages)
return pages.await()
}
override suspend fun getPageUrl(page: MangaPage): String = parser.getPageUrl(page)
override suspend fun getTags(): Set<MangaTag> = parser.getAvailableTags()
override suspend fun getLocales(): Set<Locale> {
return parser.getAvailableLocales()
}
suspend fun getFavicons(): Favicons = parser.getFavicons()
override suspend fun getRelated(seed: Manga): List<Manga> {
cache.getRelatedManga(source, seed.url)?.let { return it }
val related = asyncSafe {
parser.getRelatedManga(seed).filterNot { it.id == seed.id }
}
cache.putRelatedManga(source, seed.url, related)
return related.await()
}
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga {
if (cachePolicy.readEnabled) {
cache.getDetails(source, manga.url)?.let { return it }
}
val details = asyncSafe {
parser.getDetails(manga)
}
if (cachePolicy.writeEnabled) {
cache.putDetails(source, manga.url, details)
}
return details.await()
}
@OptIn(ExperimentalStdlibApi::class)
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
dispatcher = Dispatchers.Default
}
return SafeDeferred(
processLifecycleScope.async(dispatcher) {
runCatchingCancellable { block() }
},
)
}
private fun List<MangaPage>.distinctById(): List<MangaPage> {
if (isEmpty()) {
return emptyList()
}
val result = ArrayList<MangaPage>(size)
val set = HashSet<Long>(size)
for (page in this) {
if (set.add(page.id)) {
result.add(page)
} else if (BuildConfig.DEBUG) {
Log.w(null, "Duplicate page: $page")
}
}
return result
}
private fun Result<*>.isValidResult() = exceptionOrNull() !is ParseException
&& (getOrNull() as? Collection<*>)?.isEmpty() != true
}

View File

@@ -0,0 +1,194 @@
package org.xtimms.tokusho.core.parser.favicon
import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.disk.DiskCache
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.network.HttpException
import coil.request.Options
import coil.size.Size
import coil.size.pxOrElse
import kotlinx.coroutines.ensureActive
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.internal.closeQuietly
import okio.Closeable
import okio.buffer
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.xtimms.tokusho.core.cache.CacheDir
import org.xtimms.tokusho.core.model.MangaSource
import org.xtimms.tokusho.core.parser.MangaRepository
import org.xtimms.tokusho.core.parser.RemoteMangaRepository
import org.xtimms.tokusho.utils.lang.writeAllCancellable
import org.xtimms.tokusho.utils.withExtraCloseable
import java.net.HttpURLConnection
import kotlin.coroutines.coroutineContext
private const val FALLBACK_SIZE = 9999 // largest icon
@OptIn(ExperimentalCoilApi::class)
class FaviconFetcher(
private val okHttpClient: OkHttpClient,
private val diskCache: Lazy<DiskCache?>,
private val mangaSource: MangaSource,
private val options: Options,
private val mangaRepositoryFactory: MangaRepository.Factory,
) : Fetcher {
private val diskCacheKey
get() = options.diskCacheKey ?: "${mangaSource.name}[${mangaSource.ordinal}]x${options.size.toCacheKey()}"
private val fileSystem
get() = checkNotNull(diskCache.value).fileSystem
override suspend fun fetch(): FetchResult {
getCached(options)?.let { return it }
val repo = mangaRepositoryFactory.create(mangaSource) as RemoteMangaRepository
val sizePx = maxOf(
options.size.width.pxOrElse { FALLBACK_SIZE },
options.size.height.pxOrElse { FALLBACK_SIZE },
)
var favicons = repo.getFavicons()
var lastError: Exception? = null
while (favicons.isNotEmpty()) {
coroutineContext.ensureActive()
val icon = favicons.find(sizePx) ?: throwNSEE(lastError)
val response = try {
loadIcon(icon.url, mangaSource)
} catch (e: HttpException) {
lastError = e
favicons -= icon
continue
}
val responseBody = response.requireBody()
val source = writeToDiskCache(responseBody)?.toImageSource()?.also {
response.closeQuietly()
} ?: responseBody.toImageSource(response)
return SourceResult(
source = source,
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(icon.type),
dataSource = response.toDataSource(),
)
}
throwNSEE(lastError)
}
private suspend fun loadIcon(url: String, source: MangaSource): Response {
val request = Request.Builder()
.url(url)
.get()
.tag(MangaSource::class.java, source)
@Suppress("UNCHECKED_CAST")
options.tags.asMap().forEach { request.tag(it.key as Class<Any>, it.value) }
val response = okHttpClient.newCall(request.build()).await()
if (!response.isSuccessful && response.code != HttpURLConnection.HTTP_NOT_MODIFIED) {
response.closeQuietly()
throw HttpException(response)
}
return response
}
private fun getCached(options: Options): SourceResult? {
if (!options.diskCachePolicy.readEnabled) {
return null
}
val snapshot = diskCache.value?.openSnapshot(diskCacheKey) ?: return null
return SourceResult(
source = snapshot.toImageSource(),
mimeType = null,
dataSource = DataSource.DISK,
)
}
private suspend fun writeToDiskCache(body: ResponseBody): DiskCache.Snapshot? {
if (!options.diskCachePolicy.writeEnabled || body.contentLength() == 0L) {
return null
}
val editor = diskCache.value?.openEditor(diskCacheKey) ?: return null
try {
fileSystem.write(editor.data) {
writeAllCancellable(body.source())
}
return editor.commitAndOpenSnapshot()
} catch (e: Throwable) {
try {
editor.abort()
} catch (abortingError: Throwable) {
e.addSuppressed(abortingError)
}
body.closeQuietly()
throw e
} finally {
body.closeQuietly()
}
}
private fun DiskCache.Snapshot.toImageSource(): ImageSource {
return ImageSource(data, fileSystem, diskCacheKey, this)
}
private fun ResponseBody.toImageSource(response: Closeable): ImageSource {
return ImageSource(
source().withExtraCloseable(response).buffer(),
options.context,
FaviconMetadata(mangaSource),
)
}
private fun Response.toDataSource(): DataSource {
return if (networkResponse != null) DataSource.NETWORK else DataSource.DISK
}
private fun Response.requireBody(): ResponseBody {
return checkNotNull(body) { "response body == null" }
}
private fun Size.toCacheKey() = buildString {
append(width.toString())
append('x')
append(height.toString())
}
private fun throwNSEE(lastError: Exception?): Nothing {
if (lastError != null) {
throw lastError
} else {
throw NoSuchElementException("No favicons found")
}
}
class Factory(
context: Context,
private val okHttpClient: OkHttpClient,
private val mangaRepositoryFactory: MangaRepository.Factory,
) : Fetcher.Factory<Uri> {
private val diskCache = lazy {
val rootDir = context.externalCacheDir ?: context.cacheDir
DiskCache.Builder()
.directory(rootDir.resolve(CacheDir.FAVICONS.dir))
.build()
}
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
return if (data.scheme == URI_SCHEME_FAVICON) {
val mangaSource = MangaSource(data.schemeSpecificPart)
FaviconFetcher(okHttpClient, diskCache, mangaSource, options, mangaRepositoryFactory)
} else {
null
}
}
}
class FaviconMetadata(val source: MangaSource) : ImageSource.Metadata()
}

View File

@@ -0,0 +1,8 @@
package org.xtimms.tokusho.core.parser.favicon
import android.net.Uri
import org.koitharu.kotatsu.parsers.model.MangaSource
const val URI_SCHEME_FAVICON = "favicon"
fun MangaSource.faviconUri(): Uri = Uri.fromParts(URI_SCHEME_FAVICON, name, null)

View File

@@ -0,0 +1,190 @@
package org.xtimms.tokusho.core.prefs
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.core.os.LocaleListCompat
import com.google.android.material.color.DynamicColors
import com.tencent.mmkv.MMKV
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.xtimms.shiki.ui.theme.SEED
import org.xtimms.tokusho.App.Companion.applicationScope
import org.xtimms.tokusho.R
import org.xtimms.tokusho.ui.monet.PaletteStyle
import org.xtimms.tokusho.utils.system.languageMap
private const val DYNAMIC_COLOR = "dynamic_color"
const val DARK_THEME_VALUE = "dark_theme_value"
private const val HIGH_CONTRAST = "high_contrast"
const val AUTO_UPDATE = "auto_update"
const val UPDATE_CHANNEL = "update_channel"
private const val THEME_COLOR = "theme_color"
const val PALETTE_STYLE = "palette_style"
const val LANGUAGE = "language"
const val SYSTEM_DEFAULT = 0
const val STABLE = 0
const val PRE_RELEASE = 1
val paletteStyles = listOf(
PaletteStyle.TonalSpot,
PaletteStyle.Spritz,
PaletteStyle.FruitSalad,
PaletteStyle.Vibrant,
PaletteStyle.Monochrome
)
const val STYLE_TONAL_SPOT = 0
const val STYLE_SPRITZ = 1
const val STYLE_FRUIT_SALAD = 2
const val STYLE_VIBRANT = 3
const val STYLE_MONOCHROME = 4
private val kv: MMKV = MMKV.defaultMMKV()
private val StringPreferenceDefaults = mapOf(
"test" to "default",
)
private val BooleanPreferenceDefaults = mapOf(
"test" to false
)
private val IntPreferenceDefaults = mapOf(
LANGUAGE to SYSTEM_DEFAULT,
PALETTE_STYLE to 0,
DARK_THEME_VALUE to DarkThemePreference.FOLLOW_SYSTEM,
UPDATE_CHANNEL to STABLE,
)
object AppSettings {
fun String.getInt(default: Int = IntPreferenceDefaults.getOrElse(this) { 0 }): Int =
kv.decodeInt(this, default)
fun String.getString(default: String = StringPreferenceDefaults.getOrElse(this) { "" }): String =
kv.decodeString(this) ?: default
fun String.getBoolean(default: Boolean = BooleanPreferenceDefaults.getOrElse(this) { false }): Boolean =
kv.decodeBool(this, default)
fun String.updateString(newString: String) = kv.encode(this, newString)
fun String.updateInt(newInt: Int) = kv.encode(this, newInt)
fun String.updateBoolean(newValue: Boolean) = kv.encode(this, newValue)
fun updateValue(key: String, b: Boolean) = key.updateBoolean(b)
fun encodeInt(key: String, int: Int) = key.updateInt(int)
fun getValue(key: String): Boolean = key.getBoolean()
fun encodeString(key: String, string: String) = key.updateString(string)
fun containsKey(key: String) = kv.containsKey(key)
fun isAutoUpdateEnabled() = AUTO_UPDATE.getBoolean(true)
fun getLanguageConfiguration(languageNumber: Int = kv.decodeInt(LANGUAGE)) =
languageMap.getOrElse(languageNumber) { "" }
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()
}
data class Settings(
val darkTheme: DarkThemePreference = DarkThemePreference(),
val isDynamicColorEnabled: Boolean = false,
val seedColor: Int = SEED,
val paletteStyleIndex: Int = 0
)
private val mutableAppSettingsStateFlow = MutableStateFlow(
Settings(
DarkThemePreference(
darkThemeValue = kv.decodeInt(
DARK_THEME_VALUE, DarkThemePreference.FOLLOW_SYSTEM
), isHighContrastModeEnabled = kv.decodeBool(HIGH_CONTRAST, false)
),
isDynamicColorEnabled = kv.decodeBool(
DYNAMIC_COLOR, DynamicColors.isDynamicColorAvailable()
),
seedColor = kv.decodeInt(THEME_COLOR, SEED),
paletteStyleIndex = kv.decodeInt(PALETTE_STYLE, 0)
)
)
val AppSettingsStateFlow = mutableAppSettingsStateFlow.asStateFlow()
fun modifyDarkThemePreference(
darkThemeValue: Int = AppSettingsStateFlow.value.darkTheme.darkThemeValue,
isHighContrastModeEnabled: Boolean = AppSettingsStateFlow.value.darkTheme.isHighContrastModeEnabled
) {
applicationScope.launch(Dispatchers.IO) {
mutableAppSettingsStateFlow.update {
it.copy(
darkTheme = AppSettingsStateFlow.value.darkTheme.copy(
darkThemeValue = darkThemeValue,
isHighContrastModeEnabled = isHighContrastModeEnabled
)
)
}
kv.encode(DARK_THEME_VALUE, darkThemeValue)
kv.encode(HIGH_CONTRAST, isHighContrastModeEnabled)
}
}
fun modifyThemeSeedColor(colorArgb: Int, paletteStyleIndex: Int) {
applicationScope.launch(Dispatchers.IO) {
mutableAppSettingsStateFlow.update {
it.copy(seedColor = colorArgb, paletteStyleIndex = paletteStyleIndex)
}
kv.encode(THEME_COLOR, colorArgb)
kv.encode(PALETTE_STYLE, paletteStyleIndex)
}
}
fun switchDynamicColor(enabled: Boolean = !mutableAppSettingsStateFlow.value.isDynamicColorEnabled) {
applicationScope.launch(Dispatchers.IO) {
mutableAppSettingsStateFlow.update {
it.copy(isDynamicColorEnabled = enabled)
}
kv.encode(DYNAMIC_COLOR, enabled)
}
}
}
data class DarkThemePreference(
val darkThemeValue: Int = FOLLOW_SYSTEM, val isHighContrastModeEnabled: Boolean = false
) {
companion object {
const val FOLLOW_SYSTEM = 1
const val ON = 2
const val OFF = 3
}
@Composable
fun isDarkTheme(): Boolean {
return if (darkThemeValue == FOLLOW_SYSTEM) isSystemInDarkTheme()
else darkThemeValue == ON
}
@Composable
fun getDarkThemeDesc(): String {
return when (darkThemeValue) {
FOLLOW_SYSTEM -> stringResource(R.string.follow_system)
ON -> stringResource(R.string.on)
else -> stringResource(R.string.off)
}
}
}

View File

@@ -0,0 +1,43 @@
package org.xtimms.tokusho.core.prefs
import android.content.Context
import androidx.core.content.edit
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.xtimms.tokusho.utils.lang.ifNullOrEmpty
import org.xtimms.tokusho.utils.system.getEnumValue
import org.xtimms.tokusho.utils.system.putEnumValue
private const val KEY_SORT_ORDER = "sort_order"
private const val KEY_SLOWDOWN = "slowdown"
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE)
var defaultSortOrder: SortOrder?
get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java)
set(value) = prefs.edit { putEnumValue(KEY_SORT_ORDER, value) }
val isSlowdownEnabled: Boolean
get() = prefs.getBoolean(KEY_SLOWDOWN, false)
@Suppress("UNCHECKED_CAST")
override fun <T> get(key: ConfigKey<T>): T {
return when (key) {
is ConfigKey.UserAgent -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
} as T
}
operator fun <T> set(key: ConfigKey<T>, value: T) = prefs.edit {
when (key) {
is ConfigKey.Domain -> putString(key.key, value as String?)
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
is ConfigKey.UserAgent -> putString(key.key, value as String?)
}
}
}

View File

@@ -0,0 +1,112 @@
package org.xtimms.tokusho.core.screens
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.paddingFromBaseline
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import kotlinx.collections.immutable.ImmutableList
import org.xtimms.tokusho.core.components.ActionButton
import org.xtimms.tokusho.utils.secondaryItemAlpha
import kotlin.random.Random
data class EmptyScreenAction(
val stringRes: Int,
val icon: ImageVector,
val onClick: () -> Unit,
)
@Composable
fun EmptyScreen(
@StringRes title: Int,
modifier: Modifier = Modifier,
actions: ImmutableList<EmptyScreenAction>? = null,
) {
EmptyScreen(
message = stringResource(title),
modifier = modifier,
actions = actions,
)
}
@Composable
fun EmptyScreen(
message: String,
modifier: Modifier = Modifier,
actions: ImmutableList<EmptyScreenAction>? = null,
) {
val face = remember { getRandomErrorFace() }
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Text(
text = face,
modifier = Modifier.secondaryItemAlpha(),
style = MaterialTheme.typography.displayMedium,
)
}
Text(
text = message,
modifier = Modifier
.paddingFromBaseline(top = 24.dp)
.secondaryItemAlpha(),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
if (!actions.isNullOrEmpty()) {
Row(
modifier = Modifier
.padding(top = 24.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
actions.fastForEach {
ActionButton(
modifier = Modifier.weight(1f),
title = stringResource(it.stringRes),
icon = it.icon,
onClick = it.onClick,
)
}
}
}
}
}
private val ErrorFaces = listOf(
"(・o・;)",
"Σ(ಠ_ಠ)",
"ಥ_ಥ",
"(˘・_・˘)",
"(; ̄Д ̄)",
"(・Д・。",
)
private fun getRandomErrorFace(): String {
return ErrorFaces[Random.nextInt(ErrorFaces.size)]
}

View File

@@ -0,0 +1,142 @@
package org.xtimms.tokusho.core.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Newspaper
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import org.xtimms.tokusho.utils.secondaryItemAlpha
@Composable
fun InfoScreen(
icon: ImageVector,
headingText: String,
subtitleText: String,
acceptText: String,
onAcceptClick: () -> Unit,
canAccept: Boolean = true,
rejectText: String? = null,
onRejectClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Scaffold(
bottomBar = {
val strokeWidth = Dp.Hairline
val borderColor = MaterialTheme.colorScheme.outline
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.drawBehind {
drawLine(
borderColor,
Offset(0f, 0f),
Offset(size.width, 0f),
strokeWidth.value,
)
}
.windowInsetsPadding(NavigationBarDefaults.windowInsets)
.padding(
horizontal = 16.dp,
vertical = 8.dp,
),
) {
Button(
modifier = Modifier.fillMaxWidth(),
enabled = canAccept,
onClick = onAcceptClick,
) {
Text(text = acceptText)
}
if (rejectText != null && onRejectClick != null) {
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
onClick = onRejectClick,
) {
Text(text = rejectText)
}
}
}
},
) { paddingValues ->
// Status bar scrim
Box(
modifier = Modifier
.zIndex(2f)
.secondaryItemAlpha()
.background(MaterialTheme.colorScheme.background)
.fillMaxWidth()
.height(paddingValues.calculateTopPadding()),
)
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.fillMaxWidth()
.padding(paddingValues)
.padding(top = 48.dp)
.padding(horizontal = 16.dp),
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier
.padding(bottom = 8.dp)
.size(48.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = headingText,
style = MaterialTheme.typography.headlineLarge,
)
Text(
text = subtitleText,
modifier = Modifier
.secondaryItemAlpha()
.padding(vertical = 8.dp),
style = MaterialTheme.typography.titleSmall,
)
content()
}
}
}
@PreviewLightDark
@Composable
private fun InfoScaffoldPreview() {
InfoScreen(
icon = Icons.Outlined.Newspaper,
headingText = "Heading",
subtitleText = "Subtitle",
acceptText = "Accept",
onAcceptClick = {},
rejectText = "Reject",
onRejectClick = {},
) {
Text("Hello world")
}
}

View File

@@ -0,0 +1,94 @@
package org.xtimms.tokusho.core.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.NewReleases
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.updates.Updater
import org.xtimms.tokusho.utils.system.suspendToast
@Composable
fun UpdateDialog(
onDismissRequest: () -> Unit,
latestRelease: Updater.LatestRelease,
) {
var currentDownloadStatus by remember { mutableStateOf(Updater.DownloadStatus.NotYet as Updater.DownloadStatus) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
UpdateDialogImpl(
onDismissRequest = onDismissRequest,
title = latestRelease.name.toString(),
onConfirmUpdate = {
scope.launch(Dispatchers.IO) {
runCatching {
Updater.downloadApk(latestRelease = latestRelease)
.collect { downloadStatus ->
currentDownloadStatus = downloadStatus
if (downloadStatus is Updater.DownloadStatus.Finished) {
Updater.installLatestApk()
}
}
}.onFailure {
it.printStackTrace()
currentDownloadStatus = Updater.DownloadStatus.NotYet
context.suspendToast(R.string.app_update_failed)
return@launch
}
}
},
releaseNote = latestRelease.body.toString(),
downloadStatus = currentDownloadStatus
)
}
@Composable
fun UpdateDialogImpl(
onDismissRequest: () -> Unit,
title: String,
onConfirmUpdate: () -> Unit,
releaseNote: String,
downloadStatus: Updater.DownloadStatus,
) {
AlertDialog(
onDismissRequest = {},
title = { Text(title) },
icon = { Icon(Icons.Outlined.NewReleases, null) }, confirmButton = {
TextButton(onClick = { if (downloadStatus !is Updater.DownloadStatus.Progress) onConfirmUpdate() }) {
when (downloadStatus) {
is Updater.DownloadStatus.Progress -> Text("${downloadStatus.percent} %")
else -> Text(stringResource(R.string.update))
}
}
}, dismissButton = {
DismissButton { onDismissRequest() }
}, text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
Text(releaseNote)
}
})
}
@Composable
fun DismissButton(text: String = stringResource(R.string.dismiss), onClick: () -> Unit) {
TextButton(onClick = onClick) {
Text(text)
}
}

View File

@@ -0,0 +1,299 @@
package org.xtimms.tokusho.core.updates
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.ResponseBody
import org.xtimms.tokusho.App
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.prefs.AppSettings.getInt
import org.xtimms.tokusho.core.prefs.STABLE
import org.xtimms.tokusho.core.prefs.UPDATE_CHANNEL
import org.xtimms.tokusho.utils.system.getFileProvider
import org.xtimms.tokusho.utils.system.toast
import java.io.File
import java.util.regex.Pattern
object Updater {
private const val OWNER = "ztimms73"
private const val REPO = "Tokusho"
private const val TAG = "Updates"
private val client = OkHttpClient()
private val requestForReleases =
Request.Builder().url("https://api.github.com/repos/$OWNER/$REPO/releases")
.build()
private val jsonFormat = Json { ignoreUnknownKeys = true }
private suspend fun getLatestRelease(): LatestRelease =
client.newCall(requestForReleases).execute().run {
val releaseList =
jsonFormat.decodeFromString<List<LatestRelease>>(this.body!!.string())
val latestRelease =
releaseList.filter { if (UPDATE_CHANNEL.getInt() == STABLE) it.name.toVersion() is Version.Stable else true }
.maxByOrNull { it.name.toVersion() }
?: throw Exception("null response")
releaseList.sortedBy { it.name.toVersion() }.forEach {
Log.d(TAG, it.tagName.toString())
}
body!!.close()
latestRelease
}
suspend fun checkForUpdate(context: Context = App.context): LatestRelease? {
val currentVersion = context.getCurrentVersion()
val latestRelease = getLatestRelease()
val latestVersion = latestRelease.name.toVersion()
return if (currentVersion < latestVersion) latestRelease
else null
}
private fun Context.getCurrentVersion(): Version =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackageInfo(
packageName, PackageManager.PackageInfoFlags.of(0)
).versionName.toVersion()
} else {
packageManager.getPackageInfo(
packageName, 0
).versionName.toVersion()
}
private fun Context.getLatestApk() =
File(getExternalFilesDir("apk"), "latest.apk")
fun installLatestApk(context: Context = App.context) = context.run {
kotlin.runCatching {
val contentUri = FileProvider.getUriForFile(this, getFileProvider(), getLatestApk())
val intent = Intent(Intent.ACTION_VIEW).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
setDataAndType(contentUri, "application/vnd.android.package-archive")
}
startActivity(intent)
}.onFailure { throwable: Throwable ->
throwable.printStackTrace()
context.toast(R.string.app_update_failed)
}
}
suspend fun deleteOutdatedApk(
context: Context = App.context,
) = context.runCatching {
val apkFile = getLatestApk()
if (apkFile.exists()) {
val apkVersion = context.packageManager.getPackageArchiveInfo(
apkFile.absolutePath, 0
)?.versionName.toVersion()
if (apkVersion <= context.getCurrentVersion()) {
apkFile.delete()
}
}
}
suspend fun downloadApk(
context: Context = App.context,
latestRelease: LatestRelease
): Flow<DownloadStatus> = withContext(Dispatchers.IO) {
val apkVersion = context.packageManager.getPackageArchiveInfo(
context.getLatestApk().absolutePath, 0
)?.versionName.toVersion()
Log.d(TAG, apkVersion.toString())
if (apkVersion >= latestRelease.name.toVersion()) {
return@withContext flow<DownloadStatus> { emit(DownloadStatus.Finished(context.getLatestApk())) }
}
val abiList = Build.SUPPORTED_ABIS
val preferredArch = abiList.firstOrNull() ?: return@withContext emptyFlow()
val targetUrl = latestRelease.assets?.find {
return@find it.name?.contains(preferredArch) ?: false
}?.browserDownloadUrl ?: return@withContext emptyFlow()
val request = Request.Builder().url(targetUrl).build()
try {
val response = client.newCall(request).execute()
val responseBody = response.body
if (responseBody != null) {
return@withContext responseBody.downloadFileWithProgress(context.getLatestApk())
}
} catch (e: Exception) {
e.printStackTrace()
}
emptyFlow()
}
private fun ResponseBody.downloadFileWithProgress(saveFile: File): Flow<DownloadStatus> = flow {
emit(DownloadStatus.Progress(0))
var deleteFile = true
try {
byteStream().use { inputStream ->
saveFile.outputStream().use { outputStream ->
val totalBytes = contentLength()
val data = ByteArray(8_192)
var progressBytes = 0L
while (true) {
val bytes = inputStream.read(data)
if (bytes == -1) {
break
}
outputStream.channel
outputStream.write(data, 0, bytes)
progressBytes += bytes
emit(DownloadStatus.Progress(percent = ((progressBytes * 100) / totalBytes).toInt()))
}
when {
progressBytes < totalBytes -> throw Exception("missing bytes")
progressBytes > totalBytes -> throw Exception("too many bytes")
else -> deleteFile = false
}
}
}
emit(DownloadStatus.Finished(saveFile))
} finally {
if (deleteFile) {
saveFile.delete()
}
}
}.flowOn(Dispatchers.IO).distinctUntilChanged()
@Serializable
data class LatestRelease(
@SerialName("html_url") val htmlUrl: String? = null,
@SerialName("tag_name") val tagName: String? = null,
val name: String? = null,
val draft: Boolean? = null,
@SerialName("prerelease") val preRelease: Boolean? = null,
@SerialName("created_at") val createdAt: String? = null,
@SerialName("published_at") val publishedAt: String? = null,
val assets: List<AssetsItem>? = null,
val body: String? = null,
)
@Serializable
data class AssetsItem(
val name: String? = null,
@SerialName("content_type") val contentType: String? = null,
val size: Int? = null,
@SerialName("download_count") val downloadCount: Int? = null,
@SerialName("created_at") val createdAt: String? = null,
@SerialName("updated_at") val updatedAt: String? = null,
@SerialName("browser_download_url") val browserDownloadUrl: String? = null,
)
sealed class DownloadStatus {
object NotYet : DownloadStatus()
data class Progress(val percent: Int) : DownloadStatus()
data class Finished(val file: File) : DownloadStatus()
}
private val pattern = Pattern.compile("""v?(\d+)\.(\d+)\.(\d+)(-(\w+)\.(\d+))?""")
private val EMPTY_VERSION = Version.Stable()
fun String?.toVersion(): Version = this?.run {
val matcher = pattern.matcher(this)
if (matcher.find()) {
val major = matcher.group(1)?.toInt() ?: 0
val minor = matcher.group(2)?.toInt() ?: 0
val patch = matcher.group(3)?.toInt() ?: 0
val buildNumber = matcher.group(6)?.toInt() ?: 0
when (matcher.group(5)) {
"beta" -> Version.Beta(major, minor, patch, buildNumber)
"rc" -> Version.ReleaseCandidate(major, minor, patch, buildNumber)
else -> Version.Stable(major, minor, patch)
}
} else EMPTY_VERSION
} ?: EMPTY_VERSION
sealed class Version(
val major: Int,
val minor: Int,
val patch: Int,
val build: Int = 0
) : Comparable<Version> {
companion object {
private const val BUILD = 1L
private const val PATCH = 100L
private const val MINOR = 10_000L
private const val MAJOR = 1_000_000L
}
abstract fun toVersionName(): String
abstract fun toNumber(): Long
class Beta(versionMajor: Int, versionMinor: Int, versionPatch: Int, versionBuild: Int) :
Version(versionMajor, versionMinor, versionPatch, versionBuild) {
override fun toVersionName(): String =
"${major}.${minor}.${patch}-beta.$build"
override fun toNumber(): Long =
major * MAJOR + minor * MINOR + patch * PATCH + build * BUILD
}
class Stable(versionMajor: Int = 0, versionMinor: Int = 0, versionPatch: Int = 0) :
Version(versionMajor, versionMinor, versionPatch) {
override fun toVersionName(): String =
"${major}.${minor}.${patch}"
override fun toNumber(): Long =
major * MAJOR + minor * MINOR + patch * PATCH + build * BUILD + 100
// Prioritize stable versions
}
class ReleaseCandidate(
versionMajor: Int,
versionMinor: Int,
versionPatch: Int,
versionBuild: Int
) :
Version(versionMajor, versionMinor, versionPatch, versionBuild) {
override fun toVersionName(): String =
"${major}.${minor}.${patch}-rc.$build"
override fun toNumber(): Long =
major * MAJOR + minor * MINOR + patch * PATCH + build * BUILD + 25
}
class Alpha(versionMajor: Int = 0, versionMinor: Int = 0, versionPatch: Int = 0) :
Version(versionMajor, versionMinor, versionPatch) {
override fun toVersionName(): String =
"${major}.${minor}.${patch}-alpha.$build"
override fun toNumber(): Long =
major * MAJOR + minor * MINOR + patch * PATCH + build * BUILD + 50
}
override operator fun compareTo(other: Version): Int =
this.toNumber().compareTo(other.toNumber())
}
}

View File

@@ -0,0 +1,41 @@
package org.xtimms.tokusho.crash
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import dagger.hilt.android.AndroidEntryPoint
import org.xtimms.tokusho.LocalDarkTheme
import org.xtimms.tokusho.LocalDynamicColorSwitch
import org.xtimms.tokusho.MainActivity
import org.xtimms.tokusho.SettingsProvider
import org.xtimms.tokusho.ui.theme.TokushoTheme
@AndroidEntryPoint
class CrashActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val exception = GlobalExceptionHandler.getThrowableFromIntent(intent)
setContent {
SettingsProvider {
TokushoTheme(
darkTheme = LocalDarkTheme.current.isDarkTheme(),
isDynamicColorEnabled = LocalDynamicColorSwitch.current,
isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled,
) {
CrashScreen(
exception = exception,
onRestartClick = {
finishAffinity()
startActivity(Intent(this@CrashActivity, MainActivity::class.java))
},
)
}
}
}
}
}

View File

@@ -0,0 +1,71 @@
package org.xtimms.tokusho.crash
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.screens.InfoScreen
import org.xtimms.tokusho.ui.theme.TokushoTheme
import org.xtimms.tokusho.utils.CrashLogUtil
@Composable
fun CrashScreen(
exception: Throwable?,
onRestartClick: () -> Unit,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
InfoScreen(
icon = Icons.Outlined.BugReport,
headingText = stringResource(R.string.crash_screen_title),
subtitleText = stringResource(R.string.crash_screen_description, stringResource(R.string.app_name)),
acceptText = stringResource(R.string.pref_dump_crash_logs),
onAcceptClick = {
scope.launch {
CrashLogUtil(context).dumpLogs()
}
},
rejectText = stringResource(R.string.crash_screen_restart_application),
onRejectClick = onRestartClick,
) {
Box(
modifier = Modifier
.padding(vertical = 8.dp)
.clip(MaterialTheme.shapes.small)
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
Text(
text = exception.toString(),
modifier = Modifier
.padding(all = 8.dp),
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@PreviewLightDark
@Composable
private fun CrashScreenPreview() {
TokushoTheme {
CrashScreen(exception = RuntimeException("Dummy")) {}
}
}

View File

@@ -0,0 +1,76 @@
package org.xtimms.tokusho.crash
import android.content.Context
import android.content.Intent
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlin.system.exitProcess
class GlobalExceptionHandler private constructor(
private val applicationContext: Context,
private val defaultHandler: Thread.UncaughtExceptionHandler,
private val activityToBeLaunched: Class<*>,
) : Thread.UncaughtExceptionHandler {
object ThrowableSerializer : KSerializer<Throwable> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("Throwable", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): Throwable =
Throwable(message = decoder.decodeString())
override fun serialize(encoder: Encoder, value: Throwable) =
encoder.encodeString(value.stackTraceToString())
}
override fun uncaughtException(thread: Thread, exception: Throwable) {
try {
launchActivity(applicationContext, activityToBeLaunched, exception)
exitProcess(0)
} catch (_: Exception) {
defaultHandler.uncaughtException(thread, exception)
}
}
private fun launchActivity(
applicationContext: Context,
activity: Class<*>,
exception: Throwable,
) {
val intent = Intent(applicationContext, activity).apply {
putExtra(INTENT_EXTRA, Json.encodeToString(ThrowableSerializer, exception))
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
}
applicationContext.startActivity(intent)
}
companion object {
private const val INTENT_EXTRA = "Throwable"
fun initialize(
applicationContext: Context,
activityToBeLaunched: Class<*>,
) {
val handler = GlobalExceptionHandler(
applicationContext,
Thread.getDefaultUncaughtExceptionHandler() as Thread.UncaughtExceptionHandler,
activityToBeLaunched,
)
Thread.setDefaultUncaughtExceptionHandler(handler)
}
fun getThrowableFromIntent(intent: Intent): Throwable? {
return try {
Json.decodeFromString(ThrowableSerializer, intent.getStringExtra(INTENT_EXTRA)!!)
} catch (e: Exception) {
null
}
}
}
}

View File

@@ -0,0 +1,41 @@
package org.xtimms.tokusho.data
import android.content.Context
import android.os.StatFs
import androidx.annotation.WorkerThread
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.Cache
import java.io.File
import javax.inject.Inject
private const val DIR_NAME = "manga"
private const val NOMEDIA = ".nomedia"
private const val CACHE_DISK_PERCENTAGE = 0.02
private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB
private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
@Reusable
class LocalStorageManager @Inject constructor(
@ApplicationContext private val context: Context
) {
@WorkerThread
fun createHttpCache(): Cache {
val directory = File(context.externalCacheDir ?: context.cacheDir, "http")
directory.mkdirs()
val maxSize = calculateDiskCacheSize(directory)
return Cache(directory, maxSize)
}
private fun calculateDiskCacheSize(cacheDirectory: File): Long {
return try {
val cacheDir = StatFs(cacheDirectory.absolutePath)
val size = CACHE_DISK_PERCENTAGE * cacheDir.blockCountLong * cacheDir.blockSizeLong
return size.toLong().coerceIn(CACHE_SIZE_MIN, CACHE_SIZE_MAX)
} catch (_: Exception) {
CACHE_SIZE_MIN
}
}
}

View File

@@ -0,0 +1,30 @@
package org.xtimms.tokusho.data.repository
import dagger.Reusable
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.tokusho.BuildConfig
import org.xtimms.tokusho.core.database.MangaDatabase
import org.xtimms.tokusho.core.database.dao.MangaSourcesDao
import java.util.Collections
import java.util.EnumSet
import javax.inject.Inject
@Reusable
class MangaSourcesRepository @Inject constructor(
private val db: MangaDatabase,
) {
private val dao: MangaSourcesDao
get() = db.getSourcesDao()
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
remove(MangaSource.LOCAL)
if (!BuildConfig.DEBUG) {
remove(MangaSource.DUMMY)
}
}
val allMangaSources: Set<MangaSource>
get() = Collections.unmodifiableSet(remoteSources)
}

View File

@@ -0,0 +1,7 @@
package org.xtimms.tokusho.sections.explore
import org.xtimms.tokusho.core.base.event.UiEvent
interface ExploreEvent : UiEvent {
}

View File

@@ -0,0 +1,15 @@
package org.xtimms.tokusho.sections.explore
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.tokusho.core.base.state.UiState
data class ExploreUiState(
val sources: List<MangaSource> = emptyList(),
val coil: ImageLoader? = null,
override val isLoading: Boolean = false,
override val message: String? = null,
) : UiState() {
override fun setLoading(value: Boolean) = copy(isLoading = value)
override fun setMessage(value: String?) = copy(message = value)
}

View File

@@ -0,0 +1,188 @@
package org.xtimms.tokusho.sections.explore
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bookmarks
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.SdStorage
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import coil.ImageLoader
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.collapsable
import org.xtimms.tokusho.core.components.ExploreButton
import org.xtimms.tokusho.core.components.SourceItem
import org.xtimms.tokusho.core.components.icons.Dice
import org.xtimms.tokusho.core.parser.favicon.faviconUri
import org.xtimms.tokusho.sections.list.LIST_DESTINATION
import org.xtimms.tokusho.utils.system.toast
const val EXPLORE_DESTINATION = "explore"
@Composable
fun ExploreView(
coil: ImageLoader,
navController: NavController,
topBarHeightPx: Float,
topBarOffsetY: Animatable<Float, AnimationVector1D>,
padding: PaddingValues,
) {
val viewModel: ExploreViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
ExploreViewContent(
coil = coil,
navController = navController,
uiState = uiState,
event = viewModel,
topBarHeightPx = topBarHeightPx,
topBarOffsetY = topBarOffsetY,
padding = padding
)
}
@Composable
fun ExploreViewContent(
coil: ImageLoader,
navController: NavController,
uiState: ExploreUiState,
event: ExploreEvent?,
nestedScrollConnection: NestedScrollConnection? = null,
topBarHeightPx: Float = 0f,
topBarOffsetY: Animatable<Float, AnimationVector1D> = Animatable(0f),
padding: PaddingValues = PaddingValues(),
) {
val context = LocalContext.current
val layoutDirection = LocalLayoutDirection.current
val scrollState = rememberScrollState()
if (uiState.message != null) {
LaunchedEffect(uiState.message) {
context.toast(uiState.message)
event?.onMessageDisplayed()
}
}
Box(
modifier = Modifier
.clipToBounds()
.fillMaxSize(),
contentAlignment = Alignment.TopCenter
) {
val listState = rememberLazyGridState()
val listModifier = Modifier
.fillMaxWidth()
.align(Alignment.TopStart)
.then(
if (nestedScrollConnection != null)
Modifier.nestedScroll(nestedScrollConnection)
else Modifier
)
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 72.dp),
modifier = listModifier
.collapsable(
state = listState,
topBarHeightPx = topBarHeightPx,
topBarOffsetY = topBarOffsetY,
),
state = listState,
contentPadding = PaddingValues(
start = padding.calculateStartPadding(layoutDirection) + 8.dp,
top = padding.calculateTopPadding(),
end = padding.calculateEndPadding(layoutDirection) + 8.dp,
bottom = padding.calculateBottomPadding()
),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)
) {
item(
span = { GridItemSpan(maxCurrentLineSpan) }
) {
Row {
ExploreButton(
text = stringResource(R.string.local_storage),
icon = Icons.Outlined.SdStorage,
modifier = Modifier.weight(1f),
onClick = { }
)
ExploreButton(
text = stringResource(R.string.bookmarks),
icon = Icons.Outlined.Bookmarks,
modifier = Modifier.weight(1f),
onClick = { }
)
}
}
item(
span = { GridItemSpan(maxCurrentLineSpan) }
) {
Row {
ExploreButton(
text = stringResource(R.string.random),
icon = Icons.Outlined.Dice,
modifier = Modifier.weight(1f),
onClick = { },
)
ExploreButton(
text = stringResource(R.string.downloads),
icon = Icons.Outlined.Download,
modifier = Modifier.weight(1f),
onClick = { throw IllegalAccessException() },
)
}
}
items(
items = uiState.sources,
key = { it.ordinal },
contentType = { it }
) { item ->
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.TopCenter
) {
SourceItem(
coil = coil,
faviconUrl = item.faviconUri(),
title = item.title,
onClick = {
navController.navigate(LIST_DESTINATION)
}
)
}
}
}
}
}

View File

@@ -0,0 +1,36 @@
package org.xtimms.tokusho.sections.explore
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
import org.xtimms.tokusho.data.repository.MangaSourcesRepository
import javax.inject.Inject
@HiltViewModel
class ExploreViewModel @Inject constructor(
private val mangaSourcesRepository: MangaSourcesRepository,
) : BaseViewModel<ExploreUiState>(), ExploreEvent {
override val mutableUiState = MutableStateFlow(
ExploreUiState(
isLoading = true,
)
)
init {
viewModelScope.launch(Dispatchers.IO) {
val result = mangaSourcesRepository.allMangaSources
mutableUiState.update {
it.copy(
sources = result.toList(),
)
}
setLoading(false)
}
}
}

View File

@@ -0,0 +1,65 @@
package org.xtimms.tokusho.sections.history
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
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.collapsable
import org.xtimms.tokusho.core.screens.EmptyScreen
import org.xtimms.tokusho.ui.theme.TokushoTheme
const val HISTORY_DESTINATION = "history"
@Composable
fun HistoryView(
topBarHeightPx: Float,
padding: PaddingValues,
) {
HistoryViewContent(
topBarHeightPx = topBarHeightPx,
padding = padding
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HistoryViewContent(
topBarHeightPx: Float,
topBarOffsetY: Animatable<Float, AnimationVector1D> = Animatable(0f),
padding: PaddingValues,
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.collapsable(
state = scrollState,
topBarHeightPx = topBarHeightPx,
topBarOffsetY = topBarOffsetY
)
.padding(padding)
) {
EmptyScreen(title = R.string.nothing_here)
}
}
@Preview
@Composable
fun HistoryPreview() {
TokushoTheme {
Surface {
HistoryViewContent(
padding = PaddingValues(),
topBarHeightPx = 0f,
)
}
}
}

View File

@@ -0,0 +1,36 @@
package org.xtimms.tokusho.sections.list
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import org.xtimms.tokusho.core.components.ScaffoldWithSmallTopAppBar
const val LIST_DESTINATION = "list"
@Composable
fun MangaListView(
sourceName: String,
navigateBack: () -> Unit,
) {
val scrollState = rememberScrollState()
ScaffoldWithSmallTopAppBar(
title = sourceName,
navigateBack = navigateBack
) { padding ->
Column(
modifier = Modifier
.verticalScroll(scrollState)
.padding(padding),
horizontalAlignment = Alignment.CenterHorizontally
) {
}
}
}

View File

@@ -0,0 +1,114 @@
package org.xtimms.tokusho.sections.search
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.BackIconButton
import org.xtimms.tokusho.core.screens.EmptyScreen
import org.xtimms.tokusho.ui.theme.TokushoTheme
const val SEARCH_DESTINATION = "search"
@Composable
fun SearchHostView(
padding: PaddingValues,
isCompactScreen: Boolean,
navigateBack: () -> Unit,
) {
var query by remember { mutableStateOf("") }
val performSearch = remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Column(
modifier = Modifier
.statusBarsPadding()
.padding(top = padding.calculateTopPadding())
.fillMaxWidth()
) {
TextField(
value = query,
onValueChange = { query = it },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.height(64.dp),
placeholder = { Text(text = stringResource(R.string.search)) },
leadingIcon = {
if (isCompactScreen) BackIconButton(onClick = navigateBack)
},
keyboardActions = KeyboardActions(
onSearch = { performSearch.value = true }
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = MaterialTheme.colorScheme.outlineVariant,
unfocusedIndicatorColor = MaterialTheme.colorScheme.outlineVariant
)
)
SearchView(
query = query,
performSearch = performSearch,
showAsGrid = !isCompactScreen,
contentPadding = PaddingValues(bottom = padding.calculateBottomPadding()),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchView(
query: String,
performSearch: MutableState<Boolean>,
showAsGrid: Boolean,
contentPadding: PaddingValues = PaddingValues(),
) {
val context = LocalContext.current
EmptyScreen(title = R.string.nothing_here)
}
@Preview(showBackground = true)
@Composable
fun SearchPreview() {
TokushoTheme {
SearchHostView(
isCompactScreen = true,
padding = PaddingValues(),
navigateBack = {},
)
}
}

View File

@@ -0,0 +1,49 @@
package org.xtimms.tokusho.sections.settings
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
import org.xtimms.tokusho.core.components.SettingItem
const val SETTINGS_DESTINATION = "settings"
@Composable
fun SettingsView(
navigateBack: () -> Unit,
navigateToAppearance: () -> Unit,
navigateToAbout: () -> Unit,
) {
ScaffoldWithTopAppBar(
title = stringResource(R.string.settings),
navigateBack = navigateBack
) { padding ->
LazyColumn(
modifier = Modifier
.padding(padding)
) {
item {
SettingItem(
title = stringResource(id = R.string.appearance),
description = stringResource(id = R.string.appearance_page),
icon = Icons.Outlined.Palette,
onClick = navigateToAppearance
)
}
item {
SettingItem(
title = stringResource(id = R.string.about),
description = stringResource(id = R.string.about_page),
icon = Icons.Outlined.Info,
onClick = navigateToAbout
)
}
}
}
}

View File

@@ -0,0 +1,80 @@
package org.xtimms.tokusho.sections.settings.about
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Update
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.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import org.xtimms.tokusho.App
import org.xtimms.tokusho.App.Companion.packageInfo
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.PreferenceItem
import org.xtimms.tokusho.core.components.PreferenceSwitchWithDivider
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
import org.xtimms.tokusho.core.prefs.AUTO_UPDATE
import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.utils.system.toast
const val ABOUT_DESTINATION = "about"
const val weblate = "https://hosted.weblate.org/engage/tokusho/"
@Composable
fun AboutView(
navigateBack: () -> Unit,
navigateToUpdatePage: () -> Unit,
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
var isAutoUpdateEnabled by remember { mutableStateOf(AppSettings.isAutoUpdateEnabled()) }
val info = App.getVersionReport()
val versionName = packageInfo.versionName
ScaffoldWithTopAppBar(
title = stringResource(R.string.about),
navigateBack = navigateBack
) { padding ->
LazyColumn(
modifier = Modifier
.padding(padding)
) {
item {
PreferenceSwitchWithDivider(
title = stringResource(R.string.auto_update),
description = stringResource(R.string.check_for_updates_desc),
icon = Icons.Outlined.Update,
isChecked = isAutoUpdateEnabled,
isSwitchEnabled = true,
onClick = navigateToUpdatePage,
onChecked = {
isAutoUpdateEnabled = !isAutoUpdateEnabled
AppSettings.updateValue(AUTO_UPDATE, isAutoUpdateEnabled)
}
)
}
item {
PreferenceItem(
title = stringResource(id = R.string.version),
description = versionName,
icon = Icons.Outlined.Info
) {
clipboardManager.setText(AnnotatedString(info))
context.toast(R.string.info_copied)
}
}
}
}
}

View File

@@ -0,0 +1,197 @@
package org.xtimms.tokusho.sections.settings.about
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.PreferenceInfo
import org.xtimms.tokusho.core.components.PreferenceSingleChoiceItem
import org.xtimms.tokusho.core.components.PreferenceSubtitle
import org.xtimms.tokusho.core.components.PreferenceSwitchWithContainer
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
import org.xtimms.tokusho.core.prefs.AUTO_UPDATE
import org.xtimms.tokusho.core.prefs.AppSettings.updateBoolean
import org.xtimms.tokusho.core.prefs.AppSettings.updateInt
import org.xtimms.tokusho.core.prefs.PRE_RELEASE
import org.xtimms.tokusho.core.prefs.STABLE
import org.xtimms.tokusho.core.prefs.UPDATE_CHANNEL
import org.xtimms.tokusho.core.screens.UpdateDialog
import org.xtimms.tokusho.core.updates.Updater
import org.xtimms.tokusho.utils.lang.booleanState
import org.xtimms.tokusho.utils.lang.intState
import org.xtimms.tokusho.utils.system.suspendToast
const val UPDATES_DESTINATION = "updates"
@Composable
fun UpdateView(
navigateBack: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var autoUpdate by AUTO_UPDATE.booleanState
var updateChannel by UPDATE_CHANNEL.intState
var latestRelease by remember { mutableStateOf(Updater.LatestRelease()) }
var showUpdateDialog by remember { mutableStateOf(false) }
ScaffoldWithTopAppBar(
title = stringResource(R.string.auto_update),
navigateBack = navigateBack
) { padding ->
LazyColumn(
modifier = Modifier
.padding(padding)
) {
item {
PreferenceSwitchWithContainer(
title = stringResource(id = R.string.enable_auto_update),
icon = null,
isChecked = autoUpdate
) {
autoUpdate = !autoUpdate
AUTO_UPDATE.updateBoolean(autoUpdate)
}
}
item {
PreferenceSubtitle(
modifier = Modifier.padding(horizontal = 4.dp),
text = stringResource(id = R.string.update_channel)
)
}
item {
PreferenceSingleChoiceItem(
text = stringResource(id = R.string.stable_channel),
selected = updateChannel == STABLE,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)
) {
updateChannel = STABLE
UPDATE_CHANNEL.updateInt(updateChannel)
}
}
item {
PreferenceSingleChoiceItem(
text = stringResource(id = R.string.pre_release_channel),
selected = updateChannel == PRE_RELEASE,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)
) {
updateChannel = PRE_RELEASE
UPDATE_CHANNEL.updateInt(updateChannel)
}
}
item {
var isLoading by remember { mutableStateOf(false) }
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth()
) {
ProgressIndicatorButton(
modifier = Modifier
.padding(horizontal = 24.dp)
.padding(top = 6.dp)
.padding(bottom = 12.dp),
text = stringResource(
id = R.string.check_for_updates
),
icon = Icons.Outlined.Update,
isLoading = isLoading
) {
if (!isLoading)
scope.launch {
runCatching {
isLoading = true
withContext(Dispatchers.IO) {
Updater.checkForUpdate()?.let {
latestRelease = it
showUpdateDialog = true
}
?: context.suspendToast(R.string.app_up_to_date)
}
isLoading = false
}
.onFailure {
it.printStackTrace()
context.suspendToast(R.string.app_update_failed)
isLoading = false
}
}
}
}
HorizontalDivider()
}
item {
PreferenceInfo(
modifier = Modifier
.padding(horizontal = 4.dp),
text = stringResource(id = R.string.update_channel_desc)
)
}
}
}
if (showUpdateDialog)
UpdateDialog(onDismissRequest = { showUpdateDialog = false }, latestRelease = latestRelease)
}
@Composable
fun ProgressIndicatorButton(
modifier: Modifier = Modifier,
isLoading: Boolean = false,
text: String,
icon: ImageVector,
onClick: () -> Unit,
) {
FilledTonalButton(
modifier = modifier,
onClick = onClick,
contentPadding = ButtonDefaults.ButtonWithIconContentPadding
) {
if (isLoading)
Box(modifier = Modifier.size(18.dp)) {
CircularProgressIndicator(
modifier = Modifier
.size(16.dp)
.align(Alignment.Center),
strokeWidth = 3.dp
)
}
else Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Text(
text = text,
modifier = Modifier.padding(start = 8.dp)
)
}
}

View File

@@ -0,0 +1,277 @@
package org.xtimms.tokusho.sections.settings.appearance
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.ExperimentalFoundationApi
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
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.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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp
import coil.ImageLoader
import com.google.accompanist.pager.HorizontalPagerIndicator
import com.google.android.material.color.DynamicColors
import org.xtimms.tokusho.LocalDarkTheme
import org.xtimms.tokusho.LocalDynamicColorSwitch
import org.xtimms.tokusho.LocalPaletteStyleIndex
import org.xtimms.tokusho.LocalSeedColor
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.PreferenceItem
import org.xtimms.tokusho.core.components.PreferenceSwitch
import org.xtimms.tokusho.core.components.PreferenceSwitchWithDivider
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.OFF
import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.ON
import org.xtimms.tokusho.core.prefs.STYLE_MONOCHROME
import org.xtimms.tokusho.core.prefs.STYLE_TONAL_SPOT
import org.xtimms.tokusho.core.prefs.paletteStyles
import org.xtimms.tokusho.ui.harmonize.hct.Hct
import org.xtimms.tokusho.ui.monet.LocalTonalPalettes
import org.xtimms.tokusho.ui.monet.PaletteStyle
import org.xtimms.tokusho.ui.monet.TonalPalettes
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
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(
coil: ImageLoader,
navigateBack: () -> Unit,
navigateToDarkTheme: () -> Unit,
navigateToLanguages: () -> Unit
) {
val image by remember {
mutableIntStateOf(
listOf(
R.drawable.ookami, R.drawable.sample1
).random()
)
}
ScaffoldWithTopAppBar(
title = stringResource(R.string.appearance),
navigateBack = navigateBack
) { padding ->
Column(
Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
) {
MangaCard(
modifier = Modifier.padding(18.dp),
thumbnailUrl = image
)
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()
.clearAndSetSemantics { },
state = pagerState,
contentPadding = PaddingValues(horizontal = 12.dp)
) { page ->
if (page < pageCount - 1) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) { ColorButtons(colorList[page]) }
} else {
val isSelected =
LocalPaletteStyleIndex.current == STYLE_MONOCHROME && !LocalDynamicColorSwitch.current
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
ColorButtonImpl(
modifier = Modifier,
isSelected = { isSelected },
tonalPalettes = Color.Black.toTonalPalettes(PaletteStyle.Monochrome),
onClick = {
AppSettings.switchDynamicColor(enabled = false)
AppSettings.modifyThemeSeedColor(
Color.Black.toArgb(), STYLE_MONOCHROME
)
})
}
}
}
HorizontalPagerIndicator(pagerState = pagerState,
pageCount = pageCount,
modifier = Modifier
.clearAndSetSemantics { }
.align(Alignment.CenterHorizontally)
.padding(vertical = 12.dp),
activeColor = MaterialTheme.colorScheme.primary,
inactiveColor = MaterialTheme.colorScheme.outlineVariant,
indicatorHeight = 6.dp,
indicatorWidth = 6.dp)
if (DynamicColors.isDynamicColorAvailable()) {
PreferenceSwitch(
title = stringResource(id = R.string.dynamic_color),
description = stringResource(id = R.string.dynamic_color_desc),
icon = Icons.Outlined.ColorLens,
isChecked = LocalDynamicColorSwitch.current,
onClick = {
AppSettings.switchDynamicColor()
})
}
val isDarkTheme = LocalDarkTheme.current.isDarkTheme()
PreferenceSwitchWithDivider(
title = stringResource(id = R.string.dark_theme),
icon = if (isDarkTheme) Icons.Outlined.DarkMode else Icons.Outlined.LightMode,
isChecked = isDarkTheme,
description = LocalDarkTheme.current.getDarkThemeDesc(),
onChecked = { AppSettings.modifyDarkThemePreference(if (isDarkTheme) OFF else ON) },
onClick = { navigateToDarkTheme() })
PreferenceItem(
title = stringResource(id = R.string.language),
icon = Icons.Outlined.Language,
description = getLanguageDesc(),
onClick = { navigateToLanguages() })
}
}
}
@Composable
fun RowScope.ColorButtons(color: Color) {
paletteStyles.subList(STYLE_TONAL_SPOT, STYLE_MONOCHROME).forEachIndexed { index, style ->
ColorButton(color = color, index = index, tonalStyle = style)
}
}
@Composable
fun RowScope.ColorButton(
modifier: Modifier = Modifier,
color: Color = Color.Green,
index: Int = 0,
tonalStyle: PaletteStyle = PaletteStyle.TonalSpot,
) {
val tonalPalettes by remember {
mutableStateOf(color.toTonalPalettes(tonalStyle))
}
val isSelect =
!LocalDynamicColorSwitch.current && LocalSeedColor.current == color.toArgb() && LocalPaletteStyleIndex.current == index
ColorButtonImpl(modifier = modifier, tonalPalettes = tonalPalettes, isSelected = { isSelect }) {
AppSettings.switchDynamicColor(enabled = false)
AppSettings.modifyThemeSeedColor(color.toArgb(), index)
}
}
@Composable
fun RowScope.ColorButtonImpl(
modifier: Modifier = Modifier,
isSelected: () -> Boolean = { false },
tonalPalettes: TonalPalettes,
cardColor: Color = MaterialTheme.colorScheme.surfaceContainer,
containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
onClick: () -> Unit = {}
) {
val containerSize by animateDpAsState(targetValue = if (isSelected.invoke()) 28.dp else 0.dp)
val iconSize by animateDpAsState(targetValue = if (isSelected.invoke()) 16.dp else 0.dp)
Surface(
modifier = modifier
.padding(4.dp)
.sizeIn(maxHeight = 80.dp, maxWidth = 80.dp, minHeight = 64.dp, minWidth = 64.dp)
.weight(1f, false)
.aspectRatio(1f),
shape = RoundedCornerShape(16.dp),
color = cardColor,
onClick = onClick
) {
CompositionLocalProvider(LocalTonalPalettes provides tonalPalettes) {
val color1 = 80.a1
val color2 = 90.a2
val color3 = 60.a3
Box(Modifier.fillMaxSize()) {
Box(modifier = modifier
.size(48.dp)
.clip(CircleShape)
.drawBehind { drawCircle(color1) }
.align(Alignment.Center)) {
Surface(
color = color2, modifier = Modifier
.align(Alignment.BottomStart)
.size(24.dp)
) {}
Surface(
color = color3, modifier = Modifier
.align(Alignment.BottomEnd)
.size(24.dp)
) {}
Box(
modifier = Modifier
.align(Alignment.Center)
.clip(CircleShape)
.size(containerSize)
.drawBehind { drawCircle(containerColor) },
) {
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = null,
modifier = Modifier
.size(iconSize)
.align(Alignment.Center),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
}
}
}

View File

@@ -0,0 +1,88 @@
package org.xtimms.tokusho.sections.settings.appearance
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.xtimms.tokusho.R
import org.xtimms.tokusho.ui.theme.TokushoTheme
@Composable
fun MangaCard(
modifier: Modifier = Modifier,
title: String = "Ookami to Koushinryou",
author: String = "Hasekura Isuna",
thumbnailUrl: Any = "",
showCancelButton: Boolean = false,
onCancel: () -> Unit = {},
onClick: () -> Unit = {},
progress: Float = 75f,
) {
ElevatedCard(
modifier = modifier
.height(136.dp)
.fillMaxWidth(),
onClick = onClick,
shape = MaterialTheme.shapes.small,
) {
Row(
modifier = Modifier.fillMaxWidth()
) {
Image(
modifier = Modifier
.padding()
.fillMaxHeight()
.clip(MaterialTheme.shapes.small),
painter = painterResource(id = R.drawable.ookami),
contentDescription = null
)
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp, vertical = 8.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.Top
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
if (author != "null") Text(
modifier = Modifier.padding(top = 3.dp),
text = author,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@PreviewLightDark
@Composable
fun MangaCardPreview() {
TokushoTheme {
MangaCard(
thumbnailUrl = "https://spice-and-wolf.com/special/img/visual_january.jpg"
)
}
}

View File

@@ -0,0 +1,77 @@
package org.xtimms.tokusho.sections.settings.appearance
import android.os.Build
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Contrast
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import org.xtimms.tokusho.LocalDarkTheme
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.PreferenceSingleChoiceItem
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.DarkThemePreference.Companion.FOLLOW_SYSTEM
import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.OFF
import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.ON
const val DARK_THEME_DESTINATION = "dark_theme"
@Composable
fun DarkThemeView(
navigateBack: () -> Unit
) {
val darkThemePreference = LocalDarkTheme.current
val isHighContrastModeEnabled = darkThemePreference.isHighContrastModeEnabled
ScaffoldWithTopAppBar(
title = stringResource(R.string.dark_theme),
navigateBack = navigateBack
) { padding ->
LazyColumn(
modifier = Modifier.padding(padding)) {
if (Build.VERSION.SDK_INT >= 29)
item {
PreferenceSingleChoiceItem(
text = stringResource(id = R.string.follow_system),
selected = darkThemePreference.darkThemeValue == FOLLOW_SYSTEM
) {
AppSettings.modifyDarkThemePreference(FOLLOW_SYSTEM)
}
}
item {
PreferenceSingleChoiceItem(
text = stringResource(id = R.string.on),
selected = darkThemePreference.darkThemeValue == ON
) {
AppSettings.modifyDarkThemePreference(ON)
}
}
item {
PreferenceSingleChoiceItem(
text = stringResource(id = R.string.off),
selected = darkThemePreference.darkThemeValue == OFF
) {
AppSettings.modifyDarkThemePreference(OFF)
}
}
item {
PreferenceSubtitle(text = stringResource(R.string.additional_settings))
}
item {
PreferenceSwitch(
title = stringResource(id = R.string.high_contrast),
icon = Icons.Outlined.Contrast,
isChecked = isHighContrastModeEnabled,
onClick = {
AppSettings.modifyDarkThemePreference(isHighContrastModeEnabled = !isHighContrastModeEnabled)
})
}
}
}
}

View File

@@ -0,0 +1,200 @@
package org.xtimms.tokusho.sections.settings.appearance
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos
import androidx.compose.material.icons.outlined.Translate
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
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.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
const val LANGUAGES_DESTINATION = "languages"
@Composable
fun LanguagesView(
navigateBack: () -> Unit
) {
var language by remember { mutableStateOf(AppSettings.getLanguageNumber()) }
val context = LocalContext.current
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Intent(android.provider.Settings.ACTION_APP_LOCALE_SETTINGS).apply {
val uri = Uri.fromParts("package", context.packageName, null)
data = uri
}
} else {
Intent()
}
val isSystemLocaleSettingsAvailable =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.queryIntentActivities(
intent, PackageManager.MATCH_ALL
).isNotEmpty()
} else {
false
}
LanguageViewImpl(
navigateBack = navigateBack,
languageMap = languageMap,
isSystemLocaleSettingsAvailable = isSystemLocaleSettingsAvailable,
onNavigateToSystemLocaleSettings = {
if (isSystemLocaleSettingsAvailable) {
context.startActivity(intent)
}
},
selectedLanguage = language,
) {
language = it
AppSettings.encodeInt(LANGUAGE, language)
MainActivity.setLanguage(getLanguageConfiguration())
}
}
@Composable
private fun LanguageViewImpl(
navigateBack: () -> Unit = {},
languageMap: Map<Int, String>,
isSystemLocaleSettingsAvailable: Boolean = false,
onNavigateToSystemLocaleSettings: () -> Unit,
selectedLanguage: Int,
onLanguageSelected: (Int) -> Unit = {}
) {
val uriHandler = LocalUriHandler.current
ScaffoldWithTopAppBar(
title = stringResource(R.string.language),
navigateBack = navigateBack
) { padding ->
LazyColumn(
modifier = Modifier
.padding(padding)
) {
item {
PreferencesHintCard(
title = stringResource(R.string.translate),
description = stringResource(R.string.translate_desc),
icon = Icons.Outlined.Translate,
) { uriHandler.openUri(weblate) }
}
item {
PreferenceSingleChoiceItem(
text = stringResource(R.string.follow_system),
selected = selectedLanguage == SYSTEM_DEFAULT,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 18.dp)
) { onLanguageSelected(SYSTEM_DEFAULT) }
}
for (languageData in languageMap) {
item {
PreferenceSingleChoiceItem(
text = getLanguageDesc(languageData.key),
selected = selectedLanguage == languageData.key,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 18.dp)
) { onLanguageSelected(languageData.key) }
}
}
if (isSystemLocaleSettingsAvailable) {
item {
HorizontalDivider()
Surface(
modifier = Modifier.clickable(
onClick = onNavigateToSystemLocaleSettings
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(PaddingValues(horizontal = 12.dp, vertical = 18.dp)),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier
.weight(1f)
.padding(start = 10.dp)
) {
Text(
text = stringResource(R.string.system_settings),
maxLines = 1,
style = MaterialTheme.typography.titleLarge.copy(fontSize = 20.sp),
color = MaterialTheme.colorScheme.onSurface,
overflow = TextOverflow.Ellipsis
)
}
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos,
contentDescription = null,
modifier = Modifier
.padding(end = 16.dp)
.size(18.dp)
)
}
}
}
}
}
}
}
@Preview
@Composable
private fun LanguagePagePreview() {
var language by remember {
mutableIntStateOf(1)
}
val map = buildMap<Int, String> {
repeat(38) {
put(it + 1, "")
}
}
TokushoTheme {
LanguageViewImpl(
languageMap = map,
isSystemLocaleSettingsAvailable = true,
onNavigateToSystemLocaleSettings = { /*TODO*/ },
selectedLanguage = language
) {
language = it
}
}
}

View File

@@ -0,0 +1,9 @@
package org.xtimms.tokusho.sections.shelf
data class ShelfItem(
val libraryManga: ShelfManga,
val downloadCount: Long = -1,
val unreadCount: Long = -1,
val isLocal: Boolean = false,
val sourceLanguage: String = "",
)

View File

@@ -0,0 +1,24 @@
package org.xtimms.tokusho.sections.shelf
import org.koitharu.kotatsu.parsers.model.Manga
data class ShelfManga(
val manga: Manga,
val category: Long,
val totalChapters: Long,
val readCount: Long,
val bookmarkCount: Long,
val latestUpload: Long,
val chapterFetchedAt: Long,
val lastRead: Long,
) {
val id: Long = manga.id
val unreadCount
get() = totalChapters - readCount
val hasBookmarks
get() = bookmarkCount > 0
val hasStarted = readCount > 0
}

View File

@@ -0,0 +1,78 @@
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
import androidx.compose.foundation.layout.padding
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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.screens.EmptyScreen
import org.xtimms.tokusho.utils.system.plus
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ShelfPager(
state: PagerState,
contentPadding: PaddingValues,
hasActiveFilters: Boolean,
searchQuery: String?,
onGlobalSearchClicked: () -> Unit,
getLibraryForPage: (Int) -> List<ShelfItem>,
) {
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 library = getLibraryForPage(page)
if (library.isEmpty()) {
ShelfPagerEmptyScreen(
searchQuery = searchQuery,
hasActiveFilters = hasActiveFilters,
contentPadding = contentPadding,
onGlobalSearchClicked = onGlobalSearchClicked,
)
return@HorizontalPager
}
}
}
@Composable
private fun ShelfPagerEmptyScreen(
searchQuery: String?,
hasActiveFilters: Boolean,
contentPadding: PaddingValues,
onGlobalSearchClicked: () -> Unit,
) {
val msg = when {
!searchQuery.isNullOrEmpty() -> R.string.no_results_found
hasActiveFilters -> R.string.error_no_match
else -> R.string.information_no_manga_category
}
Column(
modifier = Modifier
.padding(contentPadding + PaddingValues(8.dp))
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
EmptyScreen(
title = msg,
modifier = Modifier.weight(1f),
)
}
}

View File

@@ -0,0 +1,54 @@
package org.xtimms.tokusho.sections.shelf
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.pager.PagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import org.xtimms.tokusho.core.components.TabText
import org.xtimms.tokusho.core.model.ShelfCategory
import org.xtimms.tokusho.sections.shelf.ext.visualName
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
internal fun ShelfTabs(
categories: List<ShelfCategory>,
pagerState: PagerState,
getNumberOfMangaForCategory: (ShelfCategory) -> Int?,
onTabItemClick: (Int) -> Unit,
) {
Column(
modifier = Modifier.zIndex(1f),
) {
PrimaryScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
edgePadding = 0.dp,
// TODO: use default when width is fixed upstream
// https://issuetracker.google.com/issues/242879624
divider = {},
) {
categories.forEachIndexed { index, category ->
Tab(
selected = pagerState.currentPage == index,
onClick = { onTabItemClick(index) },
text = {
TabText(
text = category.visualName,
badgeCount = getNumberOfMangaForCategory(category),
)
},
unselectedContentColor = MaterialTheme.colorScheme.onSurface,
)
}
}
HorizontalDivider()
}
}

View File

@@ -0,0 +1,115 @@
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
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import kotlinx.coroutines.launch
import org.xtimms.tokusho.core.collapsable
import org.xtimms.tokusho.core.model.ShelfCategory
import org.xtimms.tokusho.ui.theme.TokushoTheme
const val SHELF_DESTINATION = "stub"
@Composable
fun ShelfView(
categories: List<ShelfCategory>,
currentPage: () -> Int,
showPageTabs: Boolean,
getNumberOfMangaForCategory: (ShelfCategory) -> Int?,
getLibraryForPage: (Int) -> List<ShelfItem>,
topBarHeightPx: Float,
padding: PaddingValues,
) {
ShelfViewContent(
categories = categories,
currentPage = currentPage,
showPageTabs = showPageTabs,
getNumberOfMangaForCategory = getNumberOfMangaForCategory,
getLibraryForPage = getLibraryForPage,
topBarHeightPx = topBarHeightPx,
padding = padding
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ShelfViewContent(
categories: List<ShelfCategory>,
currentPage: () -> Int,
showPageTabs: Boolean,
getNumberOfMangaForCategory: (ShelfCategory) -> Int?,
getLibraryForPage: (Int) -> List<ShelfItem>,
topBarHeightPx: Float,
topBarOffsetY: Animatable<Float, AnimationVector1D> = Animatable(0f),
padding: PaddingValues,
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.collapsable(
state = scrollState,
topBarHeightPx = topBarHeightPx,
topBarOffsetY = topBarOffsetY
)
.padding(padding)
) {
val coercedCurrentPage = remember { currentPage().coerceAtMost(categories.lastIndex) }
val pagerState = rememberPagerState(coercedCurrentPage) { categories.size }
val scope = rememberCoroutineScope()
if (showPageTabs && categories.size > 1) {
LaunchedEffect(categories) {
if (categories.size <= pagerState.currentPage) {
pagerState.scrollToPage(categories.size - 1)
}
}
ShelfTabs(
categories = categories,
pagerState = pagerState,
getNumberOfMangaForCategory = getNumberOfMangaForCategory,
) { scope.launch { pagerState.animateScrollToPage(it) } }
}
ShelfPager(
state = pagerState,
contentPadding = PaddingValues(bottom = padding.calculateBottomPadding()),
hasActiveFilters = false,
searchQuery = "",
onGlobalSearchClicked = { },
getLibraryForPage = getLibraryForPage,
)
}
}
@Preview
@Composable
fun ShelfPreview() {
val library: ShelfMap = emptyMap()
TokushoTheme {
Surface {
ShelfViewContent(
categories = emptyList(),
currentPage = { 2 },
showPageTabs = true,
getNumberOfMangaForCategory = { 2 },
getLibraryForPage = { library.values.toTypedArray().getOrNull(0).orEmpty() },
padding = PaddingValues(),
topBarHeightPx = 0f,
)
}
}
}
typealias ShelfMap = Map<ShelfCategory, List<ShelfItem>>

View File

@@ -0,0 +1,20 @@
package org.xtimms.tokusho.sections.shelf.ext
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.model.ShelfCategory
val ShelfCategory.visualName: String
@Composable
get() = when {
isSystemCategory -> stringResource(R.string.label_default)
else -> name
}
fun ShelfCategory.visualName(context: Context): String =
when {
isSystemCategory -> context.getString(R.string.label_default)
else -> name
}

View File

@@ -0,0 +1,419 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.xtimms.tokusho.ui.harmonize.hct;
import static java.lang.Math.max;
import org.xtimms.tokusho.ui.harmonize.utils.ColorUtils;
/**
* CAM16, a color appearance model. Colors are not just defined by their hex code, but rather, a hex
* code and viewing conditions.
*
* <p>CAM16 instances also have coordinates in the CAM16-UCS space, called J*, a*, b*, or jstar,
* astar, bstar in code. CAM16-UCS is included in the CAM16 specification, and should be used when
* measuring distances between colors.
*
* <p>In traditional color spaces, a color can be identified solely by the observer's measurement of
* the color. Color appearance models such as CAM16 also use information about the environment where
* the color was observed, known as the viewing conditions.
*
* <p>For example, white under the traditional assumption of a midday sun white point is accurately
* measured as a slightly chromatic blue by CAM16. (roughly, hue 203, chroma 3, lightness 100)
*/
public final class Cam16 {
// Transforms XYZ color space coordinates to 'cone'/'RGB' responses in CAM16.
static final double[][] XYZ_TO_CAM16RGB = {
{0.401288, 0.650173, -0.051461},
{-0.250268, 1.204414, 0.045854},
{-0.002079, 0.048952, 0.953127}
};
// Transforms 'cone'/'RGB' responses in CAM16 to XYZ color space coordinates.
static final double[][] CAM16RGB_TO_XYZ = {
{1.8620678, -1.0112547, 0.14918678},
{0.38752654, 0.62144744, -0.00897398},
{-0.01584150, -0.03412294, 1.0499644}
};
// CAM16 color dimensions, see getters for documentation.
private final double hue;
private final double chroma;
private final double j;
private final double q;
private final double m;
private final double s;
// Coordinates in UCS space. Used to determine color distance, like delta E equations in L*a*b*.
private final double jstar;
private final double astar;
private final double bstar;
/**
* CAM16 instances also have coordinates in the CAM16-UCS space, called J*, a*, b*, or jstar,
* astar, bstar in code. CAM16-UCS is included in the CAM16 specification, and is used to measure
* distances between colors.
*/
double distance(Cam16 other) {
double dJ = getJstar() - other.getJstar();
double dA = getAstar() - other.getAstar();
double dB = getBstar() - other.getBstar();
double dEPrime = Math.sqrt(dJ * dJ + dA * dA + dB * dB);
double dE = 1.41 * Math.pow(dEPrime, 0.63);
return dE;
}
/** Hue in CAM16 */
public double getHue() {
return hue;
}
/** Chroma in CAM16 */
public double getChroma() {
return chroma;
}
/** Lightness in CAM16 */
public double getJ() {
return j;
}
/**
* Brightness in CAM16.
*
* <p>Prefer lightness, brightness is an absolute quantity. For example, a sheet of white paper is
* much brighter viewed in sunlight than in indoor light, but it is the lightest object under any
* lighting.
*/
public double getQ() {
return q;
}
/**
* Colorfulness in CAM16.
*
* <p>Prefer chroma, colorfulness is an absolute quantity. For example, a yellow toy car is much
* more colorful outside than inside, but it has the same chroma in both environments.
*/
public double getM() {
return m;
}
/**
* Saturation in CAM16.
*
* <p>Colorfulness in proportion to brightness. Prefer chroma, saturation measures colorfulness
* relative to the color's own brightness, where chroma is colorfulness relative to white.
*/
public double getS() {
return s;
}
/** Lightness coordinate in CAM16-UCS */
public double getJstar() {
return jstar;
}
/** a* coordinate in CAM16-UCS */
public double getAstar() {
return astar;
}
/** b* coordinate in CAM16-UCS */
public double getBstar() {
return bstar;
}
/**
* All of the CAM16 dimensions can be calculated from 3 of the dimensions, in the following
* combinations: - {j or q} and {c, m, or s} and hue - jstar, astar, bstar Prefer using a static
* method that constructs from 3 of those dimensions. This constructor is intended for those
* methods to use to return all possible dimensions.
*
* @param hue for example, red, orange, yellow, green, etc.
* @param chroma informally, colorfulness / color intensity. like saturation in HSL, except
* perceptually accurate.
* @param j lightness
* @param q brightness; ratio of lightness to white point's lightness
* @param m colorfulness
* @param s saturation; ratio of chroma to white point's chroma
* @param jstar CAM16-UCS J coordinate
* @param astar CAM16-UCS a coordinate
* @param bstar CAM16-UCS b coordinate
*/
private Cam16(
double hue,
double chroma,
double j,
double q,
double m,
double s,
double jstar,
double astar,
double bstar) {
this.hue = hue;
this.chroma = chroma;
this.j = j;
this.q = q;
this.m = m;
this.s = s;
this.jstar = jstar;
this.astar = astar;
this.bstar = bstar;
}
/**
* Create a CAM16 color from a color, assuming the color was viewed in default viewing conditions.
*
* @param argb ARGB representation of a color.
*/
public static Cam16 fromInt(int argb) {
return fromIntInViewingConditions(argb, ViewingConditions.DEFAULT);
}
/**
* Create a CAM16 color from a color in defined viewing conditions.
*
* @param argb ARGB representation of a color.
* @param viewingConditions Information about the environment where the color was observed.
*/
// The RGB => XYZ conversion matrix elements are derived scientific constants. While the values
// may differ at runtime due to floating point imprecision, keeping the values the same, and
// accurate, across implementations takes precedence.
@SuppressWarnings("FloatingPointLiteralPrecision")
static Cam16 fromIntInViewingConditions(int argb, ViewingConditions viewingConditions) {
// Transform ARGB int to XYZ
int red = (argb & 0x00ff0000) >> 16;
int green = (argb & 0x0000ff00) >> 8;
int blue = (argb & 0x000000ff);
double redL = ColorUtils.linearized(red);
double greenL = ColorUtils.linearized(green);
double blueL = ColorUtils.linearized(blue);
double x = 0.41233895 * redL + 0.35762064 * greenL + 0.18051042 * blueL;
double y = 0.2126 * redL + 0.7152 * greenL + 0.0722 * blueL;
double z = 0.01932141 * redL + 0.11916382 * greenL + 0.95034478 * blueL;
// Transform XYZ to 'cone'/'rgb' responses
double[][] matrix = XYZ_TO_CAM16RGB;
double rT = (x * matrix[0][0]) + (y * matrix[0][1]) + (z * matrix[0][2]);
double gT = (x * matrix[1][0]) + (y * matrix[1][1]) + (z * matrix[1][2]);
double bT = (x * matrix[2][0]) + (y * matrix[2][1]) + (z * matrix[2][2]);
// Discount illuminant
double rD = viewingConditions.getRgbD()[0] * rT;
double gD = viewingConditions.getRgbD()[1] * gT;
double bD = viewingConditions.getRgbD()[2] * bT;
// Chromatic adaptation
double rAF = Math.pow(viewingConditions.getFl() * Math.abs(rD) / 100.0, 0.42);
double gAF = Math.pow(viewingConditions.getFl() * Math.abs(gD) / 100.0, 0.42);
double bAF = Math.pow(viewingConditions.getFl() * Math.abs(bD) / 100.0, 0.42);
double rA = Math.signum(rD) * 400.0 * rAF / (rAF + 27.13);
double gA = Math.signum(gD) * 400.0 * gAF / (gAF + 27.13);
double bA = Math.signum(bD) * 400.0 * bAF / (bAF + 27.13);
// redness-greenness
double a = (11.0 * rA + -12.0 * gA + bA) / 11.0;
// yellowness-blueness
double b = (rA + gA - 2.0 * bA) / 9.0;
// auxiliary components
double u = (20.0 * rA + 20.0 * gA + 21.0 * bA) / 20.0;
double p2 = (40.0 * rA + 20.0 * gA + bA) / 20.0;
// hue
double atan2 = Math.atan2(b, a);
double atanDegrees = Math.toDegrees(atan2);
double hue =
atanDegrees < 0
? atanDegrees + 360.0
: atanDegrees >= 360 ? atanDegrees - 360.0 : atanDegrees;
double hueRadians = Math.toRadians(hue);
// achromatic response to color
double ac = p2 * viewingConditions.getNbb();
// CAM16 lightness and brightness
double j =
100.0
* Math.pow(
ac / viewingConditions.getAw(),
viewingConditions.getC() * viewingConditions.getZ());
double q =
4.0
/ viewingConditions.getC()
* Math.sqrt(j / 100.0)
* (viewingConditions.getAw() + 4.0)
* viewingConditions.getFlRoot();
// CAM16 chroma, colorfulness, and saturation.
double huePrime = (hue < 20.14) ? hue + 360 : hue;
double eHue = 0.25 * (Math.cos(Math.toRadians(huePrime) + 2.0) + 3.8);
double p1 = 50000.0 / 13.0 * eHue * viewingConditions.getNc() * viewingConditions.getNcb();
double t = p1 * Math.hypot(a, b) / (u + 0.305);
double alpha =
Math.pow(1.64 - Math.pow(0.29, viewingConditions.getN()), 0.73) * Math.pow(t, 0.9);
// CAM16 chroma, colorfulness, saturation
double c = alpha * Math.sqrt(j / 100.0);
double m = c * viewingConditions.getFlRoot();
double s =
50.0 * Math.sqrt((alpha * viewingConditions.getC()) / (viewingConditions.getAw() + 4.0));
// CAM16-UCS components
double jstar = (1.0 + 100.0 * 0.007) * j / (1.0 + 0.007 * j);
double mstar = 1.0 / 0.0228 * Math.log1p(0.0228 * m);
double astar = mstar * Math.cos(hueRadians);
double bstar = mstar * Math.sin(hueRadians);
return new Cam16(hue, c, j, q, m, s, jstar, astar, bstar);
}
/**
* @param j CAM16 lightness
* @param c CAM16 chroma
* @param h CAM16 hue
*/
static Cam16 fromJch(double j, double c, double h) {
return fromJchInViewingConditions(j, c, h, ViewingConditions.DEFAULT);
}
/**
* @param j CAM16 lightness
* @param c CAM16 chroma
* @param h CAM16 hue
* @param viewingConditions Information about the environment where the color was observed.
*/
private static Cam16 fromJchInViewingConditions(
double j, double c, double h, ViewingConditions viewingConditions) {
double q =
4.0
/ viewingConditions.getC()
* Math.sqrt(j / 100.0)
* (viewingConditions.getAw() + 4.0)
* viewingConditions.getFlRoot();
double m = c * viewingConditions.getFlRoot();
double alpha = c / Math.sqrt(j / 100.0);
double s =
50.0 * Math.sqrt((alpha * viewingConditions.getC()) / (viewingConditions.getAw() + 4.0));
double hueRadians = Math.toRadians(h);
double jstar = (1.0 + 100.0 * 0.007) * j / (1.0 + 0.007 * j);
double mstar = 1.0 / 0.0228 * Math.log1p(0.0228 * m);
double astar = mstar * Math.cos(hueRadians);
double bstar = mstar * Math.sin(hueRadians);
return new Cam16(h, c, j, q, m, s, jstar, astar, bstar);
}
/**
* Create a CAM16 color from CAM16-UCS coordinates.
*
* @param jstar CAM16-UCS lightness.
* @param astar CAM16-UCS a dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the Y
* axis.
* @param bstar CAM16-UCS b dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the X
* axis.
*/
public static Cam16 fromUcs(double jstar, double astar, double bstar) {
return fromUcsInViewingConditions(jstar, astar, bstar, ViewingConditions.DEFAULT);
}
/**
* Create a CAM16 color from CAM16-UCS coordinates in defined viewing conditions.
*
* @param jstar CAM16-UCS lightness.
* @param astar CAM16-UCS a dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the Y
* axis.
* @param bstar CAM16-UCS b dimension. Like a* in L*a*b*, it is a Cartesian coordinate on the X
* axis.
* @param viewingConditions Information about the environment where the color was observed.
*/
public static Cam16 fromUcsInViewingConditions(
double jstar, double astar, double bstar, ViewingConditions viewingConditions) {
double m = Math.hypot(astar, bstar);
double m2 = Math.expm1(m * 0.0228) / 0.0228;
double c = m2 / viewingConditions.getFlRoot();
double h = Math.atan2(bstar, astar) * (180.0 / Math.PI);
if (h < 0.0) {
h += 360.0;
}
double j = jstar / (1. - (jstar - 100.) * 0.007);
return fromJchInViewingConditions(j, c, h, viewingConditions);
}
/**
* ARGB representation of the color. Assumes the color was viewed in default viewing conditions,
* which are near-identical to the default viewing conditions for sRGB.
*/
public int toInt() {
return viewed(ViewingConditions.DEFAULT);
}
/**
* ARGB representation of the color, in defined viewing conditions.
*
* @param viewingConditions Information about the environment where the color will be viewed.
* @return ARGB representation of color
*/
int viewed(ViewingConditions viewingConditions) {
double alpha =
(getChroma() == 0.0 || getJ() == 0.0) ? 0.0 : getChroma() / Math.sqrt(getJ() / 100.0);
double t =
Math.pow(
alpha / Math.pow(1.64 - Math.pow(0.29, viewingConditions.getN()), 0.73), 1.0 / 0.9);
double hRad = Math.toRadians(getHue());
double eHue = 0.25 * (Math.cos(hRad + 2.0) + 3.8);
double ac =
viewingConditions.getAw()
* Math.pow(getJ() / 100.0, 1.0 / viewingConditions.getC() / viewingConditions.getZ());
double p1 = eHue * (50000.0 / 13.0) * viewingConditions.getNc() * viewingConditions.getNcb();
double p2 = (ac / viewingConditions.getNbb());
double hSin = Math.sin(hRad);
double hCos = Math.cos(hRad);
double gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11.0 * t * hCos + 108.0 * t * hSin);
double a = gamma * hCos;
double b = gamma * hSin;
double rA = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0;
double gA = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0;
double bA = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0;
double rCBase = max(0, (27.13 * Math.abs(rA)) / (400.0 - Math.abs(rA)));
double rC =
Math.signum(rA) * (100.0 / viewingConditions.getFl()) * Math.pow(rCBase, 1.0 / 0.42);
double gCBase = max(0, (27.13 * Math.abs(gA)) / (400.0 - Math.abs(gA)));
double gC =
Math.signum(gA) * (100.0 / viewingConditions.getFl()) * Math.pow(gCBase, 1.0 / 0.42);
double bCBase = max(0, (27.13 * Math.abs(bA)) / (400.0 - Math.abs(bA)));
double bC =
Math.signum(bA) * (100.0 / viewingConditions.getFl()) * Math.pow(bCBase, 1.0 / 0.42);
double rF = rC / viewingConditions.getRgbD()[0];
double gF = gC / viewingConditions.getRgbD()[1];
double bF = bC / viewingConditions.getRgbD()[2];
double[][] matrix = CAM16RGB_TO_XYZ;
double x = (rF * matrix[0][0]) + (gF * matrix[0][1]) + (bF * matrix[0][2]);
double y = (rF * matrix[1][0]) + (gF * matrix[1][1]) + (bF * matrix[1][2]);
double z = (rF * matrix[2][0]) + (gF * matrix[2][1]) + (bF * matrix[2][2]);
return ColorUtils.argbFromXyz(x, y, z);
}
}

View File

@@ -0,0 +1,652 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.xtimms.tokusho.ui.harmonize.hct;
import static java.lang.Math.max;
import org.xtimms.tokusho.ui.harmonize.utils.ColorUtils;
import org.xtimms.tokusho.ui.harmonize.utils.MathUtils;
import java.util.ArrayList;
/** A class that solves the HCT equation. */
public class CamSolver {
private CamSolver() {}
static final double[][] SCALED_DISCOUNT_FROM_LINRGB =
new double[][] {
new double[] {
0.001200833568784504, 0.002389694492170889, 0.0002795742885861124,
},
new double[] {
0.0005891086651375999, 0.0029785502573438758, 0.0003270666104008398,
},
new double[] {
0.00010146692491640572, 0.0005364214359186694, 0.0032979401770712076,
},
};
static final double[][] LINRGB_FROM_SCALED_DISCOUNT =
new double[][] {
new double[] {
1373.2198709594231, -1100.4251190754821, -7.278681089101213,
},
new double[] {
-271.815969077903, 559.6580465940733, -32.46047482791194,
},
new double[] {
1.9622899599665666, -57.173814538844006, 308.7233197812385,
},
};
static final double[] Y_FROM_LINRGB = new double[] {0.2126, 0.7152, 0.0722};
static final double[] CRITICAL_PLANES =
new double[] {
0.015176349177441876,
0.045529047532325624,
0.07588174588720938,
0.10623444424209313,
0.13658714259697685,
0.16693984095186062,
0.19729253930674434,
0.2276452376616281,
0.2579979360165119,
0.28835063437139563,
0.3188300904430532,
0.350925934958123,
0.3848314933096426,
0.42057480301049466,
0.458183274052838,
0.4976837250274023,
0.5391024159806381,
0.5824650784040898,
0.6277969426914107,
0.6751227633498623,
0.7244668422128921,
0.775853049866786,
0.829304845476233,
0.8848452951698498,
0.942497089126609,
1.0022825574869039,
1.0642236851973577,
1.1283421258858297,
1.1946592148522128,
1.2631959812511864,
1.3339731595349034,
1.407011200216447,
1.4823302800086415,
1.5599503113873272,
1.6398909516233677,
1.7221716113234105,
1.8068114625156377,
1.8938294463134073,
1.9832442801866852,
2.075074464868551,
2.1693382909216234,
2.2660538449872063,
2.36523901573795,
2.4669114995532007,
2.5710888059345764,
2.6777882626779785,
2.7870270208169257,
2.898822059350997,
3.0131901897720907,
3.1301480604002863,
3.2497121605402226,
3.3718988244681087,
3.4967242352587946,
3.624204428461639,
3.754355295633311,
3.887192587735158,
4.022731918402185,
4.160988767090289,
4.301978482107941,
4.445716283538092,
4.592217266055746,
4.741496401646282,
4.893568542229298,
5.048448422192488,
5.20615066083972,
5.3666897647573375,
5.5300801301023865,
5.696336044816294,
5.865471690767354,
6.037501145825082,
6.212438385869475,
6.390297286737924,
6.571091626112461,
6.7548350853498045,
6.941541251256611,
7.131223617812143,
7.323895587840543,
7.5195704746346665,
7.7182615035334345,
7.919981813454504,
8.124744458384042,
8.332562408825165,
8.543448553206703,
8.757415699253682,
8.974476575321063,
9.194643831691977,
9.417930041841839,
9.644347703669503,
9.873909240696694,
10.106627003236781,
10.342513269534024,
10.58158024687427,
10.8238400726681,
11.069304815507364,
11.317986476196008,
11.569896988756009,
11.825048221409341,
12.083451977536606,
12.345119996613247,
12.610063955123938,
12.878295467455942,
13.149826086772048,
13.42466730586372,
13.702830557985108,
13.984327217668513,
14.269168601521828,
14.55736596900856,
14.848930523210871,
15.143873411576273,
15.44220572664832,
15.743938506781891,
16.04908273684337,
16.35764934889634,
16.66964922287304,
16.985093187232053,
17.30399201960269,
17.62635644741625,
17.95219714852476,
18.281524751807332,
18.614349837764564,
18.95068293910138,
19.290534541298456,
19.633915083172692,
19.98083495742689,
20.331304511189067,
20.685334046541502,
21.042933821039977,
21.404114048223256,
21.76888489811322,
22.137256497705877,
22.50923893145328,
22.884842241736916,
23.264076429332462,
23.6469514538663,
24.033477234264016,
24.42366364919083,
24.817520537484558,
25.21505769858089,
25.61628489293138,
26.021211842414342,
26.429848230738664,
26.842203703840827,
27.258287870275353,
27.678110301598522,
28.10168053274597,
28.529008062403893,
28.96010235337422,
29.39497283293396,
29.83362889318845,
30.276079891419332,
30.722335150426627,
31.172403958865512,
31.62629557157785,
32.08401920991837,
32.54558406207592,
33.010999283389665,
33.4802739966603,
33.953417292456834,
34.430438229418264,
34.911345834551085,
35.39614910352207,
35.88485700094671,
36.37747846067349,
36.87402238606382,
37.37449765026789,
37.87891309649659,
38.38727753828926,
38.89959975977785,
39.41588851594697,
39.93615253289054,
40.460400508064545,
40.98864111053629,
41.520882981230194,
42.05713473317016,
42.597404951718396,
43.141702194811224,
43.6900349931913,
44.24241185063697,
44.798841244188324,
45.35933162437017,
45.92389141541209,
46.49252901546552,
47.065252796817916,
47.64207110610409,
48.22299226451468,
48.808024568002054,
49.3971762874833,
49.9904556690408,
50.587870934119984,
51.189430279724725,
51.79514187861014,
52.40501387947288,
53.0190544071392,
53.637271562750364,
54.259673423945976,
54.88626804504493,
55.517063457223934,
56.15206766869424,
56.79128866487574,
57.43473440856916,
58.08241284012621,
58.734331877617365,
59.39049941699807,
60.05092333227251,
60.715611475655585,
61.38457167773311,
62.057811747619894,
62.7353394731159,
63.417162620860914,
64.10328893648692,
64.79372614476921,
65.48848194977529,
66.18756403501224,
66.89098006357258,
67.59873767827808,
68.31084450182222,
69.02730813691093,
69.74813616640164,
70.47333615344107,
71.20291564160104,
71.93688215501312,
72.67524319850172,
73.41800625771542,
74.16517879925733,
74.9167682708136,
75.67278210128072,
76.43322770089146,
77.1981124613393,
77.96744375590167,
78.74122893956174,
79.51947534912904,
80.30219030335869,
81.08938110306934,
81.88105503125999,
82.67721935322541,
83.4778813166706,
84.28304815182372,
85.09272707154808,
85.90692527145302,
86.72564993000343,
87.54890820862819,
88.3767072518277,
89.2090541872801,
90.04595612594655,
90.88742016217518,
91.73345337380438,
92.58406282226491,
93.43925555268066,
94.29903859396902,
95.16341895893969,
96.03240364439274,
96.9059996312159,
97.78421388448044,
98.6670533535366,
99.55452497210776,
};
/**
* Sanitizes a small enough angle in radians.
*
* @param angle An angle in radians; must not deviate too much from 0.
* @return A coterminal angle between 0 and 2pi.
*/
static double sanitizeRadians(double angle) {
return (angle + Math.PI * 8) % (Math.PI * 2);
}
/**
* Delinearizes an RGB component, returning a floating-point number.
*
* @param rgbComponent 0.0 <= rgb_component <= 100.0, represents linear R/G/B channel
* @return 0.0 <= output <= 255.0, color channel converted to regular RGB space
*/
static double trueDelinearized(double rgbComponent) {
double normalized = rgbComponent / 100.0;
double delinearized;
if (normalized <= 0.0031308) {
delinearized = normalized * 12.92;
} else {
delinearized = 1.055 * Math.pow(normalized, 1.0 / 2.4) - 0.055;
}
return delinearized * 255.0;
}
static double chromaticAdaptation(double component) {
double af = Math.pow(Math.abs(component), 0.42);
return MathUtils.signum(component) * 400.0 * af / (af + 27.13);
}
/**
* Returns the hue of a linear RGB color in CAM16.
*
* @param linrgb The linear RGB coordinates of a color.
* @return The hue of the color in CAM16, in radians.
*/
static double hueOf(double[] linrgb) {
double[] scaledDiscount = MathUtils.matrixMultiply(linrgb, SCALED_DISCOUNT_FROM_LINRGB);
double rA = chromaticAdaptation(scaledDiscount[0]);
double gA = chromaticAdaptation(scaledDiscount[1]);
double bA = chromaticAdaptation(scaledDiscount[2]);
// redness-greenness
double a = (11.0 * rA + -12.0 * gA + bA) / 11.0;
// yellowness-blueness
double b = (rA + gA - 2.0 * bA) / 9.0;
return Math.atan2(b, a);
}
static boolean areInCyclicOrder(double a, double b, double c) {
double deltaAB = sanitizeRadians(b - a);
double deltaAC = sanitizeRadians(c - a);
return deltaAB < deltaAC;
}
/**
* Solves the lerp equation.
*
* @param source The starting number.
* @param mid The number in the middle.
* @param target The ending number.
* @return A number t such that lerp(source, target, t) = mid.
*/
static double intercept(double source, double mid, double target) {
return (mid - source) / (target - source);
}
static double[] lerpPoint(double[] source, double t, double[] target) {
return new double[] {
source[0] + (target[0] - source[0]) * t,
source[1] + (target[1] - source[1]) * t,
source[2] + (target[2] - source[2]) * t,
};
}
/**
* Intersects a segment with a plane.
*
* @param source The coordinates of point A.
* @param coordinate The R-, G-, or B-coordinate of the plane.
* @param target The coordinates of point B.
* @param axis The axis the plane is perpendicular with. (0: R, 1: G, 2: B)
* @return The intersection point of the segment AB with the plane R=coordinate, G=coordinate, or
* B=coordinate
*/
static double[] setCoordinate(double[] source, double coordinate, double[] target, int axis) {
double t = intercept(source[axis], coordinate, target[axis]);
return lerpPoint(source, t, target);
}
static boolean isBounded(double x) {
return 0.0 <= x && x <= 100.0;
}
/**
* Returns the intersections of the plane of constant y with the RGB cube.
*
* @param y The Y value of the plane.
* @return A list of points where the plane intersects with the edges of the RGB cube, in linear
* RGB coordinates.
*/
static ArrayList<double[]> edgePoints(double y) {
double kR = Y_FROM_LINRGB[0];
double kG = Y_FROM_LINRGB[1];
double kB = Y_FROM_LINRGB[2];
double[][] points =
new double[][] {
new double[] {y / kR, 0.0, 0.0},
new double[] {(y - 100 * kB) / kR, 0.0, 100.0},
new double[] {(y - 100 * kG) / kR, 100.0, 0.0},
new double[] {(y - 100 * kB - 100 * kG) / kR, 100.0, 100.0},
new double[] {0.0, y / kG, 0.0},
new double[] {100.0, (y - 100 * kR) / kG, 0.0},
new double[] {0.0, (y - 100 * kB) / kG, 100.0},
new double[] {100.0, (y - 100 * kR - 100 * kB) / kG, 100.0},
new double[] {0.0, 0.0, y / kB},
new double[] {100.0, 0.0, (y - 100 * kR) / kB},
new double[] {0.0, 100.0, (y - 100 * kG) / kB},
new double[] {100.0, 100.0, (y - 100 * kR - 100 * kG) / kB},
};
ArrayList<double[]> ans = new ArrayList<>();
for (double[] point : points) {
if (isBounded(point[0]) && isBounded(point[1]) && isBounded(point[2])) {
ans.add(point);
}
}
return ans;
}
/**
* Finds the segment containing the desired color.
*
* @param y The Y value of the color.
* @param targetHue The hue of the color.
* @return A list of two sets of linear RGB coordinates, each corresponding to an endpoint of the
* segment containing the desired color.
*/
static double[][] bisectToSegment(double y, double targetHue) {
ArrayList<double[]> vertices = edgePoints(y);
double[] left = vertices.get(0);
double[] right = left;
double leftHue = hueOf(left);
double rightHue = leftHue;
boolean uncut = true;
for (int i = 1; i < vertices.size(); i++) {
double[] mid = vertices.get(i);
double midHue = hueOf(mid);
if (uncut || areInCyclicOrder(leftHue, midHue, rightHue)) {
uncut = false;
if (areInCyclicOrder(leftHue, targetHue, midHue)) {
right = mid;
rightHue = midHue;
} else {
left = mid;
leftHue = midHue;
}
}
}
return new double[][] {left, right};
}
static double[] midpoint(double[] a, double[] b) {
return new double[] {
(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2,
};
}
static int criticalPlaneBelow(double x) {
return (int) Math.floor(x - 0.5);
}
static int criticalPlaneAbove(double x) {
return (int) Math.ceil(x - 0.5);
}
/**
* Finds a color with the given Y and hue on the boundary of the cube.
*
* @param y The Y value of the color.
* @param targetHue The hue of the color.
* @return The desired color, in linear RGB coordinates.
*/
static double[] bisectToLimit(double y, double targetHue) {
double[][] segment = bisectToSegment(y, targetHue);
double[] left = segment[0];
double leftHue = hueOf(left);
double[] right = segment[1];
for (int axis = 0; axis < 3; axis++) {
if (left[axis] != right[axis]) {
int lPlane = -1;
int rPlane = 255;
if (left[axis] < right[axis]) {
lPlane = criticalPlaneBelow(trueDelinearized(left[axis]));
rPlane = criticalPlaneAbove(trueDelinearized(right[axis]));
} else {
lPlane = criticalPlaneAbove(trueDelinearized(left[axis]));
rPlane = criticalPlaneBelow(trueDelinearized(right[axis]));
}
for (int i = 0; i < 8; i++) {
if (Math.abs(rPlane - lPlane) <= 1) {
break;
} else {
int mPlane = (lPlane + rPlane) / 2;
double midPlaneCoordinate = CRITICAL_PLANES[mPlane];
double[] mid = setCoordinate(left, midPlaneCoordinate, right, axis);
double midHue = hueOf(mid);
if (areInCyclicOrder(leftHue, targetHue, midHue)) {
right = mid;
rPlane = mPlane;
} else {
left = mid;
leftHue = midHue;
lPlane = mPlane;
}
}
}
}
}
return midpoint(left, right);
}
static double inverseChromaticAdaptation(double adapted) {
double adaptedAbs = Math.abs(adapted);
double base = max(0, 27.13 * adaptedAbs / (400.0 - adaptedAbs));
return MathUtils.signum(adapted) * Math.pow(base, 1.0 / 0.42);
}
/**
* Finds a color with the given hue, chroma, and Y.
*
* @param hueRadians The desired hue in radians.
* @param chroma The desired chroma.
* @param y The desired Y.
* @return The desired color as a hexadecimal integer, if found; 0 otherwise.
*/
static int findResultByJ(double hueRadians, double chroma, double y) {
// Initial estimate of j.
double j = Math.sqrt(y) * 11.0;
// ===========================================================
// Operations inlined from Cam16 to avoid repeated calculation
// ===========================================================
ViewingConditions viewingConditions = ViewingConditions.DEFAULT;
double tInnerCoeff = 1 / Math.pow(1.64 - Math.pow(0.29, viewingConditions.getN()), 0.73);
double eHue = 0.25 * (Math.cos(hueRadians + 2.0) + 3.8);
double p1 = eHue * (50000.0 / 13.0) * viewingConditions.getNc() * viewingConditions.getNcb();
double hSin = Math.sin(hueRadians);
double hCos = Math.cos(hueRadians);
for (int iterationRound = 0; iterationRound < 5; iterationRound++) {
// ===========================================================
// Operations inlined from Cam16 to avoid repeated calculation
// ===========================================================
double jNormalized = j / 100.0;
double alpha = chroma == 0.0 || j == 0.0 ? 0.0 : chroma / Math.sqrt(jNormalized);
double t = Math.pow(alpha * tInnerCoeff, 1.0 / 0.9);
double ac =
viewingConditions.getAw()
* Math.pow(jNormalized, 1.0 / viewingConditions.getC() / viewingConditions.getZ());
double p2 = ac / viewingConditions.getNbb();
double gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11 * t * hCos + 108.0 * t * hSin);
double a = gamma * hCos;
double b = gamma * hSin;
double rA = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0;
double gA = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0;
double bA = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0;
double rCScaled = inverseChromaticAdaptation(rA);
double gCScaled = inverseChromaticAdaptation(gA);
double bCScaled = inverseChromaticAdaptation(bA);
double[] linrgb =
MathUtils.matrixMultiply(
new double[] {rCScaled, gCScaled, bCScaled}, LINRGB_FROM_SCALED_DISCOUNT);
// ===========================================================
// Operations inlined from Cam16 to avoid repeated calculation
// ===========================================================
if (linrgb[0] < 0 || linrgb[1] < 0 || linrgb[2] < 0) {
return 0;
}
double kR = Y_FROM_LINRGB[0];
double kG = Y_FROM_LINRGB[1];
double kB = Y_FROM_LINRGB[2];
double fnj = kR * linrgb[0] + kG * linrgb[1] + kB * linrgb[2];
if (fnj <= 0) {
return 0;
}
if (iterationRound == 4 || Math.abs(fnj - y) < 0.002) {
if (linrgb[0] > 100.01 || linrgb[1] > 100.01 || linrgb[2] > 100.01) {
return 0;
}
return ColorUtils.argbFromLinrgb(linrgb);
}
// Iterates with Newton method,
// Using 2 * fn(j) / j as the approximation of fn'(j)
j = j - (fnj - y) * j / (2 * fnj);
}
return 0;
}
/**
* Finds an sRGB color with the given hue, chroma, and L*, if possible.
*
* @param hueDegrees The desired hue, in degrees.
* @param chroma The desired chroma.
* @param lstar The desired L*.
* @return A hexadecimal representing the sRGB color. The color has sufficiently close hue,
* chroma, and L* to the desired values, if possible; otherwise, the hue and L* will be
* sufficiently close, and chroma will be maximized.
*/
public static int solveToInt(double hueDegrees, double chroma, double lstar) {
if (chroma < 0.0001 || lstar < 0.0001 || lstar > 99.9999) {
return ColorUtils.argbFromLstar(lstar);
}
hueDegrees = MathUtils.sanitizeDegreesDouble(hueDegrees);
double hueRadians = Math.toRadians(hueDegrees);
double y = ColorUtils.yFromLstar(lstar);
int exactAnswer = findResultByJ(hueRadians, chroma, y);
if (exactAnswer != 0) {
return exactAnswer;
}
double[] linrgb = bisectToLimit(y, hueRadians);
return ColorUtils.argbFromLinrgb(linrgb);
}
/**
* Finds an sRGB color with the given hue, chroma, and L*, if possible.
*
* @param hueDegrees The desired hue, in degrees.
* @param chroma The desired chroma.
* @param lstar The desired L*.
* @return An CAM16 object representing the sRGB color. The color has sufficiently close hue,
* chroma, and L* to the desired values, if possible; otherwise, the hue and L* will be
* sufficiently close, and chroma will be maximized.
*/
public static Cam16 solveToCam(double hueDegrees, double chroma, double lstar) {
return Cam16.fromInt(solveToInt(hueDegrees, chroma, lstar));
}
}

View File

@@ -0,0 +1,127 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.xtimms.tokusho.ui.harmonize.hct;
/**
* A color system built using CAM16 hue and chroma, and L* from L*a*b*.
*
* <p>Using L* creates a link between the color system, contrast, and thus accessibility. Contrast
* ratio depends on relative luminance, or Y in the XYZ color space. L*, or perceptual luminance can
* be calculated from Y.
*
* <p>Unlike Y, L* is linear to human perception, allowing trivial creation of accurate color tones.
*
* <p>Unlike contrast ratio, measuring contrast in L* is linear, and simple to calculate. A
* difference of 40 in HCT tone guarantees a contrast ratio >= 3.0, and a difference of 50
* guarantees a contrast ratio >= 4.5.
*/
import org.xtimms.tokusho.ui.harmonize.utils.ColorUtils;
/**
* HCT, hue, chroma, and tone. A color system that provides a perceptually accurate color
* measurement system that can also accurately render what colors will appear as in different
* lighting environments.
*/
public final class Hct {
private double hue;
private double chroma;
private double tone;
private int argb;
/**
* Create an HCT color from hue, chroma, and tone.
*
* @param hue 0 <= hue < 360; invalid values are corrected.
* @param chroma 0 <= chroma < ?; Informally, colorfulness. The color returned may be lower than
* the requested chroma. Chroma has a different maximum for any given hue and tone.
* @param tone 0 <= tone <= 100; invalid values are corrected.
* @return HCT representation of a color in default viewing conditions.
*/
public static Hct from(double hue, double chroma, double tone) {
int argb = CamSolver.solveToInt(hue, chroma, tone);
return new Hct(argb);
}
/**
* Create an HCT color from a color.
*
* @param argb ARGB representation of a color.
* @return HCT representation of a color in default viewing conditions
*/
public static Hct fromInt(int argb) {
return new Hct(argb);
}
private Hct(int argb) {
setInternalState(argb);
}
public double getHue() {
return hue;
}
public double getChroma() {
return chroma;
}
public double getTone() {
return tone;
}
public int toInt() {
return argb;
}
/**
* Set the hue of this color. Chroma may decrease because chroma has a different maximum for any
* given hue and tone.
*
* @param newHue 0 <= newHue < 360; invalid values are corrected.
*/
public void setHue(double newHue) {
setInternalState(CamSolver.solveToInt(newHue, chroma, tone));
}
/**
* Set the chroma of this color. Chroma may decrease because chroma has a different maximum for
* any given hue and tone.
*
* @param newChroma 0 <= newChroma < ?
*/
public void setChroma(double newChroma) {
setInternalState(CamSolver.solveToInt(hue, newChroma, tone));
}
/**
* Set the tone of this color. Chroma may decrease because chroma has a different maximum for any
* given hue and tone.
*
* @param newTone 0 <= newTone <= 100; invalid valids are corrected.
*/
public void setTone(double newTone) {
setInternalState(CamSolver.solveToInt(hue, chroma, newTone));
}
private void setInternalState(int argb) {
this.argb = argb;
Cam16 cam = Cam16.fromInt(argb);
hue = cam.getHue();
chroma = cam.getChroma();
this.tone = ColorUtils.lstarFromArgb(argb);
}
}

View File

@@ -0,0 +1,197 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.xtimms.tokusho.ui.harmonize.hct;
import org.xtimms.tokusho.ui.harmonize.utils.ColorUtils;
import org.xtimms.tokusho.ui.harmonize.utils.MathUtils;
/**
* In traditional color spaces, a color can be identified solely by the observer's measurement of
* the color. Color appearance models such as CAM16 also use information about the environment where
* the color was observed, known as the viewing conditions.
*
* <p>For example, white under the traditional assumption of a midday sun white point is accurately
* measured as a slightly chromatic blue by CAM16. (roughly, hue 203, chroma 3, lightness 100)
*
* <p>This class caches intermediate values of the CAM16 conversion process that depend only on
* viewing conditions, enabling speed ups.
*/
public final class ViewingConditions {
/** sRGB-like viewing conditions. */
public static final ViewingConditions DEFAULT =
ViewingConditions.make(
new double[] {
ColorUtils.whitePointD65()[0],
ColorUtils.whitePointD65()[1],
ColorUtils.whitePointD65()[2]
},
(200.0 / Math.PI * ColorUtils.yFromLstar(50.0) / 100.f),
50.0,
2.0,
false);
private final double aw;
private final double nbb;
private final double ncb;
private final double c;
private final double nc;
private final double n;
private final double[] rgbD;
private final double fl;
private final double flRoot;
private final double z;
public double getAw() {
return aw;
}
public double getN() {
return n;
}
public double getNbb() {
return nbb;
}
double getNcb() {
return ncb;
}
double getC() {
return c;
}
double getNc() {
return nc;
}
public double[] getRgbD() {
return rgbD;
}
double getFl() {
return fl;
}
public double getFlRoot() {
return flRoot;
}
double getZ() {
return z;
}
/**
* Create ViewingConditions from a simple, physically relevant, set of parameters.
*
* @param whitePoint White point, measured in the XYZ color space. default = D65, or sunny day
* afternoon
* @param adaptingLuminance The luminance of the adapting field. Informally, how bright it is in
* the room where the color is viewed. Can be calculated from lux by multiplying lux by
* 0.0586. default = 11.72, or 200 lux.
* @param backgroundLstar The lightness of the area surrounding the color. measured by L* in
* L*a*b*. default = 50.0
* @param surround A general description of the lighting surrounding the color. 0 is pitch dark,
* like watching a movie in a theater. 1.0 is a dimly light room, like watching TV at home at
* night. 2.0 means there is no difference between the lighting on the color and around it.
* default = 2.0
* @param discountingIlluminant Whether the eye accounts for the tint of the ambient lighting,
* such as knowing an apple is still red in green light. default = false, the eye does not
* perform this process on self-luminous objects like displays.
*/
static ViewingConditions make(
double[] whitePoint,
double adaptingLuminance,
double backgroundLstar,
double surround,
boolean discountingIlluminant) {
// Transform white point XYZ to 'cone'/'rgb' responses
double[][] matrix = Cam16.XYZ_TO_CAM16RGB;
double[] xyz = whitePoint;
double rW = (xyz[0] * matrix[0][0]) + (xyz[1] * matrix[0][1]) + (xyz[2] * matrix[0][2]);
double gW = (xyz[0] * matrix[1][0]) + (xyz[1] * matrix[1][1]) + (xyz[2] * matrix[1][2]);
double bW = (xyz[0] * matrix[2][0]) + (xyz[1] * matrix[2][1]) + (xyz[2] * matrix[2][2]);
double f = 0.8 + (surround / 10.0);
double c =
(f >= 0.9)
? MathUtils.lerp(0.59, 0.69, ((f - 0.9) * 10.0))
: MathUtils.lerp(0.525, 0.59, ((f - 0.8) * 10.0));
double d =
discountingIlluminant
? 1.0
: f * (1.0 - ((1.0 / 3.6) * Math.exp((-adaptingLuminance - 42.0) / 92.0)));
d = MathUtils.clampDouble(0.0, 1.0, d);
double nc = f;
double[] rgbD =
new double[] {
d * (100.0 / rW) + 1.0 - d, d * (100.0 / gW) + 1.0 - d, d * (100.0 / bW) + 1.0 - d
};
double k = 1.0 / (5.0 * adaptingLuminance + 1.0);
double k4 = k * k * k * k;
double k4F = 1.0 - k4;
double fl = (k4 * adaptingLuminance) + (0.1 * k4F * k4F * Math.cbrt(5.0 * adaptingLuminance));
double n = (ColorUtils.yFromLstar(backgroundLstar) / whitePoint[1]);
double z = 1.48 + Math.sqrt(n);
double nbb = 0.725 / Math.pow(n, 0.2);
double ncb = nbb;
double[] rgbAFactors =
new double[] {
Math.pow(fl * rgbD[0] * rW / 100.0, 0.42),
Math.pow(fl * rgbD[1] * gW / 100.0, 0.42),
Math.pow(fl * rgbD[2] * bW / 100.0, 0.42)
};
double[] rgbA =
new double[] {
(400.0 * rgbAFactors[0]) / (rgbAFactors[0] + 27.13),
(400.0 * rgbAFactors[1]) / (rgbAFactors[1] + 27.13),
(400.0 * rgbAFactors[2]) / (rgbAFactors[2] + 27.13)
};
double aw = ((2.0 * rgbA[0]) + rgbA[1] + (0.05 * rgbA[2])) * nbb;
return new ViewingConditions(n, aw, nbb, ncb, c, nc, rgbD, fl, Math.pow(fl, 0.25), z);
}
/**
* Parameters are intermediate values of the CAM16 conversion process. Their names are shorthand
* for technical color science terminology, this class would not benefit from documenting them
* individually. A brief overview is available in the CAM16 specification, and a complete overview
* requires a color science textbook, such as Fairchild's Color Appearance Models.
*/
private ViewingConditions(
double n,
double aw,
double nbb,
double ncb,
double c,
double nc,
double[] rgbD,
double fl,
double flRoot,
double z) {
this.n = n;
this.aw = aw;
this.nbb = nbb;
this.ncb = ncb;
this.c = c;
this.nc = nc;
this.rgbD = rgbD;
this.fl = fl;
this.flRoot = flRoot;
this.z = z;
}
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.xtimms.tokusho.ui.harmonize.palettes;
import static java.lang.Math.max;
import static java.lang.Math.min;
import org.xtimms.tokusho.ui.harmonize.hct.Hct;
/**
* An intermediate concept between the key color for a UI theme, and a full color scheme. 5 sets of
* tones are generated, all except one use the same hue as the key color, and all vary in chroma.
*/
public final class CorePalette {
public Hct seed;
public TonalPalette a1;
public TonalPalette a2;
public TonalPalette a3;
public TonalPalette n1;
public TonalPalette n2;
public TonalPalette error;
/**
* Create key tones from a color.
*
* @param argb ARGB representation of a color
*/
public static CorePalette of(int argb) {
return new CorePalette(argb, false);
}
/**
* Create content key tones from a color.
*
* @param argb ARGB representation of a color
*/
public static CorePalette contentOf(int argb) {
return new CorePalette(argb, true);
}
private CorePalette(int argb, boolean isContent) {
Hct hct = Hct.fromInt(argb);
double hue = hct.getHue();
double chroma = hct.getChroma();
if (isContent) {
this.a1 = TonalPalette.fromHueAndChroma(hue, chroma);
this.a2 = TonalPalette.fromHueAndChroma(hue, chroma / 3.);
this.a3 = TonalPalette.fromHueAndChroma(hue + 60., chroma / 2.);
this.n1 = TonalPalette.fromHueAndChroma(hue, min(chroma / 12., 4.));
this.n2 = TonalPalette.fromHueAndChroma(hue, min(chroma / 6., 8.));
} else {
this.a1 = TonalPalette.fromHueAndChroma(hue, max(48., chroma));
this.a2 = TonalPalette.fromHueAndChroma(hue, 16.);
this.a3 = TonalPalette.fromHueAndChroma(hue + 60., 24.);
this.n1 = TonalPalette.fromHueAndChroma(hue, 4.);
this.n2 = TonalPalette.fromHueAndChroma(hue, 8.);
}
this.seed = hct;
this.error = TonalPalette.fromHueAndChroma(25, 84.);
}
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.xtimms.tokusho.ui.harmonize.palettes;
import org.xtimms.tokusho.ui.harmonize.hct.Hct;
import java.util.HashMap;
import java.util.Map;
/**
* A convenience class for retrieving colors that are constant in hue and chroma, but vary in tone.
*/
public final class TonalPalette {
Map<Double, Integer> cache;
double hue;
double chroma;
/**
* Create tones using the HCT hue and chroma from a color.
*
* @param argb ARGB representation of a color
* @return Tones matching that color's hue and chroma.
*/
public static TonalPalette fromInt(int argb) {
Hct hct = Hct.fromInt(argb);
return TonalPalette.fromHueAndChroma(hct.getHue(), hct.getChroma());
}
/**
* Create tones from a defined HCT hue and chroma.
*
* @param hue HCT hue
* @param chroma HCT chroma
* @return Tones matching hue and chroma.
*/
public static TonalPalette fromHueAndChroma(double hue, double chroma) {
return new TonalPalette(hue, chroma);
}
private TonalPalette(double hue, double chroma) {
cache = new HashMap<>();
this.hue = hue;
this.chroma = chroma;
}
/**
* Create an ARGB color with HCT hue and chroma of this Tones instance, and the provided HCT tone.
*
* @param tone HCT tone, measured from 0 to 100.
* @return ARGB representation of a color with that tone.
*/
// AndroidJdkLibsChecker is higher priority than ComputeIfAbsentUseValue (b/119581923)
@SuppressWarnings("ComputeIfAbsentUseValue")
public int tone(double tone) {
Integer color = cache.get(tone);
if (color == null) {
color = Hct.from(this.hue, this.chroma, tone).toInt();
cache.put(tone, color);
}
return color;
}
public int tone(int tone) {
Integer color = cache.get((double)tone);
if (color == null) {
color = Hct.from(this.hue, this.chroma, tone).toInt();
cache.put((double)tone, color);
}
return color;
}
}

View File

@@ -0,0 +1,271 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// This file is automatically generated. Do not modify it.
package org.xtimms.tokusho.ui.harmonize.utils;
/**
* Color science utilities.
*
* <p>Utility methods for color science constants and color space conversions that aren't HCT or
* CAM16.
*/
public class ColorUtils {
private ColorUtils() {}
static final double[][] SRGB_TO_XYZ =
new double[][] {
new double[] {0.41233895, 0.35762064, 0.18051042},
new double[] {0.2126, 0.7152, 0.0722},
new double[] {0.01932141, 0.11916382, 0.95034478},
};
static final double[][] XYZ_TO_SRGB =
new double[][] {
new double[] {
3.2413774792388685, -1.5376652402851851, -0.49885366846268053,
},
new double[] {
-0.9691452513005321, 1.8758853451067872, 0.04156585616912061,
},
new double[] {
0.05562093689691305, -0.20395524564742123, 1.0571799111220335,
},
};
static final double[] WHITE_POINT_D65 = new double[] {95.047, 100.0, 108.883};
/** Converts a color from RGB components to ARGB format. */
public static int argbFromRgb(int red, int green, int blue) {
return (255 << 24) | ((red & 255) << 16) | ((green & 255) << 8) | (blue & 255);
}
/** Converts a color from linear RGB components to ARGB format. */
public static int argbFromLinrgb(double[] linrgb) {
int r = delinearized(linrgb[0]);
int g = delinearized(linrgb[1]);
int b = delinearized(linrgb[2]);
return argbFromRgb(r, g, b);
}
/** Returns the alpha component of a color in ARGB format. */
public static int alphaFromArgb(int argb) {
return (argb >> 24) & 255;
}
/** Returns the red component of a color in ARGB format. */
public static int redFromArgb(int argb) {
return (argb >> 16) & 255;
}
/** Returns the green component of a color in ARGB format. */
public static int greenFromArgb(int argb) {
return (argb >> 8) & 255;
}
/** Returns the blue component of a color in ARGB format. */
public static int blueFromArgb(int argb) {
return argb & 255;
}
/** Returns whether a color in ARGB format is opaque. */
public static boolean isOpaque(int argb) {
return alphaFromArgb(argb) >= 255;
}
/** Converts a color from ARGB to XYZ. */
public static int argbFromXyz(double x, double y, double z) {
double[][] matrix = XYZ_TO_SRGB;
double linearR = matrix[0][0] * x + matrix[0][1] * y + matrix[0][2] * z;
double linearG = matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] * z;
double linearB = matrix[2][0] * x + matrix[2][1] * y + matrix[2][2] * z;
int r = delinearized(linearR);
int g = delinearized(linearG);
int b = delinearized(linearB);
return argbFromRgb(r, g, b);
}
/** Converts a color from XYZ to ARGB. */
public static double[] xyzFromArgb(int argb) {
double r = linearized(redFromArgb(argb));
double g = linearized(greenFromArgb(argb));
double b = linearized(blueFromArgb(argb));
return MathUtils.matrixMultiply(new double[] {r, g, b}, SRGB_TO_XYZ);
}
/** Converts a color represented in Lab color space into an ARGB integer. */
public static int argbFromLab(double l, double a, double b) {
double[] whitePoint = WHITE_POINT_D65;
double fy = (l + 16.0) / 116.0;
double fx = a / 500.0 + fy;
double fz = fy - b / 200.0;
double xNormalized = labInvf(fx);
double yNormalized = labInvf(fy);
double zNormalized = labInvf(fz);
double x = xNormalized * whitePoint[0];
double y = yNormalized * whitePoint[1];
double z = zNormalized * whitePoint[2];
return argbFromXyz(x, y, z);
}
/**
* Converts a color from ARGB representation to L*a*b* representation.
*
* @param argb the ARGB representation of a color
* @return a Lab object representing the color
*/
public static double[] labFromArgb(int argb) {
double linearR = linearized(redFromArgb(argb));
double linearG = linearized(greenFromArgb(argb));
double linearB = linearized(blueFromArgb(argb));
double[][] matrix = SRGB_TO_XYZ;
double x = matrix[0][0] * linearR + matrix[0][1] * linearG + matrix[0][2] * linearB;
double y = matrix[1][0] * linearR + matrix[1][1] * linearG + matrix[1][2] * linearB;
double z = matrix[2][0] * linearR + matrix[2][1] * linearG + matrix[2][2] * linearB;
double[] whitePoint = WHITE_POINT_D65;
double xNormalized = x / whitePoint[0];
double yNormalized = y / whitePoint[1];
double zNormalized = z / whitePoint[2];
double fx = labF(xNormalized);
double fy = labF(yNormalized);
double fz = labF(zNormalized);
double l = 116.0 * fy - 16;
double a = 500.0 * (fx - fy);
double b = 200.0 * (fy - fz);
return new double[] {l, a, b};
}
/**
* Converts an L* value to an ARGB representation.
*
* @param lstar L* in L*a*b*
* @return ARGB representation of grayscale color with lightness matching L*
*/
public static int argbFromLstar(double lstar) {
double fy = (lstar + 16.0) / 116.0;
double fz = fy;
double fx = fy;
double kappa = 24389.0 / 27.0;
double epsilon = 216.0 / 24389.0;
boolean lExceedsEpsilonKappa = lstar > 8.0;
double y = lExceedsEpsilonKappa ? fy * fy * fy : lstar / kappa;
boolean cubeExceedEpsilon = fy * fy * fy > epsilon;
double x = cubeExceedEpsilon ? fx * fx * fx : lstar / kappa;
double z = cubeExceedEpsilon ? fz * fz * fz : lstar / kappa;
double[] whitePoint = WHITE_POINT_D65;
return argbFromXyz(x * whitePoint[0], y * whitePoint[1], z * whitePoint[2]);
}
/**
* Computes the L* value of a color in ARGB representation.
*
* @param argb ARGB representation of a color
* @return L*, from L*a*b*, coordinate of the color
*/
public static double lstarFromArgb(int argb) {
double y = xyzFromArgb(argb)[1] / 100.0;
double e = 216.0 / 24389.0;
if (y <= e) {
return 24389.0 / 27.0 * y;
} else {
double yIntermediate = Math.pow(y, 1.0 / 3.0);
return 116.0 * yIntermediate - 16.0;
}
}
/**
* Converts an L* value to a Y value.
*
* <p>L* in L*a*b* and Y in XYZ measure the same quantity, luminance.
*
* <p>L* measures perceptual luminance, a linear scale. Y in XYZ measures relative luminance, a
* logarithmic scale.
*
* @param lstar L* in L*a*b*
* @return Y in XYZ
*/
public static double yFromLstar(double lstar) {
double ke = 8.0;
if (lstar > ke) {
return Math.pow((lstar + 16.0) / 116.0, 3.0) * 100.0;
} else {
return lstar / (24389.0 / 27.0) * 100.0;
}
}
/**
* Linearizes an RGB component.
*
* @param rgbComponent 0 <= rgb_component <= 255, represents R/G/B channel
* @return 0.0 <= output <= 100.0, color channel converted to linear RGB space
*/
public static double linearized(int rgbComponent) {
double normalized = rgbComponent / 255.0;
if (normalized <= 0.040449936) {
return normalized / 12.92 * 100.0;
} else {
return Math.pow((normalized + 0.055) / 1.055, 2.4) * 100.0;
}
}
/**
* Delinearizes an RGB component.
*
* @param rgbComponent 0.0 <= rgb_component <= 100.0, represents linear R/G/B channel
* @return 0 <= output <= 255, color channel converted to regular RGB space
*/
public static int delinearized(double rgbComponent) {
double normalized = rgbComponent / 100.0;
double delinearized = 0.0;
if (normalized <= 0.0031308) {
delinearized = normalized * 12.92;
} else {
delinearized = 1.055 * Math.pow(normalized, 1.0 / 2.4) - 0.055;
}
return MathUtils.clampInt(0, 255, (int) Math.round(delinearized * 255.0));
}
/**
* Returns the standard white point; white on a sunny day.
*
* @return The white point
*/
public static double[] whitePointD65() {
return WHITE_POINT_D65;
}
static double labF(double t) {
double e = 216.0 / 24389.0;
double kappa = 24389.0 / 27.0;
if (t > e) {
return Math.pow(t, 1.0 / 3.0);
} else {
return (kappa * t + 16) / 116;
}
}
static double labInvf(double ft) {
double e = 216.0 / 24389.0;
double kappa = 24389.0 / 27.0;
double ft3 = ft * ft * ft;
if (ft3 > e) {
return ft3;
} else {
return (116 * ft - 16) / kappa;
}
}
}

View File

@@ -0,0 +1,133 @@
/*
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// This file is automatically generated. Do not modify it.
package org.xtimms.tokusho.ui.harmonize.utils;
/** Utility methods for mathematical operations. */
public class MathUtils {
private MathUtils() {}
/**
* The signum function.
*
* @return 1 if num > 0, -1 if num < 0, and 0 if num = 0
*/
public static int signum(double num) {
if (num < 0) {
return -1;
} else if (num == 0) {
return 0;
} else {
return 1;
}
}
/**
* The linear interpolation function.
*
* @return start if amount = 0 and stop if amount = 1
*/
public static double lerp(double start, double stop, double amount) {
return (1.0 - amount) * start + amount * stop;
}
/**
* Clamps an integer between two integers.
*
* @return input when min <= input <= max, and either min or max otherwise.
*/
public static int clampInt(int min, int max, int input) {
if (input < min) {
return min;
} else if (input > max) {
return max;
}
return input;
}
/**
* Clamps an integer between two floating-point numbers.
*
* @return input when min <= input <= max, and either min or max otherwise.
*/
public static double clampDouble(double min, double max, double input) {
if (input < min) {
return min;
} else if (input > max) {
return max;
}
return input;
}
/**
* Sanitizes a degree measure as an integer.
*
* @return a degree measure between 0 (inclusive) and 360 (exclusive).
*/
public static int sanitizeDegreesInt(int degrees) {
degrees = degrees % 360;
if (degrees < 0) {
degrees = degrees + 360;
}
return degrees;
}
/**
* Sanitizes a degree measure as a floating-point number.
*
* @return a degree measure between 0.0 (inclusive) and 360.0 (exclusive).
*/
public static double sanitizeDegreesDouble(double degrees) {
degrees = degrees % 360.0;
if (degrees < 0) {
degrees = degrees + 360.0;
}
return degrees;
}
/**
* Sign of direction change needed to travel from one angle to another.
*
* <p>For angles that are 180 degrees apart from each other, both directions have the same travel
* distance, so either direction is shortest. The value 1.0 is returned in this case.
*
* @param from The angle travel starts from, in degrees.
* @param to The angle travel ends at, in degrees.
* @return -1 if decreasing from leads to the shortest travel distance, 1 if increasing from leads
* to the shortest travel distance.
*/
public static double rotationDirection(double from, double to) {
double increasingDifference = sanitizeDegreesDouble(to - from);
return increasingDifference <= 180.0 ? 1.0 : -1.0;
}
/** Distance of two points on a circle, represented using degrees. */
public static double differenceDegrees(double a, double b) {
return 180.0 - Math.abs(Math.abs(a - b) - 180.0);
}
/** Multiplies a 1x3 row vector with a 3x3 matrix. */
public static double[] matrixMultiply(double[] row, double[][] matrix) {
double a = row[0] * matrix[0][0] + row[1] * matrix[0][1] + row[2] * matrix[0][2];
double b = row[0] * matrix[1][0] + row[1] * matrix[1][1] + row[2] * matrix[1][2];
double c = row[0] * matrix[2][0] + row[1] * matrix[2][1] + row[2] * matrix[2][2];
return new double[] {a, b, c};
}
}

View File

@@ -0,0 +1,6 @@
package org.xtimms.tokusho.ui.monet
data class ColorSpec(
val chroma: (Double) -> Double = { it },
val hueShift: (Double) -> Double = { 0.0 }
)

Some files were not shown because too many files have changed in this diff Show More