Compare commits
230 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f79c11303 | ||
|
|
a9cd3db27e | ||
|
|
47d96a6ba9 | ||
|
|
3ee5e683f4 | ||
|
|
71e8cebff4 | ||
|
|
fd3f1454c5 | ||
|
|
4028739e70 | ||
|
|
067a263336 | ||
|
|
62948abf75 | ||
|
|
e8ba5c4881 | ||
|
|
e648b6dfee | ||
|
|
d1381b8700 | ||
|
|
f8df28311e | ||
|
|
59afa04744 | ||
|
|
7a5c3ae2ed | ||
|
|
9e9a5998cd | ||
|
|
f34876ca93 | ||
|
|
48752a323f | ||
|
|
ab3e6466d5 | ||
|
|
419c8fc644 | ||
|
|
69078ac42e | ||
|
|
91b6baaf1c | ||
|
|
3f3774a0cd | ||
|
|
efc40ce458 | ||
|
|
39b8bbc725 | ||
|
|
b0fedd78fb | ||
|
|
72b0fa78bb | ||
|
|
114158cf73 | ||
|
|
6d108dd7ff | ||
|
|
f36b7f1dbe | ||
|
|
0a22ebd8e9 | ||
|
|
3682eeaf94 | ||
|
|
7df2ae4ba7 | ||
|
|
c9519ec681 | ||
|
|
b146ed684d | ||
|
|
d2787c36d7 | ||
|
|
3ff663114a | ||
|
|
573e62f310 | ||
|
|
f9af670b82 | ||
|
|
bf461475c6 | ||
|
|
bdea6e0cc1 | ||
|
|
57f0ec4e5d | ||
|
|
d663092363 | ||
|
|
edf6188e36 | ||
|
|
f3f3395e68 | ||
|
|
ac9dc347e3 | ||
|
|
8721d85946 | ||
|
|
a0bd1a8738 | ||
|
|
35fdf3e3b0 | ||
|
|
aced8293f1 | ||
|
|
3f516faad8 | ||
|
|
824f7b9602 | ||
|
|
95aeeaa16f | ||
|
|
63f08f0230 | ||
|
|
3b241fe857 | ||
|
|
75bc104f43 | ||
|
|
30afd56324 | ||
|
|
5ee1bb11a0 | ||
|
|
c1de45abce | ||
|
|
8805033c8d | ||
|
|
0ed59bb8a9 | ||
|
|
8163f2fd28 | ||
|
|
521a65c9d2 | ||
|
|
eb98424668 | ||
|
|
961c731743 | ||
|
|
5188769fb6 | ||
|
|
8f27d9e30f | ||
|
|
b58566999e | ||
|
|
117d6dcd2b | ||
|
|
2608796929 | ||
|
|
792f5b5a7f | ||
|
|
a77b1db749 | ||
|
|
9d984d92af | ||
|
|
e303f25991 | ||
|
|
85973d2305 | ||
|
|
13f8d7b747 | ||
|
|
e198860edb | ||
|
|
fc8355467b | ||
|
|
67abc15442 | ||
|
|
e94cddb86a | ||
|
|
700f7a33a5 | ||
|
|
41e952144d | ||
|
|
910ed65937 | ||
|
|
e06701a2fb | ||
|
|
62dce26c73 | ||
|
|
ac0cff62d4 | ||
|
|
655c060814 | ||
|
|
36d27895e7 | ||
|
|
803481f74c | ||
|
|
b3ca1686e3 | ||
|
|
8f220eb0cb | ||
|
|
51d5f42e8b | ||
|
|
8d8c5ace61 | ||
|
|
4bb6b8ccc9 | ||
|
|
6bebd36e83 | ||
|
|
edc7053e50 | ||
|
|
55e6ef5f78 | ||
|
|
9781d7a5dc | ||
|
|
b83cf87cd8 | ||
|
|
430864512d | ||
|
|
16eeef1878 | ||
|
|
994d4b589b | ||
|
|
43adba6f13 | ||
|
|
e4fbd21731 | ||
|
|
8be64745fc | ||
|
|
b66f376729 | ||
|
|
cc40416e1e | ||
|
|
5073352366 | ||
|
|
9ae12a2c4c | ||
|
|
843b8412a9 | ||
|
|
4f67578371 | ||
|
|
37f2227093 | ||
|
|
1833c0bde5 | ||
|
|
aa3aeca3f2 | ||
|
|
152d4e248f | ||
|
|
7461c8d201 | ||
|
|
0902fdf981 | ||
|
|
0fd2cf4fd7 | ||
|
|
679558106f | ||
|
|
e498efc493 | ||
|
|
74bbc71741 | ||
|
|
502b4890e3 | ||
|
|
dfb60461e4 | ||
|
|
bd6bc418e6 | ||
|
|
a284143ce1 | ||
|
|
1f1c782772 | ||
|
|
5c0f5fe333 | ||
|
|
748e023fde | ||
|
|
30104bacd2 | ||
|
|
f33d1a1bfa | ||
|
|
3c08331441 | ||
|
|
3eaa38247b | ||
|
|
304ce643f9 | ||
|
|
b4ad994f95 | ||
|
|
03c5cfa791 | ||
|
|
e8056072b8 | ||
|
|
d134639a5f | ||
|
|
b4745d76b8 | ||
|
|
c5fd674020 | ||
|
|
9b821dd7cb | ||
|
|
1b441f6aea | ||
|
|
213902c854 | ||
|
|
2054922586 | ||
|
|
a17b7355f5 | ||
|
|
066a1e1f3a | ||
|
|
b10cbfbd63 | ||
|
|
fcd72bb8bd | ||
|
|
37cd99731c | ||
|
|
ed97773f24 | ||
|
|
0424ba3e87 | ||
|
|
9539c4e7bf | ||
|
|
248b378f01 | ||
|
|
1c40575665 | ||
|
|
ac67c648be | ||
|
|
42cc026acc | ||
|
|
23a74edfad | ||
|
|
5da1804f17 | ||
|
|
75f0c35017 | ||
|
|
0e6b02d260 | ||
|
|
d5a0ce55f0 | ||
|
|
09fc6fe8ef | ||
|
|
ff30be879a | ||
|
|
309fe4d831 | ||
|
|
dff0c817a7 | ||
|
|
04313981d4 | ||
|
|
810cb4d13a | ||
|
|
969e32e744 | ||
|
|
980909df9b | ||
|
|
e6753088a4 | ||
|
|
cbdb6cb63a | ||
|
|
3cdf1a899e | ||
|
|
c796be5de5 | ||
|
|
db301cb0c3 | ||
|
|
f00421ef23 | ||
|
|
b324654967 | ||
|
|
aa10ada3ee | ||
|
|
10c97987fb | ||
|
|
b532615bbd | ||
|
|
3066f41af3 | ||
|
|
0c401c6741 | ||
|
|
1a21d1c937 | ||
|
|
525b49a5c9 | ||
|
|
34c074bf7b | ||
|
|
b4dc961cdc | ||
|
|
93374d2cfe | ||
|
|
4009b10549 | ||
|
|
db1864205f | ||
|
|
bf39ccabbd | ||
|
|
0e8e7767ee | ||
|
|
5b6c86e34f | ||
|
|
6bbaca3686 | ||
|
|
35eae90df1 | ||
|
|
488d43e076 | ||
|
|
7c5e93c171 | ||
|
|
a20ef783e1 | ||
|
|
8ae0dce0ed | ||
|
|
44aea606b7 | ||
|
|
a05dc8c661 | ||
|
|
1f80e36017 | ||
|
|
1efca40744 | ||
|
|
86e3131afa | ||
|
|
4910b4a4b0 | ||
|
|
9c7320c0a0 | ||
|
|
02c17c3b75 | ||
|
|
49a47f4b4f | ||
|
|
68280f4a62 | ||
|
|
0e3669b247 | ||
|
|
4c9aa29d46 | ||
|
|
66fbf10f2d | ||
|
|
15ad806eb8 | ||
|
|
b7f80b9c82 | ||
|
|
9b511d2f8f | ||
|
|
6ebce2deb3 | ||
|
|
95dade13f4 | ||
|
|
ba4449d003 | ||
|
|
7632fe5e86 | ||
|
|
2c56bcacee | ||
|
|
c8202db3c6 | ||
|
|
223d689b0c | ||
|
|
4f0e7d9696 | ||
|
|
f4ce911de9 | ||
|
|
d0ad7effa0 | ||
|
|
a032beecbf | ||
|
|
46ec9e48d9 | ||
|
|
26bcef1cc0 | ||
|
|
bfb2f44f8f | ||
|
|
28b19b6774 | ||
|
|
8d72f4a3aa | ||
|
|
9c62e0399d | ||
|
|
65ea09854e |
@@ -2,8 +2,8 @@
|
||||
*Pupil, Hitomi.la viewer for Android*
|
||||
|
||||

|
||||
[](https://github.com/tom5079/Pupil/releases/download/5.1.6-hotfix7/Pupil-v5.1.6-hotfix7.apk)
|
||||
[](https://discord.gg/Stj4b5v)
|
||||
[](https://github.com/tom5079/Pupil/releases/download/5.3.8-hotfix1/Pupil-v5.3.8-hotfix1.apk)
|
||||
[](https://discord.gg/Stj4b5v)
|
||||
|
||||
# Features
|
||||

|
||||
@@ -20,7 +20,7 @@ or Build app yourself
|
||||
|
||||
# Contribution
|
||||
|
||||
Any kind of contribution is appriciated. Feel free to leave PR!
|
||||
Any kind of contribution is appreciated. Feel free to leave PR!
|
||||
|
||||
## Tag Translation
|
||||
Head over to [tags branch](https://github.com/tom5079/Pupil/tree/tags)
|
||||
|
||||
@@ -1,183 +1,95 @@
|
||||
import com.google.protobuf.gradle.*
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
kotlin("android")
|
||||
kotlin("kapt")
|
||||
id("kotlin-parcelize")
|
||||
id("kotlinx-serialization")
|
||||
id("com.google.android.gms.oss-licenses-plugin")
|
||||
id("com.google.gms.google-services")
|
||||
id("com.google.firebase.crashlytics")
|
||||
id("com.google.firebase.firebase-perf")
|
||||
id("com.google.protobuf")
|
||||
alias(libs.plugins.org.jetbrains.kotlin.android)
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.googleServices)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlinx.serialization)
|
||||
alias(libs.plugins.crashlytics)
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 33
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = File(System.getenv("SIGNING_STORE_FILE"))
|
||||
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
|
||||
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
|
||||
keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
|
||||
namespace = "xyz.quaver.pupil"
|
||||
defaultConfig {
|
||||
applicationId = "xyz.quaver.pupil"
|
||||
minSdk = 21
|
||||
targetSdk = 33
|
||||
versionCode = 600
|
||||
versionName = VERSION
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
versionCode = 69
|
||||
versionName = "6.0.0"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
isDebuggable = true
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-DEBUG"
|
||||
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
|
||||
extra.set("enableCrashlytics", false)
|
||||
extra.set("alwaysUpdateBuildId", false)
|
||||
ext.set("enableCrashlytics", false)
|
||||
ext.set("alwaysUpdateBuildId", false)
|
||||
}
|
||||
getByName("release") {
|
||||
isMinifyEnabled = false
|
||||
applicationIdSuffix = ".beta"
|
||||
|
||||
isCrunchPngs = false
|
||||
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = Versions.JETPACK_COMPOSE
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
resources.excludes.addAll(
|
||||
listOf(
|
||||
"META-INF/AL2.0",
|
||||
"META-INF/LGPL2.1"
|
||||
)
|
||||
)
|
||||
}
|
||||
namespace = "xyz.quaver.pupil"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
|
||||
implementation(Kotlin.SERIALIZATION)
|
||||
implementation(Kotlin.COROUTINE)
|
||||
coreLibraryDesugaring(libs.android.desugaring)
|
||||
|
||||
implementation("androidx.activity:activity-compose:1.6.1")
|
||||
implementation("androidx.navigation:navigation-compose:2.5.3")
|
||||
implementation(libs.kotlinx.serialization)
|
||||
implementation(libs.kotlinx.coroutines)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
|
||||
implementation(JetpackCompose.FOUNDATION)
|
||||
implementation(JetpackCompose.UI)
|
||||
implementation(JetpackCompose.UI_UTIL)
|
||||
implementation(JetpackCompose.UI_TOOLING)
|
||||
implementation(JetpackCompose.ANIMATION)
|
||||
implementation(JetpackCompose.MATERIAL)
|
||||
implementation(JetpackCompose.MATERIAL_ICONS)
|
||||
implementation(JetpackCompose.RUNTIME_LIVEDATA)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
// implementation(JetpackCompose.MARKDOWN)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material3.windowSizeClass)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
|
||||
implementation(Accompanist.INSETS)
|
||||
implementation(Accompanist.INSETS_UI)
|
||||
implementation(Accompanist.FLOW_LAYOUT)
|
||||
implementation(Accompanist.SYSTEM_UI_CONTROLLER)
|
||||
implementation(Accompanist.DRAWABLE_PAINTER)
|
||||
implementation(Accompanist.APPCOMPAT_THEME)
|
||||
implementation(libs.androidx.room.runtime)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
implementation("io.coil-kt:coil-compose:2.0.0-rc03")
|
||||
implementation(libs.accompanist.adaptive)
|
||||
|
||||
implementation(KtorClient.CORE)
|
||||
implementation(KtorClient.OKHTTP)
|
||||
implementation(KtorClient.CONTENT_NEGOTIATION)
|
||||
implementation(KtorClient.SERIALIZATION)
|
||||
implementation(libs.coil)
|
||||
|
||||
implementation("androidx.room:room-runtime:2.4.3")
|
||||
annotationProcessor("androidx.room:room-compiler:2.4.3")
|
||||
kapt("androidx.room:room-compiler:2.4.3")
|
||||
implementation("androidx.room:room-ktx:2.4.3")
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.analytics)
|
||||
implementation(libs.firebase.crashlytics)
|
||||
implementation(libs.firebase.perf)
|
||||
|
||||
implementation("androidx.datastore:datastore:1.0.0")
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
|
||||
implementation("org.kodein.di:kodein-di-framework-compose:7.11.0")
|
||||
implementation(libs.ktor.client)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
|
||||
implementation(platform("com.google.firebase:firebase-bom:29.0.3"))
|
||||
implementation("com.google.firebase:firebase-analytics-ktx")
|
||||
implementation("com.google.firebase:firebase-crashlytics-ktx")
|
||||
implementation("com.google.firebase:firebase-perf-ktx")
|
||||
|
||||
implementation("com.google.protobuf:protobuf-javalite:3.19.1")
|
||||
|
||||
implementation("com.google.android.gms:play-services-oss-licenses:17.0.0")
|
||||
|
||||
implementation("org.jsoup:jsoup:1.14.3")
|
||||
|
||||
implementation("xyz.quaver.pupil.sources:core:0.0.1-alpha01-DEV29")
|
||||
|
||||
implementation("xyz.quaver:documentfilex:0.7.2")
|
||||
implementation("xyz.quaver:subsampledimage:0.0.1-alpha22-SNAPSHOT")
|
||||
|
||||
implementation("org.kodein.log:kodein-log:0.12.0")
|
||||
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.8.1")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.mockito:mockito-inline:4.4.0")
|
||||
testImplementation(KtorClient.TEST)
|
||||
testImplementation(Kotlin.COROUTINE_TEST)
|
||||
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.3")
|
||||
androidTestImplementation("androidx.test:rules:1.4.0")
|
||||
androidTestImplementation("androidx.test:runner:1.4.0")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
|
||||
androidTestImplementation(KtorClient.TEST)
|
||||
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.1.1")
|
||||
}
|
||||
|
||||
protobuf {
|
||||
protoc {
|
||||
artifact = "com.google.protobuf:protoc:3.19.1"
|
||||
}
|
||||
generateProtoTasks {
|
||||
all().forEach { task ->
|
||||
task.builtins {
|
||||
id("java") {
|
||||
option("lite")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
task<Exec>("clearAppCache") {
|
||||
commandLine("adb", "shell", "pm", "clear", "xyz.quaver.pupil.debug")
|
||||
implementation(libs.documentFileX)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"installed":{"client_id":"644157827114-rnbcmlqiaqgg295o45kavchnvi3dedbo.apps.googleusercontent.com","project_id":"pupil-1598439316578","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","redirect_uris":["urn:ietf:wg:oauth:2.0:oob","http://localhost"]}}
|
||||
BIN
app/libs/pinlockview-release.aar
Normal file
BIN
app/libs/pinlockview-release.aar
Normal file
Binary file not shown.
BIN
app/libs/recyclerviewfastscroller-release.aar
Normal file
BIN
app/libs/recyclerviewfastscroller-release.aar
Normal file
Binary file not shown.
22
app/proguard-rules.pro
vendored
22
app/proguard-rules.pro
vendored
@@ -23,23 +23,13 @@
|
||||
-dontobfuscate
|
||||
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations
|
||||
|
||||
# kotlinx-serialization-json specific. Add this if you have java.lang.NoClassDefFoundError kotlinx.serialization.json.JsonObjectSerializer
|
||||
-keepclassmembers class kotlinx.serialization.json.** {
|
||||
-dontnote kotlinx.serialization.SerializationKt
|
||||
-keep,includedescriptorclasses class xyz.quaver.**$$serializer { *; } # <-- change package name to your app's
|
||||
-keepclassmembers class xyz.quaver.pupil.** { # <-- change package name to your app's
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class kotlinx.serialization.json.** {
|
||||
-keepclasseswithmembers class xyz.quaver.pupil.** { # <-- change package name to your app's
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
-keep,includedescriptorclasses class xyz.quaver.**$$serializer { *; }
|
||||
-keepclassmembers class xyz.quaver.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class xyz.quaver.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
|
||||
<fields>;
|
||||
}
|
||||
-keep class xyz.quaver.pupil.** { *; }
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
@@ -4,15 +4,15 @@
|
||||
"type": "APK",
|
||||
"kind": "Directory"
|
||||
},
|
||||
"applicationId": "xyz.quaver.pupil.beta",
|
||||
"applicationId": "xyz.quaver.pupil",
|
||||
"variantName": "release",
|
||||
"elements": [
|
||||
{
|
||||
"type": "SINGLE",
|
||||
"filters": [],
|
||||
"attributes": [],
|
||||
"versionCode": 600,
|
||||
"versionName": "6.0.0-alpha02",
|
||||
"versionCode": 69,
|
||||
"versionName": "6.0.0",
|
||||
"outputFile": "app-release.apk"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -22,18 +22,16 @@ package xyz.quaver.pupil
|
||||
|
||||
import android.util.Log
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.google.api.Http
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.okhttp.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import xyz.quaver.pupil.hitomi.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
@@ -42,10 +40,144 @@ import org.junit.runner.RunWith
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
// @Before
|
||||
// fun init() {
|
||||
// val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
// }
|
||||
|
||||
@Before
|
||||
fun init() {
|
||||
clientBuilder = OkHttpClient.Builder()
|
||||
.readTimeout(0, TimeUnit.SECONDS)
|
||||
.writeTimeout(0, TimeUnit.SECONDS)
|
||||
.callTimeout(0, TimeUnit.SECONDS)
|
||||
.connectTimeout(0, TimeUnit.SECONDS)
|
||||
.addInterceptor { chain ->
|
||||
val request = chain.request().newBuilder()
|
||||
.header("Referer", "https://hitomi.la/")
|
||||
.build()
|
||||
|
||||
chain.proceed(request)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
fun test_empty() {
|
||||
print(
|
||||
"".trim()
|
||||
.replace(Regex("""^\?"""), "")
|
||||
.lowercase(Locale.getDefault())
|
||||
.split(Regex("\\s+"))
|
||||
.map {
|
||||
it.replace('_', ' ')
|
||||
})
|
||||
}
|
||||
@Test
|
||||
fun test_nozomi() {
|
||||
val nozomi = getGalleryIDsFromNozomi(null, "index", "all")
|
||||
|
||||
Log.d("PUPILD", nozomi.size.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_search() {
|
||||
val ids = getGalleryIDsForQuery("language:korean").reversed()
|
||||
|
||||
print(ids.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_suggestions() {
|
||||
val suggestions = getSuggestionsForQuery("language:g")
|
||||
|
||||
print(suggestions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_doSearch() {
|
||||
val r = runBlocking {
|
||||
doSearch("language:korean")
|
||||
}
|
||||
|
||||
Log.d("PUPILD", r.take(10).toString())
|
||||
}
|
||||
|
||||
// @Test
|
||||
// fun test_getBlock() {
|
||||
// val galleryBlock = getGalleryBlock(2097576)
|
||||
//
|
||||
// print(galleryBlock)
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// fun test_getGallery() {
|
||||
// val gallery = getGallery(2097751)
|
||||
//
|
||||
// print(gallery)
|
||||
// }
|
||||
|
||||
@Test
|
||||
fun test_getGalleryInfo() {
|
||||
val info = getGalleryInfo(1469394)
|
||||
|
||||
print(info)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_getReader() {
|
||||
val reader = getGalleryInfo(2128654)
|
||||
|
||||
Log.d("PUPILD", reader.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_getImages() { runBlocking {
|
||||
val galleryID = 2128654
|
||||
|
||||
val images = getGalleryInfo(galleryID).files.map {
|
||||
imageUrlFromImage(galleryID, it,false)
|
||||
}
|
||||
|
||||
Log.d("PUPILD", images.toString())
|
||||
|
||||
// images.forEachIndexed { index, image ->
|
||||
// println("Testing $index/${images.size}: $image")
|
||||
// val response = client.newCall(
|
||||
// Request.Builder()
|
||||
// .url(image)
|
||||
// .header("Referer", "https://hitomi.la/")
|
||||
// .build()
|
||||
// ).execute()
|
||||
//
|
||||
// assertEquals(200, response.code())
|
||||
//
|
||||
// println("$index/${images.size} Passed")
|
||||
// }
|
||||
} }
|
||||
|
||||
// @Test
|
||||
// fun test_urlFromUrlFromHash() {
|
||||
// val url = urlFromUrlFromHash(1531795, GalleryFiles(
|
||||
// 212, "719d46a7556be0d0021c5105878507129b5b3308b02cf67f18901b69dbb3b5ef", 1, "00.jpg", 300
|
||||
// ), "webp")
|
||||
//
|
||||
// print(url)
|
||||
// }
|
||||
|
||||
// @Test
|
||||
// suspend fun test_doSearch_extreme() {
|
||||
// val query = "language:korean -tag:sample -female:humiliation -female:diaper -female:strap-on -female:squirting -female:lizard_girl -female:voyeurism -type:artistcg -female:blood -female:ryona -male:blood -male:ryona -female:crotch_tattoo -male:urethra_insertion -female:living_clothes -male:tentacles -female:slave -female:gag -male:gag -female:wooden_horse -male:exhibitionism -male:miniguy -female:mind_break -male:mind_break -male:unbirth -tag:scanmark -tag:no_penetration -tag:nudity_only -female:enema -female:brain_fuck -female:navel_fuck -tag:novel -tag:mosaic_censorship -tag:webtoon -male:rape -female:rape -female:yuri -male:anal -female:anal -female:futanari -female:huge_breasts -female:big_areolae -male:torture -male:stuck_in_wall -female:stuck_in_wall -female:torture -female:birth -female:pregnant -female:drugs -female:bdsm -female:body_writing -female:cbt -male:dark_skin -male:insect -female:insect -male:vore -female:vore -female:vomit -female:urination -female:urethra_insertion -tag:mmf_threesome -female:sex_toys -female:double_penetration -female:eggs -female:prolapse -male:smell -male:bestiality -female:bestiality -female:big_ass -female:milf -female:mother -male:dilf -male:netorare -female:netorare -female:cosplaying -female:filming -female:armpit_sex -female:armpit_licking -female:tickling -female:lactation -male:skinsuit -female:skinsuit -male:bbm -female:prostitution -female:double_penetration -female:females_only -male:males_only -female:tentacles -female:tentacles -female:stomach_deformation -female:hairy_armpits -female:large_insertions -female:mind_control -male:orc -female:dark_skin -male:yandere -female:yandere -female:scat -female:toddlercon -female:bbw -female:hairy -male:cuntboy -male:lactation -male:drugs -female:body_modification -female:monoeye -female:chikan -female:long_tongue -female:harness -female:fisting -female:glory_hole -female:latex -male:latex -female:unbirth -female:giantess -female:sole_dickgirl -female:robot -female:doll_joints -female:machine -tag:artbook -male:cbt -female:farting -male:farting -male:midget -female:midget -female:exhibitionism -male:monster -female:big_nipples -female:big_clit -female:gyaru -female:piercing -female:necrophilia -female:snuff -female:smell -male:cheating -female:cheating -male:snuff -female:harem -male:harem"
|
||||
// print(doSearch(query).size)
|
||||
// }
|
||||
|
||||
// @Test
|
||||
// suspend fun test_parse() {
|
||||
// print(doSearch("-male:yaoi -female:yaoi -female:loli").size)
|
||||
// }
|
||||
|
||||
// @Test
|
||||
// fun test_subdomainFromUrl() {
|
||||
// val galleryInfo = getGalleryInfo(1929109).files[2]
|
||||
// print(urlFromUrlFromHash(1929109, galleryInfo, "webp", null, "a"))
|
||||
// }
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
xmlns:tools="http://schemas.android.com/tools" >
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="21"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="21"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="23"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="23"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.RUN_USER_INITIATED_JOBS" />
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".Pupil"
|
||||
@@ -19,14 +24,15 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
tools:replace="android:theme"
|
||||
tools:ignore="UnusedAttribute">
|
||||
tools:ignore="UnusedAttribute" >
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||
android:value="face" />
|
||||
|
||||
<provider
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
@@ -34,23 +40,25 @@
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
|
||||
</provider>
|
||||
|
||||
<service android:name=".services.ImageCacheService"
|
||||
android:permission="android.permission.BIND_JOB_SERVICE"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:configChanges="keyboardHidden|orientation|screenSize"
|
||||
android:theme="@style/NoActionBarAppTheme"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
|
||||
@@ -23,42 +23,19 @@ import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import com.google.android.gms.common.GooglePlayServicesNotAvailableException
|
||||
import com.google.android.gms.common.GooglePlayServicesRepairableException
|
||||
import com.google.android.gms.security.ProviderInstaller
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.okhttp.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import org.kodein.di.*
|
||||
import org.kodein.di.android.x.androidXModule
|
||||
import xyz.quaver.pupil.sources.core.NetworkCache
|
||||
import xyz.quaver.pupil.sources.core.settingsDataStore
|
||||
import xyz.quaver.pupil.util.PupilHttpClient
|
||||
|
||||
class Pupil : Application(), DIAware {
|
||||
|
||||
override val di: DI by DI.lazy {
|
||||
import(androidXModule(this@Pupil))
|
||||
|
||||
bind { singleton { NetworkCache(this@Pupil) } }
|
||||
|
||||
bindSingleton { settingsDataStore }
|
||||
|
||||
bind { singleton { PupilHttpClient(OkHttp.create()) } }
|
||||
}
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import xyz.quaver.io.FileX
|
||||
import java.util.UUID
|
||||
|
||||
@HiltAndroidApp
|
||||
class Pupil : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
try {
|
||||
ProviderInstaller.installIfNeeded(this)
|
||||
} catch (e: GooglePlayServicesRepairableException) {
|
||||
e.printStackTrace()
|
||||
} catch (e: GooglePlayServicesNotAvailableException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
FirebaseApp.initializeApp(this)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
@@ -83,6 +60,15 @@ class Pupil : Application(), DIAware {
|
||||
enableVibration(true)
|
||||
lockscreenVisibility = Notification.VISIBILITY_SECRET
|
||||
})
|
||||
|
||||
manager.createNotificationChannel(NotificationChannel("import", getString(R.string.channel_update), NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = getString(R.string.channel_update_description)
|
||||
enableLights(false)
|
||||
enableVibration(false)
|
||||
lockscreenVisibility = Notification.VISIBILITY_SECRET
|
||||
})
|
||||
}
|
||||
|
||||
super.onCreate()
|
||||
}
|
||||
}
|
||||
24
app/src/main/java/xyz/quaver/pupil/di/SingletonModule.kt
Normal file
24
app/src/main/java/xyz/quaver/pupil/di/SingletonModule.kt
Normal file
@@ -0,0 +1,24 @@
|
||||
package xyz.quaver.pupil.di
|
||||
|
||||
import android.content.Context
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import xyz.quaver.pupil.networking.FileImageCache
|
||||
import xyz.quaver.pupil.networking.ImageCache
|
||||
import java.io.File
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object SingletonModule {
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideImageCache(
|
||||
@ApplicationContext context: Context
|
||||
): ImageCache {
|
||||
return FileImageCache(File(context.cacheDir, "image_cache"))
|
||||
}
|
||||
}
|
||||
110
app/src/main/java/xyz/quaver/pupil/networking/GalleryInfo.kt
Normal file
110
app/src/main/java/xyz/quaver/pupil/networking/GalleryInfo.kt
Normal file
@@ -0,0 +1,110 @@
|
||||
package xyz.quaver.pupil.networking
|
||||
|
||||
import android.os.BaseBundle
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
interface TagLike {
|
||||
fun toTag(): SearchQuery.Tag
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Artist(val artist: String): TagLike {
|
||||
override fun toTag() = SearchQuery.Tag("artist", artist)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Group(val group: String): TagLike {
|
||||
override fun toTag() = SearchQuery.Tag("group", group)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Series(@SerialName("parody") val series: String): TagLike {
|
||||
override fun toTag() = SearchQuery.Tag("series", series)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Character(val character: String): TagLike {
|
||||
override fun toTag() = SearchQuery.Tag("character", character)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class GalleryTag(
|
||||
val tag: String,
|
||||
val female: String? = null,
|
||||
val male: String? = null
|
||||
): TagLike {
|
||||
override fun toTag() = SearchQuery.Tag(
|
||||
if (female.isNullOrEmpty() && male.isNullOrEmpty()) {
|
||||
"tag"
|
||||
} else if (male.isNullOrEmpty()) {
|
||||
"female"
|
||||
} else {
|
||||
"male"
|
||||
},
|
||||
tag
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Language(
|
||||
@SerialName("galleryid") val galleryID: String,
|
||||
val name: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GalleryFile(
|
||||
@SerialName("haswebp") val hasWebP: Int = 0,
|
||||
@SerialName("hasavif") val hasAVIF: Int = 0,
|
||||
@SerialName("hasjxl") val hasJXL: Int = 0,
|
||||
val height: Int,
|
||||
val width: Int,
|
||||
val hash: String,
|
||||
val name: String,
|
||||
) {
|
||||
fun writeToBundle(bundle: BaseBundle) {
|
||||
bundle.putInt("hasWebP", hasWebP)
|
||||
bundle.putInt("hasAVIF", hasAVIF)
|
||||
bundle.putInt("hasJXL", hasJXL)
|
||||
bundle.putInt("height", height)
|
||||
bundle.putInt("width", width)
|
||||
bundle.putString("hash", hash)
|
||||
bundle.putString("name", name)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromBundle(bundle: BaseBundle) = GalleryFile(
|
||||
bundle.getInt("hasWebP"),
|
||||
bundle.getInt("hasAVIF"),
|
||||
bundle.getInt("hasJXL"),
|
||||
bundle.getInt("height"),
|
||||
bundle.getInt("width"),
|
||||
bundle.getString("hash")!!,
|
||||
bundle.getString("name")!!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class GalleryInfo(
|
||||
val id: String,
|
||||
val title: String,
|
||||
@SerialName("japanese_title") val japaneseTitle: String? = null,
|
||||
val language: String? = null,
|
||||
val type: String,
|
||||
val date: String,
|
||||
val artists: List<Artist>? = null,
|
||||
val groups: List<Group>? = null,
|
||||
@SerialName("parodys") val series: List<Series>? = null,
|
||||
val tags: List<GalleryTag>? = null,
|
||||
val related: List<Int> = emptyList(),
|
||||
val languages: List<Language> = emptyList(),
|
||||
val characters: List<Character>? = null,
|
||||
@SerialName("scene_indexes") val sceneIndices: List<Int>? = emptyList(),
|
||||
val files: List<GalleryFile> = emptyList()
|
||||
)
|
||||
|
||||
@JvmName("joinToCapitalizedStringArtist")
|
||||
fun List<Artist>.joinToCapitalizedString() = joinToString { it.artist.replaceFirstChar(Char::titlecase) }
|
||||
@JvmName("joinToCapitalizedStringGroup")
|
||||
fun List<Group>.joinToCapitalizedString() = joinToString { it.group.replaceFirstChar(Char::titlecase) }
|
||||
@@ -0,0 +1,36 @@
|
||||
package xyz.quaver.pupil.networking
|
||||
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
|
||||
class GallerySearchSource(val query: SearchQuery?) {
|
||||
private var searchResult: List<Int>? = null
|
||||
private var job: Job? = null
|
||||
|
||||
suspend fun load(range: IntRange): Result<Pair<List<GalleryInfo>, Int>> = runCatching {
|
||||
val searchResult = searchResult ?: (
|
||||
HitomiHttpClient
|
||||
.search(query)
|
||||
.getOrThrow()
|
||||
.toList()
|
||||
.also { searchResult = it }
|
||||
)
|
||||
|
||||
val galleryResults = coroutineScope {
|
||||
searchResult.slice(range).map { galleryID ->
|
||||
async {
|
||||
HitomiHttpClient.getGalleryInfo(galleryID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val galleries = galleryResults.map { result ->
|
||||
result.await().getOrThrow()
|
||||
}
|
||||
|
||||
Pair(galleries, searchResult.size)
|
||||
}
|
||||
|
||||
fun cancel() = job?.cancel()
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package xyz.quaver.pupil.networking
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.onDownload
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.utils.io.ByteReadChannel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.Clock.System.now
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.IntBuffer
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
const val domain = "ltn.hitomi.la"
|
||||
const val nozomiExtension = ".nozomi"
|
||||
|
||||
const val compressedNozomiPrefix = "n"
|
||||
|
||||
const val B = 16
|
||||
const val indexDir = "tagindex"
|
||||
const val maxNodeSize = 464
|
||||
const val galleriesIndexDir = "galleriesindex"
|
||||
const val tagIndexDomain = "tagindex.hitomi.la"
|
||||
|
||||
const val separator = "-"
|
||||
const val extension = ".html"
|
||||
|
||||
data class Suggestion(
|
||||
val tag: SearchQuery.Tag,
|
||||
val count: Int,
|
||||
)
|
||||
|
||||
fun IntBuffer.toSet(): Set<Int> {
|
||||
val result = LinkedHashSet<Int>()
|
||||
|
||||
while (this.hasRemaining()) {
|
||||
result.add(this.get())
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private val json = Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
class ImagePathResolver(ggjs: String) {
|
||||
private val defaultPrefix: Int = Regex("var o = (\\d)").find(ggjs)!!.groupValues[1].toInt()
|
||||
private val prefixMap: Map<Int, Int> = buildMap {
|
||||
val o = Regex("o = (\\d); break;").find(ggjs)!!.groupValues[1].toInt()
|
||||
|
||||
Regex("case (\\d+):").findAll(ggjs).forEach {
|
||||
val case = it.groupValues[1].toInt()
|
||||
put(case, o)
|
||||
}
|
||||
}
|
||||
|
||||
private val imageBaseDir: String = Regex("b: '(.+)'").find(ggjs)!!.groupValues[1]
|
||||
|
||||
fun decodeSubdomain(hash: String, thumbnail: Boolean): String {
|
||||
val key = (hash.last() + hash.dropLast(1).takeLast(2)).toInt(16)
|
||||
val base = if (thumbnail) "tn" else "a"
|
||||
|
||||
return "${'a' + (prefixMap[key] ?: defaultPrefix)}$base"
|
||||
}
|
||||
|
||||
fun decodeImagePath(hash: String, thumbnail: Boolean): String {
|
||||
val key = hash.last() to hash.dropLast(1).takeLast(2)
|
||||
|
||||
return if (thumbnail) {
|
||||
"${key.first}/${key.second}/$hash"
|
||||
} else {
|
||||
"$imageBaseDir/${(key.first + key.second).toInt(16)}/$hash"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ExpirableEntry<T>(
|
||||
private val expiryDuration: Duration,
|
||||
private val action: suspend () -> T,
|
||||
) {
|
||||
private var value: T? = null
|
||||
private var expiresAt: Instant = now()
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
suspend fun getValue(): T = mutex.withLock {
|
||||
value?.let { if (expiresAt > now()) value else null } ?: action().also {
|
||||
expiresAt = now() + expiryDuration
|
||||
value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object HitomiHttpClient {
|
||||
private val httpClient = HttpClient(OkHttp) {
|
||||
engine {
|
||||
config {
|
||||
sslSocketFactory(SSLSettings.sslContext!!.socketFactory, SSLSettings.trustManager!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var imagePathResolver = ExpirableEntry(1.minutes) {
|
||||
ImagePathResolver(httpClient.get("https://ltn.hitomi.la/gg.js").bodyAsText())
|
||||
}
|
||||
|
||||
private val tagIndexVersion = ExpirableEntry(1.minutes) { getIndexVersion("tagindex") }
|
||||
private val galleriesIndexVersion =
|
||||
ExpirableEntry(1.minutes) { getIndexVersion("galleriesindex") }
|
||||
|
||||
private suspend fun getIndexVersion(name: String): String = withContext(Dispatchers.IO) {
|
||||
httpClient.get("https://$domain/$name/version?_=${System.currentTimeMillis()}").bodyAsText()
|
||||
}
|
||||
|
||||
private suspend fun getURLAtRange(url: String, range: LongRange): ByteBuffer {
|
||||
val response: HttpResponse = withContext(Dispatchers.IO) {
|
||||
httpClient.get(url) {
|
||||
header("Range", "bytes=${range.first}-${range.last}")
|
||||
}
|
||||
}
|
||||
|
||||
val result: ByteArray = response.body()
|
||||
|
||||
return ByteBuffer.wrap(result)
|
||||
}
|
||||
|
||||
private suspend fun getNodeAtAddress(field: String, address: Long): Node {
|
||||
val url = when (field) {
|
||||
"galleries" -> "https://$domain/$galleriesIndexDir/galleries.${galleriesIndexVersion.getValue()}.index"
|
||||
"languages" -> "https://$domain/$galleriesIndexDir/languages.${galleriesIndexVersion.getValue()}.index"
|
||||
"nozomiurl" -> "https://$domain/$galleriesIndexDir/nozomiurl.${galleriesIndexVersion.getValue()}.index"
|
||||
else -> "https://$domain/$indexDir/$field.${tagIndexVersion.getValue()}.index"
|
||||
}
|
||||
|
||||
return Node.decodeNode(
|
||||
getURLAtRange(url, address..<address + maxNodeSize)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun bSearch(
|
||||
field: String,
|
||||
key: Node.Key,
|
||||
node: Node,
|
||||
): Node.Data? {
|
||||
if (node.keys.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val (matched, index) = node.locateKey(key)
|
||||
|
||||
if (matched) {
|
||||
return node.datas[index]
|
||||
} else if (node.isLeaf) {
|
||||
return null
|
||||
}
|
||||
|
||||
val nextNode = getNodeAtAddress(field, node.subNodeAddresses[index])
|
||||
return bSearch(field, key, nextNode)
|
||||
}
|
||||
|
||||
private suspend fun getGalleryIDsFromData(offset: Long, length: Int): IntBuffer {
|
||||
val url =
|
||||
"https://$domain/$galleriesIndexDir/galleries.${galleriesIndexVersion.getValue()}.data"
|
||||
if (length > 100000000 || length <= 0) {
|
||||
error("length $length is too long")
|
||||
}
|
||||
|
||||
return getURLAtRange(url, offset until (offset + length)).asIntBuffer()
|
||||
}
|
||||
|
||||
private fun encodeSearchQueryForUrl(s: Char) =
|
||||
when (s) {
|
||||
' ' -> "_"
|
||||
'/' -> "slash"
|
||||
'.' -> "dot"
|
||||
else -> s.toString()
|
||||
}
|
||||
|
||||
private fun sanitize(s: String) = s.replace(Regex("[/#]"), "")
|
||||
|
||||
private suspend fun getGalleryIDsFromNozomi(
|
||||
area: String?,
|
||||
tag: String,
|
||||
language: String,
|
||||
): IntBuffer {
|
||||
val nozomiAddress = if (area == null) {
|
||||
"https://$domain/$compressedNozomiPrefix/$tag-$language$nozomiExtension"
|
||||
} else {
|
||||
"https://$domain/$compressedNozomiPrefix/$area/$tag-$language$nozomiExtension"
|
||||
}
|
||||
|
||||
val response: HttpResponse = withContext(Dispatchers.IO) {
|
||||
httpClient.get(nozomiAddress)
|
||||
}
|
||||
|
||||
val result: ByteArray = response.body()
|
||||
|
||||
return ByteBuffer.wrap(result).asIntBuffer()
|
||||
}
|
||||
|
||||
private suspend fun getGalleryIDsForQuery(
|
||||
query: SearchQuery.Tag,
|
||||
language: String = "all",
|
||||
): IntBuffer = when (query.namespace) {
|
||||
"female", "male" -> getGalleryIDsFromNozomi("tag", query.toString(), language)
|
||||
"language" -> getGalleryIDsFromNozomi(null, "index", query.tag)
|
||||
null -> {
|
||||
val key = Node.Key(query.tag)
|
||||
|
||||
val node = getNodeAtAddress("galleries", 0)
|
||||
val data = bSearch("galleries", key, node)
|
||||
|
||||
if (data != null) getGalleryIDsFromData(
|
||||
data.offset,
|
||||
data.length
|
||||
) else IntBuffer.allocate(0)
|
||||
}
|
||||
|
||||
else -> getGalleryIDsFromNozomi(query.namespace, query.tag, language)
|
||||
}
|
||||
|
||||
suspend fun getSuggestionsForQuery(query: SearchQuery.Tag): Result<List<Suggestion>> =
|
||||
runCatching {
|
||||
val field = query.namespace ?: "global"
|
||||
val chars = query.tag.map(::encodeSearchQueryForUrl)
|
||||
|
||||
val suggestions = json.parseToJsonElement(
|
||||
withContext(Dispatchers.IO) {
|
||||
httpClient.get(
|
||||
"https://$tagIndexDomain/$field${
|
||||
if (chars.isNotEmpty()) "/${
|
||||
chars.joinToString(
|
||||
"/"
|
||||
)
|
||||
}" else ""
|
||||
}.json"
|
||||
).bodyAsText()
|
||||
}
|
||||
)
|
||||
|
||||
buildList {
|
||||
suggestions.jsonArray.forEach { suggestionRaw ->
|
||||
val suggestion = suggestionRaw.jsonArray
|
||||
if (suggestion.size < 3) {
|
||||
return@forEach
|
||||
}
|
||||
val namespace = suggestion[2].jsonPrimitive.contentOrNull ?: ""
|
||||
|
||||
val tag =
|
||||
sanitize(suggestion[0].jsonPrimitive.contentOrNull ?: return@forEach)
|
||||
|
||||
add(
|
||||
Suggestion(
|
||||
SearchQuery.Tag(
|
||||
namespace,
|
||||
tag
|
||||
),
|
||||
suggestion[1].jsonPrimitive.contentOrNull?.toIntOrNull() ?: 0,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getGalleryInfo(galleryID: Int) = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
json.decodeFromString<GalleryInfo>(
|
||||
httpClient.get("https://$domain/galleries/$galleryID.js").bodyAsText()
|
||||
.replace("var galleryinfo = ", "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun search(query: SearchQuery?): Result<Set<Int>> = runCatching {
|
||||
when (query) {
|
||||
is SearchQuery.Tag -> getGalleryIDsForQuery(query).toSet()
|
||||
is SearchQuery.Not -> coroutineScope {
|
||||
val allGalleries = async {
|
||||
getGalleryIDsFromNozomi(null, "index", "all")
|
||||
}
|
||||
|
||||
val queriedGalleries = search(query.query).getOrThrow()
|
||||
|
||||
val result = LinkedHashSet<Int>()
|
||||
|
||||
with(allGalleries.await()) {
|
||||
while (this.hasRemaining()) {
|
||||
val gallery = this.get()
|
||||
|
||||
if (gallery in queriedGalleries) {
|
||||
result.add(gallery)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
is SearchQuery.And -> coroutineScope {
|
||||
val queries = query.queries.map { query ->
|
||||
async {
|
||||
search(query).getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
val result = queries.first().await().toMutableSet()
|
||||
|
||||
queries.drop(1).forEach {
|
||||
val queryResult = it.await()
|
||||
|
||||
result.retainAll(queryResult)
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
is SearchQuery.Or -> coroutineScope {
|
||||
val queries = query.queries.map { query ->
|
||||
async {
|
||||
search(query).getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
val result = LinkedHashSet<Int>()
|
||||
|
||||
queries.forEach {
|
||||
val queryResult = it.await()
|
||||
result.addAll(queryResult)
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
null -> getGalleryIDsFromNozomi(null, "index", "all").toSet()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getImageURL(galleryFile: GalleryFile, thumbnail: Boolean = false): List<String> =
|
||||
buildList {
|
||||
val imagePathResolver = imagePathResolver.getValue()
|
||||
|
||||
listOf("webp", "avif", "jxl").forEach { type ->
|
||||
val available = when {
|
||||
thumbnail && type != "jxl" -> true
|
||||
type == "webp" -> galleryFile.hasWebP != 0
|
||||
type == "avif" -> galleryFile.hasAVIF != 0
|
||||
!thumbnail && type == "jxl" -> galleryFile.hasJXL != 0
|
||||
else -> false
|
||||
}
|
||||
|
||||
if (!available) return@forEach
|
||||
|
||||
val url = buildString {
|
||||
append("https://")
|
||||
append(imagePathResolver.decodeSubdomain(galleryFile.hash, thumbnail))
|
||||
append(".hitomi.la/")
|
||||
append(type)
|
||||
if (thumbnail) append("bigtn")
|
||||
append('/')
|
||||
append(imagePathResolver.decodeImagePath(galleryFile.hash, thumbnail))
|
||||
append('.')
|
||||
append(type)
|
||||
}
|
||||
|
||||
add(url)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadImage(
|
||||
galleryFile: GalleryFile,
|
||||
thumbnail: Boolean = false,
|
||||
acceptImage: (String) -> Boolean = { true },
|
||||
onDownload: (bytesSentTotal: Long, contentLength: Long?) -> Unit = { _, _ -> },
|
||||
): Result<Pair<ByteReadChannel, String>> {
|
||||
return runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val url = getImageURL(galleryFile, thumbnail).firstOrNull(acceptImage)
|
||||
?: error("No available image")
|
||||
val channel: ByteReadChannel = httpClient.get(url) { onDownload(onDownload) }.body()
|
||||
Pair(channel, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
135
app/src/main/java/xyz/quaver/pupil/networking/ImageCache.kt
Normal file
135
app/src/main/java/xyz/quaver/pupil/networking/ImageCache.kt
Normal file
@@ -0,0 +1,135 @@
|
||||
package xyz.quaver.pupil.networking
|
||||
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import io.ktor.util.cio.writeChannel
|
||||
import io.ktor.utils.io.copyAndClose
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
sealed class ImageLoadProgress {
|
||||
data object NotStarted : ImageLoadProgress()
|
||||
data class Progress(val bytesSent: Long, val contentLength: Long?) : ImageLoadProgress()
|
||||
data class Finished(val file: File) : ImageLoadProgress()
|
||||
data class Error(val exception: Throwable) : ImageLoadProgress()
|
||||
}
|
||||
|
||||
interface ImageCache {
|
||||
suspend fun load(
|
||||
galleryFile: GalleryFile,
|
||||
forceDownload: Boolean = false,
|
||||
): StateFlow<ImageLoadProgress>
|
||||
|
||||
suspend fun free(vararg files: GalleryFile)
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
class FileImageCache(
|
||||
private val cacheDir: File,
|
||||
private val cacheLimit: Long = 128 * 1024 * 1024, // 128MB
|
||||
) : ImageCache {
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val requests = mutableMapOf<String, Pair<Job, StateFlow<ImageLoadProgress>>>()
|
||||
private val activeFiles = mutableMapOf<String, File>()
|
||||
|
||||
private suspend fun cleanup() = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val size = cacheDir.listFiles()?.sumOf { it.length() } ?: 0
|
||||
|
||||
if (size > cacheLimit) {
|
||||
cacheDir.listFiles { file ->
|
||||
file.nameWithoutExtension !in activeFiles
|
||||
}?.forEach { file ->
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun free(vararg files: GalleryFile) = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
files.forEach { file ->
|
||||
val hash = file.hash
|
||||
|
||||
requests[hash]?.let { (job, _) ->
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
requests.remove(hash)
|
||||
activeFiles.remove(hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear(): Unit = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
requests.forEach { _, (job, _) -> job.cancel() }
|
||||
activeFiles.clear()
|
||||
cacheDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun load(
|
||||
galleryFile: GalleryFile,
|
||||
forceDownload: Boolean,
|
||||
): StateFlow<ImageLoadProgress> {
|
||||
val hash = galleryFile.hash
|
||||
|
||||
mutex.withLock {
|
||||
val file = activeFiles[hash]
|
||||
if (!forceDownload && file != null) {
|
||||
return MutableStateFlow(ImageLoadProgress.Finished(file))
|
||||
}
|
||||
}
|
||||
|
||||
cleanup()
|
||||
|
||||
mutex.withLock {
|
||||
requests[hash]?.first?.cancelAndJoin()
|
||||
activeFiles[hash]?.delete()
|
||||
|
||||
val flow = MutableStateFlow<ImageLoadProgress>(ImageLoadProgress.NotStarted)
|
||||
val job = coroutineScope {
|
||||
launch {
|
||||
runCatching {
|
||||
val (channel, url) = HitomiHttpClient.loadImage(galleryFile) { sent, total ->
|
||||
flow.value = ImageLoadProgress.Progress(sent, total)
|
||||
}.onFailure {
|
||||
FirebaseCrashlytics.getInstance().recordException(it)
|
||||
flow.value = ImageLoadProgress.Error(it)
|
||||
}.getOrThrow()
|
||||
|
||||
val file = File(cacheDir, "$hash.${url.substringAfterLast('.')}")
|
||||
|
||||
mutex.withLock {
|
||||
activeFiles.put(hash, file)
|
||||
}
|
||||
|
||||
channel.copyAndClose(file.writeChannel())
|
||||
|
||||
file
|
||||
}.onSuccess { file ->
|
||||
flow.value = ImageLoadProgress.Finished(file)
|
||||
}.onFailure {
|
||||
activeFiles.remove(hash)
|
||||
FirebaseCrashlytics.getInstance().recordException(it)
|
||||
flow.value = ImageLoadProgress.Error(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requests[hash] = job to flow
|
||||
|
||||
return flow
|
||||
}
|
||||
}
|
||||
}
|
||||
107
app/src/main/java/xyz/quaver/pupil/networking/Node.kt
Normal file
107
app/src/main/java/xyz/quaver/pupil/networking/Node.kt
Normal file
@@ -0,0 +1,107 @@
|
||||
@file:OptIn(ExperimentalUnsignedTypes::class)
|
||||
|
||||
package xyz.quaver.pupil.networking
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.MessageDigest
|
||||
import kotlin.math.min
|
||||
|
||||
private fun sha256(data: ByteArray): ByteArray =
|
||||
MessageDigest.getInstance("SHA-256").digest(data)
|
||||
|
||||
private fun hashTerm(term: String): UByteArray =
|
||||
sha256(term.toByteArray()).sliceArray(0..<4).toUByteArray()
|
||||
|
||||
data class Node(
|
||||
val keys: List<Key>,
|
||||
val datas: List<Data>,
|
||||
val subNodeAddresses: List<Long>
|
||||
) {
|
||||
data class Key(
|
||||
private val key: UByteArray
|
||||
): Comparable<Key> {
|
||||
|
||||
constructor(term: String): this(hashTerm(term))
|
||||
|
||||
override fun compareTo(other: Key): Int {
|
||||
val minSize = min(this.key.size, other.key.size)
|
||||
|
||||
for (i in 0..<minSize) {
|
||||
if (this.key[i] < other.key[i]) {
|
||||
return -1
|
||||
} else if(this.key[i] > other.key[i]) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Key
|
||||
|
||||
return key.contentEquals(other.key)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return key.contentHashCode()
|
||||
}
|
||||
}
|
||||
|
||||
data class Data(
|
||||
val offset: Long,
|
||||
val length: Int
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun decodeNode(buffer: ByteBuffer): Node {
|
||||
val numberOfKeys = buffer.int
|
||||
val keys = mutableListOf<Node.Key>()
|
||||
|
||||
for (i in 0..<numberOfKeys) {
|
||||
val keySize = buffer.int
|
||||
|
||||
val key = ByteArray(keySize)
|
||||
buffer.get(key)
|
||||
|
||||
keys.add(Node.Key(key.toUByteArray()))
|
||||
}
|
||||
|
||||
val numberOfDatas = buffer.int
|
||||
val datas = mutableListOf<Data>()
|
||||
|
||||
for (i in 0..<numberOfDatas) {
|
||||
val offset = buffer.long
|
||||
val length = buffer.int
|
||||
|
||||
datas.add(Data(offset, length))
|
||||
}
|
||||
|
||||
val numberOfSubNodeAddresses = B+1
|
||||
val subNodeAddresses = mutableListOf<Long>()
|
||||
|
||||
for (i in 0..<numberOfSubNodeAddresses) {
|
||||
val subNodeAddress = buffer.long
|
||||
subNodeAddresses.add(subNodeAddress)
|
||||
}
|
||||
|
||||
return Node(keys, datas, subNodeAddresses)
|
||||
}
|
||||
}
|
||||
|
||||
val isLeaf: Boolean = subNodeAddresses.all { it == 0L }
|
||||
|
||||
fun locateKey(target: Key): Pair<Boolean, Int> {
|
||||
val index = keys.indexOfFirst { key -> target <= key }
|
||||
|
||||
if (index == -1) {
|
||||
return Pair(false, keys.size)
|
||||
}
|
||||
|
||||
return Pair(keys[index] == target, index)
|
||||
}
|
||||
}
|
||||
|
||||
80
app/src/main/java/xyz/quaver/pupil/networking/SSL.kt
Normal file
80
app/src/main/java/xyz/quaver/pupil/networking/SSL.kt
Normal file
@@ -0,0 +1,80 @@
|
||||
package xyz.quaver.pupil.networking
|
||||
|
||||
import android.content.res.Resources
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.security.KeyStore
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.CertificateFactory
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
const val ISRG_ROOT_X1 = """-----BEGIN CERTIFICATE-----
|
||||
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
|
||||
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
|
||||
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
|
||||
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
|
||||
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
|
||||
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
|
||||
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
|
||||
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
|
||||
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
|
||||
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
|
||||
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
|
||||
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
|
||||
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
|
||||
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
|
||||
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
|
||||
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
|
||||
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
|
||||
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
|
||||
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
|
||||
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
|
||||
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
|
||||
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
|
||||
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
|
||||
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
|
||||
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
|
||||
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
|
||||
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
|
||||
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
|
||||
-----END CERTIFICATE-----"""
|
||||
|
||||
object SSLSettings {
|
||||
val keyStore: KeyStore by lazy {
|
||||
KeyStore.getInstance(KeyStore.getDefaultType()).apply {
|
||||
load(null, null)
|
||||
|
||||
val certificateFactory = CertificateFactory.getInstance("X.509")
|
||||
val certificate = certificateFactory.generateCertificate(ISRG_ROOT_X1.byteInputStream())
|
||||
|
||||
setCertificateEntry("isrgrootx1", certificate)
|
||||
|
||||
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply {
|
||||
init(null as KeyStore?)
|
||||
trustManagers.filterIsInstance<X509TrustManager>().forEach { trustManager ->
|
||||
trustManager.acceptedIssuers.forEach { acceptedIssuer ->
|
||||
setCertificateEntry(acceptedIssuer.subjectDN.name, acceptedIssuer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val trustManagerFactory: TrustManagerFactory? by lazy {
|
||||
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply {
|
||||
init(keyStore)
|
||||
}
|
||||
}
|
||||
|
||||
val sslContext: SSLContext? by lazy {
|
||||
SSLContext.getInstance("TLS").apply {
|
||||
init(null, trustManagerFactory?.trustManagers, null)
|
||||
}
|
||||
}
|
||||
|
||||
val trustManager: X509TrustManager? by lazy {
|
||||
trustManagerFactory?.trustManagers?.filterIsInstance<X509TrustManager>()?.firstOrNull()
|
||||
}
|
||||
}
|
||||
90
app/src/main/java/xyz/quaver/pupil/networking/SearchQuery.kt
Normal file
90
app/src/main/java/xyz/quaver/pupil/networking/SearchQuery.kt
Normal file
@@ -0,0 +1,90 @@
|
||||
package xyz.quaver.pupil.networking
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
|
||||
val validNamespace = listOf(
|
||||
"female",
|
||||
"male",
|
||||
"artist",
|
||||
"group",
|
||||
"character",
|
||||
"series",
|
||||
"type",
|
||||
"language",
|
||||
"tag"
|
||||
)
|
||||
|
||||
class SearchQueryPreviewParameterProvider: PreviewParameterProvider<SearchQuery> {
|
||||
override val values = sequenceOf(
|
||||
SearchQuery.And(listOf(
|
||||
SearchQuery.Or(listOf(
|
||||
SearchQuery.And(listOf(
|
||||
SearchQuery.Tag("language", "thisisareallylongtagyoucantevenseetheendofthis"),
|
||||
SearchQuery.Tag("language", "korean"),
|
||||
SearchQuery.Tag("female", "unusual pupil"),
|
||||
SearchQuery.Tag("female", "collar")
|
||||
)),
|
||||
SearchQuery.And(listOf(
|
||||
SearchQuery.Tag("language", "japanese"),
|
||||
SearchQuery.Tag("female", "unusual pupil"),
|
||||
SearchQuery.Tag("female", "collar")
|
||||
))
|
||||
)),
|
||||
SearchQuery.Not(
|
||||
SearchQuery.And(listOf(
|
||||
SearchQuery.Tag("male", "yaoi"),
|
||||
SearchQuery.Tag("group", "zenmai kourogi")
|
||||
))
|
||||
)
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
sealed interface SearchQuery {
|
||||
data class Tag(
|
||||
val namespace: String? = null,
|
||||
val tag: String
|
||||
): SearchQuery, TagLike {
|
||||
companion object {
|
||||
fun parseTag(tag: String): Tag {
|
||||
val splitTag = tag.split(':', limit = 1)
|
||||
|
||||
return if (splitTag.size == 1) {
|
||||
Tag(null, tag)
|
||||
} else {
|
||||
Tag(splitTag[0], splitTag[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString() = if (namespace == null) tag else "$namespace:$tag"
|
||||
|
||||
override fun toTag() = this
|
||||
}
|
||||
|
||||
|
||||
data class And(
|
||||
val queries: List<SearchQuery>
|
||||
): SearchQuery {
|
||||
init {
|
||||
if (queries.isEmpty()) {
|
||||
error("queries cannot be empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Or(
|
||||
val queries: List<SearchQuery>
|
||||
): SearchQuery {
|
||||
init {
|
||||
if (queries.isEmpty()) {
|
||||
error("queries cannot be empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Not(
|
||||
val query: SearchQuery
|
||||
): SearchQuery
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2020 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.services
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.job.JobParameters
|
||||
import android.app.job.JobService
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import io.ktor.util.cio.writeChannel
|
||||
import io.ktor.util.collections.ConcurrentMap
|
||||
import io.ktor.util.collections.ConcurrentSet
|
||||
import io.ktor.utils.io.copyAndClose
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import xyz.quaver.pupil.Pupil
|
||||
import xyz.quaver.pupil.networking.GalleryFile
|
||||
import xyz.quaver.pupil.networking.HitomiHttpClient
|
||||
import java.io.File
|
||||
|
||||
@SuppressLint("SpecifyJobSchedulerIdRange")
|
||||
@AndroidEntryPoint
|
||||
class ImageCacheService : JobService() {
|
||||
override fun onStartJob(params: JobParameters?): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onStopJob(params: JobParameters?): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2020 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import dalvik.system.PathClassLoader
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import xyz.quaver.pupil.sources.core.Source
|
||||
|
||||
@Composable
|
||||
fun rememberLocalSourceList(context: Context = LocalContext.current): State<List<SourceEntry>> = produceState(emptyList()) {
|
||||
while (true) {
|
||||
value = loadSourceList(context)
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadSource(context: Context, sourceEntry: SourceEntry): Source = coroutineScope {
|
||||
sourceCacheMutex.withLock {
|
||||
sourceCache[sourceEntry.packageName] ?: run {
|
||||
val classLoader = PathClassLoader(sourceEntry.sourceDir, null, context.classLoader)
|
||||
|
||||
Class.forName("${sourceEntry.packagePath}${sourceEntry.sourcePath}", false, classLoader)
|
||||
.getConstructor(Application::class.java)
|
||||
.newInstance(context.applicationContext) as Source
|
||||
}.also { sourceCache[sourceEntry.packageName] = it }
|
||||
}
|
||||
}
|
||||
|
||||
private const val SOURCES_FEATURE = "pupil.sources"
|
||||
private const val SOURCES_PACKAGE_PREFIX = "xyz.quaver.pupil.sources"
|
||||
private const val SOURCES_PATH = "pupil.sources.path"
|
||||
|
||||
private val PackageInfo.isSourceFeatureEnabled
|
||||
get() = this.reqFeatures.orEmpty().any { it.name == SOURCES_FEATURE }
|
||||
|
||||
private fun loadSource(context: Context, packageInfo: PackageInfo): List<SourceEntry> {
|
||||
val packageManager = context.packageManager
|
||||
|
||||
val applicationInfo = packageInfo.applicationInfo
|
||||
|
||||
val packageName = packageManager.getApplicationLabel(applicationInfo).toString().substringAfter("[Pupil] ")
|
||||
val packagePath = packageInfo.packageName
|
||||
|
||||
val icon = packageManager.getApplicationIcon(applicationInfo)
|
||||
|
||||
val version = packageInfo.versionName
|
||||
|
||||
return packageInfo
|
||||
.applicationInfo
|
||||
.metaData
|
||||
?.getString(SOURCES_PATH)
|
||||
?.split(';')
|
||||
?.map { source ->
|
||||
val (sourceName, sourcePath) = source.split(':', limit = 2)
|
||||
SourceEntry(
|
||||
packageName,
|
||||
packagePath,
|
||||
sourceName,
|
||||
sourcePath,
|
||||
applicationInfo.sourceDir,
|
||||
icon,
|
||||
version
|
||||
)
|
||||
}.orEmpty()
|
||||
}
|
||||
|
||||
private val sourceCacheMutex = Mutex()
|
||||
private val sourceCache = mutableMapOf<String, Source>()
|
||||
|
||||
private fun loadSourceList(context: Context): List<SourceEntry> {
|
||||
val packageManager = context.packageManager
|
||||
|
||||
val packages = packageManager.getInstalledPackages(
|
||||
PackageManager.GET_CONFIGURATIONS or PackageManager.GET_META_DATA
|
||||
)
|
||||
|
||||
return packages.flatMap { packageInfo ->
|
||||
if (packageInfo.isSourceFeatureEnabled)
|
||||
loadSource(context, packageInfo)
|
||||
else
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2022 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import kotlinx.coroutines.delay
|
||||
import org.kodein.di.compose.localDI
|
||||
import org.kodein.di.compose.rememberInstance
|
||||
import org.kodein.di.direct
|
||||
import org.kodein.di.instance
|
||||
import xyz.quaver.pupil.util.PupilHttpClient
|
||||
import xyz.quaver.pupil.util.RemoteSourceInfo
|
||||
|
||||
@Composable
|
||||
fun rememberRemoteSourceList(client: PupilHttpClient = localDI().direct.instance()) = produceState<Map<String, RemoteSourceInfo>?>(null) {
|
||||
while (true) {
|
||||
value = client.getRemoteSourceList()
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
* Copyright (C) 2022 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -13,14 +13,10 @@
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
`kotlin-dsl`
|
||||
}
|
||||
package xyz.quaver.pupil.types
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
}
|
||||
class SendLogException : Exception()
|
||||
class JavascriptException(message: String?) : Exception(message)
|
||||
111
app/src/main/java/xyz/quaver/pupil/types/Tags.kt
Normal file
111
app/src/main/java/xyz/quaver/pupil/types/Tags.kt
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2019 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.types
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Tag(val area: String?, val tag: String, val isNegative: Boolean = false) {
|
||||
companion object {
|
||||
fun parse(tag: String) : Tag {
|
||||
if (tag.firstOrNull() == '-') {
|
||||
tag.substring(1).split(Regex(":"), 2).let {
|
||||
return when(it.size) {
|
||||
2 -> Tag(it[0], it[1], true)
|
||||
else -> Tag(null, tag, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
tag.split(Regex(":"), 2).let {
|
||||
return when(it.size) {
|
||||
2 -> Tag(it[0], it[1])
|
||||
else -> Tag(null, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return (if (isNegative) "-" else "") + when(area) {
|
||||
null -> tag
|
||||
else -> "$area:$tag"
|
||||
}
|
||||
}
|
||||
|
||||
fun toQuery(): String {
|
||||
return toString().replace(' ', '_')
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is Tag)
|
||||
return false
|
||||
|
||||
if (other.area == area && other.tag == tag)
|
||||
return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
override fun hashCode() = toString().hashCode()
|
||||
}
|
||||
|
||||
class Tags(val tags: MutableSet<Tag> = mutableSetOf()) : MutableSet<Tag> by tags {
|
||||
|
||||
companion object {
|
||||
fun parse(tags: String) : Tags {
|
||||
return Tags(
|
||||
tags.split(' ').map {
|
||||
if (it.isNotEmpty())
|
||||
Tag.parse(it)
|
||||
else
|
||||
null
|
||||
}.filterNotNull().toMutableSet()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun contains(element: String): Boolean {
|
||||
tags.forEach {
|
||||
if (it.toString() == element)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
fun add(element: String): Boolean {
|
||||
return tags.add(Tag.parse(element))
|
||||
}
|
||||
|
||||
fun remove(element: String) {
|
||||
tags.filter { it.toString() == element }.forEach {
|
||||
tags.remove(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeByArea(area: String, isNegative: Boolean? = null) {
|
||||
tags.filter { it.area == area && (if(isNegative == null) true else (it.isNegative == isNegative)) }.forEach {
|
||||
tags.remove(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return tags.joinToString(" ") { it.toString() }
|
||||
}
|
||||
}
|
||||
@@ -20,101 +20,49 @@ package xyz.quaver.pupil.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import kotlinx.coroutines.launch
|
||||
import org.kodein.di.*
|
||||
import org.kodein.di.android.closestDI
|
||||
import org.kodein.di.android.subDI
|
||||
import org.kodein.di.compose.rememberInstance
|
||||
import xyz.quaver.pupil.BuildConfig
|
||||
import xyz.quaver.pupil.sources.core.Source
|
||||
import xyz.quaver.pupil.sources.core.util.LocalActivity
|
||||
import xyz.quaver.pupil.sources.loadSource
|
||||
import xyz.quaver.pupil.ui.theme.PupilTheme
|
||||
import xyz.quaver.pupil.util.PupilHttpClient
|
||||
import xyz.quaver.pupil.util.Release
|
||||
|
||||
class MainActivity : ComponentActivity(), DIAware {
|
||||
override val di by closestDI()
|
||||
import com.google.accompanist.adaptive.calculateDisplayFeatures
|
||||
import xyz.quaver.pupil.ui.composable.MainApp
|
||||
import xyz.quaver.pupil.ui.theme.AppTheme
|
||||
import xyz.quaver.pupil.ui.viewmodel.MainViewModel
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val viewModel: MainViewModel by viewModels()
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
setContent {
|
||||
PupilTheme {
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||
val navController = rememberNavController()
|
||||
AppTheme {
|
||||
val windowSize = calculateWindowSizeClass(this)
|
||||
val displayFeatures = calculateDisplayFeatures(this)
|
||||
|
||||
val systemUiController = rememberSystemUiController()
|
||||
val useDarkIcons = MaterialTheme.colors.isLight
|
||||
val uiState by viewModel.searchState.collectAsStateWithLifecycle()
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val navController = rememberNavController()
|
||||
|
||||
val client: PupilHttpClient by rememberInstance()
|
||||
|
||||
val latestRelease by produceState<Release?>(null) {
|
||||
value = null //client.latestRelease()
|
||||
}
|
||||
|
||||
var dismissUpdate by remember { mutableStateOf(false) }
|
||||
|
||||
SideEffect {
|
||||
systemUiController.setSystemBarsColor(
|
||||
color = Color.Transparent,
|
||||
darkIcons = useDarkIcons
|
||||
)
|
||||
}
|
||||
|
||||
latestRelease?.let { release ->
|
||||
UpdateAlertDialog(
|
||||
show = !dismissUpdate && release.version != BuildConfig.VERSION_NAME,
|
||||
release = release,
|
||||
onDismiss = { dismissUpdate = true }
|
||||
)
|
||||
}
|
||||
|
||||
NavHost(navController, "main") {
|
||||
composable("main") {
|
||||
var source by remember { mutableStateOf<Source?>(null) }
|
||||
|
||||
BackHandler(
|
||||
enabled = source != null
|
||||
) {
|
||||
source = null
|
||||
}
|
||||
|
||||
Crossfade(source) { _source ->
|
||||
if (_source == null)
|
||||
SourceSelector {
|
||||
coroutineScope.launch {
|
||||
source = loadSource(application, it)
|
||||
}
|
||||
}
|
||||
else {
|
||||
CompositionLocalProvider(LocalActivity provides this@MainActivity) {
|
||||
_source.Entry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
composable("settings") {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
MainApp(
|
||||
windowSize = windowSize,
|
||||
displayFeatures = displayFeatures,
|
||||
uiState = uiState,
|
||||
navController = navController,
|
||||
openGalleryDetails = viewModel::openGalleryDetails,
|
||||
closeGalleryDetails = viewModel::closeGalleryDetails,
|
||||
onQueryChange = viewModel::onQueryChange,
|
||||
loadSearchResult = viewModel::loadSearchResult
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.ui
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.DownloadDone
|
||||
import androidx.compose.material.icons.filled.Explore
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.runtime.*
|
||||
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.text.capitalize
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.google.accompanist.drawablepainter.rememberDrawablePainter
|
||||
import com.google.accompanist.insets.LocalWindowInsets
|
||||
import com.google.accompanist.insets.rememberInsetsPaddingValues
|
||||
import com.google.accompanist.insets.systemBarsPadding
|
||||
import com.google.accompanist.insets.ui.BottomNavigation
|
||||
import com.google.accompanist.insets.ui.Scaffold
|
||||
import com.google.accompanist.insets.ui.TopAppBar
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.kodein.di.DI
|
||||
import org.kodein.di.DIAware
|
||||
import org.kodein.di.compose.localDI
|
||||
import org.kodein.di.instance
|
||||
import xyz.quaver.pupil.sources.SourceEntry
|
||||
import xyz.quaver.pupil.sources.rememberLocalSourceList
|
||||
import xyz.quaver.pupil.sources.rememberRemoteSourceList
|
||||
import xyz.quaver.pupil.util.PupilHttpClient
|
||||
import xyz.quaver.pupil.util.RemoteSourceInfo
|
||||
import xyz.quaver.pupil.util.launchApkInstaller
|
||||
import java.io.File
|
||||
|
||||
private sealed class SourceSelectorScreen(val route: String, val icon: ImageVector) {
|
||||
object Local: SourceSelectorScreen("local", Icons.Default.DownloadDone)
|
||||
object Explore: SourceSelectorScreen("explore", Icons.Default.Explore)
|
||||
}
|
||||
|
||||
private val sourceSelectorScreens = listOf(
|
||||
SourceSelectorScreen.Local,
|
||||
SourceSelectorScreen.Explore
|
||||
)
|
||||
|
||||
private val RemoteSourceInfo.apkUrl: String
|
||||
get() = "https://github.com/tom5079/PupilSources/releases/download/$name-$version/$projectName-release.apk"
|
||||
|
||||
|
||||
class DownloadApkActionState(override val di: DI) : DIAware {
|
||||
private val app: Application by instance()
|
||||
private val client: PupilHttpClient by instance()
|
||||
|
||||
var progress by mutableStateOf<Float?>(null)
|
||||
private set
|
||||
|
||||
suspend fun download(url: String): File? = withContext(Dispatchers.IO) {
|
||||
progress = 0f
|
||||
|
||||
val file = File.createTempFile("pupil", ".apk", File(app.cacheDir, "apks").also {
|
||||
it.mkdirs()
|
||||
})
|
||||
|
||||
client.downloadFile(url, file).collect { progress = it }
|
||||
|
||||
if (progress == Float.POSITIVE_INFINITY) file else null
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
progress = null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberDownloadApkActionState(di: DI = localDI()) = remember { DownloadApkActionState(di) }
|
||||
|
||||
@Composable
|
||||
fun DownloadApkAction(
|
||||
state: DownloadApkActionState,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
state.progress?.let { progress ->
|
||||
Box(
|
||||
Modifier.padding(12.dp, 0.dp)
|
||||
) {
|
||||
when {
|
||||
progress.isFinite() && progress > 0f ->
|
||||
CircularProgressIndicator(progress, modifier = Modifier.size(24.dp))
|
||||
else ->
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
} ?: content()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SourceListItem(icon: @Composable (Modifier) -> Unit = { }, name: String, version: String, actions: @Composable () -> Unit = { }) {
|
||||
Card(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
elevation = 4.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
icon(Modifier.size(48.dp))
|
||||
|
||||
Column(
|
||||
Modifier.weight(1f)
|
||||
) {
|
||||
Text(name.capitalize(Locale.current))
|
||||
|
||||
CompositionLocalProvider(LocalContentAlpha provides 0.5f) {
|
||||
Text(
|
||||
"v$version",
|
||||
style = MaterialTheme.typography.caption
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
actions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Local(onSource: (SourceEntry) -> Unit) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
val localSourceList by rememberLocalSourceList()
|
||||
val remoteSourceList by rememberRemoteSourceList()
|
||||
|
||||
if (localSourceList.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
CompositionLocalProvider(LocalContentAlpha provides 0.5f) {
|
||||
Text("(´∇`)", style = MaterialTheme.typography.h2)
|
||||
}
|
||||
Text("No sources found!\nLet's go download one.", textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(localSourceList) { source ->
|
||||
val actionState = rememberDownloadApkActionState()
|
||||
|
||||
SourceListItem(
|
||||
icon = { modifier ->
|
||||
Image(
|
||||
rememberDrawablePainter(source.icon),
|
||||
contentDescription = "source icon",
|
||||
modifier = modifier
|
||||
)
|
||||
},
|
||||
source.sourceName,
|
||||
source.version
|
||||
) {
|
||||
DownloadApkAction(actionState) {
|
||||
val remoteSource = remoteSourceList?.get(source.packageName)
|
||||
if (remoteSource != null && remoteSource.version != source.version) {
|
||||
TextButton(onClick = {
|
||||
coroutineScope.launch {
|
||||
val file = actionState.download(remoteSource.apkUrl)!! // TODO("Handle error")
|
||||
context.launchApkInstaller(file)
|
||||
actionState.reset()
|
||||
}
|
||||
}) {
|
||||
Text("UPDATE")
|
||||
}
|
||||
} else {
|
||||
TextButton(
|
||||
onClick = { onSource(source) }
|
||||
) {
|
||||
Text("GO")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Explore() {
|
||||
val localSourceList by rememberLocalSourceList()
|
||||
val localSources by derivedStateOf {
|
||||
localSourceList.associateBy {
|
||||
it.packageName
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val remoteSources by rememberRemoteSourceList()
|
||||
|
||||
Box(
|
||||
Modifier.fillMaxSize()
|
||||
) {
|
||||
if (remoteSources == null)
|
||||
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
else
|
||||
LazyColumn {
|
||||
items(remoteSources?.values?.toList().orEmpty()) { sourceInfo ->
|
||||
val actionState = rememberDownloadApkActionState()
|
||||
|
||||
SourceListItem(
|
||||
icon = { modifier ->
|
||||
AsyncImage(
|
||||
"https://raw.githubusercontent.com/tom5079/PupilSources/master/${sourceInfo.projectName}/src/main/res/mipmap-xxxhdpi/ic_launcher.png",
|
||||
contentDescription = "source icon",
|
||||
modifier = modifier
|
||||
)
|
||||
},
|
||||
sourceInfo.name,
|
||||
sourceInfo.version
|
||||
) {
|
||||
DownloadApkAction(actionState) {
|
||||
if (localSources[sourceInfo.name]?.version != sourceInfo.version) {
|
||||
TextButton(onClick = {
|
||||
coroutineScope.launch {
|
||||
val file = actionState.download(sourceInfo.apkUrl)!! // TODO("Handle exception")
|
||||
context.launchApkInstaller(file)
|
||||
actionState.reset()
|
||||
}
|
||||
}) {
|
||||
Text("UPDATE")
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = {
|
||||
if (sourceInfo.name in localSources) {
|
||||
context.startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", localSources[sourceInfo.name]!!.packagePath, null)
|
||||
)
|
||||
)
|
||||
} else coroutineScope.launch {
|
||||
val file = actionState.download(sourceInfo.apkUrl)!! // TODO("Handle exception")
|
||||
context.launchApkInstaller(file)
|
||||
actionState.reset()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
if (sourceInfo.name !in localSources) Icons.Default.Download
|
||||
else Icons.Outlined.Info,
|
||||
contentDescription = "download"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SourceSelector(onSource: (SourceEntry) -> Unit) {
|
||||
val bottomNavController = rememberNavController()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text("Pupil")
|
||||
},
|
||||
contentPadding = rememberInsetsPaddingValues(LocalWindowInsets.current.statusBars)
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
BottomNavigation(
|
||||
contentPadding = rememberInsetsPaddingValues(LocalWindowInsets.current.navigationBars)
|
||||
) {
|
||||
val navBackStackEntry by bottomNavController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
sourceSelectorScreens.forEach { screen ->
|
||||
BottomNavigationItem(
|
||||
icon = { Icon(screen.icon, contentDescription = screen.route) },
|
||||
label = { Text(screen.route) },
|
||||
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
|
||||
onClick = {
|
||||
bottomNavController.navigate(screen.route) {
|
||||
popUpTo(bottomNavController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { contentPadding ->
|
||||
NavHost(bottomNavController, startDestination = "local", modifier = Modifier
|
||||
.systemBarsPadding(top = false, bottom = false)
|
||||
.padding(contentPadding)) {
|
||||
composable(SourceSelectorScreen.Local.route) { Local(onSource) }
|
||||
composable(SourceSelectorScreen.Explore.route) { Explore() }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2022 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import kotlinx.coroutines.launch
|
||||
import org.kodein.di.compose.onDIContext
|
||||
import xyz.quaver.pupil.util.Release
|
||||
import xyz.quaver.pupil.util.launchApkInstaller
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
fun UpdateAlertDialog(
|
||||
show: Boolean,
|
||||
release: Release,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val state = rememberDownloadApkActionState()
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
if (show) {
|
||||
Dialog(onDismissRequest = { if (state.progress == null) onDismiss() }) {
|
||||
Card {
|
||||
val progress = state.progress
|
||||
|
||||
if (progress != null) {
|
||||
if (progress.isFinite() && progress > 0)
|
||||
LinearProgressIndicator(progress)
|
||||
else
|
||||
LinearProgressIndicator()
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.padding(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 0.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Update Available",
|
||||
style = MaterialTheme.typography.h6
|
||||
)
|
||||
|
||||
Text(release.releaseNotes.getOrElse(Locale.getDefault()) { release.releaseNotes[Locale.ENGLISH]!! })
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = onDismiss, enabled = progress == null) {
|
||||
Text("DISMISS")
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
val file = state.download(release.apkUrl)!! // TODO("Handle exception")
|
||||
context.launchApkInstaller(file)
|
||||
state.reset()
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
enabled = progress == null
|
||||
) {
|
||||
Text("UPDATE")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
enum class ContentType {
|
||||
SINGLE_PANE, DUAL_PANE
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
import android.graphics.Rect
|
||||
import androidx.window.layout.FoldingFeature
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.contract
|
||||
|
||||
sealed interface DevicePosture {
|
||||
data object NormalPosture: DevicePosture
|
||||
|
||||
data class BookPosture(
|
||||
val hingePosition: Rect
|
||||
): DevicePosture
|
||||
|
||||
data class Separating(
|
||||
val hingePosition: Rect,
|
||||
val orientation: FoldingFeature.Orientation
|
||||
): DevicePosture
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
fun isBookPosture(foldingFeature: FoldingFeature?): Boolean {
|
||||
contract { returns(true) implies (foldingFeature != null) }
|
||||
|
||||
return foldingFeature?.state == FoldingFeature.State.HALF_OPENED &&
|
||||
foldingFeature.orientation == FoldingFeature.Orientation.VERTICAL
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
fun isSeparating(foldingFeature: FoldingFeature?): Boolean {
|
||||
contract { returns(true) implies (foldingFeature != null) }
|
||||
|
||||
return foldingFeature?.state == FoldingFeature.State.FLAT && foldingFeature.isSeparating
|
||||
}
|
||||
504
app/src/main/java/xyz/quaver/pupil/ui/composable/Gallery.kt
Normal file
504
app/src/main/java/xyz/quaver/pupil/ui/composable/Gallery.kt
Normal file
@@ -0,0 +1,504 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BrokenImage
|
||||
import androidx.compose.material.icons.filled.QuestionMark
|
||||
import androidx.compose.material.icons.filled.StarOutline
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
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.LaunchedEffect
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.networking.Artist
|
||||
import xyz.quaver.pupil.networking.Character
|
||||
import xyz.quaver.pupil.networking.GalleryFile
|
||||
import xyz.quaver.pupil.networking.GalleryInfo
|
||||
import xyz.quaver.pupil.networking.GalleryTag
|
||||
import xyz.quaver.pupil.networking.Group
|
||||
import xyz.quaver.pupil.networking.HitomiHttpClient
|
||||
import xyz.quaver.pupil.networking.Language
|
||||
import xyz.quaver.pupil.networking.SearchQuery
|
||||
import xyz.quaver.pupil.networking.Series
|
||||
import xyz.quaver.pupil.networking.joinToCapitalizedString
|
||||
import xyz.quaver.pupil.ui.theme.Blue500
|
||||
import xyz.quaver.pupil.ui.theme.Green500
|
||||
import xyz.quaver.pupil.ui.theme.Purple500
|
||||
import xyz.quaver.pupil.ui.theme.Red500
|
||||
import xyz.quaver.pupil.ui.theme.Yellow500
|
||||
|
||||
private val languageMap = mapOf(
|
||||
"indonesian" to "Bahasa Indonesia",
|
||||
"catalan" to "català",
|
||||
"cebuano" to "Cebuano",
|
||||
"czech" to "Čeština",
|
||||
"danish" to "Dansk",
|
||||
"german" to "Deutsch",
|
||||
"estonian" to "eesti",
|
||||
"english" to "English",
|
||||
"spanish" to "Español",
|
||||
"esperanto" to "Esperanto",
|
||||
"french" to "Français",
|
||||
"italian" to "Italiano",
|
||||
"latin" to "Latina",
|
||||
"hungarian" to "magyar",
|
||||
"dutch" to "Nederlands",
|
||||
"norwegian" to "norsk",
|
||||
"polish" to "polski",
|
||||
"portuguese" to "Português",
|
||||
"romanian" to "română",
|
||||
"albanian" to "shqip",
|
||||
"slovak" to "Slovenčina",
|
||||
"finnish" to "Suomi",
|
||||
"swedish" to "Svenska",
|
||||
"tagalog" to "Tagalog",
|
||||
"vietnamese" to "tiếng việt",
|
||||
"turkish" to "Türkçe",
|
||||
"greek" to "Ελληνικά",
|
||||
"mongolian" to "Монгол",
|
||||
"russian" to "Русский",
|
||||
"ukrainian" to "Українська",
|
||||
"hebrew" to "עברית",
|
||||
"arabic" to "العربية",
|
||||
"persian" to "فارسی",
|
||||
"thai" to "ไทย",
|
||||
"korean" to "한국어",
|
||||
"chinese" to "中文",
|
||||
"japanese" to "日本語"
|
||||
)
|
||||
|
||||
private val galleryTypeStringMap = mapOf(
|
||||
"doujinshi" to R.string.doujinshi,
|
||||
"manga" to R.string.manga,
|
||||
"artistcg" to R.string.artist_cg,
|
||||
"gamecg" to R.string.game_cg,
|
||||
"imageset" to R.string.image_set
|
||||
)
|
||||
|
||||
private val galleryTypeColorMap = mapOf(
|
||||
"doujinshi" to Red500,
|
||||
"manga" to Yellow500,
|
||||
"artistcg" to Purple500,
|
||||
"gamecg" to Green500,
|
||||
"imageset" to Blue500
|
||||
)
|
||||
|
||||
class GalleryInfoProvider: PreviewParameterProvider<GalleryInfo> {
|
||||
override val values = sequenceOf(
|
||||
GalleryInfo(
|
||||
id = "2296437",
|
||||
title = "Kakyuu Majutsushi, Inmon ni Somaru | 하급 마술사, 음문에 물들다",
|
||||
language = "korean",
|
||||
type = "doujinshi",
|
||||
date = "2022-08-11 07:14:00-05",
|
||||
artists = listOf(Artist("wagashi")),
|
||||
groups = listOf(Group("dagashiya")),
|
||||
series = listOf(Series("original")),
|
||||
tags = listOf(
|
||||
GalleryTag("ahegao", female="1"),
|
||||
GalleryTag("big penis", male="1"),
|
||||
GalleryTag("bike shorts", female="1"),
|
||||
GalleryTag("blowjob", female="1"),
|
||||
GalleryTag("blowjob face", female="1"),
|
||||
GalleryTag("bukkake", female="1"),
|
||||
GalleryTag("bunny girl", female="1"),
|
||||
GalleryTag("clone", male="1"),
|
||||
GalleryTag("corruption", female="1"),
|
||||
GalleryTag("crotch tattoo", female="1"),
|
||||
GalleryTag("gloves", female="1"),
|
||||
GalleryTag("gokkun", female="1"),
|
||||
GalleryTag("group"),
|
||||
GalleryTag("kemonomimi", female="1"),
|
||||
GalleryTag("leotard", female="1"),
|
||||
GalleryTag("lingerie", female="1"),
|
||||
GalleryTag("loli", female="1"),
|
||||
GalleryTag("masked face", female="1"),
|
||||
GalleryTag("masturbation", female="1"),
|
||||
GalleryTag("mind control", female="1"),
|
||||
GalleryTag("mmf threesome"),
|
||||
GalleryTag("moral degeneration", female="1"),
|
||||
GalleryTag("mouth mask", female="1"),
|
||||
GalleryTag("nakadashi", female="1"),
|
||||
GalleryTag("prostitution", female="1"),
|
||||
GalleryTag("smell", male="1"),
|
||||
GalleryTag("unusual pupils", female="1"),
|
||||
),
|
||||
related = listOf(2806924, 2806923, 2319091, 1647024, 2580808),
|
||||
languages = listOf(
|
||||
Language(galleryID="2806923", name="korean"),
|
||||
Language(galleryID="2609305", name="english"),
|
||||
Language(galleryID="2302333", name="spanish"),
|
||||
Language(galleryID="2392785", name="portuguese"),
|
||||
Language(galleryID="2303940", name="russian"),
|
||||
Language(galleryID="2736129", name="chinese"),
|
||||
Language(galleryID="2295647", name="japanese")
|
||||
),
|
||||
characters = listOf(Character("Kakyuu Majutsushi")),
|
||||
files = listOf(
|
||||
GalleryFile(name="01.jpg", hash="d441383396a6ba41a2db914328dc80d16b5191e53d23a5f0f9f8a0cd8f2e7cef", width=4185, height=6000, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="02.png", hash="a42517a19c7db6369749807bbc6676906e35709be07f780247f2e68d516ed1d5", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="03.png", hash="ee0841953755f34a0a146a7f757cf2993c678384f53e88715b1c97a00abe5c27", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="04.png", hash="66fafca77a7ed0287666e77fe268a02f75b4e27c2b9b77e6577bb3132396132b", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="05.png", hash="0ef8081ad9eef5093077c8551e87903e8b275e607634717857c2986e8d3c51a9", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="06.png", hash="2e59ffd59fa761355ea855a9e0f366cb39569207165a99659a9f0868cbba7e94", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="07.png", hash="5dc19fb97a2f1c64cae5634cd651f593d022b3114bbacaab15ba114be581cbea", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="08.png", hash="9121781d4f8fb1aaaee124f82c2ab98c97eb3e1f9508bab7c1d8771bb5eddfdb", width=4520, height=6295, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="09.png", hash="e5ceae4da5e497bd95a79a607f2c85bb3e8dc7386e041086cd5ccb9a9fb4dcb1", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="10.png", hash="be56219811a29f86dfcf5a7af0b25addc7436b134acd1a94d51c69c987da9d1b", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="11.png", hash="4e3b09ac015360ad4daffcea46265f3eb319bbe638ce90f26d4cbae37dc8c0f1", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="12.png", hash="5d57c0a0cd00604382eeaf0b32446b938dafc9d980eb086673e1fa0307166e39", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="13.png", hash="1ff2313fe979b52b826d482be90699a7c086afc251fa22306e92dfc582611f10", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="14.png", hash="7ad92a9408a059afafc68d3086c5f6a070c7e0d550bc2d328b5c9e16a62be01c", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="15.png", hash="4a6db95b7111b647e450c155af07c617d28313c02871ed94ad0c5986d3c5d1aa", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="16.png", hash="d4b53bf416c9bd2f72850e80ddaaf8467c663c72433c8ebebf3a70742fea7c32", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="17.png", hash="d189d5321f18414de816c049d3e2d72a7d31124d628037ca9f7da4572025cf01", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="18.png", hash="3ff372d7ba4e34cff9f7b46f5323e60b36f9b9df3dd5d02be4c71de4a7ee23c7", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="19.png", hash="2965852c2000fb17f756263b47ca196563995be2d03143f64c297f5930248d1e", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="20.png", hash="3713f95947cc6df0b67af5532a440f023c99ee37d483f3f9252400168e3a55ad", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="21.png", hash="c0b2b2d5ee79c3dc3b737c0eaff19ed1a731d81495adc2c94260de7ebbd85415", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="22.png", hash="8835fe309a26fce6882c0fcdf37cbd5f5bcc69dd2c32e436deb644891ca8499b", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="23.png", hash="5eb28619c1919ad29b86fb6cdaeccd50ad2ad857c81f56c060e8b66a2ed315d0", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="24.png", hash="7ea3ecf4c0b0e5e632163a5b0dc2475a071e1209a0fed8e8e49243093c35babb", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="25.png", hash="d01355724eb60c41e43159607652812d1fbbbac12962b2f9068a9e620ee0c246", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="26.png", hash="58a33c1d709b005a17600f7beb14a81711a106619bdb029d30646a9060c245c7", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="27.png", hash="0b18753f2fe7ea97c2e2c13a082a5a675f36085558bcd3fb7be916b6118c6000", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="28.png", hash="e8f0a2f9d35ec2c1974a4aea07eefd792462049e7b0972bf8cd5532dd82cee21", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="29.png", hash="90ec7a13aedf22c1f9317f75e843a4ace4e236d6100a8c8bb5aeb8160ff1cd00", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="30.png", hash="3229400975e6cef763c54a82a4ce0e6f51ec41c9b674072e74f66b41e325b655", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="31.png", hash="4e04efa56981804f7d13858a98935038fe421956367dcabba8e9118d273135d5", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="32.png", hash="e0d8641127aaaf587ec5e3040c49edee9cda866a5f1f377e567b908514b1eb68", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="33.png", hash="00125ab9090be7d3462fd6943c209bc68c236ef50ca0f1552530fd8253d87f7d", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="34.png", hash="1ea303198badaecfcf66a438baa6867690416d77a542f1ff0181b1895df95ccb", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="35.png", hash="40181ac057808a16716e640c462cc2b992822999ab8db43d454bb331d50bd39f", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="36.png", hash="92cbe1067c1c554c5a2e01b58b6fb86677a523cba586dfa209d61b4af70b2f54", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="37.png", hash="4fbbdc6d5450eb1f2f7e45a97ce8360d43e199454a8eb9e536740309c2067999", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="38.png", hash="0cb74b8af27e51604c728a26cf4788899fa04f854853badea9884b008c024e94", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="39.png", hash="254676c13ab418ce1110a9bad009554abc72af7e2ff9d719409881ea3c635d46", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="40.png", hash="33527e1171b9cf21ab164be2b76cb5b9daa91980a3330f2441dd9bf911e2e05b", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="41.png", hash="4dc331acb058665500aa308143c106efb6999855d1c3b0665c1dd331c8364430", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="42.png", hash="7674f03fa02cf96a20f1e192caf76d85e7e556add0cdb65aabdc86d417093d39", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="43.png", hash="27a4a280483142b213b26d53f06b991be35122cb263c69a87435e624b2a307fd", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="44.png", hash="3114ba3bff094abbae9160656894d462e0567cea23e6fa2693469c5e7defa6fe", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="45.png", hash="dc22ffed6a678a560e781f795b3ee0876ec8726ae2af942226710102758d44db", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="46.png", hash="4f7257ad75b990cc2b8dad0c5a09a831cb1670e7aaeadc71809f4bd7400f0071", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="47.png", hash="bf74fcd74ce77b5089bac98dc9f5737ba1fe87cdcfb01ad80e63024c93f82692", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="48.png", hash="d0baed210584183efa654ca7a483c558afe9eb18b71452ed2cfd4264b269ffca", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="49.png", hash="2b9c90a038e4e918655dffa84fbbb08cbedfce642277b1140d070aa9a711ebac", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="50.png", hash="cdfba6a9b8a570f2f317fb01e66ebc72ede5735a41256b326a38af2296799095", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="51.png", hash="84ad9d51aab2e8ae1cbb5a0918cc3f62473e98512db9f473ab1ffe3a4f7aa75a", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="52.png", hash="0d7a420aa23e23cee7f8e124b12080e309cffb3d7269389f23e1ac3d5b7363a5", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="53.png", hash="575cf99346f9786d6e30bc163dc6d5fa439bd157a9ffce90586bfc5657121981", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="54.png", hash="9004d3fd46dc278b79cf7a5bf72002dd4b0b03ad6bded30ceebcb633185b49ce", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="55.png", hash="8e53c301e2c2b539783c7b8c4f5028f221198d33e114dcc40e6a0025aee840ab", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="56.png", hash="576764073554fcf644c12d80f26b3bae42f39f3516ed7841c9e297248324b237", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="57.png", hash="110da01b9ae7ed4a145792f55498a5efa22cebec4da84f6220904840b508c75e", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
GalleryFile(name="58.jpg", hash="3ae38577135465b6224e0487c0cdcd37cf11764883f3b78a67545d48c6beade5", width=4260, height=6000, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun TagGroup(tags: List<SearchQuery.Tag>, folded: Boolean = false) {
|
||||
var isFolded by remember { mutableStateOf(folded) }
|
||||
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
tags.sortedBy {
|
||||
when(it.namespace) {
|
||||
"female" -> 1
|
||||
"male" -> 2
|
||||
else -> 3
|
||||
}
|
||||
}.let {
|
||||
if (isFolded) it.take(10) else it
|
||||
}.forEach { tag ->
|
||||
TagChip(tag = tag.toTag())
|
||||
}
|
||||
|
||||
if (isFolded && tags.size > 10)
|
||||
Surface(
|
||||
modifier = Modifier.height(32.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
onClick = { isFolded = false }
|
||||
) {
|
||||
Text(
|
||||
"…",
|
||||
modifier = Modifier.padding(16.dp, 8.dp),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GalleryTypeIndicator(galleryType: String) {
|
||||
Surface(
|
||||
modifier = Modifier.height(32.dp),
|
||||
color = galleryTypeColorMap[galleryType] ?: MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Box(Modifier.fillMaxHeight()) {
|
||||
Text(
|
||||
galleryTypeStringMap[galleryType]?.let { stringResource(it) } ?: galleryType,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.align(Alignment.Center),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LanguageTitle(title: String, language: String?) {
|
||||
val icon = languageIconMap[language]
|
||||
|
||||
if (icon != null) {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
appendInlineContent("language", "<language>")
|
||||
append(' ')
|
||||
append(title)
|
||||
},
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
inlineContent = mapOf(
|
||||
"language" to InlineTextContent(
|
||||
Placeholder(
|
||||
width = 20.sp,
|
||||
height = 20.sp,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painterResource(icon),
|
||||
contentDescription = null,
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DetailedGalleryInfoHeader(galleryInfo: GalleryInfo, thumbnailUrl: String?) {
|
||||
val thumbnailFile = galleryInfo.files.first()
|
||||
val aspectRatio = thumbnailFile.let { it.width / it.height.toFloat() }
|
||||
|
||||
if (thumbnailFile.let { it.width > it.height }) {
|
||||
Column {
|
||||
if (thumbnailUrl != null) {
|
||||
SubcomposeAsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(thumbnailUrl)
|
||||
.setHeader("Referer", "https://hitomi.la/")
|
||||
.build(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(aspectRatio)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
loading = { CircularProgressIndicator(Modifier.align(Alignment.Center)) },
|
||||
error = {
|
||||
Icon(
|
||||
Icons.Default.BrokenImage,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
contentDescription = "Thumbnail"
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(aspectRatio)) {
|
||||
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
LanguageTitle(galleryInfo.title, galleryInfo.language)
|
||||
|
||||
val artistsAndGroups = buildString {
|
||||
if (!galleryInfo.artists.isNullOrEmpty())
|
||||
append(galleryInfo.artists.joinToCapitalizedString())
|
||||
|
||||
if (!galleryInfo.groups.isNullOrEmpty()) {
|
||||
if (this.isNotEmpty()) append(' ')
|
||||
append('(')
|
||||
append(galleryInfo.groups.joinToCapitalizedString())
|
||||
append(')')
|
||||
}
|
||||
}
|
||||
|
||||
if (artistsAndGroups.isNotEmpty()) {
|
||||
Text(
|
||||
artistsAndGroups,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (thumbnailUrl != null) {
|
||||
SubcomposeAsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(thumbnailUrl)
|
||||
.setHeader("Referer", "https://hitomi.la/")
|
||||
.build(),
|
||||
modifier = Modifier
|
||||
.height(200.dp)
|
||||
.aspectRatio(aspectRatio)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
loading = { CircularProgressIndicator(Modifier.align(Alignment.Center)) },
|
||||
error = {
|
||||
Icon(
|
||||
Icons.Default.BrokenImage,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
contentDescription = "Thumbnail"
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.height(200.dp)
|
||||
.aspectRatio(aspectRatio)) {
|
||||
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
Column(Modifier.heightIn(min = 200.dp)) {
|
||||
LanguageTitle(galleryInfo.title, galleryInfo.language)
|
||||
|
||||
val artistsAndGroups = buildString {
|
||||
if (!galleryInfo.artists.isNullOrEmpty())
|
||||
append(galleryInfo.artists.joinToCapitalizedString())
|
||||
|
||||
if (!galleryInfo.groups.isNullOrEmpty()) {
|
||||
if (this.isNotEmpty()) append(' ')
|
||||
append('(')
|
||||
append(galleryInfo.groups.joinToCapitalizedString())
|
||||
append(')')
|
||||
}
|
||||
}
|
||||
|
||||
if (artistsAndGroups.isNotEmpty()) {
|
||||
Text(
|
||||
artistsAndGroups,
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun DetailedGalleryInfo(
|
||||
@PreviewParameter(GalleryInfoProvider::class) galleryInfo: GalleryInfo,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var thumbnailUrl by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(galleryInfo) {
|
||||
thumbnailUrl = galleryInfo.files.firstOrNull()?.let {
|
||||
HitomiHttpClient.getImageURL(it, true).firstOrNull()
|
||||
} ?: ""
|
||||
}
|
||||
|
||||
Card(modifier) {
|
||||
Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
DetailedGalleryInfoHeader(galleryInfo, thumbnailUrl)
|
||||
|
||||
GalleryTypeIndicator(galleryInfo.type)
|
||||
|
||||
if (galleryInfo.tags?.isNotEmpty() == true) {
|
||||
TagGroup(galleryInfo.tags.map { it.toTag() }, folded = true)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(4.dp)) {
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.CenterStart),
|
||||
text = galleryInfo.id,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
text = "${galleryInfo.files.size}P",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.size(32.dp),
|
||||
imageVector = Icons.Default.StarOutline,
|
||||
contentDescription = null,
|
||||
tint = Yellow500
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
306
app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt
Normal file
306
app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt
Normal file
@@ -0,0 +1,306 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.PermanentNavigationDrawer
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.activity
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.window.layout.DisplayFeature
|
||||
import androidx.window.layout.FoldingFeature
|
||||
import kotlinx.coroutines.launch
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.networking.GalleryInfo
|
||||
import xyz.quaver.pupil.networking.SearchQuery
|
||||
import xyz.quaver.pupil.ui.viewmodel.SearchState
|
||||
|
||||
@Composable
|
||||
fun MainApp(
|
||||
windowSize: WindowSizeClass,
|
||||
displayFeatures: List<DisplayFeature>,
|
||||
uiState: SearchState,
|
||||
navController: NavHostController,
|
||||
openGalleryDetails: (GalleryInfo) -> Unit,
|
||||
closeGalleryDetails: () -> Unit,
|
||||
onQueryChange: (SearchQuery?) -> Unit,
|
||||
loadSearchResult: (IntRange) -> Unit,
|
||||
) {
|
||||
val navigationType: NavigationType
|
||||
val contentType: ContentType
|
||||
|
||||
val foldingFeature: FoldingFeature? = displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()
|
||||
val foldingDevicePosture = when {
|
||||
isBookPosture(foldingFeature) -> DevicePosture.BookPosture(foldingFeature.bounds)
|
||||
isSeparating(foldingFeature) -> DevicePosture.Separating(foldingFeature.bounds, foldingFeature.orientation)
|
||||
else -> DevicePosture.NormalPosture
|
||||
}
|
||||
|
||||
when (windowSize.widthSizeClass) {
|
||||
WindowWidthSizeClass.Compact -> {
|
||||
navigationType = NavigationType.BOTTOM_NAVIGATION
|
||||
contentType = ContentType.SINGLE_PANE
|
||||
}
|
||||
WindowWidthSizeClass.Medium -> {
|
||||
navigationType = NavigationType.NAVIGATION_RAIL
|
||||
contentType = if (foldingDevicePosture != DevicePosture.NormalPosture) {
|
||||
ContentType.DUAL_PANE
|
||||
} else {
|
||||
ContentType.SINGLE_PANE
|
||||
}
|
||||
}
|
||||
WindowWidthSizeClass.Expanded -> {
|
||||
navigationType = if (foldingDevicePosture is DevicePosture.BookPosture) {
|
||||
NavigationType.NAVIGATION_RAIL
|
||||
} else {
|
||||
NavigationType.PERMANENT_NAVIGATION_DRAWER
|
||||
}
|
||||
contentType = ContentType.DUAL_PANE
|
||||
}
|
||||
else -> {
|
||||
navigationType = NavigationType.BOTTOM_NAVIGATION
|
||||
contentType = ContentType.SINGLE_PANE
|
||||
}
|
||||
}
|
||||
|
||||
val navigationContentPosition = when (windowSize.heightSizeClass) {
|
||||
WindowHeightSizeClass.Compact -> NavigationContentPosition.TOP
|
||||
WindowHeightSizeClass.Medium,
|
||||
WindowHeightSizeClass.Expanded -> NavigationContentPosition.CENTER
|
||||
else -> NavigationContentPosition.TOP
|
||||
}
|
||||
|
||||
MainNavigationWrapper(
|
||||
navigationType,
|
||||
contentType,
|
||||
displayFeatures,
|
||||
navigationContentPosition,
|
||||
uiState,
|
||||
navController,
|
||||
openGalleryDetails = openGalleryDetails,
|
||||
closeGalleryDetails = closeGalleryDetails,
|
||||
onQueryChange = onQueryChange,
|
||||
loadSearchResult = loadSearchResult
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainNavigationWrapper(
|
||||
navigationType: NavigationType,
|
||||
contentType: ContentType,
|
||||
displayFeatures: List<DisplayFeature>,
|
||||
navigationContentPosition: NavigationContentPosition,
|
||||
uiState: SearchState,
|
||||
navController: NavHostController,
|
||||
openGalleryDetails: (GalleryInfo) -> Unit,
|
||||
closeGalleryDetails: () -> Unit,
|
||||
onQueryChange: (SearchQuery?) -> Unit,
|
||||
loadSearchResult: (IntRange) -> Unit
|
||||
) {
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
|
||||
val openDrawer: () -> Unit = {
|
||||
coroutineScope.launch {
|
||||
drawerState.open()
|
||||
}
|
||||
}
|
||||
|
||||
if (navigationType == NavigationType.PERMANENT_NAVIGATION_DRAWER) {
|
||||
PermanentNavigationDrawer(
|
||||
drawerContent = {
|
||||
PermanentNavigationDrawerContent(
|
||||
selectedDestination = currentRoute,
|
||||
navigateToDestination = { navController.navigate(it.route) {
|
||||
popUpTo(MainDestination.Search.route)
|
||||
launchSingleTop = true
|
||||
} },
|
||||
navigationContentPosition = navigationContentPosition,
|
||||
)
|
||||
}
|
||||
) {
|
||||
MainContent(
|
||||
navigationType = navigationType,
|
||||
contentType = contentType,
|
||||
displayFeatures = displayFeatures,
|
||||
uiState = uiState,
|
||||
navController = navController,
|
||||
onDrawerClicked = openDrawer,
|
||||
openGalleryDetails = openGalleryDetails,
|
||||
closeGalleryDetails = closeGalleryDetails,
|
||||
onQueryChange = onQueryChange,
|
||||
loadSearchResult = loadSearchResult,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ModalNavigationDrawer(
|
||||
drawerContent = {
|
||||
ModalNavigationDrawerContent(
|
||||
selectedDestination = currentRoute,
|
||||
navigateToDestination = { navController.navigate(it.route) {
|
||||
popUpTo(MainDestination.Search.route)
|
||||
launchSingleTop = true
|
||||
} },
|
||||
navigationContentPosition = navigationContentPosition,
|
||||
onDrawerClicked = {
|
||||
coroutineScope.launch {
|
||||
drawerState.close()
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
drawerState = drawerState
|
||||
) {
|
||||
MainContent(
|
||||
navigationType = navigationType,
|
||||
contentType = contentType,
|
||||
displayFeatures = displayFeatures,
|
||||
uiState = uiState,
|
||||
navController = navController,
|
||||
onDrawerClicked = openDrawer,
|
||||
openGalleryDetails = openGalleryDetails,
|
||||
closeGalleryDetails = closeGalleryDetails,
|
||||
onQueryChange = onQueryChange,
|
||||
loadSearchResult = loadSearchResult,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NotImplemented() {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text("(⁄ ⁄•⁄ω⁄•⁄ ⁄)", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f))
|
||||
Text(stringResource(R.string.not_implemented), textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainContent(
|
||||
navigationType: NavigationType,
|
||||
contentType: ContentType,
|
||||
displayFeatures: List<DisplayFeature>,
|
||||
uiState: SearchState,
|
||||
navController: NavHostController,
|
||||
onDrawerClicked: () -> Unit,
|
||||
openGalleryDetails: (GalleryInfo) -> Unit,
|
||||
closeGalleryDetails: () -> Unit,
|
||||
onQueryChange: (SearchQuery?) -> Unit,
|
||||
loadSearchResult: (IntRange) -> Unit,
|
||||
) {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
AnimatedVisibility(visible = navigationType == NavigationType.NAVIGATION_RAIL) {
|
||||
MainNavigationRail(
|
||||
selectedDestination = currentRoute,
|
||||
navigateToDestination = { navController.navigate(it.route) {
|
||||
popUpTo(MainDestination.Search.route)
|
||||
launchSingleTop = true
|
||||
} },
|
||||
onDrawerClicked = onDrawerClicked
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.inverseOnSurface)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.run {
|
||||
if (navigationType == NavigationType.BOTTOM_NAVIGATION) {
|
||||
this
|
||||
.consumeWindowInsets(WindowInsets.ime)
|
||||
.consumeWindowInsets(WindowInsets.navigationBars)
|
||||
} else this
|
||||
}
|
||||
) {
|
||||
NavHost(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
navController = navController,
|
||||
startDestination = MainDestination.Search.route
|
||||
) {
|
||||
composable(MainDestination.Search.route) {
|
||||
SearchScreen(
|
||||
contentType = contentType,
|
||||
displayFeatures = displayFeatures,
|
||||
uiState = uiState,
|
||||
openGalleryDetails = openGalleryDetails,
|
||||
closeGalleryDetails = closeGalleryDetails,
|
||||
onQueryChange = onQueryChange,
|
||||
loadSearchResult = loadSearchResult,
|
||||
openGallery = {
|
||||
navController.navigate(MainDestination.ImageViewer(it.id).route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(MainDestination.History.route) {
|
||||
NotImplemented()
|
||||
}
|
||||
composable(MainDestination.Downloads.route) {
|
||||
NotImplemented()
|
||||
}
|
||||
composable(MainDestination.Favorites.route) {
|
||||
NotImplemented()
|
||||
}
|
||||
composable(MainDestination.Settings.route) {
|
||||
NotImplemented()
|
||||
}
|
||||
composable(MainDestination.ImageViewer.commonRoute) {
|
||||
NotImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = navigationType == NavigationType.BOTTOM_NAVIGATION) {
|
||||
BottomNavigationBar(
|
||||
selectedDestination = currentRoute,
|
||||
navigateToDestination = { navController.navigate(it.route) {
|
||||
popUpTo(MainDestination.Search.route)
|
||||
launchSingleTop = true
|
||||
} }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.MenuBook
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.MenuBook
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import xyz.quaver.pupil.R
|
||||
|
||||
sealed interface MainDestination {
|
||||
val route: String
|
||||
val icon: ImageVector
|
||||
val textId: Int
|
||||
|
||||
data object Search: MainDestination {
|
||||
override val route = "search"
|
||||
override val icon = Icons.Default.Search
|
||||
override val textId = R.string.main_destination_search
|
||||
}
|
||||
|
||||
data object History: MainDestination {
|
||||
override val route = "history"
|
||||
override val icon = Icons.Default.History
|
||||
override val textId = R.string.main_destination_history
|
||||
}
|
||||
|
||||
data object Downloads: MainDestination {
|
||||
override val route = "downloads"
|
||||
override val icon = Icons.Default.Download
|
||||
override val textId = R.string.main_destination_downloads
|
||||
}
|
||||
|
||||
data object Favorites: MainDestination {
|
||||
override val route = "favorites"
|
||||
override val icon = Icons.Default.Favorite
|
||||
override val textId = R.string.main_destination_favorites
|
||||
}
|
||||
|
||||
data object Settings: MainDestination {
|
||||
override val route = "settings"
|
||||
override val icon = Icons.Default.Settings
|
||||
override val textId = R.string.main_destination_settings
|
||||
}
|
||||
|
||||
class ImageViewer(galleryID: String): MainDestination {
|
||||
override val route = "image_viewer/$galleryID"
|
||||
override val icon = Icons.AutoMirrored.Filled.MenuBook
|
||||
override val textId = R.string.main_destination_image_viewer
|
||||
|
||||
companion object {
|
||||
val commonRoute = "image_viewer/{galleryID}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val mainDestinations = listOf(
|
||||
MainDestination.Search,
|
||||
MainDestination.History,
|
||||
MainDestination.Downloads,
|
||||
MainDestination.Favorites,
|
||||
MainDestination.Settings
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
enum class NavigationContentPosition {
|
||||
TOP, CENTER
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.MenuOpen
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.PermanentDrawerSheet
|
||||
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.layout.Layout
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.MeasurePolicy
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.offset
|
||||
import androidx.navigation.NavDestination
|
||||
import xyz.quaver.pupil.R
|
||||
|
||||
@Composable
|
||||
fun PermanentNavigationDrawerContent(
|
||||
selectedDestination: String?,
|
||||
navigateToDestination: (MainDestination) -> Unit,
|
||||
navigationContentPosition: NavigationContentPosition,
|
||||
) {
|
||||
PermanentDrawerSheet(
|
||||
modifier = Modifier.sizeIn(minWidth = 200.dp, maxWidth = 300.dp),
|
||||
drawerContainerColor = MaterialTheme.colorScheme.inverseOnSurface
|
||||
) {
|
||||
Layout(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.inverseOnSurface)
|
||||
.padding(16.dp),
|
||||
content = {
|
||||
Row(
|
||||
modifier = Modifier.layoutId(LayoutType.HEADER),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(32.dp),
|
||||
painter = painterResource(R.drawable.app_icon),
|
||||
tint = Color.Unspecified,
|
||||
contentDescription = "app icon"
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
text = "Pupil",
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.layoutId(LayoutType.CONTENT)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
mainDestinations.forEach { destination ->
|
||||
NavigationDrawerItem(
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(destination.textId),
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = stringResource(destination.textId)
|
||||
)
|
||||
},
|
||||
selected = selectedDestination == destination.route,
|
||||
colors = NavigationDrawerItemDefaults.colors(
|
||||
unselectedContainerColor = Color.Transparent
|
||||
),
|
||||
onClick = { navigateToDestination(destination) }
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
measurePolicy = navigationMeasurePolicy(navigationContentPosition)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModalNavigationDrawerContent(
|
||||
selectedDestination: String?,
|
||||
navigationContentPosition: NavigationContentPosition,
|
||||
navigateToDestination: (MainDestination) -> Unit,
|
||||
onDrawerClicked: () -> Unit
|
||||
) {
|
||||
ModalDrawerSheet {
|
||||
Layout(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.inverseOnSurface)
|
||||
.padding(16.dp),
|
||||
content = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.layoutId(LayoutType.HEADER)
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(32.dp),
|
||||
painter = painterResource(R.drawable.app_icon),
|
||||
tint = Color.Unspecified,
|
||||
contentDescription = "app icon"
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
text = "Pupil",
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = onDrawerClicked) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Default.MenuOpen,
|
||||
contentDescription = stringResource(R.string.main_open_navigation_drawer)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.layoutId(LayoutType.CONTENT)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
mainDestinations.forEach { destination ->
|
||||
NavigationDrawerItem(
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(destination.textId),
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = stringResource(destination.textId)
|
||||
)
|
||||
},
|
||||
selected = selectedDestination == destination.route,
|
||||
colors = NavigationDrawerItemDefaults.colors(
|
||||
unselectedContainerColor = Color.Transparent
|
||||
),
|
||||
onClick = { navigateToDestination(destination) }
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
measurePolicy = navigationMeasurePolicy(navigationContentPosition)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainNavigationRail(
|
||||
selectedDestination: String?,
|
||||
navigateToDestination: (MainDestination) -> Unit,
|
||||
onDrawerClicked: () -> Unit
|
||||
) {
|
||||
NavigationRail (
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
containerColor = MaterialTheme.colorScheme.inverseOnSurface
|
||||
) {
|
||||
NavigationRailItem(
|
||||
selected = false,
|
||||
onClick = onDrawerClicked,
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(R.string.main_open_navigation_drawer)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
mainDestinations.forEach { destination ->
|
||||
NavigationRailItem(
|
||||
selected = selectedDestination == destination.route,
|
||||
onClick = { navigateToDestination(destination) },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = stringResource(destination.textId)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BottomNavigationBar(
|
||||
selectedDestination: String?,
|
||||
navigateToDestination: (MainDestination) -> Unit
|
||||
) {
|
||||
NavigationBar(modifier = Modifier.fillMaxWidth(), windowInsets = WindowInsets.ime.union(WindowInsets.navigationBars)) {
|
||||
mainDestinations.forEach { destination ->
|
||||
NavigationBarItem(
|
||||
selected = selectedDestination == destination.route,
|
||||
onClick = { navigateToDestination(destination) },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = stringResource(destination.textId)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun navigationMeasurePolicy(
|
||||
navigationContentPosition: NavigationContentPosition,
|
||||
): MeasurePolicy {
|
||||
return MeasurePolicy { measurables, constraints ->
|
||||
lateinit var headerMeasurable: Measurable
|
||||
lateinit var contentMeasurable: Measurable
|
||||
measurables.forEach {
|
||||
when (it.layoutId) {
|
||||
LayoutType.HEADER -> headerMeasurable = it
|
||||
LayoutType.CONTENT -> contentMeasurable = it
|
||||
else -> error("Unknown layoutId encountered!")
|
||||
}
|
||||
}
|
||||
|
||||
val headerPlaceable = headerMeasurable.measure(constraints)
|
||||
val contentPlaceable = contentMeasurable.measure(
|
||||
constraints.offset(vertical = -headerPlaceable.height)
|
||||
)
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
headerPlaceable.placeRelative(0, 0)
|
||||
|
||||
val nonContentVerticalSpace = constraints.maxHeight - contentPlaceable.height
|
||||
|
||||
val contentPlaceableY = when (navigationContentPosition) {
|
||||
NavigationContentPosition.TOP -> 0
|
||||
NavigationContentPosition.CENTER -> nonContentVerticalSpace / 2
|
||||
}.coerceAtLeast(headerPlaceable.height)
|
||||
|
||||
contentPlaceable.placeRelative(0, contentPlaceableY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class LayoutType {
|
||||
HEADER, CONTENT
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
enum class NavigationType {
|
||||
NAVIGATION_RAIL, PERMANENT_NAVIGATION_DRAWER, BOTTOM_NAVIGATION
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.NavigateBefore
|
||||
import androidx.compose.material.icons.automirrored.filled.NavigateNext
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import androidx.compose.ui.util.fastFirstOrNull
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.ui.theme.Blue300
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@Composable
|
||||
fun OverscrollPager(
|
||||
prevPage: Int?,
|
||||
nextPage: Int?,
|
||||
onPageTurn: (Int) -> Unit,
|
||||
prevPageTurnIndicatorOffset: Dp = 0.dp,
|
||||
nextPageTurnIndicatorOffset: Dp = 0.dp,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val pageTurnIndicatorHeight = LocalDensity.current.run { 64.dp.toPx() }
|
||||
|
||||
var overscroll: Float? by remember { mutableStateOf(null) }
|
||||
|
||||
var size: Size? by remember { mutableStateOf(null) }
|
||||
val circleRadius = (size?.width ?: 0f) / 2
|
||||
|
||||
val topCircleRadius by animateFloatAsState(if (overscroll?.let { it >= pageTurnIndicatorHeight } == true) circleRadius else 0f, label = "topCircleRadius")
|
||||
val bottomCircleRadius by animateFloatAsState(if (overscroll?.let { it <= -pageTurnIndicatorHeight } == true) circleRadius else 0f, label = "bottomCircleRadius")
|
||||
|
||||
val prevPageTurnIndicatorOffsetPx = LocalDensity.current.run { prevPageTurnIndicatorOffset.toPx() }
|
||||
val nextPageTurnIndicatorOffsetPx = LocalDensity.current.run { nextPageTurnIndicatorOffset.toPx() }
|
||||
|
||||
if (topCircleRadius != 0f || bottomCircleRadius != 0f)
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawCircle(
|
||||
Blue300,
|
||||
center = Offset(this.center.x, prevPageTurnIndicatorOffsetPx),
|
||||
radius = topCircleRadius
|
||||
)
|
||||
drawCircle(
|
||||
Blue300,
|
||||
center = Offset(this.center.x, this.size.height-nextPageTurnIndicatorOffsetPx),
|
||||
radius = bottomCircleRadius
|
||||
)
|
||||
}
|
||||
|
||||
val isOverscrollOverHeight = overscroll?.let { abs(it) >= pageTurnIndicatorHeight } == true
|
||||
LaunchedEffect(isOverscrollOverHeight) {
|
||||
if (isOverscrollOverHeight) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.onGloballyPositioned {
|
||||
size = it.size.toSize()
|
||||
}
|
||||
) {
|
||||
overscroll?.let { overscroll ->
|
||||
if (overscroll > 0f && prevPage != null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(0.dp, prevPageTurnIndicatorOffset),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.NavigateBefore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Text(stringResource(R.string.move_to_page, prevPage))
|
||||
}
|
||||
}
|
||||
|
||||
if (overscroll < 0f && nextPage != null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.offset(0.dp, -nextPageTurnIndicatorOffset),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(R.string.move_to_page, nextPage))
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.NavigateNext,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
0.dp,
|
||||
overscroll
|
||||
?.coerceIn(-pageTurnIndicatorHeight, pageTurnIndicatorHeight)
|
||||
?.let { overscroll -> LocalDensity.current.run { overscroll.toDp() } }
|
||||
?: 0.dp)
|
||||
.nestedScroll(object : NestedScrollConnection {
|
||||
override fun onPreScroll(
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
val overscrollSnapshot = overscroll
|
||||
|
||||
return if (overscrollSnapshot == null || overscrollSnapshot == 0f) {
|
||||
Offset.Zero
|
||||
} else {
|
||||
val newOverscroll =
|
||||
if (overscrollSnapshot > 0f && available.y < 0f)
|
||||
max(overscrollSnapshot + available.y, 0f)
|
||||
else if (overscrollSnapshot < 0f && available.y > 0f)
|
||||
min(overscrollSnapshot + available.y, 0f)
|
||||
else
|
||||
overscrollSnapshot
|
||||
|
||||
Offset(0f, newOverscroll - overscrollSnapshot).also {
|
||||
overscroll = newOverscroll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
if (
|
||||
available.y == 0f ||
|
||||
prevPage == null && available.y > 0f ||
|
||||
nextPage == null && available.y < 0f
|
||||
) return Offset.Zero
|
||||
|
||||
return overscroll?.let {
|
||||
overscroll = it + available.y
|
||||
Offset(0f, available.y)
|
||||
} ?: Offset.Zero
|
||||
}
|
||||
})
|
||||
.pointerInput(prevPage, nextPage) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
var pointer = down.id
|
||||
overscroll = 0f
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val dragEvent =
|
||||
event.changes.fastFirstOrNull { it.id == pointer }!!
|
||||
|
||||
if (dragEvent.changedToUpIgnoreConsumed()) {
|
||||
val otherDown = event.changes.fastFirstOrNull { it.pressed }
|
||||
if (otherDown == null) {
|
||||
if (dragEvent.positionChange() != Offset.Zero) dragEvent.consume()
|
||||
overscroll?.let {
|
||||
if (abs(it) > pageTurnIndicatorHeight) {
|
||||
if (it > 0 && prevPage != null) onPageTurn(prevPage)
|
||||
if (it < 0 && nextPage != null) onPageTurn(nextPage)
|
||||
}
|
||||
}
|
||||
overscroll = null
|
||||
break
|
||||
} else
|
||||
pointer = otherDown.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
793
app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt
Normal file
793
app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt
Normal file
@@ -0,0 +1,793 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AddCircleOutline
|
||||
import androidx.compose.material.icons.filled.RemoveCircleOutline
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardColors
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.networking.HitomiHttpClient
|
||||
import xyz.quaver.pupil.networking.SearchQuery
|
||||
import xyz.quaver.pupil.networking.Suggestion
|
||||
import xyz.quaver.pupil.networking.validNamespace
|
||||
import xyz.quaver.pupil.ui.theme.Blue300
|
||||
import xyz.quaver.pupil.ui.theme.Blue600
|
||||
import xyz.quaver.pupil.ui.theme.Gray300
|
||||
import xyz.quaver.pupil.ui.theme.Pink600
|
||||
import xyz.quaver.pupil.ui.theme.Red300
|
||||
import xyz.quaver.pupil.ui.theme.Yellow400
|
||||
|
||||
private fun SearchQuery.toEditableStateInternal(): EditableSearchQueryState = when (this) {
|
||||
is SearchQuery.Tag -> EditableSearchQueryState.Tag(namespace, tag)
|
||||
is SearchQuery.And -> EditableSearchQueryState.And(queries.map { it.toEditableStateInternal() })
|
||||
is SearchQuery.Or -> EditableSearchQueryState.Or(queries.map { it.toEditableStateInternal() })
|
||||
is SearchQuery.Not -> EditableSearchQueryState.Not(query.toEditableStateInternal())
|
||||
}
|
||||
|
||||
fun SearchQuery?.toEditableState(): EditableSearchQueryState.Root =
|
||||
EditableSearchQueryState.Root(this?.toEditableStateInternal())
|
||||
|
||||
private fun EditableSearchQueryState.Tag.toSearchQueryInternal(): SearchQuery.Tag? =
|
||||
if (namespace.value != null || tag.value.isNotBlank()) SearchQuery.Tag(
|
||||
namespace.value,
|
||||
tag.value.lowercase().trim()
|
||||
) else null
|
||||
|
||||
private fun EditableSearchQueryState.And.toSearchQueryInternal(): SearchQuery.And? =
|
||||
queries.mapNotNull { it.toSearchQueryInternal() }
|
||||
.let { if (it.isNotEmpty()) SearchQuery.And(it) else null }
|
||||
|
||||
private fun EditableSearchQueryState.Or.toSearchQueryInternal(): SearchQuery.Or? =
|
||||
queries.mapNotNull { it.toSearchQueryInternal() }
|
||||
.let { if (it.isNotEmpty()) SearchQuery.Or(it) else null }
|
||||
|
||||
private fun EditableSearchQueryState.Not.toSearchQueryInternal(): SearchQuery.Not? =
|
||||
query.value?.toSearchQueryInternal()?.let { SearchQuery.Not(it) }
|
||||
|
||||
private fun EditableSearchQueryState.toSearchQueryInternal(): SearchQuery? = when (this) {
|
||||
is EditableSearchQueryState.Tag -> this.toSearchQueryInternal()
|
||||
is EditableSearchQueryState.And -> this.toSearchQueryInternal()
|
||||
is EditableSearchQueryState.Or -> this.toSearchQueryInternal()
|
||||
is EditableSearchQueryState.Not -> this.toSearchQueryInternal()
|
||||
}
|
||||
|
||||
fun EditableSearchQueryState.Root.toSearchQuery(): SearchQuery? =
|
||||
query.value?.toSearchQueryInternal()
|
||||
|
||||
fun coalesceTags(
|
||||
oldTag: EditableSearchQueryState.Tag?,
|
||||
newTag: EditableSearchQueryState?,
|
||||
): EditableSearchQueryState? = if (oldTag != null) {
|
||||
when (newTag) {
|
||||
is EditableSearchQueryState.Tag,
|
||||
is EditableSearchQueryState.Not,
|
||||
-> EditableSearchQueryState.And(listOf(oldTag, newTag))
|
||||
|
||||
is EditableSearchQueryState.And -> newTag.apply { queries.add(oldTag) }
|
||||
is EditableSearchQueryState.Or -> newTag.apply { queries.add(oldTag) }
|
||||
null -> oldTag
|
||||
}
|
||||
} else newTag
|
||||
|
||||
sealed interface EditableSearchQueryState {
|
||||
class Tag(
|
||||
namespace: String? = null,
|
||||
tag: String = "",
|
||||
expanded: Boolean = false,
|
||||
) : EditableSearchQueryState {
|
||||
val namespace = mutableStateOf(namespace)
|
||||
val tag = mutableStateOf(tag)
|
||||
val expanded = mutableStateOf(expanded)
|
||||
}
|
||||
|
||||
class And(
|
||||
queries: List<EditableSearchQueryState> = emptyList(),
|
||||
) : EditableSearchQueryState {
|
||||
val queries = queries.toMutableStateList()
|
||||
}
|
||||
|
||||
class Or(
|
||||
queries: List<EditableSearchQueryState> = emptyList(),
|
||||
) : EditableSearchQueryState {
|
||||
val queries = queries.toMutableStateList()
|
||||
}
|
||||
|
||||
class Not(
|
||||
query: EditableSearchQueryState? = null,
|
||||
) : EditableSearchQueryState {
|
||||
val query = mutableStateOf(query)
|
||||
}
|
||||
|
||||
class Root(
|
||||
query: EditableSearchQueryState? = null,
|
||||
) {
|
||||
val query = mutableStateOf(query)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TagSuggestionList(
|
||||
state: EditableSearchQueryState.Tag,
|
||||
) {
|
||||
var suggestionList: List<Suggestion>? by remember { mutableStateOf(null) }
|
||||
|
||||
var namespace by state.namespace
|
||||
var tag by state.tag
|
||||
var expanded by state.expanded
|
||||
|
||||
LaunchedEffect(namespace, tag) {
|
||||
suggestionList = null
|
||||
|
||||
val searchQuery = state.toSearchQueryInternal()
|
||||
|
||||
suggestionList = if (searchQuery != null) {
|
||||
HitomiHttpClient.getSuggestionsForQuery(searchQuery)
|
||||
.getOrDefault(emptyList())
|
||||
.filterNot { it.tag == SearchQuery.Tag(namespace, tag) }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
val suggestionListSnapshot = suggestionList
|
||||
if (suggestionListSnapshot == null) {
|
||||
Row(
|
||||
Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(Modifier.size(24.dp))
|
||||
Text("Loading")
|
||||
}
|
||||
} else if (suggestionListSnapshot.isNotEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
suggestionListSnapshot.forEach { suggestion ->
|
||||
TagChip(
|
||||
tag = suggestion.tag,
|
||||
onClick = {
|
||||
namespace = it.namespace
|
||||
tag = it.tag
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditableTagChip(
|
||||
state: EditableSearchQueryState.Tag,
|
||||
isFavorite: Boolean = false,
|
||||
autoFocus: Boolean = true,
|
||||
requestScrollTo: (Float) -> Unit,
|
||||
leftIcon: @Composable RowScope.(SearchQuery.Tag) -> Unit = { tag -> TagChipIcon(tag) },
|
||||
rightIcon: @Composable RowScope.(SearchQuery.Tag) -> Unit = { _ -> Spacer(Modifier.width(16.dp)) },
|
||||
content: @Composable RowScope.(SearchQuery.Tag) -> Unit = { tag ->
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.weight(1f, fill = false)
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
text = tag.tag.ifBlank { stringResource(R.string.search_bar_edit_tag) }
|
||||
)
|
||||
},
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var namespace by state.namespace
|
||||
var tag by state.tag
|
||||
var expanded by state.expanded
|
||||
var wasFocused by remember { mutableStateOf(false) }
|
||||
|
||||
var positionY by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
LaunchedEffect(expanded) {
|
||||
if (!expanded) {
|
||||
wasFocused = false
|
||||
}
|
||||
}
|
||||
|
||||
val surfaceColor by animateColorAsState(
|
||||
when {
|
||||
expanded -> MaterialTheme.colorScheme.surface
|
||||
isFavorite -> Yellow400
|
||||
namespace == "male" -> Blue600
|
||||
namespace == "female" -> Pink600
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
}, label = "tag surface color"
|
||||
)
|
||||
|
||||
val contentColor by animateColorAsState(
|
||||
when {
|
||||
expanded -> Color.White
|
||||
isFavorite -> Color.White
|
||||
namespace == "male" -> Color.White
|
||||
namespace == "female" -> Color.White
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}, label = "tag content color"
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.onGloballyPositioned {
|
||||
positionY = it.positionInRoot().y
|
||||
},
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = surfaceColor,
|
||||
shadowElevation = 4.dp
|
||||
) {
|
||||
AnimatedContent(targetState = expanded, label = "open tag editor") { targetExpanded ->
|
||||
if (!targetExpanded) {
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides contentColor,
|
||||
LocalTextStyle provides MaterialTheme.typography.bodyMedium
|
||||
) {
|
||||
val queryTag = SearchQuery.Tag(namespace, tag)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(32.dp)
|
||||
.clickable { expanded = true },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
leftIcon(queryTag)
|
||||
content(queryTag)
|
||||
rightIcon(queryTag)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp, bottom = 8.dp, end = 8.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
expanded = false
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "close tag editor"
|
||||
)
|
||||
}
|
||||
|
||||
var selection by remember(tag) { mutableStateOf(TextRange(tag.length)) }
|
||||
var composition by remember { mutableStateOf<TextRange?>(null) }
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
val textFieldValue = remember(tag, selection, composition) {
|
||||
TextFieldValue(tag, selection, composition)
|
||||
}
|
||||
|
||||
LaunchedEffect(expanded) {
|
||||
if (autoFocus && expanded) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = textFieldValue,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrectEnabled = false,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
leadingIcon = {
|
||||
TagChipIcon(SearchQuery.Tag(namespace, tag))
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onKeyEvent { event ->
|
||||
if (event.key == Key.Backspace && tag.isEmpty()) {
|
||||
val newTag = namespace?.dropLast(1) ?: ""
|
||||
namespace = null
|
||||
tag = newTag
|
||||
selection = TextRange(newTag.length)
|
||||
composition = null
|
||||
true
|
||||
} else false
|
||||
}
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { event ->
|
||||
if (event.isFocused) {
|
||||
wasFocused = true
|
||||
coroutineScope.launch {
|
||||
delay(300)
|
||||
requestScrollTo(positionY)
|
||||
}
|
||||
} else if (wasFocused) {
|
||||
expanded = false
|
||||
}
|
||||
},
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
expanded = false
|
||||
}
|
||||
),
|
||||
onValueChange = { newTextValue ->
|
||||
val newTag = newTextValue.text
|
||||
val possibleNamespace = newTag.dropLast(1).lowercase().trim()
|
||||
tag =
|
||||
if (namespace == null && newTag.endsWith(':') && possibleNamespace in validNamespace) {
|
||||
namespace = possibleNamespace
|
||||
""
|
||||
} else newTag
|
||||
selection = newTextValue.selection
|
||||
composition = newTextValue.composition
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
TagSuggestionList(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewQueryChip(
|
||||
currentQuery: EditableSearchQueryState?,
|
||||
onNewQuery: (EditableSearchQueryState) -> Unit,
|
||||
) {
|
||||
var opened by remember { mutableStateOf(false) }
|
||||
|
||||
@Composable
|
||||
fun NewQueryRow(
|
||||
modifier: Modifier = Modifier,
|
||||
icon: ImageVector = Icons.Default.AddCircleOutline,
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.height(32.dp)
|
||||
.clickable(onClick = onClick),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.size(16.dp),
|
||||
imageVector = icon,
|
||||
contentDescription = text
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Surface(shape = RoundedCornerShape(16.dp), shadowElevation = 4.dp) {
|
||||
AnimatedContent(targetState = opened, label = "add new query") { targetOpened ->
|
||||
if (targetOpened) {
|
||||
Column {
|
||||
NewQueryRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
icon = Icons.Default.RemoveCircleOutline,
|
||||
text = stringResource(android.R.string.cancel)
|
||||
) {
|
||||
opened = false
|
||||
}
|
||||
HorizontalDivider()
|
||||
if (currentQuery != null && currentQuery !is EditableSearchQueryState.Tag && currentQuery !is EditableSearchQueryState.And) {
|
||||
NewQueryRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.search_add_query_item_tag)
|
||||
) {
|
||||
opened = false
|
||||
onNewQuery(EditableSearchQueryState.Tag(expanded = true))
|
||||
}
|
||||
}
|
||||
if (currentQuery !is EditableSearchQueryState.And) {
|
||||
HorizontalDivider()
|
||||
NewQueryRow(modifier = Modifier.fillMaxWidth(), text = "AND") {
|
||||
opened = false
|
||||
onNewQuery(EditableSearchQueryState.And())
|
||||
}
|
||||
}
|
||||
if (currentQuery !is EditableSearchQueryState.Or) {
|
||||
HorizontalDivider()
|
||||
NewQueryRow(modifier = Modifier.fillMaxWidth(), text = "OR") {
|
||||
opened = false
|
||||
onNewQuery(EditableSearchQueryState.Or())
|
||||
}
|
||||
}
|
||||
if (currentQuery !is EditableSearchQueryState.Not || currentQuery.query.value != null) {
|
||||
HorizontalDivider()
|
||||
NewQueryRow(modifier = Modifier.fillMaxWidth(), text = "NOT") {
|
||||
opened = false
|
||||
onNewQuery(EditableSearchQueryState.Not())
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
NewQueryRow(text = stringResource(R.string.search_add_query_item)) {
|
||||
opened = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QueryEditorQueryView(
|
||||
state: EditableSearchQueryState,
|
||||
onQueryRemove: (EditableSearchQueryState) -> Unit,
|
||||
requestScrollTo: (Float) -> Unit,
|
||||
requestScrollBy: (Float) -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
is EditableSearchQueryState.Tag -> {
|
||||
EditableTagChip(
|
||||
state,
|
||||
requestScrollTo = requestScrollTo,
|
||||
rightIcon = {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.size(16.dp)
|
||||
.clickable {
|
||||
onQueryRemove(state)
|
||||
},
|
||||
imageVector = Icons.Default.RemoveCircleOutline,
|
||||
contentDescription = stringResource(R.string.search_remove_query_item_description)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is EditableSearchQueryState.Or -> {
|
||||
Card(
|
||||
colors = CardColors(
|
||||
containerColor = Blue300,
|
||||
contentColor = Color.Black,
|
||||
disabledContainerColor = Blue300,
|
||||
disabledContentColor = Color.Black
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
"OR",
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clickable { onQueryRemove(state) },
|
||||
imageVector = Icons.Default.RemoveCircleOutline,
|
||||
contentDescription = stringResource(R.string.search_remove_query_item_description)
|
||||
)
|
||||
}
|
||||
state.queries.forEachIndexed { index, subQueryState ->
|
||||
if (index != 0) {
|
||||
Text("+", modifier = Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
QueryEditorQueryView(
|
||||
subQueryState,
|
||||
onQueryRemove = { state.queries.remove(it) },
|
||||
requestScrollTo = requestScrollTo,
|
||||
requestScrollBy = requestScrollBy
|
||||
)
|
||||
}
|
||||
NewQueryChip(state) { newQueryState ->
|
||||
state.queries.add(newQueryState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is EditableSearchQueryState.And -> {
|
||||
Card(
|
||||
colors = CardColors(
|
||||
containerColor = Gray300,
|
||||
contentColor = Color.Black,
|
||||
disabledContainerColor = Gray300,
|
||||
disabledContentColor = Color.Black
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
val newSearchQuery = remember { EditableSearchQueryState.Tag() }
|
||||
|
||||
var newQueryNamespace by newSearchQuery.namespace
|
||||
var newQueryTag by newSearchQuery.tag
|
||||
var newQueryExpanded by newSearchQuery.expanded
|
||||
|
||||
val offset = with(LocalDensity.current) { 40.dp.toPx() }
|
||||
|
||||
LaunchedEffect(newQueryExpanded) {
|
||||
if (!newQueryExpanded && (newQueryNamespace != null || newQueryTag.isNotBlank())) {
|
||||
state.queries.add(
|
||||
EditableSearchQueryState.Tag(
|
||||
newQueryNamespace,
|
||||
newQueryTag
|
||||
)
|
||||
)
|
||||
newQueryNamespace = null
|
||||
newQueryTag = ""
|
||||
newQueryExpanded = true
|
||||
requestScrollBy(offset)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
"AND",
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clickable { onQueryRemove(state) },
|
||||
imageVector = Icons.Default.RemoveCircleOutline,
|
||||
contentDescription = stringResource(R.string.search_remove_query_item_description)
|
||||
)
|
||||
}
|
||||
state.queries.forEach { subQuery ->
|
||||
QueryEditorQueryView(
|
||||
subQuery,
|
||||
onQueryRemove = { state.queries.remove(it) },
|
||||
requestScrollTo = requestScrollTo,
|
||||
requestScrollBy = requestScrollBy
|
||||
)
|
||||
}
|
||||
EditableTagChip(
|
||||
newSearchQuery,
|
||||
requestScrollTo = requestScrollTo,
|
||||
)
|
||||
NewQueryChip(state) { newQueryState ->
|
||||
state.queries.add(newQueryState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is EditableSearchQueryState.Not -> {
|
||||
var subQueryState by state.query
|
||||
|
||||
Card(
|
||||
colors = CardColors(
|
||||
containerColor = Red300,
|
||||
contentColor = Color.Black,
|
||||
disabledContainerColor = Red300,
|
||||
disabledContentColor = Color.Black
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
"-",
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clickable { onQueryRemove(state) },
|
||||
imageVector = Icons.Default.RemoveCircleOutline,
|
||||
contentDescription = stringResource(R.string.search_remove_query_item_description)
|
||||
)
|
||||
}
|
||||
val subQueryStateSnapshot = subQueryState
|
||||
if (subQueryStateSnapshot != null) {
|
||||
QueryEditorQueryView(
|
||||
subQueryStateSnapshot,
|
||||
onQueryRemove = { subQueryState = null },
|
||||
requestScrollTo = requestScrollTo,
|
||||
requestScrollBy = requestScrollBy,
|
||||
)
|
||||
}
|
||||
|
||||
if (subQueryStateSnapshot == null) {
|
||||
NewQueryChip(state) { newQueryState ->
|
||||
subQueryState = newQueryState
|
||||
}
|
||||
}
|
||||
|
||||
if (subQueryStateSnapshot is EditableSearchQueryState.Tag) {
|
||||
NewQueryChip(state) { newQueryState ->
|
||||
subQueryState = coalesceTags(subQueryStateSnapshot, newQueryState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QueryEditor(
|
||||
state: EditableSearchQueryState.Root,
|
||||
) {
|
||||
var rootQuery by state.query
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
var topY by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
val scrollOffset = with(LocalDensity.current) { 16.dp.toPx() }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.onGloballyPositioned {
|
||||
topY = it.positionInRoot().y
|
||||
}
|
||||
.verticalScroll(scrollState)
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
val rootQuerySnapshot = rootQuery
|
||||
|
||||
val requestScrollTo: (Float) -> Unit = { target ->
|
||||
val topYSnapshot = topY
|
||||
|
||||
coroutineScope.launch {
|
||||
scrollState.animateScrollBy(
|
||||
target - topYSnapshot - scrollOffset,
|
||||
spring(stiffness = Spring.StiffnessLow)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val requestScrollBy: (Float) -> Unit = { value ->
|
||||
coroutineScope.launch {
|
||||
scrollState.animateScrollBy(value)
|
||||
}
|
||||
}
|
||||
|
||||
if (rootQuerySnapshot != null) {
|
||||
QueryEditorQueryView(
|
||||
state = rootQuerySnapshot,
|
||||
onQueryRemove = { rootQuery = null },
|
||||
requestScrollTo = requestScrollTo,
|
||||
requestScrollBy = requestScrollBy
|
||||
)
|
||||
}
|
||||
|
||||
if (rootQuerySnapshot is EditableSearchQueryState.Tag?) {
|
||||
val newSearchQuery = remember { EditableSearchQueryState.Tag(expanded = true) }
|
||||
|
||||
var newQueryNamespace by newSearchQuery.namespace
|
||||
var newQueryTag by newSearchQuery.tag
|
||||
var newQueryExpanded by newSearchQuery.expanded
|
||||
|
||||
val offset = with(LocalDensity.current) { 40.dp.toPx() }
|
||||
|
||||
LaunchedEffect(newQueryExpanded) {
|
||||
if (!newQueryExpanded && (newQueryNamespace != null || newQueryTag.isNotBlank())) {
|
||||
rootQuery = if (rootQuerySnapshot == null) {
|
||||
EditableSearchQueryState.Tag(newQueryNamespace, newQueryTag)
|
||||
} else {
|
||||
EditableSearchQueryState.And(
|
||||
listOf(
|
||||
rootQuerySnapshot,
|
||||
EditableSearchQueryState.Tag(newQueryNamespace, newQueryTag)
|
||||
)
|
||||
)
|
||||
}
|
||||
newQueryNamespace = null
|
||||
newQueryTag = ""
|
||||
newQueryExpanded = true
|
||||
requestScrollBy(offset)
|
||||
}
|
||||
}
|
||||
|
||||
EditableTagChip(
|
||||
newSearchQuery,
|
||||
requestScrollTo = requestScrollTo
|
||||
)
|
||||
NewQueryChip(rootQuerySnapshot) { newState ->
|
||||
rootQuery = coalesceTags(rootQuerySnapshot, newState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
703
app/src/main/java/xyz/quaver/pupil/ui/composable/SearchScreen.kt
Normal file
703
app/src/main/java/xyz/quaver/pupil/ui/composable/SearchScreen.kt
Normal file
@@ -0,0 +1,703 @@
|
||||
package xyz.quaver.pupil.ui.composable
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.AnimationState
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.animateTo
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.absoluteOffset
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
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.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsTopHeight
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Label
|
||||
import androidx.compose.material.icons.filled.Book
|
||||
import androidx.compose.material.icons.filled.Brush
|
||||
import androidx.compose.material.icons.filled.Face
|
||||
import androidx.compose.material.icons.filled.Female
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Group
|
||||
import androidx.compose.material.icons.filled.Male
|
||||
import androidx.compose.material.icons.filled.Translate
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
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.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.window.layout.DisplayFeature
|
||||
import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy
|
||||
import com.google.accompanist.adaptive.TwoPane
|
||||
import xyz.quaver.pupil.R
|
||||
import xyz.quaver.pupil.networking.GalleryInfo
|
||||
import xyz.quaver.pupil.networking.HitomiHttpClient
|
||||
import xyz.quaver.pupil.networking.SearchQuery
|
||||
import xyz.quaver.pupil.ui.theme.Blue600
|
||||
import xyz.quaver.pupil.ui.theme.Pink600
|
||||
import xyz.quaver.pupil.ui.theme.Yellow400
|
||||
import xyz.quaver.pupil.ui.viewmodel.SearchState
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val iconMap = mapOf(
|
||||
"female" to Icons.Default.Female,
|
||||
"male" to Icons.Default.Male,
|
||||
"artist" to Icons.Default.Brush,
|
||||
"group" to Icons.Default.Group,
|
||||
"character" to Icons.Default.Face,
|
||||
"series" to Icons.Default.Book,
|
||||
"type" to Icons.Default.Folder,
|
||||
"language" to Icons.Default.Translate,
|
||||
"tag" to Icons.AutoMirrored.Filled.Label,
|
||||
)
|
||||
|
||||
val languageIconMap = mapOf(
|
||||
"indonesian" to R.drawable.language_indonesian,
|
||||
"javanese" to R.drawable.language_javanese,
|
||||
"catalan" to R.drawable.language_catalan,
|
||||
"cebuano" to R.drawable.language_philippines,
|
||||
"czech" to R.drawable.language_czech,
|
||||
"danish" to R.drawable.language_danish,
|
||||
"german" to R.drawable.language_german,
|
||||
"estonian" to R.drawable.language_estonian,
|
||||
"english" to R.drawable.language_english,
|
||||
"spanish" to R.drawable.language_spanish,
|
||||
"french" to R.drawable.language_french,
|
||||
"italian" to R.drawable.language_italian,
|
||||
"latin" to R.drawable.language_latin,
|
||||
"hungarian" to R.drawable.language_hungarian,
|
||||
"dutch" to R.drawable.language_dutch,
|
||||
"norwegian" to R.drawable.language_norwegian,
|
||||
"polish" to R.drawable.language_polish,
|
||||
"portuguese" to R.drawable.language_portuguese,
|
||||
"romanian" to R.drawable.language_romanian,
|
||||
"albanian" to R.drawable.language_albanian,
|
||||
"slovak" to R.drawable.language_slovak,
|
||||
"finnish" to R.drawable.language_finnish,
|
||||
"swedish" to R.drawable.language_swedish,
|
||||
"tagalog" to R.drawable.language_philippines,
|
||||
"vietnamese" to R.drawable.language_vietnamese,
|
||||
"turkish" to R.drawable.language_turkish,
|
||||
"greek" to R.drawable.language_greek,
|
||||
"mongolian" to R.drawable.language_mongolian,
|
||||
"russian" to R.drawable.language_russian,
|
||||
"ukrainian" to R.drawable.language_ukrainian,
|
||||
"hebrew" to R.drawable.language_hebrew,
|
||||
"persian" to R.drawable.language_persian,
|
||||
"thai" to R.drawable.language_thai,
|
||||
"korean" to R.drawable.language_korean,
|
||||
"chinese" to R.drawable.language_chinese,
|
||||
"japanese" to R.drawable.language_japanese,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TagChipIcon(tag: SearchQuery.Tag) {
|
||||
val icon = iconMap[tag.namespace]
|
||||
|
||||
if (icon != null) {
|
||||
if (tag.namespace == "language" && languageIconMap.contains(tag.tag)) {
|
||||
Icon(
|
||||
painter = painterResource(languageIconMap[tag.tag]!!),
|
||||
contentDescription = "icon",
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.size(24.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = "icon",
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.width(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TagChip(
|
||||
tag: SearchQuery.Tag,
|
||||
isFavorite: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
onClick: (SearchQuery.Tag) -> Unit = { },
|
||||
leftIcon: @Composable (SearchQuery.Tag) -> Unit = { TagChipIcon(it) },
|
||||
rightIcon: @Composable (SearchQuery.Tag) -> Unit = { Spacer(Modifier.width(16.dp)) },
|
||||
content: @Composable RowScope.(SearchQuery.Tag) -> Unit = {
|
||||
Text(
|
||||
it.tag,
|
||||
modifier = Modifier
|
||||
.weight(1f, fill = false)
|
||||
.horizontalScroll(rememberScrollState())
|
||||
)
|
||||
},
|
||||
) {
|
||||
val surfaceColor = if (isFavorite) Yellow400 else when (tag.namespace) {
|
||||
"male" -> Blue600
|
||||
"female" -> Pink600
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
}
|
||||
|
||||
val contentColor =
|
||||
if (surfaceColor == MaterialTheme.colorScheme.surface)
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
else
|
||||
Color.White
|
||||
|
||||
val inner = @Composable {
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides contentColor,
|
||||
LocalTextStyle provides MaterialTheme.typography.bodyMedium
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
leftIcon(tag)
|
||||
content(tag)
|
||||
rightIcon(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val modifier = Modifier.height(32.dp)
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
|
||||
if (enabled)
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = shape,
|
||||
color = surfaceColor,
|
||||
onClick = { onClick(tag) },
|
||||
content = inner,
|
||||
shadowElevation = 4.dp
|
||||
)
|
||||
else
|
||||
Surface(
|
||||
modifier,
|
||||
shape = shape,
|
||||
color = surfaceColor,
|
||||
content = inner,
|
||||
shadowElevation = 4.dp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QueryView(
|
||||
query: SearchQuery?,
|
||||
topLevel: Boolean = true,
|
||||
) {
|
||||
val modifier = if (topLevel) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.border(
|
||||
width = 0.5.dp,
|
||||
color = LocalContentColor.current,
|
||||
shape = CardDefaults.shape
|
||||
)
|
||||
}
|
||||
|
||||
when (query) {
|
||||
null -> {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.height(60.dp)
|
||||
.wrapContentHeight()
|
||||
.padding(horizontal = 16.dp),
|
||||
text = stringResource(id = R.string.search_hint),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
is SearchQuery.Tag -> {
|
||||
TagChip(
|
||||
query,
|
||||
enabled = false
|
||||
)
|
||||
}
|
||||
|
||||
is SearchQuery.Or -> {
|
||||
Row(
|
||||
modifier = modifier.padding(vertical = 2.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
query.queries.forEachIndexed { index, subQuery ->
|
||||
if (index != 0) {
|
||||
Text("+")
|
||||
}
|
||||
QueryView(subQuery, topLevel = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is SearchQuery.And -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
query.queries.forEach { subQuery ->
|
||||
QueryView(subQuery, topLevel = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is SearchQuery.Not -> {
|
||||
Row(
|
||||
modifier = modifier.padding(vertical = 2.dp, horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("-")
|
||||
QueryView(query.query, topLevel = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchBar(
|
||||
contentType: ContentType,
|
||||
query: SearchQuery?,
|
||||
onQueryChange: (SearchQuery?) -> Unit,
|
||||
onSearchBarPositioned: (Int) -> Unit,
|
||||
topOffset: Int,
|
||||
onTopOffsetChange: (Int) -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val scrimAlpha: Float by animateFloatAsState(
|
||||
if (focused && contentType == ContentType.SINGLE_PANE) 0.3f else 0f,
|
||||
label = "scrim alpha"
|
||||
)
|
||||
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
val state = remember(query) { query.toEditableState() }
|
||||
|
||||
LaunchedEffect(focused) {
|
||||
if (!focused) {
|
||||
onQueryChange(state.toSearchQuery())
|
||||
} else {
|
||||
AnimationState(Int.VectorConverter, topOffset).animateTo(0) { onTopOffsetChange(value) }
|
||||
}
|
||||
}
|
||||
|
||||
if (focused) {
|
||||
BackHandler {
|
||||
focused = false
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null
|
||||
) {
|
||||
focused = false
|
||||
}
|
||||
) {
|
||||
val height: Dp by animateDpAsState(
|
||||
if (focused) maxHeight else 60.dp,
|
||||
label = "searchbar height"
|
||||
)
|
||||
val cardShape = RoundedCornerShape(30.dp)
|
||||
|
||||
content()
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = scrimAlpha))
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.safeDrawingPadding()
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null
|
||||
) {
|
||||
focused = true
|
||||
}
|
||||
.onGloballyPositioned {
|
||||
onSearchBarPositioned(it.positionInRoot().y.roundToInt() + it.size.height)
|
||||
}
|
||||
.absoluteOffset { IntOffset(0, topOffset) },
|
||||
shape = cardShape,
|
||||
elevation = CardDefaults.cardElevation(6.dp)
|
||||
) {
|
||||
Box {
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
!focused,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.heightIn(min = 60.dp)
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(Modifier.size(8.dp))
|
||||
QueryView(query)
|
||||
Box(Modifier.size(8.dp))
|
||||
}
|
||||
}
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
focused,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 8.dp, start = 8.dp, end = 8.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
focused = false
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "close search bar"
|
||||
)
|
||||
}
|
||||
QueryEditor(state = state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GalleryList(
|
||||
contentType: ContentType,
|
||||
galleries: List<GalleryInfo>,
|
||||
query: SearchQuery?,
|
||||
currentPage: Int,
|
||||
maxPage: Int,
|
||||
loading: Boolean = false,
|
||||
error: Boolean = false,
|
||||
onPageChange: (Int) -> Unit,
|
||||
onQueryChange: (SearchQuery?) -> Unit = {},
|
||||
openGalleryDetails: (GalleryInfo) -> Unit,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
var topOffset by remember { mutableIntStateOf(0) }
|
||||
var searchBarPosition by remember { mutableIntStateOf(0) }
|
||||
|
||||
val listModifier = Modifier.nestedScroll(object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
topOffset = (topOffset + available.y.roundToInt()).coerceIn(-searchBarPosition, 0)
|
||||
return Offset.Zero
|
||||
}
|
||||
})
|
||||
|
||||
LaunchedEffect(galleries) {
|
||||
listState.animateScrollToItem(0)
|
||||
topOffset = 0
|
||||
}
|
||||
|
||||
SearchBar(
|
||||
contentType = contentType,
|
||||
query = query,
|
||||
onQueryChange = onQueryChange,
|
||||
onSearchBarPositioned = { searchBarPosition = it },
|
||||
topOffset = topOffset,
|
||||
onTopOffsetChange = { topOffset = it },
|
||||
) {
|
||||
AnimatedVisibility(loading, enter = fadeIn(), exit = fadeOut()) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(error, enter = fadeIn(), exit = fadeOut()) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
"(´∇`)",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
|
||||
)
|
||||
Text("No sources found!\nLet's go download one.", textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(!loading && !error, enter = fadeIn(), exit = fadeOut()) {
|
||||
OverscrollPager(
|
||||
prevPage = if (currentPage != 0) currentPage else null,
|
||||
nextPage = if (currentPage < maxPage) currentPage + 2 else null,
|
||||
onPageTurn = { onPageChange(it - 1) }
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = listModifier,
|
||||
contentPadding = WindowInsets.systemBars.asPaddingValues()
|
||||
.let { systemBarPaddingValues ->
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
PaddingValues(
|
||||
top = systemBarPaddingValues.calculateTopPadding() + 96.dp,
|
||||
bottom = systemBarPaddingValues.calculateBottomPadding(),
|
||||
start = systemBarPaddingValues.calculateStartPadding(layoutDirection),
|
||||
end = systemBarPaddingValues.calculateEndPadding(layoutDirection),
|
||||
)
|
||||
},
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
state = listState
|
||||
) {
|
||||
items(galleries, key = { it.id }) { galleryInfo ->
|
||||
DetailedGalleryInfo(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.clickable { openGalleryDetails(galleryInfo) },
|
||||
galleryInfo = galleryInfo
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DetailScreen(
|
||||
galleryInfo: GalleryInfo,
|
||||
closeGalleryDetails: () -> Unit = { },
|
||||
openGallery: (GalleryInfo) -> Unit = { },
|
||||
) {
|
||||
var thumbnailUrl by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(galleryInfo) {
|
||||
thumbnailUrl = galleryInfo.files.firstOrNull()?.let {
|
||||
HitomiHttpClient.getImageURL(it, true).firstOrNull()
|
||||
} ?: ""
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.systemBars))
|
||||
IconButton(onClick = closeGalleryDetails) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close Detail")
|
||||
}
|
||||
|
||||
DetailedGalleryInfoHeader(galleryInfo, thumbnailUrl)
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
FilledTonalButton(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 4.dp),
|
||||
onClick = { /*TODO*/ }
|
||||
) {
|
||||
Text(stringResource(R.string.download))
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 4.dp),
|
||||
onClick = { openGallery(galleryInfo) }
|
||||
) {
|
||||
Text("Open")
|
||||
}
|
||||
}
|
||||
|
||||
GalleryTypeIndicator(galleryInfo.type)
|
||||
|
||||
if (galleryInfo.series?.isNotEmpty() == true) {
|
||||
TagGroup(galleryInfo.series.map { it.toTag() })
|
||||
}
|
||||
|
||||
if (galleryInfo.characters?.isNotEmpty() == true) {
|
||||
TagGroup(galleryInfo.characters.map { it.toTag() })
|
||||
}
|
||||
|
||||
if (galleryInfo.tags?.isNotEmpty() == true) {
|
||||
TagGroup(galleryInfo.tags.map { it.toTag() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
contentType: ContentType,
|
||||
displayFeatures: List<DisplayFeature>,
|
||||
uiState: SearchState,
|
||||
openGalleryDetails: (GalleryInfo) -> Unit,
|
||||
closeGalleryDetails: () -> Unit,
|
||||
onQueryChange: (SearchQuery?) -> Unit,
|
||||
loadSearchResult: (IntRange) -> Unit,
|
||||
openGallery: (GalleryInfo) -> Unit,
|
||||
) {
|
||||
val itemsPerPage by remember { mutableIntStateOf(20) }
|
||||
|
||||
val pageToRange: (Int) -> IntRange = remember(itemsPerPage) {
|
||||
{ page ->
|
||||
page * itemsPerPage..<(page + 1) * itemsPerPage
|
||||
}
|
||||
}
|
||||
|
||||
val currentPage = remember(uiState) {
|
||||
if (uiState.currentRange != IntRange.EMPTY) {
|
||||
uiState.currentRange.first / itemsPerPage
|
||||
} else 0
|
||||
}
|
||||
|
||||
val maxPage = remember(itemsPerPage, uiState) {
|
||||
if (uiState.galleryCount != null) {
|
||||
uiState.galleryCount / itemsPerPage + if (uiState.galleryCount % itemsPerPage != 0) 1 else 0
|
||||
} else 0
|
||||
}
|
||||
|
||||
val loadResult: (Int) -> Unit = remember(loadSearchResult) {
|
||||
{ page ->
|
||||
loadSearchResult(pageToRange(page))
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.query) { loadSearchResult(pageToRange(currentPage)) }
|
||||
|
||||
LaunchedEffect(contentType) {
|
||||
if (contentType == ContentType.SINGLE_PANE && !uiState.isDetailOnlyOpen) {
|
||||
closeGalleryDetails()
|
||||
}
|
||||
}
|
||||
|
||||
if (contentType == ContentType.SINGLE_PANE && uiState.isDetailOnlyOpen) {
|
||||
BackHandler {
|
||||
closeGalleryDetails()
|
||||
}
|
||||
}
|
||||
|
||||
if (contentType == ContentType.DUAL_PANE) {
|
||||
TwoPane(
|
||||
first = {
|
||||
GalleryList(
|
||||
contentType = contentType,
|
||||
galleries = uiState.galleries,
|
||||
query = uiState.query,
|
||||
currentPage = currentPage,
|
||||
maxPage = maxPage,
|
||||
loading = uiState.loading,
|
||||
error = uiState.error,
|
||||
onQueryChange = onQueryChange,
|
||||
onPageChange = loadResult,
|
||||
openGalleryDetails = openGalleryDetails
|
||||
)
|
||||
},
|
||||
second = {
|
||||
|
||||
},
|
||||
strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f, gapWidth = 16.dp),
|
||||
displayFeatures = displayFeatures
|
||||
)
|
||||
} else {
|
||||
val detailGallery = uiState.openedGallery
|
||||
AnimatedVisibility(!uiState.isDetailOnlyOpen || detailGallery == null) {
|
||||
GalleryList(
|
||||
contentType = contentType,
|
||||
galleries = uiState.galleries,
|
||||
query = uiState.query,
|
||||
currentPage = currentPage,
|
||||
maxPage = maxPage,
|
||||
loading = uiState.loading,
|
||||
error = uiState.error,
|
||||
onQueryChange = onQueryChange,
|
||||
onPageChange = loadResult,
|
||||
openGalleryDetails = openGalleryDetails
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(uiState.isDetailOnlyOpen && detailGallery != null) {
|
||||
if (detailGallery != null) {
|
||||
DetailScreen(
|
||||
galleryInfo = detailGallery,
|
||||
closeGalleryDetails = closeGalleryDetails,
|
||||
openGallery = openGallery
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,148 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val LightBlue300 = Color(0xFF4FC3F7)
|
||||
val LightBlue700 = Color(0xFF0288D1)
|
||||
val Pink600 = Color(0xFFD81B60)
|
||||
val Blue700 = Color(0xFF1976D2)
|
||||
val GreenA700 = Color(0xFF00C853)
|
||||
val Orange500 = Color(0xFFFF9800)
|
||||
val md_theme_light_primary = Color(0xFF006688)
|
||||
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_primaryContainer = Color(0xFFC2E8FF)
|
||||
val md_theme_light_onPrimaryContainer = Color(0xFF001E2B)
|
||||
val md_theme_light_secondary = Color(0xFF4E616D)
|
||||
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_secondaryContainer = Color(0xFFD1E5F3)
|
||||
val md_theme_light_onSecondaryContainer = Color(0xFF091E28)
|
||||
val md_theme_light_tertiary = Color(0xFF5F5A7D)
|
||||
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_tertiaryContainer = Color(0xFFE5DEFF)
|
||||
val md_theme_light_onTertiaryContainer = Color(0xFF1C1736)
|
||||
val md_theme_light_error = Color(0xFFBA1A1A)
|
||||
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_light_onError = Color(0xFFFFFFFF)
|
||||
val md_theme_light_onErrorContainer = Color(0xFF410002)
|
||||
val md_theme_light_background = Color(0xFFFBFCFE)
|
||||
val md_theme_light_onBackground = Color(0xFF191C1E)
|
||||
val md_theme_light_surface = Color(0xFFFBFCFE)
|
||||
val md_theme_light_onSurface = Color(0xFF191C1E)
|
||||
val md_theme_light_surfaceVariant = Color(0xFFDCE3E9)
|
||||
val md_theme_light_onSurfaceVariant = Color(0xFF40484D)
|
||||
val md_theme_light_outline = Color(0xFF71787D)
|
||||
val md_theme_light_inverseOnSurface = Color(0xFFF0F1F3)
|
||||
val md_theme_light_inverseSurface = Color(0xFF2E3133)
|
||||
val md_theme_light_inversePrimary = Color(0xFF75D1FF)
|
||||
val md_theme_light_shadow = Color(0xFF000000)
|
||||
val md_theme_light_surfaceTint = Color(0xFF006688)
|
||||
val md_theme_light_outlineVariant = Color(0xFFC0C7CD)
|
||||
val md_theme_light_scrim = Color(0xFF000000)
|
||||
|
||||
val md_theme_dark_primary = Color(0xFF75D1FF)
|
||||
val md_theme_dark_onPrimary = Color(0xFF003548)
|
||||
val md_theme_dark_primaryContainer = Color(0xFF004D67)
|
||||
val md_theme_dark_onPrimaryContainer = Color(0xFFC2E8FF)
|
||||
val md_theme_dark_secondary = Color(0xFFB5C9D7)
|
||||
val md_theme_dark_onSecondary = Color(0xFF20333D)
|
||||
val md_theme_dark_secondaryContainer = Color(0xFF364954)
|
||||
val md_theme_dark_onSecondaryContainer = Color(0xFFD1E5F3)
|
||||
val md_theme_dark_tertiary = Color(0xFFC9C2EA)
|
||||
val md_theme_dark_onTertiary = Color(0xFF312C4C)
|
||||
val md_theme_dark_tertiaryContainer = Color(0xFF474364)
|
||||
val md_theme_dark_onTertiaryContainer = Color(0xFFE5DEFF)
|
||||
val md_theme_dark_error = Color(0xFFFFB4AB)
|
||||
val md_theme_dark_errorContainer = Color(0xFF93000A)
|
||||
val md_theme_dark_onError = Color(0xFF690005)
|
||||
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_dark_background = Color(0xFF191C1E)
|
||||
val md_theme_dark_onBackground = Color(0xFFE1E2E5)
|
||||
val md_theme_dark_surface = Color(0xFF191C1E)
|
||||
val md_theme_dark_onSurface = Color(0xFFE1E2E5)
|
||||
val md_theme_dark_surfaceVariant = Color(0xFF40484D)
|
||||
val md_theme_dark_onSurfaceVariant = Color(0xFFC0C7CD)
|
||||
val md_theme_dark_outline = Color(0xFF8A9297)
|
||||
val md_theme_dark_inverseOnSurface = Color(0xFF191C1E)
|
||||
val md_theme_dark_inverseSurface = Color(0xFFE1E2E5)
|
||||
val md_theme_dark_inversePrimary = Color(0xFF006688)
|
||||
val md_theme_dark_shadow = Color(0xFF000000)
|
||||
val md_theme_dark_surfaceTint = Color(0xFF75D1FF)
|
||||
val md_theme_dark_outlineVariant = Color(0xFF40484D)
|
||||
val md_theme_dark_scrim = Color(0xFF000000)
|
||||
|
||||
|
||||
val seed = Color(0xFF4FC3F7)
|
||||
|
||||
val Gray50 = Color(0xFFF9FAFB)
|
||||
val Gray100 = Color(0xFFF3F4F6)
|
||||
val Gray200 = Color(0xFFE5E7EB)
|
||||
val Gray300 = Color(0xFFD1D5DB)
|
||||
val Gray400 = Color(0xFF9CA3AF)
|
||||
val Gray500 = Color(0xFF6B7280)
|
||||
val Gray600 = Color(0xFF4B5563)
|
||||
val Gray700 = Color(0xFF374151)
|
||||
val Gray800 = Color(0xFF1F2937)
|
||||
val Gray900 = Color(0xFF111827)
|
||||
val Red50 = Color(0xFFFEF2F2)
|
||||
val Red100 = Color(0xFFFEE2E2)
|
||||
val Red200 = Color(0xFFFECACA)
|
||||
val Red300 = Color(0xFFFCA5A5)
|
||||
val Red400 = Color(0xFFF87171)
|
||||
val Red500 = Color(0xFFEF4444)
|
||||
val Red600 = Color(0xFFDC2626)
|
||||
val Red700 = Color(0xFFB91C1C)
|
||||
val Red800 = Color(0xFF991B1B)
|
||||
val Red900 = Color(0xFF7F1D1D)
|
||||
val Yellow50 = Color(0xFFFFFBEB)
|
||||
val Yellow100 = Color(0xFFFEF3C7)
|
||||
val Yellow200 = Color(0xFFFDE68A)
|
||||
val Yellow300 = Color(0xFFFCD34D)
|
||||
val Yellow400 = Color(0xFFFBBF24)
|
||||
val Yellow500 = Color(0xFFF59E0B)
|
||||
val Yellow600 = Color(0xFFD97706)
|
||||
val Yellow700 = Color(0xFFB45309)
|
||||
val Yellow800 = Color(0xFF92400E)
|
||||
val Yellow900 = Color(0xFF78350F)
|
||||
val Green50 = Color(0xFFECFDF5)
|
||||
val Green100 = Color(0xFFD1FAE5)
|
||||
val Green200 = Color(0xFFA7F3D0)
|
||||
val Green300 = Color(0xFF6EE7B7)
|
||||
val Green400 = Color(0xFF34D399)
|
||||
val Green500 = Color(0xFF10B981)
|
||||
val Green600 = Color(0xFF059669)
|
||||
val Green700 = Color(0xFF047857)
|
||||
val Green800 = Color(0xFF065F46)
|
||||
val Green900 = Color(0xFF064E3B)
|
||||
val Blue50 = Color(0xFFEFF6FF)
|
||||
val Blue100 = Color(0xFFDBEAFE)
|
||||
val Blue200 = Color(0xFFBFDBFE)
|
||||
val Blue300 = Color(0xFF93C5FD)
|
||||
val Blue400 = Color(0xFF60A5FA)
|
||||
val Blue500 = Color(0xFF3B82F6)
|
||||
val Blue600 = Color(0xFF2563EB)
|
||||
val Blue700 = Color(0xFF1D4ED8)
|
||||
val Blue800 = Color(0xFF1E40AF)
|
||||
val Blue900 = Color(0xFF1E3A8A)
|
||||
val Indigo50 = Color(0xFFEEF2FF)
|
||||
val Indigo100 = Color(0xFFE0E7FF)
|
||||
val Indigo200 = Color(0xFFC7D2FE)
|
||||
val Indigo300 = Color(0xFFA5B4FC)
|
||||
val Indigo400 = Color(0xFF818CF8)
|
||||
val Indigo500 = Color(0xFF6366F1)
|
||||
val Indigo600 = Color(0xFF4F46E5)
|
||||
val Indigo700 = Color(0xFF4338CA)
|
||||
val Indigo800 = Color(0xFF3730A3)
|
||||
val Indigo900 = Color(0xFF312E81)
|
||||
val Purple50 = Color(0xFFF5F3FF)
|
||||
val Purple100 = Color(0xFFEDE9FE)
|
||||
val Purple200 = Color(0xFFDDD6FE)
|
||||
val Purple300 = Color(0xFFC4B5FD)
|
||||
val Purple400 = Color(0xFFA78BFA)
|
||||
val Purple500 = Color(0xFF8B5CF6)
|
||||
val Purple600 = Color(0xFF7C3AED)
|
||||
val Purple700 = Color(0xFF6D28D9)
|
||||
val Purple800 = Color(0xFF5B21B6)
|
||||
val Purple900 = Color(0xFF4C1D95)
|
||||
val Pink50 = Color(0xFFFDF2F8)
|
||||
val Pink100 = Color(0xFFFCE7F3)
|
||||
val Pink200 = Color(0xFFFBCFE8)
|
||||
val Pink300 = Color(0xFFF9A8D4)
|
||||
val Pink400 = Color(0xFFF472B6)
|
||||
val Pink500 = Color(0xFFEC4899)
|
||||
val Pink600 = Color(0xFFDB2777)
|
||||
val Pink700 = Color(0xFFBE185D)
|
||||
val Pink800 = Color(0xFF9D174D)
|
||||
val Pink900 = Color(0xFF831843)
|
||||
@@ -1,29 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Shapes = Shapes(
|
||||
small = RoundedCornerShape(4.dp),
|
||||
medium = RoundedCornerShape(4.dp),
|
||||
large = RoundedCornerShape(0.dp)
|
||||
)
|
||||
@@ -1,57 +1,90 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.contentColorFor
|
||||
import androidx.compose.material.darkColors
|
||||
import androidx.compose.material.lightColors
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
private val DarkColorPalette = darkColors(
|
||||
primary = LightBlue300,
|
||||
primaryVariant = LightBlue700,
|
||||
secondary = Pink600,
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White
|
||||
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = md_theme_light_primary,
|
||||
onPrimary = md_theme_light_onPrimary,
|
||||
primaryContainer = md_theme_light_primaryContainer,
|
||||
onPrimaryContainer = md_theme_light_onPrimaryContainer,
|
||||
secondary = md_theme_light_secondary,
|
||||
onSecondary = md_theme_light_onSecondary,
|
||||
secondaryContainer = md_theme_light_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_light_onSecondaryContainer,
|
||||
tertiary = md_theme_light_tertiary,
|
||||
onTertiary = md_theme_light_onTertiary,
|
||||
tertiaryContainer = md_theme_light_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_light_onTertiaryContainer,
|
||||
error = md_theme_light_error,
|
||||
errorContainer = md_theme_light_errorContainer,
|
||||
onError = md_theme_light_onError,
|
||||
onErrorContainer = md_theme_light_onErrorContainer,
|
||||
background = md_theme_light_background,
|
||||
onBackground = md_theme_light_onBackground,
|
||||
surface = md_theme_light_surface,
|
||||
onSurface = md_theme_light_onSurface,
|
||||
surfaceVariant = md_theme_light_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_light_onSurfaceVariant,
|
||||
outline = md_theme_light_outline,
|
||||
inverseOnSurface = md_theme_light_inverseOnSurface,
|
||||
inverseSurface = md_theme_light_inverseSurface,
|
||||
inversePrimary = md_theme_light_inversePrimary,
|
||||
surfaceTint = md_theme_light_surfaceTint,
|
||||
outlineVariant = md_theme_light_outlineVariant,
|
||||
scrim = md_theme_light_scrim,
|
||||
)
|
||||
private val LightColorPalette = lightColors(
|
||||
primary = LightBlue300,
|
||||
primaryVariant = LightBlue700,
|
||||
secondary = Pink600,
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White
|
||||
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = md_theme_dark_primary,
|
||||
onPrimary = md_theme_dark_onPrimary,
|
||||
primaryContainer = md_theme_dark_primaryContainer,
|
||||
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
|
||||
secondary = md_theme_dark_secondary,
|
||||
onSecondary = md_theme_dark_onSecondary,
|
||||
secondaryContainer = md_theme_dark_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
|
||||
tertiary = md_theme_dark_tertiary,
|
||||
onTertiary = md_theme_dark_onTertiary,
|
||||
tertiaryContainer = md_theme_dark_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
|
||||
error = md_theme_dark_error,
|
||||
errorContainer = md_theme_dark_errorContainer,
|
||||
onError = md_theme_dark_onError,
|
||||
onErrorContainer = md_theme_dark_onErrorContainer,
|
||||
background = md_theme_dark_background,
|
||||
onBackground = md_theme_dark_onBackground,
|
||||
surface = md_theme_dark_surface,
|
||||
onSurface = md_theme_dark_onSurface,
|
||||
surfaceVariant = md_theme_dark_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
|
||||
outline = md_theme_dark_outline,
|
||||
inverseOnSurface = md_theme_dark_inverseOnSurface,
|
||||
inverseSurface = md_theme_dark_inverseSurface,
|
||||
inversePrimary = md_theme_dark_inversePrimary,
|
||||
surfaceTint = md_theme_dark_surfaceTint,
|
||||
outlineVariant = md_theme_dark_outlineVariant,
|
||||
scrim = md_theme_dark_scrim,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PupilTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
fun AppTheme(
|
||||
useDarkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable() () -> Unit
|
||||
) {
|
||||
val colors = if (darkTheme) DarkColorPalette else LightColorPalette
|
||||
val colors = if (!useDarkTheme) {
|
||||
LightColors
|
||||
} else {
|
||||
DarkColors
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colors = colors,
|
||||
typography = Typography,
|
||||
shapes = Shapes,
|
||||
content = content
|
||||
)
|
||||
MaterialTheme(
|
||||
colorScheme = colors,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.ui.theme
|
||||
|
||||
import androidx.compose.material.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
body1 = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
package xyz.quaver.pupil.ui.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import xyz.quaver.pupil.networking.GalleryInfo
|
||||
import xyz.quaver.pupil.networking.GallerySearchSource
|
||||
import xyz.quaver.pupil.networking.SearchQuery
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
class MainViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(SearchState())
|
||||
val searchState: StateFlow<SearchState> = _uiState
|
||||
private var searchSource: GallerySearchSource = GallerySearchSource(null)
|
||||
private var job: Job? = null
|
||||
|
||||
fun openGalleryDetails(galleryInfo: GalleryInfo) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
openedGallery = galleryInfo,
|
||||
isDetailOnlyOpen = true
|
||||
)
|
||||
}
|
||||
|
||||
fun closeGalleryDetails() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isDetailOnlyOpen = false
|
||||
)
|
||||
}
|
||||
|
||||
fun onQueryChange(query: SearchQuery?) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
query = query,
|
||||
galleryCount = null,
|
||||
currentRange = IntRange.EMPTY
|
||||
)
|
||||
|
||||
searchSource = GallerySearchSource(query)
|
||||
}
|
||||
|
||||
fun loadSearchResult(range: IntRange) {
|
||||
Thread.dumpStack()
|
||||
job?.cancel()
|
||||
job = viewModelScope.launch {
|
||||
val sanitizedRange = max(range.first, 0) .. min(range.last, searchState.value.galleryCount ?: Int.MAX_VALUE)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
loading = true,
|
||||
error = false,
|
||||
currentRange = sanitizedRange
|
||||
)
|
||||
|
||||
var error = false
|
||||
val (galleries, galleryCount) = searchSource.load(range).getOrElse {
|
||||
error = true
|
||||
it.printStackTrace()
|
||||
emptyList<GalleryInfo>() to 0
|
||||
}
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
galleries = galleries,
|
||||
galleryCount = galleryCount,
|
||||
error = error,
|
||||
loading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateToDetail() {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
data class SearchState(
|
||||
val query: SearchQuery? = null,
|
||||
val galleries: List<GalleryInfo> = emptyList(),
|
||||
val loading: Boolean = false,
|
||||
val error: Boolean = false,
|
||||
val galleryCount: Int? = null,
|
||||
val currentRange: IntRange = IntRange.EMPTY,
|
||||
val openedGallery: GalleryInfo? = null,
|
||||
val isDetailOnlyOpen: Boolean = false
|
||||
)
|
||||
@@ -1,156 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2022 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.util
|
||||
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.utils.io.core.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.*
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
|
||||
@Serializable
|
||||
data class RemoteSourceInfo(
|
||||
val projectName: String,
|
||||
val name: String,
|
||||
val version: String
|
||||
)
|
||||
|
||||
class Release(
|
||||
val version: String,
|
||||
val apkUrl: String,
|
||||
val releaseNotes: Map<Locale, String>
|
||||
)
|
||||
|
||||
private val localeMap = mapOf(
|
||||
"한국어" to Locale.KOREAN,
|
||||
"日本語" to Locale.JAPANESE,
|
||||
"English" to Locale.ENGLISH
|
||||
)
|
||||
|
||||
class PupilHttpClient(engine: HttpClientEngine) {
|
||||
private val httpClient = HttpClient(engine) {
|
||||
install(ContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a list of available sources from PupilSources repository.
|
||||
* Returns empty map when exception occurs
|
||||
*/
|
||||
suspend fun getRemoteSourceList(): Map<String, RemoteSourceInfo> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
httpClient.get("https://tom5079.github.io/PupilSources/versions.json").body<Map<String, RemoteSourceInfo>>()
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads specific file from :url to :dest.
|
||||
* Returns flow that emits progress.
|
||||
* when value emitted by flow {
|
||||
* in 0f .. 1f -> downloading
|
||||
* POSITIVE_INFINITY -> download finised
|
||||
* NEGATIVE_INFINITY -> exception occured
|
||||
* }
|
||||
*/
|
||||
fun downloadFile(url: String, dest: File) = flow {
|
||||
runCatching {
|
||||
httpClient.prepareGet(url).execute { response ->
|
||||
val channel = response.bodyAsChannel()
|
||||
val contentLength = response.contentLength() ?: -1
|
||||
var readBytes = 0f
|
||||
|
||||
dest.outputStream().use { outputStream ->
|
||||
while (!channel.isClosedForRead) {
|
||||
val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
|
||||
while (!packet.isEmpty) {
|
||||
val bytes = packet.readBytes()
|
||||
outputStream.write(bytes)
|
||||
|
||||
readBytes += bytes.size
|
||||
emit(readBytes / contentLength)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit(Float.POSITIVE_INFINITY)
|
||||
}.onFailure {
|
||||
emit(Float.NEGATIVE_INFINITY)
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
/**
|
||||
* Latest application release info from Github API.
|
||||
* Returns null when exception occurs.
|
||||
*/
|
||||
suspend fun latestRelease(beta: Boolean = true): Release? = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val releases = Json.parseToJsonElement(
|
||||
httpClient.get("https://api.github.com/repos/tom5079/Pupil/releases").bodyAsText()
|
||||
).jsonArray
|
||||
|
||||
val latestRelease = releases.first { release ->
|
||||
beta || !release.jsonObject["prerelease"]!!.jsonPrimitive.boolean
|
||||
}.jsonObject
|
||||
|
||||
val version = latestRelease["tag_name"]!!.jsonPrimitive.content
|
||||
|
||||
val apkUrl = latestRelease["assets"]!!.jsonArray.first { asset ->
|
||||
val name = asset.jsonObject["name"]!!.jsonPrimitive.content
|
||||
name.startsWith("Pupil-v") && name.endsWith(".apk")
|
||||
}.jsonObject["browser_download_url"]!!.jsonPrimitive.content
|
||||
|
||||
val releaseNotes: Map<Locale, String> = buildMap {
|
||||
val body = latestRelease["body"]!!.jsonPrimitive.content
|
||||
|
||||
var locale: Locale? = null
|
||||
val stringBuilder = StringBuilder()
|
||||
body.lineSequence().forEach { line ->
|
||||
localeMap[line.drop(3)]?.let { newLocale ->
|
||||
if (locale != null) {
|
||||
put(locale!!, stringBuilder.deleteCharAt(stringBuilder.length-1).toString())
|
||||
stringBuilder.clear()
|
||||
}
|
||||
locale = newLocale
|
||||
return@forEach
|
||||
}
|
||||
|
||||
if (locale != null) stringBuilder.appendLine(line)
|
||||
}
|
||||
put(locale!!, stringBuilder.deleteCharAt(stringBuilder.length-1).toString())
|
||||
}
|
||||
|
||||
Release(version, apkUrl, releaseNotes)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2022 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
|
||||
fun Context.launchApkInstaller(file: File) {
|
||||
val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", file)
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
setDataAndType(uri, MimeTypeMap.getSingleton().getMimeTypeFromExtension("apk"))
|
||||
}
|
||||
|
||||
startActivity(intent)
|
||||
}
|
||||
12
app/src/main/res/drawable/app_icon.xml
Normal file
12
app/src/main/res/drawable/app_icon.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="900dp"
|
||||
android:height="900dp"
|
||||
android:viewportWidth="900"
|
||||
android:viewportHeight="900">
|
||||
<path
|
||||
android:pathData="M450,450m-450,0a450,450 0,1 1,900 0a450,450 0,1 1,-900 0"
|
||||
android:fillColor="#4EC1F5"/>
|
||||
<path
|
||||
android:pathData="M450,450m-175,0a175,175 0,1 1,350 0a175,175 0,1 1,-350 0"
|
||||
android:fillColor="#1D1D1D"/>
|
||||
</vector>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -1,4 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0">
|
||||
<path android:fillColor="#fff" android:pathData="M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73 0.72 5.12 1.88L13 16h9V7l-3.6 3.6z"/>
|
||||
<path android:fillColor="#fff" android:pathData="M8.5 15a1.5 1.5 0 1 1 1.5 1.5A1.5 1.5 0 0 1 8.5 15z"/>
|
||||
</vector>
|
||||
@@ -1,3 +0,0 @@
|
||||
<vector android:height="24dp" android:width="24dp" android:viewportHeight="24.0" android:viewportWidth="24.0" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@color/material_orange_500" android:pathData="M12 15.39l-3.76 2.27 0.99-4.28-3.32-2.88 4.38-0.37L12 6.09l1.71 4.04 4.38 0.37-3.32 2.88 0.99 4.28M22 9.24l-7.19-0.61L12 2 9.19 8.63 2 9.24l5.45 4.73L5.82 21 12 17.27 18.18 21l-1.64-7.03L22 9.24z"/>
|
||||
</vector>
|
||||
16
app/src/main/res/drawable/language_albanian.xml
Normal file
16
app/src/main/res/drawable/language_albanian.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v512H0z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M400.7,190H308a33.3,33.3 0,0 0,-24.2 -56.4,33.3 33.3,0 0,0 -27.8,14.9 33.4,33.4 0,1 0,-52 41.5h-92.7a45.8,45.8 0,0 0,46 44.5h-1.5c0,24.6 20,44.6 44.5,44.6 0,8 2.1,15.4 5.8,21.8l-37,37 28.4,28.3 40.2,-40.2a30.5,30.5 0,0 0,4.9 1.4l-24.3,54.8L256,423l37.7,-40.8 -24.3,-54.8a30.4,30.4 0,0 0,4.9 -1.4l40.2,40.2 28.3,-28.3 -37,-37a44.2,44.2 0,0 0,5.9 -21.8c24.5,0 44.5,-20 44.5,-44.6h-1.5c24.6,0 46,-19.9 46,-44.5z"
|
||||
android:fillColor="#333"/>
|
||||
</group>
|
||||
</vector>
|
||||
16
app/src/main/res/drawable/language_catalan.xml
Normal file
16
app/src/main/res/drawable/language_catalan.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,0v57l32,29 -32,28v57l32,29 -32,28v57l32,28 -32,28v57l32,29 -32,28v57h512v-57l-32,-28 32,-29v-57l-32,-28 32,-28v-57l-32,-28 32,-29v-57l-32,-28 32,-29V0H0z"
|
||||
android:fillColor="#ffda44"/>
|
||||
<path
|
||||
android:pathData="M0,57h512v57L0,114ZM0,171h512v57L0,228ZM0,285h512v56L0,341ZM0,398h512v57L0,455Z"
|
||||
android:fillColor="#d80027"/>
|
||||
</group>
|
||||
</vector>
|
||||
16
app/src/main/res/drawable/language_chinese.xml
Normal file
16
app/src/main/res/drawable/language_chinese.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v512H0z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="m140.1,155.8 l22.1,68h71.5l-57.8,42.1 22.1,68 -57.9,-42 -57.9,42 22.2,-68 -57.9,-42.1L118,223.8zM303.5,396.5 L286.6,375.7 261.6,385.4 276.1,362.9 259.2,342 285.1,348.9 299.7,326.4 301.1,353.2 327.1,360.1 302,369.7zM337.1,335.5 L345.1,309.9 323.2,294.4 350,294 357.9,268.4 366.6,293.8 393.4,293.5 371.9,309.5 380.5,334.9 358.6,319.4zM382.4,187.9L370.6,212l19.2,18.7 -26.5,-3.8 -11.8,24 -4.6,-26.4 -26.6,-3.8 23.8,-12.5 -4.6,-26.5 19.2,18.7zM304.2,114.9 L302.2,141.6 327.1,151.7 301,158.1 299.1,184.9 285,162.1 258.9,168.5 276.2,148 262,125.3 286.9,135.4z"
|
||||
android:fillColor="#ffda44"/>
|
||||
</group>
|
||||
</vector>
|
||||
19
app/src/main/res/drawable/language_czech.xml
Normal file
19
app/src/main/res/drawable/language_czech.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v256l-265,45.2z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M210,256h302v256H0z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M0,0v512l256,-256L0,0z"
|
||||
android:fillColor="#0052b4"/>
|
||||
</group>
|
||||
</vector>
|
||||
16
app/src/main/res/drawable/language_danish.xml
Normal file
16
app/src/main/res/drawable/language_danish.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,0h133.6l32.7,20.3 34,-20.3H512v222.6L491.4,256l20.6,33.4V512H200.3l-31.7,-20.4 -35,20.4H0V289.4l29.4,-33L0,222.7z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M133.6,0v222.6H0v66.8h133.6V512h66.7V289.4H512v-66.8H200.3V0h-66.7z"
|
||||
android:fillColor="#eee"/>
|
||||
</group>
|
||||
</vector>
|
||||
19
app/src/main/res/drawable/language_dutch.xml
Normal file
19
app/src/main/res/drawable/language_dutch.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="m0,167 l253.8,-19.3L512,167v178l-254.9,32.3L0,345z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v167H0z"
|
||||
android:fillColor="#a2001d"/>
|
||||
<path
|
||||
android:pathData="M0,345h512v167H0z"
|
||||
android:fillColor="#0052b4"/>
|
||||
</group>
|
||||
</vector>
|
||||
22
app/src/main/res/drawable/language_english.xml
Normal file
22
app/src/main/res/drawable/language_english.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M256,0 L0,256v64l32,32 -32,32v128l22,-8 23,8h23l54,-32 54,32h32l48,-32 48,32h32l54,-32 54,32h68l-8,-22 8,-23v-23l-32,-54 32,-54v-32l-32,-48 32,-48v-32l-32,-54 32,-54V0H256z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M224,64v64h160l64,-64zM224,192 L256,256 208,304v208h96L304,304h208v-96L304,208l16,-16zM0,320v64h128l-64,64L0,448v64h45l131,-131v-45l16,-16zM336,336 L512,512v-45L381,336Z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M0,0v256h256L256,0L0,0zM512,68L404,176h108L512,68zM404,336l108,108L512,336L404,336zM176,404L68,512h108L176,404zM336,404v108h108L336,404z"
|
||||
android:fillColor="#0052b4"/>
|
||||
<path
|
||||
android:pathData="m187,243 l57,-41h-70l57,41 -22,-67zM106,243 L163,202L93,202l57,41 -22,-67zM25,243 L82,202L12,202l57,41 -22,-67zM187,162 L244,121h-70l57,41 -22,-67zM106,162 L163,121L93,121l57,41 -22,-67zM25,162 L82,121L12,121l57,41 -22,-67ZM187,80 L244,39h-70l57,41 -22,-67zM106,80 L163,39L93,39l57,41 -22,-67ZM25,80 L82,39L12,39l57,41 -22,-67Z"
|
||||
android:fillColor="#eee"/>
|
||||
</group>
|
||||
</vector>
|
||||
19
app/src/main/res/drawable/language_estonian.xml
Normal file
19
app/src/main/res/drawable/language_estonian.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="m0,167 l254.6,-36.6L512,166.9v178l-254.6,36.4L0,344.9z"
|
||||
android:fillColor="#333"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v166.9H0z"
|
||||
android:fillColor="#0052b4"/>
|
||||
<path
|
||||
android:pathData="M0,344.9h512V512H0z"
|
||||
android:fillColor="#eee"/>
|
||||
</group>
|
||||
</vector>
|
||||
16
app/src/main/res/drawable/language_finnish.xml
Normal file
16
app/src/main/res/drawable/language_finnish.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,0h133.6l35.3,16.7L200.3,0H512v222.6l-22.6,31.7 22.6,35.1V512H200.3l-32,-19.8 -34.7,19.8H0V289.4l22.1,-33.3L0,222.6z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M133.6,0v222.6H0v66.8h133.6V512h66.7V289.4H512v-66.8H200.3V0h-66.7z"
|
||||
android:fillColor="#0052b4"/>
|
||||
</group>
|
||||
</vector>
|
||||
19
app/src/main/res/drawable/language_french.xml
Normal file
19
app/src/main/res/drawable/language_french.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M167,0h178l25.9,252.3L345,512H167l-29.8,-253.4z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M0,0h167v512H0z"
|
||||
android:fillColor="#0052b4"/>
|
||||
<path
|
||||
android:pathData="M345,0h167v512H345z"
|
||||
android:fillColor="#d80027"/>
|
||||
</group>
|
||||
</vector>
|
||||
19
app/src/main/res/drawable/language_german.xml
Normal file
19
app/src/main/res/drawable/language_german.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="m0,345 l256.7,-25.5L512,345v167H0z"
|
||||
android:fillColor="#ffda44"/>
|
||||
<path
|
||||
android:pathData="m0,167 l255,-23 257,23v178H0z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v167H0z"
|
||||
android:fillColor="#333"/>
|
||||
</group>
|
||||
</vector>
|
||||
16
app/src/main/res/drawable/language_greek.xml
Normal file
16
app/src/main/res/drawable/language_greek.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,0h99l29,32 28,-32h356v57l-32,28 32,29v57l-32,28 32,29v57l-32,28 32,28v57l-32,29 32,28v57H0v-57l32,-28 -32,-29v-56l32,-29 -32,-28V171l32,-29 -32,-28Z"
|
||||
android:fillColor="#0052b4"/>
|
||||
<path
|
||||
android:pathData="M99,0v114L0,114v57h99v114L0,285v57h512v-57L156,285L156,171h100v-57L156,114L156,0ZM256,57v57h256L512,57ZM256,171v57h256v-57ZM0,398v57h512v-57z"
|
||||
android:fillColor="#eee"/>
|
||||
</group>
|
||||
</vector>
|
||||
16
app/src/main/res/drawable/language_hebrew.xml
Normal file
16
app/src/main/res/drawable/language_hebrew.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v55.7l-25,32.7 25,34v267.2l-26,36 26,30.7V512H0v-55.7l24.8,-34.1L0,389.6V122.4l27.2,-33.2L0,55.7z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M0,55.7v66.7h512L512,55.7zM0,389.6v66.7h512v-66.7zM352.4,200.3L288,200.3l-32,-55.6 -32.1,55.6h-64.3l32.1,55.7 -32,55.7h64.2l32.1,55.6 32.1,-55.6h64.3L320.3,256l32,-55.7zM295.4,256 L275.7,290.2h-39.4L216.5,256l19.8,-34.2h39.4l19.8,34.2zM256,187.6l7.3,12.7h-14.6zM196.8,221.8h14.7l-7.4,12.7zM196.8,290.2 L204.1,277.5 211.5,290.2zM256,324.4 L248.7,311.7h14.6zM315.2,290.2h-14.7l7.4,-12.7zM300.5,221.8h14.7l-7.3,12.7z"
|
||||
android:fillColor="#0052b4"/>
|
||||
</group>
|
||||
</vector>
|
||||
19
app/src/main/res/drawable/language_hungarian.xml
Normal file
19
app/src/main/res/drawable/language_hungarian.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="m0,167 l253.8,-19.3L512,167v178l-254.9,32.3L0,345z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v167H0z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M0,345h512v167H0z"
|
||||
android:fillColor="#6da544"/>
|
||||
</group>
|
||||
</vector>
|
||||
16
app/src/main/res/drawable/language_indonesian.xml
Normal file
16
app/src/main/res/drawable/language_indonesian.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="m0,256 l249.6,-41.3L512,256v256H0z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v256H0z"
|
||||
android:fillColor="#a2001d"/>
|
||||
</group>
|
||||
</vector>
|
||||
19
app/src/main/res/drawable/language_italian.xml
Normal file
19
app/src/main/res/drawable/language_italian.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M167,0h178l25.9,252.3L345,512H167l-29.8,-253.4z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M0,0h167v512H0z"
|
||||
android:fillColor="#6da544"/>
|
||||
<path
|
||||
android:pathData="M345,0h167v512H345z"
|
||||
android:fillColor="#d80027"/>
|
||||
</group>
|
||||
</vector>
|
||||
16
app/src/main/res/drawable/language_japanese.xml
Normal file
16
app/src/main/res/drawable/language_japanese.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v512H0z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M256,256m-111.3,0a111.3,111.3 0,1 1,222.6 0a111.3,111.3 0,1 1,-222.6 0"
|
||||
android:fillColor="#d80027"/>
|
||||
</group>
|
||||
</vector>
|
||||
43
app/src/main/res/drawable/language_javanese.xml
Normal file
43
app/src/main/res/drawable/language_javanese.xml
Normal file
@@ -0,0 +1,43 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v512H0Z"
|
||||
android:fillColor="#ffda44"/>
|
||||
<path
|
||||
android:pathData="m256,114 l-6,2 -95,78c-4,3 -4,9 -1,13l102,-33 102,33c3,-4 3,-10 -1,-13l-95,-78 -6,-2z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M278,231h-1zM235,231z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="m256,134 l-99,72 6,22 51,-32 42,25 42,-25 51,32 6,-22z"
|
||||
android:fillColor="#6da544"/>
|
||||
<path
|
||||
android:pathData="m256,191 l-28,60 -9,-21 -10,21 -7,-16 -10,21 -7,-16 -13,27 14,23h140l14,-23 -12,-27 -8,16 -10,-21 -7,16 -10,-21 -9,21zM194,325zM318,325z"
|
||||
android:fillColor="#333"/>
|
||||
<path
|
||||
android:pathData="m183,290 l11,32h124l11,-32h-73z"
|
||||
android:fillColor="#338af3"/>
|
||||
<path
|
||||
android:pathData="M256,129a4,4 0,0 0,-2 1l-100,73a4,4 0,0 0,-2 4l38,117a4,4 0,0 0,4 3h124a4,4 0,0 0,4 -3l38,-117a4,4 0,0 0,-2 -4l-100,-73a4,4 0,0 0,-2 -1zM256,138 L351,208 315,319L197,319l-36,-111 95,-70z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M256.5,170L256.5,170A4.5,4.5 0,0 1,261 174.5L261,307.5A4.5,4.5 0,0 1,256.5 312L256.5,312A4.5,4.5 0,0 1,252 307.5L252,174.5A4.5,4.5 0,0 1,256.5 170z"
|
||||
android:fillColor="#ff9811"/>
|
||||
<path
|
||||
android:pathData="M204,338L308,338A12,12 0,0 1,320 350L320,350A12,12 0,0 1,308 362L204,362A12,12 0,0 1,192 350L192,350A12,12 0,0 1,204 338z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M188,350L324,350A12,12 0,0 1,336 362L336,362A12,12 0,0 1,324 374L188,374A12,12 0,0 1,176 362L176,362A12,12 0,0 1,188 350z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="m242,191 l14,-42 14,42 -36,-26h44z"
|
||||
android:fillColor="#ffda44"/>
|
||||
</group>
|
||||
</vector>
|
||||
28
app/src/main/res/drawable/language_korean.xml
Normal file
28
app/src/main/res/drawable/language_korean.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v512H0Z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="m350,335 l24,-24 16,16 -24,23zM311,374 L335,350 350,366 327,390zM398,382 L421,358 437,374 413,398zM358,421 L382,398 398,413 374,437ZM374,358 L398,335 413,350 390,374zM335,398 L358,374 374,390 350,413zM398,177 L335,114 350,99 414,162zM335,162 L311,138 327,122 350,146zM374,201 L350,177 366,162 390,185zM382,114 L358,91 374,75 398,99ZM421,154 L398,130 413,114 437,138ZM91,358l63,63 -16,16 -63,-63zM154,374 L177,398 162,413 138,390zM114,335 L138,358 122,374 99,350zM138,311 L201,374 185,390 122,327zM154,91 L91,154 75,138 138,75zM177,114 L114,177 99,161 162,98zM201,138 L138,201 122,185 185,122z"
|
||||
android:fillColor="#333"/>
|
||||
<path
|
||||
android:pathData="M319,319 L193,193a89,89 0,1 1,126 126z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M319,319a89,89 0,1 1,-126 -126z"
|
||||
android:fillColor="#0052b4"/>
|
||||
<path
|
||||
android:pathData="M224.5,224.5m-44.5,0a44.5,44.5 0,1 1,89 0a44.5,44.5 0,1 1,-89 0"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M287.5,287.5m-44.5,0a44.5,44.5 0,1 1,89 0a44.5,44.5 0,1 1,-89 0"
|
||||
android:fillColor="#0052b4"/>
|
||||
</group>
|
||||
</vector>
|
||||
16
app/src/main/res/drawable/language_latin.xml
Normal file
16
app/src/main/res/drawable/language_latin.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v512H0Z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M192,112a16,16 0,0 0,-8 2c-17,9 -33,20 -45,34 -8,-22 -24,-26 -24,-26s-14,13 -7,35c2,9 6,15 10,19 -8,14 -14,29 -18,45 -17,-15 -33,-10 -33,-10s-6,17 11,34c6,6 13,9 18,11 0,17 3,33 7,48 -22,-5 -33,7 -33,7s4,18 26,24c9,3 16,2 21,1 9,14 19,27 31,38 -22,6 -26,23 -26,23s13,14 35,7c9,-2 15,-6 19,-10a159,159 0,0 0,160 0c4,4 10,8 19,10 22,7 35,-7 35,-7s-4,-17 -26,-23c12,-11 22,-24 31,-38 5,1 12,2 21,-1 22,-6 26,-24 26,-24s-11,-12 -33,-7c4,-15 7,-31 7,-48 5,-2 12,-5 18,-11 17,-17 11,-34 11,-34s-16,-5 -33,10c-4,-16 -10,-31 -18,-45 4,-4 8,-10 10,-19 7,-22 -7,-35 -7,-35s-16,4 -24,26c-12,-14 -28,-25 -45,-34a16,16 0,0 0,-8 -2,16 16,0 0,0 -13,9 16,16 0,0 0,7 21c13,7 24,16 34,26 -23,1 -31,16 -31,16s8,16 32,16c8,0 15,-2 19,-5 6,10 10,20 12,31 -20,-11 -35,-2 -35,-2s-1,18 19,30c8,4 14,6 20,6 0,11 -2,23 -5,33l-1,-1c-11,-20 -30,-19 -30,-19s-9,15 3,36c4,7 9,12 14,14 -6,9 -12,18 -20,25 -1,-23 -17,-31 -17,-31s-16,8 -16,32c0,8 3,15 5,19a126,126 0,0 1,-122 0c3,-4 5,-11 5,-19 0,-24 -16,-32 -16,-32s-16,8 -17,31c-7,-7 -14,-16 -20,-25 5,-2 10,-7 14,-14 12,-21 3,-36 3,-36s-19,-1 -30,19v1c-4,-10 -6,-22 -6,-33 6,0 12,-2 20,-6 20,-12 19,-30 19,-30s-15,-9 -35,2c2,-11 6,-21 12,-31 4,3 11,5 19,5 24,0 32,-16 32,-16s-8,-15 -31,-16c10,-10 21,-19 34,-26a16,16 0,0 0,7 -21,16 16,0 0,0 -13,-9z"
|
||||
android:fillColor="#ffda44"/>
|
||||
</group>
|
||||
</vector>
|
||||
28
app/src/main/res/drawable/language_mongolian.xml
Normal file
28
app/src/main/res/drawable/language_mongolian.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,0h167l84.9,45L345,0h167v512H345l-87.7,-48.1L167,512H0z"
|
||||
android:fillColor="#a2001d"/>
|
||||
<path
|
||||
android:pathData="M167,0h178v512H167z"
|
||||
android:fillColor="#0052b4"/>
|
||||
<path
|
||||
android:pathData="M122.4,256h22.3v89h-22.3zM33.4,256h22.3v89L33.4,345z"
|
||||
android:fillColor="#ffda44"/>
|
||||
<path
|
||||
android:pathData="M89,289.4m-22.3,0a22.3,22.3 0,1 1,44.6 0a22.3,22.3 0,1 1,-44.6 0"
|
||||
android:fillColor="#ffda44"/>
|
||||
<path
|
||||
android:pathData="M89,211.5m-11.1,0a11.1,11.1 0,1 1,22.2 0a11.1,11.1 0,1 1,-22.2 0"
|
||||
android:fillColor="#ffda44"/>
|
||||
<path
|
||||
android:pathData="M66.8,322.8h44.5L111.3,345L66.8,345zM66.8,233.8h44.5L111.3,256L66.8,256zM89,133.5l8,24.2h25.4l-20.6,15 7.9,24.3L89,182l-20.6,15 7.9,-24.3 -20.6,-15h25.5z"
|
||||
android:fillColor="#ffda44"/>
|
||||
</group>
|
||||
</vector>
|
||||
19
app/src/main/res/drawable/language_norwegian.xml
Normal file
19
app/src/main/res/drawable/language_norwegian.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,0h100.2l66.1,53.5L233.7,0H512v189.3L466.3,257l45.7,65.8V512H233.7l-68,-50.7 -65.5,50.7H0V322.8l51.4,-68.5 -51.4,-65z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M100.2,0v189.3H0v33.4l24.6,33L0,289.5v33.4h100.2V512h33.4l30.6,-26.3 36.1,26.3h33.4V322.8H512v-33.4l-24.6,-33.7 24.6,-33v-33.4H233.7V0h-33.4l-33.8,25.3L133.6,0z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M133.6,0v222.7H0v66.7h133.6V512h66.7V289.4H512v-66.7H200.3V0z"
|
||||
android:fillColor="#0052b4"/>
|
||||
</group>
|
||||
</vector>
|
||||
19
app/src/main/res/drawable/language_persian.xml
Normal file
19
app/src/main/res/drawable/language_persian.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,144.7 L258.8,39.6 512,144.7v222.6L257,493 0,367.3z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M0,0v144.7h105.6v-22.2h33.6v22.2h33.3v-22.2h33.6v22.2h33.3v-22.2H273v22.2h33v-22.2h33.6v22.2h33.2v-22.2h33.6v22.2H512V0z"
|
||||
android:fillColor="#6da544"/>
|
||||
<path
|
||||
android:pathData="M0,367.3L0,512h512L512,367.3L406.4,367.3v22.4h-33.6v-22.4h-33.2v22.4L306,389.7v-22.4h-33v22.4h-33.6v-22.4h-33.3v22.4h-33.6v-22.4h-33.3v22.4h-33.6v-22.4zM339.1,189.3h-33.4c0.2,3.7 0.4,7.4 0.4,11.1 0,24.8 -6.2,48.8 -17,66 -3.3,5.2 -9,12.6 -16.4,17.6v-94.7h-33.4v94.8c-7.5,-5 -13,-12.4 -16.4,-17.7 -10.8,-17 -17,-41 -17,-65.9 0,-3.7 0.2,-7.4 0.4,-11L173,189.5a190,190 0,0 0,-0.4 11c0,68.7 36.7,122.5 83.5,122.5s83.5,-53.8 83.5,-122.5c0,-3.7 -0.1,-7.4 -0.4,-11z"
|
||||
android:fillColor="#d80027"/>
|
||||
</group>
|
||||
</vector>
|
||||
22
app/src/main/res/drawable/language_philippines.xml
Normal file
22
app/src/main/res/drawable/language_philippines.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v256l-265,45.2z"
|
||||
android:fillColor="#0052b4"/>
|
||||
<path
|
||||
android:pathData="M210,256h302v256H0z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M0,0v512l256,-256z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M175.3,256 L144,241.3l16.7,-30.3 -34,6.5 -4.3,-34.3 -23.6,25.2L75,183.2l-4.3,34.3 -34,-6.5 16.7,30.3L22.3,256l31.2,14.7L37,301l34,-6.5 4.2,34.3 23.7,-25.2 23.6,25.2 4.3,-34.3 34,6.5 -16.7,-30.3zM68.3,100.2 L78.7,114.7 95.7,109.3 85.1,123.7 95.5,138.2 78.5,132.6L68,147l0.2,-17.9 -17,-5.6 17,-5.4zM68.3,365 L78.7,379.6 95.7,374.2 85.1,388.5 95.5,403.1 78.5,397.4L68,411.8l0.2,-17.9 -17,-5.6 17,-5.4zM216.7,232.6L206.3,247l-17,-5.4 10.5,14.4 -10.4,14.6 17,-5.7 10.6,14.4 -0.1,-17.9 17,-5.6 -17.1,-5.4z"
|
||||
android:fillColor="#ffda44"/>
|
||||
</group>
|
||||
</vector>
|
||||
16
app/src/main/res/drawable/language_polish.xml
Normal file
16
app/src/main/res/drawable/language_polish.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="m0,256 l256.4,-44.3L512,256v256H0z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v256H0z"
|
||||
android:fillColor="#eee"/>
|
||||
</group>
|
||||
</vector>
|
||||
25
app/src/main/res/drawable/language_portuguese.xml
Normal file
25
app/src/main/res/drawable/language_portuguese.xml
Normal file
@@ -0,0 +1,25 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,512h167l37.9,-260.3L167,0H0z"
|
||||
android:fillColor="#6da544"/>
|
||||
<path
|
||||
android:pathData="M512,0H167v512h345z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M167,256m-89,0a89,89 0,1 1,178 0a89,89 0,1 1,-178 0"
|
||||
android:fillColor="#ffda44"/>
|
||||
<path
|
||||
android:pathData="M116.9,211.5V267a50,50 0,1 0,100.1 0v-55.6H117z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M167,283.8c-9.2,0 -16.7,-7.5 -16.7,-16.7V245h33.4v22c0,9.2 -7.5,16.7 -16.7,16.7z"
|
||||
android:fillColor="#eee"/>
|
||||
</group>
|
||||
</vector>
|
||||
19
app/src/main/res/drawable/language_romanian.xml
Normal file
19
app/src/main/res/drawable/language_romanian.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M167,0h178l25.9,252.3L345,512H167l-29.8,-253.4z"
|
||||
android:fillColor="#ffda44"/>
|
||||
<path
|
||||
android:pathData="M0,0h167v512H0z"
|
||||
android:fillColor="#0052b4"/>
|
||||
<path
|
||||
android:pathData="M345,0h167v512H345z"
|
||||
android:fillColor="#d80027"/>
|
||||
</group>
|
||||
</vector>
|
||||
19
app/src/main/res/drawable/language_russian.xml
Normal file
19
app/src/main/res/drawable/language_russian.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M512,170v172l-256,32L0,342V170l256,-32z"
|
||||
android:fillColor="#0052b4"/>
|
||||
<path
|
||||
android:pathData="M512,0v170H0V0Z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M512,342v170H0V342Z"
|
||||
android:fillColor="#d80027"/>
|
||||
</group>
|
||||
</vector>
|
||||
31
app/src/main/res/drawable/language_slovak.xml
Normal file
31
app/src/main/res/drawable/language_slovak.xml
Normal file
@@ -0,0 +1,31 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="m0,160 l256,-32 256,32v192l-256,32L0,352z"
|
||||
android:fillColor="#0052b4"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v160H0z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M0,352h512v160H0z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M64,63v217c0,104 144,137 144,137s144,-33 144,-137V63z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M96,95v185a83,78 0,0 0,9 34h206a83,77 0,0 0,9 -34V95z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M288,224h-64v-32h32v-32h-32v-32h-32v32h-32v32h32v32h-64v32h64v32h32v-32h64z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M152,359a247,231 0,0 0,56 24c12,-3 34,-11 56,-24a123,115 0,0 0,47 -45,60 56,0 0,0 -34,-10l-14,2a60,56 0,0 0,-110 0,60 56,0 0,0 -14,-2c-12,0 -24,4 -34,10a123,115 0,0 0,47 45z"
|
||||
android:fillColor="#0052b4"/>
|
||||
</group>
|
||||
</vector>
|
||||
91
app/src/main/res/drawable/language_spanish.xml
Normal file
91
app/src/main/res/drawable/language_spanish.xml
Normal file
@@ -0,0 +1,91 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="m0,128 l256,-32 256,32v256l-256,32L0,384Z"
|
||||
android:fillColor="#ffda44"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v128L0,128zM0,384h512v128L0,512z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M144,304h-16v-80h16zM272,304h16v-80h-16z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M160,296a48,32 0,1 0,96 0a48,32 0,1 0,-96 0z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M136,192L136,192A8,8 0,0 1,144 200L144,208A8,8 0,0 1,136 216L136,216A8,8 0,0 1,128 208L128,200A8,8 0,0 1,136 192z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M280,192L280,192A8,8 0,0 1,288 200L288,208A8,8 0,0 1,280 216L280,216A8,8 0,0 1,272 208L272,200A8,8 0,0 1,280 192z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M208,272v24a24,24 0,0 0,24 24,24 24,0 0,0 24,-24v-24h-24z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M128,208L144,208A8,8 0,0 1,152 216L152,216A8,8 0,0 1,144 224L128,224A8,8 0,0 1,120 216L120,216A8,8 0,0 1,128 208z"
|
||||
android:fillColor="#ff9811"/>
|
||||
<path
|
||||
android:pathData="M272,208L288,208A8,8 0,0 1,296 216L296,216A8,8 0,0 1,288 224L272,224A8,8 0,0 1,264 216L264,216A8,8 0,0 1,272 208z"
|
||||
android:fillColor="#ff9811"/>
|
||||
<path
|
||||
android:pathData="M128,304L144,304A8,8 0,0 1,152 312L152,312A8,8 0,0 1,144 320L128,320A8,8 0,0 1,120 312L120,312A8,8 0,0 1,128 304z"
|
||||
android:fillColor="#ff9811"/>
|
||||
<path
|
||||
android:pathData="M272,304L288,304A8,8 0,0 1,296 312L296,312A8,8 0,0 1,288 320L272,320A8,8 0,0 1,264 312L264,312A8,8 0,0 1,272 304z"
|
||||
android:fillColor="#ff9811"/>
|
||||
<path
|
||||
android:pathData="M160,272v24c0,8 4,14 9,19l5,-6 5,10a21,21 0,0 0,10 0l5,-10 5,6c6,-5 9,-11 9,-19v-24h-9l-5,8 -5,-8h-10l-5,8 -5,-8z"
|
||||
android:fillColor="#ff9811"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M122,252h172m-172,24h28m116,0h28"/>
|
||||
<path
|
||||
android:pathData="M122,248a4,4 0,0 0,-4 4,4 4,0 0,0 4,4h172a4,4 0,0 0,4 -4,4 4,0 0,0 -4,-4zM122,272a4,4 0,0 0,-4 4,4 4,0 0,0 4,4h28a4,4 0,0 0,4 -4,4 4,0 0,0 -4,-4zM266,272a4,4 0,0 0,-4 4,4 4,0 0,0 4,4h28a4,4 0,0 0,4 -4,4 4,0 0,0 -4,-4z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M196,168c-7,0 -13,5 -15,11l-5,-1c-9,0 -16,7 -16,16s7,16 16,16c7,0 13,-4 15,-11a16,16 0,0 0,17 -4,16 16,0 0,0 17,4 16,16 0,1 0,10 -20,16 16,0 0,0 -27,-5c-3,-4 -7,-6 -12,-6zM196,176c5,0 8,4 8,8 0,5 -3,8 -8,8 -4,0 -8,-3 -8,-8 0,-4 4,-8 8,-8zM220,176c5,0 8,4 8,8 0,5 -3,8 -8,8 -4,0 -8,-3 -8,-8 0,-4 4,-8 8,-8zM176,186 L180,187 184,195c0,4 -4,7 -8,7s-8,-3 -8,-8c0,-4 4,-8 8,-8zM240,186c5,0 8,4 8,8 0,5 -3,8 -8,8 -4,0 -8,-3 -8,-7l4,-8z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M200,160h16v32h-16z"
|
||||
android:fillColor="#ff9811"/>
|
||||
<path
|
||||
android:pathData="M208,224h48v48h-48z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="m248,208 l-8,8h-64l-8,-8c0,-13 18,-24 40,-24s40,11 40,24zM160,224h48v48h-48z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M232,232L232,232A10,10 0,0 1,242 242L242,254A10,10 0,0 1,232 264L232,264A10,10 0,0 1,222 254L222,242A10,10 0,0 1,232 232z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M168,232v8h8v16h-8v8h32v-8h-8v-16h8v-8zM176,216h64v8h-64z"
|
||||
android:fillColor="#ff9811"/>
|
||||
<path
|
||||
android:pathData="M186,202m-6,0a6,6 0,1 1,12 0a6,6 0,1 1,-12 0"
|
||||
android:fillColor="#ffda44"/>
|
||||
<path
|
||||
android:pathData="M208,202m-6,0a6,6 0,1 1,12 0a6,6 0,1 1,-12 0"
|
||||
android:fillColor="#ffda44"/>
|
||||
<path
|
||||
android:pathData="M230,202m-6,0a6,6 0,1 1,12 0a6,6 0,1 1,-12 0"
|
||||
android:fillColor="#ffda44"/>
|
||||
<path
|
||||
android:pathData="M169,272v43a24,24 0,0 0,10 4v-47h-10zM189,272v47a24,24 0,0 0,10 -4v-43h-10z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M208,272m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"
|
||||
android:fillColor="#338af3"/>
|
||||
<path
|
||||
android:pathData="M272,320L288,320A8,8 0,0 1,296 328L296,328A8,8 0,0 1,288 336L272,336A8,8 0,0 1,264 328L264,328A8,8 0,0 1,272 320z"
|
||||
android:fillColor="#338af3"/>
|
||||
<path
|
||||
android:pathData="M128,320L144,320A8,8 0,0 1,152 328L152,328A8,8 0,0 1,144 336L128,336A8,8 0,0 1,120 328L120,328A8,8 0,0 1,128 320z"
|
||||
android:fillColor="#338af3"/>
|
||||
</group>
|
||||
</vector>
|
||||
16
app/src/main/res/drawable/language_swedish.xml
Normal file
16
app/src/main/res/drawable/language_swedish.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,0h133.6l35.3,16.7L200.3,0H512v222.6l-22.6,31.7 22.6,35.1V512H200.3l-32,-19.8 -34.7,19.8H0V289.4l22.1,-33.3L0,222.6z"
|
||||
android:fillColor="#0052b4"/>
|
||||
<path
|
||||
android:pathData="M133.6,0v222.6H0v66.8h133.6V512h66.7V289.4H512v-66.8H200.3V0z"
|
||||
android:fillColor="#ffda44"/>
|
||||
</group>
|
||||
</vector>
|
||||
19
app/src/main/res/drawable/language_thai.xml
Normal file
19
app/src/main/res/drawable/language_thai.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v89l-79.2,163.7L512,423v89H0v-89l82.7,-169.6L0,89z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="M0,89h512v78l-42.6,91.2L512,345v78H0v-78l40,-92.5L0,167z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M0,167h512v178H0z"
|
||||
android:fillColor="#0052b4"/>
|
||||
</group>
|
||||
</vector>
|
||||
19
app/src/main/res/drawable/language_turkish.xml
Normal file
19
app/src/main/res/drawable/language_turkish.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v512H0z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="m245.5,209.2 l21,29 34,-11.1 -21,29 21,28.9 -34,-11.1 -21,29V267l-34,-11.1 34,-11z"
|
||||
android:fillColor="#eee"/>
|
||||
<path
|
||||
android:pathData="M188.2,328.3a72.3,72.3 0,1 1,34.4 -136,89 89,0 1,0 0,127.3 72,72 0,0 1,-34.4 8.7z"
|
||||
android:fillColor="#eee"/>
|
||||
</group>
|
||||
</vector>
|
||||
16
app/src/main/res/drawable/language_ukrainian.xml
Normal file
16
app/src/main/res/drawable/language_ukrainian.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="m0,256 l258,-39.4L512,256v256H0z"
|
||||
android:fillColor="#ffda44"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v256H0z"
|
||||
android:fillColor="#338af3"/>
|
||||
</group>
|
||||
</vector>
|
||||
16
app/src/main/res/drawable/language_vietnamese.xml
Normal file
16
app/src/main/res/drawable/language_vietnamese.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="512dp"
|
||||
android:height="512dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
|
||||
<path
|
||||
android:pathData="M0,0h512v512H0z"
|
||||
android:fillColor="#d80027"/>
|
||||
<path
|
||||
android:pathData="m256,133.6 l27.6,85H373L300.7,271l27.6,85 -72.3,-52.5 -72.3,52.6 27.6,-85 -72.3,-52.6h89.4z"
|
||||
android:fillColor="#ffda44"/>
|
||||
</group>
|
||||
</vector>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.3 KiB |
@@ -1,4 +0,0 @@
|
||||
<!--drawable/numeric.xml-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#fff" android:pathData="M4 17V9H2V7h4v10H4m18-2c0 1.11-0.9 2-2 2h-4v-2h4v-2h-2v-2h2V9h-4V7h4a2 2 0 0 1 2 2v1.5a1.5 1.5 0 0 1-1.5 1.5 1.5 1.5 0 0 1 1.5 1.5V15m-8 0v2H8v-4c0-1.11 0.9-2 2-2h2V9H8V7h4a2 2 0 0 1 2 2v2c0 1.11-0.9 2-2 2h-2v2h4z"/>
|
||||
</vector>
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Pupil, Hitomi.la viewer for Android
|
||||
~ Copyright (C) 2020 tom5079
|
||||
~
|
||||
~ This program is free software: you can redistribute it and/or modify
|
||||
~ it under the terms of the GNU General Public License as published by
|
||||
~ the Free Software Foundation, either version 3 of the License, or
|
||||
~ (at your option) any later version.
|
||||
~
|
||||
~ This program is distributed in the hope that it will be useful,
|
||||
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
~ GNU General Public License for more details.
|
||||
~
|
||||
~ You should have received a copy of the GNU General Public License
|
||||
~ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<resources></resources>
|
||||
@@ -4,7 +4,7 @@
|
||||
<string name="galleryblock_series">シリーズ: %1$s</string>
|
||||
<string name="galleryblock_type">タイプ: %1$s</string>
|
||||
<string name="main_no_result">結果なし</string>
|
||||
<string name="search_hint">検索…</string>
|
||||
<string name="search_hint">ギャラリー検索</string>
|
||||
<string name="settings_clear_cache">キャッシュクリア</string>
|
||||
<string name="settings_clear_cache_alert_message">キャッシュをクリアするとイメージのロード速度に影響を与えます。実行しますか?</string>
|
||||
<string name="settings_storage_usage">%s使用中</string>
|
||||
@@ -14,6 +14,11 @@
|
||||
<string name="settings_search_title">検索設定</string>
|
||||
<string name="settings_title">設定</string>
|
||||
<string name="update_notification_description">アップデートダウンロード中</string>
|
||||
<string name="doujinshi">同人誌</string>
|
||||
<string name="manga">漫画</string>
|
||||
<string name="artist_cg">アーティストCG</string>
|
||||
<string name="game_cg">ゲームCG</string>
|
||||
<string name="image_set">イメージまとめ</string>
|
||||
<string name="update_title">新しいアップデートがあります</string>
|
||||
<string name="warning">注意</string>
|
||||
<string name="settings_miscellaneous_title">その他</string>
|
||||
@@ -21,8 +26,9 @@
|
||||
<string name="settings_clear_history">履歴を削除</string>
|
||||
<string name="settings_clear_history_alert_message">履歴を削除しますか?</string>
|
||||
<string name="settings_clear_history_summary">履歴数: %1$d</string>
|
||||
<string name="main_drawer_history">履歴</string>
|
||||
<string name="main_drawer_home">トップ</string>
|
||||
<string name="main_destination_history">履歴</string>
|
||||
<string name="notification_denied">通知を無効にするとバックグラウンドダウンロード及びアプリのアップデート機能が使用不可になります。</string>
|
||||
<string name="main_destination_search">トップ</string>
|
||||
<string name="update_release_note"># リリースノート(v%1$s)\n%2$s</string>
|
||||
<string name="settings_security_mode_title">セキュリティーモード</string>
|
||||
<string name="settings_security_mode_summary">アプリ履歴でアプリの画面を表示しない</string>
|
||||
@@ -32,6 +38,8 @@
|
||||
<string name="default_query_dialog_filter_guro">グロフィルター</string>
|
||||
<string name="default_query_dialog_language">"言語: "</string>
|
||||
<string name="default_query_dialog_title">デフォルトキーワード設定</string>
|
||||
<string name="main_destination_settings">設定</string>
|
||||
<string name="main_open_navigation_drawer">メニューを開く</string>
|
||||
<string name="main_drawer_group_contact_title">お問い合わせ先</string>
|
||||
<string name="main_drawer_group_contact_homepage">ホームページ</string>
|
||||
<string name="main_drawer_group_contact_help">ヘルプ</string>
|
||||
@@ -44,7 +52,7 @@
|
||||
<string name="reader_notification_text">ダウンロード中…</string>
|
||||
<string name="reader_notification_complete">ダウンロード完了</string>
|
||||
<string name="reader_fab_download_cancel">バックグラウンドダウンロード中止</string>
|
||||
<string name="main_drawer_downloads">ダウンロード</string>
|
||||
<string name="main_destination_downloads">ダウンロード</string>
|
||||
<string name="main_jump_title">ページ移動</string>
|
||||
<string name="main_jump_message">現ページ番号: %1$d\nページ数: %2$d</string>
|
||||
<string name="unable_to_connect">hitomi.laに接続できません</string>
|
||||
@@ -52,7 +60,7 @@
|
||||
<string name="settings_clear_downloads">ダウンロード削除</string>
|
||||
<string name="settings_clear_downloads_alert_message">ダウンロードしたギャラリーを全て削除します。\n実行しますか?</string>
|
||||
<string name="settings_mirror_summary">ミラーサーバからイメージをロード</string>
|
||||
<string name="main_drawer_favorite">ブックマーク</string>
|
||||
<string name="main_destination_favorites">ブックマーク</string>
|
||||
<string name="main_open_gallery_by_id">ギャラリー番号で見る</string>
|
||||
<string name="reader_failed_to_find_gallery">エラーが発生しました</string>
|
||||
<string name="settings_storage">ストレージ</string>
|
||||
@@ -77,6 +85,7 @@
|
||||
<string name="lock_corrupted">ロックファイルが破損されています。Pupilを再再インストールしてください。</string>
|
||||
<string name="settings_dark_mode_title">ダークモード</string>
|
||||
<string name="settings_dark_mode_summary">夜にシコりたい方々へ</string>
|
||||
<string name="search_add_query_item">追加</string>
|
||||
<string name="gallery_details">ギャラリー情報</string>
|
||||
<string name="gallery_artists">アーティスト</string>
|
||||
<string name="gallery_characters">キャラクター</string>
|
||||
@@ -88,7 +97,7 @@
|
||||
<string name="gallery_related">おすすめ</string>
|
||||
<string name="settings_nomedia_title">イメージを隠す</string>
|
||||
<string name="main_delete">削除</string>
|
||||
<string name="main_download">ダウンロード</string>
|
||||
<string name="download">ダウンロード</string>
|
||||
<string name="settings_backup_title">ブックマークバックアップ</string>
|
||||
<string name="settings_restore_title">ブックマーク復元</string>
|
||||
<string name="settings_backup_file_created">バックアップファイルを作成しました</string>
|
||||
@@ -157,4 +166,12 @@
|
||||
<string name="settings_max_concurrent_download">並列ダウンロード</string>
|
||||
<string name="unaccessible_download_folder">アンドロイド11以上では外部からのアプリ内部空間接近が不可能です。ダウンロードフォルダを変更しますか?</string>
|
||||
<string name="settings_networking">ネットワーク</string>
|
||||
<string name="settings_recover_downloads">ダウンロードデータベースを再構築</string>
|
||||
<string name="main_close_navigation_drawer">メニューを閉じる</string>
|
||||
<string name="search_remove_query_item_description">検索構文を除去</string>
|
||||
<string name="search_add_query_item_tag">タグ</string>
|
||||
<string name="move_to_page">%1$d ページへ移動</string>
|
||||
<string name="search_bar_edit_tag">タッチして編集</string>
|
||||
<string name="main_destination_image_viewer">イメージビューア</string>
|
||||
<string name="not_implemented">この機能はまだ実装されていません</string>
|
||||
</resources>
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Pupil, Hitomi.la viewer for Android
|
||||
~ Copyright (C) 2020 tom5079
|
||||
~
|
||||
~ This program is free software: you can redistribute it and/or modify
|
||||
~ it under the terms of the GNU General Public License as published by
|
||||
~ the Free Software Foundation, either version 3 of the License, or
|
||||
~ (at your option) any later version.
|
||||
~
|
||||
~ This program is distributed in the hope that it will be useful,
|
||||
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
~ GNU General Public License for more details.
|
||||
~
|
||||
~ You should have received a copy of the GNU General Public License
|
||||
~ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<resources></resources>
|
||||
@@ -3,7 +3,7 @@
|
||||
<string name="galleryblock_language">언어: %1$s</string>
|
||||
<string name="galleryblock_series">시리즈: %1$s</string>
|
||||
<string name="galleryblock_type">종류: %1$s</string>
|
||||
<string name="search_hint">검색…</string>
|
||||
<string name="search_hint">갤러리 검색</string>
|
||||
<string name="settings_default_query">기본 검색어</string>
|
||||
<string name="settings_clear_cache">캐시 정리하기</string>
|
||||
<string name="settings_clear_cache_alert_message">캐시를 정리하면 이미지 로딩속도가 느려질 수 있습니다. 계속하시겠습니까?</string>
|
||||
@@ -13,6 +13,11 @@
|
||||
<string name="settings_search_title">검색 설정</string>
|
||||
<string name="settings_title">설정</string>
|
||||
<string name="update_notification_description">업데이트 다운로드중…</string>
|
||||
<string name="doujinshi">동인지</string>
|
||||
<string name="manga">만화</string>
|
||||
<string name="artist_cg">아티스트 CG</string>
|
||||
<string name="game_cg">게임 CG</string>
|
||||
<string name="image_set">이미지 모음</string>
|
||||
<string name="update_title">업데이트가 있습니다!</string>
|
||||
<string name="warning">경고</string>
|
||||
<string name="main_no_result">결과 없음\n해결법</string>
|
||||
@@ -20,8 +25,9 @@
|
||||
<string name="settings_clear_history">기록 삭제</string>
|
||||
<string name="settings_clear_history_alert_message">기록을 삭제하시겠습니까?</string>
|
||||
<string name="settings_clear_history_summary">기록 %1$d개 저장됨</string>
|
||||
<string name="main_drawer_history">기록</string>
|
||||
<string name="main_drawer_home">홈</string>
|
||||
<string name="main_destination_history">기록</string>
|
||||
<string name="notification_denied">백그라운드 다운로드를 위해서는 알림을 활성화할 필요가 있습니다. 알림을 비활성화하면 백그라운드 다운로드와 앱 업데이트 기능을 사용할 수 없습니다.</string>
|
||||
<string name="main_destination_search">홈</string>
|
||||
<string name="update_release_note"># 릴리즈 노트(v%1$s)\n%2$s</string>
|
||||
<string name="settings_security_mode_summary">최근 앱 목록 창에서 앱 화면을 보이지 않게 합니다</string>
|
||||
<string name="settings_security_mode_title">보안 모드 활성화</string>
|
||||
@@ -35,6 +41,9 @@
|
||||
<string name="main_drawer_group_contact_github">Github</string>
|
||||
<string name="main_drawer_group_contact_help">도움말</string>
|
||||
<string name="main_drawer_group_contact_homepage">홈페이지</string>
|
||||
<string name="main_destination_settings">설정</string>
|
||||
<string name="main_destination_image_viewer">뷰어</string>
|
||||
<string name="main_open_navigation_drawer">메뉴 열기</string>
|
||||
<string name="main_drawer_group_contact_title">문의</string>
|
||||
<string name="reader_fab_fullscreen">전체 화면</string>
|
||||
<string name="channel_download">다운로드</string>
|
||||
@@ -43,14 +52,14 @@
|
||||
<string name="reader_notification_text">다운로드 중…</string>
|
||||
<string name="reader_notification_complete">다운로드 완료</string>
|
||||
<string name="reader_fab_download_cancel">백그라운드 다운로드 취소</string>
|
||||
<string name="main_drawer_downloads">다운로드</string>
|
||||
<string name="main_destination_downloads">다운로드</string>
|
||||
<string name="main_jump_title">페이지 이동</string>
|
||||
<string name="main_jump_message">현재 페이지: %1$d\n페이지 수: %2$d</string>
|
||||
<string name="unable_to_connect">hitomi.la에 연결할 수 없습니다</string>
|
||||
<string name="main_move_to_page">%1$d 페이지로 이동</string>
|
||||
<string name="settings_clear_downloads">다운로드 삭제</string>
|
||||
<string name="settings_clear_downloads_alert_message">다운로드 된 만화를 모두 삭제합니다.\n계속하시겠습니까?</string>
|
||||
<string name="main_drawer_favorite">즐겨찾기</string>
|
||||
<string name="main_destination_favorites">즐겨찾기</string>
|
||||
<string name="main_open_gallery_by_id">갤러리 번호로 열기</string>
|
||||
<string name="reader_failed_to_find_gallery">갤러리를 찾지 못했습니다</string>
|
||||
<string name="settings_storage">저장 공간</string>
|
||||
@@ -75,6 +84,7 @@
|
||||
<string name="lock_corrupted">잠금 파일이 손상되었습니다! 앱을 재설치 해 주시기 바랍니다.</string>
|
||||
<string name="settings_dark_mode_title">다크 모드</string>
|
||||
<string name="settings_dark_mode_summary">딥 다크한 모오드</string>
|
||||
<string name="search_add_query_item">추가</string>
|
||||
<string name="gallery_details">갤러리 정보</string>
|
||||
<string name="gallery_artists">작가</string>
|
||||
<string name="gallery_characters">캐릭터</string>
|
||||
@@ -86,7 +96,7 @@
|
||||
<string name="gallery_thumbnails">미리보기</string>
|
||||
<string name="settings_nomedia_title">이미지 숨기기</string>
|
||||
<string name="main_delete">삭제</string>
|
||||
<string name="main_download">다운로드</string>
|
||||
<string name="download">다운로드</string>
|
||||
<string name="settings_backup_title">즐겨찾기 백업</string>
|
||||
<string name="settings_restore_title">즐겨찾기 복원</string>
|
||||
<string name="settings_backup_file_created">백업 파일을 생성하였습니다</string>
|
||||
@@ -157,4 +167,11 @@
|
||||
<string name="settings_max_concurrent_download">병렬 다운로드</string>
|
||||
<string name="unaccessible_download_folder">안드로이드 11 이상에서는 외부에서 현재 다운로드 폴더에 접근할 수 없습니다. 변경하시겠습니까?</string>
|
||||
<string name="settings_networking">네트워크</string>
|
||||
<string name="settings_recover_downloads">다운로드 데이터베이스 복구</string>
|
||||
<string name="main_close_navigation_drawer">메뉴 닫기</string>
|
||||
<string name="search_remove_query_item_description">검색 구문 제거</string>
|
||||
<string name="search_add_query_item_tag">태그</string>
|
||||
<string name="move_to_page">%1$d 페이지로 이동</string>
|
||||
<string name="search_bar_edit_tag">터치하여 수정</string>
|
||||
<string name="not_implemented">이 기능은 아직 개발 중입니다</string>
|
||||
</resources>
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<string-array name="hitomi_sort_mode">
|
||||
<item>NEWEST</item>
|
||||
<item>POPULAR</item>
|
||||
</string-array>
|
||||
|
||||
</resources>
|
||||
@@ -1,31 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Pupil, Hitomi.la viewer for Android
|
||||
~ Copyright (C) 2020 tom5079
|
||||
~
|
||||
~ This program is free software: you can redistribute it and/or modify
|
||||
~ it under the terms of the GNU General Public License as published by
|
||||
~ the Free Software Foundation, either version 3 of the License, or
|
||||
~ (at your option) any later version.
|
||||
~
|
||||
~ This program is distributed in the hope that it will be useful,
|
||||
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
~ GNU General Public License for more details.
|
||||
~
|
||||
~ You should have received a copy of the GNU General Public License
|
||||
~ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<resources>
|
||||
<declare-styleable name="TagChipGroup">
|
||||
<attr name="maxTag" format="integer"/>
|
||||
</declare-styleable>
|
||||
|
||||
<declare-styleable name="RippleCircleStatus">
|
||||
<attr name="half" format="enum">
|
||||
<enum name="top" value="1"/>
|
||||
<enum name="bottom" value="-1"/>
|
||||
</attr>
|
||||
</declare-styleable>
|
||||
</resources>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="colorPrimary">#4fc3f7</color>
|
||||
<color name="colorPrimaryDark">#0093c4</color>
|
||||
<color name="colorAccent">#D81B60</color>
|
||||
|
||||
<color name="material_orange_500">#ff9800</color>
|
||||
</resources>
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
</resources>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<item name="notification_id_update" type="id" />
|
||||
|
||||
</resources>
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="app_name" translatable="false" tools:override="true">Pupil-Beta</string>
|
||||
<string name="app_name" translatable="false" tools:override="true">Pupil</string>
|
||||
|
||||
<string name="release_url" translatable="false">https://api.github.com/repos/tom5079/Pupil/releases</string>
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
|
||||
<string name="unlimited">Unlimited</string>
|
||||
|
||||
<string name="not_implemented">This feature is not implemented yet</string>
|
||||
|
||||
<string name="copied_to_clipboard">Copied to clipboard</string>
|
||||
|
||||
<string name="channel_download">Download</string>
|
||||
@@ -51,10 +53,16 @@
|
||||
|
||||
<string name="unaccessible_download_folder">From Android 11 and above, current Download folder cannot be accessed by outside apps. Would you like to change the download folder?</string>
|
||||
|
||||
<string name="main_drawer_home">Home</string>
|
||||
<string name="main_drawer_history">History</string>
|
||||
<string name="main_drawer_downloads">Downloads</string>
|
||||
<string name="main_drawer_favorite">Favorites</string>
|
||||
<string name="notification_denied">Notification permission is required for background downloads. If you deny notifications from this app, in-app update and background download will be disabled.</string>
|
||||
|
||||
<string name="main_destination_search">Home</string>
|
||||
<string name="main_destination_history">History</string>
|
||||
<string name="main_destination_downloads">Downloads</string>
|
||||
<string name="main_destination_favorites">Favorites</string>
|
||||
<string name="main_destination_settings">Settings</string>
|
||||
<string name="main_destination_image_viewer">Reader</string>
|
||||
<string name="main_open_navigation_drawer">Open Navigation Drawer</string>
|
||||
<string name="main_close_navigation_drawer">Close Navigation Drawer</string>
|
||||
<string name="main_drawer_group_contact_title">Contact</string>
|
||||
<string name="main_drawer_group_contact_help">Help</string>
|
||||
<string name="main_drawer_group_contact_homepage">Visit homepage</string>
|
||||
@@ -62,8 +70,6 @@
|
||||
<string name="main_drawer_group_contact_email">Email me!</string>
|
||||
<string name="main_drawer_grouop_contact_discord">Discord</string>
|
||||
|
||||
<string name="main_menu_thin">Thin Mode</string>
|
||||
|
||||
<string name="main_menu_sort">Sort</string>
|
||||
<string name="main_menu_sort_newest">Newest</string>
|
||||
<string name="main_menu_sort_popular">Popular</string>
|
||||
@@ -77,20 +83,30 @@
|
||||
|
||||
<string name="main_move_to_page">Move to page %1$d</string>
|
||||
|
||||
<string name="main_download">DOWNLOAD</string>
|
||||
<string name="download">Download</string>
|
||||
<string name="main_delete">DELETE</string>
|
||||
|
||||
<string name="doujinshi">Doujinshi</string>
|
||||
<string name="manga">Manga</string>
|
||||
<string name="artist_cg">Artist CG</string>
|
||||
<string name="game_cg">Game CG</string>
|
||||
<string name="image_set">Image Set</string>
|
||||
|
||||
<string name="update_title">Update available</string>
|
||||
<string name="update_download_completed">Download Completed</string>
|
||||
<string name="update_download_completed_description">Click here to update</string>
|
||||
<string name="update_notification_description">Downloading update…</string>
|
||||
<string name="update_release_note"># Release Note(v%1$s)\n%2$s</string>
|
||||
|
||||
<string name="search_hint">Search…</string>
|
||||
<string name="search_hint">Search galleries</string>
|
||||
<string name="search_all">Search all galleries</string>
|
||||
<string name="search_show_histories">Show histories</string>
|
||||
<string name="search_show_tags">Show favorite tags</string>
|
||||
|
||||
<string name="search_add_query_item">Add</string>
|
||||
<string name="search_add_query_item_tag">Tag</string>
|
||||
<string name="search_remove_query_item_description">Remove query item</string>
|
||||
|
||||
<string name="gallery_details">Details</string>
|
||||
<string name="gallery_thumbnails">Thumbnails</string>
|
||||
<string name="gallery_related">Related Galleries</string>
|
||||
@@ -106,6 +122,11 @@
|
||||
<string name="galleryblock_language">Language: %1$s</string>
|
||||
<string name="galleryblock_pagecount" translatable="false">%dP</string>
|
||||
|
||||
<string name="move_to_page">Move to page %1$d</string>
|
||||
|
||||
<!-- SEARCH BAR -->
|
||||
<string name="search_bar_edit_tag">Touch to edit</string>
|
||||
|
||||
<!-- READER -->
|
||||
|
||||
<string name="reader_loading">Loading</string>
|
||||
@@ -150,6 +171,7 @@
|
||||
<string name="settings_storage_usage_loading">Calculating storage usage…</string>
|
||||
<string name="settings_clear_cache">Clear cache</string>
|
||||
<string name="settings_clear_cache_alert_message">Deleting cache can affect image loading speed. Do you want to continue?</string>
|
||||
<string name="settings_recover_downloads">Reconstruct download database</string>
|
||||
<string name="settings_clear_downloads">Clear downloads</string>
|
||||
<string name="settings_clear_downloads_alert_message">Delete all downloaded galleries.\nDo you want to continue?</string>
|
||||
<string name="settings_clear_history">Clear history</string>
|
||||
@@ -248,5 +270,6 @@
|
||||
<string name="import_old_galleries_notification_text" translatable="false">%1$d/%2$d</string>
|
||||
<string name="import_old_galleries_notification_done">Importing completed</string>
|
||||
<string name="settings_lock_fingerprint_prompt_subtitle">Ah Shit, Here we go again</string>
|
||||
<string name="main_menu_thin">Thin Mode</string>
|
||||
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="NoActionBarAppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
@@ -18,6 +18,7 @@
|
||||
-->
|
||||
|
||||
<paths>
|
||||
<cache-path name="cached_image" path="networkcache/" />
|
||||
<cache-path name="apks" path="apks/" />
|
||||
<external-path name="external" path="."/>
|
||||
<external-files-path name="files" path="."/>
|
||||
<files-path name="files" path="." />
|
||||
</paths>
|
||||
@@ -18,6 +18,9 @@
|
||||
-->
|
||||
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||
</domain-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">ix.io</domain>
|
||||
</domain-config>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2022 tom5079
|
||||
* Copyright (C) 2019 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -16,16 +16,26 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.sources
|
||||
package xyz.quaver.pupil
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
|
||||
data class SourceEntry(
|
||||
val packageName: String,
|
||||
val packagePath: String,
|
||||
val sourceName: String,
|
||||
val sourcePath: String,
|
||||
val sourceDir: String,
|
||||
val icon: Drawable,
|
||||
val version: String
|
||||
)
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import xyz.quaver.pupil.networking.HitomiHttpClient
|
||||
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun test() = runTest {
|
||||
val hitomi = HitomiHttpClient()
|
||||
|
||||
val result = hitomi.getGalleryIDsFromNozomi(null, "index", "all")
|
||||
|
||||
println(result.array())
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2022 tom5079
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil
|
||||
|
||||
import io.ktor.client.engine.mock.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import xyz.quaver.pupil.util.PupilHttpClient
|
||||
import xyz.quaver.pupil.util.RemoteSourceInfo
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import kotlin.random.Random
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PupilHttpClientTest {
|
||||
|
||||
val tempFile = File.createTempFile("pupilhttpclienttest", ".apk").also {
|
||||
it.deleteOnExit()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getRemoteSourceList() = runTest {
|
||||
val expected = buildMap {
|
||||
put("hitomi.la", RemoteSourceInfo("hitomi", "hitomi.la", "0.0.1"))
|
||||
}
|
||||
|
||||
val mockEngine = MockEngine { _ ->
|
||||
respond(Json.encodeToString(expected), headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.contentType))
|
||||
}
|
||||
|
||||
val client = PupilHttpClient(mockEngine)
|
||||
|
||||
assertEquals(expected, client.getRemoteSourceList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadApk() = runTest {
|
||||
val expected = Random.Default.nextBytes(1000000) // 1MB
|
||||
|
||||
val mockEngine = MockEngine { _ ->
|
||||
respond(expected, headers = headersOf(HttpHeaders.ContentType, "application/vnd.android.package-archive"))
|
||||
}
|
||||
|
||||
val client = PupilHttpClient(mockEngine)
|
||||
|
||||
client.downloadFile("http://a/", tempFile).collect()
|
||||
|
||||
assertArrayEquals(expected, tempFile.readBytes())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun latestRelease() = runTest {
|
||||
val expectedVersion = "5.3.7"
|
||||
val expectedApkUrl = "https://github.com/tom5079/Pupil/releases/download/5.3.7/Pupil-v5.3.7.apk"
|
||||
val expectedReleaseNotes = mapOf(
|
||||
Locale.KOREAN to """
|
||||
* 가끔씩 무한로딩 걸리는 현상 수정
|
||||
* 백업시 즐겨찾기 태그도 백업되게 수정
|
||||
* 이전 안드로이드에서 앱이 튕기는 오류 수정
|
||||
""".trimIndent(),
|
||||
Locale.JAPANESE to """
|
||||
* 稀に接続不可になるバグを修正
|
||||
* お気に入りタグを含むようバックアップ機能を修正
|
||||
* 旧バージョンのアンドロイドでアプリがクラッシュするバグを解決
|
||||
""".trimIndent(),
|
||||
Locale.ENGLISH to """
|
||||
* Fixed occasional outage
|
||||
* Updated backup/restore feature to include favorite tags
|
||||
* Fixed app crashing on older Androids
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
val mockEngine = MockEngine { _ ->
|
||||
val response = javaClass.getResource("/releases.json")!!.readText()
|
||||
respond(response)
|
||||
}
|
||||
|
||||
val client = PupilHttpClient(mockEngine)
|
||||
|
||||
val release = client.latestRelease()!!
|
||||
|
||||
assertEquals(expectedVersion, release.version)
|
||||
assertEquals(expectedApkUrl, release.apkUrl)
|
||||
|
||||
println(expectedReleaseNotes)
|
||||
println(release.releaseNotes)
|
||||
assertEquals(expectedReleaseNotes, release.releaseNotes)
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user