Set up macrobenchmark module
This commit is contained in:
1
app/.gitignore
vendored
1
app/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
/build
|
||||
@@ -1,177 +0,0 @@
|
||||
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.TimeZone
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
id("org.jetbrains.kotlin.kapt")
|
||||
id("org.jetbrains.kotlin.plugin.parcelize")
|
||||
id("com.google.devtools.ksp")
|
||||
id("com.mikepenz.aboutlibraries.plugin")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
val acraAuthLogin: String = gradleLocalProperties(rootDir).getProperty("authLogin") ?: "\"acra_login\""
|
||||
val acraAuthPassword: String = gradleLocalProperties(rootDir).getProperty("authPassword") ?: "\"acra_password\""
|
||||
|
||||
android {
|
||||
namespace = "org.xtimms.shirizu"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "org.xtimms.shirizu"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
|
||||
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
|
||||
buildConfigField("String", "BUILD_TIME", "\"${getBuildTime()}\"")
|
||||
|
||||
buildConfigField("String", "ACRA_URI", "\"https://bugs.kotatsu.app/report\"")
|
||||
buildConfigField("String", "ACRA_AUTH_LOGIN", acraAuthLogin)
|
||||
buildConfigField("String", "ACRA_AUTH_PASSWORD", acraAuthPassword)
|
||||
|
||||
testInstrumentationRunner = "org.xtimms.shirizu.HiltTestRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
javaCompileOptions {
|
||||
annotationProcessorOptions {
|
||||
arguments += mapOf(
|
||||
"room.generateKotlin" to "true",
|
||||
"room.schemaLocation" to "$projectDir/schemas"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.1"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-process:2.7.0")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
implementation(platform("dev.chrisbanes.compose:compose-bom:2024.03.00-alpha01"))
|
||||
implementation("androidx.compose.animation:animation-graphics")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material:material-icons-extended:1.6.5")
|
||||
implementation("androidx.compose.material3:material3-android:1.2.1")
|
||||
implementation("androidx.compose.material3:material3-window-size-class:1.2.1")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.7.7")
|
||||
implementation("androidx.preference:preference-ktx:1.2.1")
|
||||
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
implementation("ch.acra:acra-http:5.9.7")
|
||||
implementation("com.github.solkin:disk-lru-cache:1.4")
|
||||
implementation("com.google.android.material:material:1.11.0")
|
||||
implementation("com.google.accompanist:accompanist-flowlayout:0.32.0")
|
||||
implementation("com.google.accompanist:accompanist-systemuicontroller:0.32.0")
|
||||
implementation("com.google.accompanist:accompanist-pager:0.32.0")
|
||||
implementation("com.google.accompanist:accompanist-pager-indicators:0.32.0")
|
||||
implementation("com.google.dagger:hilt-android:2.51")
|
||||
kapt("com.google.dagger:hilt-compiler:2.51")
|
||||
implementation("androidx.hilt:hilt-work:1.2.0")
|
||||
kapt("androidx.hilt:hilt-compiler:1.2.0")
|
||||
implementation("com.github.KotatsuApp:kotatsu-parsers:fec60955ed") {
|
||||
exclude(group = "org.json", module = "json")
|
||||
}
|
||||
implementation("com.mikepenz:aboutlibraries-compose-m3:10.10.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0")
|
||||
implementation("com.squareup.okio:okio:3.8.0")
|
||||
implementation("com.tencent:mmkv:1.3.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.7.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
implementation("me.saket.telephoto:zoomable-image-coil:0.8.0")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00"))
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
androidTestImplementation("com.squareup.moshi:moshi-kotlin:1.15.1")
|
||||
androidTestImplementation("com.google.dagger:hilt-android-testing:2.50")
|
||||
kaptAndroidTest("com.google.dagger:hilt-android-compiler:2.50")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
}
|
||||
|
||||
// Git is needed in your system PATH for these commands to work.
|
||||
// If it's not installed, you can return a random value as a workaround
|
||||
fun Project.getCommitCount(): String {
|
||||
return runCommand("git rev-list --count HEAD")
|
||||
// return "1"
|
||||
}
|
||||
|
||||
fun Project.getGitSha(): String {
|
||||
return runCommand("git rev-parse --short HEAD")
|
||||
// return "1"
|
||||
}
|
||||
|
||||
fun Project.getBuildTime(): String {
|
||||
val df = SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'")
|
||||
df.timeZone = TimeZone.getTimeZone("UTC")
|
||||
return df.format(Date())
|
||||
}
|
||||
|
||||
fun Project.runCommand(command: String): String {
|
||||
val byteOut = ByteArrayOutputStream()
|
||||
project.exec {
|
||||
commandLine = command.split(" ")
|
||||
standardOutput = byteOut
|
||||
}
|
||||
return String(byteOut.toByteArray()).trim()
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
-dontusemixedcaseclassnames
|
||||
-ignorewarnings
|
||||
-verbose
|
||||
|
||||
-keepattributes *Annotation*
|
||||
|
||||
-keepclasseswithmembernames,includedescriptorclasses class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
-keepclassmembers enum * {
|
||||
public static **[] values();
|
||||
public static ** valueOf(java.lang.String);
|
||||
}
|
||||
|
||||
-keepclassmembers class * implements android.os.Parcelable {
|
||||
public static final ** CREATOR;
|
||||
}
|
||||
|
||||
-keep class androidx.annotation.Keep
|
||||
|
||||
-keep @androidx.annotation.Keep class * {*;}
|
||||
|
||||
-keepclasseswithmembers class * {
|
||||
@androidx.annotation.Keep <methods>;
|
||||
}
|
||||
|
||||
-keepclasseswithmembers class * {
|
||||
@androidx.annotation.Keep <fields>;
|
||||
}
|
||||
|
||||
-keepclasseswithmembers class * {
|
||||
@androidx.annotation.Keep <init>(...);
|
||||
}
|
||||
3
app/proguard-rules.pro
vendored
3
app/proguard-rules.pro
vendored
@@ -1,3 +0,0 @@
|
||||
-dontobfuscate
|
||||
|
||||
-keep,allowoptimization class org.xtimms.**
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Read later",
|
||||
"sortKey": 1,
|
||||
"order": "NEWEST",
|
||||
"createdAt": 1335906000000,
|
||||
"isTrackingEnabled": true,
|
||||
"isVisibleInLibrary": true
|
||||
}
|
||||
Binary file not shown.
@@ -1,163 +0,0 @@
|
||||
{
|
||||
"id": -2096681732556647985,
|
||||
"title": "Странствия Эманон",
|
||||
"url": "/stranstviia_emanon",
|
||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||
"rating": 0.9400894,
|
||||
"isNsfw": true,
|
||||
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
|
||||
"tags": [
|
||||
{
|
||||
"title": "Сверхъестественное",
|
||||
"key": "supernatural",
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"title": "Сэйнэн",
|
||||
"key": "seinen",
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"title": "Повседневность",
|
||||
"key": "slice_of_life",
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"title": "Приключения",
|
||||
"key": "adventure",
|
||||
"source": "READMANGA_RU"
|
||||
}
|
||||
],
|
||||
"state": "FINISHED",
|
||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||
"chapters": [
|
||||
{
|
||||
"id": 1552943969433540704,
|
||||
"name": "1 - 1",
|
||||
"number": 1,
|
||||
"url": "/stranstviia_emanon/vol1/1",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 1552943969433540705,
|
||||
"name": "1 - 2",
|
||||
"number": 2,
|
||||
"url": "/stranstviia_emanon/vol1/2",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 1552943969433540706,
|
||||
"name": "1 - 3",
|
||||
"number": 3,
|
||||
"url": "/stranstviia_emanon/vol1/3",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 1552943969433540707,
|
||||
"name": "1 - 4",
|
||||
"number": 4,
|
||||
"url": "/stranstviia_emanon/vol1/4",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 1552943969433540708,
|
||||
"name": "1 - 5",
|
||||
"number": 5,
|
||||
"url": "/stranstviia_emanon/vol1/5",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 1552943969433541665,
|
||||
"name": "2 - 1",
|
||||
"number": 6,
|
||||
"url": "/stranstviia_emanon/vol2/1",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1415570400000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 1552943969433541666,
|
||||
"name": "2 - 2",
|
||||
"number": 7,
|
||||
"url": "/stranstviia_emanon/vol2/2",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1419976800000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 1552943969433541667,
|
||||
"name": "2 - 3",
|
||||
"number": 8,
|
||||
"url": "/stranstviia_emanon/vol2/3",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1427922000000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 1552943969433541668,
|
||||
"name": "2 - 4",
|
||||
"number": 9,
|
||||
"url": "/stranstviia_emanon/vol2/4",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1436907600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 1552943969433541669,
|
||||
"name": "2 - 5",
|
||||
"number": 10,
|
||||
"url": "/stranstviia_emanon/vol2/5",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1446674400000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 1552943969433541670,
|
||||
"name": "2 - 6",
|
||||
"number": 11,
|
||||
"url": "/stranstviia_emanon/vol2/6",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1451512800000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 1552943969433542626,
|
||||
"name": "3 - 1",
|
||||
"number": 12,
|
||||
"url": "/stranstviia_emanon/vol3/1",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1461618000000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 1552943969433542627,
|
||||
"name": "3 - 2",
|
||||
"number": 13,
|
||||
"url": "/stranstviia_emanon/vol3/2",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1461618000000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 1552943969433542628,
|
||||
"name": "3 - 3",
|
||||
"number": 14,
|
||||
"url": "/stranstviia_emanon/vol3/3",
|
||||
"scanlator": "",
|
||||
"uploadDate": 1465851600000,
|
||||
"source": "READMANGA_RU"
|
||||
}
|
||||
],
|
||||
"source": "READMANGA_RU"
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"id": -2096681732556647985,
|
||||
"title": "Странствия Эманон",
|
||||
"url": "/stranstviia_emanon",
|
||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||
"rating": 0.9400894,
|
||||
"isNsfw": true,
|
||||
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
|
||||
"tags": [
|
||||
{
|
||||
"title": "Сверхъестественное",
|
||||
"key": "supernatural",
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"title": "Сэйнэн",
|
||||
"key": "seinen",
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"title": "Повседневность",
|
||||
"key": "slice_of_life",
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"title": "Приключения",
|
||||
"key": "adventure",
|
||||
"source": "READMANGA_RU"
|
||||
}
|
||||
],
|
||||
"state": "FINISHED",
|
||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||
"chapters": [],
|
||||
"source": "READMANGA_RU"
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
{
|
||||
"id": -2096681732556647985,
|
||||
"title": "Странствия Эманон",
|
||||
"url": "/stranstviia_emanon",
|
||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||
"rating": 0.9400894,
|
||||
"isNsfw": true,
|
||||
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
|
||||
"tags": [
|
||||
{
|
||||
"title": "Сверхъестественное",
|
||||
"key": "supernatural",
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"title": "Сэйнэн",
|
||||
"key": "seinen",
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"title": "Повседневность",
|
||||
"key": "slice_of_life",
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"title": "Приключения",
|
||||
"key": "adventure",
|
||||
"source": "READMANGA_RU"
|
||||
}
|
||||
],
|
||||
"state": "FINISHED",
|
||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||
"chapters": [
|
||||
{
|
||||
"id": 3552943969433540704,
|
||||
"name": "1 - 1",
|
||||
"number": 1,
|
||||
"url": "/stranstviia_emanon/vol1/1",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433540705,
|
||||
"name": "1 - 2",
|
||||
"number": 2,
|
||||
"url": "/stranstviia_emanon/vol1/2",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433540706,
|
||||
"name": "1 - 3",
|
||||
"number": 3,
|
||||
"url": "/stranstviia_emanon/vol1/3",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433540707,
|
||||
"name": "1 - 4",
|
||||
"number": 4,
|
||||
"url": "/stranstviia_emanon/vol1/4",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433540708,
|
||||
"name": "1 - 5",
|
||||
"number": 5,
|
||||
"url": "/stranstviia_emanon/vol1/5",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433541665,
|
||||
"name": "2 - 1",
|
||||
"number": 6,
|
||||
"url": "/stranstviia_emanon/vol2/1",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1415570400000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433541666,
|
||||
"name": "2 - 2",
|
||||
"number": 7,
|
||||
"url": "/stranstviia_emanon/vol2/2",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1419976800000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433541667,
|
||||
"name": "2 - 3",
|
||||
"number": 8,
|
||||
"url": "/stranstviia_emanon/vol2/3",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1427922000000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433541668,
|
||||
"name": "2 - 4",
|
||||
"number": 9,
|
||||
"url": "/stranstviia_emanon/vol2/4",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1436907600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433541669,
|
||||
"name": "2 - 5",
|
||||
"number": 10,
|
||||
"url": "/stranstviia_emanon/vol2/5",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1446674400000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433541670,
|
||||
"name": "2 - 6",
|
||||
"number": 11,
|
||||
"url": "/stranstviia_emanon/vol2/6",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1451512800000,
|
||||
"source": "READMANGA_RU"
|
||||
}
|
||||
],
|
||||
"source": "READMANGA_RU"
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
{
|
||||
"id": -2096681732556647985,
|
||||
"title": "Странствия Эманон",
|
||||
"url": "/stranstviia_emanon",
|
||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||
"rating": 0.9400894,
|
||||
"isNsfw": true,
|
||||
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
|
||||
"tags": [
|
||||
{
|
||||
"title": "Сверхъестественное",
|
||||
"key": "supernatural",
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"title": "Сэйнэн",
|
||||
"key": "seinen",
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"title": "Повседневность",
|
||||
"key": "slice_of_life",
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"title": "Приключения",
|
||||
"key": "adventure",
|
||||
"source": "READMANGA_RU"
|
||||
}
|
||||
],
|
||||
"state": "FINISHED",
|
||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||
"chapters": [
|
||||
{
|
||||
"id": 3552943969433540704,
|
||||
"name": "1 - 1",
|
||||
"number": 1,
|
||||
"url": "/stranstviia_emanon/vol1/1",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433540705,
|
||||
"name": "1 - 2",
|
||||
"number": 2,
|
||||
"url": "/stranstviia_emanon/vol1/2",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433540706,
|
||||
"name": "1 - 3",
|
||||
"number": 3,
|
||||
"url": "/stranstviia_emanon/vol1/3",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433540707,
|
||||
"name": "1 - 4",
|
||||
"number": 4,
|
||||
"url": "/stranstviia_emanon/vol1/4",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433540708,
|
||||
"name": "1 - 5",
|
||||
"number": 5,
|
||||
"url": "/stranstviia_emanon/vol1/5",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433541665,
|
||||
"name": "2 - 1",
|
||||
"number": 6,
|
||||
"url": "/stranstviia_emanon/vol2/1",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1415570400000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433541666,
|
||||
"name": "2 - 2",
|
||||
"number": 7,
|
||||
"url": "/stranstviia_emanon/vol2/2",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1419976800000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433541667,
|
||||
"name": "2 - 3",
|
||||
"number": 8,
|
||||
"url": "/stranstviia_emanon/vol2/3",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1427922000000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433541668,
|
||||
"name": "2 - 4",
|
||||
"number": 9,
|
||||
"url": "/stranstviia_emanon/vol2/4",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1436907600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433541669,
|
||||
"name": "2 - 5",
|
||||
"number": 10,
|
||||
"url": "/stranstviia_emanon/vol2/5",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1446674400000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433541670,
|
||||
"name": "2 - 6",
|
||||
"number": 11,
|
||||
"url": "/stranstviia_emanon/vol2/6",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1451512800000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433542626,
|
||||
"name": "3 - 1",
|
||||
"number": 12,
|
||||
"url": "/stranstviia_emanon/vol3/1",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1461618000000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433542627,
|
||||
"name": "3 - 2",
|
||||
"number": 13,
|
||||
"url": "/stranstviia_emanon/vol3/2",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1461618000000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433542628,
|
||||
"name": "3 - 3",
|
||||
"number": 14,
|
||||
"url": "/stranstviia_emanon/vol3/3",
|
||||
"scanlator": "",
|
||||
"uploadDate": 1465851600000,
|
||||
"source": "READMANGA_RU"
|
||||
}
|
||||
],
|
||||
"source": "READMANGA_RU"
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"id": -2096681732556647985,
|
||||
"title": "Странствия Эманон",
|
||||
"url": "/stranstviia_emanon",
|
||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||
"rating": 0.9400894,
|
||||
"isNsfw": true,
|
||||
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
|
||||
"tags": [
|
||||
{
|
||||
"title": "Сверхъестественное",
|
||||
"key": "supernatural",
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"title": "Сэйнэн",
|
||||
"key": "seinen",
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"title": "Повседневность",
|
||||
"key": "slice_of_life",
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"title": "Приключения",
|
||||
"key": "adventure",
|
||||
"source": "READMANGA_RU"
|
||||
}
|
||||
],
|
||||
"state": "FINISHED",
|
||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||
"description": null,
|
||||
"source": "READMANGA_RU"
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
{
|
||||
"id": -2096681732556647985,
|
||||
"title": "Странствия Эманон",
|
||||
"url": "/stranstviia_emanon",
|
||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||
"rating": 0.9400894,
|
||||
"isNsfw": true,
|
||||
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
|
||||
"tags": [
|
||||
{
|
||||
"title": "Сверхъестественное",
|
||||
"key": "supernatural",
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"title": "Сэйнэн",
|
||||
"key": "seinen",
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"title": "Повседневность",
|
||||
"key": "slice_of_life",
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"title": "Приключения",
|
||||
"key": "adventure",
|
||||
"source": "READMANGA_RU"
|
||||
}
|
||||
],
|
||||
"state": "FINISHED",
|
||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||
"chapters": [
|
||||
{
|
||||
"id": 3552943969433540704,
|
||||
"name": "1 - 1",
|
||||
"number": 1,
|
||||
"url": "/stranstviia_emanon/vol1/1",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433540705,
|
||||
"name": "1 - 2",
|
||||
"number": 2,
|
||||
"url": "/stranstviia_emanon/vol1/2",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433540706,
|
||||
"name": "1 - 3",
|
||||
"number": 3,
|
||||
"url": "/stranstviia_emanon/vol1/3",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433540707,
|
||||
"name": "1 - 4",
|
||||
"number": 4,
|
||||
"url": "/stranstviia_emanon/vol1/4",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433540708,
|
||||
"name": "1 - 5",
|
||||
"number": 5,
|
||||
"url": "/stranstviia_emanon/vol1/5",
|
||||
"scanlator": "Sad-Robot",
|
||||
"uploadDate": 1342731600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433541666,
|
||||
"name": "2 - 2",
|
||||
"number": 7,
|
||||
"url": "/stranstviia_emanon/vol2/2",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1419976800000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433541667,
|
||||
"name": "2 - 3",
|
||||
"number": 8,
|
||||
"url": "/stranstviia_emanon/vol2/3",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1427922000000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433541668,
|
||||
"name": "2 - 4",
|
||||
"number": 9,
|
||||
"url": "/stranstviia_emanon/vol2/4",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1436907600000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433541669,
|
||||
"name": "2 - 5",
|
||||
"number": 10,
|
||||
"url": "/stranstviia_emanon/vol2/5",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1446674400000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433541670,
|
||||
"name": "2 - 6",
|
||||
"number": 11,
|
||||
"url": "/stranstviia_emanon/vol2/6",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1451512800000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433542626,
|
||||
"name": "3 - 1",
|
||||
"number": 12,
|
||||
"url": "/stranstviia_emanon/vol3/1",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1461618000000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433542627,
|
||||
"name": "3 - 2",
|
||||
"number": 13,
|
||||
"url": "/stranstviia_emanon/vol3/2",
|
||||
"scanlator": "Sup!",
|
||||
"uploadDate": 1461618000000,
|
||||
"source": "READMANGA_RU"
|
||||
},
|
||||
{
|
||||
"id": 3552943969433542628,
|
||||
"name": "3 - 3",
|
||||
"number": 14,
|
||||
"url": "/stranstviia_emanon/vol3/3",
|
||||
"scanlator": "",
|
||||
"uploadDate": 1465851600000,
|
||||
"source": "READMANGA_RU"
|
||||
}
|
||||
],
|
||||
"source": "READMANGA_RU"
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package org.xtimms.shirizu
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.test.runner.AndroidJUnitRunner
|
||||
import dagger.hilt.android.testing.HiltTestApplication
|
||||
|
||||
class HiltTestRunner : AndroidJUnitRunner() {
|
||||
|
||||
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
|
||||
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package org.xtimms.shirizu
|
||||
|
||||
import android.app.Instrumentation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
suspend fun Instrumentation.awaitForIdle() = suspendCoroutine<Unit> { cont ->
|
||||
waitForIdle { cont.resume(Unit) }
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package org.xtimms.shirizu
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.JsonReader
|
||||
import com.squareup.moshi.JsonWriter
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.ToJson
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.xtimms.shirizu.core.model.FavouriteCategory
|
||||
import java.util.Date
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
object SampleData {
|
||||
|
||||
private val moshi = Moshi.Builder()
|
||||
.add(DateAdapter())
|
||||
.add(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
|
||||
val manga: Manga = loadAsset("manga/header.json", Manga::class)
|
||||
|
||||
val mangaDetails: Manga = loadAsset("manga/full.json", Manga::class)
|
||||
|
||||
val tag = mangaDetails.tags.elementAt(2)
|
||||
|
||||
val chapter = checkNotNull(mangaDetails.chapters)[2]
|
||||
|
||||
val favouriteCategory: FavouriteCategory = loadAsset("categories/simple.json", FavouriteCategory::class)
|
||||
|
||||
fun <T : Any> loadAsset(name: String, cls: KClass<T>): T {
|
||||
val assets = InstrumentationRegistry.getInstrumentation().context.assets
|
||||
return assets.open(name).use {
|
||||
moshi.adapter(cls.java).fromJson(it.source().buffer())
|
||||
} ?: throw RuntimeException("Cannot read asset from json \"$name\"")
|
||||
}
|
||||
|
||||
private class DateAdapter : JsonAdapter<Date>() {
|
||||
|
||||
@FromJson
|
||||
override fun fromJson(reader: JsonReader): Date? {
|
||||
val ms = reader.nextLong()
|
||||
return if (ms == 0L) {
|
||||
null
|
||||
} else {
|
||||
Date(ms)
|
||||
}
|
||||
}
|
||||
|
||||
@ToJson
|
||||
override fun toJson(writer: JsonWriter, value: Date?) {
|
||||
writer.value(value?.time ?: 0L)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package org.xtimms.shirizu.sections.settings.backup
|
||||
|
||||
import android.content.res.AssetManager
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.xtimms.shirizu.SampleData
|
||||
import org.xtimms.shirizu.core.database.ShirizuDatabase
|
||||
import org.xtimms.shirizu.core.database.entity.toMangaTags
|
||||
import org.xtimms.shirizu.data.repository.FavouritesRepository
|
||||
import org.xtimms.shirizu.data.repository.HistoryRepository
|
||||
import org.xtimms.shirizu.data.repository.backup.BackupRepository
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AppBackupAgentTest {
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var historyRepository: HistoryRepository
|
||||
|
||||
@Inject
|
||||
lateinit var favouritesRepository: FavouritesRepository
|
||||
|
||||
@Inject
|
||||
lateinit var backupRepository: BackupRepository
|
||||
|
||||
@Inject
|
||||
lateinit var database: ShirizuDatabase
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
database.clearAllTables()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backupAndRestore() = runTest {
|
||||
val category = favouritesRepository.createCategory(
|
||||
title = SampleData.favouriteCategory.title,
|
||||
sortOrder = SampleData.favouriteCategory.order,
|
||||
isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled,
|
||||
isVisibleOnShelf = SampleData.favouriteCategory.isVisibleInLibrary,
|
||||
)
|
||||
favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga))
|
||||
historyRepository.addOrUpdate(
|
||||
manga = SampleData.mangaDetails,
|
||||
chapterId = SampleData.mangaDetails.chapters!![2].id,
|
||||
page = 3,
|
||||
scroll = 40,
|
||||
percent = 0.2f,
|
||||
)
|
||||
val history = checkNotNull(historyRepository.getOne(SampleData.manga))
|
||||
|
||||
val agent = AppBackupAgent()
|
||||
val backup = agent.createBackupFile(
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
repository = backupRepository,
|
||||
)
|
||||
|
||||
database.clearAllTables()
|
||||
assertTrue(favouritesRepository.getAllManga().isEmpty())
|
||||
assertNull(historyRepository.getLastOrNull())
|
||||
|
||||
backup.inputStream().use {
|
||||
agent.restoreBackupFile(it.fd, backup.length(), backupRepository)
|
||||
}
|
||||
|
||||
assertEquals(category, favouritesRepository.getCategory(category.id))
|
||||
assertEquals(history, historyRepository.getOne(SampleData.manga))
|
||||
assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id))
|
||||
|
||||
val allTags = database.getTagsDao().findTags(SampleData.tag.source.name).toMangaTags()
|
||||
assertTrue(SampleData.tag in allTags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restoreOldBackup() {
|
||||
val agent = AppBackupAgent()
|
||||
val backup = File.createTempFile("backup_", ".tmp")
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open("kotatsu_test.bak", AssetManager.ACCESS_STREAMING)
|
||||
.use { input ->
|
||||
backup.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
backup.inputStream().use {
|
||||
agent.restoreBackupFile(it.fd, backup.length(), backupRepository)
|
||||
}
|
||||
runTest {
|
||||
assertEquals(6, historyRepository.observeAll().first().size)
|
||||
assertEquals(2, favouritesRepository.observeCategories().first().size)
|
||||
assertEquals(15, favouritesRepository.getAllManga().size)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
<uses-permission
|
||||
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="false"
|
||||
android:backupAgent=".sections.settings.backup.AppBackupAgent"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Shirizu"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
tools:targetApi="tiramisu">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/Theme.Shirizu.SplashTheme">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".crash.CrashActivity"
|
||||
android:exported="false"
|
||||
android:process=":error_handler" />
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="remove">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
|
||||
<meta-data
|
||||
android:name="android.webkit.WebView.EnableSafeBrowsing"
|
||||
android:value="false" />
|
||||
<meta-data
|
||||
android:name="android.webkit.WebView.MetricsOptOut"
|
||||
android:value="true" />
|
||||
<meta-data
|
||||
android:name="com.samsung.android.icon_container.has_icon_container"
|
||||
android:value="@bool/com_samsung_android_icon_container_has_icon_container" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
Binary file not shown.
@@ -1,149 +0,0 @@
|
||||
package org.xtimms.shirizu
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import com.tencent.mmkv.MMKV
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.acra.ReportField
|
||||
import org.acra.config.httpSender
|
||||
import org.acra.data.StringFormat
|
||||
import org.acra.ktx.initAcra
|
||||
import org.acra.sender.HttpSender
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.xtimms.shirizu.core.database.ShirizuDatabase
|
||||
import org.xtimms.shirizu.core.prefs.KotatsuAppSettings
|
||||
import org.xtimms.shirizu.core.updates.Updater
|
||||
import org.xtimms.shirizu.crash.CrashActivity
|
||||
import org.xtimms.shirizu.crash.GlobalExceptionHandler
|
||||
import org.xtimms.shirizu.utils.lang.processLifecycleScope
|
||||
import org.xtimms.shirizu.work.WorkScheduleManager
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
@HiltAndroidApp
|
||||
class App : Application(), Configuration.Provider {
|
||||
|
||||
@Inject
|
||||
lateinit var database: Provider<ShirizuDatabase>
|
||||
|
||||
@Inject
|
||||
lateinit var settings: KotatsuAppSettings
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
@Inject
|
||||
lateinit var workScheduleManager: WorkScheduleManager
|
||||
|
||||
@Inject
|
||||
lateinit var workManagerProvider: Provider<WorkManager>
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
MMKV.initialize(this)
|
||||
packageInfo = packageManager.run {
|
||||
if (Build.VERSION.SDK_INT >= 33) getPackageInfo(
|
||||
packageName, PackageManager.PackageInfoFlags.of(0)
|
||||
) else getPackageInfo(packageName, 0)
|
||||
}
|
||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||
connectivityManager = getSystemService()!!
|
||||
|
||||
processLifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Updater.deleteOutdatedApk(this@App)
|
||||
} catch (_: Throwable) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
initAcra {
|
||||
buildConfigClass = BuildConfig::class.java
|
||||
reportFormat = StringFormat.JSON
|
||||
httpSender {
|
||||
uri = BuildConfig.ACRA_URI
|
||||
basicAuthLogin = BuildConfig.ACRA_AUTH_LOGIN
|
||||
basicAuthPassword = BuildConfig.ACRA_AUTH_PASSWORD
|
||||
httpMethod = HttpSender.Method.POST
|
||||
}
|
||||
reportContent = listOf(
|
||||
ReportField.PACKAGE_NAME,
|
||||
ReportField.INSTALLATION_ID,
|
||||
ReportField.APP_VERSION_CODE,
|
||||
ReportField.APP_VERSION_NAME,
|
||||
ReportField.ANDROID_VERSION,
|
||||
ReportField.PHONE_MODEL,
|
||||
ReportField.STACK_TRACE,
|
||||
ReportField.CRASH_CONFIGURATION,
|
||||
ReportField.CUSTOM_DATA,
|
||||
)
|
||||
}
|
||||
|
||||
GlobalExceptionHandler.initialize(applicationContext, CrashActivity::class.java)
|
||||
workScheduleManager.init()
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
if (BuildConfig.DEBUG) {
|
||||
enableStrictMode()
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableStrictMode() {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
)
|
||||
StrictMode.setVmPolicy(
|
||||
StrictMode.VmPolicy.Builder()
|
||||
.detectAll()
|
||||
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
lateinit var packageInfo: PackageInfo
|
||||
lateinit var connectivityManager: ConnectivityManager
|
||||
|
||||
fun getVersionReport(): String {
|
||||
val versionName = packageInfo.versionName
|
||||
val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
packageInfo.longVersionCode
|
||||
} else {
|
||||
packageInfo.versionCode.toLong()
|
||||
}
|
||||
val release = if (Build.VERSION.SDK_INT >= 30) {
|
||||
Build.VERSION.RELEASE_OR_CODENAME
|
||||
} else {
|
||||
Build.VERSION.RELEASE
|
||||
}
|
||||
return StringBuilder().append("App version: $versionName ($versionCode)\n")
|
||||
.append("Device information: Android $release (API ${Build.VERSION.SDK_INT})\n")
|
||||
.append("Supported ABIs: ${Build.SUPPORTED_ABIS.contentToString()}\n").toString()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package org.xtimms.shirizu
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.xtimms.shirizu.ui.theme.SEED
|
||||
import org.xtimms.shirizu.core.prefs.AppSettings
|
||||
import org.xtimms.shirizu.core.prefs.DarkThemePreference
|
||||
import org.xtimms.shirizu.core.prefs.paletteStyles
|
||||
import org.xtimms.shirizu.ui.monet.LocalTonalPalettes
|
||||
import org.xtimms.shirizu.ui.monet.PaletteStyle
|
||||
import org.xtimms.shirizu.ui.monet.TonalPalettes.Companion.toTonalPalettes
|
||||
|
||||
data class BottomSheetScrollState(
|
||||
val topPadding: Dp,
|
||||
)
|
||||
|
||||
val LocalBottomSheetScrollState = compositionLocalOf { BottomSheetScrollState(0.dp) }
|
||||
val LocalDarkTheme = compositionLocalOf { DarkThemePreference() }
|
||||
val LocalSeedColor = compositionLocalOf { SEED }
|
||||
val LocalDynamicColorSwitch = compositionLocalOf { false }
|
||||
val LocalPaletteStyleIndex = compositionLocalOf { 0 }
|
||||
val LocalWindowInsets = compositionLocalOf { PaddingValues(0.dp) }
|
||||
|
||||
@Composable
|
||||
fun SettingsProvider(content: @Composable () -> Unit) {
|
||||
AppSettings.AppSettingsStateFlow.collectAsState().value.run {
|
||||
CompositionLocalProvider(
|
||||
LocalDarkTheme provides darkTheme,
|
||||
LocalSeedColor provides seedColor,
|
||||
LocalPaletteStyleIndex provides paletteStyleIndex,
|
||||
LocalTonalPalettes provides if (isDynamicColorEnabled && Build.VERSION.SDK_INT >= 31) dynamicDarkColorScheme(
|
||||
LocalContext.current
|
||||
).toTonalPalettes()
|
||||
else Color(seedColor).toTonalPalettes(
|
||||
paletteStyles.getOrElse(paletteStyleIndex) { PaletteStyle.TonalSpot }
|
||||
),
|
||||
LocalDynamicColorSwitch provides isDynamicColorEnabled,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
package org.xtimms.shirizu
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import coil.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.xtimms.shirizu.core.Navigation
|
||||
import org.xtimms.shirizu.core.components.BottomNavBar
|
||||
import org.xtimms.shirizu.core.components.ContinueReadingButton
|
||||
import org.xtimms.shirizu.core.components.NavigationRail
|
||||
import org.xtimms.shirizu.core.components.TopAppBar
|
||||
import org.xtimms.shirizu.core.logs.FileLogger
|
||||
import org.xtimms.shirizu.core.prefs.AppSettings
|
||||
import org.xtimms.shirizu.core.screens.UpdateDialogImpl
|
||||
import org.xtimms.shirizu.core.updates.Updater
|
||||
import org.xtimms.shirizu.ui.theme.ShirizuTheme
|
||||
import org.xtimms.shirizu.utils.system.setLanguage
|
||||
import org.xtimms.shirizu.utils.system.suspendToast
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val isReady: MutableState<Boolean> = mutableStateOf(false)
|
||||
private val isDone: MutableState<Boolean> = mutableStateOf(false)
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
@Inject
|
||||
lateinit var loggers: Set<@JvmSuppressWildcards FileLogger>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen().setKeepOnScreenCondition { !isDone.value }
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (!isTaskRoot) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
if (Build.VERSION.SDK_INT < 33) {
|
||||
setLanguage(AppSettings.getLocaleFromPreference())
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
val context = LocalContext.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
var updateJob: Job? = null
|
||||
var latestRelease by remember { mutableStateOf(Updater.LatestRelease()) }
|
||||
var showUpdateDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var currentDownloadStatus by remember { mutableStateOf(Updater.DownloadStatus.NotYet as Updater.DownloadStatus) }
|
||||
|
||||
val navController = rememberNavController()
|
||||
val windowSizeClass = calculateWindowSizeClass(this)
|
||||
val isCompactScreen = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact
|
||||
|
||||
val settings =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
Updater.installLatestApk(context)
|
||||
}
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { result ->
|
||||
if (result) {
|
||||
Updater.installLatestApk(context)
|
||||
} else {
|
||||
if (!context.packageManager.canRequestPackageInstalls())
|
||||
settings.launch(
|
||||
Intent(
|
||||
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:${context.packageName}"),
|
||||
)
|
||||
)
|
||||
else
|
||||
Updater.installLatestApk(context)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
isReady.value = true
|
||||
}
|
||||
if (isReady.value) {
|
||||
SettingsProvider {
|
||||
ShirizuTheme(
|
||||
darkTheme = LocalDarkTheme.current.isDarkTheme(),
|
||||
isDynamicColorEnabled = LocalDynamicColorSwitch.current,
|
||||
isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled,
|
||||
) {
|
||||
MainView(
|
||||
coil = coil,
|
||||
loggers = loggers,
|
||||
isCompactScreen = isCompactScreen,
|
||||
navController = navController
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
isDone.value = true
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
if (!AppSettings.isAutoUpdateEnabled())
|
||||
return@LaunchedEffect
|
||||
launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
Updater.checkForUpdate(context)?.let {
|
||||
latestRelease = it
|
||||
showUpdateDialog = true
|
||||
}
|
||||
}.onFailure {
|
||||
it.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showUpdateDialog) {
|
||||
UpdateDialogImpl(
|
||||
onDismissRequest = {
|
||||
showUpdateDialog = false
|
||||
updateJob?.cancel()
|
||||
},
|
||||
title = latestRelease.name.toString(),
|
||||
onConfirmUpdate = {
|
||||
updateJob = scope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
Updater.downloadApk(context, latestRelease)
|
||||
.collect { downloadStatus ->
|
||||
currentDownloadStatus = downloadStatus
|
||||
if (downloadStatus is Updater.DownloadStatus.Finished) {
|
||||
launcher.launch(Manifest.permission.REQUEST_INSTALL_PACKAGES)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
it.printStackTrace()
|
||||
currentDownloadStatus = Updater.DownloadStatus.NotYet
|
||||
context.suspendToast(R.string.app_update_failed)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
},
|
||||
releaseNote = latestRelease.body.toString(),
|
||||
downloadStatus = currentDownloadStatus
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
putDataToExtras(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
putDataToExtras(intent)
|
||||
super.onNewIntent(intent)
|
||||
}
|
||||
|
||||
private fun putDataToExtras(intent: Intent?) {
|
||||
intent?.putExtra(EXTRA_DATA, intent.data)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
const val EXTRA_DATA = "data"
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainView(
|
||||
coil: ImageLoader,
|
||||
loggers: Set<FileLogger>,
|
||||
isCompactScreen: Boolean,
|
||||
navController: NavHostController,
|
||||
) {
|
||||
val bottomBarState = remember { mutableStateOf(true) }
|
||||
val topBarOffsetY = remember { Animatable(0f) }
|
||||
|
||||
val scroll = rememberLazyListState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (isCompactScreen) {
|
||||
val isScrolled by remember {
|
||||
derivedStateOf { scroll.firstVisibleItemScrollOffset > 0 }
|
||||
}
|
||||
val animatedBgAlpha by animateFloatAsState(
|
||||
if (isScrolled) 1f else 0f,
|
||||
label = "Top Bar Background",
|
||||
)
|
||||
val animatedSearchBarColor by animateColorAsState(
|
||||
if (isScrolled) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
|
||||
label = "Top Bar Background",
|
||||
)
|
||||
TopAppBar(
|
||||
navController = navController,
|
||||
modifier = Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(0.dp, 16.dp),
|
||||
backgroundAlphaProvider = { animatedBgAlpha },
|
||||
searchBarColorProvider = { animatedSearchBarColor }
|
||||
)
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
if (isCompactScreen) {
|
||||
BottomNavBar(
|
||||
navController = navController,
|
||||
bottomBarState = bottomBarState,
|
||||
topBarOffsetY = topBarOffsetY,
|
||||
)
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
ContinueReadingButton(navController = navController)
|
||||
},
|
||||
contentWindowInsets = WindowInsets.systemBars
|
||||
.only(WindowInsetsSides.Horizontal)
|
||||
) { padding ->
|
||||
if (!isCompactScreen) {
|
||||
val systemBarsPadding = WindowInsets.systemBars.asPaddingValues()
|
||||
Row(
|
||||
modifier = Modifier.padding(padding)
|
||||
) {
|
||||
NavigationRail(
|
||||
navController = navController
|
||||
)
|
||||
Navigation(
|
||||
coil = coil,
|
||||
loggers = loggers,
|
||||
navController = navController,
|
||||
isCompactScreen = false,
|
||||
modifier = Modifier.nestedScroll(TopAppBarDefaults.pinnedScrollBehavior().nestedScrollConnection),
|
||||
padding = PaddingValues(
|
||||
start = padding.calculateStartPadding(LocalLayoutDirection.current),
|
||||
top = systemBarsPadding.calculateTopPadding(),
|
||||
end = padding.calculateEndPadding(LocalLayoutDirection.current),
|
||||
bottom = systemBarsPadding.calculateBottomPadding()
|
||||
),
|
||||
listState = scroll
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Navigation(
|
||||
coil = coil,
|
||||
loggers = loggers,
|
||||
navController = navController,
|
||||
isCompactScreen = true,
|
||||
modifier = Modifier.padding(
|
||||
start = padding.calculateStartPadding(LocalLayoutDirection.current),
|
||||
end = padding.calculateEndPadding(LocalLayoutDirection.current),
|
||||
),
|
||||
padding = padding,
|
||||
listState = scroll
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package org.xtimms.shirizu
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.text.Html
|
||||
import androidx.work.WorkManager
|
||||
import coil.ComponentRegistry
|
||||
import coil.ImageLoader
|
||||
import coil.disk.DiskCache
|
||||
import coil.util.DebugLogger
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.xtimms.shirizu.core.cache.CacheDir
|
||||
import org.xtimms.shirizu.core.cache.ContentCache
|
||||
import org.xtimms.shirizu.core.cache.MemoryContentCache
|
||||
import org.xtimms.shirizu.core.cache.StubContentCache
|
||||
import org.xtimms.shirizu.core.database.ShirizuDatabase
|
||||
import org.xtimms.shirizu.core.model.LocalManga
|
||||
import org.xtimms.shirizu.core.network.MangaHttpClient
|
||||
import org.xtimms.shirizu.core.network.interceptors.ImageProxyInterceptor
|
||||
import org.xtimms.shirizu.core.os.NetworkState
|
||||
import org.xtimms.shirizu.core.parser.MangaLoaderContextImpl
|
||||
import org.xtimms.shirizu.core.parser.MangaRepository
|
||||
import org.xtimms.shirizu.core.parser.favicon.FaviconFetcher
|
||||
import org.xtimms.shirizu.core.parser.local.LocalStorageChanges
|
||||
import org.xtimms.shirizu.sections.reader.thumbnails.MangaPageFetcher
|
||||
import org.xtimms.shirizu.utils.CoilImageGetter
|
||||
import org.xtimms.shirizu.utils.system.connectivityManager
|
||||
import org.xtimms.shirizu.utils.system.isLowRamDevice
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface ShirizuModule {
|
||||
|
||||
@Binds
|
||||
fun bindMangaLoaderContext(mangaLoaderContextImpl: MangaLoaderContextImpl): MangaLoaderContext
|
||||
|
||||
@Binds
|
||||
fun bindImageGetter(coilImageGetter: CoilImageGetter): Html.ImageGetter
|
||||
|
||||
companion object {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideNetworkState(
|
||||
@ApplicationContext context: Context
|
||||
) = NetworkState(context.connectivityManager)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMangaDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
): ShirizuDatabase {
|
||||
return ShirizuDatabase(context)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCoil(
|
||||
@ApplicationContext context: Context,
|
||||
@MangaHttpClient okHttpClient: OkHttpClient,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
imageProxyInterceptor: ImageProxyInterceptor,
|
||||
pageFetcherFactory: MangaPageFetcher.Factory,
|
||||
): ImageLoader {
|
||||
val diskCacheFactory = {
|
||||
val rootDir = context.externalCacheDir ?: context.cacheDir
|
||||
DiskCache.Builder()
|
||||
.directory(rootDir.resolve(CacheDir.THUMBS.dir))
|
||||
.build()
|
||||
}
|
||||
return ImageLoader.Builder(context)
|
||||
.crossfade(500)
|
||||
.okHttpClient(okHttpClient.newBuilder().cache(null).build())
|
||||
.interceptorDispatcher(Dispatchers.Default)
|
||||
.fetcherDispatcher(Dispatchers.IO)
|
||||
.decoderDispatcher(Dispatchers.Default)
|
||||
.transformationDispatcher(Dispatchers.Default)
|
||||
.diskCache(diskCacheFactory)
|
||||
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
||||
.allowRgb565(context.isLowRamDevice())
|
||||
.components(
|
||||
ComponentRegistry.Builder()
|
||||
.add(FaviconFetcher.Factory(context, okHttpClient, mangaRepositoryFactory))
|
||||
.add(pageFetcherFactory)
|
||||
.add(imageProxyInterceptor)
|
||||
.build(),
|
||||
).build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideContentCache(
|
||||
application: Application,
|
||||
): ContentCache {
|
||||
return if (application.isLowRamDevice()) {
|
||||
StubContentCache()
|
||||
} else {
|
||||
MemoryContentCache(application)
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@LocalStorageChanges
|
||||
fun provideMutableLocalStorageChangesFlow(): MutableSharedFlow<LocalManga?> = MutableSharedFlow()
|
||||
|
||||
@Provides
|
||||
@LocalStorageChanges
|
||||
fun provideLocalStorageChangesFlow(
|
||||
@LocalStorageChanges flow: MutableSharedFlow<LocalManga?>,
|
||||
): SharedFlow<LocalManga?> = flow.asSharedFlow()
|
||||
|
||||
@Provides
|
||||
fun provideWorkManager(
|
||||
@ApplicationContext context: Context,
|
||||
): WorkManager = WorkManager.getInstance(context)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package org.xtimms.shirizu.core
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.painter.ColorPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.utils.composable.rememberResourceBitmapPainter
|
||||
|
||||
@Composable
|
||||
fun AsyncImageImpl(
|
||||
coil: ImageLoader,
|
||||
model: Any? = null,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform,
|
||||
onState: ((AsyncImagePainter.State) -> Unit)? = null,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
contentScale: ContentScale = ContentScale.Crop,
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
|
||||
isPreview: Boolean = false,
|
||||
) {
|
||||
if (isPreview) Image(
|
||||
painter = painterResource(R.drawable.sample),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
colorFilter = colorFilter,
|
||||
)
|
||||
else AsyncImage(
|
||||
imageLoader = coil,
|
||||
model = model,
|
||||
placeholder = ColorPainter(Color(0x1F888888)),
|
||||
error = rememberResourceBitmapPainter(id = R.drawable.cover_error),
|
||||
fallback = rememberResourceBitmapPainter(id = R.drawable.cover_loading),
|
||||
modifier = modifier,
|
||||
contentScale = contentScale,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package org.xtimms.shirizu.core
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Explore
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.LocalLibrary
|
||||
import androidx.compose.material.icons.outlined.Explore
|
||||
import androidx.compose.material.icons.outlined.History
|
||||
import androidx.compose.material.icons.outlined.LocalLibrary
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.sections.explore.EXPLORE_DESTINATION
|
||||
import org.xtimms.shirizu.sections.history.HISTORY_DESTINATION
|
||||
import org.xtimms.shirizu.sections.shelf.SHELF_DESTINATION
|
||||
|
||||
sealed class BottomNavDestination(
|
||||
val value: String,
|
||||
val route: String,
|
||||
@StringRes val title: Int,
|
||||
val icon: ImageVector,
|
||||
val iconSelected: ImageVector,
|
||||
) {
|
||||
data object Shelf : BottomNavDestination(
|
||||
value = "shelf",
|
||||
route = SHELF_DESTINATION,
|
||||
title = R.string.nav_shelf,
|
||||
icon = Icons.Outlined.LocalLibrary,
|
||||
iconSelected = Icons.Filled.LocalLibrary
|
||||
)
|
||||
|
||||
data object History : BottomNavDestination(
|
||||
value = "history",
|
||||
route = HISTORY_DESTINATION,
|
||||
title = R.string.nav_history,
|
||||
icon = Icons.Outlined.History,
|
||||
iconSelected = Icons.Filled.History
|
||||
)
|
||||
|
||||
data object Explore : BottomNavDestination(
|
||||
value = "explore",
|
||||
route = EXPLORE_DESTINATION,
|
||||
title = R.string.nav_explore,
|
||||
icon = Icons.Outlined.Explore,
|
||||
iconSelected = Icons.Filled.Explore
|
||||
)
|
||||
|
||||
companion object {
|
||||
val values = listOf(Shelf, History, Explore)
|
||||
|
||||
val railValues = listOf(Shelf, History, Explore)
|
||||
|
||||
val routes = values.map { it.route }
|
||||
|
||||
fun String.toBottomDestinationIndex() = when (this) {
|
||||
Shelf.value -> 0
|
||||
History.value -> 1
|
||||
Explore.value -> 2
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BottomNavDestination.Icon(selected: Boolean) {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = if (selected) iconSelected else icon,
|
||||
contentDescription = stringResource(title)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package org.xtimms.shirizu.core
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationVector1D
|
||||
import androidx.compose.foundation.gestures.ScrollableState
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
|
||||
fun Modifier.collapsable(
|
||||
state: ScrollableState,
|
||||
topBarHeightPx: Float,
|
||||
topBarOffsetY: Animatable<Float, AnimationVector1D>,
|
||||
) = composed {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(key1 = state.isScrollInProgress) {
|
||||
if (!state.isScrollInProgress && topBarOffsetY.value != 0f && topBarOffsetY.value != -topBarHeightPx) {
|
||||
val half = topBarHeightPx / 2
|
||||
val oldOffsetY = topBarOffsetY.value
|
||||
|
||||
val targetOffsetY = when {
|
||||
abs(topBarOffsetY.value) >= half -> -topBarHeightPx
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
launch {
|
||||
state.animateScrollBy(oldOffsetY - targetOffsetY)
|
||||
}
|
||||
|
||||
launch {
|
||||
topBarOffsetY.animateTo(targetOffsetY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nestedScroll(
|
||||
object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
scope.launch {
|
||||
if (state.canScrollForward) {
|
||||
topBarOffsetY.snapTo(
|
||||
targetValue = (topBarOffsetY.value + available.y).coerceIn(
|
||||
minimumValue = -topBarHeightPx,
|
||||
maximumValue = 0f,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return Offset.Zero
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
package org.xtimms.shirizu.core
|
||||
|
||||
import android.graphics.Path
|
||||
import android.view.animation.PathInterpolator
|
||||
import androidx.compose.animation.core.Easing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.shirizu.core.logs.FileLogger
|
||||
import org.xtimms.shirizu.sections.details.DETAILS_DESTINATION
|
||||
import org.xtimms.shirizu.sections.details.DetailsView
|
||||
import org.xtimms.shirizu.sections.details.FULL_POSTER_DESTINATION
|
||||
import org.xtimms.shirizu.sections.details.FullImageView
|
||||
import org.xtimms.shirizu.sections.details.MANGA_ID_ARGUMENT
|
||||
import org.xtimms.shirizu.sections.details.PICTURES_ARGUMENT
|
||||
import org.xtimms.shirizu.sections.explore.ExploreView
|
||||
import org.xtimms.shirizu.sections.feed.FEED_DESTINATION
|
||||
import org.xtimms.shirizu.sections.feed.FeedView
|
||||
import org.xtimms.shirizu.sections.history.HistoryView
|
||||
import org.xtimms.shirizu.sections.list.LIST_DESTINATION
|
||||
import org.xtimms.shirizu.sections.list.MangaListView
|
||||
import org.xtimms.shirizu.sections.list.PROVIDER_ARGUMENT
|
||||
import org.xtimms.shirizu.sections.reader.READER_DESTINATION
|
||||
import org.xtimms.shirizu.sections.reader.ReaderView
|
||||
import org.xtimms.shirizu.sections.search.SEARCH_DESTINATION
|
||||
import org.xtimms.shirizu.sections.search.SearchHostView
|
||||
import org.xtimms.shirizu.sections.settings.SETTINGS_DESTINATION
|
||||
import org.xtimms.shirizu.sections.settings.SettingsView
|
||||
import org.xtimms.shirizu.sections.settings.about.ABOUT_DESTINATION
|
||||
import org.xtimms.shirizu.sections.settings.about.AboutView
|
||||
import org.xtimms.shirizu.sections.settings.about.LICENSES_DESTINATION
|
||||
import org.xtimms.shirizu.sections.settings.about.LICENSE_CONTENT_ARGUMENT
|
||||
import org.xtimms.shirizu.sections.settings.about.LICENSE_DESTINATION
|
||||
import org.xtimms.shirizu.sections.settings.about.LICENSE_NAME_ARGUMENT
|
||||
import org.xtimms.shirizu.sections.settings.about.LICENSE_WEBSITE_ARGUMENT
|
||||
import org.xtimms.shirizu.sections.settings.about.LicenseView
|
||||
import org.xtimms.shirizu.sections.settings.about.OpenSourceLicensesView
|
||||
import org.xtimms.shirizu.sections.settings.about.UPDATES_DESTINATION
|
||||
import org.xtimms.shirizu.sections.settings.about.UpdateView
|
||||
import org.xtimms.shirizu.sections.settings.advanced.ADVANCED_DESTINATION
|
||||
import org.xtimms.shirizu.sections.settings.advanced.AdvancedView
|
||||
import org.xtimms.shirizu.sections.settings.appearance.APPEARANCE_DESTINATION
|
||||
import org.xtimms.shirizu.sections.settings.appearance.AppearanceView
|
||||
import org.xtimms.shirizu.sections.settings.appearance.DARK_THEME_DESTINATION
|
||||
import org.xtimms.shirizu.sections.settings.appearance.DarkThemeView
|
||||
import org.xtimms.shirizu.sections.settings.appearance.LANGUAGES_DESTINATION
|
||||
import org.xtimms.shirizu.sections.settings.appearance.LanguagesView
|
||||
import org.xtimms.shirizu.sections.settings.backup.BACKUP_RESTORE_DESTINATION
|
||||
import org.xtimms.shirizu.sections.settings.backup.BackupRestoreView
|
||||
import org.xtimms.shirizu.sections.settings.backup.RESTORE_ARGUMENT
|
||||
import org.xtimms.shirizu.sections.settings.backup.RESTORE_DESTINATION
|
||||
import org.xtimms.shirizu.sections.settings.backup.RestoreItemsView
|
||||
import org.xtimms.shirizu.sections.settings.network.NETWORK_DESTINATION
|
||||
import org.xtimms.shirizu.sections.settings.network.NetworkView
|
||||
import org.xtimms.shirizu.sections.settings.shelf.SHELF_SETTINGS_DESTINATION
|
||||
import org.xtimms.shirizu.sections.settings.shelf.ShelfSettingsView
|
||||
import org.xtimms.shirizu.sections.settings.shelf.categories.CATEGORIES_DESTINATION
|
||||
import org.xtimms.shirizu.sections.settings.shelf.categories.CategoriesView
|
||||
import org.xtimms.shirizu.sections.settings.sources.SOURCES_DESTINATION
|
||||
import org.xtimms.shirizu.sections.settings.sources.SourcesView
|
||||
import org.xtimms.shirizu.sections.settings.sources.catalog.CATALOG_DESTINATION
|
||||
import org.xtimms.shirizu.sections.settings.sources.catalog.SourcesCatalogView
|
||||
import org.xtimms.shirizu.sections.settings.storage.STORAGE_DESTINATION
|
||||
import org.xtimms.shirizu.sections.settings.storage.StorageView
|
||||
import org.xtimms.shirizu.sections.shelf.ShelfView
|
||||
import org.xtimms.shirizu.sections.stats.STATS_DESTINATION
|
||||
import org.xtimms.shirizu.sections.stats.StatsView
|
||||
import org.xtimms.shirizu.sections.suggestions.SUGGESTIONS_DESTINATION
|
||||
import org.xtimms.shirizu.sections.suggestions.SuggestionsView
|
||||
import org.xtimms.shirizu.utils.StringArrayNavType
|
||||
import org.xtimms.shirizu.utils.lang.removeFirstAndLast
|
||||
|
||||
const val DURATION_ENTER = 400
|
||||
const val DURATION_EXIT = 200
|
||||
const val initialOffset = 0.10f
|
||||
|
||||
fun PathInterpolator.toEasing(): Easing {
|
||||
return Easing { f -> this.getInterpolation(f) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Navigation(
|
||||
coil: ImageLoader,
|
||||
loggers: Set<FileLogger>,
|
||||
navController: NavHostController,
|
||||
isCompactScreen: Boolean,
|
||||
modifier: Modifier,
|
||||
padding: PaddingValues,
|
||||
listState: LazyListState,
|
||||
) {
|
||||
|
||||
val navigateBack: () -> Unit = { navController.popBackStack() }
|
||||
|
||||
val navigateToDetails: (Long) -> Unit = {
|
||||
navController.navigate(
|
||||
DETAILS_DESTINATION.replace(MANGA_ID_ARGUMENT, it.toString())
|
||||
)
|
||||
}
|
||||
|
||||
val navigateToLicense: (String, String?, String?) -> Unit = { name, website, content ->
|
||||
navController.navigate(
|
||||
LICENSE_DESTINATION
|
||||
.replace(LICENSE_NAME_ARGUMENT, name)
|
||||
.replace(LICENSE_WEBSITE_ARGUMENT, website.orEmpty())
|
||||
.replace(LICENSE_CONTENT_ARGUMENT, content ?: "No license text")
|
||||
)
|
||||
}
|
||||
|
||||
val path = Path().apply {
|
||||
moveTo(0f, 0f)
|
||||
cubicTo(0.05F, 0F, 0.133333F, 0.06F, 0.166666F, 0.4F)
|
||||
cubicTo(0.208333F, 0.82F, 0.25F, 1F, 1F, 1F)
|
||||
}
|
||||
|
||||
val emphasizePathInterpolator = PathInterpolator(path)
|
||||
val emphasizeEasing = emphasizePathInterpolator.toEasing()
|
||||
|
||||
val enterTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
|
||||
val exitTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
|
||||
val fadeTween = tween<Float>(durationMillis = DURATION_EXIT)
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = BottomNavDestination.Shelf.route,
|
||||
modifier = modifier,
|
||||
enterTransition = {
|
||||
slideInHorizontally(
|
||||
enterTween,
|
||||
initialOffsetX = { (it * initialOffset).toInt() }) + fadeIn(fadeTween)
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(
|
||||
exitTween,
|
||||
targetOffsetX = { -(it * initialOffset).toInt() }) + fadeOut(fadeTween)
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideInHorizontally(
|
||||
enterTween,
|
||||
initialOffsetX = { -(it * initialOffset).toInt() }) + fadeIn(fadeTween)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally(
|
||||
exitTween,
|
||||
targetOffsetX = { (it * initialOffset).toInt() }) + fadeOut(fadeTween)
|
||||
}
|
||||
) {
|
||||
|
||||
composable(BottomNavDestination.Shelf.route) {
|
||||
ShelfView(
|
||||
coil = coil,
|
||||
currentPage = { 2 },
|
||||
showPageTabs = true,
|
||||
padding = padding,
|
||||
navigateToDetails = navigateToDetails,
|
||||
onRefresh = { true },
|
||||
)
|
||||
}
|
||||
|
||||
composable(BottomNavDestination.History.route) {
|
||||
HistoryView(
|
||||
coil = coil,
|
||||
padding = padding,
|
||||
navigateToDetails = navigateToDetails,
|
||||
navigateToReader = { navController.navigate(READER_DESTINATION) },
|
||||
listState = listState
|
||||
)
|
||||
}
|
||||
|
||||
composable(BottomNavDestination.Explore.route) {
|
||||
ExploreView(
|
||||
coil = coil,
|
||||
navigateToDetails = navigateToDetails,
|
||||
navigateToSource = {
|
||||
navController.navigate(
|
||||
LIST_DESTINATION.replace(PROVIDER_ARGUMENT, it.name)
|
||||
)
|
||||
},
|
||||
navigateToSuggestions = { navController.navigate(SUGGESTIONS_DESTINATION) },
|
||||
padding = padding,
|
||||
listState = listState
|
||||
)
|
||||
}
|
||||
|
||||
composable(SEARCH_DESTINATION) {
|
||||
SearchHostView(
|
||||
isCompactScreen = isCompactScreen,
|
||||
padding = if (isCompactScreen) PaddingValues() else padding,
|
||||
navigateBack = navigateBack,
|
||||
)
|
||||
}
|
||||
|
||||
composable(FEED_DESTINATION) {
|
||||
FeedView(
|
||||
coil = coil,
|
||||
navigateBack = navigateBack,
|
||||
navigateToShelf = { navController.navigate(SHELF_SETTINGS_DESTINATION) }
|
||||
)
|
||||
}
|
||||
|
||||
composable(SUGGESTIONS_DESTINATION) {
|
||||
SuggestionsView(
|
||||
coil = coil,
|
||||
navigateBack = navigateBack,
|
||||
navigateToDetails = navigateToDetails
|
||||
)
|
||||
}
|
||||
|
||||
composable(SETTINGS_DESTINATION) {
|
||||
SettingsView(
|
||||
navigateBack = navigateBack,
|
||||
navigateToAppearance = { navController.navigate(APPEARANCE_DESTINATION) },
|
||||
navigateToAbout = { navController.navigate(ABOUT_DESTINATION) },
|
||||
navigateToAdvanced = { navController.navigate(ADVANCED_DESTINATION) },
|
||||
navigateToBackupRestoreSettings = {
|
||||
navController.navigate(
|
||||
BACKUP_RESTORE_DESTINATION
|
||||
)
|
||||
},
|
||||
navigateToMangaSources = { navController.navigate(SOURCES_DESTINATION) },
|
||||
navigateToNetwork = { navController.navigate(NETWORK_DESTINATION) },
|
||||
navigateToShelfSettings = { navController.navigate(SHELF_SETTINGS_DESTINATION) },
|
||||
navigateToStorage = { navController.navigate(STORAGE_DESTINATION) }
|
||||
)
|
||||
}
|
||||
|
||||
composable(APPEARANCE_DESTINATION) {
|
||||
AppearanceView(
|
||||
navigateBack = navigateBack,
|
||||
navigateToDarkTheme = { navController.navigate(DARK_THEME_DESTINATION) },
|
||||
navigateToLanguages = { navController.navigate(LANGUAGES_DESTINATION) }
|
||||
)
|
||||
}
|
||||
|
||||
composable(DARK_THEME_DESTINATION) {
|
||||
DarkThemeView(
|
||||
navigateBack = navigateBack
|
||||
)
|
||||
}
|
||||
|
||||
composable(LANGUAGES_DESTINATION) {
|
||||
LanguagesView(
|
||||
navigateBack = navigateBack
|
||||
)
|
||||
}
|
||||
|
||||
composable(SOURCES_DESTINATION) {
|
||||
SourcesView(
|
||||
navigateBack = navigateBack,
|
||||
navigateToSourcesCatalog = { navController.navigate(CATALOG_DESTINATION) },
|
||||
navigateToSourcesManagement = { /*TODO*/ }
|
||||
)
|
||||
}
|
||||
|
||||
composable(CATALOG_DESTINATION) {
|
||||
SourcesCatalogView(
|
||||
coil = coil,
|
||||
navigateBack = navigateBack,
|
||||
)
|
||||
}
|
||||
|
||||
composable(BACKUP_RESTORE_DESTINATION) {
|
||||
BackupRestoreView(
|
||||
navigateBack = navigateBack,
|
||||
navigateToRestoreScreen = {
|
||||
navController.navigate(RESTORE_DESTINATION.replace(RESTORE_ARGUMENT, it))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = RESTORE_DESTINATION,
|
||||
arguments = listOf(
|
||||
navArgument(RESTORE_ARGUMENT.removeFirstAndLast()) {
|
||||
type = NavType.StringType
|
||||
}
|
||||
)
|
||||
) { navEntry ->
|
||||
RestoreItemsView(
|
||||
uri = navEntry.arguments?.getString(PROVIDER_ARGUMENT.removeFirstAndLast()) ?: "",
|
||||
navigateBack = navigateBack
|
||||
)
|
||||
}
|
||||
|
||||
composable(SHELF_SETTINGS_DESTINATION) {
|
||||
ShelfSettingsView(
|
||||
navigateBack = navigateBack,
|
||||
navigateToCategories = { navController.navigate(CATEGORIES_DESTINATION) }
|
||||
)
|
||||
}
|
||||
|
||||
composable(CATEGORIES_DESTINATION) {
|
||||
CategoriesView(
|
||||
navigateBack = navigateBack,
|
||||
)
|
||||
}
|
||||
|
||||
composable(NETWORK_DESTINATION) {
|
||||
NetworkView(
|
||||
navigateBack = navigateBack,
|
||||
)
|
||||
}
|
||||
|
||||
composable(STORAGE_DESTINATION) {
|
||||
StorageView(
|
||||
navigateBack = navigateBack,
|
||||
)
|
||||
}
|
||||
|
||||
composable(ADVANCED_DESTINATION) {
|
||||
AdvancedView(
|
||||
loggers = loggers,
|
||||
navigateBack = navigateBack,
|
||||
navigateToStats = { navController.navigate(STATS_DESTINATION) }
|
||||
)
|
||||
}
|
||||
|
||||
composable(STATS_DESTINATION) {
|
||||
StatsView(
|
||||
navigateBack = navigateBack
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = LIST_DESTINATION,
|
||||
arguments = listOf(
|
||||
navArgument(PROVIDER_ARGUMENT.removeFirstAndLast()) {
|
||||
type = NavType.StringType
|
||||
}
|
||||
)
|
||||
) { navEntry ->
|
||||
MangaListView(
|
||||
coil = coil,
|
||||
source = navEntry.arguments?.getString(PROVIDER_ARGUMENT.removeFirstAndLast())
|
||||
?.let { source -> MangaSource.valueOf(source) } ?: MangaSource.DUMMY,
|
||||
navigateBack = navigateBack,
|
||||
navigateToDetails = navigateToDetails
|
||||
)
|
||||
}
|
||||
|
||||
composable(ABOUT_DESTINATION) {
|
||||
AboutView(
|
||||
navigateBack = navigateBack,
|
||||
navigateToLicensesPage = { navController.navigate(LICENSES_DESTINATION) },
|
||||
navigateToUpdatePage = { navController.navigate(UPDATES_DESTINATION) }
|
||||
)
|
||||
}
|
||||
|
||||
composable(LICENSES_DESTINATION) {
|
||||
OpenSourceLicensesView(
|
||||
navigateBack = navigateBack,
|
||||
navigateToLicensePage = navigateToLicense
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = LICENSE_DESTINATION,
|
||||
arguments = listOf(
|
||||
navArgument(LICENSE_NAME_ARGUMENT.removeFirstAndLast()) {
|
||||
type = NavType.StringType
|
||||
},
|
||||
navArgument(LICENSE_WEBSITE_ARGUMENT.removeFirstAndLast()) {
|
||||
type = NavType.StringType
|
||||
},
|
||||
navArgument(LICENSE_CONTENT_ARGUMENT.removeFirstAndLast()) {
|
||||
type = NavType.StringType
|
||||
}
|
||||
)
|
||||
) { navEntry ->
|
||||
LicenseView(
|
||||
name = navEntry.arguments?.getString(LICENSE_NAME_ARGUMENT.removeFirstAndLast())
|
||||
.orEmpty(),
|
||||
website = navEntry.arguments?.getString(LICENSE_WEBSITE_ARGUMENT.removeFirstAndLast())
|
||||
.orEmpty(),
|
||||
license = navEntry.arguments?.getString(LICENSE_CONTENT_ARGUMENT.removeFirstAndLast())
|
||||
?: "No license text",
|
||||
navigateBack = navigateBack
|
||||
)
|
||||
}
|
||||
|
||||
composable(UPDATES_DESTINATION) {
|
||||
UpdateView(
|
||||
navigateBack = navigateBack,
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = DETAILS_DESTINATION,
|
||||
arguments = listOf(
|
||||
navArgument(MANGA_ID_ARGUMENT.removeFirstAndLast()) {
|
||||
type = NavType.LongType
|
||||
}
|
||||
),
|
||||
) { navEntry ->
|
||||
DetailsView(
|
||||
coil = coil,
|
||||
mangaId = navEntry.arguments?.getLong(MANGA_ID_ARGUMENT.removeFirstAndLast()) ?: 0L,
|
||||
navigateBack = navigateBack,
|
||||
navigateToFullImage = { pictures ->
|
||||
navController.navigate(
|
||||
FULL_POSTER_DESTINATION.replace(PICTURES_ARGUMENT, pictures)
|
||||
)
|
||||
},
|
||||
navigateToDetails = navigateToDetails,
|
||||
navigateToSource = {
|
||||
navController.navigate(
|
||||
LIST_DESTINATION.replace(PROVIDER_ARGUMENT, it.name)
|
||||
)
|
||||
},
|
||||
navigateToReader = { navController.navigate(READER_DESTINATION) }
|
||||
)
|
||||
}
|
||||
|
||||
composable(READER_DESTINATION) {
|
||||
ReaderView(
|
||||
navigateBack = navigateBack
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
FULL_POSTER_DESTINATION,
|
||||
arguments = listOf(
|
||||
navArgument(PICTURES_ARGUMENT.removeFirstAndLast()) { type = StringArrayNavType }
|
||||
),
|
||||
) { navEntry ->
|
||||
FullImageView(
|
||||
coil = coil,
|
||||
pictures = navEntry.arguments?.getStringArray(PICTURES_ARGUMENT.removeFirstAndLast())
|
||||
?: emptyArray(),
|
||||
navigateBack = navigateBack
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package org.xtimms.shirizu.core.base
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
||||
abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
|
||||
|
||||
var viewBinding: B? = null
|
||||
private set
|
||||
|
||||
@Deprecated("", ReplaceWith("requireViewBinding()"))
|
||||
protected val binding: B
|
||||
get() = requireViewBinding()
|
||||
|
||||
final override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val binding = onCreateViewBinding(layoutInflater, null)
|
||||
viewBinding = binding
|
||||
return MaterialAlertDialogBuilder(requireContext(), theme)
|
||||
.setView(binding.root)
|
||||
.run(::onBuildDialog)
|
||||
.create()
|
||||
.also(::onDialogCreated)
|
||||
}
|
||||
|
||||
final override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
) = viewBinding?.root
|
||||
|
||||
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
onViewBindingCreated(requireViewBinding(), savedInstanceState)
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun onDestroyView() {
|
||||
viewBinding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
open fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder = builder
|
||||
|
||||
open fun onDialogCreated(dialog: AlertDialog) = Unit
|
||||
|
||||
@Deprecated("", ReplaceWith("viewBinding"))
|
||||
protected fun bindingOrNull() = viewBinding
|
||||
|
||||
fun requireViewBinding(): B = checkNotNull(viewBinding) {
|
||||
"Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
|
||||
}
|
||||
|
||||
protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B
|
||||
|
||||
protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package org.xtimms.shirizu.core.base
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.viewbinding.ViewBinding
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
abstract class BaseActivity<B : ViewBinding> :
|
||||
AppCompatActivity() {
|
||||
|
||||
lateinit var viewBinding: B
|
||||
private set
|
||||
|
||||
private var defaultStatusBarColor = Color.TRANSPARENT
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
putDataToExtras(intent)
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
putDataToExtras(intent)
|
||||
super.onNewIntent(intent)
|
||||
}
|
||||
|
||||
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
|
||||
override fun setContentView(layoutResID: Int) {
|
||||
super.setContentView(layoutResID)
|
||||
}
|
||||
|
||||
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
|
||||
override fun setContentView(view: View?) {
|
||||
super.setContentView(view)
|
||||
}
|
||||
|
||||
protected fun setContentView(binding: B) {
|
||||
this.viewBinding = binding
|
||||
super.setContentView(binding.root)
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
dispatchNavigateUp()
|
||||
return true
|
||||
}
|
||||
|
||||
protected fun isDarkAmoledTheme(): Boolean {
|
||||
val uiMode = resources.configuration.uiMode
|
||||
return uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
|
||||
}
|
||||
|
||||
protected open fun dispatchNavigateUp() {
|
||||
val upIntent = parentActivityIntent
|
||||
if (upIntent != null) {
|
||||
if (!navigateUpTo(upIntent)) {
|
||||
startActivity(upIntent)
|
||||
}
|
||||
} else {
|
||||
finishAfterTransition()
|
||||
}
|
||||
}
|
||||
|
||||
private fun putDataToExtras(intent: Intent?) {
|
||||
intent?.putExtra(EXTRA_DATA, intent.data)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val EXTRA_DATA = "data"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package org.xtimms.shirizu.core.base.event
|
||||
|
||||
interface PagedUiEvent : UiEvent {
|
||||
fun loadMore()
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package org.xtimms.shirizu.core.base.event
|
||||
|
||||
interface UiEvent {
|
||||
fun showMessage(message: String?)
|
||||
fun onMessageDisplayed()
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package org.xtimms.shirizu.core.base.state
|
||||
|
||||
abstract class PagedUiState : UiState() {
|
||||
|
||||
abstract val nextPage: String?
|
||||
|
||||
/**
|
||||
* Trigger variable to load more items, be careful to set it to false after loading more
|
||||
*/
|
||||
abstract val loadMore: Boolean
|
||||
|
||||
val canLoadMore get() = nextPage != null && !isLoading
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package org.xtimms.shirizu.core.base.state
|
||||
|
||||
abstract class UiState {
|
||||
abstract val isLoading: Boolean
|
||||
abstract val message: String?
|
||||
|
||||
// These methods are required because we can't have an abstract data class
|
||||
// so we need to manually implement the copy() method
|
||||
|
||||
/**
|
||||
* copy(isLoading = value)
|
||||
*/
|
||||
abstract fun setLoading(value: Boolean): UiState
|
||||
|
||||
/**
|
||||
* copy(message = value)
|
||||
*/
|
||||
abstract fun setMessage(value: String?): UiState
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package org.xtimms.shirizu.core.base.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xtimms.shirizu.core.base.event.UiEvent
|
||||
import org.xtimms.shirizu.core.base.state.UiState
|
||||
import org.xtimms.shirizu.utils.lang.EventFlow
|
||||
import org.xtimms.shirizu.utils.lang.MutableEventFlow
|
||||
import org.xtimms.shirizu.utils.lang.call
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
abstract class BaseViewModel<S : UiState> : ViewModel(), UiEvent {
|
||||
|
||||
@JvmField
|
||||
protected val loadingCounter = MutableStateFlow(0)
|
||||
|
||||
@JvmField
|
||||
protected val errorEvent = MutableEventFlow<Throwable>()
|
||||
|
||||
val onError: EventFlow<Throwable>
|
||||
get() = errorEvent
|
||||
|
||||
protected abstract val mutableUiState: MutableStateFlow<S>
|
||||
val uiState: StateFlow<S> by lazy { mutableUiState.asStateFlow() }
|
||||
|
||||
protected fun launchJob(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
): Job = viewModelScope.launch(context + createErrorHandler(), start, block)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun setLoading(value: Boolean) {
|
||||
mutableUiState.update { it.setLoading(value) as S }
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun showMessage(message: String?) {
|
||||
mutableUiState.update { it.setMessage(message ?: GENERIC_ERROR) as S }
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun onMessageDisplayed() {
|
||||
mutableUiState.update { it.setMessage(null) as S }
|
||||
}
|
||||
|
||||
protected fun launchLoadingJob(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
): Job = viewModelScope.launch(context + createErrorHandler(), start) {
|
||||
loadingCounter.increment()
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
loadingCounter.decrement()
|
||||
}
|
||||
}
|
||||
|
||||
protected fun MutableStateFlow<Int>.increment() = update { it + 1 }
|
||||
|
||||
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
|
||||
|
||||
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
|
||||
if (throwable !is CancellationException) {
|
||||
errorEvent.call(throwable)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val GENERIC_ERROR = "Generic Error"
|
||||
const val FLOW_TIMEOUT = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package org.xtimms.shirizu.core.base.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xtimms.shirizu.utils.lang.EventFlow
|
||||
import org.xtimms.shirizu.utils.lang.MutableEventFlow
|
||||
import org.xtimms.shirizu.utils.lang.call
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
abstract class KotatsuBaseViewModel : ViewModel() {
|
||||
|
||||
@JvmField
|
||||
protected val loadingCounter = MutableStateFlow(0)
|
||||
|
||||
@JvmField
|
||||
protected val errorEvent = MutableEventFlow<Throwable>()
|
||||
|
||||
val onError: EventFlow<Throwable>
|
||||
get() = errorEvent
|
||||
|
||||
val isLoading: StateFlow<Boolean> = loadingCounter.map { it > 0 }
|
||||
.stateIn(viewModelScope, SharingStarted.Lazily, loadingCounter.value > 0)
|
||||
|
||||
protected fun launchJob(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
): Job = viewModelScope.launch(context + createErrorHandler(), start, block)
|
||||
|
||||
protected fun launchLoadingJob(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
): Job = viewModelScope.launch(context + createErrorHandler(), start) {
|
||||
loadingCounter.increment()
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
loadingCounter.decrement()
|
||||
}
|
||||
}
|
||||
|
||||
protected fun <T> Flow<T>.withLoading() = onStart {
|
||||
loadingCounter.increment()
|
||||
}.onCompletion {
|
||||
loadingCounter.decrement()
|
||||
}
|
||||
|
||||
protected fun <T> Flow<T>.withErrorHandling() = catch { error ->
|
||||
errorEvent.call(error)
|
||||
}
|
||||
|
||||
protected fun MutableStateFlow<Int>.increment() = update { it + 1 }
|
||||
|
||||
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
|
||||
|
||||
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
|
||||
if (throwable !is CancellationException) {
|
||||
errorEvent.call(throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package org.xtimms.shirizu.core.cache
|
||||
|
||||
enum class CacheDir(val dir: String) {
|
||||
|
||||
THUMBS("image_cache"),
|
||||
FAVICONS("favicons"),
|
||||
PAGES("pages");
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package org.xtimms.shirizu.core.cache
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
interface ContentCache {
|
||||
|
||||
val isCachingEnabled: Boolean
|
||||
|
||||
suspend fun getDetails(source: MangaSource, url: String): Manga?
|
||||
|
||||
fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>)
|
||||
|
||||
suspend fun getPages(source: MangaSource, url: String): List<MangaPage>?
|
||||
|
||||
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>)
|
||||
|
||||
suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>?
|
||||
|
||||
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>)
|
||||
|
||||
data class Key(
|
||||
val source: MangaSource,
|
||||
val url: String,
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package org.xtimms.shirizu.core.cache
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ExpiringLruCache<T>(
|
||||
val maxSize: Int,
|
||||
private val lifetime: Long,
|
||||
private val timeUnit: TimeUnit,
|
||||
) {
|
||||
|
||||
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
|
||||
|
||||
operator fun get(key: ContentCache.Key): T? {
|
||||
val value = cache[key] ?: return null
|
||||
if (value.isExpired) {
|
||||
cache.remove(key)
|
||||
}
|
||||
return value.get()
|
||||
}
|
||||
|
||||
operator fun set(key: ContentCache.Key, value: T) {
|
||||
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
cache.evictAll()
|
||||
}
|
||||
|
||||
fun trimToSize(size: Int) {
|
||||
cache.trimToSize(size)
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package org.xtimms.shirizu.core.cache
|
||||
|
||||
import android.os.SystemClock
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ExpiringValue<T>(
|
||||
private val value: T,
|
||||
lifetime: Long,
|
||||
timeUnit: TimeUnit,
|
||||
) {
|
||||
|
||||
private val expiresAt = SystemClock.elapsedRealtime() + timeUnit.toMillis(lifetime)
|
||||
|
||||
val isExpired: Boolean
|
||||
get() = SystemClock.elapsedRealtime() >= expiresAt
|
||||
|
||||
fun get(): T? = if (isExpired) null else value
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as ExpiringValue<*>
|
||||
|
||||
if (value != other.value) return false
|
||||
return expiresAt == other.expiresAt
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = value?.hashCode() ?: 0
|
||||
result = 31 * result + expiresAt.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package org.xtimms.shirizu.core.cache
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ComponentCallbacks2
|
||||
import android.content.res.Configuration
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 {
|
||||
|
||||
init {
|
||||
application.registerComponentCallbacks(this)
|
||||
}
|
||||
|
||||
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(4, 5, TimeUnit.MINUTES)
|
||||
private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(4, 10, TimeUnit.MINUTES)
|
||||
private val relatedMangaCache = ExpiringLruCache<SafeDeferred<List<Manga>>>(4, 10, TimeUnit.MINUTES)
|
||||
|
||||
override val isCachingEnabled: Boolean = true
|
||||
|
||||
override suspend fun getDetails(source: MangaSource, url: String): Manga? {
|
||||
return detailsCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
||||
}
|
||||
|
||||
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
|
||||
detailsCache[ContentCache.Key(source, url)] = details
|
||||
}
|
||||
|
||||
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
|
||||
return pagesCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
||||
}
|
||||
|
||||
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
|
||||
pagesCache[ContentCache.Key(source, url)] = pages
|
||||
}
|
||||
|
||||
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
|
||||
return relatedMangaCache[ContentCache.Key(source, url)]?.awaitOrNull()
|
||||
}
|
||||
|
||||
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
|
||||
relatedMangaCache[ContentCache.Key(source, url)] = related
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) = Unit
|
||||
|
||||
override fun onLowMemory() = Unit
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
trimCache(detailsCache, level)
|
||||
trimCache(pagesCache, level)
|
||||
trimCache(relatedMangaCache, level)
|
||||
}
|
||||
|
||||
private fun trimCache(cache: ExpiringLruCache<*>, level: Int) {
|
||||
when (level) {
|
||||
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL,
|
||||
ComponentCallbacks2.TRIM_MEMORY_COMPLETE,
|
||||
ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.clear()
|
||||
|
||||
ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN,
|
||||
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW,
|
||||
ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> cache.trimToSize(1)
|
||||
|
||||
else -> cache.trimToSize(cache.maxSize / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package org.xtimms.shirizu.core.cache
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.os.StatFs
|
||||
import com.tomclaw.cache.DiskLruCache
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.Source
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.xtimms.shirizu.utils.FileSize
|
||||
import org.xtimms.shirizu.utils.lang.longHashCode
|
||||
import org.xtimms.shirizu.utils.lang.writeAllCancellable
|
||||
import org.xtimms.shirizu.utils.system.compressToPNG
|
||||
import org.xtimms.shirizu.utils.system.subdir
|
||||
import org.xtimms.shirizu.utils.system.takeIfReadable
|
||||
import org.xtimms.shirizu.utils.system.takeIfWriteable
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PagesCache @Inject constructor(@ApplicationContext context: Context) {
|
||||
|
||||
private val cacheDir = SuspendLazy {
|
||||
val dirs = context.externalCacheDirs + context.cacheDir
|
||||
dirs.firstNotNullOf {
|
||||
it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable()
|
||||
}
|
||||
}
|
||||
private val lruCache = SuspendLazy {
|
||||
val dir = cacheDir.get()
|
||||
val availableSize = (getAvailableSize() * 0.8).toLong()
|
||||
val size = SIZE_DEFAULT.coerceAtMost(availableSize).coerceAtLeast(SIZE_MIN)
|
||||
runCatchingCancellable {
|
||||
DiskLruCache.create(dir, size)
|
||||
}.recoverCatching { error ->
|
||||
error.printStackTrace()
|
||||
dir.deleteRecursively()
|
||||
dir.mkdir()
|
||||
DiskLruCache.create(dir, size)
|
||||
}.getOrThrow()
|
||||
}
|
||||
|
||||
suspend fun get(url: String): File? {
|
||||
val cache = lruCache.get()
|
||||
return runInterruptible(Dispatchers.IO) {
|
||||
cache.get(url)?.takeIfReadable()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun put(url: String, source: Source): File = withContext(Dispatchers.IO) {
|
||||
val file = File(cacheDir.get().parentFile, url.longHashCode().toString())
|
||||
try {
|
||||
val bytes = file.sink(append = false).buffer().use {
|
||||
it.writeAllCancellable(source)
|
||||
}
|
||||
check(bytes != 0L) { "No data has been written" }
|
||||
lruCache.get().put(url, file)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun put(url: String, bitmap: Bitmap): File = withContext(Dispatchers.IO) {
|
||||
val file = File(cacheDir.get().parentFile, url.longHashCode().toString())
|
||||
try {
|
||||
bitmap.compressToPNG(file)
|
||||
lruCache.get().put(url, file)
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getAvailableSize(): Long = runCatchingCancellable {
|
||||
val statFs = StatFs(cacheDir.get().absolutePath)
|
||||
statFs.availableBytes
|
||||
}.onFailure {
|
||||
it.printStackTrace()
|
||||
}.getOrDefault(SIZE_DEFAULT)
|
||||
|
||||
private companion object {
|
||||
|
||||
val SIZE_MIN
|
||||
get() = FileSize.MEGABYTES.convert(20, FileSize.BYTES)
|
||||
|
||||
val SIZE_DEFAULT
|
||||
get() = FileSize.MEGABYTES.convert(200, FileSize.BYTES)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package org.xtimms.shirizu.core.cache
|
||||
|
||||
import kotlinx.coroutines.Deferred
|
||||
|
||||
class SafeDeferred<T>(
|
||||
private val delegate: Deferred<Result<T>>,
|
||||
) {
|
||||
|
||||
suspend fun await(): T {
|
||||
return delegate.await().getOrThrow()
|
||||
}
|
||||
|
||||
suspend fun awaitOrNull(): T? {
|
||||
return delegate.await().getOrNull()
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
delegate.cancel()
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package org.xtimms.shirizu.core.cache
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
class StubContentCache : ContentCache {
|
||||
|
||||
override val isCachingEnabled: Boolean = false
|
||||
|
||||
override suspend fun getDetails(source: MangaSource, url: String): Manga? = null
|
||||
|
||||
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) = Unit
|
||||
|
||||
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? = null
|
||||
|
||||
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) = Unit
|
||||
|
||||
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? = null
|
||||
|
||||
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) = Unit
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.min
|
||||
import org.xtimms.shirizu.ui.theme.ShirizuTheme
|
||||
import java.lang.Integer.MAX_VALUE
|
||||
import kotlin.math.min
|
||||
|
||||
enum class ButtonType { PRIMARY, SECONDARY, TERTIARY, DELETE }
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun AnimatedButton(
|
||||
modifier: Modifier = Modifier,
|
||||
type: ButtonType,
|
||||
icon: ImageVector? = null,
|
||||
onClick: (() -> Unit) = {},
|
||||
onLongClick: (() -> Unit) = {},
|
||||
) {
|
||||
val localDensity = LocalDensity.current
|
||||
var minSize by remember { mutableStateOf(MAX_VALUE.dp) }
|
||||
var minSizeFloat by remember { mutableStateOf(MAX_VALUE.toFloat()) }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isPressed = interactionSource.collectIsPressedAsState()
|
||||
val radius = animateDpAsState(targetValue = if (isPressed.value) 12.dp else minSize / 2)
|
||||
|
||||
val color = when (type) {
|
||||
ButtonType.PRIMARY -> MaterialTheme.colorScheme.primaryContainer
|
||||
ButtonType.SECONDARY -> MaterialTheme.colorScheme.secondaryContainer
|
||||
ButtonType.TERTIARY -> MaterialTheme.colorScheme.tertiaryContainer
|
||||
ButtonType.DELETE -> MaterialTheme.colorScheme.errorContainer
|
||||
}
|
||||
|
||||
val contentColor = when (type) {
|
||||
ButtonType.PRIMARY -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
ButtonType.SECONDARY -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
ButtonType.TERTIARY -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||
ButtonType.DELETE -> MaterialTheme.colorScheme.onErrorContainer
|
||||
}
|
||||
|
||||
Surface(
|
||||
tonalElevation = 10.dp,
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned {
|
||||
minSize = with(localDensity) { min(it.size.height, it.size.width).toDp() }
|
||||
minSizeFloat = min(it.size.height, it.size.width).toFloat()
|
||||
}
|
||||
.clip(RoundedCornerShape(radius.value))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(color = color)
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(radius.value))
|
||||
.combinedClickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple(),
|
||||
onClick = { onClick.invoke() },
|
||||
onLongClick = { onLongClick.invoke() },
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (icon !== null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(min(minSize * 0.5f, 154.dp)),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Icon")
|
||||
@Composable
|
||||
private fun PreviewWithIcon() {
|
||||
ShirizuTheme {
|
||||
AnimatedButton(
|
||||
type = ButtonType.PRIMARY,
|
||||
icon = Icons.Outlined.Edit
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import java.text.BreakIterator
|
||||
|
||||
data class CharState(val preview: String, val current: String)
|
||||
|
||||
// https://github.com/danilkinkin/buckwheat/blob/master/app/src/main/java/com/danilkinkin/buckwheat/base/AnimatedNumber.kt
|
||||
@Composable
|
||||
fun AnimatedNumber(
|
||||
value: String = "",
|
||||
style: TextStyle = MaterialTheme.typography.displayLarge,
|
||||
) {
|
||||
|
||||
var previewsValue by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
var blocks by remember { mutableStateOf<List<CharState>>(emptyList()) }
|
||||
|
||||
DisposableEffect(value) {
|
||||
var splittedValue = emptyList<String>().toMutableList()
|
||||
val it = BreakIterator.getCharacterInstance()
|
||||
it.setText(value)
|
||||
var count = 0
|
||||
|
||||
var start = 0
|
||||
var end = it.next()
|
||||
while (end != BreakIterator.DONE) {
|
||||
splittedValue.add(value.substring(start, end))
|
||||
|
||||
start = end
|
||||
end = it.next()
|
||||
count++
|
||||
}
|
||||
|
||||
val length = splittedValue.size.coerceAtLeast(previewsValue.size)
|
||||
|
||||
var newBlocks: MutableList<CharState> = emptyList<CharState>().toMutableList()
|
||||
|
||||
for (i in 0..length) {
|
||||
newBlocks.add(
|
||||
CharState(
|
||||
preview = previewsValue.getOrElse(previewsValue.size - i) { "" },
|
||||
current = splittedValue.getOrElse(splittedValue.size - i) { "" },
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
newBlocks = newBlocks.asReversed()
|
||||
|
||||
blocks = newBlocks
|
||||
previewsValue = splittedValue
|
||||
|
||||
onDispose { }
|
||||
}
|
||||
|
||||
Row {
|
||||
blocks.forEach {
|
||||
AnimatedContent(
|
||||
targetState = it.current,
|
||||
transitionSpec = {
|
||||
if (targetState > initialState) {
|
||||
(slideInVertically(tween(durationMillis = 300)) { height -> height } + fadeIn(
|
||||
tween(durationMillis = 300)
|
||||
)).togetherWith(
|
||||
slideOutVertically(tween(durationMillis = 300)) { height -> -height } + fadeOut(
|
||||
tween(durationMillis = 300)
|
||||
))
|
||||
} else {
|
||||
(slideInVertically(tween(durationMillis = 300)) { height -> -height } + fadeIn(
|
||||
tween(durationMillis = 300)
|
||||
)).togetherWith(
|
||||
slideOutVertically(tween(durationMillis = 300)) { height -> height } + fadeOut(
|
||||
tween(durationMillis = 300)
|
||||
))
|
||||
}.using(
|
||||
SizeTransform(clip = false)
|
||||
)
|
||||
},
|
||||
label = ""
|
||||
) { targetCount ->
|
||||
Text(text = targetCount, style = style)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.TweenSpec
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.times
|
||||
import org.xtimms.shirizu.core.components.shape.WavyShape
|
||||
import org.xtimms.shirizu.utils.lang.clamp
|
||||
|
||||
@Composable
|
||||
fun BackgroundProgress(
|
||||
color: Color,
|
||||
alpha: Float,
|
||||
percent: Float,
|
||||
) {
|
||||
|
||||
val animatedAlpha = animateFloatAsState(
|
||||
label = "alpha",
|
||||
targetValue = alpha,
|
||||
animationSpec = TweenSpec(500)
|
||||
).value
|
||||
|
||||
val percentAnimated = animateFloatAsState(
|
||||
label = "percentAnimated",
|
||||
targetValue = percent,
|
||||
animationSpec = TweenSpec(300),
|
||||
).value
|
||||
|
||||
val shift = remember { Animatable(0f) }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color.copy(alpha = animatedAlpha),
|
||||
shape = WavyShape(
|
||||
period = 30.dp,
|
||||
amplitude = percentAnimated.clamp(0.96f, 1f) * 2.dp,
|
||||
shift = shift.value,
|
||||
),
|
||||
)
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(percentAnimated),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationVector1D
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xtimms.shirizu.core.BottomNavDestination
|
||||
import org.xtimms.shirizu.core.BottomNavDestination.Companion.Icon
|
||||
import org.xtimms.shirizu.sections.explore.EXPLORE_DESTINATION
|
||||
import org.xtimms.shirizu.sections.history.HISTORY_DESTINATION
|
||||
import org.xtimms.shirizu.sections.shelf.SHELF_DESTINATION
|
||||
|
||||
@Composable
|
||||
fun BottomNavBar(
|
||||
navController: NavController,
|
||||
bottomBarState: State<Boolean>,
|
||||
topBarOffsetY: Animatable<Float, AnimationVector1D>,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val isVisible by remember {
|
||||
derivedStateOf {
|
||||
when (navBackStackEntry?.destination?.route) {
|
||||
SHELF_DESTINATION, HISTORY_DESTINATION, EXPLORE_DESTINATION, null -> bottomBarState.value
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter = slideInVertically(initialOffsetY = { it }),
|
||||
exit = slideOutVertically(targetOffsetY = { it })
|
||||
) {
|
||||
NavigationBar {
|
||||
BottomNavDestination.values.forEachIndexed { _, dest ->
|
||||
val isSelected = navBackStackEntry?.destination?.route == dest.route
|
||||
NavigationBarItem(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
topBarOffsetY.animateTo(0f)
|
||||
}
|
||||
|
||||
navController.navigate(dest.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = { dest.Icon(selected = isSelected) },
|
||||
label = { Text(text = stringResource(dest.title)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.xtimms.shirizu.R
|
||||
|
||||
@Composable
|
||||
fun ConfirmButton(
|
||||
text: String = stringResource(R.string.confirm),
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
TextButton(onClick = onClick, enabled = enabled) {
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DismissButton(text: String = stringResource(R.string.dismiss), onClick: () -> Unit) {
|
||||
TextButton(onClick = onClick) {
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActionButton(
|
||||
title: String,
|
||||
icon: ImageVector,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
TextButton(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun DialogCheckBoxItem(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String,
|
||||
checked: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.toggleable(
|
||||
value = checked,
|
||||
enabled = true,
|
||||
onValueChange = { onClick() },
|
||||
)
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
modifier = Modifier.clearAndSetSemantics { },
|
||||
checked = checked, onCheckedChange = { onClick() },
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.LocalLibrary
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.material3.FloatingActionButtonElevation
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.sections.history.HISTORY_DESTINATION
|
||||
import org.xtimms.shirizu.sections.reader.READER_DESTINATION
|
||||
|
||||
@Composable
|
||||
fun ContinueReadingButton(
|
||||
navController: NavController,
|
||||
) {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
|
||||
val isVisible by remember {
|
||||
derivedStateOf {
|
||||
when (navBackStackEntry?.destination?.route) {
|
||||
HISTORY_DESTINATION, null -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val fabScale by animateFloatAsState(
|
||||
targetValue = when (navBackStackEntry?.destination?.route) {
|
||||
HISTORY_DESTINATION, null -> 1f
|
||||
else -> 0f
|
||||
},
|
||||
animationSpec = tween(150), label = "elevation"
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter = fadeIn(animationSpec = tween(300, delayMillis = 150)) +
|
||||
scaleIn(
|
||||
initialScale = 0.92f,
|
||||
animationSpec = tween(300, delayMillis = 150)
|
||||
),
|
||||
exit = fadeOut(animationSpec = tween(0))
|
||||
) {
|
||||
androidx.compose.material3.ExtendedFloatingActionButton(
|
||||
onClick = {
|
||||
navController.navigate(
|
||||
READER_DESTINATION
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(8.dp),
|
||||
elevation = FloatingActionButtonDefaults.elevation(
|
||||
defaultElevation = 4.dp
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.LocalLibrary,
|
||||
contentDescription = null
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.continue_reading),
|
||||
modifier = Modifier.padding(start = 16.dp, end = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.Language
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonColors
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DetailsToolbar(
|
||||
title: String,
|
||||
titleAlphaProvider: () -> Float,
|
||||
navigateBack: () -> Unit,
|
||||
navigateToWebBrowser: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundAlphaProvider: () -> Float = titleAlphaProvider
|
||||
) {
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val padding by animateDpAsState(
|
||||
targetValue = if (backgroundAlphaProvider() == 1f) 0.dp else 16.dp,
|
||||
label = "padding",
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
) {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
CircleBackIconButton(
|
||||
modifier = Modifier.padding(start = padding),
|
||||
onClick = navigateBack
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = LocalContentColor.current.copy(alpha = titleAlphaProvider()),
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
FilledTonalIconButton(
|
||||
modifier = Modifier.padding(end = padding),
|
||||
onClick = { expanded = true }, colors = IconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.outline,
|
||||
disabledContentColor = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.MoreVert, contentDescription = null)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Share") },
|
||||
onClick = { /*TODO*/ },
|
||||
leadingIcon = {
|
||||
Icon(imageVector = Icons.Default.Share, contentDescription = null)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Download") },
|
||||
onClick = { /*TODO*/ },
|
||||
leadingIcon = {
|
||||
Icon(imageVector = Icons.Outlined.Download, contentDescription = null)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Open in web browser") },
|
||||
onClick = {
|
||||
navigateToWebBrowser()
|
||||
expanded = false
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(imageVector = Icons.Outlined.Language, contentDescription = null)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme
|
||||
.surfaceColorAtElevation(3.dp)
|
||||
.copy(alpha = backgroundAlphaProvider())
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AlertDialogDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
||||
private val DialogVerticalPadding = PaddingValues(vertical = 24.dp)
|
||||
private val IconPadding = PaddingValues(bottom = 16.dp)
|
||||
private val DialogHorizontalPadding = PaddingValues(horizontal = 24.dp)
|
||||
private val TitlePadding = PaddingValues(bottom = 16.dp)
|
||||
private val TextPadding = PaddingValues(bottom = 24.dp)
|
||||
private val ButtonsMainAxisSpacing = Arrangement.spacedBy(8.dp, Alignment.Start)
|
||||
private val ButtonsCrossAxisSpacing = Arrangement.spacedBy(12.dp, Alignment.Top)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ShirizuDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
confirmButton: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
dismissButton: @Composable (() -> Unit)? = null,
|
||||
icon: @Composable (() -> Unit)? = null,
|
||||
title: @Composable (() -> Unit)? = null,
|
||||
text: @Composable (() -> Unit)? = null,
|
||||
shape: Shape = AlertDialogDefaults.shape,
|
||||
containerColor: Color = AlertDialogDefaults.containerColor,
|
||||
iconContentColor: Color = AlertDialogDefaults.iconContentColor,
|
||||
titleContentColor: Color = AlertDialogDefaults.titleContentColor,
|
||||
textContentColor: Color = AlertDialogDefaults.textContentColor,
|
||||
tonalElevation: Dp = AlertDialogDefaults.TonalElevation,
|
||||
properties: DialogProperties = DialogProperties()
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
modifier = modifier,
|
||||
properties = properties
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = shape,
|
||||
color = containerColor,
|
||||
tonalElevation = tonalElevation,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(DialogVerticalPadding)
|
||||
) {
|
||||
icon?.let {
|
||||
CompositionLocalProvider(LocalContentColor provides iconContentColor) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(IconPadding)
|
||||
.padding(DialogHorizontalPadding)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
) {
|
||||
icon()
|
||||
}
|
||||
}
|
||||
}
|
||||
title?.let {
|
||||
CompositionLocalProvider(LocalContentColor provides titleContentColor) {
|
||||
val textStyle = MaterialTheme.typography.headlineSmall
|
||||
ProvideTextStyle(textStyle) {
|
||||
Box(
|
||||
// Align the title to the center when an icon is present.
|
||||
Modifier
|
||||
.padding(TitlePadding)
|
||||
.padding(DialogHorizontalPadding)
|
||||
.align(
|
||||
if (icon == null) {
|
||||
Alignment.Start
|
||||
} else {
|
||||
Alignment.CenterHorizontally
|
||||
}
|
||||
)
|
||||
) {
|
||||
title()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
text?.let {
|
||||
CompositionLocalProvider(LocalContentColor provides textContentColor) {
|
||||
val textStyle =
|
||||
MaterialTheme.typography.bodyMedium
|
||||
ProvideTextStyle(textStyle) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(weight = 1f, fill = false)
|
||||
.padding(TextPadding)
|
||||
.align(Alignment.Start)
|
||||
) {
|
||||
text()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(DialogHorizontalPadding)
|
||||
) {
|
||||
val textStyle =
|
||||
MaterialTheme.typography.labelLarge
|
||||
ProvideTextStyle(value = textStyle) {
|
||||
FlowRow(
|
||||
horizontalArrangement = ButtonsMainAxisSpacing,
|
||||
verticalArrangement = ButtonsCrossAxisSpacing
|
||||
) {
|
||||
dismissButton?.invoke()
|
||||
confirmButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun DotSeparatorText(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = " • ",
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DotSeparatorNoSpaceText(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = "•",
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ExploreButton(
|
||||
text: String,
|
||||
icon: ImageVector,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = modifier.padding(start = 8.dp, end = 8.dp),
|
||||
shape = RoundedCornerShape(50),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.height(40.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = text,
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 14.sp,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
lineHeight = 15.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.material3.FloatingActionButtonElevation
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* ExtendedFloatingActionButton with custom transition between collapsed/expanded state.
|
||||
*
|
||||
* @see androidx.compose.material3.ExtendedFloatingActionButton
|
||||
*/
|
||||
@Composable
|
||||
fun ExtendedFloatingActionButton(
|
||||
text: @Composable () -> Unit,
|
||||
icon: @Composable () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
expanded: Boolean = true,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
shape: Shape = MaterialTheme.shapes.large,
|
||||
containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor: Color = contentColorFor(containerColor),
|
||||
elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(),
|
||||
) {
|
||||
val minWidth by animateDpAsState(
|
||||
targetValue = if (expanded) ExtendedFabMinimumWidth else FabContainerWidth,
|
||||
label = "minWidth",
|
||||
)
|
||||
FloatingActionButton(
|
||||
modifier = modifier.sizeIn(minWidth = minWidth),
|
||||
onClick = onClick,
|
||||
interactionSource = interactionSource,
|
||||
shape = shape,
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
elevation = elevation,
|
||||
) {
|
||||
val startPadding by animateDpAsState(
|
||||
targetValue = if (expanded) ExtendedFabIconSize / 2 else 0.dp,
|
||||
label = "startPadding",
|
||||
)
|
||||
val endPadding by animateDpAsState(
|
||||
targetValue = if (expanded) ExtendedFabTextPadding else 0.dp,
|
||||
label = "endPadding",
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(start = startPadding, end = endPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
icon()
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = ExtendedFabExpandAnimation,
|
||||
exit = ExtendedFabCollapseAnimation,
|
||||
) {
|
||||
Row {
|
||||
Spacer(Modifier.width(ExtendedFabIconPadding))
|
||||
text()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val EasingLinearCubicBezier = CubicBezierEasing(0.0f, 0.0f, 1.0f, 1.0f)
|
||||
private val EasingEmphasizedCubicBezier = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f)
|
||||
|
||||
private val ExtendedFabMinimumWidth = 80.dp
|
||||
private val ExtendedFabIconSize = 24.0.dp
|
||||
private val ExtendedFabIconPadding = 12.dp
|
||||
private val ExtendedFabTextPadding = 20.dp
|
||||
|
||||
private val ExtendedFabCollapseAnimation = fadeOut(
|
||||
animationSpec = tween(
|
||||
durationMillis = 100,
|
||||
easing = EasingLinearCubicBezier,
|
||||
),
|
||||
) + shrinkHorizontally(
|
||||
animationSpec = tween(
|
||||
durationMillis = 500,
|
||||
easing = EasingEmphasizedCubicBezier,
|
||||
),
|
||||
shrinkTowards = Alignment.Start,
|
||||
)
|
||||
|
||||
private val ExtendedFabExpandAnimation = fadeIn(
|
||||
animationSpec = tween(
|
||||
durationMillis = 200,
|
||||
delayMillis = 100,
|
||||
easing = EasingLinearCubicBezier,
|
||||
),
|
||||
) + expandHorizontally(
|
||||
animationSpec = tween(
|
||||
durationMillis = 500,
|
||||
easing = EasingEmphasizedCubicBezier,
|
||||
),
|
||||
expandFrom = Alignment.Start,
|
||||
)
|
||||
|
||||
private val FabContainerWidth = 56.0.dp
|
||||
@@ -1,144 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import android.text.Spanned
|
||||
import android.text.style.BulletSpan
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.RelativeSizeSpan
|
||||
import android.text.style.StrikethroughSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.text.style.SubscriptSpan
|
||||
import android.text.style.SuperscriptSpan
|
||||
import android.text.style.TypefaceSpan
|
||||
import android.text.style.URLSpan
|
||||
import android.text.style.UnderlineSpan
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.text.HtmlCompat
|
||||
import org.xtimms.shirizu.utils.spanStyle
|
||||
|
||||
private const val URL_TAG = "url_tag"
|
||||
|
||||
@Composable
|
||||
fun HtmlTextField(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = TextStyle.Default,
|
||||
softWrap: Boolean = true,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
linkClicked: ((String) -> Unit)? = null,
|
||||
fontSize: TextUnit = 14.sp,
|
||||
flags: Int = HtmlCompat.FROM_HTML_MODE_COMPACT,
|
||||
urlSpanStyle: SpanStyle = SpanStyle(
|
||||
color = linkTextColor(),
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
customSpannedHandler: ((Spanned) -> AnnotatedString)? = null
|
||||
) {
|
||||
val content = text.asHTML(fontSize, flags, urlSpanStyle, customSpannedHandler)
|
||||
if (linkClicked != null) {
|
||||
ClickableText(
|
||||
modifier = modifier,
|
||||
text = content,
|
||||
style = style,
|
||||
softWrap = softWrap,
|
||||
overflow = overflow,
|
||||
maxLines = maxLines,
|
||||
onTextLayout = onTextLayout,
|
||||
onClick = {
|
||||
content
|
||||
.getStringAnnotations(URL_TAG, it, it)
|
||||
.firstOrNull()
|
||||
?.let { stringAnnotation -> linkClicked(stringAnnotation.item) }
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
modifier = modifier,
|
||||
text = content,
|
||||
style = style,
|
||||
softWrap = softWrap,
|
||||
overflow = overflow,
|
||||
maxLines = maxLines,
|
||||
onTextLayout = onTextLayout
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun linkTextColor() = Color(
|
||||
TextView(LocalContext.current).linkTextColors.defaultColor
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun String.asHTML(
|
||||
fontSize: TextUnit,
|
||||
flags: Int,
|
||||
URLSpanStyle: SpanStyle,
|
||||
customSpannedHandler: ((Spanned) -> AnnotatedString)? = null
|
||||
) = buildAnnotatedString {
|
||||
val spanned = HtmlCompat.fromHtml(this@asHTML, flags)
|
||||
val spans = spanned.getSpans(0, spanned.length, Any::class.java)
|
||||
|
||||
if (customSpannedHandler != null) {
|
||||
append(customSpannedHandler(spanned))
|
||||
} else {
|
||||
append(spanned.toString())
|
||||
}
|
||||
|
||||
spans
|
||||
.filter { it !is BulletSpan }
|
||||
.forEach { span ->
|
||||
val start = spanned.getSpanStart(span)
|
||||
val end = spanned.getSpanEnd(span)
|
||||
when (span) {
|
||||
is RelativeSizeSpan -> span.spanStyle(fontSize)
|
||||
is StyleSpan -> span.spanStyle()
|
||||
is UnderlineSpan -> span.spanStyle()
|
||||
is ForegroundColorSpan -> span.spanStyle()
|
||||
is TypefaceSpan -> span.spanStyle()
|
||||
is StrikethroughSpan -> span.spanStyle()
|
||||
is SuperscriptSpan -> span.spanStyle()
|
||||
is SubscriptSpan -> span.spanStyle()
|
||||
is URLSpan -> {
|
||||
addStringAnnotation(
|
||||
tag = URL_TAG,
|
||||
annotation = span.url,
|
||||
start = start,
|
||||
end = end
|
||||
)
|
||||
URLSpanStyle
|
||||
}
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}?.let { spanStyle ->
|
||||
addStyle(spanStyle, start, end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun UnderlineSpan.spanStyle(): SpanStyle =
|
||||
SpanStyle(textDecoration = TextDecoration.Underline)
|
||||
|
||||
internal fun ForegroundColorSpan.spanStyle(): SpanStyle =
|
||||
SpanStyle(color = Color(foregroundColor))
|
||||
|
||||
internal fun StrikethroughSpan.spanStyle(): SpanStyle =
|
||||
SpanStyle(textDecoration = TextDecoration.LineThrough)
|
||||
@@ -1,63 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.OpenInBrowser
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonColors
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.xtimms.shirizu.R
|
||||
|
||||
@Composable
|
||||
fun BackIconButton(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = "arrow_back"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CircleBackIconButton(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
FilledTonalIconButton(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
colors = IconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.outline,
|
||||
disabledContentColor = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = "arrow_back"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ViewInBrowserButton(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.OpenInBrowser,
|
||||
contentDescription = stringResource(R.string.open_in_browser)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ListGroupHeader(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = modifier
|
||||
.padding(
|
||||
horizontal = 16.dp,
|
||||
vertical = 4.dp,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import org.xtimms.shirizu.core.AsyncImageImpl
|
||||
|
||||
enum class MangaCover(val ratio: Float) {
|
||||
Square(1f / 1f),
|
||||
Book(2f / 3f),
|
||||
;
|
||||
|
||||
@Composable
|
||||
operator fun invoke(
|
||||
coil: ImageLoader,
|
||||
data: String,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String = "",
|
||||
shape: Shape = MaterialTheme.shapes.small,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
AsyncImageImpl(
|
||||
coil = coil,
|
||||
model = data,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier
|
||||
.aspectRatio(ratio)
|
||||
.clip(shape)
|
||||
.then(
|
||||
if (onClick != null) {
|
||||
Modifier.clickable(
|
||||
role = Role.Button,
|
||||
onClick = onClick,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val CoverPlaceholderColor = Color(0x1F888888)
|
||||
@@ -1,245 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shadow
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.xtimms.shirizu.core.AsyncImageImpl
|
||||
|
||||
private const val GridSelectedCoverAlpha = 0.76f
|
||||
|
||||
@Composable
|
||||
fun MangaGridItem(
|
||||
coil: ImageLoader,
|
||||
manga: Manga,
|
||||
onClick: (Manga) -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
isSelected: Boolean = false,
|
||||
) {
|
||||
GridItemSelectable(
|
||||
manga = manga,
|
||||
isSelected = isSelected,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Box {
|
||||
AsyncImageImpl(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(4.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.aspectRatio(10F / 16F),
|
||||
coil = coil,
|
||||
model = manga.largeCoverUrl ?: manga.coverUrl,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = manga.title,
|
||||
modifier = Modifier.padding(4.dp),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MangaHorizontalItem(
|
||||
coil: ImageLoader,
|
||||
manga: Manga,
|
||||
onClick: (Manga) -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
isSelected: Boolean = false,
|
||||
) {
|
||||
GridItemSelectable(
|
||||
manga = manga,
|
||||
isSelected = isSelected,
|
||||
onClick = { onClick(manga) },
|
||||
onLongClick = onLongClick,
|
||||
modifier = Modifier.width(IntrinsicSize.Min)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Box {
|
||||
AsyncImageImpl(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(4.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.aspectRatio(10F / 16F)
|
||||
.height(156.dp),
|
||||
coil = coil,
|
||||
model = manga.largeCoverUrl ?: manga.coverUrl,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = manga.title,
|
||||
modifier = Modifier.padding(4.dp),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common cover layout to add contents to be drawn on top of the cover.
|
||||
*/
|
||||
@Composable
|
||||
private fun MangaGridCover(
|
||||
modifier: Modifier = Modifier,
|
||||
cover: @Composable BoxScope.() -> Unit = {},
|
||||
content: @Composable (BoxScope.() -> Unit)? = null,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(MangaCover.Book.ratio),
|
||||
) {
|
||||
cover()
|
||||
content?.invoke(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Title overlay for [MangaCompactGridItem]
|
||||
*/
|
||||
@Composable
|
||||
private fun BoxScope.CoverTextOverlay(
|
||||
title: String,
|
||||
onClickContinueReading: (() -> Unit)? = null,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(bottomStart = 4.dp, bottomEnd = 4.dp))
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Transparent,
|
||||
1f to Color(0xAA000000),
|
||||
),
|
||||
)
|
||||
.fillMaxHeight(0.33f)
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.BottomStart),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
GridItemTitle(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(8.dp),
|
||||
title = title,
|
||||
style = MaterialTheme.typography.titleSmall.copy(
|
||||
color = Color.White,
|
||||
shadow = Shadow(
|
||||
color = Color.Black,
|
||||
blurRadius = 4f,
|
||||
),
|
||||
),
|
||||
minLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GridItemTitle(
|
||||
title: String,
|
||||
style: TextStyle,
|
||||
minLines: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
maxLines: Int = 2,
|
||||
) {
|
||||
Text(
|
||||
modifier = modifier,
|
||||
text = title,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 18.sp,
|
||||
minLines = minLines,
|
||||
maxLines = maxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = style,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for grid items to handle selection state, click and long click.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun GridItemSelectable(
|
||||
manga: Manga,
|
||||
isSelected: Boolean,
|
||||
onClick: (Manga) -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.combinedClickable(
|
||||
onClick = { onClick(manga) },
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
.selectedOutline(isSelected = isSelected, color = MaterialTheme.colorScheme.secondary)
|
||||
.padding(4.dp),
|
||||
) {
|
||||
val contentColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.onSecondary
|
||||
} else {
|
||||
LocalContentColor.current
|
||||
}
|
||||
CompositionLocalProvider(LocalContentColor provides contentColor) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @see GridItemSelectable
|
||||
*/
|
||||
private fun Modifier.selectedOutline(
|
||||
isSelected: Boolean,
|
||||
color: Color,
|
||||
) = this then drawBehind { if (isSelected) drawRect(color = color) }
|
||||
@@ -1,76 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import org.xtimms.shirizu.core.BottomNavDestination
|
||||
import org.xtimms.shirizu.core.BottomNavDestination.Companion.Icon
|
||||
import org.xtimms.shirizu.sections.search.SEARCH_DESTINATION
|
||||
|
||||
@Composable
|
||||
fun NavigationRail(
|
||||
navController: NavController,
|
||||
) {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
NavigationRail(
|
||||
header = {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
navController.navigate(SEARCH_DESTINATION) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Search,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.Bottom
|
||||
) {
|
||||
BottomNavDestination.railValues.forEachIndexed { index, dest ->
|
||||
val isSelected = navBackStackEntry?.destination?.route == dest.route
|
||||
NavigationRailItem(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
navController.navigate(dest.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = { dest.Icon(selected = isSelected) },
|
||||
label = { Text(text = stringResource(dest.title)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun Pill(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = MaterialTheme.colorScheme.background,
|
||||
contentColor: Color = MaterialTheme.colorScheme.onBackground,
|
||||
elevation: Dp = 1.dp,
|
||||
fontSize: TextUnit = LocalTextStyle.current.fontSize,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.padding(start = 4.dp),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
color = color,
|
||||
contentColor = contentColor,
|
||||
tonalElevation = elevation,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(6.dp, 1.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = fontSize,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,831 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Call
|
||||
import androidx.compose.material.icons.outlined.Check
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material.icons.outlined.Save
|
||||
import androidx.compose.material.icons.outlined.TipsAndUpdates
|
||||
import androidx.compose.material.icons.outlined.ToggleOn
|
||||
import androidx.compose.material.icons.outlined.Translate
|
||||
import androidx.compose.material.icons.outlined.Update
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.xtimms.shirizu.ui.theme.FixedAccentColors
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.ui.monet.LocalTonalPalettes
|
||||
import org.xtimms.shirizu.ui.monet.TonalPalettes.Companion.toTonalPalettes
|
||||
import org.xtimms.shirizu.ui.theme.PreviewThemeLight
|
||||
import org.xtimms.shirizu.ui.theme.ShirizuTheme
|
||||
import org.xtimms.shirizu.ui.theme.applyOpacity
|
||||
import org.xtimms.shirizu.utils.FileSize
|
||||
|
||||
private const val horizontal = 8
|
||||
private const val vertical = 16
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun PreferenceItem(
|
||||
title: String,
|
||||
description: String? = null,
|
||||
icon: Any? = null,
|
||||
enabled: Boolean = true,
|
||||
onLongClickLabel: String? = null,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
onClickLabel: String? = null,
|
||||
leadingIcon: (@Composable () -> Unit)? = null,
|
||||
trailingIcon: (@Composable () -> Unit)? = null,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.combinedClickable(
|
||||
onClick = onClick,
|
||||
onClickLabel = onClickLabel,
|
||||
enabled = enabled,
|
||||
onLongClickLabel = onLongClickLabel,
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal.dp, vertical.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
leadingIcon?.invoke()
|
||||
|
||||
when (icon) {
|
||||
is ImageVector -> {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, end = 16.dp)
|
||||
.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(enabled)
|
||||
)
|
||||
}
|
||||
|
||||
is Painter -> {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, end = 16.dp)
|
||||
.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(enabled)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.then(
|
||||
if (icon != null)
|
||||
Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(end = 8.dp)
|
||||
else Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
) {
|
||||
PreferenceItemTitle(text = title, enabled = enabled)
|
||||
if (!description.isNullOrEmpty()) PreferenceItemDescription(
|
||||
text = description,
|
||||
enabled = enabled
|
||||
)
|
||||
}
|
||||
trailingIcon?.let {
|
||||
VerticalDivider(
|
||||
modifier = Modifier
|
||||
.height(32.dp)
|
||||
.padding(horizontal = 8.dp)
|
||||
.align(Alignment.CenterVertically),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
|
||||
thickness = 1.dp
|
||||
)
|
||||
trailingIcon.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PreferenceItemTitle(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String,
|
||||
maxLines: Int = 2,
|
||||
enabled: Boolean,
|
||||
color: Color = MaterialTheme.colorScheme.onBackground,
|
||||
overflow: TextOverflow = TextOverflow.Ellipsis
|
||||
) {
|
||||
Text(
|
||||
modifier = modifier,
|
||||
text = text,
|
||||
maxLines = maxLines,
|
||||
color = color.applyOpacity(enabled),
|
||||
overflow = overflow
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PreferenceItemDescription(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
style: TextStyle = MaterialTheme.typography.bodyMedium,
|
||||
enabled: Boolean,
|
||||
color: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
overflow: TextOverflow = TextOverflow.Ellipsis
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = text,
|
||||
transitionSpec = {
|
||||
if (targetState > initialState) {
|
||||
(slideInVertically { height -> -height } + fadeIn()).togetherWith(
|
||||
slideOutVertically { height -> height } + fadeOut())
|
||||
} else {
|
||||
(slideInVertically { height -> height } + fadeIn()).togetherWith(
|
||||
slideOutVertically { height -> -height } + fadeOut())
|
||||
}.using(SizeTransform(clip = false))
|
||||
},
|
||||
modifier = modifier.padding(top = 2.dp),
|
||||
label = "Preference desc"
|
||||
) { targetText ->
|
||||
Text(
|
||||
text = targetText,
|
||||
maxLines = maxLines,
|
||||
style = style,
|
||||
color = color.applyOpacity(enabled),
|
||||
overflow = overflow
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PreferenceSwitchWithDivider(
|
||||
title: String,
|
||||
description: String? = null,
|
||||
icon: ImageVector? = null,
|
||||
enabled: Boolean = true,
|
||||
isSwitchEnabled: Boolean = enabled,
|
||||
isChecked: Boolean = true,
|
||||
checkedIcon: ImageVector = Icons.Outlined.Check,
|
||||
onClick: (() -> Unit) = {},
|
||||
onChecked: () -> Unit = {}
|
||||
) {
|
||||
val thumbContent: (@Composable () -> Unit)? = if (isChecked) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = checkedIcon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(SwitchDefaults.IconSize),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.clickable(
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
onClickLabel = stringResource(id = R.string.open_settings)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal.dp, vertical.dp)
|
||||
.height(IntrinsicSize.Min),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
icon?.let {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, end = 16.dp)
|
||||
.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(enabled)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
PreferenceItemTitle(text = title, enabled = enabled)
|
||||
if (!description.isNullOrEmpty()) PreferenceItemDescription(
|
||||
text = description,
|
||||
enabled = enabled
|
||||
)
|
||||
}
|
||||
VerticalDivider(
|
||||
modifier = Modifier
|
||||
.height(32.dp)
|
||||
.padding(horizontal = 8.dp)
|
||||
.width(1f.dp)
|
||||
.align(Alignment.CenterVertically),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
|
||||
)
|
||||
Switch(
|
||||
checked = isChecked,
|
||||
onCheckedChange = { onChecked() },
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 6.dp)
|
||||
.semantics {
|
||||
contentDescription = title
|
||||
},
|
||||
enabled = isSwitchEnabled,
|
||||
thumbContent = thumbContent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PreferenceSwitchWithContainer(
|
||||
title: String,
|
||||
icon: ImageVector? = null,
|
||||
isChecked: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val thumbContent: (@Composable () -> Unit)? = if (isChecked) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(SwitchDefaults.IconSize),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
.clip(MaterialTheme.shapes.extraLarge)
|
||||
.background(
|
||||
if (isChecked) FixedAccentColors.primaryFixed else MaterialTheme.colorScheme.outline
|
||||
)
|
||||
.toggleable(value = isChecked) { onClick() }
|
||||
.padding(horizontal = 16.dp, vertical = 20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
icon?.let {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, end = 16.dp)
|
||||
.size(24.dp),
|
||||
tint = if (isChecked) FixedAccentColors.onPrimaryFixed else MaterialTheme.colorScheme.surface
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = if (icon == null) 12.dp else 0.dp, end = 12.dp)
|
||||
) {
|
||||
with(MaterialTheme) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 2,
|
||||
color = if (isChecked) FixedAccentColors.onPrimaryFixed else colorScheme.surface
|
||||
)
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = isChecked,
|
||||
onCheckedChange = null,
|
||||
modifier = Modifier.padding(start = 12.dp, end = 6.dp),
|
||||
thumbContent = thumbContent,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedIconColor = FixedAccentColors.onPrimaryFixed,
|
||||
checkedThumbColor = FixedAccentColors.primaryFixed,
|
||||
checkedTrackColor = FixedAccentColors.onPrimaryFixedVariant,
|
||||
uncheckedBorderColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PreferenceSubtitle(
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(start = 18.dp, top = 24.dp, bottom = 12.dp),
|
||||
text: String,
|
||||
color: Color = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(contentPadding),
|
||||
color = color,
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PreferenceSingleChoiceItem(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String,
|
||||
selected: Boolean,
|
||||
contentPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 18.dp),
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.selectable(
|
||||
selected = selected, onClick = onClick
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(contentPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontSize = 20.sp),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
RadioButton(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.padding()
|
||||
.clearAndSetSemantics { },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PreferenceInfo(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String,
|
||||
icon: ImageVector = Icons.Outlined.Info,
|
||||
applyPaddings: Boolean = true
|
||||
) {
|
||||
Row(modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.run {
|
||||
if (applyPaddings) padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
else this
|
||||
}) {
|
||||
Icon(
|
||||
modifier = Modifier.padding(), imageVector = icon, contentDescription = null
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp)
|
||||
.align(Alignment.CenterVertically),
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PreferenceSwitch(
|
||||
title: String,
|
||||
description: String? = null,
|
||||
icon: ImageVector? = null,
|
||||
enabled: Boolean = true,
|
||||
isChecked: Boolean = true,
|
||||
checkedIcon: ImageVector = Icons.Outlined.Check,
|
||||
onClick: (() -> Unit) = {},
|
||||
) {
|
||||
val thumbContent: (@Composable () -> Unit)? = if (isChecked) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = checkedIcon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(SwitchDefaults.IconSize),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.toggleable(value = isChecked,
|
||||
enabled = enabled,
|
||||
onValueChange = { onClick() })
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal.dp, vertical.dp)
|
||||
.padding(start = if (icon == null) 12.dp else 0.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
icon?.let {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, end = 16.dp)
|
||||
.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(enabled)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
PreferenceItemTitle(
|
||||
text = title, enabled = enabled
|
||||
)
|
||||
if (!description.isNullOrEmpty()) PreferenceItemDescription(
|
||||
text = description, enabled = enabled
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = isChecked,
|
||||
onCheckedChange = null,
|
||||
modifier = Modifier.padding(start = 20.dp, end = 6.dp),
|
||||
enabled = enabled,
|
||||
thumbContent = thumbContent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PreferencesHintCard(
|
||||
title: String = "Title ".repeat(2),
|
||||
description: String? = "Description text ".repeat(3),
|
||||
icon: ImageVector? = Icons.Outlined.Translate,
|
||||
containerColor: Color = FixedAccentColors.secondaryFixed,
|
||||
contentColor: Color = FixedAccentColors.onSecondaryFixed,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
.clip(MaterialTheme.shapes.extraLarge)
|
||||
.background(containerColor)
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
icon?.let {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, end = 24.dp)
|
||||
.size(24.dp),
|
||||
tint = contentColor
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = if (icon == null) 12.dp else 0.dp, end = 12.dp)
|
||||
) {
|
||||
with(MaterialTheme) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = typography.titleLarge.copy(fontSize = 20.sp),
|
||||
color = contentColor
|
||||
)
|
||||
if (description != null) Text(
|
||||
text = description,
|
||||
color = contentColor,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
style = typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PreferenceStorageHeader(
|
||||
used: Float = 40F,
|
||||
total: Float = 128F
|
||||
) {
|
||||
|
||||
val animatedProgress = animateFloatAsState(
|
||||
targetValue = 1 - ((total - used) / total),
|
||||
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
||||
label = "Progress"
|
||||
).value
|
||||
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AnimatedNumber(
|
||||
value = FileSize.BYTES.formatWithoutUnits(used)
|
||||
)
|
||||
AnimatedContent(
|
||||
targetState = used,
|
||||
transitionSpec = {
|
||||
if (targetState > initialState) {
|
||||
(fadeIn()).togetherWith(fadeOut())
|
||||
} else {
|
||||
(fadeIn()).togetherWith(fadeOut())
|
||||
}.using(SizeTransform(clip = false))
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.Bottom)
|
||||
.padding(PaddingValues(start = 4.dp, bottom = 12.dp)),
|
||||
label = "Unit"
|
||||
) { targetUsed ->
|
||||
Text(text = FileSize.BYTES.showUnit(LocalContext.current, targetUsed))
|
||||
}
|
||||
AnimatedContent(
|
||||
targetState = total,
|
||||
transitionSpec = {
|
||||
if (targetState > initialState) {
|
||||
(fadeIn()).togetherWith(fadeOut())
|
||||
} else {
|
||||
(fadeIn()).togetherWith(fadeOut())
|
||||
}.using(SizeTransform(clip = false))
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.Bottom)
|
||||
.padding(PaddingValues(bottom = 12.dp)),
|
||||
label = "Total used"
|
||||
) { targetTotal ->
|
||||
Text(
|
||||
text = FileSize.BYTES.totalFormat(LocalContext.current, targetTotal),
|
||||
)
|
||||
}
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier
|
||||
.padding(PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp))
|
||||
.height(16.dp)
|
||||
.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
strokeCap = StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun PreferenceStorageItem(
|
||||
title: String,
|
||||
used: Float = 0F,
|
||||
total: Float = 0F,
|
||||
icon: Any? = null,
|
||||
leadingIcon: (@Composable () -> Unit)? = null,
|
||||
trailingIcon: (@Composable () -> Unit)? = null,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
|
||||
val animatedProgress = animateFloatAsState(
|
||||
targetValue = 1 - ((total - used) / total),
|
||||
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
||||
label = "Progress"
|
||||
).value
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.combinedClickable(
|
||||
onClick = onClick,
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal.dp, vertical.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
leadingIcon?.invoke()
|
||||
|
||||
when (icon) {
|
||||
is ImageVector -> {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, end = 16.dp)
|
||||
.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(true),
|
||||
)
|
||||
}
|
||||
|
||||
is Painter -> {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, end = 16.dp)
|
||||
.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(true),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.then(
|
||||
if (icon != null)
|
||||
Modifier
|
||||
.padding(start = 16.dp, end = 8.dp)
|
||||
else Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
) {
|
||||
Row {
|
||||
PreferenceItemTitle(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = title,
|
||||
enabled = true
|
||||
)
|
||||
AnimatedContent(
|
||||
targetState = used,
|
||||
transitionSpec = {
|
||||
if (targetState > initialState) {
|
||||
(fadeIn()).togetherWith(fadeOut())
|
||||
} else {
|
||||
(fadeIn()).togetherWith(fadeOut())
|
||||
}.using(SizeTransform(clip = false))
|
||||
},
|
||||
label = "Total used"
|
||||
) { targetTotal ->
|
||||
Text(text = FileSize.BYTES.format(LocalContext.current, targetTotal))
|
||||
}
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier
|
||||
.padding(PaddingValues(top = 12.dp))
|
||||
.height(5.dp)
|
||||
.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
strokeCap = StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
trailingIcon?.let {
|
||||
VerticalDivider(
|
||||
modifier = Modifier
|
||||
.height(32.dp)
|
||||
.padding(horizontal = 8.dp)
|
||||
.align(Alignment.CenterVertically),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
|
||||
thickness = 1.dp
|
||||
)
|
||||
trailingIcon.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
fun PreferenceStorageHeaderPreview() {
|
||||
ShirizuTheme {
|
||||
PreferenceStorageHeader()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
fun PreferenceStorageItemPreview() {
|
||||
ShirizuTheme {
|
||||
PreferenceStorageItem(title = "Saved manga", icon = Icons.Outlined.Save, total = 0F)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun PreferenceItemPreview() {
|
||||
Column {
|
||||
PreferenceItem(title = "title", description = "description")
|
||||
PreferenceItem(title = "title", description = "description", icon = Icons.Outlined.Update)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun PreferenceSwitchPreview() {
|
||||
PreferenceSwitch(
|
||||
title = "PreferenceSwitch",
|
||||
description = "Supporting text",
|
||||
icon = Icons.Outlined.ToggleOn,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun PreferenceSwitchWithDividerPreview() {
|
||||
PreferenceSwitchWithDivider(
|
||||
title = "PreferenceSwitch",
|
||||
description = "Supporting text",
|
||||
icon = Icons.Outlined.Call,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
private fun PreferenceSwitchWithContainerPreview() {
|
||||
var isChecked by remember { mutableStateOf(true) }
|
||||
PreviewThemeLight {
|
||||
PreferenceSwitchWithContainer(
|
||||
title = "Title ".repeat(2),
|
||||
isChecked = isChecked,
|
||||
onClick = { isChecked = !isChecked },
|
||||
icon = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
fun PreferenceInfoPreview() {
|
||||
PreferenceInfo(text = "Title")
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreferencesHintCardPreview() {
|
||||
CompositionLocalProvider(LocalTonalPalettes provides Color.Green.toTonalPalettes()) {
|
||||
PreferencesHintCard(
|
||||
title = "Explore new features",
|
||||
icon = Icons.Outlined.TipsAndUpdates,
|
||||
description = "Find out what's new in this version",
|
||||
containerColor = FixedAccentColors.primaryFixed,
|
||||
contentColor = FixedAccentColors.onPrimaryFixed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* @param refreshing Whether the layout is currently refreshing
|
||||
* @param onRefresh Lambda which is invoked when a swipe to refresh gesture is completed.
|
||||
* @param enabled Whether the the layout should react to swipe gestures or not.
|
||||
* @param indicatorPadding Content padding for the indicator, to inset the indicator in if required.
|
||||
* @param content The content containing a vertically scrollable composable.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PullRefresh(
|
||||
refreshing: Boolean,
|
||||
enabled: () -> Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
indicatorPadding: PaddingValues = PaddingValues(0.dp),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val state = rememberPullToRefreshState(
|
||||
isRefreshing = refreshing,
|
||||
extraVerticalOffset = indicatorPadding.calculateTopPadding(),
|
||||
enabled = enabled,
|
||||
onRefresh = onRefresh,
|
||||
)
|
||||
|
||||
Box(modifier.nestedScroll(state.nestedScrollConnection)) {
|
||||
content()
|
||||
|
||||
val contentPadding = remember(indicatorPadding) {
|
||||
object : PaddingValues {
|
||||
override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp =
|
||||
indicatorPadding.calculateLeftPadding(layoutDirection)
|
||||
|
||||
override fun calculateTopPadding(): Dp = 0.dp
|
||||
|
||||
override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp =
|
||||
indicatorPadding.calculateRightPadding(layoutDirection)
|
||||
|
||||
override fun calculateBottomPadding(): Dp =
|
||||
indicatorPadding.calculateBottomPadding()
|
||||
}
|
||||
}
|
||||
PullToRefreshContainer(
|
||||
state = state,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(contentPadding),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberPullToRefreshState(
|
||||
isRefreshing: Boolean,
|
||||
extraVerticalOffset: Dp,
|
||||
positionalThreshold: Dp = 64.dp,
|
||||
enabled: () -> Boolean = { true },
|
||||
onRefresh: () -> Unit,
|
||||
): PullToRefreshStateImpl {
|
||||
val density = LocalDensity.current
|
||||
val extraVerticalOffsetPx = with(density) { extraVerticalOffset.toPx() }
|
||||
val positionalThresholdPx = with(density) { positionalThreshold.toPx() }
|
||||
return rememberSaveable(
|
||||
extraVerticalOffset,
|
||||
positionalThresholdPx,
|
||||
enabled,
|
||||
onRefresh,
|
||||
saver = PullToRefreshStateImpl.Saver(
|
||||
extraVerticalOffset = extraVerticalOffsetPx,
|
||||
positionalThreshold = positionalThresholdPx,
|
||||
enabled = enabled,
|
||||
onRefresh = onRefresh,
|
||||
),
|
||||
) {
|
||||
PullToRefreshStateImpl(
|
||||
initialRefreshing = isRefreshing,
|
||||
extraVerticalOffset = extraVerticalOffsetPx,
|
||||
positionalThreshold = positionalThresholdPx,
|
||||
enabled = enabled,
|
||||
onRefresh = onRefresh,
|
||||
)
|
||||
}.also {
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing && !it.isRefreshing) {
|
||||
it.startRefreshAnimated()
|
||||
} else if (!isRefreshing && it.isRefreshing) {
|
||||
it.endRefreshAnimated()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a [PullToRefreshState].
|
||||
*
|
||||
* @param positionalThreshold The positional threshold, in pixels, in which a refresh is triggered
|
||||
* @param extraVerticalOffset Extra vertical offset, in pixels, for the "refreshing" state
|
||||
* @param initialRefreshing The initial refreshing value of [PullToRefreshState]
|
||||
* @param enabled a callback used to determine whether scroll events are to be handled by this
|
||||
* @param onRefresh a callback to run when pull-to-refresh action is triggered by user
|
||||
* [PullToRefreshState]
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private class PullToRefreshStateImpl(
|
||||
initialRefreshing: Boolean,
|
||||
private val extraVerticalOffset: Float,
|
||||
override val positionalThreshold: Float,
|
||||
enabled: () -> Boolean,
|
||||
private val onRefresh: () -> Unit,
|
||||
) : PullToRefreshState {
|
||||
|
||||
override val progress get() = adjustedDistancePulled / positionalThreshold
|
||||
override var verticalOffset by mutableFloatStateOf(if (initialRefreshing) refreshingVerticalOffset else 0f)
|
||||
|
||||
override var isRefreshing by mutableStateOf(initialRefreshing)
|
||||
|
||||
private val refreshingVerticalOffset: Float
|
||||
get() = positionalThreshold + extraVerticalOffset
|
||||
|
||||
override fun startRefresh() {
|
||||
isRefreshing = true
|
||||
verticalOffset = refreshingVerticalOffset
|
||||
}
|
||||
|
||||
suspend fun startRefreshAnimated() {
|
||||
isRefreshing = true
|
||||
animateTo(refreshingVerticalOffset)
|
||||
}
|
||||
|
||||
override fun endRefresh() {
|
||||
verticalOffset = 0f
|
||||
isRefreshing = false
|
||||
}
|
||||
|
||||
suspend fun endRefreshAnimated() {
|
||||
animateTo(0f)
|
||||
isRefreshing = false
|
||||
}
|
||||
|
||||
override var nestedScrollConnection = object : NestedScrollConnection {
|
||||
override fun onPreScroll(
|
||||
available: Offset,
|
||||
source: NestedScrollSource,
|
||||
): Offset = when {
|
||||
!enabled() -> Offset.Zero
|
||||
// Swiping up
|
||||
source == NestedScrollSource.Drag && available.y < 0 -> {
|
||||
consumeAvailableOffset(available)
|
||||
}
|
||||
else -> Offset.Zero
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource,
|
||||
): Offset = when {
|
||||
!enabled() -> Offset.Zero
|
||||
// Swiping down
|
||||
source == NestedScrollSource.Drag && available.y > 0 -> {
|
||||
consumeAvailableOffset(available)
|
||||
}
|
||||
else -> Offset.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
return Velocity(0f, onRelease(available.y))
|
||||
}
|
||||
}
|
||||
|
||||
/** Helper method for nested scroll connection */
|
||||
fun consumeAvailableOffset(available: Offset): Offset {
|
||||
val y = if (isRefreshing) {
|
||||
0f
|
||||
} else {
|
||||
val newOffset = (distancePulled + available.y).coerceAtLeast(0f)
|
||||
val dragConsumed = newOffset - distancePulled
|
||||
distancePulled = newOffset
|
||||
verticalOffset = calculateVerticalOffset() + (extraVerticalOffset * progress.coerceIn(0f, 1f))
|
||||
dragConsumed
|
||||
}
|
||||
return Offset(0f, y)
|
||||
}
|
||||
|
||||
/** Helper method for nested scroll connection. Calls onRefresh callback when triggered */
|
||||
suspend fun onRelease(velocity: Float): Float {
|
||||
if (isRefreshing) return 0f // Already refreshing, do nothing
|
||||
// Trigger refresh
|
||||
if (adjustedDistancePulled > positionalThreshold) {
|
||||
onRefresh()
|
||||
startRefreshAnimated()
|
||||
} else {
|
||||
animateTo(0f)
|
||||
}
|
||||
|
||||
val consumed = when {
|
||||
// We are flinging without having dragged the pull refresh (for example a fling inside
|
||||
// a list) - don't consume
|
||||
distancePulled == 0f -> 0f
|
||||
// If the velocity is negative, the fling is upwards, and we don't want to prevent the
|
||||
// the list from scrolling
|
||||
velocity < 0f -> 0f
|
||||
// We are showing the indicator, and the fling is downwards - consume everything
|
||||
else -> velocity
|
||||
}
|
||||
distancePulled = 0f
|
||||
return consumed
|
||||
}
|
||||
|
||||
suspend fun animateTo(offset: Float) {
|
||||
animate(initialValue = verticalOffset, targetValue = offset) { value, _ ->
|
||||
verticalOffset = value
|
||||
}
|
||||
}
|
||||
|
||||
/** Provides custom vertical offset behavior for [PullToRefreshContainer] */
|
||||
fun calculateVerticalOffset(): Float = when {
|
||||
// If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
|
||||
adjustedDistancePulled <= positionalThreshold -> adjustedDistancePulled
|
||||
else -> {
|
||||
// How far beyond the threshold pull has gone, as a percentage of the threshold.
|
||||
val overshootPercent = abs(progress) - 1.0f
|
||||
// Limit the overshoot to 200%. Linear between 0 and 200.
|
||||
val linearTension = overshootPercent.coerceIn(0f, 2f)
|
||||
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
|
||||
val tensionPercent = linearTension - linearTension.pow(2) / 4
|
||||
// The additional offset beyond the threshold.
|
||||
val extraOffset = positionalThreshold * tensionPercent
|
||||
positionalThreshold + extraOffset
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The default [Saver] for [PullToRefreshStateImpl]. */
|
||||
fun Saver(
|
||||
extraVerticalOffset: Float,
|
||||
positionalThreshold: Float,
|
||||
enabled: () -> Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
) = Saver<PullToRefreshStateImpl, Boolean>(
|
||||
save = { it.isRefreshing },
|
||||
restore = { isRefreshing ->
|
||||
PullToRefreshStateImpl(
|
||||
initialRefreshing = isRefreshing,
|
||||
extraVerticalOffset = extraVerticalOffset,
|
||||
positionalThreshold = positionalThreshold,
|
||||
enabled = enabled,
|
||||
onRefresh = onRefresh,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private var distancePulled by mutableFloatStateOf(0f)
|
||||
private val adjustedDistancePulled: Float get() = distancePulled * 0.5f
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FloatTweenSpec
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.TweenSpec
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.animateValue
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
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.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
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.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.core.model.MangaHistory
|
||||
import org.xtimms.shirizu.sections.details.model.HistoryInfo
|
||||
import org.xtimms.shirizu.ui.theme.ShirizuTheme
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
fun RowScope.ReadButton(
|
||||
info: HistoryInfo,
|
||||
estimatedReadTime: String
|
||||
) {
|
||||
|
||||
val shift = remember { Animatable(0f) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val animatedCardContainerColor = animateColorAsState(
|
||||
label = "animatedCardContainerColor",
|
||||
targetValue = if (info.totalChapters == 0) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.primaryContainer,
|
||||
animationSpec = TweenSpec(500)
|
||||
).value
|
||||
|
||||
val animatedCardContentColor = animateColorAsState(
|
||||
label = "animatedCardContentColor",
|
||||
targetValue = if (info.totalChapters == 0) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
animationSpec = TweenSpec(500)
|
||||
).value
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
fun anim() {
|
||||
coroutineScope.launch {
|
||||
shift.animateTo(
|
||||
1f,
|
||||
animationSpec = FloatTweenSpec(4000, 0, LinearEasing)
|
||||
)
|
||||
shift.snapTo(0f)
|
||||
anim()
|
||||
}
|
||||
}
|
||||
anim()
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.weight(1F)
|
||||
.height(54.dp),
|
||||
shape = CircleShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = animatedCardContainerColor,
|
||||
contentColor = animatedCardContentColor,
|
||||
),
|
||||
onClick = {
|
||||
// todo
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
BackgroundProgress(
|
||||
if (info.totalChapters == 0) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||
if (!info.isValid) 0.1f else 0.33f,
|
||||
info.history?.percent?.coerceIn(0f, 1f) ?: 0f
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.drawWithLayer {
|
||||
drawContent()
|
||||
val leftOffset = size.width - 20.dp.toPx()
|
||||
drawRect(
|
||||
topLeft = Offset(leftOffset, 0f),
|
||||
size = Size(
|
||||
20.dp.toPx(),
|
||||
size.height,
|
||||
),
|
||||
blendMode = BlendMode.SrcIn,
|
||||
brush = Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
Color.Black,
|
||||
Color.Black.copy(alpha = 0f),
|
||||
),
|
||||
startX = leftOffset,
|
||||
endX = leftOffset + 14.dp.toPx()
|
||||
)
|
||||
)
|
||||
},
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
val infiniteTransition by rememberInfiniteTransition("infiniteTransition").animateValue(
|
||||
label = "infiniteTransition",
|
||||
initialValue = 0,
|
||||
targetValue = 2,
|
||||
typeConverter = Int.VectorConverter,
|
||||
animationSpec = infiniteRepeatable(tween(15000), RepeatMode.Restart)
|
||||
)
|
||||
val chaptersSubtitle = when {
|
||||
!info.isValid -> stringResource(R.string.loading_)
|
||||
info.currentChapter >= 0 -> when (infiniteTransition) {
|
||||
1 -> stringResource(
|
||||
R.string.chapter_d_of_d,
|
||||
info.currentChapter + 1,
|
||||
info.totalChapters,
|
||||
)
|
||||
0 -> estimatedReadTime
|
||||
else -> estimatedReadTime
|
||||
}
|
||||
|
||||
info.totalChapters == 0 -> stringResource(R.string.no_chapters)
|
||||
else -> LocalContext.current.resources.getQuantityString(
|
||||
R.plurals.chapters,
|
||||
info.totalChapters,
|
||||
info.totalChapters,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = if (info.history != null) {
|
||||
stringResource(R.string.continue_reading)
|
||||
} else {
|
||||
stringResource(R.string.read)
|
||||
},
|
||||
)
|
||||
AnimatedContent(targetState = chaptersSubtitle, label = "Subtitle animation") {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ContentDrawScope.drawWithLayer(block: ContentDrawScope.() -> Unit) {
|
||||
with(drawContext.canvas.nativeCanvas) {
|
||||
val checkPoint = saveLayer(null, null)
|
||||
block()
|
||||
restoreToCount(checkPoint)
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.drawWithLayer(block: ContentDrawScope.() -> Unit) = this.then(
|
||||
Modifier.drawWithContent {
|
||||
drawWithLayer {
|
||||
block()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun Preview() {
|
||||
ShirizuTheme {
|
||||
Row {
|
||||
ReadButton(
|
||||
HistoryInfo(
|
||||
100,
|
||||
33,
|
||||
MangaHistory(
|
||||
Instant.now(),
|
||||
Instant.now(),
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
50f
|
||||
)
|
||||
),
|
||||
"123"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Night mode", uiMode = UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun PreviewNightMode() {
|
||||
ShirizuTheme {
|
||||
Row {
|
||||
ReadButton(
|
||||
HistoryInfo(
|
||||
100,
|
||||
33,
|
||||
MangaHistory(
|
||||
Instant.now(),
|
||||
Instant.now(),
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
50f
|
||||
)
|
||||
),
|
||||
"123"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ScaffoldWithTopAppBar(
|
||||
title: String,
|
||||
navigateBack: () -> Unit,
|
||||
snackbarHost: @Composable () -> Unit = {},
|
||||
floatingActionButton: @Composable () -> Unit = {},
|
||||
actions: @Composable (RowScope.() -> Unit) = {},
|
||||
content: @Composable (PaddingValues) -> Unit
|
||||
) {
|
||||
val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
|
||||
rememberTopAppBarState(),
|
||||
canScroll = { true }
|
||||
)
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection)
|
||||
.consumeWindowInsets(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)),
|
||||
topBar = {
|
||||
DefaultTopAppBar(
|
||||
title = title,
|
||||
scrollBehavior = topAppBarScrollBehavior,
|
||||
actions = actions,
|
||||
navigateBack = navigateBack
|
||||
)
|
||||
},
|
||||
snackbarHost = snackbarHost,
|
||||
floatingActionButton = floatingActionButton,
|
||||
contentWindowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ScaffoldWithSmallTopAppBarWithChips(
|
||||
title: String,
|
||||
chips: List<String>,
|
||||
navigateBack: () -> Unit,
|
||||
floatingActionButton: @Composable (() -> Unit) = {},
|
||||
contentWindowInsets: WindowInsets = WindowInsets.systemBars,
|
||||
content: @Composable (PaddingValues) -> Unit
|
||||
) {
|
||||
val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
|
||||
rememberTopAppBarState(),
|
||||
canScroll = { true }
|
||||
)
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
SmallTopAppBarWithChips(
|
||||
title = title,
|
||||
scrollBehavior = topAppBarScrollBehavior,
|
||||
chips = chips,
|
||||
navigateBack = navigateBack
|
||||
)
|
||||
},
|
||||
floatingActionButton = floatingActionButton,
|
||||
contentWindowInsets = contentWindowInsets,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ScaffoldWithClassicTopAppBar(
|
||||
title: String,
|
||||
navigateBack: () -> Unit,
|
||||
floatingActionButton: @Composable (() -> Unit) = {},
|
||||
actions: @Composable (RowScope.() -> Unit) = {},
|
||||
contentWindowInsets: WindowInsets = WindowInsets.systemBars,
|
||||
content: @Composable (PaddingValues) -> Unit
|
||||
) {
|
||||
val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
|
||||
rememberTopAppBarState(),
|
||||
canScroll = { true }
|
||||
)
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
ClassicTopAppBar(
|
||||
title = title,
|
||||
scrollBehavior = topAppBarScrollBehavior,
|
||||
actions = actions,
|
||||
navigateBack = navigateBack
|
||||
)
|
||||
},
|
||||
floatingActionButton = floatingActionButton,
|
||||
contentWindowInsets = contentWindowInsets,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.StarOutline
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.ui.theme.ShirizuTheme
|
||||
import org.xtimms.shirizu.utils.lang.toStringPositiveValueOrUnknown
|
||||
|
||||
@Composable
|
||||
fun SmallScoreIndicator(
|
||||
score: Float?,
|
||||
modifier: Modifier = Modifier,
|
||||
fontSize: TextUnit = 16.sp,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.StarOutline,
|
||||
contentDescription = stringResource(R.string.mean_score),
|
||||
tint = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
if (score != null) {
|
||||
Text(
|
||||
text = (score.times(5.0F)).toStringPositiveValueOrUnknown(),
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
fontSize = fontSize
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun SmallScoreIndicatorPreview() {
|
||||
ShirizuTheme {
|
||||
SmallScoreIndicator(score = 1f)
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SearchBar
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.xtimms.shirizu.R
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun EmbeddedSearchBar(
|
||||
onQueryChange: (String) -> Unit,
|
||||
isSearchActive: Boolean,
|
||||
onActiveChanged: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onSearch: ((String) -> Unit)? = null,
|
||||
) {
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
val activeChanged: (Boolean) -> Unit = { active ->
|
||||
searchQuery = ""
|
||||
onQueryChange("")
|
||||
onActiveChanged(active)
|
||||
}
|
||||
SearchBar(
|
||||
query = searchQuery,
|
||||
onQueryChange = { query ->
|
||||
searchQuery = query
|
||||
onQueryChange(query)
|
||||
},
|
||||
onSearch = onSearch ?: { activeChanged(false) },
|
||||
active = isSearchActive,
|
||||
onActiveChange = activeChanged,
|
||||
modifier = if (isSearchActive) {
|
||||
modifier
|
||||
.animateContentSize(spring(stiffness = Spring.StiffnessHigh))
|
||||
} else {
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize(spring(stiffness = Spring.StiffnessHigh))
|
||||
},
|
||||
placeholder = { Text("Search") },
|
||||
leadingIcon = {
|
||||
if (isSearchActive) {
|
||||
IconButton(
|
||||
onClick = { activeChanged(false) },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Search,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingIcon = if (isSearchActive && searchQuery.isNotEmpty()) {
|
||||
{
|
||||
IconButton(
|
||||
onClick = {
|
||||
searchQuery = ""
|
||||
onQueryChange("")
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Close,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
colors = SearchBarDefaults.colors(
|
||||
containerColor = if (isSearchActive) {
|
||||
MaterialTheme.colorScheme.background
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerLow
|
||||
},
|
||||
),
|
||||
tonalElevation = 0.dp,
|
||||
windowInsets = if (isSearchActive) {
|
||||
SearchBarDefaults.windowInsets
|
||||
} else {
|
||||
WindowInsets(0.dp)
|
||||
}
|
||||
) {
|
||||
// Search suggestions or results
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.xtimms.shirizu.ui.theme.applyOpacity
|
||||
|
||||
@Composable
|
||||
fun SettingTitle(text: String) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(top = 32.dp)
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
text = text,
|
||||
style = MaterialTheme.typography.displaySmall
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingItem(title: String, description: String, icon: ImageVector?, onClick: () -> Unit) {
|
||||
Surface(
|
||||
modifier = Modifier.clickable { onClick() }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp, 20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
icon?.let {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, end = 16.dp)
|
||||
.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(true)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
AnimatedContent(
|
||||
targetState = description,
|
||||
transitionSpec = {
|
||||
if (targetState > initialState) {
|
||||
(fadeIn()).togetherWith(fadeOut())
|
||||
} else {
|
||||
(fadeIn()).togetherWith(fadeOut())
|
||||
}.using(SizeTransform(clip = false))
|
||||
},
|
||||
label = "Total used"
|
||||
) { targetDescription ->
|
||||
Text(
|
||||
text = targetDescription,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.net.toUri
|
||||
import coil.ImageLoader
|
||||
import org.xtimms.shirizu.core.AsyncImageImpl
|
||||
import org.xtimms.shirizu.ui.theme.ShirizuTheme
|
||||
|
||||
@Composable
|
||||
fun SourceItem(
|
||||
coil: ImageLoader,
|
||||
faviconUrl: Uri,
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
maxLines: Int = 1,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(88.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(start = 8.dp, end = 8.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.size(88.dp)
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.aspectRatio(1f)
|
||||
) {
|
||||
AsyncImageImpl(
|
||||
coil = coil,
|
||||
model = faviconUrl,
|
||||
contentDescription = "favicon",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp, bottom = 4.dp)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = maxLines
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SourceItemPreview() {
|
||||
ShirizuTheme {
|
||||
SourceItem(
|
||||
coil = ImageLoader(LocalContext.current),
|
||||
faviconUrl = "".toUri(),
|
||||
title = "Test",
|
||||
onClick = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun TabText(text: String, badgeCount: Int? = null) {
|
||||
val pillAlpha = if (isSystemInDarkTheme()) 0.12f else 0.08f
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (badgeCount != null) {
|
||||
Pill(
|
||||
text = "$badgeCount",
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = pillAlpha),
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
fun TabTextPreview() {
|
||||
TabText(
|
||||
text = "Title",
|
||||
badgeCount = 5
|
||||
)
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components
|
||||
|
||||
import android.graphics.Path
|
||||
import android.view.animation.PathInterpolator
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.FastOutLinearInEasing
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.foundation.border
|
||||
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.RowScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
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.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.outlined.RssFeed
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.icons.outlined.SentimentSatisfiedAlt
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LargeTopAppBar
|
||||
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MediumTopAppBar
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarColors
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Color.Companion.Blue
|
||||
import androidx.compose.ui.graphics.Color.Companion.DarkGray
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import org.xtimms.shirizu.R
|
||||
import org.xtimms.shirizu.core.DURATION_ENTER
|
||||
import org.xtimms.shirizu.core.DURATION_EXIT
|
||||
import org.xtimms.shirizu.core.initialOffset
|
||||
import org.xtimms.shirizu.core.toEasing
|
||||
import org.xtimms.shirizu.sections.explore.EXPLORE_DESTINATION
|
||||
import org.xtimms.shirizu.sections.feed.FEED_DESTINATION
|
||||
import org.xtimms.shirizu.sections.history.HISTORY_DESTINATION
|
||||
import org.xtimms.shirizu.sections.search.SEARCH_DESTINATION
|
||||
import org.xtimms.shirizu.sections.settings.SETTINGS_DESTINATION
|
||||
import org.xtimms.shirizu.sections.shelf.SHELF_DESTINATION
|
||||
import org.xtimms.shirizu.ui.theme.ShirizuTheme
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TopAppBar(
|
||||
navController: NavController,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundAlphaProvider: () -> Float,
|
||||
searchBarColorProvider: () -> Color,
|
||||
) {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
|
||||
val isVisible by remember {
|
||||
derivedStateOf {
|
||||
when (navBackStackEntry?.destination?.route) {
|
||||
SHELF_DESTINATION, HISTORY_DESTINATION, EXPLORE_DESTINATION,
|
||||
null -> true
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val aprilFoolsDay = LocalDate.of(LocalDate.now().year, 4, 1)
|
||||
val dtStart = aprilFoolsDay.format(DateTimeFormatter.ISO_DATE)
|
||||
val currentDt = LocalDate.now()
|
||||
|
||||
val isAprilFoolsDay = currentDt.format(DateTimeFormatter.ISO_DATE).equals(dtStart)
|
||||
|
||||
val path = Path().apply {
|
||||
moveTo(0f, 0f)
|
||||
cubicTo(0.05F, 0F, 0.133333F, 0.06F, 0.166666F, 0.4F)
|
||||
cubicTo(0.208333F, 0.82F, 0.25F, 1F, 1F, 1F)
|
||||
}
|
||||
|
||||
val emphasizePathInterpolator = PathInterpolator(path)
|
||||
val emphasizeEasing = emphasizePathInterpolator.toEasing()
|
||||
|
||||
val enterTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
|
||||
val exitTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
|
||||
val fadeTween = tween<Float>(durationMillis = DURATION_EXIT)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter = slideInHorizontally(
|
||||
enterTween,
|
||||
initialOffsetX = { -(it * initialOffset).toInt() }) + fadeIn(fadeTween),
|
||||
exit = slideOutHorizontally(
|
||||
exitTween,
|
||||
targetOffsetX = { -(it * initialOffset).toInt() }) + fadeOut(fadeTween)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
MaterialTheme.colorScheme
|
||||
.surfaceColorAtElevation(3.dp)
|
||||
.copy(
|
||||
alpha = backgroundAlphaProvider()
|
||||
)
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Card(
|
||||
onClick = { navController.navigate(SEARCH_DESTINATION) },
|
||||
modifier = modifier
|
||||
.weight(1f)
|
||||
.height(56.dp)
|
||||
.padding(start = 16.dp),
|
||||
shape = RoundedCornerShape(50),
|
||||
colors = CardDefaults.cardColors().copy(containerColor = searchBarColorProvider()),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxHeight(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isAprilFoolsDay) Icons.Outlined.SentimentSatisfiedAlt else Icons.Outlined.Search,
|
||||
contentDescription = "search",
|
||||
tint = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.search),
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = modifier.padding(end = 16.dp),
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { navController.navigate(FEED_DESTINATION) },
|
||||
modifier = Modifier.padding(0.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.RssFeed,
|
||||
contentDescription = stringResource(id = R.string.feed),
|
||||
tint = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { navController.navigate(SETTINGS_DESTINATION) },
|
||||
modifier = Modifier.padding(0.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Settings,
|
||||
contentDescription = stringResource(id = R.string.settings),
|
||||
tint = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DefaultTopAppBar(
|
||||
title: String,
|
||||
actions: @Composable (RowScope.() -> Unit),
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
navigateBack: () -> Unit,
|
||||
) {
|
||||
LargeTopAppBar(
|
||||
title = { Text(text = title) },
|
||||
navigationIcon = {
|
||||
BackIconButton(onClick = navigateBack)
|
||||
},
|
||||
actions = actions,
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SmallTopAppBarWithChips(
|
||||
title: String,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
navigateBack: () -> Unit,
|
||||
chips: List<String>
|
||||
) {
|
||||
Column {
|
||||
MediumTopAppBar(
|
||||
title = { Text(text = title) },
|
||||
navigationIcon = {
|
||||
BackIconButton(onClick = navigateBack)
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
LazyRow(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(items = chips) {
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
|
||||
SuggestionChip(
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
onClick = { },
|
||||
label = { Text(text = it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SmallTopAppBar(
|
||||
title: String,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
navigateBack: () -> Unit,
|
||||
) {
|
||||
MediumTopAppBar(
|
||||
title = { Text(text = title) },
|
||||
navigationIcon = {
|
||||
BackIconButton(onClick = navigateBack)
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ClassicTopAppBar(
|
||||
title: String,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
actions: @Composable (RowScope.() -> Unit),
|
||||
navigateBack: () -> Unit,
|
||||
) {
|
||||
androidx.compose.material3.TopAppBar(
|
||||
title = { Text(text = title) },
|
||||
navigationIcon = {
|
||||
BackIconButton(onClick = navigateBack)
|
||||
},
|
||||
actions = actions,
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppBarTitle(
|
||||
title: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
subtitle?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.basicMarquee(
|
||||
delayMillis = 2_000,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components.effects
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListUpdateCallback
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.xtimms.shirizu.core.model.ListModel
|
||||
import java.time.Instant
|
||||
|
||||
enum class RowEntityType { Header, Item }
|
||||
|
||||
data class RowEntity(
|
||||
val type: RowEntityType,
|
||||
val key: String,
|
||||
var contentHash: String? = null,
|
||||
val day: Instant,
|
||||
var itemModel: ListModel?,
|
||||
)
|
||||
|
||||
@SuppressLint("ComposableNaming", "UnusedTransitionTargetStateParameter")
|
||||
/**
|
||||
* @param state Use [updateAnimatedItemsState].
|
||||
*/
|
||||
inline fun LazyListScope.animatedItemsIndexed(
|
||||
state: List<AnimatedItem<RowEntity>>,
|
||||
enterTransition: EnterTransition = expandVertically() + fadeIn(),
|
||||
exitTransition: ExitTransition = shrinkVertically() + fadeOut(),
|
||||
noinline key: ((item: RowEntity) -> Any)? = null,
|
||||
crossinline itemContent: @Composable LazyItemScope.(index: Int, item: RowEntity) -> Unit
|
||||
) {
|
||||
items(
|
||||
state.size,
|
||||
if (key != null) { keyIndex: Int -> key(state[keyIndex].item) } else null
|
||||
) { index ->
|
||||
|
||||
val item = state[index]
|
||||
|
||||
key(key?.invoke(item.item)) {
|
||||
AnimatedVisibility(
|
||||
visibleState = item.visibility,
|
||||
enter = enterTransition,
|
||||
exit = exitTransition
|
||||
) {
|
||||
itemContent(index, item.item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun updateAnimatedItemsState(
|
||||
newList: List<RowEntity>
|
||||
): State<List<AnimatedItem<RowEntity>>> {
|
||||
|
||||
val state = remember { mutableStateOf(emptyList<AnimatedItem<RowEntity>>()) }
|
||||
val firstInject = remember { mutableStateOf(true) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
state.value = emptyList()
|
||||
onDispose {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(newList) {
|
||||
if (state.value == newList) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val oldList = state.value.toList()
|
||||
|
||||
val diffCb = object : DiffUtil.Callback() {
|
||||
override fun getOldListSize(): Int = oldList.size
|
||||
override fun getNewListSize(): Int = newList.size
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
||||
oldList[oldItemPosition].item.key == newList[newItemPosition].key
|
||||
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
||||
(oldList[oldItemPosition].item.contentHash
|
||||
?: oldList[oldItemPosition].item.key) == (newList[newItemPosition].contentHash
|
||||
?: newList[newItemPosition].key)
|
||||
|
||||
override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): RowEntity =
|
||||
newList[newItemPosition]
|
||||
}
|
||||
val diffResult = calculateDiff(false, diffCb)
|
||||
val compositeList = oldList.toMutableList()
|
||||
|
||||
diffResult.dispatchUpdatesTo(object : ListUpdateCallback {
|
||||
override fun onInserted(position: Int, count: Int) {
|
||||
for (i in 0 until count) {
|
||||
val newItem = AnimatedItem(
|
||||
visibility = MutableTransitionState(firstInject.value),
|
||||
newList[position + i]
|
||||
)
|
||||
newItem.visibility.targetState = true
|
||||
compositeList.add(position + i, newItem)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRemoved(position: Int, count: Int) {
|
||||
for (i in 0 until count) {
|
||||
compositeList[position + i].visibility.targetState = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMoved(fromPosition: Int, toPosition: Int) {
|
||||
// not detecting moves.
|
||||
}
|
||||
|
||||
override fun onChanged(position: Int, count: Int, payload: Any?) {
|
||||
for (i in 0 until count) {
|
||||
compositeList[position + i].item.itemModel = (payload as RowEntity).itemModel
|
||||
compositeList[position + i].item.contentHash = payload.contentHash
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (state.value != compositeList) {
|
||||
state.value = compositeList
|
||||
}
|
||||
firstInject.value = false
|
||||
val initialAnimation = androidx.compose.animation.core.Animatable(1.0f)
|
||||
initialAnimation.animateTo(0f)
|
||||
state.value = state.value.filter { it.visibility.targetState }
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
data class AnimatedItem<T>(
|
||||
val visibility: MutableTransitionState<Boolean>,
|
||||
val item: T,
|
||||
) {
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return item?.hashCode() ?: 0
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as AnimatedItem<*>
|
||||
|
||||
if (item != other.item) return false
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun calculateDiff(
|
||||
detectMoves: Boolean = true,
|
||||
diffCb: DiffUtil.Callback
|
||||
): DiffUtil.DiffResult {
|
||||
return withContext(Dispatchers.Unconfined) {
|
||||
DiffUtil.calculateDiff(diffCb, detectMoves)
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components.effects
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import kotlin.random.Random
|
||||
|
||||
data class Snowflake(
|
||||
var x: Float,
|
||||
var y: Float,
|
||||
var radius: Float,
|
||||
var speed: Float
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SnowfallEffect() {
|
||||
val snowflakes = remember { List(100) { generateRandomSnowflake() } }
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "")
|
||||
|
||||
val offsetY by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 100f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 10000, easing = LinearEasing)
|
||||
), label = ""
|
||||
)
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize().background(Color.Transparent)) {
|
||||
snowflakes.forEach { snowflake ->
|
||||
drawSnowflake(snowflake, offsetY % size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun generateRandomSnowflake(): Snowflake {
|
||||
return Snowflake(
|
||||
x = Random.nextFloat(),
|
||||
y = Random.nextFloat() * 1000f,
|
||||
radius = Random.nextFloat() * 2f + 2f, // Snowflake size
|
||||
speed = Random.nextFloat() * 0.5f + 1f // Falling speed
|
||||
)
|
||||
}
|
||||
|
||||
fun DrawScope.drawSnowflake(snowflake: Snowflake, offsetY: Float) {
|
||||
val newY = (snowflake.y + offsetY * snowflake.speed) % size.height
|
||||
drawCircle(Color.White, radius = snowflake.radius, center = Offset(snowflake.x * size.width, newY))
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components.icons
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.materialIcon
|
||||
import androidx.compose.material.icons.materialPath
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
public val Icons.Outlined.ArrowDecisionOutline: ImageVector
|
||||
get() {
|
||||
if (_arrow_decision_outline != null) {
|
||||
return _arrow_decision_outline!!
|
||||
}
|
||||
_arrow_decision_outline = materialIcon(name = "Outlined.ArrowDecisionOutline") {
|
||||
materialPath {
|
||||
moveTo(9.64f, 13.4f)
|
||||
curveTo(8.63f, 12.5f, 7.34f, 12.03f, 6.0f, 12.0f)
|
||||
verticalLineTo(15.0f)
|
||||
lineTo(2.0f, 11.0f)
|
||||
lineTo(6.0f, 7.0f)
|
||||
verticalLineTo(10.0f)
|
||||
curveTo(7.67f, 10.0f, 9.3f, 10.57f, 10.63f, 11.59f)
|
||||
curveTo(10.22f, 12.15f, 9.89f, 12.76f, 9.64f, 13.4f)
|
||||
moveTo(18.0f, 15.0f)
|
||||
verticalLineTo(12.0f)
|
||||
curveTo(17.5f, 12.0f, 13.5f, 12.16f, 13.05f, 16.2f)
|
||||
curveTo(14.61f, 16.75f, 15.43f, 18.47f, 14.88f, 20.03f)
|
||||
curveTo(14.33f, 21.59f, 12.61f, 22.41f, 11.05f, 21.86f)
|
||||
curveTo(9.5f, 21.3f, 8.67f, 19.59f, 9.22f, 18.03f)
|
||||
curveTo(9.5f, 17.17f, 10.2f, 16.5f, 11.05f, 16.2f)
|
||||
curveTo(11.34f, 12.61f, 14.4f, 9.88f, 18.0f, 10.0f)
|
||||
verticalLineTo(7.0f)
|
||||
lineTo(22.0f, 11.0f)
|
||||
lineTo(18.0f, 15.0f)
|
||||
moveTo(13.0f, 19.0f)
|
||||
arcTo(1.0f, 1.0f, 0.0f, false, false, 12.0f, 18.0f)
|
||||
arcTo(1.0f, 1.0f, 0.0f, false, false, 11.0f, 19.0f)
|
||||
arcTo(1.0f, 1.0f, 0.0f, false, false, 12.0f, 20.0f)
|
||||
arcTo(1.0f, 1.0f, 0.0f, false, false, 13.0f, 19.0f)
|
||||
moveTo(11.0f, 11.12f)
|
||||
curveTo(11.58f, 10.46f, 12.25f, 9.89f, 13.0f, 9.43f)
|
||||
verticalLineTo(5.0f)
|
||||
horizontalLineTo(16.0f)
|
||||
lineTo(12.0f, 1.0f)
|
||||
lineTo(8.0f, 5.0f)
|
||||
horizontalLineTo(11.0f)
|
||||
verticalLineTo(11.12f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
return _arrow_decision_outline!!
|
||||
}
|
||||
|
||||
private var _arrow_decision_outline: ImageVector? = null
|
||||
@@ -1,60 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components.icons
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.materialIcon
|
||||
import androidx.compose.material.icons.materialPath
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
public val Icons.Outlined.Dice: ImageVector
|
||||
get() {
|
||||
if (_dice != null) {
|
||||
return _dice!!
|
||||
}
|
||||
_dice = materialIcon(name = "Outlined.Dice") {
|
||||
materialPath {
|
||||
moveTo(19.0f, 5.0f)
|
||||
verticalLineTo(19.0f)
|
||||
horizontalLineTo(5.0f)
|
||||
verticalLineTo(5.0f)
|
||||
horizontalLineTo(19.0f)
|
||||
moveTo(19.0f, 3.0f)
|
||||
horizontalLineTo(5.0f)
|
||||
curveTo(3.9f, 3.0f, 3.0f, 3.9f, 3.0f, 5.0f)
|
||||
verticalLineTo(19.0f)
|
||||
curveTo(3.0f, 20.1f, 3.9f, 21.0f, 5.0f, 21.0f)
|
||||
horizontalLineTo(19.0f)
|
||||
curveTo(20.1f, 21.0f, 21.0f, 20.1f, 21.0f, 19.0f)
|
||||
verticalLineTo(5.0f)
|
||||
curveTo(21.0f, 3.9f, 20.1f, 3.0f, 19.0f, 3.0f)
|
||||
moveTo(7.5f, 6.0f)
|
||||
curveTo(6.7f, 6.0f, 6.0f, 6.7f, 6.0f, 7.5f)
|
||||
reflectiveCurveTo(6.7f, 9.0f, 7.5f, 9.0f)
|
||||
reflectiveCurveTo(9.0f, 8.3f, 9.0f, 7.5f)
|
||||
reflectiveCurveTo(8.3f, 6.0f, 7.5f, 6.0f)
|
||||
moveTo(16.5f, 15.0f)
|
||||
curveTo(15.7f, 15.0f, 15.0f, 15.7f, 15.0f, 16.5f)
|
||||
curveTo(15.0f, 17.3f, 15.7f, 18.0f, 16.5f, 18.0f)
|
||||
curveTo(17.3f, 18.0f, 18.0f, 17.3f, 18.0f, 16.5f)
|
||||
curveTo(18.0f, 15.7f, 17.3f, 15.0f, 16.5f, 15.0f)
|
||||
moveTo(16.5f, 6.0f)
|
||||
curveTo(15.7f, 6.0f, 15.0f, 6.7f, 15.0f, 7.5f)
|
||||
reflectiveCurveTo(15.7f, 9.0f, 16.5f, 9.0f)
|
||||
curveTo(17.3f, 9.0f, 18.0f, 8.3f, 18.0f, 7.5f)
|
||||
reflectiveCurveTo(17.3f, 6.0f, 16.5f, 6.0f)
|
||||
moveTo(12.0f, 10.5f)
|
||||
curveTo(11.2f, 10.5f, 10.5f, 11.2f, 10.5f, 12.0f)
|
||||
reflectiveCurveTo(11.2f, 13.5f, 12.0f, 13.5f)
|
||||
reflectiveCurveTo(13.5f, 12.8f, 13.5f, 12.0f)
|
||||
reflectiveCurveTo(12.8f, 10.5f, 12.0f, 10.5f)
|
||||
moveTo(7.5f, 15.0f)
|
||||
curveTo(6.7f, 15.0f, 6.0f, 15.7f, 6.0f, 16.5f)
|
||||
curveTo(6.0f, 17.3f, 6.7f, 18.0f, 7.5f, 18.0f)
|
||||
reflectiveCurveTo(9.0f, 17.3f, 9.0f, 16.5f)
|
||||
curveTo(9.0f, 15.7f, 8.3f, 15.0f, 7.5f, 15.0f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
return _dice!!
|
||||
}
|
||||
|
||||
private var _dice: ImageVector? = null
|
||||
@@ -1,293 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components.icons
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathFillType.Companion.NonZero
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap.Companion.Butt
|
||||
import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.ImageVector.Builder
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
public val Icons.Filled.Kotatsu: ImageVector
|
||||
get() {
|
||||
if (_kotatsu != null) {
|
||||
return _kotatsu!!
|
||||
}
|
||||
_kotatsu = Builder(name = "Kotatsu", defaultWidth = 1406.2.dp, defaultHeight = 1406.2.dp,
|
||||
viewportWidth = 1406.2f, viewportHeight = 1406.2f).apply {
|
||||
path(fill = SolidColor(Color(0xFF0058C9)), stroke = null, strokeLineWidth = 0.0f,
|
||||
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
|
||||
pathFillType = NonZero) {
|
||||
moveTo(391.7f, 270.7f)
|
||||
curveToRelative(-51.6f, 18.6f, -96.2f, 88.4f, -117.9f, 183.6f)
|
||||
curveToRelative(-7.8f, 34.8f, -15.1f, 93.5f, -15.1f, 121.7f)
|
||||
verticalLineToRelative(19.7f)
|
||||
lineToRelative(-23.3f, 36.6f)
|
||||
curveToRelative(-65.0f, 101.3f, -124.6f, 206.8f, -180.5f, 319.2f)
|
||||
curveTo(5.1f, 1051.0f, 0.0f, 1063.2f, 0.0f, 1080.9f)
|
||||
curveToRelative(0.0f, 7.8f, 2.0f, 18.0f, 4.0f, 22.2f)
|
||||
curveToRelative(6.6f, 12.0f, 22.2f, 24.4f, 39.7f, 31.0f)
|
||||
lineToRelative(16.0f, 6.2f)
|
||||
lineToRelative(651.1f, -0.2f)
|
||||
curveToRelative(633.1f, 0.0f, 651.1f, -0.4f, 661.2f, -5.3f)
|
||||
curveToRelative(32.6f, -16.9f, 43.0f, -51.7f, 26.2f, -88.0f)
|
||||
curveToRelative(-63.8f, -139.0f, -150.5f, -296.8f, -229.6f, -418.5f)
|
||||
lineToRelative(-19.1f, -29.5f)
|
||||
lineToRelative(-2.2f, -33.7f)
|
||||
curveToRelative(-8.7f, -129.4f, -36.1f, -208.6f, -92.0f, -266.5f)
|
||||
curveToRelative(-24.2f, -24.8f, -33.5f, -30.4f, -50.6f, -30.6f)
|
||||
curveToRelative(-23.9f, 0.0f, -39.9f, 10.9f, -75.6f, 52.1f)
|
||||
curveToRelative(-35.2f, 40.4f, -42.4f, 50.1f, -66.9f, 86.4f)
|
||||
curveToRelative(-12.0f, 17.7f, -27.0f, 38.3f, -33.2f, 45.5f)
|
||||
lineToRelative(-11.3f, 13.5f)
|
||||
horizontalLineToRelative(-117.0f)
|
||||
lineToRelative(-117.0f, -0.2f)
|
||||
lineTo(560.2f, 429.0f)
|
||||
curveTo(515.0f, 359.2f, 440.7f, 274.5f, 419.6f, 268.7f)
|
||||
curveTo(406.8f, 264.9f, 409.0f, 264.5f, 391.7f, 270.7f)
|
||||
close()
|
||||
moveTo(466.2f, 666.4f)
|
||||
curveToRelative(8.9f, 6.2f, 11.3f, 11.8f, 14.4f, 37.7f)
|
||||
curveToRelative(4.0f, 30.6f, 7.7f, 34.8f, 27.5f, 32.4f)
|
||||
curveToRelative(18.0f, -2.2f, 32.6f, 3.6f, 40.8f, 16.6f)
|
||||
curveToRelative(16.0f, 25.9f, -11.5f, 80.2f, -50.6f, 99.3f)
|
||||
curveToRelative(-14.0f, 7.1f, -19.1f, 7.8f, -42.8f, 7.8f)
|
||||
curveToRelative(-22.8f, 0.0f, -28.8f, -1.1f, -39.4f, -6.7f)
|
||||
curveToRelative(-31.2f, -16.4f, -50.3f, -40.3f, -58.3f, -71.8f)
|
||||
curveToRelative(-4.0f, -16.6f, -4.2f, -21.7f, -1.1f, -36.3f)
|
||||
curveToRelative(4.2f, -21.1f, 11.5f, -35.2f, 24.8f, -50.1f)
|
||||
curveTo(404.8f, 669.5f, 449.1f, 654.4f, 466.2f, 666.4f)
|
||||
close()
|
||||
moveTo(964.0f, 669.0f)
|
||||
curveToRelative(8.7f, 7.3f, 9.3f, 9.7f, 13.5f, 43.4f)
|
||||
curveToRelative(2.6f, 20.6f, 8.7f, 26.8f, 25.3f, 24.2f)
|
||||
curveToRelative(16.0f, -2.2f, 29.9f, 2.2f, 39.2f, 12.9f)
|
||||
curveToRelative(15.1f, 18.2f, 6.2f, 53.4f, -20.8f, 82.2f)
|
||||
curveToRelative(-21.7f, 23.1f, -35.2f, 28.4f, -69.2f, 28.8f)
|
||||
curveToRelative(-25.9f, 0.0f, -29.1f, -0.5f, -42.8f, -8.2f)
|
||||
curveToRelative(-20.2f, -11.3f, -38.4f, -29.9f, -47.7f, -49.0f)
|
||||
curveToRelative(-6.2f, -12.8f, -8.2f, -20.8f, -8.9f, -39.2f)
|
||||
curveToRelative(-0.9f, -21.1f, 0.0f, -25.0f, 7.7f, -41.0f)
|
||||
curveToRelative(14.0f, -30.4f, 35.5f, -49.2f, 65.0f, -57.0f)
|
||||
curveTo(945.2f, 660.2f, 954.7f, 661.1f, 964.0f, 669.0f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
|
||||
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
|
||||
pathFillType = NonZero) {
|
||||
moveTo(1806.5f, 939.2f)
|
||||
verticalLineTo(464.0f)
|
||||
horizontalLineToRelative(88.3f)
|
||||
verticalLineToRelative(475.3f)
|
||||
lineTo(1806.5f, 939.2f)
|
||||
lineTo(1806.5f, 939.2f)
|
||||
close()
|
||||
moveTo(1885.3f, 827.2f)
|
||||
lineToRelative(-4.8f, -104.5f)
|
||||
lineTo(2129.7f, 464.0f)
|
||||
horizontalLineToRelative(99.1f)
|
||||
lineToRelative(-207.1f, 220.0f)
|
||||
lineToRelative(-48.9f, 53.6f)
|
||||
lineTo(1885.3f, 827.2f)
|
||||
close()
|
||||
moveTo(2137.8f, 939.2f)
|
||||
lineToRelative(-181.9f, -216.6f)
|
||||
lineToRelative(58.4f, -64.5f)
|
||||
lineTo(2241.0f, 939.2f)
|
||||
horizontalLineTo(2137.8f)
|
||||
lineTo(2137.8f, 939.2f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
|
||||
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
|
||||
pathFillType = NonZero) {
|
||||
moveTo(2440.6f, 944.0f)
|
||||
curveToRelative(-37.1f, 0.0f, -70.2f, -8.0f, -99.1f, -24.1f)
|
||||
curveToRelative(-29.0f, -16.1f, -51.8f, -38.1f, -68.6f, -66.2f)
|
||||
curveToRelative(-16.8f, -28.1f, -25.1f, -60.0f, -25.1f, -95.7f)
|
||||
curveToRelative(0.0f, -36.2f, 8.4f, -68.2f, 25.1f, -96.1f)
|
||||
curveToRelative(16.7f, -27.8f, 39.6f, -49.7f, 68.6f, -65.5f)
|
||||
reflectiveCurveToRelative(62.0f, -23.8f, 99.1f, -23.8f)
|
||||
curveToRelative(37.6f, 0.0f, 70.9f, 7.9f, 100.1f, 23.8f)
|
||||
curveToRelative(29.2f, 15.9f, 52.1f, 37.6f, 68.6f, 65.2f)
|
||||
curveToRelative(16.5f, 27.6f, 24.8f, 59.8f, 24.8f, 96.4f)
|
||||
curveToRelative(0.0f, 35.8f, -8.3f, 67.7f, -24.8f, 95.7f)
|
||||
curveToRelative(-16.5f, 28.1f, -39.4f, 50.1f, -68.6f, 66.2f)
|
||||
curveTo(2511.6f, 936.0f, 2478.2f, 944.0f, 2440.6f, 944.0f)
|
||||
close()
|
||||
moveTo(2440.6f, 871.3f)
|
||||
curveToRelative(20.8f, 0.0f, 39.4f, -4.5f, 55.7f, -13.6f)
|
||||
curveToRelative(16.3f, -9.0f, 29.1f, -22.2f, 38.4f, -39.4f)
|
||||
curveToRelative(9.3f, -17.2f, 13.9f, -37.3f, 13.9f, -60.4f)
|
||||
curveToRelative(0.0f, -23.5f, -4.6f, -43.8f, -13.9f, -60.8f)
|
||||
curveToRelative(-9.3f, -17.0f, -22.1f, -30.0f, -38.4f, -39.0f)
|
||||
curveToRelative(-16.3f, -9.0f, -34.6f, -13.6f, -55.0f, -13.6f)
|
||||
curveToRelative(-20.8f, 0.0f, -39.3f, 4.5f, -55.3f, 13.6f)
|
||||
curveToRelative(-16.1f, 9.0f, -28.8f, 22.1f, -38.3f, 39.0f)
|
||||
curveToRelative(-9.5f, 17.0f, -14.3f, 37.2f, -14.3f, 60.8f)
|
||||
curveToRelative(0.0f, 23.1f, 4.8f, 43.2f, 14.3f, 60.4f)
|
||||
reflectiveCurveToRelative(22.3f, 30.3f, 38.3f, 39.4f)
|
||||
curveTo(2402.0f, 866.8f, 2420.3f, 871.3f, 2440.6f, 871.3f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
|
||||
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
|
||||
pathFillType = NonZero) {
|
||||
moveTo(2667.4f, 647.3f)
|
||||
verticalLineToRelative(-67.9f)
|
||||
horizontalLineToRelative(241.7f)
|
||||
verticalLineToRelative(67.9f)
|
||||
horizontalLineTo(2667.4f)
|
||||
close()
|
||||
moveTo(2852.1f, 944.0f)
|
||||
curveToRelative(-39.8f, 0.0f, -70.6f, -10.3f, -92.3f, -30.9f)
|
||||
curveToRelative(-21.7f, -20.6f, -32.6f, -51.0f, -32.6f, -91.3f)
|
||||
verticalLineTo(496.6f)
|
||||
horizontalLineToRelative(84.9f)
|
||||
verticalLineToRelative(323.2f)
|
||||
curveToRelative(0.0f, 17.2f, 4.4f, 30.5f, 13.2f, 40.1f)
|
||||
curveToRelative(8.8f, 9.5f, 21.1f, 14.3f, 37.0f, 14.3f)
|
||||
curveToRelative(19.0f, 0.0f, 34.9f, -5.0f, 47.5f, -14.9f)
|
||||
lineToRelative(23.8f, 60.4f)
|
||||
curveToRelative(-9.9f, 8.2f, -22.2f, 14.3f, -36.7f, 18.3f)
|
||||
curveTo(2882.4f, 941.9f, 2867.4f, 944.0f, 2852.1f, 944.0f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
|
||||
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
|
||||
pathFillType = NonZero) {
|
||||
moveTo(3112.8f, 944.0f)
|
||||
curveToRelative(-27.2f, 0.0f, -50.9f, -4.6f, -71.3f, -13.9f)
|
||||
curveToRelative(-20.4f, -9.3f, -36.1f, -22.2f, -47.2f, -38.7f)
|
||||
curveToRelative(-11.1f, -16.5f, -16.6f, -35.2f, -16.6f, -56.0f)
|
||||
curveToRelative(0.0f, -20.4f, 4.9f, -38.7f, 14.6f, -55.0f)
|
||||
curveToRelative(9.7f, -16.3f, 25.7f, -29.2f, 47.9f, -38.7f)
|
||||
reflectiveCurveToRelative(51.6f, -14.3f, 88.3f, -14.3f)
|
||||
horizontalLineToRelative(105.2f)
|
||||
verticalLineToRelative(56.3f)
|
||||
horizontalLineToRelative(-99.1f)
|
||||
curveToRelative(-29.0f, 0.0f, -48.4f, 4.7f, -58.4f, 13.9f)
|
||||
curveToRelative(-10.0f, 9.3f, -14.9f, 20.7f, -14.9f, 34.3f)
|
||||
curveToRelative(0.0f, 15.4f, 6.1f, 27.6f, 18.3f, 36.7f)
|
||||
curveToRelative(12.2f, 9.1f, 29.2f, 13.6f, 50.9f, 13.6f)
|
||||
curveToRelative(20.8f, 0.0f, 39.5f, -4.8f, 56.0f, -14.3f)
|
||||
reflectiveCurveToRelative(28.4f, -23.5f, 35.6f, -42.1f)
|
||||
lineToRelative(14.3f, 50.9f)
|
||||
curveToRelative(-8.1f, 21.3f, -22.7f, 37.8f, -43.8f, 49.6f)
|
||||
reflectiveCurveTo(3144.9f, 944.0f, 3112.8f, 944.0f)
|
||||
close()
|
||||
moveTo(3226.8f, 939.2f)
|
||||
verticalLineToRelative(-73.3f)
|
||||
lineToRelative(-4.8f, -15.6f)
|
||||
verticalLineTo(722.0f)
|
||||
curveToRelative(0.0f, -24.9f, -7.5f, -44.2f, -22.4f, -58.0f)
|
||||
reflectiveCurveToRelative(-37.6f, -20.7f, -67.9f, -20.7f)
|
||||
curveToRelative(-20.4f, 0.0f, -40.4f, 3.2f, -60.1f, 9.5f)
|
||||
curveToRelative(-19.7f, 6.3f, -36.3f, 15.2f, -49.9f, 26.5f)
|
||||
lineToRelative(-33.3f, -61.8f)
|
||||
curveToRelative(19.4f, -14.9f, 42.6f, -26.1f, 69.6f, -33.6f)
|
||||
curveToRelative(26.9f, -7.5f, 54.9f, -11.2f, 83.8f, -11.2f)
|
||||
curveToRelative(52.5f, 0.0f, 93.1f, 12.6f, 121.9f, 37.7f)
|
||||
curveToRelative(28.7f, 25.1f, 43.1f, 63.9f, 43.1f, 116.4f)
|
||||
verticalLineToRelative(212.5f)
|
||||
lineTo(3226.8f, 939.2f)
|
||||
lineTo(3226.8f, 939.2f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
|
||||
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
|
||||
pathFillType = NonZero) {
|
||||
moveTo(3367.4f, 647.3f)
|
||||
verticalLineToRelative(-67.9f)
|
||||
horizontalLineToRelative(241.7f)
|
||||
verticalLineToRelative(67.9f)
|
||||
horizontalLineTo(3367.4f)
|
||||
close()
|
||||
moveTo(3552.0f, 944.0f)
|
||||
curveToRelative(-39.8f, 0.0f, -70.6f, -10.3f, -92.3f, -30.9f)
|
||||
curveToRelative(-21.7f, -20.6f, -32.6f, -51.0f, -32.6f, -91.3f)
|
||||
verticalLineTo(496.6f)
|
||||
horizontalLineToRelative(84.9f)
|
||||
verticalLineToRelative(323.2f)
|
||||
curveToRelative(0.0f, 17.2f, 4.4f, 30.5f, 13.2f, 40.1f)
|
||||
curveToRelative(8.8f, 9.5f, 21.1f, 14.3f, 37.0f, 14.3f)
|
||||
curveToRelative(19.0f, 0.0f, 34.9f, -5.0f, 47.5f, -14.9f)
|
||||
lineToRelative(23.8f, 60.4f)
|
||||
curveToRelative(-9.9f, 8.2f, -22.2f, 14.3f, -36.7f, 18.3f)
|
||||
curveTo(3582.3f, 941.9f, 3567.4f, 944.0f, 3552.0f, 944.0f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
|
||||
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
|
||||
pathFillType = NonZero) {
|
||||
moveTo(3815.5f, 944.0f)
|
||||
curveToRelative(-30.3f, 0.0f, -59.4f, -4.0f, -87.2f, -11.9f)
|
||||
reflectiveCurveToRelative(-49.9f, -17.5f, -66.2f, -28.9f)
|
||||
lineToRelative(32.6f, -64.5f)
|
||||
curveToRelative(16.3f, 10.4f, 35.8f, 19.0f, 58.4f, 25.8f)
|
||||
curveToRelative(22.6f, 6.8f, 45.3f, 10.2f, 67.9f, 10.2f)
|
||||
curveToRelative(26.7f, 0.0f, 46.0f, -3.6f, 58.0f, -10.9f)
|
||||
curveToRelative(12.0f, -7.2f, 18.0f, -17.0f, 18.0f, -29.2f)
|
||||
curveToRelative(0.0f, -10.0f, -4.1f, -17.5f, -12.2f, -22.8f)
|
||||
curveToRelative(-8.1f, -5.2f, -18.8f, -9.2f, -31.9f, -11.9f)
|
||||
curveToRelative(-13.1f, -2.7f, -27.7f, -5.2f, -43.8f, -7.5f)
|
||||
reflectiveCurveToRelative(-32.1f, -5.3f, -48.2f, -9.2f)
|
||||
curveToRelative(-16.1f, -3.8f, -30.7f, -9.5f, -43.8f, -17.0f)
|
||||
reflectiveCurveToRelative(-23.8f, -17.5f, -31.9f, -30.2f)
|
||||
curveToRelative(-8.1f, -12.7f, -12.2f, -29.4f, -12.2f, -50.2f)
|
||||
curveToRelative(0.0f, -23.1f, 6.6f, -43.1f, 19.7f, -60.1f)
|
||||
curveToRelative(13.1f, -17.0f, 31.6f, -30.1f, 55.3f, -39.4f)
|
||||
curveToRelative(23.8f, -9.3f, 51.9f, -13.9f, 84.5f, -13.9f)
|
||||
curveToRelative(24.4f, 0.0f, 49.1f, 2.7f, 74.0f, 8.1f)
|
||||
curveToRelative(24.9f, 5.4f, 45.5f, 13.1f, 61.8f, 23.1f)
|
||||
lineToRelative(-32.6f, 64.5f)
|
||||
curveToRelative(-17.2f, -10.4f, -34.5f, -17.5f, -51.9f, -21.4f)
|
||||
curveToRelative(-17.4f, -3.8f, -34.7f, -5.8f, -51.9f, -5.8f)
|
||||
curveToRelative(-25.8f, 0.0f, -44.9f, 3.9f, -57.4f, 11.5f)
|
||||
curveToRelative(-12.4f, 7.7f, -18.7f, 17.4f, -18.7f, 29.2f)
|
||||
curveToRelative(0.0f, 10.9f, 4.1f, 19.0f, 12.2f, 24.4f)
|
||||
curveToRelative(8.1f, 5.4f, 18.8f, 9.7f, 31.9f, 12.9f)
|
||||
curveToRelative(13.1f, 3.2f, 27.7f, 5.8f, 43.8f, 7.8f)
|
||||
reflectiveCurveToRelative(32.0f, 5.1f, 47.9f, 9.2f)
|
||||
curveToRelative(15.8f, 4.1f, 30.4f, 9.6f, 43.8f, 16.6f)
|
||||
curveToRelative(13.3f, 7.0f, 24.1f, 16.9f, 32.3f, 29.5f)
|
||||
curveToRelative(8.1f, 12.7f, 12.2f, 29.2f, 12.2f, 49.6f)
|
||||
curveToRelative(0.0f, 22.6f, -6.7f, 42.3f, -20.0f, 59.1f)
|
||||
curveToRelative(-13.4f, 16.8f, -32.3f, 29.8f, -56.7f, 39.0f)
|
||||
curveTo(3878.6f, 939.3f, 3849.4f, 944.0f, 3815.5f, 944.0f)
|
||||
close()
|
||||
}
|
||||
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
|
||||
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
|
||||
pathFillType = NonZero) {
|
||||
moveTo(4206.5f, 944.0f)
|
||||
curveToRelative(-30.8f, 0.0f, -57.9f, -5.8f, -81.5f, -17.3f)
|
||||
curveToRelative(-23.5f, -11.5f, -41.9f, -29.2f, -55.0f, -53.0f)
|
||||
reflectiveCurveToRelative(-19.7f, -53.7f, -19.7f, -90.0f)
|
||||
verticalLineToRelative(-207.0f)
|
||||
horizontalLineToRelative(84.9f)
|
||||
verticalLineToRelative(195.5f)
|
||||
curveToRelative(0.0f, 32.6f, 7.4f, 56.9f, 22.1f, 73.0f)
|
||||
reflectiveCurveToRelative(35.6f, 24.1f, 62.8f, 24.1f)
|
||||
curveToRelative(19.9f, 0.0f, 37.2f, -4.1f, 51.9f, -12.2f)
|
||||
curveToRelative(14.7f, -8.1f, 26.2f, -20.4f, 34.6f, -36.7f)
|
||||
reflectiveCurveToRelative(12.6f, -36.4f, 12.6f, -60.4f)
|
||||
verticalLineTo(576.7f)
|
||||
horizontalLineToRelative(84.9f)
|
||||
verticalLineToRelative(362.5f)
|
||||
horizontalLineToRelative(-80.8f)
|
||||
verticalLineToRelative(-97.8f)
|
||||
lineToRelative(14.3f, 29.9f)
|
||||
curveToRelative(-12.2f, 23.5f, -30.1f, 41.5f, -53.6f, 54.0f)
|
||||
curveTo(4260.4f, 937.8f, 4234.6f, 944.0f, 4206.5f, 944.0f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
return _kotatsu!!
|
||||
}
|
||||
|
||||
private var _kotatsu: ImageVector? = null
|
||||
@@ -1,46 +0,0 @@
|
||||
package org.xtimms.shirizu.core.components.shape
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathOperation
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import kotlin.math.ceil
|
||||
|
||||
class WavyShape(
|
||||
private val period: Dp,
|
||||
private val amplitude: Dp,
|
||||
private val shift: Float,
|
||||
) : Shape {
|
||||
override fun createOutline(
|
||||
size: Size,
|
||||
layoutDirection: LayoutDirection,
|
||||
density: Density,
|
||||
) = Outline.Generic(Path().apply {
|
||||
val halfPeriod = with(density) { period.toPx() } / 2
|
||||
val amplitude = with(density) { amplitude.toPx() }
|
||||
|
||||
val wavyPath = Path().apply {
|
||||
moveTo(x = 0f, y = 0f)
|
||||
lineTo(size.width - amplitude, -halfPeriod * 2.5f + halfPeriod * 2 * shift)
|
||||
repeat(ceil(size.height / halfPeriod + 3).toInt()) { i ->
|
||||
relativeQuadraticBezierTo(
|
||||
dx1 = 2 * amplitude * (if (i % 2 == 0) 1 else -1),
|
||||
dy1 = halfPeriod / 2,
|
||||
dx2 = 0f,
|
||||
dy2 = halfPeriod,
|
||||
)
|
||||
}
|
||||
lineTo(0f, size.height)
|
||||
}
|
||||
val boundsPath = Path().apply {
|
||||
addRect(Rect(offset = Offset.Zero, size = size))
|
||||
}
|
||||
op(wavyPath, boundsPath, PathOperation.Intersect)
|
||||
})
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.xtimms.shirizu.R
|
||||
|
||||
class DatabasePrePopulateCallback(private val resources: Resources) : RoomDatabase.Callback() {
|
||||
|
||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track, show_in_lib, `deleted_at`) VALUES (?,?,?,?,?,?,?)",
|
||||
arrayOf(
|
||||
System.currentTimeMillis(),
|
||||
1,
|
||||
resources.getString(R.string.read_later),
|
||||
SortOrder.NEWEST.name,
|
||||
1,
|
||||
1,
|
||||
0L,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.InvalidationTracker
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.xtimms.shirizu.core.database.dao.BookmarksDao
|
||||
import org.xtimms.shirizu.core.database.dao.FavouriteCategoriesDao
|
||||
import org.xtimms.shirizu.core.database.dao.FavouritesDao
|
||||
import org.xtimms.shirizu.core.database.dao.HistoryDao
|
||||
import org.xtimms.shirizu.core.database.dao.MangaDao
|
||||
import org.xtimms.shirizu.core.database.dao.MangaSourcesDao
|
||||
import org.xtimms.shirizu.core.database.dao.SuggestionDao
|
||||
import org.xtimms.shirizu.core.database.dao.TagsDao
|
||||
import org.xtimms.shirizu.core.database.dao.TrackLogsDao
|
||||
import org.xtimms.shirizu.core.database.dao.TracksDao
|
||||
import org.xtimms.shirizu.core.database.entity.BookmarkEntity
|
||||
import org.xtimms.shirizu.core.database.entity.FavouriteCategoryEntity
|
||||
import org.xtimms.shirizu.core.database.entity.FavouriteEntity
|
||||
import org.xtimms.shirizu.core.database.entity.HistoryEntity
|
||||
import org.xtimms.shirizu.core.database.entity.MangaEntity
|
||||
import org.xtimms.shirizu.core.database.entity.MangaSourceEntity
|
||||
import org.xtimms.shirizu.core.database.entity.MangaTagsEntity
|
||||
import org.xtimms.shirizu.core.database.entity.SuggestionEntity
|
||||
import org.xtimms.shirizu.core.database.entity.TagEntity
|
||||
import org.xtimms.shirizu.core.database.entity.TrackEntity
|
||||
import org.xtimms.shirizu.core.database.entity.TrackLogEntity
|
||||
import org.xtimms.shirizu.utils.lang.processLifecycleScope
|
||||
|
||||
const val DATABASE_VERSION = 1
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
MangaEntity::class,
|
||||
TagEntity::class,
|
||||
MangaTagsEntity::class,
|
||||
MangaSourceEntity::class,
|
||||
HistoryEntity::class,
|
||||
FavouriteEntity::class,
|
||||
FavouriteCategoryEntity::class,
|
||||
BookmarkEntity::class,
|
||||
SuggestionEntity::class,
|
||||
TrackEntity::class,
|
||||
TrackLogEntity::class
|
||||
],
|
||||
version = DATABASE_VERSION
|
||||
)
|
||||
abstract class ShirizuDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun getTagsDao(): TagsDao
|
||||
|
||||
abstract fun getHistoryDao(): HistoryDao
|
||||
|
||||
abstract fun getMangaDao(): MangaDao
|
||||
|
||||
abstract fun getSourcesDao(): MangaSourcesDao
|
||||
|
||||
abstract fun getFavouritesDao(): FavouritesDao
|
||||
|
||||
abstract fun getFavouriteCategoriesDao(): FavouriteCategoriesDao
|
||||
|
||||
abstract fun getBookmarksDao(): BookmarksDao
|
||||
|
||||
abstract fun getSuggestionDao(): SuggestionDao
|
||||
|
||||
abstract fun getTracksDao(): TracksDao
|
||||
|
||||
abstract fun getTrackLogsDao(): TrackLogsDao
|
||||
|
||||
}
|
||||
|
||||
fun ShirizuDatabase(context: Context): ShirizuDatabase = Room
|
||||
.databaseBuilder(context, ShirizuDatabase::class.java, "shirizu-db")
|
||||
.addCallback(DatabasePrePopulateCallback(context.resources))
|
||||
.build()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun InvalidationTracker.removeObserverAsync(observer: InvalidationTracker.Observer) {
|
||||
val scope = processLifecycleScope
|
||||
if (scope.isActive) {
|
||||
processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) {
|
||||
removeObserver(observer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database
|
||||
|
||||
const val TABLE_MANGA = "manga"
|
||||
const val TABLE_TAGS = "tags"
|
||||
const val TABLE_MANGA_TAGS = "manga_tags"
|
||||
const val TABLE_SOURCES = "sources"
|
||||
const val TABLE_HISTORY = "history"
|
||||
const val TABLE_FAVOURITES = "favourites"
|
||||
const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories"
|
||||
@@ -1,57 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.xtimms.shirizu.core.database.entity.BookmarkEntity
|
||||
import org.xtimms.shirizu.core.database.entity.MangaWithTags
|
||||
|
||||
@Dao
|
||||
abstract class BookmarksDao {
|
||||
|
||||
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
|
||||
abstract suspend fun find(mangaId: Long, pageId: Long): BookmarkEntity?
|
||||
|
||||
@Query("SELECT * FROM bookmarks WHERE page_id = :pageId")
|
||||
abstract suspend fun find(pageId: Long): BookmarkEntity?
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
|
||||
)
|
||||
abstract suspend fun findAll(): Map<MangaWithTags, List<BookmarkEntity>>
|
||||
|
||||
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page ORDER BY percent")
|
||||
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>
|
||||
|
||||
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY percent")
|
||||
abstract fun observe(mangaId: Long): Flow<List<BookmarkEntity>>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY percent",
|
||||
)
|
||||
abstract fun observe(): Flow<Map<MangaWithTags, List<BookmarkEntity>>>
|
||||
|
||||
@Insert
|
||||
abstract suspend fun insert(entity: BookmarkEntity)
|
||||
|
||||
@Delete
|
||||
abstract suspend fun delete(entity: BookmarkEntity)
|
||||
|
||||
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
|
||||
abstract suspend fun delete(mangaId: Long, pageId: Long): Int
|
||||
|
||||
@Query("DELETE FROM bookmarks WHERE page_id = :pageId")
|
||||
abstract suspend fun delete(pageId: Long): Int
|
||||
|
||||
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page")
|
||||
abstract suspend fun delete(mangaId: Long, chapterId: Long, page: Int): Int
|
||||
|
||||
@Upsert
|
||||
abstract suspend fun upsert(bookmarks: Collection<BookmarkEntity>)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.xtimms.shirizu.core.database.entity.FavouriteCategoryEntity
|
||||
|
||||
@Dao
|
||||
abstract class FavouriteCategoriesDao {
|
||||
|
||||
@Query("SELECT * FROM favourite_categories WHERE category_id = :id AND deleted_at = 0")
|
||||
abstract suspend fun find(id: Int): FavouriteCategoryEntity
|
||||
|
||||
@Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 ORDER BY sort_key")
|
||||
abstract suspend fun findAll(): List<FavouriteCategoryEntity>
|
||||
|
||||
@Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 ORDER BY sort_key")
|
||||
abstract fun observeAll(): Flow<List<FavouriteCategoryEntity>>
|
||||
|
||||
@Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 AND show_in_lib = 1 ORDER BY sort_key")
|
||||
abstract fun observeAllForLibrary(): Flow<List<FavouriteCategoryEntity>>
|
||||
|
||||
@Query("SELECT * FROM favourite_categories WHERE category_id = :id AND deleted_at = 0")
|
||||
abstract fun observe(id: Long): Flow<FavouriteCategoryEntity?>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
abstract suspend fun insert(category: FavouriteCategoryEntity): Long
|
||||
|
||||
suspend fun delete(id: Long) = setDeletedAt(id, System.currentTimeMillis())
|
||||
|
||||
@Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker, `show_in_lib` = :onShelf WHERE category_id = :id")
|
||||
abstract suspend fun update(id: Long, title: String, order: String, tracker: Boolean, onShelf: Boolean)
|
||||
|
||||
@Query("UPDATE favourite_categories SET `order` = :order WHERE category_id = :id")
|
||||
abstract suspend fun updateOrder(id: Long, order: String)
|
||||
|
||||
@Query("UPDATE favourite_categories SET `track` = :isEnabled WHERE category_id = :id")
|
||||
abstract suspend fun updateTracking(id: Long, isEnabled: Boolean)
|
||||
|
||||
@Query("UPDATE favourite_categories SET `show_in_lib` = :isEnabled WHERE category_id = :id")
|
||||
abstract suspend fun updateLibVisibility(id: Long, isEnabled: Boolean)
|
||||
|
||||
@Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id")
|
||||
abstract suspend fun updateSortKey(id: Long, sortKey: Int)
|
||||
|
||||
@Query("DELETE FROM favourite_categories WHERE deleted_at != 0 AND deleted_at < :maxDeletionTime")
|
||||
abstract suspend fun gc(maxDeletionTime: Long)
|
||||
|
||||
@Query("SELECT MAX(sort_key) FROM favourite_categories WHERE deleted_at = 0")
|
||||
protected abstract suspend fun getMaxSortKey(): Int?
|
||||
|
||||
suspend fun getNextSortKey(): Int {
|
||||
return (getMaxSortKey() ?: 0) + 1
|
||||
}
|
||||
|
||||
@Upsert
|
||||
abstract suspend fun upsert(entity: FavouriteCategoryEntity)
|
||||
|
||||
@Query("UPDATE favourite_categories SET deleted_at = :deletedAt WHERE category_id = :id")
|
||||
protected abstract suspend fun setDeletedAt(id: Long, deletedAt: Long)
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.RawQuery
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.intellij.lang.annotations.Language
|
||||
import org.xtimms.shirizu.core.database.entity.FavouriteEntity
|
||||
import org.xtimms.shirizu.core.database.entity.MangaEntity
|
||||
import org.xtimms.shirizu.core.model.ListSortOrder
|
||||
import org.xtimms.shirizu.sections.shelf.FavouriteManga
|
||||
|
||||
@Dao
|
||||
abstract class FavouritesDao {
|
||||
|
||||
/** SELECT **/
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC")
|
||||
abstract suspend fun findAll(): List<FavouriteManga>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit")
|
||||
abstract suspend fun findLast(limit: Int): List<FavouriteManga>
|
||||
|
||||
fun observeAll(order: ListSortOrder): Flow<List<FavouriteManga>> {
|
||||
val orderBy = getOrderBy(order)
|
||||
|
||||
@Language("RoomSql")
|
||||
val query = SimpleSQLiteQuery(
|
||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
||||
"WHERE favourites.deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy",
|
||||
)
|
||||
return observeAllImpl(query)
|
||||
}
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"SELECT * FROM favourites WHERE deleted_at = 0 " +
|
||||
"GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
|
||||
)
|
||||
abstract suspend fun findAll(offset: Int, limit: Int): List<FavouriteManga>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM favourites WHERE deleted_at = 0 ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
|
||||
abstract suspend fun findAllRaw(offset: Int, limit: Int): List<FavouriteManga>
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " +
|
||||
"GROUP BY manga_id ORDER BY created_at DESC",
|
||||
)
|
||||
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
|
||||
|
||||
fun observeAll(categoryId: Long, order: ListSortOrder): Flow<List<FavouriteManga>> {
|
||||
val orderBy = getOrderBy(order)
|
||||
|
||||
@Language("RoomSql")
|
||||
val query = SimpleSQLiteQuery(
|
||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
||||
"WHERE category_id = ? AND deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy",
|
||||
arrayOf<Any>(categoryId),
|
||||
)
|
||||
return observeAllImpl(query)
|
||||
}
|
||||
|
||||
@Transaction
|
||||
@Query(
|
||||
"SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " +
|
||||
"GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
|
||||
)
|
||||
abstract suspend fun findAll(categoryId: Long, offset: Int, limit: Int): List<FavouriteManga>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM manga WHERE manga_id IN " +
|
||||
"(SELECT manga_id FROM favourites WHERE category_id = :categoryId AND deleted_at = 0)",
|
||||
)
|
||||
abstract suspend fun findAllManga(categoryId: Int): List<MangaEntity>
|
||||
|
||||
@Query("SELECT COUNT(DISTINCT manga_id) FROM favourites WHERE deleted_at = 0")
|
||||
abstract fun observeMangaCount(): Flow<Int>
|
||||
|
||||
@Query("SELECT COUNT(DISTINCT manga_id) FROM favourites WHERE deleted_at = 0 AND category_id = :categoryId")
|
||||
abstract fun observeMangaCountInCategory(categoryId: Long): Flow<Int>
|
||||
|
||||
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)")
|
||||
abstract suspend fun findAllManga(): List<MangaEntity>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id")
|
||||
abstract suspend fun find(id: Long): FavouriteManga?
|
||||
|
||||
@Transaction
|
||||
@Deprecated("Ignores order")
|
||||
@Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id")
|
||||
abstract fun observe(id: Long): Flow<FavouriteManga?>
|
||||
|
||||
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :id AND deleted_at = 0")
|
||||
abstract fun observeIds(id: Long): Flow<List<Long>>
|
||||
|
||||
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0")
|
||||
abstract suspend fun findCategoriesIds(mangaIds: Collection<Long>): List<Long>
|
||||
|
||||
/** INSERT **/
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
abstract suspend fun insert(favourite: FavouriteEntity)
|
||||
|
||||
/** DELETE **/
|
||||
|
||||
suspend fun delete(mangaId: Long) = setDeletedAt(
|
||||
mangaId = mangaId,
|
||||
deletedAt = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
suspend fun delete(mangaId: Long, categoryId: Long) = setDeletedAt(
|
||||
categoryId = categoryId,
|
||||
mangaId = mangaId,
|
||||
deletedAt = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
suspend fun deleteAll(categoryId: Long) = setDeletedAtAll(
|
||||
categoryId = categoryId,
|
||||
deletedAt = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
suspend fun recover(mangaId: Long) = setDeletedAt(
|
||||
mangaId = mangaId,
|
||||
deletedAt = 0L,
|
||||
)
|
||||
|
||||
suspend fun recover(categoryId: Long, mangaId: Long) = setDeletedAt(
|
||||
categoryId = categoryId,
|
||||
mangaId = mangaId,
|
||||
deletedAt = 0L,
|
||||
)
|
||||
|
||||
@Query("DELETE FROM favourites WHERE deleted_at != 0 AND deleted_at < :maxDeletionTime")
|
||||
abstract suspend fun gc(maxDeletionTime: Long)
|
||||
|
||||
/** TOOLS **/
|
||||
|
||||
@Upsert
|
||||
abstract suspend fun upsert(entity: FavouriteEntity)
|
||||
|
||||
@Transaction
|
||||
@RawQuery(observedEntities = [FavouriteEntity::class])
|
||||
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<FavouriteManga>>
|
||||
|
||||
@Query("UPDATE favourites SET deleted_at = :deletedAt WHERE manga_id = :mangaId")
|
||||
protected abstract suspend fun setDeletedAt(mangaId: Long, deletedAt: Long)
|
||||
|
||||
@Query("UPDATE favourites SET deleted_at = :deletedAt WHERE manga_id = :mangaId AND category_id = :categoryId")
|
||||
abstract suspend fun setDeletedAt(categoryId: Long, mangaId: Long, deletedAt: Long)
|
||||
|
||||
@Query("UPDATE favourites SET deleted_at = :deletedAt WHERE category_id = :categoryId AND deleted_at = 0")
|
||||
protected abstract suspend fun setDeletedAtAll(categoryId: Long, deletedAt: Long)
|
||||
|
||||
private fun getOrderBy(sortOrder: ListSortOrder) = when (sortOrder) {
|
||||
ListSortOrder.NEWEST -> "favourites.created_at DESC"
|
||||
ListSortOrder.ALPHABETIC -> "manga.title ASC"
|
||||
ListSortOrder.PROGRESS -> "IFNULL((SELECT percent FROM history WHERE history.manga_id = manga.manga_id), 0) DESC"
|
||||
|
||||
else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.RawQuery
|
||||
import androidx.room.Transaction
|
||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.intellij.lang.annotations.Language
|
||||
import org.xtimms.shirizu.core.database.entity.HistoryEntity
|
||||
import org.xtimms.shirizu.core.database.entity.HistoryWithManga
|
||||
import org.xtimms.shirizu.core.database.entity.MangaEntity
|
||||
import org.xtimms.shirizu.core.database.entity.TagEntity
|
||||
import org.xtimms.shirizu.core.model.ListSortOrder
|
||||
|
||||
@Dao
|
||||
abstract class HistoryDao {
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit OFFSET :offset")
|
||||
abstract suspend fun findAll(offset: Int, limit: Int): List<HistoryWithManga>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM history WHERE deleted_at = 0 AND manga_id IN (:ids)")
|
||||
abstract suspend fun findAll(ids: Collection<Long>): List<HistoryEntity>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC")
|
||||
abstract fun observeAll(): Flow<List<HistoryWithManga>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit")
|
||||
abstract fun observeAll(limit: Int): Flow<List<HistoryWithManga>>
|
||||
|
||||
fun observeAll(order: ListSortOrder): Flow<List<HistoryWithManga>> {
|
||||
val orderBy = when (order) {
|
||||
ListSortOrder.NEWEST -> "history.created_at DESC"
|
||||
ListSortOrder.PROGRESS -> "history.percent DESC"
|
||||
ListSortOrder.ALPHABETIC -> "manga.title"
|
||||
else -> throw IllegalArgumentException("Sort order $order is not supported")
|
||||
}
|
||||
|
||||
@Language("RoomSql")
|
||||
val query = SimpleSQLiteQuery(
|
||||
"SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " +
|
||||
"WHERE history.deleted_at = 0 GROUP BY history.manga_id ORDER BY $orderBy",
|
||||
)
|
||||
return observeAllImpl(query)
|
||||
}
|
||||
|
||||
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history WHERE deleted_at = 0)")
|
||||
abstract suspend fun findAllManga(): List<MangaEntity>
|
||||
|
||||
@Query(
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
INNER JOIN history ON history.manga_id = manga_tags.manga_id
|
||||
WHERE history.deleted_at = 0
|
||||
GROUP BY manga_tags.tag_id
|
||||
ORDER BY COUNT(manga_tags.manga_id) DESC
|
||||
LIMIT :limit""",
|
||||
)
|
||||
abstract suspend fun findPopularTags(limit: Int): List<TagEntity>
|
||||
|
||||
@Query("SELECT * FROM history WHERE manga_id = :id AND deleted_at = 0")
|
||||
abstract suspend fun find(id: Long): HistoryEntity?
|
||||
|
||||
@Query("SELECT * FROM history WHERE manga_id = :id AND deleted_at = 0")
|
||||
abstract fun observe(id: Long): Flow<HistoryEntity?>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM history WHERE deleted_at = 0")
|
||||
abstract fun observeCount(): Flow<Int>
|
||||
|
||||
@Query("SELECT percent FROM history WHERE manga_id = :id AND deleted_at = 0")
|
||||
abstract suspend fun findProgress(id: Long): Float?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract suspend fun insert(entity: HistoryEntity): Long
|
||||
|
||||
@Query(
|
||||
"UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, percent = :percent, updated_at = :updatedAt, deleted_at = 0 WHERE manga_id = :mangaId",
|
||||
)
|
||||
abstract suspend fun update(
|
||||
mangaId: Long,
|
||||
page: Int,
|
||||
chapterId: Long,
|
||||
scroll: Float,
|
||||
percent: Float,
|
||||
updatedAt: Long,
|
||||
): Int
|
||||
|
||||
suspend fun delete(mangaId: Long) = setDeletedAt(mangaId, System.currentTimeMillis())
|
||||
|
||||
suspend fun recover(mangaId: Long) = setDeletedAt(mangaId, 0L)
|
||||
|
||||
@Query("DELETE FROM history WHERE deleted_at != 0 AND deleted_at < :maxDeletionTime")
|
||||
abstract suspend fun gc(maxDeletionTime: Long)
|
||||
|
||||
suspend fun deleteAfter(minDate: Long) = setDeletedAtAfter(minDate, System.currentTimeMillis())
|
||||
|
||||
suspend fun clear() = setDeletedAtAfter(0L, System.currentTimeMillis())
|
||||
|
||||
suspend fun update(entity: HistoryEntity) = update(
|
||||
mangaId = entity.mangaId,
|
||||
page = entity.page,
|
||||
chapterId = entity.chapterId,
|
||||
scroll = entity.scroll,
|
||||
percent = entity.percent,
|
||||
updatedAt = entity.updatedAt,
|
||||
)
|
||||
|
||||
@Transaction
|
||||
open suspend fun upsert(entity: HistoryEntity): Boolean {
|
||||
return if (update(entity) == 0) {
|
||||
insert(entity)
|
||||
true
|
||||
} else false
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun upsert(entities: Iterable<HistoryEntity>) {
|
||||
for (e in entities) {
|
||||
if (update(e) == 0) {
|
||||
insert(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Query("UPDATE history SET deleted_at = :deletedAt WHERE manga_id = :mangaId")
|
||||
protected abstract suspend fun setDeletedAt(mangaId: Long, deletedAt: Long)
|
||||
|
||||
@Query("UPDATE history SET deleted_at = :deletedAt WHERE created_at >= :minDate AND deleted_at = 0")
|
||||
protected abstract suspend fun setDeletedAtAfter(minDate: Long, deletedAt: Long)
|
||||
|
||||
@Transaction
|
||||
@RawQuery(observedEntities = [HistoryEntity::class])
|
||||
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<HistoryWithManga>>
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
import androidx.room.Upsert
|
||||
import org.xtimms.shirizu.core.database.entity.MangaEntity
|
||||
import org.xtimms.shirizu.core.database.entity.MangaTagsEntity
|
||||
import org.xtimms.shirizu.core.database.entity.MangaWithTags
|
||||
import org.xtimms.shirizu.core.database.entity.TagEntity
|
||||
|
||||
@Dao
|
||||
abstract class MangaDao {
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM manga WHERE manga_id = :id")
|
||||
abstract suspend fun find(id: Long): MangaWithTags?
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM manga WHERE public_url = :publicUrl")
|
||||
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM manga WHERE source = :source")
|
||||
abstract suspend fun findAllBySource(source: String): List<MangaWithTags>
|
||||
|
||||
@Upsert
|
||||
abstract suspend fun upsert(manga: MangaEntity)
|
||||
|
||||
@Update(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract suspend fun update(manga: MangaEntity): Int
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract suspend fun insertTagRelation(tag: MangaTagsEntity): Long
|
||||
|
||||
@Query("DELETE FROM manga_tags WHERE manga_id = :mangaId")
|
||||
abstract suspend fun clearTagRelation(mangaId: Long)
|
||||
|
||||
@Transaction
|
||||
@Delete
|
||||
abstract suspend fun delete(subjects: Collection<MangaEntity>)
|
||||
|
||||
@Transaction
|
||||
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
|
||||
upsert(manga)
|
||||
if (tags != null) {
|
||||
clearTagRelation(manga.id)
|
||||
tags.map {
|
||||
MangaTagsEntity(manga.id, it.id)
|
||||
}.forEach {
|
||||
insertTagRelation(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.RawQuery
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.intellij.lang.annotations.Language
|
||||
import org.xtimms.shirizu.core.database.entity.MangaSourceEntity
|
||||
import org.xtimms.shirizu.sections.explore.data.SourcesSortOrder
|
||||
|
||||
@Dao
|
||||
abstract class MangaSourcesDao {
|
||||
|
||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
||||
abstract suspend fun findAll(): List<MangaSourceEntity>
|
||||
|
||||
@Query("SELECT * FROM sources WHERE enabled = 0 ORDER BY sort_key")
|
||||
abstract suspend fun findAllDisabled(): List<MangaSourceEntity>
|
||||
|
||||
@Query("SELECT * FROM sources WHERE enabled = 0")
|
||||
abstract fun observeDisabled(): Flow<List<MangaSourceEntity>>
|
||||
|
||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
||||
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
||||
|
||||
@Query("SELECT IFNULL(MAX(sort_key),0) FROM sources")
|
||||
abstract suspend fun getMaxSortKey(): Int
|
||||
|
||||
@Query("UPDATE sources SET enabled = 0")
|
||||
abstract suspend fun disableAllSources()
|
||||
|
||||
@Upsert
|
||||
abstract suspend fun upsert(entry: MangaSourceEntity)
|
||||
|
||||
fun observeEnabled(order: SourcesSortOrder): Flow<List<MangaSourceEntity>> {
|
||||
val orderBy = getOrderBy(order)
|
||||
|
||||
@Language("RoomSql")
|
||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
|
||||
return observeImpl(query)
|
||||
}
|
||||
|
||||
suspend fun findAllEnabled(order: SourcesSortOrder): List<MangaSourceEntity> {
|
||||
val orderBy = getOrderBy(order)
|
||||
|
||||
@Language("RoomSql")
|
||||
val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY $orderBy")
|
||||
return findAllImpl(query)
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun setEnabled(source: String, isEnabled: Boolean) {
|
||||
if (updateIsEnabled(source, isEnabled) == 0) {
|
||||
val entity = MangaSourceEntity(
|
||||
source = source,
|
||||
isEnabled = isEnabled,
|
||||
sortKey = getMaxSortKey() + 1,
|
||||
)
|
||||
upsert(entity)
|
||||
}
|
||||
}
|
||||
|
||||
@Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
|
||||
protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int
|
||||
|
||||
@RawQuery(observedEntities = [MangaSourceEntity::class])
|
||||
protected abstract fun observeImpl(query: SupportSQLiteQuery): Flow<List<MangaSourceEntity>>
|
||||
|
||||
@RawQuery
|
||||
protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List<MangaSourceEntity>
|
||||
|
||||
private fun getOrderBy(order: SourcesSortOrder) = when (order) {
|
||||
SourcesSortOrder.ALPHABETIC -> "source ASC"
|
||||
SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC"
|
||||
SourcesSortOrder.MANUAL -> "sort_key ASC"
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.xtimms.shirizu.core.database.entity.SuggestionEntity
|
||||
import org.xtimms.shirizu.core.database.entity.SuggestionWithManga
|
||||
|
||||
@Dao
|
||||
abstract class SuggestionDao {
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM suggestions ORDER BY relevance DESC")
|
||||
abstract fun observeAll(): Flow<List<SuggestionWithManga>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM suggestions ORDER BY relevance DESC LIMIT :limit")
|
||||
abstract fun observeAll(limit: Int): Flow<List<SuggestionWithManga>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM suggestions ORDER BY RANDOM() LIMIT 1")
|
||||
abstract suspend fun getRandom(): SuggestionWithManga?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM suggestions")
|
||||
abstract suspend fun count(): Int
|
||||
|
||||
@Query("SELECT manga.title FROM suggestions LEFT JOIN manga ON suggestions.manga_id = manga.manga_id WHERE manga.title LIKE :query")
|
||||
abstract suspend fun getTitles(query: String): List<String>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract suspend fun insert(entity: SuggestionEntity): Long
|
||||
|
||||
@Update
|
||||
abstract suspend fun update(entity: SuggestionEntity): Int
|
||||
|
||||
@Query("DELETE FROM suggestions")
|
||||
abstract suspend fun deleteAll()
|
||||
|
||||
@Transaction
|
||||
open suspend fun upsert(entity: SuggestionEntity) {
|
||||
if (update(entity) == 0) {
|
||||
insert(entity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import org.xtimms.shirizu.core.database.entity.TagEntity
|
||||
|
||||
@Dao
|
||||
abstract class TagsDao {
|
||||
|
||||
@Query("SELECT * FROM tags WHERE source = :source")
|
||||
abstract suspend fun findTags(source: String): List<TagEntity>
|
||||
|
||||
@Query(
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
WHERE manga_tags.manga_id IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)
|
||||
GROUP BY tags.title
|
||||
ORDER BY COUNT(manga_id) DESC
|
||||
LIMIT :limit""",
|
||||
)
|
||||
abstract suspend fun findPopularTags(limit: Int): List<TagEntity>
|
||||
|
||||
@Query(
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
WHERE tags.source = :source
|
||||
GROUP BY tags.title
|
||||
ORDER BY COUNT(manga_id) DESC
|
||||
LIMIT :limit""",
|
||||
)
|
||||
abstract suspend fun findPopularTags(source: String, limit: Int): List<TagEntity>
|
||||
|
||||
@Query(
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
WHERE tags.source = :source
|
||||
GROUP BY tags.title
|
||||
ORDER BY COUNT(manga_id) ASC
|
||||
LIMIT :limit""",
|
||||
)
|
||||
abstract suspend fun findRareTags(source: String, limit: Int): List<TagEntity>
|
||||
|
||||
@Query(
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
WHERE tags.source = :source AND title LIKE :query
|
||||
GROUP BY tags.title
|
||||
ORDER BY COUNT(manga_id) DESC
|
||||
LIMIT :limit""",
|
||||
)
|
||||
abstract suspend fun findTags(source: String, query: String, limit: Int): List<TagEntity>
|
||||
|
||||
@Query(
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
WHERE title LIKE :query AND manga_tags.manga_id IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)
|
||||
GROUP BY tags.title
|
||||
ORDER BY COUNT(manga_id) DESC
|
||||
LIMIT :limit""",
|
||||
)
|
||||
abstract suspend fun findTags(query: String, limit: Int): List<TagEntity>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT tags.* FROM manga_tags
|
||||
LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id
|
||||
WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id = :tagId)
|
||||
GROUP BY tags.tag_id
|
||||
ORDER BY COUNT(manga_id) DESC;
|
||||
""",
|
||||
)
|
||||
abstract suspend fun findRelatedTags(tagId: Long): List<TagEntity>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT tags.* FROM manga_tags
|
||||
LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id
|
||||
WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id IN (:ids))
|
||||
GROUP BY tags.tag_id
|
||||
ORDER BY COUNT(manga_id) DESC;
|
||||
""",
|
||||
)
|
||||
abstract suspend fun findRelatedTags(ids: Set<Long>): List<TagEntity>
|
||||
|
||||
@Upsert
|
||||
abstract suspend fun upsert(tags: Iterable<TagEntity>)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.xtimms.shirizu.core.database.entity.TrackLogEntity
|
||||
import org.xtimms.shirizu.core.database.entity.TrackLogWithManga
|
||||
|
||||
@Dao
|
||||
interface TrackLogsDao {
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0")
|
||||
fun observeAll(limit: Int): Flow<List<TrackLogWithManga>>
|
||||
|
||||
@Query("DELETE FROM track_logs")
|
||||
suspend fun clear()
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(entity: TrackLogEntity): Long
|
||||
|
||||
@Query("DELETE FROM track_logs WHERE manga_id = :mangaId")
|
||||
suspend fun removeAll(mangaId: Long)
|
||||
|
||||
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
|
||||
suspend fun gc()
|
||||
|
||||
@Query("SELECT COUNT(*) FROM track_logs")
|
||||
suspend fun count(): Int
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.MapColumn
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.xtimms.shirizu.core.database.entity.MangaWithTags
|
||||
import org.xtimms.shirizu.core.database.entity.TrackEntity
|
||||
|
||||
@Dao
|
||||
abstract class TracksDao {
|
||||
|
||||
@Query("SELECT * FROM tracks")
|
||||
abstract suspend fun findAll(): List<TrackEntity>
|
||||
|
||||
@Query("SELECT * FROM tracks WHERE manga_id IN (:ids)")
|
||||
abstract suspend fun findAll(ids: Collection<Long>): List<TrackEntity>
|
||||
|
||||
@Query("SELECT * FROM tracks WHERE manga_id = :mangaId")
|
||||
abstract suspend fun find(mangaId: Long): TrackEntity?
|
||||
|
||||
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
|
||||
abstract suspend fun findNewChapters(mangaId: Long): Int?
|
||||
|
||||
@Query("SELECT manga_id, chapters_new FROM tracks")
|
||||
abstract fun observeNewChaptersMap(): Flow<Map<@MapColumn(columnName = "manga_id") Long, @MapColumn(
|
||||
columnName = "chapters_new"
|
||||
) Int>>
|
||||
|
||||
@Query("SELECT chapters_new FROM tracks")
|
||||
abstract fun observeNewChapters(): Flow<List<Int>>
|
||||
|
||||
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
|
||||
abstract fun observeNewChapters(mangaId: Long): Flow<Int?>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT manga.* FROM tracks LEFT JOIN manga ON manga.manga_id = tracks.manga_id WHERE chapters_new > 0 ORDER BY chapters_new DESC")
|
||||
abstract fun observeUpdatedManga(): Flow<List<MangaWithTags>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT manga.* FROM tracks LEFT JOIN manga ON manga.manga_id = tracks.manga_id WHERE chapters_new > 0 ORDER BY chapters_new DESC LIMIT :limit")
|
||||
abstract fun observeUpdatedManga(limit: Int): Flow<List<MangaWithTags>>
|
||||
|
||||
@Query("DELETE FROM tracks")
|
||||
abstract suspend fun clear()
|
||||
|
||||
@Query("UPDATE tracks SET chapters_new = 0")
|
||||
abstract suspend fun clearCounters()
|
||||
|
||||
@Query("UPDATE tracks SET chapters_new = 0 WHERE manga_id = :mangaId")
|
||||
abstract suspend fun clearCounter(mangaId: Long)
|
||||
|
||||
@Query("DELETE FROM tracks WHERE manga_id = :mangaId")
|
||||
abstract suspend fun delete(mangaId: Long)
|
||||
|
||||
@Query("DELETE FROM tracks WHERE manga_id NOT IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites WHERE category_id IN (SELECT category_id FROM favourite_categories WHERE track = 1))")
|
||||
abstract suspend fun gc()
|
||||
|
||||
@Upsert
|
||||
abstract suspend fun upsert(entity: TrackEntity)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
|
||||
@Entity(
|
||||
tableName = "bookmarks",
|
||||
primaryKeys = ["manga_id", "page_id"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
childColumns = ["manga_id"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
),
|
||||
]
|
||||
)
|
||||
data class BookmarkEntity(
|
||||
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||
@ColumnInfo(name = "page_id", index = true) val pageId: Long,
|
||||
@ColumnInfo(name = "chapter_id") val chapterId: Long,
|
||||
@ColumnInfo(name = "page") val page: Int,
|
||||
@ColumnInfo(name = "scroll") val scroll: Int,
|
||||
@ColumnInfo(name = "image") val imageUrl: String,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
@ColumnInfo(name = "percent") val percent: Float,
|
||||
)
|
||||
@@ -1,159 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.entity
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import org.xtimms.shirizu.core.model.Bookmark
|
||||
import org.xtimms.shirizu.core.model.FavouriteCategory
|
||||
import org.xtimms.shirizu.core.model.ListSortOrder
|
||||
import org.xtimms.shirizu.core.model.MangaHistory
|
||||
import org.xtimms.shirizu.core.model.MangaSource
|
||||
import org.xtimms.shirizu.core.tracker.model.TrackingLogItem
|
||||
import org.xtimms.shirizu.sections.shelf.FavouriteManga
|
||||
import org.xtimms.shirizu.utils.lang.longHashCode
|
||||
import java.time.Instant
|
||||
|
||||
// Entity to model
|
||||
|
||||
fun TagEntity.toMangaTag() = MangaTag(
|
||||
key = this.key,
|
||||
title = this.title.toTitleCase(),
|
||||
source = MangaSource(this.source),
|
||||
)
|
||||
|
||||
fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
|
||||
|
||||
fun Collection<TagEntity>.toMangaTagsList() = map(TagEntity::toMangaTag)
|
||||
|
||||
fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
|
||||
id = this.id,
|
||||
title = this.title,
|
||||
altTitle = this.altTitle,
|
||||
state = this.state?.let { MangaState(it) },
|
||||
rating = this.rating,
|
||||
isNsfw = this.isNsfw,
|
||||
url = this.url,
|
||||
publicUrl = this.publicUrl,
|
||||
coverUrl = this.coverUrl,
|
||||
largeCoverUrl = this.largeCoverUrl,
|
||||
author = this.author,
|
||||
source = MangaSource(this.source),
|
||||
tags = tags,
|
||||
)
|
||||
|
||||
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
|
||||
|
||||
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
|
||||
id = id,
|
||||
title = title,
|
||||
sortKey = sortKey,
|
||||
order = ListSortOrder(order, ListSortOrder.NEWEST),
|
||||
createdAt = Instant.ofEpochMilli(createdAt),
|
||||
isTrackingEnabled = track,
|
||||
isVisibleInLibrary = isVisibleInLibrary,
|
||||
)
|
||||
|
||||
fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags())
|
||||
|
||||
fun Collection<FavouriteManga>.toMangaList() = map { it.toManga() }
|
||||
|
||||
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
|
||||
manga = manga,
|
||||
pageId = pageId,
|
||||
chapterId = chapterId,
|
||||
page = page,
|
||||
scroll = scroll,
|
||||
imageUrl = imageUrl,
|
||||
createdAt = Instant.ofEpochMilli(createdAt),
|
||||
percent = percent,
|
||||
)
|
||||
|
||||
fun Collection<BookmarkEntity>.toBookmarks(manga: Manga) = map {
|
||||
it.toBookmark(manga)
|
||||
}
|
||||
|
||||
@JvmName("bookmarksIds")
|
||||
fun Collection<Bookmark>.ids() = map { it.pageId }
|
||||
|
||||
fun TrackLogWithManga.toTrackingLogItem(counters: MutableMap<Long, Int>): TrackingLogItem {
|
||||
val chaptersList = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() }
|
||||
return TrackingLogItem(
|
||||
id = trackLog.id,
|
||||
chapters = chaptersList,
|
||||
manga = manga.toManga(tags.toMangaTags()),
|
||||
createdAt = Instant.ofEpochMilli(trackLog.createdAt),
|
||||
isNew = counters.decrement(trackLog.mangaId, chaptersList.size),
|
||||
)
|
||||
}
|
||||
|
||||
private fun MutableMap<Long, Int>.decrement(key: Long, count: Int): Boolean = synchronized(this) {
|
||||
val counter = get(key)
|
||||
if (counter == null || counter <= 0) {
|
||||
return false
|
||||
}
|
||||
if (counter < count) {
|
||||
remove(key)
|
||||
} else {
|
||||
put(key, counter - count)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Model to entity
|
||||
|
||||
fun Manga.toEntity() = MangaEntity(
|
||||
id = id,
|
||||
url = url,
|
||||
publicUrl = publicUrl,
|
||||
source = source.name,
|
||||
largeCoverUrl = largeCoverUrl,
|
||||
coverUrl = coverUrl,
|
||||
altTitle = altTitle,
|
||||
rating = rating,
|
||||
isNsfw = isNsfw,
|
||||
state = state?.name,
|
||||
title = title,
|
||||
author = author,
|
||||
)
|
||||
|
||||
fun MangaTag.toEntity() = TagEntity(
|
||||
title = title,
|
||||
key = key,
|
||||
source = source.name,
|
||||
id = "${key}_${source.name}".longHashCode(),
|
||||
)
|
||||
|
||||
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
|
||||
|
||||
fun Bookmark.toEntity() = BookmarkEntity(
|
||||
mangaId = manga.id,
|
||||
pageId = pageId,
|
||||
chapterId = chapterId,
|
||||
page = page,
|
||||
scroll = scroll,
|
||||
imageUrl = imageUrl,
|
||||
createdAt = createdAt.toEpochMilli(),
|
||||
percent = percent,
|
||||
)
|
||||
|
||||
// Other
|
||||
|
||||
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
|
||||
SortOrder.valueOf(name)
|
||||
}.getOrDefault(fallback)
|
||||
|
||||
fun MangaState(name: String): MangaState? = runCatching {
|
||||
MangaState.valueOf(name)
|
||||
}.getOrNull()
|
||||
|
||||
fun HistoryEntity.toMangaHistory() = MangaHistory(
|
||||
createdAt = Instant.ofEpochMilli(createdAt),
|
||||
updatedAt = Instant.ofEpochMilli(updatedAt),
|
||||
chapterId = chapterId,
|
||||
page = page,
|
||||
scroll = scroll.toInt(),
|
||||
percent = percent,
|
||||
)
|
||||
@@ -1,46 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import org.xtimms.shirizu.core.database.TABLE_FAVOURITE_CATEGORIES
|
||||
|
||||
@Entity(tableName = TABLE_FAVOURITE_CATEGORIES)
|
||||
data class FavouriteCategoryEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = "category_id") val categoryId: Int,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
@ColumnInfo(name = "sort_key") val sortKey: Int,
|
||||
@ColumnInfo(name = "title") val title: String,
|
||||
@ColumnInfo(name = "order") val order: String,
|
||||
@ColumnInfo(name = "track") val track: Boolean,
|
||||
@ColumnInfo(name = "show_in_lib") val isVisibleInLibrary: Boolean,
|
||||
@ColumnInfo(name = "deleted_at") val deletedAt: Long,
|
||||
) {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as FavouriteCategoryEntity
|
||||
|
||||
if (categoryId != other.categoryId) return false
|
||||
if (createdAt != other.createdAt) return false
|
||||
if (sortKey != other.sortKey) return false
|
||||
if (title != other.title) return false
|
||||
if (order != other.order) return false
|
||||
if (track != other.track) return false
|
||||
return isVisibleInLibrary == other.isVisibleInLibrary
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = categoryId
|
||||
result = 31 * result + createdAt.hashCode()
|
||||
result = 31 * result + sortKey
|
||||
result = 31 * result + title.hashCode()
|
||||
result = 31 * result + order.hashCode()
|
||||
result = 31 * result + track.hashCode()
|
||||
result = 31 * result + isVisibleInLibrary.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import org.xtimms.shirizu.core.database.TABLE_FAVOURITES
|
||||
|
||||
@Entity(
|
||||
tableName = TABLE_FAVOURITES,
|
||||
primaryKeys = ["manga_id", "category_id"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
childColumns = ["manga_id"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
),
|
||||
ForeignKey(
|
||||
entity = FavouriteCategoryEntity::class,
|
||||
parentColumns = ["category_id"],
|
||||
childColumns = ["category_id"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
]
|
||||
)
|
||||
data class FavouriteEntity(
|
||||
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||
@ColumnInfo(name = "category_id", index = true) val categoryId: Long,
|
||||
@ColumnInfo(name = "sort_key") val sortKey: Int,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
@ColumnInfo(name = "deleted_at") val deletedAt: Long,
|
||||
)
|
||||
@@ -1,30 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
import org.xtimms.shirizu.core.database.TABLE_HISTORY
|
||||
|
||||
@Entity(
|
||||
tableName = TABLE_HISTORY,
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
childColumns = ["manga_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
]
|
||||
)
|
||||
data class HistoryEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "manga_id") val mangaId: Long,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
||||
@ColumnInfo(name = "chapter_id") val chapterId: Long,
|
||||
@ColumnInfo(name = "page") val page: Int,
|
||||
@ColumnInfo(name = "scroll") val scroll: Float,
|
||||
@ColumnInfo(name = "percent") val percent: Float,
|
||||
@ColumnInfo(name = "deleted_at") val deletedAt: Long,
|
||||
)
|
||||
@@ -1,20 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.entity
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Junction
|
||||
import androidx.room.Relation
|
||||
|
||||
class HistoryWithManga(
|
||||
@Embedded val history: HistoryEntity,
|
||||
@Relation(
|
||||
parentColumn = "manga_id",
|
||||
entityColumn = "manga_id"
|
||||
)
|
||||
val manga: MangaEntity,
|
||||
@Relation(
|
||||
parentColumn = "manga_id",
|
||||
entityColumn = "tag_id",
|
||||
associateBy = Junction(MangaTagsEntity::class)
|
||||
)
|
||||
val tags: List<TagEntity>,
|
||||
)
|
||||
@@ -1,23 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import org.xtimms.shirizu.core.database.TABLE_MANGA
|
||||
|
||||
@Entity(tableName = TABLE_MANGA)
|
||||
data class MangaEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "manga_id") val id: Long,
|
||||
@ColumnInfo(name = "title") val title: String,
|
||||
@ColumnInfo(name = "alt_title") val altTitle: String?,
|
||||
@ColumnInfo(name = "url") val url: String,
|
||||
@ColumnInfo(name = "public_url") val publicUrl: String,
|
||||
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
|
||||
@ColumnInfo(name = "nsfw") val isNsfw: Boolean,
|
||||
@ColumnInfo(name = "cover_url") val coverUrl: String,
|
||||
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
|
||||
@ColumnInfo(name = "state") val state: String?,
|
||||
@ColumnInfo(name = "author") val author: String?,
|
||||
@ColumnInfo(name = "source") val source: String,
|
||||
)
|
||||
@@ -1,15 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import org.xtimms.shirizu.core.database.TABLE_SOURCES
|
||||
|
||||
@Entity(tableName = TABLE_SOURCES)
|
||||
data class MangaSourceEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "source")
|
||||
val source: String,
|
||||
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
|
||||
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
|
||||
)
|
||||
@@ -1,29 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import org.xtimms.shirizu.core.database.TABLE_MANGA_TAGS
|
||||
|
||||
@Entity(
|
||||
tableName = TABLE_MANGA_TAGS,
|
||||
primaryKeys = ["manga_id", "tag_id"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
childColumns = ["manga_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
ForeignKey(
|
||||
entity = TagEntity::class,
|
||||
parentColumns = ["tag_id"],
|
||||
childColumns = ["tag_id"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
]
|
||||
)
|
||||
class MangaTagsEntity(
|
||||
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||
@ColumnInfo(name = "tag_id", index = true) val tagId: Long,
|
||||
)
|
||||
@@ -1,15 +0,0 @@
|
||||
package org.xtimms.shirizu.core.database.entity
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Junction
|
||||
import androidx.room.Relation
|
||||
|
||||
data class MangaWithTags(
|
||||
@Embedded val manga: MangaEntity,
|
||||
@Relation(
|
||||
parentColumn = "manga_id",
|
||||
entityColumn = "tag_id",
|
||||
associateBy = Junction(MangaTagsEntity::class)
|
||||
)
|
||||
val tags: List<TagEntity>,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user