Initial commit

master
Zakhar Timoshenko 2 years ago
commit 8617f18f90
Signed by: Xtimms
SSH Key Fingerprint: SHA256:wH6spYepK/A5erBh7ZyAnr1ru9H4eaMVBEuiw6DSpxI

115
.gitignore vendored

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

3
.idea/.gitignore vendored

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

@ -0,0 +1 @@
Tokusho

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
</component>
</project>

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<value>
<entry key="app">
<State>
<targetSelectedWithDropDown>
<Target>
<type value="QUICK_BOOT_TARGET" />
<deviceKey>
<Key>
<type value="VIRTUAL_DEVICE_PATH" />
<value value="C:\Users\xtimms\.android\avd\Pixel_API_34.avd" />
</Key>
</deviceKey>
</Target>
</targetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2024-01-29T14:26:34.807344800Z" />
</State>
</entry>
</value>
</component>
</project>

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
</component>
</project>

@ -0,0 +1,41 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.0" />
</component>
</project>

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

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

1
app/.gitignore vendored

@ -0,0 +1 @@
/build

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,66 @@
package org.xtimms.tokusho.core.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
import org.xtimms.tokusho.core.BottomNavDestination
import org.xtimms.tokusho.core.BottomNavDestination.Companion.Icon
import org.xtimms.tokusho.sections.explore.EXPLORE_DESTINATION
import org.xtimms.tokusho.sections.history.HISTORY_DESTINATION
import org.xtimms.tokusho.sections.shelf.SHELF_DESTINATION
@Composable
fun BottomNavBar(
navController: NavController,
bottomBarState: State<Boolean>,
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val isVisible by remember {
derivedStateOf {
when (navBackStackEntry?.destination?.route) {
SHELF_DESTINATION, HISTORY_DESTINATION, EXPLORE_DESTINATION, null -> bottomBarState.value
else -> false
}
}
}
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it })
) {
NavigationBar {
BottomNavDestination.values.forEachIndexed { _, dest ->
val isSelected = navBackStackEntry?.destination?.route == dest.route
NavigationBarItem(
selected = isSelected,
onClick = {
navController.navigate(dest.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = { dest.Icon(selected = isSelected) },
label = { Text(text = stringResource(dest.title)) }
)
}
}
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -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 = "",
)

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

@ -0,0 +1,78 @@
package org.xtimms.tokusho.sections.shelf
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.screens.EmptyScreen
import org.xtimms.tokusho.utils.system.plus
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ShelfPager(
state: PagerState,
contentPadding: PaddingValues,
hasActiveFilters: Boolean,
searchQuery: String?,
onGlobalSearchClicked: () -> Unit,
getLibraryForPage: (Int) -> List<ShelfItem>,
) {
HorizontalPager(
modifier = Modifier.fillMaxSize(),
state = state,
verticalAlignment = Alignment.Top,
) { page ->
if (page !in ((state.currentPage - 1)..(state.currentPage + 1))) {
// To make sure only one offscreen page is being composed
return@HorizontalPager
}
val library = getLibraryForPage(page)
if (library.isEmpty()) {
ShelfPagerEmptyScreen(
searchQuery = searchQuery,
hasActiveFilters = hasActiveFilters,
contentPadding = contentPadding,
onGlobalSearchClicked = onGlobalSearchClicked,
)
return@HorizontalPager
}
}
}
@Composable
private fun ShelfPagerEmptyScreen(
searchQuery: String?,
hasActiveFilters: Boolean,
contentPadding: PaddingValues,
onGlobalSearchClicked: () -> Unit,
) {
val msg = when {
!searchQuery.isNullOrEmpty() -> R.string.no_results_found
hasActiveFilters -> R.string.error_no_match
else -> R.string.information_no_manga_category
}
Column(
modifier = Modifier
.padding(contentPadding + PaddingValues(8.dp))
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
EmptyScreen(
title = msg,
modifier = Modifier.weight(1f),
)
}
}

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

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

Loading…
Cancel
Save