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