Compare commits

..

34 Commits

Author SHA1 Message Date
4f4a9049f8 Fix language headers in catalog 2024-09-04 00:35:33 +03:00
4acbe85ea1 Update Kotatsu parsers lib 2024-09-04 00:15:15 +03:00
47fffb5541 Partially migrate to Voyager navigation library 2024-05-23 01:03:43 +03:00
210da5db8a Add new manga screen variant 2024-04-15 02:47:09 +03:00
b8c2446335 Tracker improvements 2024-04-15 02:38:23 +03:00
bb21f9cfad Move benchmark module .gitignore 2024-04-14 21:43:41 +03:00
df60f2e84a Move benchmark and app modules to project root 2024-04-14 21:17:56 +03:00
774bb84f7f Set up macrobenchmark module 2024-04-14 20:05:00 +03:00
0245c04122 Update notification icon 2024-04-10 00:15:40 +03:00
df158db1e9 Splash screen tweak 2024-04-10 00:15:19 +03:00
44b6f1b52f Crash screen tweak 2024-04-10 00:14:58 +03:00
746934d421 Change app name from Etsudoku to Shirizu 2024-04-06 14:23:07 +03:00
f2e1b26a59 Update screenshots 2024-04-04 22:02:40 +03:00
c9b9fabf86 Center title 2024-04-04 21:29:14 +03:00
ac3d1efc60 Update README again... 2024-04-04 21:27:48 +03:00
c51fe59b4d Update README 2024-04-04 21:26:16 +03:00
5bfb0dcfcb Merge remote-tracking branch 'github/master'
# Conflicts:
#	README.md
2024-04-04 21:25:11 +03:00
3e12247600 Update README 2024-04-04 21:24:32 +03:00
a73e6ad767 Update README 2024-04-04 21:23:28 +03:00
02cc6c8bf2 Rename project from Tokusho to Etsudoku
tokusho means profit :trollface:
2024-04-04 21:17:15 +03:00
f0f25a4d48 Adjust feed list 2024-03-31 13:21:50 +03:00
d947e8234d Fix crash while sharing logs 2024-03-31 12:32:12 +03:00
ff021c6eec Restoring Kotatsu schema backups 2024-03-28 00:19:59 +03:00
91a3ddff4e Update details screenshot 2024-03-26 23:20:33 +03:00
8b278ba0d7 Add feed screenshot 2024-03-26 22:16:48 +03:00
d3e10583d3 Implement new chapters tracking system 2024-03-26 22:07:42 +03:00
ac9626616d Change a bit read button component UI 2024-03-26 19:17:46 +03:00
57e490fe28 Some changes 2024-03-26 14:10:37 +03:00
9e269a9e03 Suggestions 2024-03-25 17:47:59 +03:00
2746b71575 Add library desugaring 2024-03-25 14:01:52 +03:00
c905548f37 Easter egg 2024-03-25 11:51:34 +03:00
97c256ec06 Add history screenshot 2024-03-17 00:32:50 +03:00
1c54c4dc59 Update screenshots 2024-03-11 19:33:51 +03:00
47321c7ae6 I'm always forget to commit 2024-03-11 19:33:12 +03:00
663 changed files with 56625 additions and 6789 deletions

2
.idea/.name generated
View File

@@ -1 +1 @@
Tokusho
Shirizu

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidTestResultsUserPreferences">
<option name="androidTestResultsTableState">
<map>
<entry key="-639820289">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_API_34" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="83150218">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5_API_34" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1878637654">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_5_API_34" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
</map>
</option>
</component>
</project>

41
.idea/appInsightsSettings.xml generated Normal file
View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="selectedTabId" value="Android Vitals" />
<option name="tabSettings">
<map>
<entry key="Android Vitals">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="org.spray.qmanga" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="SEVEN_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
<entry key="Firebase Crashlytics">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="PLACEHOLDER" />
<option name="mobileSdkAppId" value="" />
<option name="projectId" value="" />
<option name="projectNumber" value="" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component>
</project>

View File

@@ -3,20 +3,7 @@
<component name="deploymentTargetDropDown">
<value>
<entry key="app">
<State>
<targetSelectedWithDropDown>
<Target>
<type value="QUICK_BOOT_TARGET" />
<deviceKey>
<Key>
<type value="VIRTUAL_DEVICE_PATH" />
<value value="C:\Users\xtimms\.android\avd\Pixel_API_34.avd" />
</Key>
</deviceKey>
</Target>
</targetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2024-01-29T14:26:34.807344800Z" />
</State>
<State />
</entry>
</value>
</component>

9
.idea/other.xml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ScreenshotViewer">
<option name="frameScreenshot" value="true" />
</component>
<component name="direct_access_persist.xml">
<option name="selectedCloudProject" />
</component>
</project>

View File

@@ -1,26 +1,35 @@
# Tokusho
<div align="center">
An attempt to write an Android manga reading application on Jetpack Compose using the [Kotatsu parser library](https://github.com/KotatsuApp/kotatsu-parsers).
<a>
<img src="./app/src/main/res/mipmap-xxxhdpi/ic_launcher.png" alt="Shirizu logo" title="Shirizu logo" width="80"/>
</a>
# Shirizu
</div>
Shirizu (シリーズ, from Japanese - series) - An attempt to write an Android manga reading application on Jetpack Compose using the [Kotatsu parser library](https://github.com/KotatsuApp/kotatsu-parsers).
## Is it possible to use it now?
No, nothing works.
## Screens
| Shelf | Details |
|:-----------------------------------------------------------------------------------:|:-----------------------------------------------------------------------------------------:|
| ![Shelf](https://git.kotatsu.app/Xtimms/Tokusho/raw/branch/master/images/shelf.png) | ![Details](https://git.kotatsu.app/Xtimms/Tokusho/raw/branch/master/images/details.png) |
| Library | Explore | Search |
|:------------------------------------------:|:--------------------------------------------:|:-------------------------------------------:|
| ![Shelf light theme](./images/library.png) | ![Details light theme](./images/explore.png) | ![History light theme](./images/search.png) |
## Acknowledgements
- [Kotatsu](https://github.com/KotatsuApp/Kotatsu) - UI, parsers, under the hood
- [Mihon](https://github.com/mihonapp/mihon) - UI, under the hood
- [Seal](https://github.com/JunkFood02/Seal) - UI
- [MoeList](https://github.com/axiel7/MoeList) - Under the hood
- [Tivi](https://github.com/chrisbanes/tivi) - UI
- [Buckwheat](https://github.com/danilkinkin/buckwheat) - UI
## License
You may copy, distribute and modify the software as long as you track changes/dates in source files.
Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the
GPL along with build & install instructions.

View File

@@ -11,18 +11,26 @@ plugins {
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\""
val acraAuthLogin: String =
gradleLocalProperties(rootDir).getProperty("authLogin") ?: "\"acra_login\""
val acraAuthPassword: String =
gradleLocalProperties(rootDir).getProperty("authPassword") ?: "\"acra_password\""
val shikimoriClientId: String =
gradleLocalProperties(rootDir).getProperty("shikimoriClientId") ?: "\"shikimori\""
val shikimoriClientSecret: String =
gradleLocalProperties(rootDir).getProperty("shikimoriClientSecret") ?: "\"shikimori\""
android {
namespace = "org.xtimms.tokusho"
namespace = "org.xtimms.shirizu"
compileSdk = 34
defaultConfig {
applicationId = "org.xtimms.tokusho"
applicationId = "org.xtimms.shirizu"
minSdk = 26
targetSdk = 34
versionCode = 1
@@ -36,33 +44,55 @@ android {
buildConfigField("String", "ACRA_AUTH_LOGIN", acraAuthLogin)
buildConfigField("String", "ACRA_AUTH_PASSWORD", acraAuthPassword)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "SHIKIMORI_CLIENT_ID", shikimoriClientId)
buildConfigField("String", "SHIKIMORI_CLIENT_SECRET", shikimoriClientSecret)
testInstrumentationRunner = "org.xtimms.shirizu.HiltTestRunner"
vectorDrawables {
useSupportLibrary = true
}
javaCompileOptions {
annotationProcessorOptions {
arguments += mapOf(
"room.generateKotlin" to "true",
"room.schemaLocation" to "$projectDir/schemas"
)
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
}
buildTypes {
debug {
named("debug") {
versionNameSuffix = "-${getCommitCount()}"
applicationIdSuffix = ".debug"
}
release {
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
}
@@ -71,6 +101,7 @@ android {
}
buildFeatures {
compose = true
viewBinding = true
buildConfig = true
}
composeOptions {
@@ -81,55 +112,106 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
androidResources {
generateLocaleConfig = true
}
kapt {
correctErrorTypes = true
}
}
dependencies {
// AndroidX
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("androidx.compose:compose-bom:2024.01.00"))
implementation(platform("dev.chrisbanes.compose:compose-bom:2024.05.00-alpha02"))
implementation("androidx.compose.animation:animation-graphics")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-text-google-fonts")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material:material-icons-extended:1.6.0")
implementation("androidx.compose.material3:material3-android:1.2.0")
implementation("androidx.compose.material3:material3-window-size-class:1.2.0")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
implementation("androidx.navigation:navigation-compose:2.7.6")
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.paging:paging-runtime-ktx:3.2.1")
implementation("androidx.paging:paging-compose:3.2.1")
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")
implementation("androidx.webkit:webkit:1.11.0")
ksp("androidx.room:room-compiler:2.6.1")
// Navigation
implementation("cafe.adriel.voyager:voyager-navigator:1.1.0-alpha04")
implementation("cafe.adriel.voyager:voyager-hilt:1.1.0-alpha04")
implementation("cafe.adriel.voyager:voyager-screenmodel:1.1.0-alpha04")
implementation("cafe.adriel.voyager:voyager-tab-navigator:1.1.0-alpha04")
implementation("cafe.adriel.voyager:voyager-transitions:1.1.0-alpha04")
// Motion
implementation("io.github.fornewid:material-motion-compose-core:1.2.0")
implementation("ch.acra:acra-http:5.9.7")
implementation("com.github.solkin:disk-lru-cache:1.4")
implementation("com.google.android.material:material:1.11.0")
implementation("com.google.accompanist:accompanist-flowlayout:0.32.0")
implementation("com.google.accompanist:accompanist-systemuicontroller:0.32.0")
implementation("com.google.accompanist:accompanist-pager:0.32.0")
implementation("com.google.accompanist:accompanist-pager-indicators:0.32.0")
implementation("com.google.dagger:hilt-android:2.50")
kapt("com.google.dagger:hilt-compiler:2.50")
implementation("androidx.hilt:hilt-work:1.1.0")
kapt("androidx.hilt:hilt-compiler:1.1.0")
implementation("com.github.KotatsuApp:kotatsu-parsers:a8f9423307")
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
implementation("com.google.dagger:hilt-android:2.51.1")
kapt("com.google.dagger:hilt-compiler:2.51.1")
implementation("androidx.hilt:hilt-work:1.2.0")
kapt("androidx.hilt:hilt-compiler:1.2.0")
implementation("com.github.KotatsuApp:kotatsu-parsers:b404b44008") {
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.7.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.51.1")
kaptAndroidTest("com.google.dagger:hilt-android-compiler:2.51.1")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
debugImplementation("com.github.koitharu:workinspector:5778dd1747")
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"))
}
}
onVariants(selector().withFlavor("default" to "standard")) {
// Only excluding in standard flavor because this breaks
// Layout Inspector's Compose tree
it.packaging.resources.excludes.add("META-INF/*.version")
}
}
// Git is needed in your system PATH for these commands to work.

View File

@@ -0,0 +1,793 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "37a2caa74779de4fe989f19a8cb1eaf6",
"entities": [
{
"tableName": "manga",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `alt_title` TEXT, `url` TEXT NOT NULL, `public_url` TEXT NOT NULL, `rating` REAL NOT NULL, `nsfw` INTEGER NOT NULL, `cover_url` TEXT NOT NULL, `large_cover_url` TEXT, `state` TEXT, `author` TEXT, `source` TEXT NOT NULL, PRIMARY KEY(`manga_id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "altTitle",
"columnName": "alt_title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "publicUrl",
"columnName": "public_url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rating",
"columnName": "rating",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "isNsfw",
"columnName": "nsfw",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "coverUrl",
"columnName": "cover_url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "largeCoverUrl",
"columnName": "large_cover_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "state",
"columnName": "state",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "tags",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tag_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `key` TEXT NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`tag_id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "tag_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"tag_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "manga_tags",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `tag_id` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `tag_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`tag_id`) REFERENCES `tags`(`tag_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tagId",
"columnName": "tag_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id",
"tag_id"
]
},
"indices": [
{
"name": "index_manga_tags_manga_id",
"unique": false,
"columnNames": [
"manga_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_manga_tags_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
},
{
"name": "index_manga_tags_tag_id",
"unique": false,
"columnNames": [
"tag_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_manga_tags_tag_id` ON `${TABLE_NAME}` (`tag_id`)"
}
],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
},
{
"table": "tags",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"tag_id"
],
"referencedColumns": [
"tag_id"
]
}
]
},
{
"tableName": "sources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))",
"fields": [
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isEnabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortKey",
"columnName": "sort_key",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"source"
]
},
"indices": [
{
"name": "index_sources_sort_key",
"unique": false,
"columnNames": [
"sort_key"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_sources_sort_key` ON `${TABLE_NAME}` (`sort_key`)"
}
],
"foreignKeys": []
},
{
"tableName": "history",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `chapter_id` INTEGER NOT NULL, `page` INTEGER NOT NULL, `scroll` REAL NOT NULL, `percent` REAL NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "chapterId",
"columnName": "chapter_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "page",
"columnName": "page",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scroll",
"columnName": "scroll",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "percent",
"columnName": "percent",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "deletedAt",
"columnName": "deleted_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id"
]
},
"indices": [],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
}
]
},
{
"tableName": "favourites",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `category_id` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `category_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`category_id`) REFERENCES `favourite_categories`(`category_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortKey",
"columnName": "sort_key",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deletedAt",
"columnName": "deleted_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id",
"category_id"
]
},
"indices": [
{
"name": "index_favourites_manga_id",
"unique": false,
"columnNames": [
"manga_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_favourites_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
},
{
"name": "index_favourites_category_id",
"unique": false,
"columnNames": [
"category_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_favourites_category_id` ON `${TABLE_NAME}` (`category_id`)"
}
],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
},
{
"table": "favourite_categories",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"category_id"
],
"referencedColumns": [
"category_id"
]
}
]
},
{
"tableName": "favourite_categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `created_at` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, `title` TEXT NOT NULL, `order` TEXT NOT NULL, `track` INTEGER NOT NULL, `show_in_lib` INTEGER NOT NULL, `deleted_at` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortKey",
"columnName": "sort_key",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "track",
"columnName": "track",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isVisibleInLibrary",
"columnName": "show_in_lib",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deletedAt",
"columnName": "deleted_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"category_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "bookmarks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `page_id` INTEGER NOT NULL, `chapter_id` INTEGER NOT NULL, `page` INTEGER NOT NULL, `scroll` INTEGER NOT NULL, `image` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `percent` REAL NOT NULL, PRIMARY KEY(`manga_id`, `page_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageId",
"columnName": "page_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "chapterId",
"columnName": "chapter_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "page",
"columnName": "page",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scroll",
"columnName": "scroll",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "percent",
"columnName": "percent",
"affinity": "REAL",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id",
"page_id"
]
},
"indices": [
{
"name": "index_bookmarks_manga_id",
"unique": false,
"columnNames": [
"manga_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
},
{
"name": "index_bookmarks_page_id",
"unique": false,
"columnNames": [
"page_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `${TABLE_NAME}` (`page_id`)"
}
],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
}
]
},
{
"tableName": "suggestions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `relevance` REAL NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "relevance",
"columnName": "relevance",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id"
]
},
"indices": [
{
"name": "index_suggestions_manga_id",
"unique": false,
"columnNames": [
"manga_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_suggestions_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
}
],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
}
]
},
{
"tableName": "tracks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `chapters_total` INTEGER NOT NULL, `last_chapter_id` INTEGER NOT NULL, `chapters_new` INTEGER NOT NULL, `last_check` INTEGER NOT NULL, `last_notified_id` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "totalChapters",
"columnName": "chapters_total",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastChapterId",
"columnName": "last_chapter_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "newChapters",
"columnName": "chapters_new",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastCheck",
"columnName": "last_check",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastNotifiedChapterId",
"columnName": "last_notified_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id"
]
},
"indices": [],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
}
]
},
{
"tableName": "track_logs",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `manga_id` INTEGER NOT NULL, `chapters` TEXT NOT NULL, `created_at` INTEGER NOT NULL, FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "chapters",
"columnName": "chapters",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_track_logs_manga_id",
"unique": false,
"columnNames": [
"manga_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_track_logs_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
}
],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
}
]
},
{
"tableName": "stats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `started_at` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `pages` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `started_at`), FOREIGN KEY(`manga_id`) REFERENCES `history`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "startedAt",
"columnName": "started_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pages",
"columnName": "pages",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id",
"started_at"
]
},
"indices": [],
"foreignKeys": [
{
"table": "history",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '37a2caa74779de4fe989f19a8cb1eaf6')"
]
}
}

View File

@@ -0,0 +1,857 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "dbe1dcac0f49c5ae2ac88d88aa280081",
"entities": [
{
"tableName": "manga",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `alt_title` TEXT, `url` TEXT NOT NULL, `public_url` TEXT NOT NULL, `rating` REAL NOT NULL, `nsfw` INTEGER NOT NULL, `cover_url` TEXT NOT NULL, `large_cover_url` TEXT, `state` TEXT, `author` TEXT, `source` TEXT NOT NULL, PRIMARY KEY(`manga_id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "altTitle",
"columnName": "alt_title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "publicUrl",
"columnName": "public_url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rating",
"columnName": "rating",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "isNsfw",
"columnName": "nsfw",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "coverUrl",
"columnName": "cover_url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "largeCoverUrl",
"columnName": "large_cover_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "state",
"columnName": "state",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "tags",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tag_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `key` TEXT NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`tag_id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "tag_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"tag_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "manga_tags",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `tag_id` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `tag_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`tag_id`) REFERENCES `tags`(`tag_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tagId",
"columnName": "tag_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id",
"tag_id"
]
},
"indices": [
{
"name": "index_manga_tags_manga_id",
"unique": false,
"columnNames": [
"manga_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_manga_tags_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
},
{
"name": "index_manga_tags_tag_id",
"unique": false,
"columnNames": [
"tag_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_manga_tags_tag_id` ON `${TABLE_NAME}` (`tag_id`)"
}
],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
},
{
"table": "tags",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"tag_id"
],
"referencedColumns": [
"tag_id"
]
}
]
},
{
"tableName": "sources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))",
"fields": [
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isEnabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortKey",
"columnName": "sort_key",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"source"
]
},
"indices": [
{
"name": "index_sources_sort_key",
"unique": false,
"columnNames": [
"sort_key"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_sources_sort_key` ON `${TABLE_NAME}` (`sort_key`)"
}
],
"foreignKeys": []
},
{
"tableName": "history",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `chapter_id` INTEGER NOT NULL, `page` INTEGER NOT NULL, `scroll` REAL NOT NULL, `percent` REAL NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "chapterId",
"columnName": "chapter_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "page",
"columnName": "page",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scroll",
"columnName": "scroll",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "percent",
"columnName": "percent",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "deletedAt",
"columnName": "deleted_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id"
]
},
"indices": [],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
}
]
},
{
"tableName": "favourites",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `category_id` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `category_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`category_id`) REFERENCES `favourite_categories`(`category_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortKey",
"columnName": "sort_key",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deletedAt",
"columnName": "deleted_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id",
"category_id"
]
},
"indices": [
{
"name": "index_favourites_manga_id",
"unique": false,
"columnNames": [
"manga_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_favourites_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
},
{
"name": "index_favourites_category_id",
"unique": false,
"columnNames": [
"category_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_favourites_category_id` ON `${TABLE_NAME}` (`category_id`)"
}
],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
},
{
"table": "favourite_categories",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"category_id"
],
"referencedColumns": [
"category_id"
]
}
]
},
{
"tableName": "favourite_categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `created_at` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, `title` TEXT NOT NULL, `order` TEXT NOT NULL, `track` INTEGER NOT NULL, `show_in_lib` INTEGER NOT NULL, `deleted_at` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortKey",
"columnName": "sort_key",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "track",
"columnName": "track",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isVisibleInLibrary",
"columnName": "show_in_lib",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deletedAt",
"columnName": "deleted_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"category_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "bookmarks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `page_id` INTEGER NOT NULL, `chapter_id` INTEGER NOT NULL, `page` INTEGER NOT NULL, `scroll` INTEGER NOT NULL, `image` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `percent` REAL NOT NULL, PRIMARY KEY(`manga_id`, `page_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageId",
"columnName": "page_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "chapterId",
"columnName": "chapter_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "page",
"columnName": "page",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scroll",
"columnName": "scroll",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "percent",
"columnName": "percent",
"affinity": "REAL",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id",
"page_id"
]
},
"indices": [
{
"name": "index_bookmarks_manga_id",
"unique": false,
"columnNames": [
"manga_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
},
{
"name": "index_bookmarks_page_id",
"unique": false,
"columnNames": [
"page_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `${TABLE_NAME}` (`page_id`)"
}
],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
}
]
},
{
"tableName": "suggestions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `relevance` REAL NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "relevance",
"columnName": "relevance",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id"
]
},
"indices": [
{
"name": "index_suggestions_manga_id",
"unique": false,
"columnNames": [
"manga_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_suggestions_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
}
],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
}
]
},
{
"tableName": "tracks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `last_chapter_id` INTEGER NOT NULL, `chapters_new` INTEGER NOT NULL, `last_check_time` INTEGER NOT NULL, `last_chapter_date` INTEGER NOT NULL, `last_result` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastChapterId",
"columnName": "last_chapter_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "newChapters",
"columnName": "chapters_new",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastCheckTime",
"columnName": "last_check_time",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastChapterDate",
"columnName": "last_chapter_date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastResult",
"columnName": "last_result",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id"
]
},
"indices": [],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
}
]
},
{
"tableName": "track_logs",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `manga_id` INTEGER NOT NULL, `chapters` TEXT NOT NULL, `created_at` INTEGER NOT NULL, FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "chapters",
"columnName": "chapters",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_track_logs_manga_id",
"unique": false,
"columnNames": [
"manga_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_track_logs_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
}
],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
}
]
},
{
"tableName": "stats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `started_at` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `pages` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `started_at`), FOREIGN KEY(`manga_id`) REFERENCES `history`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "startedAt",
"columnName": "started_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pages",
"columnName": "pages",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id",
"started_at"
]
},
"indices": [],
"foreignKeys": [
{
"table": "history",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
}
]
},
{
"tableName": "scrobblings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`scrobbler` INTEGER NOT NULL, `id` INTEGER NOT NULL, `manga_id` INTEGER NOT NULL, `target_id` INTEGER NOT NULL, `status` TEXT, `chapter` INTEGER NOT NULL, `comment` TEXT, `rating` REAL NOT NULL, PRIMARY KEY(`scrobbler`, `id`, `manga_id`))",
"fields": [
{
"fieldPath": "scrobbler",
"columnName": "scrobbler",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "targetId",
"columnName": "target_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "chapter",
"columnName": "chapter",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "comment",
"columnName": "comment",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "rating",
"columnName": "rating",
"affinity": "REAL",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"scrobbler",
"id",
"manga_id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dbe1dcac0f49c5ae2ac88d88aa280081')"
]
}
}

View File

@@ -0,0 +1,875 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "90b73386d5c61c2ddf46d6354ca2f1b6",
"entities": [
{
"tableName": "manga",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `alt_title` TEXT, `url` TEXT NOT NULL, `public_url` TEXT NOT NULL, `rating` REAL NOT NULL, `nsfw` INTEGER NOT NULL, `cover_url` TEXT NOT NULL, `large_cover_url` TEXT, `state` TEXT, `author` TEXT, `source` TEXT NOT NULL, PRIMARY KEY(`manga_id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "altTitle",
"columnName": "alt_title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "publicUrl",
"columnName": "public_url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rating",
"columnName": "rating",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "isNsfw",
"columnName": "nsfw",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "coverUrl",
"columnName": "cover_url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "largeCoverUrl",
"columnName": "large_cover_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "state",
"columnName": "state",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "tags",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tag_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `key` TEXT NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`tag_id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "tag_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"tag_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "manga_tags",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `tag_id` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `tag_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`tag_id`) REFERENCES `tags`(`tag_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tagId",
"columnName": "tag_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id",
"tag_id"
]
},
"indices": [
{
"name": "index_manga_tags_manga_id",
"unique": false,
"columnNames": [
"manga_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_manga_tags_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
},
{
"name": "index_manga_tags_tag_id",
"unique": false,
"columnNames": [
"tag_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_manga_tags_tag_id` ON `${TABLE_NAME}` (`tag_id`)"
}
],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
},
{
"table": "tags",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"tag_id"
],
"referencedColumns": [
"tag_id"
]
}
]
},
{
"tableName": "sources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, `added_in` INTEGER NOT NULL, `used_at` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, PRIMARY KEY(`source`))",
"fields": [
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isEnabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortKey",
"columnName": "sort_key",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "addedIn",
"columnName": "added_in",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUsedAt",
"columnName": "used_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"source"
]
},
"indices": [
{
"name": "index_sources_sort_key",
"unique": false,
"columnNames": [
"sort_key"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_sources_sort_key` ON `${TABLE_NAME}` (`sort_key`)"
}
],
"foreignKeys": []
},
{
"tableName": "history",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `chapter_id` INTEGER NOT NULL, `page` INTEGER NOT NULL, `scroll` REAL NOT NULL, `percent` REAL NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "chapterId",
"columnName": "chapter_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "page",
"columnName": "page",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scroll",
"columnName": "scroll",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "percent",
"columnName": "percent",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "deletedAt",
"columnName": "deleted_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id"
]
},
"indices": [],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
}
]
},
{
"tableName": "favourites",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `category_id` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `category_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`category_id`) REFERENCES `favourite_categories`(`category_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortKey",
"columnName": "sort_key",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deletedAt",
"columnName": "deleted_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id",
"category_id"
]
},
"indices": [
{
"name": "index_favourites_manga_id",
"unique": false,
"columnNames": [
"manga_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_favourites_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
},
{
"name": "index_favourites_category_id",
"unique": false,
"columnNames": [
"category_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_favourites_category_id` ON `${TABLE_NAME}` (`category_id`)"
}
],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
},
{
"table": "favourite_categories",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"category_id"
],
"referencedColumns": [
"category_id"
]
}
]
},
{
"tableName": "favourite_categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `created_at` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, `title` TEXT NOT NULL, `order` TEXT NOT NULL, `track` INTEGER NOT NULL, `show_in_lib` INTEGER NOT NULL, `deleted_at` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortKey",
"columnName": "sort_key",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "track",
"columnName": "track",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isVisibleInLibrary",
"columnName": "show_in_lib",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deletedAt",
"columnName": "deleted_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"category_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "bookmarks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `page_id` INTEGER NOT NULL, `chapter_id` INTEGER NOT NULL, `page` INTEGER NOT NULL, `scroll` INTEGER NOT NULL, `image` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `percent` REAL NOT NULL, PRIMARY KEY(`manga_id`, `page_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageId",
"columnName": "page_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "chapterId",
"columnName": "chapter_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "page",
"columnName": "page",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scroll",
"columnName": "scroll",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "percent",
"columnName": "percent",
"affinity": "REAL",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id",
"page_id"
]
},
"indices": [
{
"name": "index_bookmarks_manga_id",
"unique": false,
"columnNames": [
"manga_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
},
{
"name": "index_bookmarks_page_id",
"unique": false,
"columnNames": [
"page_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `${TABLE_NAME}` (`page_id`)"
}
],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
}
]
},
{
"tableName": "suggestions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `relevance` REAL NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "relevance",
"columnName": "relevance",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id"
]
},
"indices": [
{
"name": "index_suggestions_manga_id",
"unique": false,
"columnNames": [
"manga_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_suggestions_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
}
],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
}
]
},
{
"tableName": "tracks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `last_chapter_id` INTEGER NOT NULL, `chapters_new` INTEGER NOT NULL, `last_check_time` INTEGER NOT NULL, `last_chapter_date` INTEGER NOT NULL, `last_result` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastChapterId",
"columnName": "last_chapter_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "newChapters",
"columnName": "chapters_new",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastCheckTime",
"columnName": "last_check_time",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastChapterDate",
"columnName": "last_chapter_date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastResult",
"columnName": "last_result",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id"
]
},
"indices": [],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
}
]
},
{
"tableName": "track_logs",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `manga_id` INTEGER NOT NULL, `chapters` TEXT NOT NULL, `created_at` INTEGER NOT NULL, FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "chapters",
"columnName": "chapters",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_track_logs_manga_id",
"unique": false,
"columnNames": [
"manga_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_track_logs_manga_id` ON `${TABLE_NAME}` (`manga_id`)"
}
],
"foreignKeys": [
{
"table": "manga",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
}
]
},
{
"tableName": "stats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`manga_id` INTEGER NOT NULL, `started_at` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `pages` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `started_at`), FOREIGN KEY(`manga_id`) REFERENCES `history`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "startedAt",
"columnName": "started_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pages",
"columnName": "pages",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"manga_id",
"started_at"
]
},
"indices": [],
"foreignKeys": [
{
"table": "history",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"manga_id"
],
"referencedColumns": [
"manga_id"
]
}
]
},
{
"tableName": "scrobblings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`scrobbler` INTEGER NOT NULL, `id` INTEGER NOT NULL, `manga_id` INTEGER NOT NULL, `target_id` INTEGER NOT NULL, `status` TEXT, `chapter` INTEGER NOT NULL, `comment` TEXT, `rating` REAL NOT NULL, PRIMARY KEY(`scrobbler`, `id`, `manga_id`))",
"fields": [
{
"fieldPath": "scrobbler",
"columnName": "scrobbler",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mangaId",
"columnName": "manga_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "targetId",
"columnName": "target_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "chapter",
"columnName": "chapter",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "comment",
"columnName": "comment",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "rating",
"columnName": "rating",
"affinity": "REAL",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"scrobbler",
"id",
"manga_id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '90b73386d5c61c2ddf46d6354ca2f1b6')"
]
}
}

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

@@ -1,24 +0,0 @@
package org.xtimms.tokusho
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("org.xtimms.tokusho", appContext.packageName)
}
}

View File

@@ -5,12 +5,20 @@
<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.READ_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES"/>
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<queries>
<intent>
@@ -23,23 +31,25 @@
<application
android:name=".App"
android:allowBackup="false"
android:enableOnBackInvokedCallback="true"
android:hardwareAccelerated="true"
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:supportsRtl="true"
android:theme="@style/Theme.Tokusho"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
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.Tokusho">
android:theme="@style/Theme.Shirizu.SplashTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -52,6 +62,22 @@
android:exported="false"
android:process=":error_handler" />
<activity
android:name=".core.scrobbling.ScrobblingLoginActivity"
android:exported="true"
android:label="@string/scrobbling">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="shrz" />
<data android:host="shikimori-auth" />
</intent-filter>
</activity>
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
@@ -59,13 +85,23 @@
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="org.xtimms.tokusho.provider"
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"
@@ -76,7 +112,6 @@
<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

@@ -1,11 +1,17 @@
package org.xtimms.tokusho
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 coil.ImageLoader
import com.google.android.material.color.DynamicColors
import com.tencent.mmkv.MMKV
import dagger.hilt.android.HiltAndroidApp
@@ -17,18 +23,41 @@ import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.acra.sender.HttpSender
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.xtimms.tokusho.core.database.TokushoDatabase
import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.core.updates.Updater
import org.xtimms.tokusho.utils.lang.processLifecycleScope
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() {
class App : Application(), Configuration.Provider {
@Inject
lateinit var database: Provider<TokushoDatabase>
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>
@Inject
lateinit var imageLoader: ImageLoader
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
override fun onCreate() {
super.onCreate()
@@ -39,6 +68,7 @@ class App : Application() {
) else getPackageInfo(packageName, 0)
}
DynamicColors.applyToActivitiesIfAvailable(this)
connectivityManager = getSystemService()!!
processLifecycleScope.launch(Dispatchers.IO) {
try {
@@ -48,30 +78,30 @@ class App : Application() {
}
}
// GlobalExceptionHandler.initialize(applicationContext, CrashActivity::class.java)
if (AppSettings.isACRAEnabled()) {
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,
)
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?) {
@@ -100,8 +130,8 @@ class App : Application() {
companion object {
lateinit var packageInfo: PackageInfo
lateinit var connectivityManager: ConnectivityManager
@Suppress("DEPRECATION")
fun getVersionReport(): String {
val versionName = packageInfo.versionName
val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {

View File

@@ -1,23 +1,29 @@
package org.xtimms.tokusho
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.shiki.ui.theme.SEED
import org.xtimms.tokusho.core.prefs.AppSettings
import org.xtimms.tokusho.core.prefs.DarkThemePreference
import org.xtimms.tokusho.core.prefs.paletteStyles
import org.xtimms.tokusho.ui.monet.LocalTonalPalettes
import org.xtimms.tokusho.ui.monet.PaletteStyle
import org.xtimms.tokusho.ui.monet.TonalPalettes.Companion.toTonalPalettes
import coil.ImageLoader
import org.xtimms.shirizu.core.logs.FileLogger
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.core.scrobbling.services.kitsu.data.KitsuRepository
import org.xtimms.shirizu.core.scrobbling.services.shikimori.data.ShikimoriRepository
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,
@@ -29,6 +35,12 @@ val LocalSeedColor = compositionLocalOf { SEED }
val LocalDynamicColorSwitch = compositionLocalOf { false }
val LocalPaletteStyleIndex = compositionLocalOf { 0 }
val LocalWindowInsets = compositionLocalOf { PaddingValues(0.dp) }
val LocalWindowWidthState = staticCompositionLocalOf { WindowWidthSizeClass.Compact }
val LocalImageLoader = compositionLocalOf<ImageLoader> { error("No ImageLoader provided") }
val LocalLoggers = compositionLocalOf<Set<@JvmSuppressWildcards FileLogger>> { error("No file loggers provided") }
val LocalKitsuRepository = compositionLocalOf<KitsuRepository> { error("No KitsuRepository provided") }
val LocalShikimoriRepository = compositionLocalOf<ShikimoriRepository> { error("No ShikimoriRepository provided") }
@Composable
fun SettingsProvider(content: @Composable () -> Unit) {

View File

@@ -0,0 +1,508 @@
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.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Box
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.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.RssFeed
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.util.fastForEach
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior
import cafe.adriel.voyager.navigator.currentOrThrow
import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
import cafe.adriel.voyager.navigator.tab.TabNavigator
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.xtimms.shirizu.core.components.AppBar
import org.xtimms.shirizu.core.components.AppBarActions
import org.xtimms.shirizu.core.components.AppToolbar
import org.xtimms.shirizu.core.components.Scaffold
import org.xtimms.shirizu.core.components.icons.Creation
import org.xtimms.shirizu.core.logs.FileLogger
import org.xtimms.shirizu.core.onboarding.OnboardingScreen
import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.core.scrobbling.services.kitsu.data.KitsuRepository
import org.xtimms.shirizu.core.scrobbling.services.shikimori.data.ShikimoriRepository
import org.xtimms.shirizu.core.ui.dialogs.UpdateDialogImpl
import org.xtimms.shirizu.core.updates.Updater
import org.xtimms.shirizu.sections.explore.ExploreTab
import org.xtimms.shirizu.sections.feed.FeedScreen
import org.xtimms.shirizu.sections.history.HistoryTab
import org.xtimms.shirizu.sections.library.LibraryTab
import org.xtimms.shirizu.sections.onboarding.OnboardingScreen
import org.xtimms.shirizu.sections.profile.ProfileTab
import org.xtimms.shirizu.sections.search.SearchTab
import org.xtimms.shirizu.sections.settings.SettingsScreen
import org.xtimms.shirizu.sections.shelf.ShelfTab
import org.xtimms.shirizu.sections.suggestions.SuggestionsScreen
import org.xtimms.shirizu.ui.theme.ShirizuTheme
import org.xtimms.shirizu.utils.lang.DefaultNavigatorScreenTransition
import org.xtimms.shirizu.utils.lang.NoLiftingAppBarScreen
import org.xtimms.shirizu.utils.lang.Screen
import org.xtimms.shirizu.utils.lang.isTabletUi
import org.xtimms.shirizu.utils.lang.materialSharedAxisX
import org.xtimms.shirizu.utils.system.setLanguage
import org.xtimms.shirizu.utils.system.suspendToast
import javax.inject.Inject
@OptIn(ExperimentalMaterial3Api::class)
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val isReady: MutableState<Boolean> = mutableStateOf(false)
private val isDone: MutableState<Boolean> = mutableStateOf(false)
private var navigator: Navigator? = null
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var loggers: Set<@JvmSuppressWildcards FileLogger>
@Inject
lateinit var shikimoriRepository: ShikimoriRepository
@Inject
lateinit var kitsuRepository: KitsuRepository
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 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) {
CompositionLocalProvider(
LocalImageLoader provides coil,
LocalLoggers provides loggers,
LocalShikimoriRepository provides shikimoriRepository,
LocalKitsuRepository provides kitsuRepository
) {
SettingsProvider {
ShirizuTheme(
darkTheme = LocalDarkTheme.current.isDarkTheme(),
isDynamicColorEnabled = LocalDynamicColorSwitch.current,
isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled,
) {
Navigator(
screen = MainScreen,
disposeBehavior = NavigatorDisposeBehavior(
disposeNestedNavigators = false,
disposeSteps = true
),
) { navigator ->
LaunchedEffect(navigator) {
this@MainActivity.navigator = navigator
}
val scaffoldInsets =
WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)
Scaffold(
contentWindowInsets = scaffoldInsets,
) { contentPadding ->
// Consume insets already used by app state banners
Box(
modifier = Modifier
.padding(contentPadding)
.consumeWindowInsets(contentPadding),
) {
// Shows current screen
DefaultNavigatorScreenTransition(navigator = navigator)
}
}
// ShowOnboarding()
}
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)
}
@Composable
private fun ShowOnboarding() {
val navigator = LocalNavigator.currentOrThrow
LaunchedEffect(Unit) {
if (navigator.lastItem !is OnboardingScreen) {
navigator.push(OnboardingScreen())
}
}
}
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)
object MainScreen : Screen() {
private val openTabEvent = Channel<Tab>()
private val showBottomNavEvent = Channel<Boolean>()
private const val TabNavigatorKey = "HomeTabs"
private val tabs = listOf(
LibraryTab(),
// ShelfTab,
// HistoryTab,
ExploreTab(),
SearchTab,
// ProfileTab
)
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
TabNavigator(
tab = LibraryTab(),
key = TabNavigatorKey,
) { tabNavigator ->
// Provide usable navigator to content screen
CompositionLocalProvider(LocalNavigator provides navigator) {
Scaffold(
topBar = { scrollBehavior ->
if (!isTabletUi()) {
AppToolbar(
actions = {
AppBarActions(
persistentListOf(
AppBar.Action(
title = stringResource(R.string.feed),
icon = Icons.Outlined.RssFeed,
onClick = {
navigator.push(FeedScreen)
},
),
AppBar.Action(
title = stringResource(R.string.settings),
icon = Icons.Outlined.Settings,
onClick = {
navigator.push(SettingsScreen)
},
),
),
)
},
scrollBehavior = if (tabNavigator.current is NoLiftingAppBarScreen) {
null
} else scrollBehavior
)
}
},
startBar = {
if (isTabletUi()) {
NavigationRail {
tabs.fastForEach {
NavigationRailItem(it)
}
}
}
},
bottomBar = {
if (!isTabletUi()) {
val bottomNavVisible by produceState(initialValue = true) {
showBottomNavEvent.receiveAsFlow().collectLatest { value = it }
}
AnimatedVisibility(
visible = bottomNavVisible,
enter = expandVertically(),
exit = shrinkVertically(),
) {
NavigationBar {
tabs.fastForEach {
NavigationBarItem(it)
}
}
}
}
},
contentWindowInsets = WindowInsets(0),
) { contentPadding ->
Box(
modifier = Modifier
.padding(contentPadding)
.consumeWindowInsets(contentPadding),
) {
AnimatedContent(
targetState = tabNavigator.current,
transitionSpec = {
materialSharedAxisX(forward = true)
},
label = "tabContent",
) {
tabNavigator.saveableState(key = "currentTab", it) {
it.Content()
}
}
}
}
}
val goToLibraryTab = { tabNavigator.current = LibraryTab() }
BackHandler(
enabled = tabNavigator.current != LibraryTab(),
onBack = goToLibraryTab,
)
LaunchedEffect(Unit) {
launch {
openTabEvent.receiveAsFlow().collectLatest {
tabNavigator.current = when (it) {
is Tab.Library -> LibraryTab()
// is Tab.Shelf -> ShelfTab
// is Tab.History -> HistoryTab
is Tab.Explore -> ExploreTab()
is Tab.Search -> SearchTab
// is Tab.Profile -> ProfileTab
}
}
}
}
}
}
@Composable
private fun RowScope.NavigationBarItem(tab: org.xtimms.shirizu.utils.lang.Tab) {
val tabNavigator = LocalTabNavigator.current
val navigator = LocalNavigator.currentOrThrow
val scope = rememberCoroutineScope()
val selected = tabNavigator.current::class == tab::class
NavigationBarItem(
selected = selected,
onClick = {
if (!selected) {
tabNavigator.current = tab
} else {
scope.launch { tab.onReselect(navigator) }
}
},
icon = { NavigationIconItem(tab) },
label = {
Text(
text = tab.options.title,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
alwaysShowLabel = true,
)
}
@Composable
fun NavigationRailItem(tab: org.xtimms.shirizu.utils.lang.Tab) {
val tabNavigator = LocalTabNavigator.current
val navigator = LocalNavigator.currentOrThrow
val scope = rememberCoroutineScope()
val selected = tabNavigator.current::class == tab::class
NavigationRailItem(
selected = selected,
onClick = {
if (!selected) {
tabNavigator.current = tab
} else {
scope.launch { tab.onReselect(navigator) }
}
},
icon = { NavigationIconItem(tab) },
label = {
Text(
text = tab.options.title,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
alwaysShowLabel = true,
)
}
@Composable
private fun NavigationIconItem(tab: org.xtimms.shirizu.utils.lang.Tab) {
Icon(
painter = tab.options.icon!!,
contentDescription = tab.options.title,
// TODO: https://issuetracker.google.com/u/0/issues/316327367
tint = LocalContentColor.current,
)
}
suspend fun showBottomNav(show: Boolean) {
showBottomNavEvent.send(show)
}
sealed interface Tab {
data object Library : Tab
// data object Shelf : Tab
// data object History : Tab
data object Explore : Tab
data object Search : Tab
// data object Profile : Tab
}
}

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,30 @@
package org.xtimms.shirizu.core
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage
import org.xtimms.shirizu.LocalImageLoader
import org.xtimms.shirizu.R
import org.xtimms.shirizu.utils.composable.rememberResourceBitmapPainter
@Composable
fun ShirizuAsyncImage(
model: Any? = null,
contentDescription: String?,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop,
) {
AsyncImage(
imageLoader = LocalImageLoader.current,
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,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

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

View File

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

View File

@@ -1,4 +1,4 @@
package org.xtimms.tokusho.core.base.state
package org.xtimms.shirizu.core.base.state
abstract class PagedUiState : UiState() {

View File

@@ -1,4 +1,4 @@
package org.xtimms.tokusho.core.base.state
package org.xtimms.shirizu.core.base.state
abstract class UiState {
abstract val isLoading: Boolean

View File

@@ -0,0 +1,90 @@
package org.xtimms.shirizu.core.base.viewmodel
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
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.asStateFlow
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 BaseStateScreenModel<S>(initialState: S) : ScreenModel {
protected val mutableState: MutableStateFlow<S> = MutableStateFlow(initialState)
public val state: StateFlow<S> = mutableState.asStateFlow()
@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(screenModelScope, SharingStarted.Lazily, loadingCounter.value > 0)
protected fun launchJob(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job = screenModelScope.launch(context + createErrorHandler(), start, block)
protected fun launchLoadingJob(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job = screenModelScope.launch(context + createErrorHandler(), start) {
loadingCounter.increment()
try {
block()
} finally {
loadingCounter.decrement()
}
}
protected fun <T> Flow<T>.withLoading() = onStart {
loadingCounter.increment()
}.onCompletion {
loadingCounter.decrement()
}
protected suspend inline 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

@@ -1,4 +1,4 @@
package org.xtimms.tokusho.core.base.viewmodel
package org.xtimms.shirizu.core.base.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -11,11 +11,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.xtimms.tokusho.core.base.event.UiEvent
import org.xtimms.tokusho.core.base.state.UiState
import org.xtimms.tokusho.utils.lang.EventFlow
import org.xtimms.tokusho.utils.lang.MutableEventFlow
import org.xtimms.tokusho.utils.lang.call
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

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 suspend inline 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

@@ -1,4 +1,4 @@
package org.xtimms.tokusho.core.cache
package org.xtimms.shirizu.core.cache
enum class CacheDir(val dir: String) {

View File

@@ -1,17 +1,20 @@
package org.xtimms.tokusho.core.cache
package org.xtimms.shirizu.core.cache
import androidx.collection.LruCache
import java.util.concurrent.TimeUnit
import org.xtimms.shirizu.core.cache.MemoryContentCache.Key as CacheKey
class ExpiringLruCache<T>(
val maxSize: Int,
private val lifetime: Long,
private val timeUnit: TimeUnit,
) {
) : Iterable<CacheKey> {
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
private val cache = LruCache<CacheKey, ExpiringValue<T>>(maxSize)
operator fun get(key: ContentCache.Key): T? {
override fun iterator(): Iterator<CacheKey> = cache.snapshot().keys.iterator()
operator fun get(key: CacheKey): T? {
val value = cache[key] ?: return null
if (value.isExpired) {
cache.remove(key)
@@ -19,7 +22,7 @@ class ExpiringLruCache<T>(
return value.get()
}
operator fun set(key: ContentCache.Key, value: T) {
operator fun set(key: CacheKey, value: T) {
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
}
@@ -30,4 +33,8 @@ class ExpiringLruCache<T>(
fun trimToSize(size: Int) {
cache.trimToSize(size)
}
fun remove(key: CacheKey) {
cache.remove(key)
}
}

View File

@@ -1,4 +1,4 @@
package org.xtimms.tokusho.core.cache
package org.xtimms.shirizu.core.cache
import android.os.SystemClock
import java.util.concurrent.TimeUnit

View File

@@ -0,0 +1,95 @@
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 org.xtimms.shirizu.utils.system.isLowRamDevice
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MemoryContentCache @Inject constructor(application: Application) : ComponentCallbacks2 {
private val isLowRam = application.isLowRamDevice()
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
private val pagesCache =
ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
private val relatedMangaCache =
ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
init {
application.registerComponentCallbacks(this)
}
suspend fun getDetails(source: MangaSource, url: String): Manga? {
return detailsCache[Key(source, url)]?.awaitOrNull()
}
fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
detailsCache[Key(source, url)] = details
}
suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
return pagesCache[Key(source, url)]?.awaitOrNull()
}
fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
pagesCache[Key(source, url)] = pages
}
suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? {
return relatedMangaCache[Key(source, url)]?.awaitOrNull()
}
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) {
relatedMangaCache[Key(source, url)] = related
}
fun clear(source: MangaSource) {
clearCache(detailsCache, source)
clearCache(pagesCache, source)
clearCache(relatedMangaCache, source)
}
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)
}
}
private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) {
cache.forEach { key ->
if (key.source == source) {
cache.remove(key)
}
}
}
data class Key(
val source: MangaSource,
val url: String,
)
}

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

@@ -1,4 +1,4 @@
package org.xtimms.tokusho.core.cache
package org.xtimms.shirizu.core.cache
import kotlinx.coroutines.Deferred

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

@@ -1,4 +1,4 @@
package org.xtimms.tokusho.core.components
package org.xtimms.shirizu.core.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform

View File

@@ -0,0 +1,42 @@
package org.xtimms.shirizu.core.components
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
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
import androidx.compose.ui.unit.min
import kotlin.math.roundToInt
@Composable
fun AutoSizedCircularProgressIndicator(
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.onSurface,
) {
BoxWithConstraints(modifier) {
val diameter = with(LocalDensity.current) {
// We need to minus the padding added within CircularProgressIndicator
min(constraints.maxWidth.toDp(), constraints.maxHeight.toDp()) - InternalPadding
}
CircularProgressIndicator(
strokeWidth = (diameter.value * StrokeDiameterFraction)
.roundToInt().dp
.coerceAtLeast(2.dp),
color = color,
)
}
}
// Default stroke size
private val DefaultStrokeWidth = 4.dp
// Preferred diameter for CircularProgressIndicator
private val DefaultDiameter = 40.dp
// Internal padding added by CircularProgressIndicator
private val InternalPadding = 4.dp
private val StrokeDiameterFraction = DefaultStrokeWidth / DefaultDiameter

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,183 @@
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.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.ZeroCornerSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Label
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.DoneAll
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Favorite
import androidx.compose.material.icons.outlined.RemoveDone
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.xtimms.shirizu.R
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RowScope.Button(
title: String,
icon: ImageVector,
toConfirm: Boolean,
onLongClick: () -> Unit,
onClick: () -> Unit,
content: (@Composable () -> Unit)? = null,
) {
val animatedWeight by animateFloatAsState(
targetValue = if (toConfirm) 2f else 1f,
label = "weight",
)
Column(
modifier = Modifier
.size(48.dp)
.weight(animatedWeight)
.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(bounded = false),
onLongClick = onLongClick,
onClick = onClick,
),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = icon,
contentDescription = title,
)
AnimatedVisibility(
visible = toConfirm,
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
) {
Text(
text = title,
overflow = TextOverflow.Visible,
maxLines = 1,
style = MaterialTheme.typography.labelSmall,
)
}
content?.invoke()
}
}
@Composable
fun LibraryBottomActionMenu(
visible: Boolean,
onDeleteClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = visible,
enter = expandVertically(animationSpec = tween(delayMillis = 300)),
exit = shrinkVertically(animationSpec = tween()),
) {
val scope = rememberCoroutineScope()
Surface(
modifier = modifier,
shape = MaterialTheme.shapes.large.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize),
tonalElevation = 3.dp,
) {
val haptic = LocalHapticFeedback.current
val confirm = remember { mutableStateListOf(false, false, false, false, false) }
var resetJob: Job? = remember { null }
val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
(0..<5).forEach { i -> confirm[i] = i == toConfirmIndex }
resetJob?.cancel()
resetJob = scope.launch {
delay(1.seconds)
if (isActive) confirm[toConfirmIndex] = false
}
}
Row(
modifier = Modifier
.windowInsetsPadding(
WindowInsets.navigationBars
.only(WindowInsetsSides.Bottom),
)
.padding(horizontal = 8.dp, vertical = 12.dp),
) {
Button(
title = stringResource(R.string.action_share),
icon = Icons.Outlined.Share,
toConfirm = confirm[0],
onLongClick = { onLongClickItem(0) },
onClick = { },
)
Button(
title = stringResource(R.string.action_delete),
icon = Icons.Outlined.Delete,
toConfirm = confirm[1],
onLongClick = { onLongClickItem(1) },
onClick = onDeleteClicked,
)
Button(
title = stringResource(R.string.action_save),
icon = Icons.Outlined.Save,
toConfirm = confirm[2],
onLongClick = { onLongClickItem(2) },
onClick = { },
)
Button(
title = stringResource(R.string.add_to_shelf),
icon = Icons.Outlined.Favorite,
toConfirm = confirm[3],
onLongClick = { onLongClickItem(3) },
onClick = { },
)
Button(
title = stringResource(R.string.action_mark_as_completed),
icon = Icons.Outlined.DoneAll,
toConfirm = confirm[4],
onLongClick = { onLongClickItem(4) },
onClick = { },
)
}
}
}
}

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,282 @@
package org.xtimms.shirizu.core.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
import androidx.compose.foundation.gestures.snapping.SnapPosition
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
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.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.FirstBaseline
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.shirizu.LocalImageLoader
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.icons.Creation
import org.xtimms.shirizu.core.ui.screens.EmptyScreen
import org.xtimms.shirizu.ui.theme.ShirizuTheme
import kotlin.math.pow
import kotlin.math.roundToInt
@Composable
fun MangaCarouselWithHeader(
items: List<Manga>,
title: String,
refreshing: Boolean,
onItemClick: (Manga) -> Unit,
onMoreClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
if (refreshing || items.isNotEmpty()) {
Header(
title = title,
loading = refreshing,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
) {
TextButton(
onClick = onMoreClick,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.secondary,
),
modifier = Modifier.alignBy(FirstBaseline),
) {
Text(text = stringResource(id = R.string.more))
}
}
}
if (items.isNotEmpty()) {
MangaCarousel(
items = items,
onItemClick = onItemClick,
modifier = Modifier
.testTag("search_carousel")
.fillMaxWidth(),
)
} else {
Card(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth()
.clip(MaterialTheme.shapes.extraLarge)
) {
EmptyScreen(
icon = Icons.Outlined.Creation,
title = R.string.nothing_here,
description = R.string.empty_carousel_hint,
modifier = Modifier.height(IntrinsicSize.Min)
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MangaCarousel(
items: List<Manga>,
onItemClick: (Manga) -> Unit,
modifier: Modifier = Modifier,
) {
val lazyListState = rememberLazyListState()
LazyRow(
state = lazyListState,
modifier = modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.clip(MaterialTheme.shapes.extraLarge),
flingBehavior = rememberSnapFlingBehavior(
snapLayoutInfoProvider = remember(lazyListState) {
SnapLayoutInfoProvider(
lazyListState = lazyListState,
snapPosition = SnapPosition.Start,
)
},
),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
items = items,
key = { it.id },
) { item ->
BackdropCard(
manga = item,
onClick = { onItemClick(item) },
alignment = remember {
ParallaxAlignment(
horizontalBias = {
val layoutInfo = lazyListState.layoutInfo
val itemInfo = layoutInfo.visibleItemsInfo.first {
it.key == item.id
}
val adjustedOffset = itemInfo.offset - layoutInfo.viewportStartOffset
(adjustedOffset / itemInfo.size.toFloat()).coerceIn(-1f, 1f)
},
)
},
modifier = Modifier
.testTag("search_carousel_item")
.animateItem()
.width(156.dp)
.aspectRatio(2 / 3f),
)
}
}
}
@Composable
fun BackdropCard(
manga: Manga,
onClick: () -> Unit,
modifier: Modifier = Modifier,
alignment: Alignment = Alignment.Center,
) {
Card(
onClick = onClick,
shape = MaterialTheme.shapes.extraLarge,
modifier = modifier,
) {
BackdropCardContent(
manga = manga,
alignment = alignment,
)
}
}
@Composable
private fun BackdropCardContent(
manga: Manga,
alignment: Alignment = Alignment.Center,
) {
Box(modifier = Modifier.fillMaxSize()) {
AsyncImage(
imageLoader = LocalImageLoader.current,
model = manga.largeCoverUrl ?: manga.coverUrl,
contentDescription = null,
modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Crop,
alignment = alignment,
)
Spacer(
Modifier
.matchParentSize()
.drawForegroundGradientScrim(MaterialTheme.colorScheme.surfaceDim),
)
Text(
text = manga.title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(16.dp)
.align(Alignment.BottomStart),
)
}
}
@Stable
class ParallaxAlignment(
private val horizontalBias: () -> Float = { 0f },
private val verticalBias: () -> Float = { 0f },
) : Alignment {
override fun align(
size: IntSize,
space: IntSize,
layoutDirection: LayoutDirection,
): IntOffset {
// Convert to Px first and only round at the end, to avoid rounding twice while calculating
// the new positions
val centerX = (space.width - size.width).toFloat() / 2f
val centerY = (space.height - size.height).toFloat() / 2f
val resolvedHorizontalBias = if (layoutDirection == LayoutDirection.Ltr) {
horizontalBias()
} else {
-1 * horizontalBias()
}
val x = centerX * (1 + resolvedHorizontalBias)
val y = centerY * (1 + verticalBias())
return IntOffset(x.roundToInt(), y.roundToInt())
}
}
/**
* Draws a vertical gradient scrim in the foreground.
*
* @param color The color of the gradient scrim.
* @param decay The exponential decay to apply to the gradient. Defaults to `3.0f` which is
* a cubic decay.
* @param numStops The number of color stops to draw in the gradient. Higher numbers result in
* the higher visual quality at the cost of draw performance. Defaults to `16`.
*/
fun Modifier.drawForegroundGradientScrim(
color: Color,
decay: Float = 1.0f,
numStops: Int = 16,
startY: Float = 0f,
endY: Float = 1f,
): Modifier = composed {
val colors = remember(color, numStops) {
val baseAlpha = color.alpha
List(numStops) { i ->
val x = i * 1f / (numStops - 1)
val opacity = x.pow(decay)
color.copy(alpha = baseAlpha * opacity)
}
}
drawWithContent {
drawContent()
drawRect(
topLeft = Offset(x = 0f, y = startY * size.height),
size = size.copy(height = (endY - startY) * size.height),
brush = Brush.verticalGradient(colors = colors),
)
}
}

View File

@@ -1,4 +1,4 @@
package org.xtimms.tokusho.core.components
package org.xtimms.shirizu.core.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth

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,176 @@
package org.xtimms.shirizu.core.components
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModernDetailsToolbar(
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())
)
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ClassicDetailsToolbar(
title: String,
titleAlphaProvider: () -> Float,
navigateBack: () -> Unit,
modifier: Modifier = Modifier,
backgroundAlphaProvider: () -> Float = titleAlphaProvider
) {
var expanded by remember { mutableStateOf(false) }
Column(
modifier = modifier
) {
TopAppBar(
navigationIcon = {
BackIconButton(
onClick = navigateBack
)
},
title = {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = LocalContentColor.current.copy(alpha = titleAlphaProvider()),
)
},
actions = {
IconButton(
onClick = { expanded = true }
) {
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)
}
)
}
},
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,328 @@
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.BasicAlertDialog
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()
) {
BasicAlertDialog(
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()
) {
BasicAlertDialog(
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

@@ -1,4 +1,4 @@
package org.xtimms.tokusho.core.components
package org.xtimms.shirizu.core.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
@@ -15,23 +15,24 @@ 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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExploreButton(
text: String,
icon: ImageVector,
icon: Any? = null,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Card(
onClick = onClick,
modifier = modifier.padding(start = 8.dp, end = 8.dp),
shape = RoundedCornerShape(50),
shape = RoundedCornerShape(25),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)
)
@@ -42,14 +43,29 @@ fun ExploreButton(
.height(40.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = text,
modifier = Modifier
.padding(end = 16.dp)
.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
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,

View File

@@ -0,0 +1,39 @@
package org.xtimms.shirizu.core.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun FilterSortPanel(
filterExpanded: Boolean,
filterIcon: @Composable () -> Unit,
filterTextField: @Composable () -> Unit,
modifier: Modifier = Modifier,
content: @Composable RowScope.() -> Unit,
) {
Column(modifier = modifier) {
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AnimatedVisibility(!filterExpanded) {
filterIcon()
}
content()
}
AnimatedVisibility(visible = filterExpanded) {
filterTextField()
}
}
}

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,58 @@
package org.xtimms.shirizu.core.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun FastScrollLazyVerticalGrid(
columns: GridCells,
modifier: Modifier = Modifier,
state: LazyGridState = rememberLazyGridState(),
thumbAllowed: () -> Boolean = { true },
thumbColor: Color = MaterialTheme.colorScheme.primary,
contentPadding: PaddingValues = PaddingValues(0.dp),
topContentPadding: Dp = Dp.Hairline,
bottomContentPadding: Dp = Dp.Hairline,
endContentPadding: Dp = Dp.Hairline,
reverseLayout: Boolean = false,
verticalArrangement: Arrangement.Vertical =
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
userScrollEnabled: Boolean = true,
content: LazyGridScope.() -> Unit,
) {
VerticalGridFastScroller(
state = state,
columns = columns,
arrangement = horizontalArrangement,
contentPadding = contentPadding,
modifier = modifier,
thumbAllowed = thumbAllowed,
thumbColor = thumbColor,
topContentPadding = topContentPadding,
bottomContentPadding = bottomContentPadding,
endContentPadding = endContentPadding,
) {
LazyVerticalGrid(
columns = columns,
state = state,
contentPadding = contentPadding,
reverseLayout = reverseLayout,
verticalArrangement = verticalArrangement,
horizontalArrangement = horizontalArrangement,
userScrollEnabled = userScrollEnabled,
content = content,
)
}
}

View File

@@ -0,0 +1,87 @@
package org.xtimms.shirizu.core.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import org.xtimms.shirizu.utils.composable.drawVerticalScrollbar
/**
* LazyColumn with scrollbar.
*/
@Composable
fun ScrollbarLazyColumn(
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(0.dp),
reverseLayout: Boolean = false,
verticalArrangement: Arrangement.Vertical =
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
userScrollEnabled: Boolean = true,
content: LazyListScope.() -> Unit,
) {
val direction = LocalLayoutDirection.current
val density = LocalDensity.current
val positionOffset = remember(contentPadding) {
with(density) { contentPadding.calculateEndPadding(direction).toPx() }
}
LazyColumn(
modifier = modifier
.drawVerticalScrollbar(
state = state,
reverseScrolling = reverseLayout,
positionOffsetPx = positionOffset,
),
state = state,
contentPadding = contentPadding,
reverseLayout = reverseLayout,
verticalArrangement = verticalArrangement,
horizontalAlignment = horizontalAlignment,
userScrollEnabled = userScrollEnabled,
content = content,
)
}
/**
* LazyColumn with fast scroller.
*/
@Composable
fun FastScrollLazyColumn(
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(0.dp),
reverseLayout: Boolean = false,
verticalArrangement: Arrangement.Vertical =
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
userScrollEnabled: Boolean = true,
content: LazyListScope.() -> Unit,
) {
VerticalFastScroller(
listState = state,
modifier = modifier,
topContentPadding = contentPadding.calculateTopPadding(),
endContentPadding = contentPadding.calculateEndPadding(LocalLayoutDirection.current),
) {
LazyColumn(
state = state,
contentPadding = contentPadding,
reverseLayout = reverseLayout,
verticalArrangement = verticalArrangement,
horizontalAlignment = horizontalAlignment,
userScrollEnabled = userScrollEnabled,
content = content,
)
}
}

View File

@@ -0,0 +1,60 @@
package org.xtimms.shirizu.core.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun Header(
title: String,
modifier: Modifier = Modifier,
loading: Boolean = false,
content: @Composable RowScope.() -> Unit = {},
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
)
Spacer(Modifier.weight(1f))
AnimatedVisibility(visible = loading) {
AutoSizedCircularProgressIndicator(
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(16.dp),
)
}
content()
}
}
@Composable
fun ListGroupHeader(
text: String,
modifier: Modifier = Modifier,
) {
Text(
text = text,
modifier = modifier
.padding(
horizontal = 16.dp,
vertical = 8.dp,
),
style = MaterialTheme.typography.bodyLarge,
)
}

View File

@@ -1,6 +1,5 @@
package org.xtimms.tokusho.core.components
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
@@ -9,30 +8,35 @@ 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.graphics.painter.ColorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import coil.ImageLoader
import coil.compose.AsyncImage
import org.xtimms.tokusho.core.AsyncImageImpl
import coil.request.ImageRequest
import org.xtimms.shirizu.LocalImageLoader
import org.xtimms.shirizu.R
import org.xtimms.shirizu.utils.composable.rememberResourceBitmapPainter
enum class MangaCover(val ratio: Float) {
Square(1f / 1f),
Book(10f / 16f),
Book(2f / 3f),
;
@Composable
operator fun invoke(
coil: ImageLoader,
data: String,
data: Any?,
modifier: Modifier = Modifier,
contentDescription: String = "",
shape: Shape = MaterialTheme.shapes.small,
onClick: (() -> Unit)? = null,
) {
AsyncImageImpl(
coil = coil,
AsyncImage(
imageLoader = LocalImageLoader.current,
model = data,
placeholder = ColorPainter(CoverPlaceholderColor),
error = rememberResourceBitmapPainter(id = R.drawable.cover_error),
contentDescription = contentDescription,
modifier = modifier
.aspectRatio(ratio)

View File

@@ -1,4 +1,4 @@
package org.xtimms.tokusho.core.components
package org.xtimms.shirizu.core.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
@@ -6,14 +6,16 @@ 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.RowScope
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.Card
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -21,62 +23,91 @@ 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.alpha
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.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.ImageLoader
import org.xtimms.tokusho.core.AsyncImageImpl
import org.xtimms.tokusho.ui.theme.TokushoTheme
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.shirizu.core.ShirizuAsyncImage
private const val GridSelectedCoverAlpha = 0.76f
/**
* Layout of grid list item with title overlaying the cover.
* Accepts null [title] for a cover-only view.
*/
@Composable
fun MangaCompactGridItem(
coil: ImageLoader,
imageUrl: String,
fun MangaGridItem(
manga: Manga,
title: String,
onClick: () -> Unit,
onLongClick: () -> Unit,
isSelected: Boolean = false,
title: String? = null,
onClickContinueReading: (() -> Unit)? = null,
titleMaxLines: Int = 2,
coverAlpha: Float = 1f,
) {
GridItemSelectable(
isSelected = isSelected,
onClick = onClick,
onLongClick = onLongClick,
) {
Column {
MangaGridCover(
cover = {
MangaCover.Book(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp)
.clip(MaterialTheme.shapes.medium)
.alpha(if (isSelected) GridSelectedCoverAlpha else coverAlpha),
data = manga.largeCoverUrl ?: manga.coverUrl,
)
},
)
GridItemTitle(
modifier = Modifier.padding(4.dp),
title = title,
style = MaterialTheme.typography.titleSmall,
minLines = 2,
maxLines = titleMaxLines,
)
}
}
}
@Composable
fun MangaHorizontalItem(
manga: Manga,
onClick: (Manga) -> Unit,
onLongClick: () -> Unit,
isSelected: Boolean = false,
) {
GridItemSelectable(
isSelected = isSelected,
onClick = { onClick(manga) },
onLongClick = onLongClick,
modifier = Modifier.width(IntrinsicSize.Min)
) {
Column(
modifier = Modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Box {
AsyncImageImpl(
ShirizuAsyncImage(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp)
.clip(MaterialTheme.shapes.medium)
.aspectRatio(10F / 16F),
coil = coil,
model = imageUrl,
.aspectRatio(10F / 16F)
.height(156.dp),
model = manga.largeCoverUrl ?: manga.coverUrl,
contentDescription = null
)
}
Text(
text = title!!,
text = manga.title,
modifier = Modifier.padding(4.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 2,
@@ -93,7 +124,6 @@ fun MangaCompactGridItem(
private fun MangaGridCover(
modifier: Modifier = Modifier,
cover: @Composable BoxScope.() -> Unit = {},
content: @Composable (BoxScope.() -> Unit)? = null,
) {
Box(
modifier = modifier
@@ -101,7 +131,6 @@ private fun MangaGridCover(
.aspectRatio(MangaCover.Book.ratio),
) {
cover()
content?.invoke(this)
}
}
@@ -158,8 +187,6 @@ private fun GridItemTitle(
Text(
modifier = modifier,
text = title,
fontSize = 12.sp,
lineHeight = 18.sp,
minLines = minLines,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
@@ -206,18 +233,4 @@ private fun GridItemSelectable(
private fun Modifier.selectedOutline(
isSelected: Boolean,
color: Color,
) = this then drawBehind { if (isSelected) drawRect(color = color) }
@PreviewLightDark
@Composable
fun MangaGridItemPreview() {
TokushoTheme {
MangaCompactGridItem(
coil = ImageLoader(LocalContext.current),
imageUrl = "https://cdn.myanimelist.net/images/manga/2/170594l.jpg",
title = "Stub",
onClick = { },
onLongClick = { }
)
}
}
) = 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,
contentWindowInsets = { 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

@@ -1,4 +1,4 @@
package org.xtimms.tokusho.core.components
package org.xtimms.shirizu.core.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding

View File

@@ -1,4 +1,4 @@
package org.xtimms.tokusho.core.components
package org.xtimms.shirizu.core.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
@@ -23,6 +23,7 @@ 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.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Call
import androidx.compose.material.icons.outlined.Check
@@ -65,15 +66,15 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.xtimms.shiki.ui.theme.FixedAccentColors
import org.xtimms.tokusho.R
import org.xtimms.tokusho.ui.monet.LocalTonalPalettes
import org.xtimms.tokusho.ui.monet.TonalPalettes.Companion.toTonalPalettes
import org.xtimms.tokusho.ui.theme.PreviewThemeLight
import org.xtimms.tokusho.ui.theme.TokushoTheme
import org.xtimms.tokusho.ui.theme.applyOpacity
import org.xtimms.tokusho.ui.theme.preferenceTitle
import org.xtimms.tokusho.utils.FileSize
import org.xtimms.shirizu.ui.theme.FixedAccentColors
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.ShirizuAsyncImage
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
@@ -170,7 +171,6 @@ internal fun PreferenceItemTitle(
modifier: Modifier = Modifier,
text: String,
maxLines: Int = 2,
style: TextStyle = preferenceTitle,
enabled: Boolean,
color: Color = MaterialTheme.colorScheme.onBackground,
overflow: TextOverflow = TextOverflow.Ellipsis
@@ -179,7 +179,6 @@ internal fun PreferenceItemTitle(
modifier = modifier,
text = text,
maxLines = maxLines,
style = style,
color = color.applyOpacity(enabled),
overflow = overflow
)
@@ -349,7 +348,6 @@ fun PreferenceSwitchWithContainer(
Text(
text = title,
maxLines = 2,
style = preferenceTitle,
color = if (isChecked) FixedAccentColors.onPrimaryFixed else colorScheme.surface
)
}
@@ -607,7 +605,7 @@ fun PreferenceStorageHeader(
modifier = Modifier
.weight(1f)
.align(Alignment.Bottom)
.padding(PaddingValues(start = 4.dp, bottom = 8.dp)),
.padding(PaddingValues(start = 4.dp, bottom = 12.dp)),
label = "Unit"
) { targetUsed ->
Text(text = FileSize.BYTES.showUnit(LocalContext.current, targetUsed))
@@ -623,7 +621,7 @@ fun PreferenceStorageHeader(
},
modifier = Modifier
.align(Alignment.Bottom)
.padding(PaddingValues(bottom = 8.dp)),
.padding(PaddingValues(bottom = 12.dp)),
label = "Total used"
) { targetTotal ->
Text(
@@ -758,7 +756,7 @@ fun PreferenceStorageItem(
@Composable
@Preview(showBackground = true)
fun PreferenceStorageHeaderPreview() {
TokushoTheme {
ShirizuTheme {
PreferenceStorageHeader()
}
}
@@ -766,7 +764,7 @@ fun PreferenceStorageHeaderPreview() {
@Composable
@Preview(showBackground = true)
fun PreferenceStorageItemPreview() {
TokushoTheme {
ShirizuTheme {
PreferenceStorageItem(title = "Saved manga", icon = Icons.Outlined.Save, total = 0F)
}
}

View File

@@ -0,0 +1,245 @@
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 == true) 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 == null -> "null"
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,333 @@
package org.xtimms.shirizu.core.components
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.MutableWindowInsets
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.contentColorFor
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastMap
import androidx.compose.ui.util.fastMaxBy
import kotlin.math.max
/**
* From Mihon
*
* <a href="https://material.io/design/layout/understanding-layout.html" class="external" target="_blank">Material Design layout</a>.
*
* Scaffold implements the basic material design visual layout structure.
*
* This component provides API to put together several material components to construct your
* screen, by ensuring proper layout strategy for them and collecting necessary data so these
* components will work together correctly.
*
* Simple example of a Scaffold with [SmallTopAppBar], [FloatingActionButton]:
*
* @sample androidx.compose.material3.samples.SimpleScaffoldWithTopBar
*
* To show a [Snackbar], use [SnackbarHostState.showSnackbar].
*
* @sample androidx.compose.material3.samples.ScaffoldWithSimpleSnackbar
*
* @param modifier the [Modifier] to be applied to this scaffold
* @param topBar top app bar of the screen, typically a [SmallTopAppBar]
* @param startBar side bar on the start of the screen, typically a [NavigationRail]
* @param bottomBar bottom bar of the screen, typically a [NavigationBar]
* @param snackbarHost component to host [Snackbar]s that are pushed to be shown via
* [SnackbarHostState.showSnackbar], typically a [SnackbarHost]
* @param floatingActionButton Main action button of the screen, typically a [FloatingActionButton]
* @param floatingActionButtonPosition position of the FAB on the screen. See [FabPosition].
* @param containerColor the color used for the background of this scaffold. Use [Color.Transparent]
* to have no color.
* @param contentColor the preferred color for content inside this scaffold. Defaults to either the
* matching content color for [containerColor], or to the current [LocalContentColor] if
* [containerColor] is not a color from the theme.
* @param contentWindowInsets window insets to be passed to content slot via PaddingValues params.
* Scaffold will take the insets into account from the top/bottom only if the topBar/ bottomBar
* are not present, as the scaffold expect topBar/bottomBar to handle insets instead
* @param content content of the screen. The lambda receives a [PaddingValues] that should be
* applied to the content root via [Modifier.padding] and [Modifier.consumeWindowInsets] to
* properly offset top and bottom bars. If using [Modifier.verticalScroll], apply this modifier to
* the child of the scroll, and not on the scroll itself.
*/
@OptIn(ExperimentalLayoutApi::class)
@ExperimentalMaterial3Api
@Composable
fun Scaffold(
modifier: Modifier = Modifier,
topBarScrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(
rememberTopAppBarState(),
),
topBar: @Composable (TopAppBarScrollBehavior) -> Unit = {},
bottomBar: @Composable () -> Unit = {},
startBar: @Composable () -> Unit = {},
snackbarHost: @Composable () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {},
floatingActionButtonPosition: FabPosition = FabPosition.End,
containerColor: Color = MaterialTheme.colorScheme.background,
contentColor: Color = contentColorFor(containerColor),
contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
content: @Composable (PaddingValues) -> Unit,
) {
val remainingWindowInsets = remember { MutableWindowInsets() }
androidx.compose.material3.Surface(
modifier = Modifier
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
.onConsumedWindowInsetsChanged {
remainingWindowInsets.insets = contentWindowInsets.exclude(
it,
)
}
.then(modifier),
color = containerColor,
contentColor = contentColor,
) {
ScaffoldLayout(
fabPosition = floatingActionButtonPosition,
topBar = { topBar(topBarScrollBehavior) },
startBar = startBar,
bottomBar = bottomBar,
content = content,
snackbar = snackbarHost,
contentWindowInsets = remainingWindowInsets,
fab = floatingActionButton,
)
}
}
/**
* Layout for a [Scaffold]'s content.
*
* @param fabPosition [FabPosition] for the FAB (if present)
* @param topBar the content to place at the top of the [Scaffold], typically a [SmallTopAppBar]
* @param content the main 'body' of the [Scaffold]
* @param snackbar the [Snackbar] displayed on top of the [content]
* @param fab the [FloatingActionButton] displayed on top of the [content], below the [snackbar]
* and above the [bottomBar]
* @param bottomBar the content to place at the bottom of the [Scaffold], on top of the
* [content], typically a [NavigationBar].
*/
@Suppress("CyclomaticComplexMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ScaffoldLayout(
fabPosition: FabPosition,
topBar: @Composable () -> Unit,
startBar: @Composable () -> Unit,
content: @Composable (PaddingValues) -> Unit,
snackbar: @Composable () -> Unit,
fab: @Composable () -> Unit,
contentWindowInsets: WindowInsets,
bottomBar: @Composable () -> Unit,
) {
SubcomposeLayout { constraints ->
val layoutWidth = constraints.maxWidth
val layoutHeight = constraints.maxHeight
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val topBarConstraints = looseConstraints.copy(maxHeight = Constraints.Infinity)
layout(layoutWidth, layoutHeight) {
val leftInset = contentWindowInsets.getLeft(this@SubcomposeLayout, layoutDirection)
val rightInset = contentWindowInsets.getRight(this@SubcomposeLayout, layoutDirection)
val bottomInset = contentWindowInsets.getBottom(this@SubcomposeLayout)
val startBarPlaceables = subcompose(ScaffoldLayoutContent.StartBar, startBar).fastMap {
it.measure(looseConstraints)
}
val startBarWidth = startBarPlaceables.fastMaxBy { it.width }?.width ?: 0
val insetLayoutWidth = layoutWidth - leftInset - rightInset - startBarWidth
val topBarPlaceables = subcompose(ScaffoldLayoutContent.TopBar, topBar).fastMap {
it.measure(topBarConstraints)
}
val topBarHeight = topBarPlaceables.fastMaxBy { it.height }?.height ?: 0
val snackbarPlaceables = subcompose(ScaffoldLayoutContent.Snackbar, snackbar).fastMap {
it.measure(looseConstraints)
}
val snackbarHeight = snackbarPlaceables.fastMaxBy { it.height }?.height ?: 0
val snackbarWidth = snackbarPlaceables.fastMaxBy { it.width }?.width ?: 0
val snackbarLeft = if (snackbarPlaceables.isNotEmpty()) {
(insetLayoutWidth - snackbarWidth) / 2 + leftInset
} else {
0
}
val fabPlaceables =
subcompose(ScaffoldLayoutContent.Fab, fab).fastMap { measurable ->
measurable.measure(looseConstraints)
}
val fabWidth = fabPlaceables.fastMaxBy { it.width }?.width ?: 0
val fabHeight = fabPlaceables.fastMaxBy { it.height }?.height ?: 0
val fabPlacement = if (fabPlaceables.isNotEmpty() && fabWidth != 0 && fabHeight != 0) {
// FAB distance from the left of the layout, taking into account LTR / RTL
val fabLeftOffset = if (fabPosition == FabPosition.End) {
if (layoutDirection == LayoutDirection.Ltr) {
layoutWidth - FabSpacing.roundToPx() - fabWidth - rightInset
} else {
FabSpacing.roundToPx() + leftInset
}
} else {
leftInset + ((insetLayoutWidth - fabWidth) / 2)
}
FabPlacement(
left = fabLeftOffset,
width = fabWidth,
height = fabHeight,
)
} else {
null
}
val bottomBarPlaceables = subcompose(ScaffoldLayoutContent.BottomBar) {
bottomBar()
}.fastMap { it.measure(looseConstraints) }
val bottomBarHeight = bottomBarPlaceables
.fastMaxBy { it.height }
?.height
?.takeIf { it != 0 }
val fabOffsetFromBottom = fabPlacement?.let {
max(bottomBarHeight ?: 0, bottomInset) + it.height + FabSpacing.roundToPx()
}
val snackbarOffsetFromBottom = if (snackbarHeight != 0) {
snackbarHeight + (fabOffsetFromBottom ?: max(bottomBarHeight ?: 0, bottomInset))
} else {
0
}
val bodyContentPlaceables = subcompose(ScaffoldLayoutContent.MainContent) {
val insets = contentWindowInsets.asPaddingValues(this@SubcomposeLayout)
val fabOffsetDp = fabOffsetFromBottom?.toDp() ?: 0.dp
val bottomBarHeightPx = bottomBarHeight ?: 0
val innerPadding = PaddingValues(
top =
if (topBarPlaceables.isEmpty()) {
insets.calculateTopPadding()
} else {
topBarHeight.toDp()
},
bottom =
if (bottomBarPlaceables.isEmpty() || bottomBarHeightPx == 0) {
max(insets.calculateBottomPadding(), fabOffsetDp)
} else {
max(bottomBarHeightPx.toDp(), fabOffsetDp)
},
start = max(
insets.calculateStartPadding((this@SubcomposeLayout).layoutDirection),
startBarWidth.toDp(),
),
end = insets.calculateEndPadding((this@SubcomposeLayout).layoutDirection),
)
content(innerPadding)
}.fastMap { it.measure(looseConstraints) }
// Placing to control drawing order to match default elevation of each placeable
bodyContentPlaceables.fastForEach {
it.place(0, 0)
}
startBarPlaceables.fastForEach {
it.placeRelative(0, 0)
}
topBarPlaceables.fastForEach {
it.place(0, 0)
}
snackbarPlaceables.fastForEach {
it.place(
snackbarLeft,
layoutHeight - snackbarOffsetFromBottom,
)
}
// The bottom bar is always at the bottom of the layout
bottomBarPlaceables.fastForEach {
it.place(0, layoutHeight - (bottomBarHeight ?: 0))
}
// Explicitly not using placeRelative here as `leftOffset` already accounts for RTL
fabPlaceables.fastForEach {
it.place(fabPlacement?.left ?: 0, layoutHeight - (fabOffsetFromBottom ?: 0))
}
}
}
}
/**
* The possible positions for a [FloatingActionButton] attached to a [Scaffold].
*/
@ExperimentalMaterial3Api
@JvmInline
value class FabPosition internal constructor(@Suppress("unused") private val value: Int) {
companion object {
/**
* Position FAB at the bottom of the screen in the center, above the [NavigationBar] (if it
* exists)
*/
val Center = FabPosition(0)
/**
* Position FAB at the bottom of the screen at the end, above the [NavigationBar] (if it
* exists)
*/
val End = FabPosition(1)
}
override fun toString(): String {
return when (this) {
Center -> "FabPosition.Center"
else -> "FabPosition.End"
}
}
}
/**
* Placement information for a [FloatingActionButton] inside a [Scaffold].
*
* @property left the FAB's offset from the left edge of the bottom bar, already adjusted for RTL
* support
* @property width the width of the FAB
* @property height the height of the FAB
*/
@Immutable
internal class FabPlacement(
val left: Int,
val width: Int,
val height: Int,
)
// FAB spacing above the bottom bar / bottom of the Scaffold
private val FabSpacing = 16.dp
private enum class ScaffoldLayoutContent { TopBar, MainContent, Snackbar, Fab, BottomBar, StartBar }

View File

@@ -1,6 +1,7 @@
package org.xtimms.tokusho.core.components
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
@@ -21,7 +22,9 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
fun ScaffoldWithTopAppBar(
title: String,
navigateBack: () -> Unit,
floatingActionButton: @Composable (() -> Unit) = {},
snackbarHost: @Composable () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {},
actions: @Composable (RowScope.() -> Unit) = {},
content: @Composable (PaddingValues) -> Unit
) {
val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
@@ -37,9 +40,11 @@ fun ScaffoldWithTopAppBar(
DefaultTopAppBar(
title = title,
scrollBehavior = topAppBarScrollBehavior,
actions = actions,
navigateBack = navigateBack
)
},
snackbarHost = snackbarHost,
floatingActionButton = floatingActionButton,
contentWindowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal),
content = content
@@ -84,21 +89,16 @@ 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),
org.xtimms.shirizu.core.components.Scaffold(
topBar = {
ClassicTopAppBar(
title = title,
scrollBehavior = topAppBarScrollBehavior,
scrollBehavior = it,
actions = actions,
navigateBack = navigateBack
)
},

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,63 @@
package org.xtimms.shirizu.core.components
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.TextFieldValue
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun SearchTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
hint: String,
modifier: Modifier = Modifier,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions(),
onCleared: (() -> Unit) = { onValueChange(TextFieldValue()) },
) {
val keyboardController = LocalSoftwareKeyboardController.current
OutlinedTextField(
value = value,
onValueChange = onValueChange,
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null, // decorative
)
},
trailingIcon = {
IconButton(
onClick = {
onCleared()
// This is mostly for iOS, otherwise there is no way to dismiss the iOS
// keyboard once opened.
keyboardController?.hide()
},
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = null,
)
}
},
placeholder = { Text(text = hint) },
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
maxLines = 1,
singleLine = true,
modifier = modifier,
)
}

View File

@@ -1,4 +1,4 @@
package org.xtimms.tokusho.core.components
package org.xtimms.shirizu.core.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
@@ -20,16 +20,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import org.xtimms.tokusho.ui.theme.applyOpacity
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),
.padding(horizontal = 16.dp, vertical = 16.dp),
text = text,
style = MaterialTheme.typography.displaySmall
style = MaterialTheme.typography.headlineLarge
)
}

View File

@@ -0,0 +1,78 @@
package org.xtimms.shirizu.core.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.ArrowDropUp
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import org.xtimms.shirizu.sections.library.history.SortOption
@Composable
fun SortChip(
sortOptions: List<SortOption>,
currentSortOption: SortOption,
modifier: Modifier = Modifier,
onSortSelected: (SortOption) -> Unit,
) {
Box(modifier) {
var expanded by remember { mutableStateOf(false) }
val arrowDrop = if (expanded) Icons.Outlined.ArrowDropUp else Icons.Outlined.ArrowDropDown
FilterChip(
selected = true,
onClick = { expanded = true },
label = {
AnimatedContent(targetState = currentSortOption.label(LocalContext.current.resources), label = "Text") {
Text(text = it)
}
},
leadingIcon = {
AnimatedContent(targetState = currentSortOption.icon(), label = "Icon") {
Icon(
imageVector = it,
contentDescription = null, // decorative
modifier = Modifier.size(16.dp),
)
}
},
trailingIcon = {
AnimatedContent(targetState = arrowDrop, label = "Arrow drop") {
Icon(
imageVector = it,
contentDescription = null, // decorative
modifier = Modifier.size(16.dp),
)
}
},
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
SortDropdownMenuContent(
sortOptions = sortOptions,
currentSortOption = currentSortOption,
onItemClick = {
onSortSelected(it)
expanded = false
},
)
}
}
}

View File

@@ -0,0 +1,51 @@
package org.xtimms.shirizu.core.components
import android.content.res.Resources
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowOutward
import androidx.compose.material.icons.outlined.DateRange
import androidx.compose.material.icons.outlined.SortByAlpha
import androidx.compose.material3.DropdownMenuItem
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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import org.xtimms.shirizu.R
import org.xtimms.shirizu.sections.library.history.SortOption
@Composable
internal fun ColumnScope.SortDropdownMenuContent(
sortOptions: List<SortOption>,
onItemClick: (SortOption) -> Unit,
modifier: Modifier = Modifier,
currentSortOption: SortOption? = null,
) {
val resources = LocalContext.current.resources
for (sort in sortOptions) {
DropdownMenuItem(
text = {
Text(
text = sort.label(resources),
fontWeight = if (sort == currentSortOption) FontWeight.Bold else null,
)
},
onClick = { onItemClick(sort) },
modifier = modifier,
)
}
}
internal fun SortOption.label(resources: Resources): String = when (this) {
SortOption.ALPHABETICAL -> resources.getString(R.string.sort_alphabetically)
SortOption.PROGRESS -> resources.getString(R.string.progress)
SortOption.DATE_ADDED -> resources.getString(R.string.sort_date_added)
}
internal fun SortOption.icon(): ImageVector = when (this) {
SortOption.ALPHABETICAL -> Icons.Outlined.SortByAlpha
SortOption.PROGRESS -> Icons.Outlined.ArrowOutward
SortOption.DATE_ADDED -> Icons.Outlined.DateRange
}

View File

@@ -1,4 +1,4 @@
package org.xtimms.tokusho.core.components
package org.xtimms.shirizu.core.components
import android.net.Uri
import androidx.compose.foundation.clickable
@@ -24,12 +24,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import coil.ImageLoader
import org.xtimms.tokusho.core.AsyncImageImpl
import org.xtimms.tokusho.ui.theme.TokushoTheme
import org.xtimms.shirizu.core.ShirizuAsyncImage
import org.xtimms.shirizu.ui.theme.ShirizuTheme
@Composable
fun SourceItem(
coil: ImageLoader,
faviconUrl: Uri,
title: String,
modifier: Modifier = Modifier,
@@ -38,24 +37,23 @@ fun SourceItem(
) {
Column(
modifier = modifier
.width(96.dp)
.width(88.dp)
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
.padding(start = 8.dp, end = 8.dp),
horizontalAlignment = Alignment.Start
) {
Card(
modifier = modifier
.size(96.dp)
modifier = Modifier
.size(88.dp)
.clip(MaterialTheme.shapes.large)
.aspectRatio(1f)
) {
AsyncImageImpl(
coil = coil,
ShirizuAsyncImage(
model = faviconUrl,
contentDescription = "favicon",
contentScale = ContentScale.Crop,
modifier = modifier
modifier = Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.large)
)
@@ -77,9 +75,8 @@ fun SourceItem(
@Preview
@Composable
fun SourceItemPreview() {
TokushoTheme {
ShirizuTheme {
SourceItem(
coil = ImageLoader(LocalContext.current),
faviconUrl = "".toUri(),
title = "Test",
onClick = { }

View File

@@ -1,4 +1,4 @@
package org.xtimms.tokusho.core.components
package org.xtimms.shirizu.core.components
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Row

View File

@@ -0,0 +1,594 @@
package org.xtimms.shirizu.core.components
import android.graphics.Path
import android.view.animation.PathInterpolator
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.interaction.MutableInteractionSource
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.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.QueryStats
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.LocalContentColor
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTooltipState
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.key
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.VisualTransformation
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 kotlinx.collections.immutable.ImmutableList
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.components.icons.Shirizu
import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.ui.theme.ShirizuTheme
import org.xtimms.shirizu.utils.composable.secondaryItemAlpha
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppBar(
title: String?,
modifier: Modifier = Modifier,
backgroundColor: Color? = null,
// Text
subtitle: String? = null,
// Up button
navigateUp: (() -> Unit)? = null,
navigationIcon: ImageVector? = null,
// Menu
actions: @Composable RowScope.() -> Unit = {},
// Action mode
actionModeCounter: Int = 0,
onCancelActionMode: () -> Unit = {},
actionModeActions: @Composable RowScope.() -> Unit = {},
scrollBehavior: TopAppBarScrollBehavior? = null,
) {
val isActionMode by remember(actionModeCounter) {
derivedStateOf { actionModeCounter > 0 }
}
AppBar(
modifier = modifier,
backgroundColor = backgroundColor,
titleContent = {
if (isActionMode) {
AppBarTitle(actionModeCounter.toString())
} else {
AppBarTitle(title, subtitle = subtitle)
}
},
navigateUp = navigateUp,
navigationIcon = navigationIcon,
actions = {
if (isActionMode) {
actionModeActions()
} else {
actions()
}
},
isActionMode = isActionMode,
onCancelActionMode = onCancelActionMode,
scrollBehavior = scrollBehavior,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppBar(
// Title
titleContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
backgroundColor: Color? = null,
// Up button
navigateUp: (() -> Unit)? = null,
navigationIcon: ImageVector? = null,
// Menu
actions: @Composable RowScope.() -> Unit = {},
// Action mode
isActionMode: Boolean = false,
onCancelActionMode: () -> Unit = {},
scrollBehavior: TopAppBarScrollBehavior? = null,
) {
Column(
modifier = modifier,
) {
androidx.compose.material3.TopAppBar(
navigationIcon = {
if (isActionMode) {
IconButton(onClick = onCancelActionMode) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = stringResource(R.string.cancel),
)
}
} else {
navigateUp?.let {
IconButton(onClick = it) {
UpIcon(navigationIcon = navigationIcon)
}
}
}
},
title = titleContent,
actions = actions,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = backgroundColor
?: MaterialTheme.colorScheme.surfaceColorAtElevation(
elevation = if (isActionMode) 3.dp else 0.dp,
),
),
scrollBehavior = scrollBehavior,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppToolbar(
modifier: Modifier = Modifier,
actions: @Composable RowScope.() -> Unit = {},
scrollBehavior: TopAppBarScrollBehavior? = null,
) {
AppBar(
modifier = modifier,
titleContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
modifier = Modifier.size(22.dp),
imageVector = Icons.Filled.Shirizu,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(id = R.string.app_name),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight(900)
)
}
},
actions = { key("actions") { actions() } },
isActionMode = false,
scrollBehavior = scrollBehavior,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppBarActions(
actions: ImmutableList<AppBar.AppBarAction>,
) {
var showMenu by remember { mutableStateOf(false) }
actions.filterIsInstance<AppBar.Action>().map {
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(it.title)
}
},
state = rememberTooltipState(),
) {
IconButton(
onClick = it.onClick,
enabled = it.enabled,
) {
Icon(
imageVector = it.icon,
tint = it.iconTint ?: LocalContentColor.current,
contentDescription = it.title,
)
}
}
}
val overflowActions = actions.filterIsInstance<AppBar.OverflowAction>()
if (overflowActions.isNotEmpty()) {
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(stringResource(R.string.action_menu_overflow_description))
}
},
state = rememberTooltipState(),
) {
IconButton(
onClick = { showMenu = !showMenu },
) {
Icon(
Icons.Outlined.MoreVert,
contentDescription = stringResource(R.string.action_menu_overflow_description),
)
}
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
overflowActions.map {
DropdownMenuItem(
onClick = {
it.onClick()
showMenu = false
},
text = { Text(it.title, fontWeight = FontWeight.Normal) },
)
}
}
}
}
@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) },
)
}
}
}
}
}
private val path = Path().apply {
moveTo(0f, 0f)
lineTo(0.7f, 0.1f)
cubicTo(0.7f, 0.1f, .95F, .5F, 1F, 1F)
moveTo(1f, 1f)
}
val fraction: (Float) -> Float = { PathInterpolator(path).getInterpolation(it) }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SmallTopAppBar(
modifier: Modifier = Modifier,
titleText: String = "",
scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),
title: @Composable () -> Unit = {
Text(
text = titleText,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = fraction(scrollBehavior.state.overlappedFraction)),
maxLines = 1
)
},
navigationIcon: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
) {
androidx.compose.material3.TopAppBar(
modifier = modifier,
title = title,
navigationIcon = navigationIcon,
actions = actions,
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,
),
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable
fun SearchToolbar(
searchQuery: String?,
onChangeSearchQuery: (String?) -> Unit,
modifier: Modifier = Modifier,
titleContent: @Composable () -> Unit = {},
navigateUp: (() -> Unit)? = null,
searchEnabled: Boolean = true,
placeholderText: String? = null,
onSearch: (String) -> Unit = {},
onClickCloseSearch: () -> Unit = { onChangeSearchQuery(null) },
actions: @Composable RowScope.() -> Unit = {},
scrollBehavior: TopAppBarScrollBehavior? = null,
visualTransformation: VisualTransformation = VisualTransformation.None,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val focusRequester = remember { FocusRequester() }
AppBar(
modifier = modifier,
titleContent = {
if (searchQuery == null) return@AppBar titleContent()
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val searchAndClearFocus: () -> Unit = f@{
if (searchQuery.isBlank()) return@f
onSearch(searchQuery)
focusManager.clearFocus()
keyboardController?.hide()
}
BasicTextField(
value = searchQuery,
onValueChange = onChangeSearchQuery,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
textStyle = MaterialTheme.typography.titleMedium.copy(
color = MaterialTheme.colorScheme.onBackground,
fontWeight = FontWeight.Normal,
fontSize = 18.sp,
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { searchAndClearFocus() }),
singleLine = true,
cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground),
visualTransformation = visualTransformation,
interactionSource = interactionSource,
decorationBox = { innerTextField ->
TextFieldDefaults.TextFieldDecorationBox(
value = searchQuery,
innerTextField = innerTextField,
enabled = true,
singleLine = true,
visualTransformation = visualTransformation,
interactionSource = interactionSource,
placeholder = {
Text(
modifier = Modifier.secondaryItemAlpha(),
text = (placeholderText ?: stringResource(R.string.search)),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleMedium.copy(
fontSize = 18.sp,
fontWeight = FontWeight.Normal,
),
)
},
)
},
)
},
navigateUp = if (searchQuery == null) navigateUp else onClickCloseSearch,
actions = {
key("search") {
val onClick = { onChangeSearchQuery("") }
if (!searchEnabled) {
// Don't show search action
} else if (searchQuery == null) {
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(stringResource(R.string.search))
}
},
state = rememberTooltipState(),
) {
IconButton(
onClick = onClick,
) {
Icon(
Icons.Outlined.Search,
contentDescription = stringResource(R.string.search),
)
}
}
} else if (searchQuery.isNotEmpty()) {
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(stringResource(R.string.action_reset))
}
},
state = rememberTooltipState(),
) {
IconButton(
onClick = {
onClick()
focusRequester.requestFocus()
},
) {
Icon(
Icons.Outlined.Close,
contentDescription = stringResource(R.string.action_reset),
)
}
}
}
}
key("actions") { actions() }
},
isActionMode = false,
scrollBehavior = scrollBehavior,
)
}
@Composable
fun UpIcon(
modifier: Modifier = Modifier,
navigationIcon: ImageVector? = null,
) {
val icon = navigationIcon
?: Icons.AutoMirrored.Outlined.ArrowBack
Icon(
imageVector = icon,
contentDescription = null,
modifier = modifier,
)
}
sealed interface AppBar {
sealed interface AppBarAction
data class Action(
val title: String,
val icon: ImageVector,
val iconTint: Color? = null,
val onClick: () -> Unit,
val enabled: Boolean = true,
) : AppBarAction
data class OverflowAction(
val title: String,
val onClick: () -> Unit,
) : AppBarAction
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
fun SearchToolbarPreview() {
ShirizuTheme {
AppToolbar()
}
}

View File

@@ -0,0 +1,451 @@
package org.xtimms.shirizu.core.components
import android.view.ViewConfiguration
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.systemGestureExclusion
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastFirstOrNull
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastMaxBy
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import org.xtimms.shirizu.core.components.Scroller.STICKY_HEADER_KEY_PREFIX
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
/**
* Draws vertical fast scroller to a lazy list
*
* Set key with [STICKY_HEADER_KEY_PREFIX] prefix to any sticky header item in the list.
*/
@Composable
fun VerticalFastScroller(
listState: LazyListState,
modifier: Modifier = Modifier,
thumbAllowed: () -> Boolean = { true },
thumbColor: Color = MaterialTheme.colorScheme.primary,
topContentPadding: Dp = Dp.Hairline,
bottomContentPadding: Dp = Dp.Hairline,
endContentPadding: Dp = Dp.Hairline,
content: @Composable () -> Unit,
) {
SubcomposeLayout(modifier = modifier) { constraints ->
val contentPlaceable = subcompose("content", content).map { it.measure(constraints) }
val contentHeight = contentPlaceable.fastMaxBy { it.height }?.height ?: 0
val contentWidth = contentPlaceable.fastMaxBy { it.width }?.width ?: 0
val scrollerConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val scrollerPlaceable = subcompose("scroller") {
val layoutInfo = listState.layoutInfo
val showScroller = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount
if (!showScroller) return@subcompose
val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() }
var thumbOffsetY by remember(thumbTopPadding) { mutableFloatStateOf(thumbTopPadding) }
val dragInteractionSource = remember { MutableInteractionSource() }
val isThumbDragged by dragInteractionSource.collectIsDraggedAsState()
val scrolled = remember {
MutableSharedFlow<Unit>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
}
val thumbBottomPadding = with(LocalDensity.current) { bottomContentPadding.toPx() }
val heightPx = contentHeight.toFloat() -
thumbTopPadding -
thumbBottomPadding -
listState.layoutInfo.afterContentPadding
val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() }
val trackHeightPx = heightPx - thumbHeightPx
// When thumb dragged
LaunchedEffect(thumbOffsetY) {
if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect
val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx
val scrollItem = layoutInfo.totalItemsCount * scrollRatio
val scrollItemRounded = scrollItem.roundToInt()
val scrollItemSize = layoutInfo.visibleItemsInfo.find { it.index == scrollItemRounded }?.size ?: 0
val scrollItemOffset = scrollItemSize * (scrollItem - scrollItemRounded)
listState.scrollToItem(index = scrollItemRounded, scrollOffset = scrollItemOffset.roundToInt())
scrolled.tryEmit(Unit)
}
// When list scrolled
LaunchedEffect(listState.firstVisibleItemScrollOffset) {
if (listState.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect
val scrollOffset = computeScrollOffset(state = listState)
val scrollRange = computeScrollRange(state = listState)
val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx)
thumbOffsetY = trackHeightPx * proportion + thumbTopPadding
scrolled.tryEmit(Unit)
}
// Thumb alpha
val alpha = remember { Animatable(0f) }
val isThumbVisible = alpha.value > 0f
LaunchedEffect(scrolled, alpha) {
scrolled
.sample(100)
.collectLatest {
if (thumbAllowed()) {
alpha.snapTo(1f)
alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec)
} else {
alpha.animateTo(0f, animationSpec = ImmediateFadeOutAnimationSpec)
}
}
}
Box(
modifier = Modifier
.offset { IntOffset(0, thumbOffsetY.roundToInt()) }
.then(
// Recompose opts
if (isThumbVisible && !listState.isScrollInProgress) {
Modifier.draggable(
interactionSource = dragInteractionSource,
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
val newOffsetY = thumbOffsetY + delta
thumbOffsetY = newOffsetY.coerceIn(
thumbTopPadding,
thumbTopPadding + trackHeightPx,
)
},
)
} else {
Modifier
},
)
.then(
// Exclude thumb from gesture area only when needed
if (isThumbVisible && !isThumbDragged && !listState.isScrollInProgress) {
Modifier.systemGestureExclusion()
} else {
Modifier
},
)
.height(ThumbLength)
.padding(horizontal = 8.dp)
.padding(end = endContentPadding)
.width(ThumbThickness)
.alpha(alpha.value)
.background(color = thumbColor, shape = ThumbShape),
)
}.map { it.measure(scrollerConstraints) }
val scrollerWidth = scrollerPlaceable.fastMaxBy { it.width }?.width ?: 0
layout(contentWidth, contentHeight) {
contentPlaceable.fastForEach {
it.place(0, 0)
}
scrollerPlaceable.fastForEach {
it.placeRelative(contentWidth - scrollerWidth, 0)
}
}
}
}
@Composable
private fun rememberColumnWidthSums(
columns: GridCells,
horizontalArrangement: Arrangement.Horizontal,
contentPadding: PaddingValues,
) = remember<Density.(Constraints) -> List<Int>>(
columns,
horizontalArrangement,
contentPadding,
) {
{
constraints ->
require(constraints.maxWidth != Constraints.Infinity) {
"LazyVerticalGrid's width should be bound by parent"
}
val horizontalPadding = contentPadding.calculateStartPadding(LayoutDirection.Ltr) +
contentPadding.calculateEndPadding(LayoutDirection.Ltr)
val gridWidth = constraints.maxWidth - horizontalPadding.roundToPx()
with(columns) {
calculateCrossAxisCellSizes(
gridWidth,
horizontalArrangement.spacing.roundToPx(),
).toMutableList().apply {
for (i in 1..<size) {
this[i] += this[i - 1]
}
}
}
}
}
@OptIn(FlowPreview::class)
@Composable
fun VerticalGridFastScroller(
state: LazyGridState,
columns: GridCells,
arrangement: Arrangement.Horizontal,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
thumbAllowed: () -> Boolean = { true },
thumbColor: Color = MaterialTheme.colorScheme.primary,
topContentPadding: Dp = Dp.Hairline,
bottomContentPadding: Dp = Dp.Hairline,
endContentPadding: Dp = Dp.Hairline,
content: @Composable () -> Unit,
) {
val slotSizesSums = rememberColumnWidthSums(
columns = columns,
horizontalArrangement = arrangement,
contentPadding = contentPadding,
)
SubcomposeLayout(modifier = modifier) { constraints ->
val contentPlaceable = subcompose("content", content).map { it.measure(constraints) }
val contentHeight = contentPlaceable.fastMaxBy { it.height }?.height ?: 0
val contentWidth = contentPlaceable.fastMaxBy { it.width }?.width ?: 0
val scrollerConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val scrollerPlaceable = subcompose("scroller") {
val layoutInfo = state.layoutInfo
val showScroller = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount
if (!showScroller) return@subcompose
val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() }
var thumbOffsetY by remember(thumbTopPadding) { mutableFloatStateOf(thumbTopPadding) }
val dragInteractionSource = remember { MutableInteractionSource() }
val isThumbDragged by dragInteractionSource.collectIsDraggedAsState()
val scrolled = remember {
MutableSharedFlow<Unit>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
}
val thumbBottomPadding = with(LocalDensity.current) { bottomContentPadding.toPx() }
val heightPx = contentHeight.toFloat() -
thumbTopPadding -
thumbBottomPadding -
state.layoutInfo.afterContentPadding
val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() }
val trackHeightPx = heightPx - thumbHeightPx
val columnCount = remember { slotSizesSums(constraints).size }
// When thumb dragged
LaunchedEffect(thumbOffsetY) {
if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect
val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx
val scrollItem = layoutInfo.totalItemsCount * scrollRatio
// I can't think of anything else rn but this'll do
val scrollItemWhole = scrollItem.toInt()
val columnNum = ((scrollItemWhole + 1) % columnCount).takeIf { it != 0 } ?: columnCount
val scrollItemFraction = if (scrollItemWhole == 0) scrollItem else scrollItem % scrollItemWhole
val offsetPerItem = 1f / columnCount
val offsetRatio = (offsetPerItem * scrollItemFraction) + (offsetPerItem * (columnNum - 1))
// TODO: Sometimes item height is not available when scrolling up
val scrollItemSize = (1..columnCount).maxOf { num ->
val actualIndex = if (num != columnNum) {
scrollItemWhole + num - columnCount
} else {
scrollItemWhole
}
layoutInfo.visibleItemsInfo.find { it.index == actualIndex }?.size?.height ?: 0
}
val scrollItemOffset = scrollItemSize * offsetRatio
state.scrollToItem(index = scrollItemWhole, scrollOffset = scrollItemOffset.roundToInt())
scrolled.tryEmit(Unit)
}
// When list scrolled
LaunchedEffect(state.firstVisibleItemScrollOffset) {
if (state.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect
val scrollOffset = computeScrollOffset(state = state)
val scrollRange = computeScrollRange(state = state)
val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx)
thumbOffsetY = trackHeightPx * proportion + thumbTopPadding
scrolled.tryEmit(Unit)
}
// Thumb alpha
val alpha = remember { Animatable(0f) }
val isThumbVisible = alpha.value > 0f
LaunchedEffect(scrolled, alpha) {
scrolled
.sample(100)
.collectLatest {
if (thumbAllowed()) {
alpha.snapTo(1f)
alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec)
} else {
alpha.animateTo(0f, animationSpec = ImmediateFadeOutAnimationSpec)
}
}
}
Box(
modifier = Modifier
.offset { IntOffset(0, thumbOffsetY.roundToInt()) }
.then(
// Recompose opts
if (isThumbVisible && !state.isScrollInProgress) {
Modifier.draggable(
interactionSource = dragInteractionSource,
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
val newOffsetY = thumbOffsetY + delta
thumbOffsetY = newOffsetY.coerceIn(
thumbTopPadding,
thumbTopPadding + trackHeightPx,
)
},
)
} else {
Modifier
},
)
.then(
// Exclude thumb from gesture area only when needed
if (isThumbVisible && !isThumbDragged && !state.isScrollInProgress) {
Modifier.systemGestureExclusion()
} else {
Modifier
},
)
.height(ThumbLength)
.padding(end = endContentPadding)
.width(ThumbThickness)
.alpha(alpha.value)
.background(color = thumbColor, shape = ThumbShape),
)
}.map { it.measure(scrollerConstraints) }
val scrollerWidth = scrollerPlaceable.fastMaxBy { it.width }?.width ?: 0
layout(contentWidth, contentHeight) {
contentPlaceable.fastForEach {
it.place(0, 0)
}
scrollerPlaceable.fastForEach {
it.placeRelative(contentWidth - scrollerWidth, 0)
}
}
}
}
private fun computeScrollOffset(state: LazyGridState): Int {
if (state.layoutInfo.totalItemsCount == 0) return 0
val visibleItems = state.layoutInfo.visibleItemsInfo
val startChild = visibleItems.first()
val endChild = visibleItems.last()
val minPosition = min(startChild.index, endChild.index)
val maxPosition = max(startChild.index, endChild.index)
val itemsBefore = minPosition.coerceAtLeast(0)
val startDecoratedTop = startChild.offset.y
val laidOutArea = abs((endChild.offset.y + endChild.size.height) - startDecoratedTop)
val itemRange = abs(minPosition - maxPosition) + 1
val avgSizePerRow = laidOutArea.toFloat() / itemRange
return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt()
}
private fun computeScrollRange(state: LazyGridState): Int {
if (state.layoutInfo.totalItemsCount == 0) return 0
val visibleItems = state.layoutInfo.visibleItemsInfo
val startChild = visibleItems.first()
val endChild = visibleItems.last()
val laidOutArea = (endChild.offset.y + endChild.size.height) - startChild.offset.y
val laidOutRange = abs(startChild.index - endChild.index) + 1
return (laidOutArea.toFloat() / laidOutRange * state.layoutInfo.totalItemsCount).roundToInt()
}
private fun computeScrollOffset(state: LazyListState): Int {
if (state.layoutInfo.totalItemsCount == 0) return 0
val visibleItems = state.layoutInfo.visibleItemsInfo
val startChild = visibleItems
.fastFirstOrNull { (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true }!!
val endChild = visibleItems.last()
val minPosition = min(startChild.index, endChild.index)
val maxPosition = max(startChild.index, endChild.index)
val itemsBefore = minPosition.coerceAtLeast(0)
val startDecoratedTop = startChild.top
val laidOutArea = abs(endChild.bottom - startDecoratedTop)
val itemRange = abs(minPosition - maxPosition) + 1
val avgSizePerRow = laidOutArea.toFloat() / itemRange
return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt()
}
private fun computeScrollRange(state: LazyListState): Int {
if (state.layoutInfo.totalItemsCount == 0) return 0
val visibleItems = state.layoutInfo.visibleItemsInfo
val startChild = visibleItems
.fastFirstOrNull { (it.key as? String)?.startsWith(STICKY_HEADER_KEY_PREFIX)?.not() ?: true }!!
val endChild = visibleItems.last()
val laidOutArea = endChild.bottom - startChild.top
val laidOutRange = abs(startChild.index - endChild.index) + 1
return (laidOutArea.toFloat() / laidOutRange * state.layoutInfo.totalItemsCount).roundToInt()
}
object Scroller {
const val STICKY_HEADER_KEY_PREFIX = "sticky:"
}
private val ThumbLength = 48.dp
private val ThumbThickness = 6.dp
private val ThumbShape = RoundedCornerShape(ThumbThickness / 2)
private val FadeOutAnimationSpec = tween<Float>(
durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
delayMillis = 2000,
)
private val ImmediateFadeOutAnimationSpec = tween<Float>(
durationMillis = ViewConfiguration.getScrollBarFadeDuration(),
)
private val LazyListItemInfo.top: Int
get() = offset
private val LazyListItemInfo.bottom: Int
get() = offset + size

View File

@@ -0,0 +1,173 @@
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<*>
return item == other.item
}
}
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
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
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

@@ -1,11 +1,11 @@
package org.xtimms.tokusho.core.components.icons
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
val Icons.Outlined.Dice: ImageVector
get() {
if (_dice != null) {
return _dice!!

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
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,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
val Icons.Filled.Shirizu: ImageVector
get() {
if (_shirizu != null) {
return _shirizu!!
}
_shirizu = Builder(name = "Shirizu", defaultWidth = 30.0.dp, defaultHeight = 30.0.dp,
viewportWidth = 30.0f, viewportHeight = 30.0f).apply {
path(fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = NonZero) {
moveTo(9.1f, 17.6f)
curveToRelative(-0.8f, -0.4f, -1.7f, -0.9f, -2.8f, -1.3f)
curveToRelative(-1.1f, -0.4f, -2.2f, -0.8f, -3.3f, -1.2f)
curveToRelative(-1.1f, -0.3f, -2.1f, -0.6f, -2.9f, -0.8f)
lineToRelative(1.6f, -5.3f)
curveToRelative(1.0f, 0.2f, 2.1f, 0.5f, 3.2f, 0.8f)
curveToRelative(1.1f, 0.4f, 2.3f, 0.8f, 3.4f, 1.2f)
curveToRelative(1.1f, 0.4f, 2.1f, 0.9f, 3.1f, 1.3f)
lineTo(9.1f, 17.6f)
close()
moveTo(30.0f, 15.7f)
curveToRelative(-1.0f, 1.5f, -2.2f, 3.0f, -3.6f, 4.4f)
curveToRelative(-1.4f, 1.4f, -2.9f, 2.7f, -4.5f, 3.9f)
curveToRelative(-1.6f, 1.2f, -3.2f, 2.3f, -4.9f, 3.2f)
curveToRelative(-1.7f, 0.9f, -3.3f, 1.6f, -4.9f, 2.1f)
curveTo(10.6f, 29.7f, 9.1f, 30.0f, 7.8f, 30.0f)
curveToRelative(-1.8f, 0.0f, -3.4f, -0.6f, -4.8f, -1.7f)
curveToRelative(-1.4f, -1.1f, -2.3f, -3.0f, -2.9f, -5.6f)
lineTo(5.0f, 20.5f)
curveToRelative(0.3f, 1.4f, 0.8f, 2.4f, 1.4f, 3.0f)
curveToRelative(0.6f, 0.6f, 1.4f, 0.9f, 2.3f, 0.9f)
curveToRelative(0.6f, 0.0f, 1.4f, -0.2f, 2.5f, -0.6f)
curveToRelative(1.1f, -0.4f, 2.2f, -0.9f, 3.5f, -1.7f)
curveToRelative(1.3f, -0.7f, 2.6f, -1.6f, 4.0f, -2.7f)
curveToRelative(1.4f, -1.1f, 2.7f, -2.3f, 4.0f, -3.7f)
curveToRelative(1.3f, -1.4f, 2.4f, -2.9f, 3.4f, -4.6f)
lineTo(30.0f, 15.7f)
close()
moveTo(12.9f, 10.3f)
curveToRelative(-1.0f, -0.9f, -2.3f, -1.9f, -4.0f, -2.8f)
curveTo(7.3f, 6.6f, 5.6f, 5.8f, 3.8f, 5.0f)
lineToRelative(1.9f, -5.0f)
curveTo(7.0f, 0.4f, 8.2f, 1.0f, 9.5f, 1.6f)
curveToRelative(1.2f, 0.6f, 2.4f, 1.3f, 3.5f, 2.0f)
curveToRelative(1.1f, 0.7f, 2.0f, 1.3f, 2.7f, 2.0f)
lineTo(12.9f, 10.3f)
close()
}
}
.build()
return _shirizu!!
}
private var _shirizu: 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 ->
relativeQuadraticTo(
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

@@ -1,10 +1,10 @@
package org.xtimms.tokusho.core.database
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.tokusho.R
import org.xtimms.shirizu.R
class DatabasePrePopulateCallback(private val resources: Resources) : RoomDatabase.Callback() {

View File

@@ -0,0 +1,112 @@
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.core.database.migrations.Migration2To3
import org.xtimms.shirizu.core.database.migrations.Migration3To4
import org.xtimms.shirizu.core.scrobbling.data.ScrobblingDao
import org.xtimms.shirizu.core.scrobbling.data.ScrobblingEntity
import org.xtimms.shirizu.utils.lang.processLifecycleScope
const val DATABASE_VERSION = 4
@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,
ScrobblingEntity::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
abstract fun getScrobblingDao(): ScrobblingDao
}
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration1To2(),
Migration2To3(),
Migration3To4()
)
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

@@ -1,4 +1,4 @@
package org.xtimms.tokusho.core.database
package org.xtimms.shirizu.core.database
const val TABLE_MANGA = "manga"
const val TABLE_TAGS = "tags"

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

@@ -1,4 +1,4 @@
package org.xtimms.tokusho.core.database.dao
package org.xtimms.shirizu.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
@@ -6,7 +6,7 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import org.xtimms.tokusho.core.database.entity.FavouriteCategoryEntity
import org.xtimms.shirizu.core.database.entity.FavouriteCategoryEntity
@Dao
abstract class FavouriteCategoriesDao {

View File

@@ -1,4 +1,4 @@
package org.xtimms.tokusho.core.database.dao
package org.xtimms.shirizu.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
@@ -11,10 +11,11 @@ import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language
import org.xtimms.tokusho.core.database.entity.FavouriteEntity
import org.xtimms.tokusho.core.database.entity.MangaEntity
import org.xtimms.tokusho.core.model.ListSortOrder
import org.xtimms.tokusho.sections.shelf.FavouriteManga
import org.xtimms.shirizu.core.database.entity.FavouriteCategoryEntity
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 {
@@ -51,6 +52,9 @@ abstract class FavouritesDao {
@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>
@Query("SELECT DISTINCT manga_id FROM favourites WHERE deleted_at = 0 AND category_id IN (SELECT category_id FROM favourite_categories WHERE track = 1)")
abstract suspend fun findIdsWithTrack(): LongArray
@Transaction
@Query(
"SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " +
@@ -93,6 +97,9 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id")
abstract suspend fun find(id: Long): FavouriteManga?
@Query("SELECT * FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0")
abstract suspend fun findAllRaw(mangaId: Long): List<FavouriteEntity>
@Transaction
@Deprecated("Ignores order")
@Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id")
@@ -101,9 +108,15 @@ abstract class FavouritesDao {
@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")
@Query("SELECT favourite_categories.* FROM favourites LEFT JOIN favourite_categories ON favourite_categories.category_id = favourites.category_id WHERE favourites.manga_id = :mangaId AND favourites.deleted_at = 0")
abstract fun observeCategories(mangaId: Long): Flow<List<FavouriteCategoryEntity>>
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id IN (:mangaIds) AND deleted_at = 0 ORDER BY favourites.created_at ASC")
abstract suspend fun findCategoriesIds(mangaIds: Collection<Long>): List<Long>
@Query("SELECT DISTINCT favourite_categories.category_id FROM favourites LEFT JOIN favourite_categories ON favourites.category_id = favourite_categories.category_id WHERE manga_id = :mangaId AND favourites.deleted_at = 0 AND favourite_categories.deleted_at = 0 AND favourite_categories.track = 1")
abstract suspend fun findCategoriesIdsWithTrack(mangaId: Long): List<Long>
/** INSERT **/
@Insert(onConflict = OnConflictStrategy.REPLACE)
@@ -163,7 +176,5 @@ abstract class FavouritesDao {
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")
}
}

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