Move benchmark and app modules to project root

This commit is contained in:
2024-04-14 21:17:56 +03:00
parent 774bb84f7f
commit df60f2e84a
510 changed files with 3 additions and 3 deletions

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

208
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,208 @@
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
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
}
buildTypes {
named("debug") {
versionNameSuffix = "-${getCommitCount()}"
applicationIdSuffix = ".debug"
}
named("release") {
isShrinkResources = true
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
create("benchmark") {
initWith(getByName("release"))
signingConfig = signingConfigs.getByName("debug")
matchingFallbacks.add("release")
isDebuggable = false
isProfileable = true
versionNameSuffix = "-benchmark"
applicationIdSuffix = ".benchmark"
}
}
sourceSets {
getByName("benchmark").res.srcDirs("src/debug/res")
}
flavorDimensions.add("default")
productFlavors {
create("standard") {
dimension = "default"
}
create("dev") {
dimension = "default"
}
}
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")
implementation("androidx.room:room-testing:2.6.1")
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.accompanist:accompanist-permissions: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")
}
androidComponents {
beforeVariants { variantBuilder ->
// Disables standardBenchmark
if (variantBuilder.buildType == "benchmark") {
variantBuilder.enable = variantBuilder.productFlavors.containsAll(listOf("default" to "dev"))
}
}
}
// 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()
}

View File

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

3
app/proguard-rules.pro vendored Normal file
View File

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

View File

@@ -0,0 +1,9 @@
{
"id": 4,
"title": "Read later",
"sortKey": 1,
"order": "NEWEST",
"createdAt": 1335906000000,
"isTrackingEnabled": true,
"isVisibleInLibrary": true
}

Binary file not shown.

View File

@@ -0,0 +1,163 @@
{
"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"
}

View File

@@ -0,0 +1,36 @@
{
"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"
}

View File

@@ -0,0 +1,136 @@
{
"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"
}

View File

@@ -0,0 +1,163 @@
{
"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"
}

View File

@@ -0,0 +1,35 @@
{
"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"
}

View File

@@ -0,0 +1,154 @@
{
"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"
}

View File

@@ -0,0 +1,13 @@
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)
}
}

View File

@@ -0,0 +1,9 @@
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) }
}

View File

@@ -0,0 +1,59 @@
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)
}
}
}

View File

@@ -0,0 +1,57 @@
package org.xtimms.shirizu.core.database
import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ShirizuDatabaseTest {
@get:Rule
val helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
ShirizuDatabase::class.java,
)
private val migrations = getDatabaseMigrations(InstrumentationRegistry.getInstrumentation().targetContext)
@Test
fun versions() {
assertEquals(1, migrations.first().startVersion)
repeat(migrations.size) { i ->
assertEquals(i + 1, migrations[i].startVersion)
assertEquals(i + 2, migrations[i].endVersion)
}
assertEquals(DATABASE_VERSION, migrations.last().endVersion)
}
@Test
fun migrateAll() {
helper.createDatabase(TEST_DB, 1).close()
for (migration in migrations) {
helper.runMigrationsAndValidate(
TEST_DB,
migration.endVersion,
true,
migration,
).close()
}
}
@Test
fun prePopulate() {
val resources = InstrumentationRegistry.getInstrumentation().targetContext.resources
helper.createDatabase(TEST_DB, DATABASE_VERSION).use {
DatabasePrePopulateCallback(resources).onCreate(it)
}
}
private companion object {
const val TEST_DB = "test-db"
}
}

View File

@@ -0,0 +1,109 @@
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)
}
}
}

View File

@@ -0,0 +1,94 @@
<?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:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_rules"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="true"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Shirizu"
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.

17808
app/src/main/baseline-prof.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
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()
}
}
}

View File

@@ -0,0 +1,54 @@
package org.xtimms.shirizu
import android.os.Build
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf
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) }
val LocalWindowWidthState = staticCompositionLocalOf { WindowWidthSizeClass.Compact }
@Composable
fun SettingsProvider(windowWidthSizeClass: WindowWidthSizeClass, 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 }
),
LocalWindowWidthState provides windowWidthSizeClass,
LocalDynamicColorSwitch provides isDynamicColorEnabled,
content = content
)
}
}

View File

@@ -0,0 +1,314 @@
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.ui.dialogs.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 = LocalWindowWidthState.current == 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(windowSizeClass.widthSizeClass) {
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
)
}
}
}

View File

@@ -0,0 +1,131 @@
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)
}
}

View File

@@ -0,0 +1,52 @@
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
)
}

View File

@@ -0,0 +1,72 @@
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)
)
}
}
}

View File

@@ -0,0 +1,12 @@
package org.xtimms.shirizu.core
import android.view.HapticFeedbackConstants
import android.view.View
object HapticFeedback {
fun View.slightHapticFeedback() =
this.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
fun View.longPressHapticFeedback() =
this.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}

View File

@@ -0,0 +1,63 @@
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
}
}
)
}

View File

@@ -0,0 +1,466 @@
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.services.SERVICES_DESTINATION
import org.xtimms.shirizu.sections.settings.services.ServicesView
import org.xtimms.shirizu.sections.settings.services.suggestions.SUGGESTIONS_SETTINGS_DESTINATION
import org.xtimms.shirizu.sections.settings.services.suggestions.SuggestionsSettingsView
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) },
navigateToServicesSettings = { navController.navigate(SERVICES_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(SERVICES_DESTINATION) {
ServicesView(
navigateBack = navigateBack,
navigateToSuggestionsSettings = { navController.navigate(SUGGESTIONS_SETTINGS_DESTINATION) },
navigateToStatistics = { navController.navigate(STATS_DESTINATION) }
)
}
composable(SUGGESTIONS_SETTINGS_DESTINATION) {
SuggestionsSettingsView(
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
)
}
}
}

View File

@@ -0,0 +1,64 @@
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
}

View File

@@ -0,0 +1,76 @@
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"
}
}

View File

@@ -0,0 +1,5 @@
package org.xtimms.shirizu.core.base.event
interface PagedUiEvent : UiEvent {
fun loadMore()
}

View File

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

View File

@@ -0,0 +1,13 @@
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
}

View File

@@ -0,0 +1,19 @@
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
}

View File

@@ -0,0 +1,85 @@
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
}
}

View File

@@ -0,0 +1,86 @@
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 inline suspend fun <T> withLoading(block: () -> T): T = try {
loadingCounter.increment()
block()
} finally {
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)
}
}
}

View File

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

View File

@@ -0,0 +1,27 @@
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,
)
}

View File

@@ -0,0 +1,33 @@
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)
}
}

View File

@@ -0,0 +1,34 @@
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
}
}

View File

@@ -0,0 +1,70 @@
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)
}
}
}

View File

@@ -0,0 +1,95 @@
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)
}
}

View File

@@ -0,0 +1,20 @@
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()
}
}

View File

@@ -0,0 +1,22 @@
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
}

View File

@@ -0,0 +1,114 @@
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
)
}
}

View File

@@ -0,0 +1,101 @@
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)
}
}
}
}

View File

@@ -0,0 +1,56 @@
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),
)
}
}

View File

@@ -0,0 +1,76 @@
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)) }
)
}
}
}
}

View File

@@ -0,0 +1,181 @@
package org.xtimms.shirizu.core.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.Color
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,
)
}
}
}
@Composable
fun OutlinedButtonWithIcon(
modifier: Modifier = Modifier,
onClick: () -> Unit,
icon: ImageVector,
text: String,
contentColor: Color = MaterialTheme.colorScheme.primary
) {
OutlinedButton(
modifier = modifier,
onClick = onClick,
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
colors = ButtonDefaults.outlinedButtonColors(contentColor = contentColor)
)
{
Icon(
modifier = Modifier.size(ButtonDefaults.IconSize),
imageVector = icon,
contentDescription = null
)
Text(
modifier = Modifier.padding(start = 8.dp),
text = text
)
}
}
@Composable
fun TextButtonWithIcon(
modifier: Modifier = Modifier,
icon: ImageVector,
text: String,
contentColor: Color = MaterialTheme.colorScheme.primary,
onClick: () -> Unit,
) {
TextButton(
modifier = modifier,
onClick = onClick,
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
colors = ButtonDefaults.textButtonColors(contentColor = contentColor)
)
{
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
modifier = Modifier.size(18.dp),
imageVector = icon,
contentDescription = null
)
Text(
modifier = Modifier.padding(start = 8.dp),
text = text
)
}
}
}
@Composable
fun FilledTonalButtonWithIcon(
modifier: Modifier = Modifier,
onClick: () -> Unit,
icon: ImageVector,
text: String,
colors: ButtonColors = ButtonDefaults.filledTonalButtonColors(),
) {
FilledTonalButton(
modifier = modifier,
onClick = onClick,
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
colors = colors
)
{
Icon(
modifier = Modifier.size(18.dp),
imageVector = icon,
contentDescription = null
)
Text(
modifier = Modifier.padding(start = 8.dp),
text = text
)
}
}
@Composable
fun FilledButtonWithIcon(
modifier: Modifier = Modifier,
icon: ImageVector,
text: String,
enabled: Boolean = true,
onClick: () -> Unit,
) {
Button(
modifier = modifier,
onClick = onClick,
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
enabled = enabled
)
{
Icon(
modifier = Modifier.size(18.dp),
imageVector = icon,
contentDescription = null
)
Text(
modifier = Modifier.padding(start = 6.dp),
text = text
)
}
}

View File

@@ -0,0 +1,44 @@
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,
)
}
}

View File

@@ -0,0 +1,50 @@
package org.xtimms.shirizu.core.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun SingleChoiceChip(
modifier: Modifier = Modifier,
selected: Boolean,
enabled: Boolean = true,
onClick: () -> Unit,
label: String,
leadingIcon: ImageVector = Icons.Outlined.Check
) {
FilterChip(
modifier = modifier.padding(horizontal = 4.dp),
selected = selected,
onClick = onClick,
enabled = enabled,
shape = MaterialTheme.shapes.large,
label = {
Text(text = label)
},
leadingIcon = {
Row {
AnimatedVisibility(visible = selected) {
Icon(
imageVector = leadingIcon,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize)
)
}
}
},
)
}

View File

@@ -0,0 +1,83 @@
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)
)
}
}
}

View File

@@ -0,0 +1,121 @@
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())
)
)
}
}

View File

@@ -0,0 +1,222 @@
package org.xtimms.shirizu.core.components
import androidx.compose.foundation.layout.Arrangement
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.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.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.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun DialogSingleChoiceItem(
modifier: Modifier = Modifier,
text: String,
selected: Boolean,
onClick: () -> Unit
) {
Row(
modifier = modifier
.selectable(
selected = selected,
enabled = true,
onClick = onClick,
)
.fillMaxWidth()
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
RadioButton(
modifier = Modifier
.padding(end = 8.dp)
.clearAndSetSemantics { },
selected = selected,
onClick = onClick
)
Text(text = text, style = LocalTextStyle.current.copy(fontSize = 16.sp))
}
}
@Preview
@Composable
fun SingleChoiceItemPreview() {
Surface {
Column {
DialogSingleChoiceItemWithLabel(
text = "Better compatibility", label = "For sharing to other apps", selected = false
) {
}
DialogSingleChoiceItemWithLabel(
text = "Better quality", label = "For watching in compatible apps", selected = true
) {
}
DialogSingleChoiceItem(text = "Preview", selected = true) {
}
}
}
}
@Composable
fun DialogSingleChoiceItemWithLabel(
modifier: Modifier = Modifier,
text: String,
label: String?,
selected: Boolean,
onClick: () -> Unit
) {
Row(
modifier = modifier
.selectable(
selected = selected,
enabled = true,
onClick = onClick,
)
.fillMaxWidth()
.padding(start = 8.dp, end = 16.dp)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
RadioButton(
modifier = Modifier
.padding(end = 8.dp)
.clearAndSetSemantics { },
selected = selected,
onClick = onClick
)
Column {
Text(text = text, style = MaterialTheme.typography.bodyLarge)
label?.let {
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding()
)
}
}
}
}
@Composable
fun CheckBoxItem(
modifier: Modifier = Modifier,
text: String,
checked: Boolean,
onValueChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier
.padding(top = 12.dp)
.fillMaxWidth()
.toggleable(
value = checked, enabled = true, onValueChange = onValueChange
),
) {
Row(
modifier = modifier, verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
modifier = Modifier.clearAndSetSemantics { },
checked = checked, onCheckedChange = onValueChange,
)
Text(
modifier = Modifier, text = text, style = MaterialTheme.typography.bodyMedium
)
}
}
}
@Composable
fun DialogSwitchItem(
modifier: Modifier = Modifier,
text: String,
value: Boolean,
onValueChange: (Boolean) -> Unit
) {
Row(
modifier = modifier
.fillMaxWidth()
.toggleable(value = value, onValueChange = onValueChange)
.padding(horizontal = 24.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.weight(1f)
)
val thumbContent: (@Composable () -> Unit)? = remember(value) {
if (value) {
{
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = null,
modifier = Modifier.size(SwitchDefaults.IconSize)
)
}
} else {
null
}
}
val density = LocalDensity.current
CompositionLocalProvider(
LocalDensity provides Density(
density.density * 0.8f,
density.fontScale
)
) {
Switch(
checked = value,
onCheckedChange = onValueChange,
modifier = Modifier.clearAndSetSemantics { },
thumbContent = thumbContent
)
}
}
}
@Preview
@Composable
private fun SwitchItemPrev() {
var value by remember { mutableStateOf(false) }
Surface {
DialogSwitchItem(text = "Use cookies", value = value) {
value = it
}
}
}

View File

@@ -0,0 +1,327 @@
package org.xtimms.shirizu.core.components
import android.content.res.Configuration
import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SignalCellularConnectedNoInternet4Bar
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Surface
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.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import org.xtimms.shirizu.R
import org.xtimms.shirizu.ui.theme.FixedAccentColors
import org.xtimms.shirizu.ui.theme.ShirizuTheme
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()
}
}
}
}
}
}
}
@Composable
fun ShirizuDialogButtonVariant(
modifier: Modifier = Modifier,
shape: Shape = MiddleButtonShape,
text: String,
onClick: () -> Unit
) {
Box() {
Surface(
modifier = modifier
.clickable(onClick = onClick)
.fillMaxWidth()
.height(48.dp),
color = FixedAccentColors.secondaryFixed,
shape = shape
) {
}
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = FixedAccentColors.onSecondaryFixed,
modifier = Modifier.align(Alignment.Center)
)
}
}
@Preview(name = "dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "light", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Composable
private fun ButtonVariantPreview() {
ShirizuTheme {
ShirizuDialogVariant(
onDismissRequest = {}, modifier = Modifier,
icon = {
Icon(
imageVector = Icons.Outlined.SignalCellularConnectedNoInternet4Bar,
contentDescription = null
)
},
title = {
Text(
text = "Download with cellular network?",
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
)
},
buttons = {
ShirizuDialogButtonVariant(
text = stringResource(R.string.allow_always),
shape = TopButtonShape
) {}
ShirizuDialogButtonVariant(
text = stringResource(id = R.string.allow_once),
shape = MiddleButtonShape
) {}
ShirizuDialogButtonVariant(
text = stringResource(R.string.dont_allow),
shape = BottomButtonShape
) {}
}
)
}
}
val TopButtonShape = RoundedCornerShape(
topStart = 12.dp,
topEnd = 12.dp,
bottomStart = 4.dp,
bottomEnd = 4.dp
)
val MiddleButtonShape = RoundedCornerShape(4.dp)
val BottomButtonShape = RoundedCornerShape(
topStart = 4.dp,
topEnd = 4.dp,
bottomStart = 12.dp,
bottomEnd = 12.dp
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShirizuDialogVariant(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
buttons: @Composable (() -> Unit)? = null,
icon: @Composable (() -> Unit)? = null,
title: @Composable (() -> Unit)? = null,
text: @Composable (() -> Unit)? = null,
shape: Shape = AlertDialogDefaults.shape,
containerColor: Color = MaterialTheme.colorScheme.surfaceContainer,
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.copy(textAlign = TextAlign.Center)) {
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()
}
}
}
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(DialogHorizontalPadding)
) {
buttons?.invoke()
}
}
}
}
}

View File

@@ -0,0 +1,25 @@
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,
)
}

View File

@@ -0,0 +1,79 @@
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.painter.Painter
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
import org.xtimms.shirizu.ui.theme.applyOpacity
@Composable
fun ExploreButton(
text: String,
icon: Any? = null,
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
) {
when (icon) {
is ImageVector -> {
Icon(
imageVector = icon,
contentDescription = text,
modifier = Modifier
.padding(end = 16.dp)
.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
}
is Painter -> {
Icon(
painter = 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
)
}
}
}

View File

@@ -0,0 +1,125 @@
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

View File

@@ -0,0 +1,144 @@
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)

View File

@@ -0,0 +1,63 @@
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)
)
}
}

View File

@@ -0,0 +1,25 @@
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,
)
}

View File

@@ -0,0 +1,55 @@
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)

View File

@@ -0,0 +1,245 @@
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) }

View File

@@ -0,0 +1,94 @@
package org.xtimms.shirizu.core.components
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
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.LocalDensity
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShirizuModalBottomSheet(
modifier: Modifier = Modifier,
sheetState: SheetState = SheetState(
skipPartiallyExpanded = true,
density = LocalDensity.current,
initialValue = SheetValue.Hidden
),
onDismissRequest: () -> Unit,
horizontalPadding: PaddingValues = PaddingValues(horizontal = 28.dp),
content: @Composable ColumnScope.() -> Unit = {},
) {
ModalBottomSheet(
modifier = modifier,
onDismissRequest = onDismissRequest,
sheetState = sheetState,
windowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
) {
Column(modifier = Modifier.padding(paddingValues = horizontalPadding)) {
content()
Spacer(modifier = Modifier.height(28.dp))
}
Spacer(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.fillMaxWidth()
.height(
with(
WindowInsets.navigationBars
.asPaddingValues()
.calculateBottomPadding()
) {
when {
this.value > 30f -> {
this
}
// FIXME: https://issuetracker.google.com/issues/290798798
Build.VERSION.SDK_INT < 30 -> {
48.dp
}
else -> {
0.dp
}
}
}
)
)
}
}
@Composable
fun DrawerSheetSubtitle(
modifier: Modifier = Modifier,
text: String,
color: Color = MaterialTheme.colorScheme.primary,
) {
Text(
text = text,
modifier = modifier
.fillMaxWidth()
.padding(start = 4.dp, top = 16.dp, bottom = 8.dp),
color = color,
style = MaterialTheme.typography.labelLarge
)
}

View File

@@ -0,0 +1,76 @@
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)) }
)
}
}
}
}

View File

@@ -0,0 +1,46 @@
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,
)
}
}
}

View File

@@ -0,0 +1,831 @@
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,
)
}
}

View File

@@ -0,0 +1,290 @@
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
}

View File

@@ -0,0 +1,244 @@
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"
)
}
}
}

View File

@@ -0,0 +1,116 @@
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
)
}

View File

@@ -0,0 +1,54 @@
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)
}
}

View File

@@ -0,0 +1,116 @@
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
}
}

View File

@@ -0,0 +1,89 @@
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,
)
}
}
}
}
}

View File

@@ -0,0 +1,88 @@
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 = { }
)
}
}

View File

@@ -0,0 +1,42 @@
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
)
}

View File

@@ -0,0 +1,334 @@
package org.xtimms.shirizu.core.components
import android.graphics.Path
import android.view.animation.PathInterpolator
import androidx.compose.animation.AnimatedVisibility
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.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.QueryStats
import androidx.compose.material.icons.outlined.MoreVert
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.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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.SuggestionChip
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarScrollBehavior
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.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.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
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.sections.stats.STATS_DESTINATION
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@Composable
fun TopAppBar(
navController: NavController,
modifier: Modifier = Modifier,
backgroundAlphaProvider: () -> Float,
searchBarColorProvider: () -> Color,
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
var expanded by remember { mutableStateOf(false) }
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 = { expanded = true },
modifier = Modifier.padding(0.dp),
) {
Icon(
Icons.Outlined.MoreVert,
contentDescription = stringResource(id = R.string.open_menu),
tint = MaterialTheme.colorScheme.outline
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.statistics)) },
onClick = {
navController.navigate(STATS_DESTINATION)
expanded = false
},
leadingIcon = {
Icon(
imageVector = Icons.Default.QueryStats,
contentDescription = stringResource(id = R.string.statistics)
)
}
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.settings)) },
onClick = {
navController.navigate(SETTINGS_DESTINATION)
expanded = false
},
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(id = R.string.settings)
)
}
)
}
}
}
}
}
@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,
),
)
}
}
}

View File

@@ -0,0 +1,175 @@
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)
}
}

View File

@@ -0,0 +1,60 @@
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))
}

View File

@@ -0,0 +1,53 @@
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

View File

@@ -0,0 +1,67 @@
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.Outlined.Creation: ImageVector
get() {
if (_creation != null) {
return _creation!!
}
_creation = Builder(name = "Creation", defaultWidth = 24.0.dp, defaultHeight = 24.0.dp,
viewportWidth = 24.0f, viewportHeight = 24.0f).apply {
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = NonZero) {
moveTo(9.0f, 4.0f)
lineTo(11.5f, 9.5f)
lineTo(17.0f, 12.0f)
lineTo(11.5f, 14.5f)
lineTo(9.0f, 20.0f)
lineTo(6.5f, 14.5f)
lineTo(1.0f, 12.0f)
lineTo(6.5f, 9.5f)
lineTo(9.0f, 4.0f)
moveTo(9.0f, 8.83f)
lineTo(8.0f, 11.0f)
lineTo(5.83f, 12.0f)
lineTo(8.0f, 13.0f)
lineTo(9.0f, 15.17f)
lineTo(10.0f, 13.0f)
lineTo(12.17f, 12.0f)
lineTo(10.0f, 11.0f)
lineTo(9.0f, 8.83f)
moveTo(19.0f, 9.0f)
lineTo(17.74f, 6.26f)
lineTo(15.0f, 5.0f)
lineTo(17.74f, 3.75f)
lineTo(19.0f, 1.0f)
lineTo(20.25f, 3.75f)
lineTo(23.0f, 5.0f)
lineTo(20.25f, 6.26f)
lineTo(19.0f, 9.0f)
moveTo(19.0f, 23.0f)
lineTo(17.74f, 20.26f)
lineTo(15.0f, 19.0f)
lineTo(17.74f, 17.75f)
lineTo(19.0f, 15.0f)
lineTo(20.25f, 17.75f)
lineTo(23.0f, 19.0f)
lineTo(20.25f, 20.26f)
lineTo(19.0f, 23.0f)
close()
}
}
.build()
return _creation!!
}
private var _creation: ImageVector? = null

View File

@@ -0,0 +1,60 @@
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

View File

@@ -0,0 +1,293 @@
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

View File

@@ -0,0 +1,46 @@
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)
})
}

View File

@@ -0,0 +1,25 @@
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,
)
)
}
}

View File

@@ -0,0 +1,103 @@
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 androidx.room.migration.Migration
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.StatsDao
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.StatsEntity
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.core.database.migrations.Migration1To2
import org.xtimms.shirizu.utils.lang.processLifecycleScope
const val DATABASE_VERSION = 2
@Database(
entities = [
MangaEntity::class,
TagEntity::class,
MangaTagsEntity::class,
MangaSourceEntity::class,
HistoryEntity::class,
FavouriteEntity::class,
FavouriteCategoryEntity::class,
BookmarkEntity::class,
SuggestionEntity::class,
TrackEntity::class,
TrackLogEntity::class,
StatsEntity::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
abstract fun getStatsDao(): StatsDao
}
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration1To2()
)
fun ShirizuDatabase(context: Context): ShirizuDatabase = Room
.databaseBuilder(context, ShirizuDatabase::class.java, "shirizu-db")
.addMigrations(*getDatabaseMigrations(context))
.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)
}
}
}

View File

@@ -0,0 +1,9 @@
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"

View File

@@ -0,0 +1,57 @@
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>)
}

View File

@@ -0,0 +1,64 @@
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)
}

View File

@@ -0,0 +1,172 @@
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")
}
}

View File

@@ -0,0 +1,141 @@
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>>
}

View File

@@ -0,0 +1,59 @@
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)
}
}
}
}

View File

@@ -0,0 +1,81 @@
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"
}
}

View File

@@ -0,0 +1,69 @@
package org.xtimms.shirizu.core.database.dao
import androidx.room.Dao
import androidx.room.MapColumn
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.xtimms.shirizu.core.database.entity.MangaEntity
import org.xtimms.shirizu.core.database.entity.StatsEntity
@Dao
abstract class StatsDao {
@Query("SELECT * FROM stats ORDER BY started_at")
abstract suspend fun findAll(): List<StatsEntity>
@Query("SELECT * FROM stats WHERE manga_id = :mangaId ORDER BY started_at")
abstract suspend fun findAll(mangaId: Long): List<StatsEntity>
@Query("SELECT IFNULL(SUM(pages),0) FROM stats WHERE manga_id = :mangaId")
abstract suspend fun getReadPagesCount(mangaId: Long): Int
@Query("SELECT IFNULL(SUM(duration)/SUM(pages), 0) FROM stats WHERE manga_id = :mangaId")
abstract suspend fun getAverageTimePerPage(mangaId: Long): Long
@Query("SELECT IFNULL(SUM(duration)/SUM(pages), 0) FROM stats")
abstract suspend fun getAverageTimePerPage(): Long
@Query("SELECT IFNULL(SUM(duration), 0) FROM stats WHERE manga_id = :mangaId")
abstract suspend fun getReadingTime(mangaId: Long): Long
@Query("SELECT IFNULL(SUM(duration), 0) FROM stats")
abstract suspend fun getTotalReadingTime(): Long
@Query("DELETE FROM stats")
abstract suspend fun clear()
@Query("SELECT COUNT(*) FROM stats WHERE manga_id = :mangaId")
abstract fun observeRowCount(mangaId: Long): Flow<Int>
@Upsert
abstract suspend fun upsert(entity: StatsEntity)
suspend fun getDurationStats(fromDate: Long, isNsfw: Boolean?, favouriteCategories: Set<Long>): Map<MangaEntity, Long> {
val conditions = ArrayList<String>()
conditions.add("stats.started_at >= $fromDate")
if (favouriteCategories.isNotEmpty()) {
val ids = favouriteCategories.joinToString(",")
conditions.add("stats.manga_id IN (SELECT manga_id FROM favourites WHERE category_id IN ($ids))")
}
if (isNsfw != null) {
val flag = if (isNsfw) 1 else 0
conditions.add("manga.nsfw = $flag")
}
val where = conditions.joinToString(separator = " AND ")
val query = SimpleSQLiteQuery(
"SELECT manga.*, SUM(duration) AS d FROM stats LEFT JOIN manga ON manga.manga_id = stats.manga_id WHERE $where GROUP BY manga.manga_id ORDER BY d DESC",
)
return getDurationStatsImpl(query)
}
@RawQuery
protected abstract fun getDurationStatsImpl(
query: SupportSQLiteQuery
): Map<@MapColumn("manga") MangaEntity, @MapColumn("d") Long>
}

View File

@@ -0,0 +1,49 @@
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)
}
}
}

View File

@@ -0,0 +1,88 @@
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>)
}

View File

@@ -0,0 +1,33 @@
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
}

View File

@@ -0,0 +1,63 @@
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)
}

View File

@@ -0,0 +1,28 @@
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,
)

View File

@@ -0,0 +1,159 @@
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,
)

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