commit 8617f18f9011ca16611aecff937c185b5652d6b2 Author: Zakhar Timoshenko Date: Sat Feb 3 13:32:24 2024 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e1384e --- /dev/null +++ b/.gitignore @@ -0,0 +1,115 @@ +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +.idea/jarRepositories.xml +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# End of https://mrkandreev.name/snippets/gitignore-generator/#Kotlin,Android \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..3337158 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Tokusho \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b589d56 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml new file mode 100644 index 0000000..8184c57 --- /dev/null +++ b/.idea/deploymentTargetDropDown.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/discord.xml b/.idea/discord.xml new file mode 100644 index 0000000..d8e9561 --- /dev/null +++ b/.idea/discord.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..44ca2d9 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,41 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..fdf8d99 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..8978d23 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ceb5d03 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# Tokusho + +An attempt to write an Android manga reading application on Jetpack Compose using the [Kotatsu parser library](https://github.com/KotatsuApp/kotatsu-parsers). + +## Is it possible to use it now? + +No, nothing works. + +## Acknowledgements + +[Kotatsu](https://github.com/KotatsuApp/Kotatsu) - UI and parsers +[Seal](https://github.com/JunkFood02/Seal) - UI + +## License + +You may copy, distribute and modify the software as long as you track changes/dates in source files. +Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the +GPL along with build & install instructions. + + diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..14fdce7 --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} \ No newline at end of file diff --git a/app/proguard-android-optimize.txt b/app/proguard-android-optimize.txt new file mode 100644 index 0000000..7072ff7 --- /dev/null +++ b/app/proguard-android-optimize.txt @@ -0,0 +1,34 @@ +-dontusemixedcaseclassnames +-ignorewarnings +-verbose + +-keepattributes *Annotation* + +-keepclasseswithmembernames,includedescriptorclasses class * { + native ; +} + +-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 ; +} + +-keepclasseswithmembers class * { + @androidx.annotation.Keep ; +} + +-keepclasseswithmembers class * { + @androidx.annotation.Keep (...); +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..72c795e --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,3 @@ +-dontobfuscate + +-keep,allowoptimization class org.xtimms.** \ No newline at end of file diff --git a/app/src/androidTest/java/org/xtimms/tokusho/ExampleInstrumentedTest.kt b/app/src/androidTest/java/org/xtimms/tokusho/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..b006053 --- /dev/null +++ b/app/src/androidTest/java/org/xtimms/tokusho/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c1aaf35 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/App.kt b/app/src/main/java/org/xtimms/tokusho/App.kt new file mode 100644 index 0000000..8098da6 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/App.kt @@ -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 + + 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 + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/CompositionLocals.kt b/app/src/main/java/org/xtimms/tokusho/CompositionLocals.kt new file mode 100644 index 0000000..eca513e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/CompositionLocals.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/MainActivity.kt b/app/src/main/java/org/xtimms/tokusho/MainActivity.kt new file mode 100644 index 0000000..bbf205f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/MainActivity.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt b/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt new file mode 100644 index 0000000..fcddc2c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/TokushoModule.kt @@ -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) + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/AsyncImageImpl.kt b/app/src/main/java/org/xtimms/tokusho/core/AsyncImageImpl.kt new file mode 100644 index 0000000..599fe13 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/AsyncImageImpl.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/BottomNavDestination.kt b/app/src/main/java/org/xtimms/tokusho/core/BottomNavDestination.kt new file mode 100644 index 0000000..838d2f5 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/BottomNavDestination.kt @@ -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) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/ModifierCollapsable.kt b/app/src/main/java/org/xtimms/tokusho/core/ModifierCollapsable.kt new file mode 100644 index 0000000..efc5702 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/ModifierCollapsable.kt @@ -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, +) = 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 + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt b/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt new file mode 100644 index 0000000..c8dffc7 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/Navigation.kt @@ -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, +) { + + 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(durationMillis = DURATION_ENTER, easing = emphasizeEasing) + val exitTween = tween(durationMillis = DURATION_ENTER, easing = emphasizeEasing) + val fadeTween = tween(durationMillis = DURATION_EXIT) + val fadeSpec = fadeTween + + NavHost( + navController = navController, + startDestination = BottomNavDestination.Shelf.route, + modifier = modifier, + enterTransition = { materialSharedAxisXIn(initialOffsetX = { (it * initialOffset).toInt() }) }, + exitTransition = { materialSharedAxisXOut(targetOffsetX = { -(it * initialOffset).toInt() }) }, + popEnterTransition = { materialSharedAxisXIn(initialOffsetX = { -(it * initialOffset).toInt() }) }, + popExitTransition = { materialSharedAxisXOut(targetOffsetX = { (it * initialOffset).toInt() }) } + ) { + + 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, + ) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/base/event/UiEvent.kt b/app/src/main/java/org/xtimms/tokusho/core/base/event/UiEvent.kt new file mode 100644 index 0000000..56444c4 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/base/event/UiEvent.kt @@ -0,0 +1,6 @@ +package org.xtimms.tokusho.core.base.event + +interface UiEvent { + fun showMessage(message: String?) + fun onMessageDisplayed() +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/base/state/UiState.kt b/app/src/main/java/org/xtimms/tokusho/core/base/state/UiState.kt new file mode 100644 index 0000000..d909208 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/base/state/UiState.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/base/viewmodel/BaseViewModel.kt b/app/src/main/java/org/xtimms/tokusho/core/base/viewmodel/BaseViewModel.kt new file mode 100644 index 0000000..1759076 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/base/viewmodel/BaseViewModel.kt @@ -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 : ViewModel(), UiEvent { + + protected abstract val mutableUiState: MutableStateFlow + val uiState: StateFlow 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/cache/CacheDir.kt b/app/src/main/java/org/xtimms/tokusho/core/cache/CacheDir.kt new file mode 100644 index 0000000..49223c5 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/cache/CacheDir.kt @@ -0,0 +1,8 @@ +package org.xtimms.tokusho.core.cache + +enum class CacheDir(val dir: String) { + + THUMBS("image_cache"), + FAVICONS("favicons"), + PAGES("pages"); +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/cache/ContentCache.kt b/app/src/main/java/org/xtimms/tokusho/core/cache/ContentCache.kt new file mode 100644 index 0000000..14d8faf --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/cache/ContentCache.kt @@ -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) + + suspend fun getPages(source: MangaSource, url: String): List? + + fun putPages(source: MangaSource, url: String, pages: SafeDeferred>) + + suspend fun getRelatedManga(source: MangaSource, url: String): List? + + fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred>) + + data class Key( + val source: MangaSource, + val url: String, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/cache/ExpiringLruCache.kt b/app/src/main/java/org/xtimms/tokusho/core/cache/ExpiringLruCache.kt new file mode 100644 index 0000000..6a3867e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/cache/ExpiringLruCache.kt @@ -0,0 +1,33 @@ +package org.xtimms.tokusho.core.cache + +import androidx.collection.LruCache +import java.util.concurrent.TimeUnit + +class ExpiringLruCache( + val maxSize: Int, + private val lifetime: Long, + private val timeUnit: TimeUnit, +) { + + private val cache = LruCache>(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) + } +} diff --git a/app/src/main/java/org/xtimms/tokusho/core/cache/ExpiringValue.kt b/app/src/main/java/org/xtimms/tokusho/core/cache/ExpiringValue.kt new file mode 100644 index 0000000..5d14b5e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/cache/ExpiringValue.kt @@ -0,0 +1,34 @@ +package org.xtimms.tokusho.core.cache + +import android.os.SystemClock +import java.util.concurrent.TimeUnit + +class ExpiringValue( + 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 + } +} diff --git a/app/src/main/java/org/xtimms/tokusho/core/cache/MemoryContentCache.kt b/app/src/main/java/org/xtimms/tokusho/core/cache/MemoryContentCache.kt new file mode 100644 index 0000000..bb0b83a --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/cache/MemoryContentCache.kt @@ -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>(4, 5, TimeUnit.MINUTES) + private val pagesCache = ExpiringLruCache>>(4, 10, TimeUnit.MINUTES) + private val relatedMangaCache = ExpiringLruCache>>(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) { + detailsCache[ContentCache.Key(source, url)] = details + } + + override suspend fun getPages(source: MangaSource, url: String): List? { + return pagesCache[ContentCache.Key(source, url)]?.awaitOrNull() + } + + override fun putPages(source: MangaSource, url: String, pages: SafeDeferred>) { + pagesCache[ContentCache.Key(source, url)] = pages + } + + override suspend fun getRelatedManga(source: MangaSource, url: String): List? { + return relatedMangaCache[ContentCache.Key(source, url)]?.awaitOrNull() + } + + override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred>) { + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/cache/SafeDeferred.kt b/app/src/main/java/org/xtimms/tokusho/core/cache/SafeDeferred.kt new file mode 100644 index 0000000..fdaffa4 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/cache/SafeDeferred.kt @@ -0,0 +1,20 @@ +package org.xtimms.tokusho.core.cache + +import kotlinx.coroutines.Deferred + +class SafeDeferred( + private val delegate: Deferred>, +) { + + suspend fun await(): T { + return delegate.await().getOrThrow() + } + + suspend fun awaitOrNull(): T? { + return delegate.await().getOrNull() + } + + fun cancel() { + delegate.cancel() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/cache/StubContentCache.kt b/app/src/main/java/org/xtimms/tokusho/core/cache/StubContentCache.kt new file mode 100644 index 0000000..1deb47c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/cache/StubContentCache.kt @@ -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) = Unit + + override suspend fun getPages(source: MangaSource, url: String): List? = null + + override fun putPages(source: MangaSource, url: String, pages: SafeDeferred>) = Unit + + override suspend fun getRelatedManga(source: MangaSource, url: String): List? = null + + override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred>) = Unit +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/ActionButton.kt b/app/src/main/java/org/xtimms/tokusho/core/components/ActionButton.kt new file mode 100644 index 0000000..a83b07d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/ActionButton.kt @@ -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, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/BottomNavBar.kt b/app/src/main/java/org/xtimms/tokusho/core/components/BottomNavBar.kt new file mode 100644 index 0000000..88d15fa --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/BottomNavBar.kt @@ -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, +) { + + 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)) } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/ExploreButton.kt b/app/src/main/java/org/xtimms/tokusho/core/components/ExploreButton.kt new file mode 100644 index 0000000..1150744 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/ExploreButton.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/IconButton.kt b/app/src/main/java/org/xtimms/tokusho/core/components/IconButton.kt new file mode 100644 index 0000000..8c54853 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/IconButton.kt @@ -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" + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/Pill.kt b/app/src/main/java/org/xtimms/tokusho/core/components/Pill.kt new file mode 100644 index 0000000..3e80048 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/Pill.kt @@ -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, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt b/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt new file mode 100644 index 0000000..a2ae13a --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/PreferenceItem.kt @@ -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, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt b/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt new file mode 100644 index 0000000..9321d77 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/ScaffoldWithTopAppBar.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/SettingItem.kt b/app/src/main/java/org/xtimms/tokusho/core/components/SettingItem.kt new file mode 100644 index 0000000..b9b7ee2 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/SettingItem.kt @@ -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, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/SourceItem.kt b/app/src/main/java/org/xtimms/tokusho/core/components/SourceItem.kt new file mode 100644 index 0000000..ef93d49 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/SourceItem.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/Tab.kt b/app/src/main/java/org/xtimms/tokusho/core/components/Tab.kt new file mode 100644 index 0000000..69046dd --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/Tab.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/TopAppBar.kt b/app/src/main/java/org/xtimms/tokusho/core/components/TopAppBar.kt new file mode 100644 index 0000000..7cc4558 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/TopAppBar.kt @@ -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 = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/components/icons/Dice.kt b/app/src/main/java/org/xtimms/tokusho/core/components/icons/Dice.kt new file mode 100644 index 0000000..7533a40 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/components/icons/Dice.kt @@ -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 \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/Tables.kt b/app/src/main/java/org/xtimms/tokusho/core/database/Tables.kt new file mode 100644 index 0000000..6f8f25d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/Tables.kt @@ -0,0 +1,3 @@ +package org.xtimms.tokusho.core.database + +const val TABLE_SOURCES = "sources" \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt b/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt new file mode 100644 index 0000000..164961d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/TokushoDatabase.kt @@ -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() \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/dao/MangaSourcesDao.kt b/app/src/main/java/org/xtimms/tokusho/core/database/dao/MangaSourcesDao.kt new file mode 100644 index 0000000..2e9e705 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/dao/MangaSourcesDao.kt @@ -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 + + @Query("SELECT * FROM sources WHERE enabled = 0 ORDER BY sort_key") + abstract suspend fun findAllDisabled(): List + + @Query("SELECT * FROM sources WHERE enabled = 0") + abstract fun observeDisabled(): Flow> + + @Query("SELECT * FROM sources ORDER BY sort_key") + abstract fun observeAll(): Flow> + + @Query("SELECT IFNULL(MAX(sort_key),0) FROM sources") + abstract suspend fun getMaxSortKey(): Int + + @Query("UPDATE sources SET enabled = 0") + abstract suspend fun disableAllSources() + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaSourceEntity.kt b/app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaSourceEntity.kt new file mode 100644 index 0000000..c1ea648 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/database/entity/MangaSourceEntity.kt @@ -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, +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/ListModel.kt b/app/src/main/java/org/xtimms/tokusho/core/model/ListModel.kt new file mode 100644 index 0000000..eeba04b --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/model/ListModel.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/ListSortOrder.kt b/app/src/main/java/org/xtimms/tokusho/core/model/ListSortOrder.kt new file mode 100644 index 0000000..7b04905 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/model/ListSortOrder.kt @@ -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 = EnumSet.of(NEWEST, PROGRESS, ALPHABETIC) + + operator fun invoke(value: String, fallback: ListSortOrder) = entries.find(value) ?: fallback + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/MangaSource.kt b/app/src/main/java/org/xtimms/tokusho/core/model/MangaSource.kt new file mode 100644 index 0000000..feac6a1 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/model/MangaSource.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/model/ShelfCategory.kt b/app/src/main/java/org/xtimms/tokusho/core/model/ShelfCategory.kt new file mode 100644 index 0000000..a64b448 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/model/ShelfCategory.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/motion/MaterialSharedAxis.kt b/app/src/main/java/org/xtimms/tokusho/core/motion/MaterialSharedAxis.kt new file mode 100644 index 0000000..1097886 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/motion/MaterialSharedAxis.kt @@ -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 + ) +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/motion/MotionConstants.kt b/app/src/main/java/org/xtimms/tokusho/core/motion/MotionConstants.kt new file mode 100644 index 0000000..4a1e3a5 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/motion/MotionConstants.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/HttpClients.kt b/app/src/main/java/org/xtimms/tokusho/core/network/HttpClients.kt new file mode 100644 index 0000000..6e25b0e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/network/HttpClients.kt @@ -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 \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/NetworkModule.kt b/app/src/main/java/org/xtimms/tokusho/core/network/NetworkModule.kt new file mode 100644 index 0000000..076f9a8 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/network/NetworkModule.kt @@ -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() + + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/cookies/AndroidCookieJar.kt b/app/src/main/java/org/xtimms/tokusho/core/network/cookies/AndroidCookieJar.kt new file mode 100644 index 0000000..fcf0e16 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/network/cookies/AndroidCookieJar.kt @@ -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 { + val rawCookie = cookieManager.getCookie(url.toString()) ?: return emptyList() + return rawCookie.split(';').mapNotNull { + Cookie.parse(url, it) + } + } + + @WorkerThread + override fun saveFromResponse(url: HttpUrl, cookies: List) { + if (cookies.isEmpty()) { + return + } + val urlString = url.toString() + for (cookie in cookies) { + cookieManager.setCookie(urlString, cookie.toString()) + } + } + + override fun removeCookies(url: HttpUrl, predicate: Predicate?) { + 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 { continuation -> + cookieManager.removeAllCookies(continuation::resume) + } +} diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/cookies/CookieWrapper.kt b/app/src/main/java/org/xtimms/tokusho/core/network/cookies/CookieWrapper.kt new file mode 100644 index 0000000..b6befb7 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/network/cookies/CookieWrapper.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/cookies/MutableCookieJar.kt b/app/src/main/java/org/xtimms/tokusho/core/network/cookies/MutableCookieJar.kt new file mode 100644 index 0000000..32419ce --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/network/cookies/MutableCookieJar.kt @@ -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 + + @WorkerThread + override fun saveFromResponse(url: HttpUrl, cookies: List) + + @WorkerThread + fun removeCookies(url: HttpUrl, predicate: Predicate?) + + suspend fun clear(): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/network/cookies/PreferencesCookieJar.kt b/app/src/main/java/org/xtimms/tokusho/core/network/cookies/PreferencesCookieJar.kt new file mode 100644 index 0000000..2dc40b7 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/network/cookies/PreferencesCookieJar.kt @@ -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() + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private var isLoaded = false + + @WorkerThread + @Synchronized + override fun loadForRequest(url: HttpUrl): List { + loadPersistent() + val expired = HashSet() + val result = ArrayList() + 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) { + 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?) { + loadPersistent() + val toRemove = HashSet() + 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) { + prefs.edit(commit = true) { + for (key in keys) { + remove(key) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/os/NetworkState.kt b/app/src/main/java/org/xtimms/tokusho/core/os/NetworkState.kt new file mode 100644 index 0000000..2309c30 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/os/NetworkState.kt @@ -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(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() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/LocalMangaRepository.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/LocalMangaRepository.kt new file mode 100644 index 0000000..93d414b --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/LocalMangaRepository.kt @@ -0,0 +1,4 @@ +package org.xtimms.tokusho.core.parser + +private const val MAX_PARALLELISM = 4 + diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/MangaLoaderContextImpl.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaLoaderContextImpl.kt new file mode 100644 index 0000000..31d16c5 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaLoaderContextImpl.kt @@ -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? = 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 { + return LocaleListCompat.getAdjustedDefault().toList() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/MangaParser.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaParser.kt new file mode 100644 index 0000000..ec87920 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaParser.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/MangaRepository.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaRepository.kt new file mode 100644 index 0000000..0357f0c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/MangaRepository.kt @@ -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 + + val states: Set + + val contentRatings: Set + + val isMultipleTagsSupported: Boolean + + val isTagsExclusionSupported: Boolean + + val isSearchSupported: Boolean + + suspend fun getList(offset: Int, filter: MangaListFilter?): List + + suspend fun getDetails(manga: Manga): Manga + + suspend fun getPages(chapter: MangaChapter): List + + suspend fun getPageUrl(page: MangaPage): String + + suspend fun getTags(): Set + + suspend fun getLocales(): Set + + suspend fun getRelated(seed: Manga): List + + @Singleton + class Factory @Inject constructor( + private val loaderContext: MangaLoaderContext, + private val contentCache: ContentCache, + ) { + + private val cache = EnumMap>(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 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt new file mode 100644 index 0000000..e1b77d2 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/RemoteMangaRepository.kt @@ -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 + get() = parser.availableSortOrders + + override val states: Set + get() = parser.availableStates + + override val contentRatings: Set + 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 { + return parser.getList(offset, filter) + } + + override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED) + + override suspend fun getPages(chapter: MangaChapter): List { + 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 = parser.getAvailableTags() + + override suspend fun getLocales(): Set { + return parser.getAvailableLocales() + } + + suspend fun getFavicons(): Favicons = parser.getFavicons() + + override suspend fun getRelated(seed: Manga): List { + 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 asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred { + var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key] + if (dispatcher == null || dispatcher is MainCoroutineDispatcher) { + dispatcher = Dispatchers.Default + } + return SafeDeferred( + processLifecycleScope.async(dispatcher) { + runCatchingCancellable { block() } + }, + ) + } + + private fun List.distinctById(): List { + if (isEmpty()) { + return emptyList() + } + val result = ArrayList(size) + val set = HashSet(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 +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/favicon/FaviconFetcher.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/favicon/FaviconFetcher.kt new file mode 100644 index 0000000..1b26401 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/favicon/FaviconFetcher.kt @@ -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, + 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, 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 { + + 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() +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/parser/favicon/FaviconUri.kt b/app/src/main/java/org/xtimms/tokusho/core/parser/favicon/FaviconUri.kt new file mode 100644 index 0000000..1600b8f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/parser/favicon/FaviconUri.kt @@ -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) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt b/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt new file mode 100644 index 0000000..76764cb --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/prefs/AppSettings.kt @@ -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) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/prefs/SourceSettings.kt b/app/src/main/java/org/xtimms/tokusho/core/prefs/SourceSettings.kt new file mode 100644 index 0000000..1822c34 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/prefs/SourceSettings.kt @@ -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 get(key: ConfigKey): 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 set(key: ConfigKey, 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?) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/screens/EmptyScreen.kt b/app/src/main/java/org/xtimms/tokusho/core/screens/EmptyScreen.kt new file mode 100644 index 0000000..20c8307 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/screens/EmptyScreen.kt @@ -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? = null, +) { + EmptyScreen( + message = stringResource(title), + modifier = modifier, + actions = actions, + ) +} + +@Composable +fun EmptyScreen( + message: String, + modifier: Modifier = Modifier, + actions: ImmutableList? = 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)] +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/screens/InfoScreen.kt b/app/src/main/java/org/xtimms/tokusho/core/screens/InfoScreen.kt new file mode 100644 index 0000000..2959bf4 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/screens/InfoScreen.kt @@ -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") + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/screens/UpdateDialog.kt b/app/src/main/java/org/xtimms/tokusho/core/screens/UpdateDialog.kt new file mode 100644 index 0000000..b617fcd --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/screens/UpdateDialog.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/core/updates/Updater.kt b/app/src/main/java/org/xtimms/tokusho/core/updates/Updater.kt new file mode 100644 index 0000000..decc66a --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/core/updates/Updater.kt @@ -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>(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 = 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 { 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 = 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? = 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 { + 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()) + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/crash/CrashActivity.kt b/app/src/main/java/org/xtimms/tokusho/crash/CrashActivity.kt new file mode 100644 index 0000000..8dc3fd6 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/crash/CrashActivity.kt @@ -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)) + }, + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/crash/CrashScreen.kt b/app/src/main/java/org/xtimms/tokusho/crash/CrashScreen.kt new file mode 100644 index 0000000..3916c35 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/crash/CrashScreen.kt @@ -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")) {} + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/crash/GlobalExceptionHandler.kt b/app/src/main/java/org/xtimms/tokusho/crash/GlobalExceptionHandler.kt new file mode 100644 index 0000000..443a493 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/crash/GlobalExceptionHandler.kt @@ -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 { + 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 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt b/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt new file mode 100644 index 0000000..fb5c134 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/data/LocalStorageManager.kt @@ -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 + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/data/repository/MangaSourcesRepository.kt b/app/src/main/java/org/xtimms/tokusho/data/repository/MangaSourcesRepository.kt new file mode 100644 index 0000000..089356e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/data/repository/MangaSourcesRepository.kt @@ -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 + get() = Collections.unmodifiableSet(remoteSources) + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreEvent.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreEvent.kt new file mode 100644 index 0000000..eba7a3c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreEvent.kt @@ -0,0 +1,7 @@ +package org.xtimms.tokusho.sections.explore + +import org.xtimms.tokusho.core.base.event.UiEvent + +interface ExploreEvent : UiEvent { + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreUiState.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreUiState.kt new file mode 100644 index 0000000..d53429d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreUiState.kt @@ -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 = 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) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt new file mode 100644 index 0000000..82b9c94 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreView.kt @@ -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, + 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 = 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) + } + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt new file mode 100644 index 0000000..93e64a8 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/explore/ExploreViewModel.kt @@ -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(), 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) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryView.kt b/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryView.kt new file mode 100644 index 0000000..c164119 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/history/HistoryView.kt @@ -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 = 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, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt b/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt new file mode 100644 index 0000000..4e11f41 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/list/MangaListView.kt @@ -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 + ) { + + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/search/SearchView.kt b/app/src/main/java/org/xtimms/tokusho/sections/search/SearchView.kt new file mode 100644 index 0000000..a0d8dec --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/search/SearchView.kt @@ -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, + 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 = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt new file mode 100644 index 0000000..07860c1 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/SettingsView.kt @@ -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 + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/about/AboutView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/AboutView.kt new file mode 100644 index 0000000..33716f9 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/AboutView.kt @@ -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) + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/about/UpdateView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/UpdateView.kt new file mode 100644 index 0000000..37f4251 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/about/UpdateView.kt @@ -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) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt new file mode 100644 index 0000000..3f53523 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/AppearanceView.kt @@ -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 + ) + } + + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/Card.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/Card.kt new file mode 100644 index 0000000..e0e210c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/Card.kt @@ -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" + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/DarkThemeView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/DarkThemeView.kt new file mode 100644 index 0000000..b02292b --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/DarkThemeView.kt @@ -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) + }) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/LanguagesView.kt b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/LanguagesView.kt new file mode 100644 index 0000000..b971360 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/settings/appearance/LanguagesView.kt @@ -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, + 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 { + repeat(38) { + put(it + 1, "") + } + } + TokushoTheme { + LanguageViewImpl( + languageMap = map, + isSystemLocaleSettingsAvailable = true, + onNavigateToSystemLocaleSettings = { /*TODO*/ }, + selectedLanguage = language + ) { + language = it + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfItem.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfItem.kt new file mode 100644 index 0000000..6704d9d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfItem.kt @@ -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 = "", +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfManga.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfManga.kt new file mode 100644 index 0000000..f73d8e2 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfManga.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfPager.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfPager.kt new file mode 100644 index 0000000..cda5902 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfPager.kt @@ -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, +) { + 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), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfTabs.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfTabs.kt new file mode 100644 index 0000000..8b782c6 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfTabs.kt @@ -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, + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt new file mode 100644 index 0000000..ea9deae --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ShelfView.kt @@ -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, + currentPage: () -> Int, + showPageTabs: Boolean, + getNumberOfMangaForCategory: (ShelfCategory) -> Int?, + getLibraryForPage: (Int) -> List, + 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, + currentPage: () -> Int, + showPageTabs: Boolean, + getNumberOfMangaForCategory: (ShelfCategory) -> Int?, + getLibraryForPage: (Int) -> List, + topBarHeightPx: Float, + topBarOffsetY: Animatable = 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> \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/sections/shelf/ext/ShelfCategoryExtensions.kt b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ext/ShelfCategoryExtensions.kt new file mode 100644 index 0000000..6ae9d81 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/sections/shelf/ext/ShelfCategoryExtensions.kt @@ -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 + } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/Cam16.java b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/Cam16.java new file mode 100644 index 0000000..b74cf98 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/Cam16.java @@ -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. + * + *

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. + * + *

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. + * + *

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. + * + *

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. + * + *

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. + * + *

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); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/CamSolver.java b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/CamSolver.java new file mode 100644 index 0000000..d3e8035 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/CamSolver.java @@ -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 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 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 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)); + } +} diff --git a/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/Hct.java b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/Hct.java new file mode 100644 index 0000000..7b0987c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/Hct.java @@ -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*. + * + *

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. + * + *

Unlike Y, L* is linear to human perception, allowing trivial creation of accurate color tones. + * + *

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); + } +} diff --git a/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/ViewingConditions.java b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/ViewingConditions.java new file mode 100644 index 0000000..0f2bddb --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/hct/ViewingConditions.java @@ -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. + * + *

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) + * + *

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; + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/harmonize/palettes/CorePalette.java b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/palettes/CorePalette.java new file mode 100644 index 0000000..e30f251 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/palettes/CorePalette.java @@ -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.); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/harmonize/palettes/TonalPalette.java b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/palettes/TonalPalette.java new file mode 100644 index 0000000..9cd39ab --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/palettes/TonalPalette.java @@ -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 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; + } +} diff --git a/app/src/main/java/org/xtimms/tokusho/ui/harmonize/utils/ColorUtils.java b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/utils/ColorUtils.java new file mode 100644 index 0000000..c751dbb --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/utils/ColorUtils.java @@ -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. + * + *

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. + * + *

L* in L*a*b* and Y in XYZ measure the same quantity, luminance. + * + *

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; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/harmonize/utils/MathUtils.java b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/utils/MathUtils.java new file mode 100644 index 0000000..22f156f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/harmonize/utils/MathUtils.java @@ -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. + * + *

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}; + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/monet/ColorSpec.kt b/app/src/main/java/org/xtimms/tokusho/ui/monet/ColorSpec.kt new file mode 100644 index 0000000..4e10e24 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/monet/ColorSpec.kt @@ -0,0 +1,6 @@ +package org.xtimms.tokusho.ui.monet + +data class ColorSpec( + val chroma: (Double) -> Double = { it }, + val hueShift: (Double) -> Double = { 0.0 } +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/monet/Monet.kt b/app/src/main/java/org/xtimms/tokusho/ui/monet/Monet.kt new file mode 100644 index 0000000..a3358ef --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/monet/Monet.kt @@ -0,0 +1,101 @@ +package org.xtimms.tokusho.ui.monet + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import org.xtimms.tokusho.ui.monet.TonalPalettes.Companion.toTonalPalettes + +val LocalTonalPalettes = staticCompositionLocalOf { + Color(0xFF0057C9).toTonalPalettes() +} + +inline val Number.a1: Color + @Composable + get() = LocalTonalPalettes.current accent1 toDouble() + +inline val Number.a2: Color + @Composable get() = LocalTonalPalettes.current accent2 toDouble() + +inline val Number.a3: Color + @Composable get() = LocalTonalPalettes.current accent3 toDouble() + +inline val Number.n1: Color + @Composable get() = LocalTonalPalettes.current neutral1 toDouble() + +inline val Number.n2: Color + @Composable get() = LocalTonalPalettes.current neutral2 toDouble() + +@Composable +fun dynamicColorScheme(isLight: Boolean = !isSystemInDarkTheme()): ColorScheme { + return if (isLight) { + lightColorScheme( + background = 98.n1, + inverseOnSurface = 95.n1, + inversePrimary = 80.a1, + inverseSurface = 20.n1, + onBackground = 10.n1, + onPrimary = 100.a1, + onPrimaryContainer = 10.a1, + onSecondary = 100.a2, + onSecondaryContainer = 10.a2, + onSurface = 10.n1, + onSurfaceVariant = 30.n2, + onTertiary = 100.a3, + onTertiaryContainer = 10.a3, + outline = 50.n2, + outlineVariant = 80.n2, + primary = 40.a1, + primaryContainer = 90.a1, + secondary = 40.a2, + secondaryContainer = 90.a2, + surface = 98.n1, + surfaceVariant = 90.n2, + tertiary = 40.a3, + tertiaryContainer = 90.a3, + surfaceBright = 98.n1, + surfaceDim = 87.n1, + surfaceContainerLowest = 100.n1, + surfaceContainerLow = 96.n1, + surfaceContainer = 94.n1, + surfaceContainerHigh = 92.n1, + surfaceContainerHighest = 90.n1, + ) + } else { + darkColorScheme( + background = 6.n1, + inverseOnSurface = 20.n1, + inversePrimary = 40.a1, + inverseSurface = 90.n1, + onBackground = 90.n1, + onPrimary = 20.a1, + onPrimaryContainer = 90.a1, + onSecondary = 20.a2, + onSecondaryContainer = 90.a2, + onSurface = 90.n1, + onSurfaceVariant = 80.n2, + onTertiary = 20.a3, + onTertiaryContainer = 90.a3, + outline = 60.n2, + outlineVariant = 30.n2, + primary = 80.a1, + primaryContainer = 30.a1, + secondary = 80.a2, + secondaryContainer = 30.a2, + surface = 6.n1, + surfaceVariant = 30.n2, + tertiary = 80.a3, + tertiaryContainer = 30.a3, + surfaceBright = 24.n1, + surfaceDim = 6.n1, + surfaceContainerLowest = 4.n1, + surfaceContainerLow = 10.n1, + surfaceContainer = 12.n1, + surfaceContainerHigh = 17.n1, + surfaceContainerHighest = 22.n1, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/monet/PaletteStyle.kt b/app/src/main/java/org/xtimms/tokusho/ui/monet/PaletteStyle.kt new file mode 100644 index 0000000..89ce1e0 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/monet/PaletteStyle.kt @@ -0,0 +1,133 @@ +package org.xtimms.tokusho.ui.monet + +class PaletteStyle( + val accent1Spec: ColorSpec, + val accent2Spec: ColorSpec, + val accent3Spec: ColorSpec, + val neutral1Spec: ColorSpec, + val neutral2Spec: ColorSpec +) { + companion object { + private val VibrantSecondaryHueRotation = arrayOf( + 0 to 18, + 41 to 15, + 61 to 10, + 101 to 12, + 131 to 15, + 181 to 18, + 251 to 15, + 301 to 12, + 360 to 12 + ) + private val VibrantTertiaryHueRotation = arrayOf( + 0 to 35, + 41 to 30, + 61 to 20, + 101 to 25, + 131 to 30, + 181 to 35, + 251 to 30, + 301 to 25, + 360 to 25 + ) + private val ExpressiveSecondaryHueRotation = arrayOf( + 0 to 45, + 21 to 95, + 51 to 45, + 121 to 20, + 151 to 45, + 191 to 90, + 271 to 45, + 321 to 45, + 360 to 45 + ) + private val ExpressiveTertiaryHueRotation = arrayOf( + 0 to 120, + 21 to 120, + 51 to 120, + 121 to 45, + 151 to 20, + 191 to 15, + 271 to 20, + 321 to 120, + 360 to 120 + ) + val TonalSpot: PaletteStyle = PaletteStyle( + accent1Spec = ColorSpec({ 36.0 }) { 0.0 }, + accent2Spec = ColorSpec({ 16.0 }) { 0.0 }, + accent3Spec = ColorSpec({ 24.0 }) { 60.0 }, + neutral1Spec = ColorSpec({ 6.0 }) { 0.0 }, + neutral2Spec = ColorSpec({ 8.0 }) { 0.0 } + ) + val Spritz: PaletteStyle = PaletteStyle( + accent1Spec = ColorSpec({ 12.0 }) { 0.0 }, + accent2Spec = ColorSpec({ 8.0 }) { 0.0 }, + accent3Spec = ColorSpec({ 16.0 }) { 30.0 }, + neutral1Spec = ColorSpec({ 2.0 }) { 0.0 }, + neutral2Spec = ColorSpec({ 2.0 }) { 0.0 } + ) + val Vibrant: PaletteStyle = PaletteStyle( + accent1Spec = ColorSpec({ 48.0 }) { 0.0 }, + accent2Spec = ColorSpec({ 24.0 }) { it.hueRotation(VibrantSecondaryHueRotation) }, + accent3Spec = ColorSpec({ 32.0 }) { it.hueRotation(VibrantTertiaryHueRotation) }, + neutral1Spec = ColorSpec({ 10.0 }) { 0.0 }, + neutral2Spec = ColorSpec({ 12.0 }) { 0.0 } + ) + val Expressive: PaletteStyle = PaletteStyle( + accent1Spec = ColorSpec({ 40.0 }) { 240.0 }, + accent2Spec = ColorSpec({ 24.0 }) { it.hueRotation(ExpressiveSecondaryHueRotation) }, + accent3Spec = ColorSpec({ 32.0 }) { it.hueRotation(ExpressiveTertiaryHueRotation) }, + neutral1Spec = ColorSpec({ 15.0 }) { 15.0 }, + neutral2Spec = ColorSpec({ 12.0 }) { 15.0 } + ) + val Rainbow: PaletteStyle = PaletteStyle( + accent1Spec = ColorSpec({ 48.0 }) { 0.0 }, + accent2Spec = ColorSpec({ 16.0 }) { 0.0 }, + accent3Spec = ColorSpec({ 24.0 }) { -60.0 }, + neutral1Spec = ColorSpec({ 0.0 }) { 0.0 }, + neutral2Spec = ColorSpec({ 0.0 }) { 0.0 } + ) + val FruitSalad: PaletteStyle = PaletteStyle( + accent1Spec = ColorSpec({ 48.0 }) { -50.0 }, + accent2Spec = ColorSpec({ 36.0 }) { -30.0 }, + accent3Spec = ColorSpec({ 36.0 }) { 0.0 }, + neutral1Spec = ColorSpec({ 10.0 }) { 0.0 }, + neutral2Spec = ColorSpec({ 16.0 }) { 0.0 } + ) + val Content: PaletteStyle = PaletteStyle( + accent1Spec = ColorSpec({ it * 1 }) { 0.0 }, + accent2Spec = ColorSpec({ it / 3 }) { 0.0 }, + accent3Spec = ColorSpec({ it * 2 / 3 }) { 60.0 }, + neutral1Spec = ColorSpec({ it / 12 }) { 0.0 }, + neutral2Spec = ColorSpec({ it / 6 }) { 0.0 } + ) + val Monochrome: PaletteStyle = PaletteStyle( + accent1Spec = ColorSpec({ 0.0 }) { 0.0 }, + accent2Spec = ColorSpec({ 0.0 }) { 0.0 }, + accent3Spec = ColorSpec({ 0.0 }) { 0.0 }, + neutral1Spec = ColorSpec({ 0.0 }) { 0.0 }, + neutral2Spec = ColorSpec({ 0.0 }) { 0.0 }, + ) + + + private fun Double.hueRotation(list: Array>): Double { + var i = 0 + val size = list.size - 2 + if (size >= 0) { + while (true) { + val i2 = i + 1 + val intValue = (list[i2]).first.toFloat() + when { + list[i].first <= this && this < intValue -> { + return (this + list[i].second.toDouble()).mod(360.0) + } + + i == size -> break + else -> i = i2 + } + } + } + return this + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/monet/TonalPalettes.kt b/app/src/main/java/org/xtimms/tokusho/ui/monet/TonalPalettes.kt new file mode 100644 index 0000000..71c7eef --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/monet/TonalPalettes.kt @@ -0,0 +1,119 @@ +package org.xtimms.tokusho.ui.monet + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import org.xtimms.tokusho.ui.harmonize.hct.Hct + +typealias TonalPalette = Map + +class TonalPalettes( + val keyColor: Color, + val style: PaletteStyle = PaletteStyle.TonalSpot, + private val accent1: TonalPalette, + private val accent2: TonalPalette, + private val accent3: TonalPalette, + private val neutral1: TonalPalette, + private val neutral2: TonalPalette +) { + infix fun accent1(tone: Double): Color = accent1.getOrElse(tone) { + keyColor.transform(tone, style.accent1Spec) + } + + infix fun accent2(tone: Double): Color = accent2.getOrElse(tone) { + keyColor.transform(tone, style.accent2Spec) + } + + infix fun accent3(tone: Double): Color = accent3.getOrElse(tone) { + keyColor.transform(tone, style.accent3Spec) + } + + infix fun neutral1(tone: Double): Color = neutral1.getOrElse(tone) { + keyColor.transform(tone, style.neutral1Spec) + } + + infix fun neutral2(tone: Double): Color = neutral2.getOrElse(tone) { + keyColor.transform(tone, style.neutral2Spec) + } + + companion object { + private val M3TonalValues = doubleArrayOf( + 0.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 85.0, 90.0, 95.0, 99.0, 100.0 + ) + private val M3SurfaceTonalValues = doubleArrayOf( + 0.0, + 4.0, + 6.0, + 10.0, + 12.0, + 17.0, + 20.0, + 22.0, + 24.0, + 30.0, + 40.0, + 50.0, + 60.0, + 70.0, + 80.0, + 85.0, + 87.0, + 90.0, + 92.0, + 94.0, + 95.0, + 96.0, + 98.0, + 99.0, + 100.0 + ) + + fun Color.toTonalPalettes( + style: PaletteStyle = PaletteStyle.TonalSpot, + tonalValues: DoubleArray = M3TonalValues + ): TonalPalettes = TonalPalettes( + keyColor = this, + style = style, + accent1 = tonalValues.associateWith { transform(it, style.accent1Spec) }, + accent2 = tonalValues.associateWith { transform(it, style.accent2Spec) }, + accent3 = tonalValues.associateWith { transform(it, style.accent3Spec) }, + neutral1 = M3SurfaceTonalValues.associateWith { transform(it, style.neutral1Spec) }, + neutral2 = tonalValues.associateWith { transform(it, style.neutral2Spec) } + ) + + + private fun Color.toTonalPalette( + tonalValues: DoubleArray = M3TonalValues + ): TonalPalette = + tonalValues.associateWith { transform(it, ColorSpec()) } + + + /** + * Convert an existing `ColorScheme` to an MD3 `TonalPalettes` + * + * Notice: This function is `PaletteStyle` independent + * + * @see ColorScheme + * @see TonalPalettes + */ + fun ColorScheme.toTonalPalettes( + tonalValues: DoubleArray = M3TonalValues + ): TonalPalettes = TonalPalettes( + keyColor = primary, + accent1 = primary.toTonalPalette(tonalValues), + accent2 = secondary.toTonalPalette(tonalValues), + accent3 = tertiary.toTonalPalette(tonalValues), + neutral1 = surface.toTonalPalette(M3SurfaceTonalValues), + neutral2 = surfaceVariant.toTonalPalette(tonalValues), + ) + + private fun Color.transform(tone: Double, spec: ColorSpec): Color { + return Color(Hct.fromInt(this.toArgb()).apply { + setTone(tone) + setChroma(spec.chroma(this.chroma)) + setHue(spec.hueShift(this.hue) + this.hue) + }.toInt()) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/theme/Color.kt b/app/src/main/java/org/xtimms/tokusho/ui/theme/Color.kt new file mode 100644 index 0000000..430a8d1 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/theme/Color.kt @@ -0,0 +1,36 @@ +package org.xtimms.shiki.ui.theme + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import org.xtimms.tokusho.ui.monet.a1 +import org.xtimms.tokusho.ui.monet.a2 +import org.xtimms.tokusho.ui.monet.a3 + +object FixedAccentColors { + val primaryFixed: Color + @Composable get() = 90.a1 + val primaryFixedDim: Color + @Composable get() = 80.a1 + val onPrimaryFixed: Color + @Composable get() = 10.a1 + val onPrimaryFixedVariant: Color + @Composable get() = 30.a1 + val secondaryFixed: Color + @Composable get() = 90.a2 + val secondaryFixedDim: Color + @Composable get() = 80.a2 + val onSecondaryFixed: Color + @Composable get() = 10.a2 + val onSecondaryFixedVariant: Color + @Composable get() = 30.a2 + val tertiaryFixed: Color + @Composable get() = 90.a3 + val tertiaryFixedDim: Color + @Composable get() = 80.a3 + val onTertiaryFixed: Color + @Composable get() = 10.a3 + val onTertiaryFixedVariant: Color + @Composable get() = 30.a3 +} + +const val SEED = 0x0057c9 \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/theme/Theme.kt b/app/src/main/java/org/xtimms/tokusho/ui/theme/Theme.kt new file mode 100644 index 0000000..280b1a0 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/theme/Theme.kt @@ -0,0 +1,89 @@ +package org.xtimms.tokusho.ui.theme + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.view.Window +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.text.style.TextDirection +import androidx.core.view.WindowCompat +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.google.android.material.color.MaterialColors +import org.xtimms.tokusho.ui.monet.dynamicColorScheme + +fun Color.applyOpacity(enabled: Boolean): Color { + return if (enabled) this else this.copy(alpha = 0.62f) +} + +@Composable +fun Color.harmonizeWith(other: Color) = + Color(MaterialColors.harmonize(this.toArgb(), other.toArgb())) + +@Composable +fun Color.harmonizeWithPrimary(): Color = + this.harmonizeWith(other = MaterialTheme.colorScheme.primary) + +private tailrec fun Context.findWindow(): Window? = + when (this) { + is Activity -> window + is ContextWrapper -> baseContext.findWindow() + else -> null + } + +@Composable +fun TokushoTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + isHighContrastModeEnabled: Boolean = false, + isDynamicColorEnabled: Boolean = false, + content: @Composable () -> Unit +) { + val colorScheme = + dynamicColorScheme(!darkTheme).run { + if (isHighContrastModeEnabled && darkTheme) copy( + surface = Color.Black, + background = Color.Black + ) + else this + } + val window = LocalView.current.context.findWindow() + val view = LocalView.current + + window?.let { + WindowCompat.getInsetsController(it, view).isAppearanceLightStatusBars = darkTheme + } + + rememberSystemUiController(window).setSystemBarsColor(Color.Transparent, !darkTheme, false) + + ProvideTextStyle( + value = LocalTextStyle.current.copy( + lineBreak = LineBreak.Paragraph, + textDirection = TextDirection.Content + ) + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) + } +} + +@Composable +fun PreviewThemeLight( + content: @Composable () -> Unit +) { + MaterialTheme( + colorScheme = dynamicColorScheme(), + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/ui/theme/Type.kt b/app/src/main/java/org/xtimms/tokusho/ui/theme/Type.kt new file mode 100644 index 0000000..1e3fbe4 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/ui/theme/Type.kt @@ -0,0 +1,42 @@ +package org.xtimms.tokusho.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) + +val preferenceTitle = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 20.sp, lineHeight = 24.sp, + lineBreak = LineBreak.Paragraph, +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/CancellableSource.kt b/app/src/main/java/org/xtimms/tokusho/utils/CancellableSource.kt new file mode 100644 index 0000000..ad326c4 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/CancellableSource.kt @@ -0,0 +1,18 @@ +package org.xtimms.tokusho.utils + +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive +import okio.Buffer +import okio.ForwardingSource +import okio.Source + +class CancellableSource( + private val job: Job?, + delegate: Source, +) : ForwardingSource(delegate) { + + override fun read(sink: Buffer, byteCount: Long): Long { + job?.ensureActive() + return super.read(sink, byteCount) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/CoilImageGetter.kt b/app/src/main/java/org/xtimms/tokusho/utils/CoilImageGetter.kt new file mode 100644 index 0000000..f51709b --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/CoilImageGetter.kt @@ -0,0 +1,29 @@ +package org.xtimms.tokusho.utils + +import android.content.Context +import android.graphics.drawable.Drawable +import android.text.Html +import androidx.annotation.WorkerThread +import coil.ImageLoader +import coil.executeBlocking +import coil.request.ImageRequest +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class CoilImageGetter @Inject constructor( + @ApplicationContext private val context: Context, + private val coil: ImageLoader, +) : Html.ImageGetter { + + @WorkerThread + override fun getDrawable(source: String?): Drawable? { + return coil.executeBlocking( + ImageRequest.Builder(context) + .data(source) + .allowHardware(false) + .build(), + ).drawable?.apply { + setBounds(0, 0, intrinsicHeight, intrinsicHeight) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/CrashLogUtil.kt b/app/src/main/java/org/xtimms/tokusho/utils/CrashLogUtil.kt new file mode 100644 index 0000000..c41d63f --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/CrashLogUtil.kt @@ -0,0 +1,42 @@ +package org.xtimms.tokusho.utils + +import android.content.Context +import android.os.Build +import org.xtimms.tokusho.BuildConfig +import org.xtimms.tokusho.utils.lang.withNonCancellableContext +import org.xtimms.tokusho.utils.lang.withUIContext +import org.xtimms.tokusho.utils.storage.getUriCompat +import org.xtimms.tokusho.utils.system.createFileInCacheDir +import org.xtimms.tokusho.utils.system.toShareIntent +import org.xtimms.tokusho.utils.system.toast + +class CrashLogUtil( + private val context: Context, +) { + + suspend fun dumpLogs() = withNonCancellableContext { + try { + val file = context.createFileInCacheDir("tokusho_crash_logs.txt") + + file.appendText(getDebugInfo() + "\n\n") + + Runtime.getRuntime().exec("logcat *:E -d -f ${file.absolutePath}").waitFor() + + val uri = file.getUriCompat(context) + context.startActivity(uri.toShareIntent(context, "text/plain")) + } catch (e: Throwable) { + withUIContext { context.toast("Failed to get logs") } + } + } + + fun getDebugInfo(): String { + return """ + App version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) + Android version: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}; build ${Build.DISPLAY}) + Device brand: ${Build.BRAND} + Device manufacturer: ${Build.MANUFACTURER} + Device name: ${Build.DEVICE} (${Build.PRODUCT}) + Device model: ${Build.MODEL} + """.trimIndent() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/ExtraCloseableSource.kt b/app/src/main/java/org/xtimms/tokusho/utils/ExtraCloseableSource.kt new file mode 100644 index 0000000..345d526 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/ExtraCloseableSource.kt @@ -0,0 +1,21 @@ +package org.xtimms.tokusho.utils + +import okhttp3.internal.closeQuietly +import okio.Closeable +import okio.Source + +private class ExtraCloseableSource( + private val delegate: Source, + private val extraCloseable: Closeable, +) : Source by delegate { + + override fun close() { + try { + delegate.close() + } finally { + extraCloseable.closeQuietly() + } + } +} + +fun Source.withExtraCloseable(closeable: Closeable): Source = ExtraCloseableSource(this, closeable) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/MediatorStateFlow.kt b/app/src/main/java/org/xtimms/tokusho/utils/MediatorStateFlow.kt new file mode 100644 index 0000000..c69b750 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/MediatorStateFlow.kt @@ -0,0 +1,39 @@ +package org.xtimms.tokusho.utils + +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.util.concurrent.atomic.AtomicInteger + +abstract class MediatorStateFlow(initialValue: T) : StateFlow { + + private val delegate = MutableStateFlow(initialValue) + private val collectors = AtomicInteger(0) + + final override val replayCache: List + get() = delegate.replayCache + + final override val value: T + get() = delegate.value + + final override suspend fun collect(collector: FlowCollector): Nothing { + try { + if (collectors.getAndIncrement() == 0) { + onActive() + } + delegate.collect(collector) + } finally { + if (collectors.decrementAndGet() == 0) { + onInactive() + } + } + } + + protected fun publishValue(v: T) { + delegate.value = v + } + + protected abstract fun onActive() + + protected abstract fun onInactive() +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/Modifier.kt b/app/src/main/java/org/xtimms/tokusho/utils/Modifier.kt new file mode 100644 index 0000000..1e62a14 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/Modifier.kt @@ -0,0 +1,7 @@ +package org.xtimms.tokusho.utils + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import org.xtimms.tokusho.utils.material.SecondaryItemAlpha + +fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(SecondaryItemAlpha) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/ProgressResponseBody.kt b/app/src/main/java/org/xtimms/tokusho/utils/ProgressResponseBody.kt new file mode 100644 index 0000000..c3255cc --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/ProgressResponseBody.kt @@ -0,0 +1,51 @@ +package org.xtimms.tokusho.utils + +import kotlinx.coroutines.flow.MutableStateFlow +import okhttp3.MediaType +import okhttp3.ResponseBody +import okio.Buffer +import okio.BufferedSource +import okio.ForwardingSource +import okio.Source +import okio.buffer + +class ProgressResponseBody( + private val delegate: ResponseBody, + private val progressState: MutableStateFlow, +) : ResponseBody() { + + private var bufferedSource: BufferedSource? = null + + override fun close() { + super.close() + delegate.close() + } + + override fun contentLength(): Long = delegate.contentLength() + + override fun contentType(): MediaType? = delegate.contentType() + + override fun source(): BufferedSource { + return bufferedSource ?: ProgressSource(delegate.source(), contentLength(), progressState).buffer().also { + bufferedSource = it + } + } + + private class ProgressSource( + delegate: Source, + private val contentLength: Long, + private val progressState: MutableStateFlow, + ) : ForwardingSource(delegate) { + + private var totalBytesRead = 0L + + override fun read(sink: Buffer, byteCount: Long): Long { + val bytesRead = super.read(sink, byteCount) + if (contentLength > 0) { + totalBytesRead += if (bytesRead != -1L) bytesRead else 0 + progressState.value = (totalBytesRead.toDouble() / contentLength.toDouble()).toFloat() + } + return bytesRead + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/Coroutines.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/Coroutines.kt new file mode 100644 index 0000000..865b627 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Coroutines.kt @@ -0,0 +1,72 @@ +package org.xtimms.tokusho.utils.lang + +import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used. + * + * **Possible replacements** + * - suspend function + * - custom scope like view or presenter scope + */ +@DelicateCoroutinesApi +fun launchUI(block: suspend CoroutineScope.() -> Unit): Job = + GlobalScope.launch(Dispatchers.Main, CoroutineStart.DEFAULT, block) + +/** + * Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used. + * + * **Possible replacements** + * - suspend function + * - custom scope like view or presenter scope + */ +@DelicateCoroutinesApi +fun launchIO(block: suspend CoroutineScope.() -> Unit): Job = + GlobalScope.launch(Dispatchers.IO, CoroutineStart.DEFAULT, block) + +/** + * Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used. + * + * **Possible replacements** + * - suspend function + * - custom scope like view or presenter scope + */ +@DelicateCoroutinesApi +fun launchNow(block: suspend CoroutineScope.() -> Unit): Job = + GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED, block) + +fun CoroutineScope.launchUI(block: suspend CoroutineScope.() -> Unit): Job = + launch(Dispatchers.Main, block = block) + +fun CoroutineScope.launchIO(block: suspend CoroutineScope.() -> Unit): Job = + launch(Dispatchers.IO, block = block) + +fun CoroutineScope.launchNonCancellable(block: suspend CoroutineScope.() -> Unit): Job = + launchIO { withContext(NonCancellable, block) } + +suspend fun withUIContext(block: suspend CoroutineScope.() -> T) = withContext( + Dispatchers.Main, + block, +) + +suspend fun withIOContext(block: suspend CoroutineScope.() -> T) = withContext( + Dispatchers.IO, + block, +) + +suspend fun withNonCancellableContext(block: suspend CoroutineScope.() -> T) = + withContext(NonCancellable, block) + +val processLifecycleScope: LifecycleCoroutineScope + inline get() = ProcessLifecycleOwner.get().lifecycleScope \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/IO.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/IO.kt new file mode 100644 index 0000000..122e25a --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/IO.kt @@ -0,0 +1,25 @@ +package org.xtimms.tokusho.utils.lang + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext +import okhttp3.ResponseBody +import okio.BufferedSink +import okio.Source +import org.xtimms.tokusho.utils.CancellableSource +import org.xtimms.tokusho.utils.ProgressResponseBody + +fun ResponseBody.withProgress(progressState: MutableStateFlow): ResponseBody { + return ProgressResponseBody(this, progressState) +} + +suspend fun Source.cancellable(): Source { + val job = currentCoroutineContext()[Job] + return CancellableSource(job, this) +} + +suspend fun BufferedSink.writeAllCancellable(source: Source) = withContext(Dispatchers.IO) { + writeAll(source.cancellable()) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/Primitive.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/Primitive.kt new file mode 100644 index 0000000..ce71ae3 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/Primitive.kt @@ -0,0 +1,22 @@ +package org.xtimms.tokusho.utils.lang + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import org.xtimms.tokusho.core.prefs.AppSettings.getBoolean +import org.xtimms.tokusho.core.prefs.AppSettings.getInt +import org.xtimms.tokusho.core.prefs.AppSettings.getString + +inline val String.booleanState + @Composable get() = + remember { mutableStateOf(this.getBoolean()) } + +inline val String.stringState + @Composable get() = + remember { mutableStateOf(this.getString()) } + +inline val String.intState + @Composable get() = remember { + mutableIntStateOf(this.getInt()) + } \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/lang/String.kt b/app/src/main/java/org/xtimms/tokusho/utils/lang/String.kt new file mode 100644 index 0000000..b060491 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/lang/String.kt @@ -0,0 +1,5 @@ +package org.xtimms.tokusho.utils.lang + +inline fun C?.ifNullOrEmpty(defaultValue: () -> C): C { + return if (this.isNullOrEmpty()) defaultValue() else this +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/material/Constants.kt b/app/src/main/java/org/xtimms/tokusho/utils/material/Constants.kt new file mode 100644 index 0000000..07dfe42 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/material/Constants.kt @@ -0,0 +1,3 @@ +package org.xtimms.tokusho.utils.material + +const val SecondaryItemAlpha = .78f \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/storage/File.kt b/app/src/main/java/org/xtimms/tokusho/utils/storage/File.kt new file mode 100644 index 0000000..d79eec0 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/storage/File.kt @@ -0,0 +1,16 @@ +package org.xtimms.tokusho.utils.storage + +import android.content.Context +import android.net.Uri +import androidx.core.content.FileProvider +import org.xtimms.tokusho.BuildConfig +import java.io.File + +/** + * Returns the uri of a file + * + * @param context context of application + */ +fun File.getUriCompat(context: Context): Uri { + return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", this) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Context.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Context.kt new file mode 100644 index 0000000..6e0bf1e --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Context.kt @@ -0,0 +1,23 @@ +package org.xtimms.tokusho.utils.system + +import android.app.ActivityManager +import android.content.Context +import android.content.Context.ACTIVITY_SERVICE +import java.io.File + +val Context.activityManager: ActivityManager? + get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager + +fun Context.createFileInCacheDir(name: String): File { + val file = File(externalCacheDir, name) + if (file.exists()) { + file.delete() + } + file.createNewFile() + + return file +} + +fun Context.isLowRamDevice(): Boolean { + return activityManager?.isLowRamDevice ?: false +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt new file mode 100644 index 0000000..46b52fb --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/File.kt @@ -0,0 +1,5 @@ +package org.xtimms.tokusho.utils.system + +import android.content.Context + +fun Context.getFileProvider() = "$packageName.provider" \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Http.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Http.kt new file mode 100644 index 0000000..b200afc --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Http.kt @@ -0,0 +1,23 @@ +package org.xtimms.tokusho.utils.system + +import okhttp3.Cookie + +fun Cookie.newBuilder(): Cookie.Builder = 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Intent.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Intent.kt new file mode 100644 index 0000000..2e84730 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Intent.kt @@ -0,0 +1,30 @@ +package org.xtimms.tokusho.utils.system + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.net.Uri +import org.xtimms.tokusho.R + +fun Uri.toShareIntent(context: Context, type: String = "image/*", message: String? = null): Intent { + val uri = this + + val shareIntent = Intent(Intent.ACTION_SEND).apply { + when (uri.scheme) { + "http", "https" -> { + putExtra(Intent.EXTRA_TEXT, uri.toString()) + } + "content" -> { + message?.let { putExtra(Intent.EXTRA_TEXT, it) } + putExtra(Intent.EXTRA_STREAM, uri) + } + } + clipData = ClipData.newRawUri(null, uri) + setType(type) + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + } + + return Intent.createChooser(shareIntent, context.getString(R.string.action_share)).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Locale.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Locale.kt new file mode 100644 index 0000000..301e31c --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Locale.kt @@ -0,0 +1,46 @@ +package org.xtimms.tokusho.utils.system + +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.core.os.LocaleListCompat +import org.xtimms.tokusho.R +import org.xtimms.tokusho.core.prefs.AppSettings.getInt +import org.xtimms.tokusho.core.prefs.LANGUAGE +import org.xtimms.tokusho.core.prefs.SYSTEM_DEFAULT +import java.util.Locale + +fun LocaleListCompat.toList(): List = List(size()) { i -> getOrThrow(i) } + +fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException() + +private fun getLanguageNumberByCode(languageCode: String) : Int = + languageMap.entries.find { it.value == languageCode }?.key ?: SYSTEM_DEFAULT + +fun getLanguageNumber(): Int { + return if (Build.VERSION.SDK_INT >= 33) + getLanguageNumberByCode( + LocaleListCompat.getAdjustedDefault()[0]?.toLanguageTag().toString() + ) + else LANGUAGE.getInt() +} + +@Composable +fun getLanguageDesc(language: Int = getLanguageNumber()): String { + return stringResource( + when (language) { + ENGLISH -> R.string.la_en_US + RUSSIAN -> R.string.la_ru + else -> R.string.follow_system + } + ) +} + +// Do not modify +private const val ENGLISH = 1 +private const val RUSSIAN = 2 + +// Sorted alphabetically +val languageMap: Map = mapOf( + RUSSIAN to "ru", +) \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Network.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Network.kt new file mode 100644 index 0000000..080283d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Network.kt @@ -0,0 +1,18 @@ +package org.xtimms.tokusho.utils.system + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities + +val Context.connectivityManager: ConnectivityManager + get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + +fun ConnectivityManager.isOnline(): Boolean { + return activeNetwork?.let { isOnline(it) } ?: false +} + +private fun ConnectivityManager.isOnline(network: Network): Boolean { + val capabilities = getNetworkCapabilities(network) + return capabilities != null && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/PaddingValues.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/PaddingValues.kt new file mode 100644 index 0000000..b66089d --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/PaddingValues.kt @@ -0,0 +1,22 @@ +package org.xtimms.tokusho.utils.system + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.platform.LocalLayoutDirection + +@Composable +@ReadOnlyComposable +operator fun PaddingValues.plus(other: PaddingValues): PaddingValues { + val layoutDirection = LocalLayoutDirection.current + return PaddingValues( + start = calculateStartPadding(layoutDirection) + + other.calculateStartPadding(layoutDirection), + end = calculateEndPadding(layoutDirection) + + other.calculateEndPadding(layoutDirection), + top = calculateTopPadding() + other.calculateTopPadding(), + bottom = calculateBottomPadding() + other.calculateBottomPadding(), + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Preferences.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Preferences.kt new file mode 100644 index 0000000..40e228b --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Preferences.kt @@ -0,0 +1,18 @@ +package org.xtimms.tokusho.utils.system + +import android.content.SharedPreferences + +fun > SharedPreferences.getEnumValue(key: String, enumClass: Class): E? { + val stringValue = getString(key, null) ?: return null + return enumClass.enumConstants?.find { + it.name == stringValue + } +} + +fun > SharedPreferences.getEnumValue(key: String, defaultValue: E): E { + return getEnumValue(key, defaultValue.javaClass) ?: defaultValue +} + +fun > SharedPreferences.Editor.putEnumValue(key: String, value: E?) { + putString(key, value?.name) +} \ No newline at end of file diff --git a/app/src/main/java/org/xtimms/tokusho/utils/system/Toast.kt b/app/src/main/java/org/xtimms/tokusho/utils/system/Toast.kt new file mode 100644 index 0000000..e28b193 --- /dev/null +++ b/app/src/main/java/org/xtimms/tokusho/utils/system/Toast.kt @@ -0,0 +1,22 @@ +package org.xtimms.tokusho.utils.system + +import android.content.Context +import android.widget.Toast +import androidx.annotation.StringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.xtimms.tokusho.App.Companion.applicationScope + +fun Context.toast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() +} + +fun Context.toast(@StringRes stringRes: Int) { + toast(getString(stringRes)) +} + +fun Context.suspendToast(@StringRes stringRes: Int) { + applicationScope.launch(Dispatchers.Main) { + toast(getString(stringRes)) + } +} diff --git a/app/src/main/res/drawable-nodpi/ookami.webp b/app/src/main/res/drawable-nodpi/ookami.webp new file mode 100644 index 0000000..60d40e2 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ookami.webp differ diff --git a/app/src/main/res/drawable-nodpi/sample.webp b/app/src/main/res/drawable-nodpi/sample.webp new file mode 100644 index 0000000..245d29f Binary files /dev/null and b/app/src/main/res/drawable-nodpi/sample.webp differ diff --git a/app/src/main/res/drawable-nodpi/sample1.webp b/app/src/main/res/drawable-nodpi/sample1.webp new file mode 100644 index 0000000..9e35e63 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/sample1.webp differ diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..8b8484d --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,60 @@ + + Tokusho + Settings + Feed + Search + Explore + History + Shelf + Local storage + Bookmarks + Random + Downloads + About + Appearance + Theme, list mode, language + Version, automatic updates + Nothing here + Whoops! + %s ran into an unexpected error. We suggest you share the crash logs in our support channel on Discord. + Share crash logs + Restart the application + Share + Follow system + On + Off + Open settings + Automatic updates + Automatically check for the latest version on GitHub + Version + Info copied to clipboard + Enable auto update + Update channel + Stable + Preview + Check for updates + No new updates + An error occurred while checking updates + Install pre-release builds to preview new features and changes.\n\nThere will be some instability in there versions, so please don\'t hesitate to give us feedback if you experience any problems to help us improve the app for the future. + Language + Dark theme + Dynamic color + Apply colors from wallpapers to the app theme + English (United States) + Русский + Cancel + Additional settings + High contrast dark theme + System settings + Translate + Help translate this app on Hosted Weblate + Update + Dismiss + Added + Progress + Alphabetic + Default + No results found + No matches + No manga in this category + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..df42be7 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +