Initial commit
commit
8617f18f90
@ -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
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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…
Reference in New Issue