Partially migrate to Voyager navigation library

master
Zakhar Timoshenko 2 years ago
parent 210da5db8a
commit 47fffb5541
Signed by: Xtimms
SSH Key Fingerprint: SHA256:wH6spYepK/A5erBh7ZyAnr1ru9H4eaMVBEuiw6DSpxI

@ -1,6 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="selectedTabId" value="Firebase Crashlytics" />
<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>

@ -2,13 +2,7 @@
<project version="4">
<component name="deploymentTargetDropDown">
<value>
<entry key="BaselineProfileGenerator">
<State />
</entry>
<entry key="Generate Baseline Profile">
<State />
</entry>
<entry key="android-app.app">
<entry key="app">
<State />
</entry>
</value>

@ -3,4 +3,280 @@
<component name="ScreenshotViewer">
<option name="frameScreenshot" value="true" />
</component>
<component name="direct_access_persist.xml">
<option name="deviceSelectionList">
<list>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="b0q" />
<option name="id" value="b0q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S22 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
<option name="selected" value="true" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix" />
<option name="id" value="felix" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
<option name="selected" value="true" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="gts8uwifi" />
<option name="id" value="gts8uwifi" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Tab S8 Ultra" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1848" />
<option name="screenY" value="2960" />
<option name="selected" value="true" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="shiba" />
<option name="id" value="shiba" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
<option name="selected" value="true" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="27" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="F01L" />
<option name="id" value="F01L" />
<option name="manufacturer" value="FUJITSU" />
<option name="name" value="F-01L" />
<option name="screenDensity" value="360" />
<option name="screenX" value="720" />
<option name="screenY" value="1280" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="28" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="SH-01L" />
<option name="id" value="SH-01L" />
<option name="manufacturer" value="SHARP" />
<option name="name" value="AQUOS sense2 SH-01L" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2160" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="samsung" />
<option name="codename" value="a51" />
<option name="id" value="a51" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy A51" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="akita" />
<option name="id" value="akita" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="32" />
<option name="brand" value="google" />
<option name="codename" value="bluejay" />
<option name="id" value="bluejay" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="29" />
<option name="brand" value="samsung" />
<option name="codename" value="crownqlteue" />
<option name="id" value="crownqlteue" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Note9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2220" />
<option name="screenY" value="1080" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="dm3q" />
<option name="id" value="dm3q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S23 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix_camera" />
<option name="id" value="felix_camera" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold (Camera-enabled)" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="husky" />
<option name="id" value="husky" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8 Pro" />
<option name="screenDensity" value="390" />
<option name="screenX" value="1008" />
<option name="screenY" value="2244" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="motorola" />
<option name="codename" value="java" />
<option name="id" value="java" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="G20" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="lynx" />
<option name="id" value="lynx" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="google" />
<option name="codename" value="oriole" />
<option name="id" value="oriole" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="panther" />
<option name="id" value="panther" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="samsung" />
<option name="codename" value="q2q" />
<option name="id" value="q2q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold3" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1768" />
<option name="screenY" value="2208" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="q5q" />
<option name="id" value="q5q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold5" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1812" />
<option name="screenY" value="2176" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="r11" />
<option name="id" value="r11" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Watch" />
<option name="screenDensity" value="320" />
<option name="screenX" value="384" />
<option name="screenY" value="384" />
<option name="type" value="WEAR_OS" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="redfin" />
<option name="id" value="redfin" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 5" />
<option name="screenDensity" value="440" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="google" />
<option name="codename" value="shiba_beta" />
<option name="id" value="shiba_beta" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="tangorpro" />
<option name="id" value="tangorpro" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Tablet" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="29" />
<option name="brand" value="samsung" />
<option name="codename" value="x1q" />
<option name="id" value="x1q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S20" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1440" />
<option name="screenY" value="3200" />
</PersistentDeviceSelectionData>
</list>
</option>
<option name="selectedCloudProject" value="api-7108673381507456403-50668" />
</component>
</project>

@ -15,16 +15,17 @@ Shirizu (シリーズ, from Japanese - series) - An attempt to write an Android
No, nothing works.
## Screens
| Shelf | Details | History |
|:--------------------------------------------:|:------------------------------------------------:|:------------------------------------------------:|
| ![Shelf light theme](./images/shelf.png) | ![Details light theme](./images/details.png) | ![History light theme](./images/history.png) |
| ![Shelf dark theme](./images/shelf_dark.png) | ![Details dark theme](./images/details_dark.png) | ![History dark theme](./images/history_dark.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

@ -20,6 +20,11 @@ val acraAuthLogin: String =
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.shirizu"
compileSdk = 34
@ -39,6 +44,9 @@ android {
buildConfigField("String", "ACRA_AUTH_LOGIN", acraAuthLogin)
buildConfigField("String", "ACRA_AUTH_PASSWORD", acraAuthPassword)
buildConfigField("String", "SHIKIMORI_CLIENT_ID", shikimoriClientId)
buildConfigField("String", "SHIKIMORI_CLIENT_SECRET", shikimoriClientSecret)
testInstrumentationRunner = "org.xtimms.shirizu.HiltTestRunner"
vectorDrawables {
useSupportLibrary = true
@ -107,32 +115,52 @@ android {
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("dev.chrisbanes.compose:compose-bom:2024.03.00-alpha01"))
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.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")
@ -141,11 +169,11 @@ dependencies {
implementation("com.google.accompanist:accompanist-pager:0.32.0")
implementation("com.google.accompanist:accompanist-pager-indicators:0.32.0")
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
implementation("com.google.dagger:hilt-android:2.51")
kapt("com.google.dagger:hilt-compiler:2.51")
implementation("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:fec60955ed") {
implementation("com.github.KotatsuApp:kotatsu-parsers:7d2f5696f5") {
exclude(group = "org.json", module = "json")
}
implementation("com.mikepenz:aboutlibraries-compose-m3:10.10.0")
@ -164,10 +192,11 @@ dependencies {
androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("com.squareup.moshi:moshi-kotlin:1.15.1")
androidTestImplementation("com.google.dagger:hilt-android-testing:2.50")
kaptAndroidTest("com.google.dagger:hilt-android-compiler:2.50")
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")
}
@ -178,6 +207,11 @@ androidComponents {
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.

@ -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')"
]
}
}

@ -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')"
]
}
}

@ -0,0 +1,857 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"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')"
]
}
}

@ -11,6 +11,7 @@
<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" />
@ -35,6 +36,7 @@
android:label="@string/app_name"
android:largeHeap="true"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:supportsRtl="true"
android:theme="@style/Theme.Shirizu"
tools:targetApi="tiramisu">
@ -50,11 +52,28 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".crash.CrashActivity"
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"

@ -11,6 +11,7 @@ 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
@ -50,6 +51,9 @@ class App : Application(), Configuration.Provider {
@Inject
lateinit var workManagerProvider: Provider<WorkManager>
@Inject
lateinit var imageLoader: ImageLoader
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)

@ -13,10 +13,14 @@ 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 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
@ -32,9 +36,14 @@ 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(windowWidthSizeClass: WindowWidthSizeClass, content: @Composable () -> Unit) {
fun SettingsProvider(content: @Composable () -> Unit) {
AppSettings.AppSettingsStateFlow.collectAsState().value.run {
CompositionLocalProvider(
LocalDarkTheme provides darkTheme,
@ -46,7 +55,6 @@ fun SettingsProvider(windowWidthSizeClass: WindowWidthSizeClass, content: @Compo
else Color(seedColor).toTonalPalettes(
paletteStyles.getOrElse(paletteStyleIndex) { PaletteStyle.TonalSpot }
),
LocalWindowWidthState provides windowWidthSizeClass,
LocalDynamicColorSwitch provides isDynamicColorEnabled,
content = content
)

@ -7,84 +7,120 @@ 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.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.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.asPaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
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.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.rememberLazyListState
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.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.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.derivedStateOf
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.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
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 androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
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.Navigation
import org.xtimms.shirizu.core.components.BottomNavBar
import org.xtimms.shirizu.core.components.ContinueReadingButton
import org.xtimms.shirizu.core.components.NavigationRail
import org.xtimms.shirizu.core.components.TopAppBar
import org.xtimms.shirizu.core.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.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(ExperimentalMaterial3WindowSizeClassApi::class)
@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()
@ -110,10 +146,6 @@ class MainActivity : ComponentActivity() {
var showUpdateDialog by rememberSaveable { mutableStateOf(false) }
var currentDownloadStatus by remember { mutableStateOf(Updater.DownloadStatus.NotYet as Updater.DownloadStatus) }
val navController = rememberNavController()
val windowSizeClass = calculateWindowSizeClass(this)
val isCompactScreen = LocalWindowWidthState.current == WindowWidthSizeClass.Compact
val settings =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
Updater.installLatestApk(context)
@ -141,63 +173,97 @@ class MainActivity : ComponentActivity() {
isReady.value = true
}
if (isReady.value) {
SettingsProvider(windowSizeClass.widthSizeClass) {
ShirizuTheme(
darkTheme = LocalDarkTheme.current.isDarkTheme(),
isDynamicColorEnabled = LocalDynamicColorSwitch.current,
isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled,
) {
MainView(
coil = coil,
loggers = loggers,
isCompactScreen = isCompactScreen,
navController = navController
)
LaunchedEffect(Unit) {
isDone.value = true
}
LaunchedEffect(Unit) {
if (!AppSettings.isAutoUpdateEnabled())
return@LaunchedEffect
launch(Dispatchers.IO) {
runCatching {
Updater.checkForUpdate(context)?.let {
latestRelease = it
showUpdateDialog = true
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)
}
}.onFailure {
it.printStackTrace()
}
// ShowOnboarding()
}
}
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
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()
}
},
releaseNote = latestRelease.body.toString(),
downloadStatus = currentDownloadStatus
)
}
}
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
)
}
}
}
}
@ -206,7 +272,18 @@ class MainActivity : ComponentActivity() {
putDataToExtras(intent)
}
override fun onNewIntent(intent: 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)
}
@ -222,93 +299,214 @@ class MainActivity : ComponentActivity() {
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainView(
coil: ImageLoader,
loggers: Set<FileLogger>,
isCompactScreen: Boolean,
navController: NavHostController,
) {
val bottomBarState = remember { mutableStateOf(true) }
val topBarOffsetY = remember { Animatable(0f) }
val scroll = rememberLazyListState()
Scaffold(
topBar = {
if (isCompactScreen) {
val isScrolled by remember {
derivedStateOf { scroll.firstVisibleItemScrollOffset > 0 }
object MainScreen : Screen() {
private val librarySearchEvent = Channel<String>()
private val openTabEvent = Channel<Tab>()
private val showBottomNavEvent = Channel<Boolean>()
private const val TabNavigatorKey = "HomeTabs"
private val tabs = listOf(
LibraryTab(),
// ShelfTab,
// HistoryTab,
ExploreTab(),
SearchTab
)
@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.suggestions),
icon = Icons.Outlined.Creation,
onClick = {
navigator.push(SuggestionsScreen)
},
),
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 animatedBgAlpha by animateFloatAsState(
if (isScrolled) 1f else 0f,
label = "Top Bar Background",
)
val animatedSearchBarColor by animateColorAsState(
if (isScrolled) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
label = "Top Bar Background",
)
TopAppBar(
navController = navController,
modifier = Modifier
.statusBarsPadding()
.padding(0.dp, 16.dp),
backgroundAlphaProvider = { animatedBgAlpha },
searchBarColorProvider = { animatedSearchBarColor }
)
}
},
bottomBar = {
if (isCompactScreen) {
BottomNavBar(
navController = navController,
bottomBarState = bottomBarState,
topBarOffsetY = topBarOffsetY,
)
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
}
}
}
}
},
floatingActionButton = {
ContinueReadingButton(navController = navController)
},
contentWindowInsets = WindowInsets.systemBars
.only(WindowInsetsSides.Horizontal)
) { padding ->
if (!isCompactScreen) {
val systemBarsPadding = WindowInsets.systemBars.asPaddingValues()
Row(
modifier = Modifier.padding(padding)
) {
NavigationRail(
navController = navController
}
}
@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,
)
Navigation(
coil = coil,
loggers = loggers,
navController = navController,
isCompactScreen = false,
modifier = Modifier.nestedScroll(TopAppBarDefaults.pinnedScrollBehavior().nestedScrollConnection),
padding = PaddingValues(
start = padding.calculateStartPadding(LocalLayoutDirection.current),
top = systemBarsPadding.calculateTopPadding(),
end = padding.calculateEndPadding(LocalLayoutDirection.current),
bottom = systemBarsPadding.calculateBottomPadding()
),
listState = scroll
},
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,
)
}
} else {
Navigation(
coil = coil,
loggers = loggers,
navController = navController,
isCompactScreen = true,
modifier = Modifier.padding(
start = padding.calculateStartPadding(LocalLayoutDirection.current),
end = padding.calculateEndPadding(LocalLayoutDirection.current),
),
padding = padding,
listState = scroll
)
}
},
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
}
}

@ -1,52 +0,0 @@
package org.xtimms.shirizu.core
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import org.xtimms.shirizu.R
import org.xtimms.shirizu.utils.composable.rememberResourceBitmapPainter
@Composable
fun AsyncImageImpl(
coil: ImageLoader,
model: Any? = null,
contentDescription: String?,
modifier: Modifier = Modifier,
transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform,
onState: ((AsyncImagePainter.State) -> Unit)? = null,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Crop,
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
isPreview: Boolean = false,
) {
if (isPreview) Image(
painter = painterResource(R.drawable.sample),
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
colorFilter = colorFilter,
)
else AsyncImage(
imageLoader = coil,
model = model,
placeholder = ColorPainter(Color(0x1F888888)),
error = rememberResourceBitmapPainter(id = R.drawable.cover_error),
fallback = rememberResourceBitmapPainter(id = R.drawable.cover_loading),
modifier = modifier,
contentScale = contentScale,
contentDescription = contentDescription
)
}

@ -1,72 +0,0 @@
package org.xtimms.shirizu.core
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.LocalLibrary
import androidx.compose.material.icons.outlined.Explore
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.LocalLibrary
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import org.xtimms.shirizu.R
import org.xtimms.shirizu.sections.explore.EXPLORE_DESTINATION
import org.xtimms.shirizu.sections.history.HISTORY_DESTINATION
import org.xtimms.shirizu.sections.shelf.SHELF_DESTINATION
sealed class BottomNavDestination(
val value: String,
val route: String,
@StringRes val title: Int,
val icon: ImageVector,
val iconSelected: ImageVector,
) {
data object Shelf : BottomNavDestination(
value = "shelf",
route = SHELF_DESTINATION,
title = R.string.nav_shelf,
icon = Icons.Outlined.LocalLibrary,
iconSelected = Icons.Filled.LocalLibrary
)
data object History : BottomNavDestination(
value = "history",
route = HISTORY_DESTINATION,
title = R.string.nav_history,
icon = Icons.Outlined.History,
iconSelected = Icons.Filled.History
)
data object Explore : BottomNavDestination(
value = "explore",
route = EXPLORE_DESTINATION,
title = R.string.nav_explore,
icon = Icons.Outlined.Explore,
iconSelected = Icons.Filled.Explore
)
companion object {
val values = listOf(Shelf, History, Explore)
val railValues = listOf(Shelf, History, Explore)
val routes = values.map { it.route }
fun String.toBottomDestinationIndex() = when (this) {
Shelf.value -> 0
History.value -> 1
Explore.value -> 2
else -> null
}
@Composable
fun BottomNavDestination.Icon(selected: Boolean) {
androidx.compose.material3.Icon(
imageVector = if (selected) iconSelected else icon,
contentDescription = stringResource(title)
)
}
}
}

@ -1,63 +0,0 @@
package org.xtimms.shirizu.core
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import kotlinx.coroutines.launch
import kotlin.math.abs
fun Modifier.collapsable(
state: ScrollableState,
topBarHeightPx: Float,
topBarOffsetY: Animatable<Float, AnimationVector1D>,
) = composed {
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = state.isScrollInProgress) {
if (!state.isScrollInProgress && topBarOffsetY.value != 0f && topBarOffsetY.value != -topBarHeightPx) {
val half = topBarHeightPx / 2
val oldOffsetY = topBarOffsetY.value
val targetOffsetY = when {
abs(topBarOffsetY.value) >= half -> -topBarHeightPx
else -> 0f
}
launch {
state.animateScrollBy(oldOffsetY - targetOffsetY)
}
launch {
topBarOffsetY.animateTo(targetOffsetY)
}
}
}
nestedScroll(
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
scope.launch {
if (state.canScrollForward) {
topBarOffsetY.snapTo(
targetValue = (topBarOffsetY.value + available.y).coerceIn(
minimumValue = -topBarHeightPx,
maximumValue = 0f,
)
)
}
}
return Offset.Zero
}
}
)
}

@ -1,466 +0,0 @@
package org.xtimms.shirizu.core
import android.graphics.Path
import android.view.animation.PathInterpolator
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.xtimms.shirizu.core.logs.FileLogger
import org.xtimms.shirizu.sections.details.DETAILS_DESTINATION
import org.xtimms.shirizu.sections.details.DetailsView
import org.xtimms.shirizu.sections.details.FULL_POSTER_DESTINATION
import org.xtimms.shirizu.sections.details.FullImageView
import org.xtimms.shirizu.sections.details.MANGA_ID_ARGUMENT
import org.xtimms.shirizu.sections.details.PICTURES_ARGUMENT
import org.xtimms.shirizu.sections.explore.ExploreView
import org.xtimms.shirizu.sections.feed.FEED_DESTINATION
import org.xtimms.shirizu.sections.feed.FeedView
import org.xtimms.shirizu.sections.history.HistoryView
import org.xtimms.shirizu.sections.list.LIST_DESTINATION
import org.xtimms.shirizu.sections.list.MangaListView
import org.xtimms.shirizu.sections.list.PROVIDER_ARGUMENT
import org.xtimms.shirizu.sections.reader.READER_DESTINATION
import org.xtimms.shirizu.sections.reader.ReaderView
import org.xtimms.shirizu.sections.search.SEARCH_DESTINATION
import org.xtimms.shirizu.sections.search.SearchHostView
import org.xtimms.shirizu.sections.settings.SETTINGS_DESTINATION
import org.xtimms.shirizu.sections.settings.SettingsView
import org.xtimms.shirizu.sections.settings.about.ABOUT_DESTINATION
import org.xtimms.shirizu.sections.settings.about.AboutView
import org.xtimms.shirizu.sections.settings.about.LICENSES_DESTINATION
import org.xtimms.shirizu.sections.settings.about.LICENSE_CONTENT_ARGUMENT
import org.xtimms.shirizu.sections.settings.about.LICENSE_DESTINATION
import org.xtimms.shirizu.sections.settings.about.LICENSE_NAME_ARGUMENT
import org.xtimms.shirizu.sections.settings.about.LICENSE_WEBSITE_ARGUMENT
import org.xtimms.shirizu.sections.settings.about.LicenseView
import org.xtimms.shirizu.sections.settings.about.OpenSourceLicensesView
import org.xtimms.shirizu.sections.settings.about.UPDATES_DESTINATION
import org.xtimms.shirizu.sections.settings.about.UpdateView
import org.xtimms.shirizu.sections.settings.advanced.ADVANCED_DESTINATION
import org.xtimms.shirizu.sections.settings.advanced.AdvancedView
import org.xtimms.shirizu.sections.settings.appearance.APPEARANCE_DESTINATION
import org.xtimms.shirizu.sections.settings.appearance.AppearanceView
import org.xtimms.shirizu.sections.settings.appearance.DARK_THEME_DESTINATION
import org.xtimms.shirizu.sections.settings.appearance.DarkThemeView
import org.xtimms.shirizu.sections.settings.appearance.LANGUAGES_DESTINATION
import org.xtimms.shirizu.sections.settings.appearance.LanguagesView
import org.xtimms.shirizu.sections.settings.backup.BACKUP_RESTORE_DESTINATION
import org.xtimms.shirizu.sections.settings.backup.BackupRestoreView
import org.xtimms.shirizu.sections.settings.backup.RESTORE_ARGUMENT
import org.xtimms.shirizu.sections.settings.backup.RESTORE_DESTINATION
import org.xtimms.shirizu.sections.settings.backup.RestoreItemsView
import org.xtimms.shirizu.sections.settings.network.NETWORK_DESTINATION
import org.xtimms.shirizu.sections.settings.network.NetworkView
import org.xtimms.shirizu.sections.settings.services.SERVICES_DESTINATION
import org.xtimms.shirizu.sections.settings.services.ServicesView
import org.xtimms.shirizu.sections.settings.services.suggestions.SUGGESTIONS_SETTINGS_DESTINATION
import org.xtimms.shirizu.sections.settings.services.suggestions.SuggestionsSettingsView
import org.xtimms.shirizu.sections.settings.shelf.SHELF_SETTINGS_DESTINATION
import org.xtimms.shirizu.sections.settings.shelf.ShelfSettingsView
import org.xtimms.shirizu.sections.settings.shelf.categories.CATEGORIES_DESTINATION
import org.xtimms.shirizu.sections.settings.shelf.categories.CategoriesView
import org.xtimms.shirizu.sections.settings.sources.SOURCES_DESTINATION
import org.xtimms.shirizu.sections.settings.sources.SourcesView
import org.xtimms.shirizu.sections.settings.sources.catalog.CATALOG_DESTINATION
import org.xtimms.shirizu.sections.settings.sources.catalog.SourcesCatalogView
import org.xtimms.shirizu.sections.settings.storage.STORAGE_DESTINATION
import org.xtimms.shirizu.sections.settings.storage.StorageView
import org.xtimms.shirizu.sections.shelf.ShelfView
import org.xtimms.shirizu.sections.stats.STATS_DESTINATION
import org.xtimms.shirizu.sections.stats.StatsView
import org.xtimms.shirizu.sections.suggestions.SUGGESTIONS_DESTINATION
import org.xtimms.shirizu.sections.suggestions.SuggestionsView
import org.xtimms.shirizu.utils.StringArrayNavType
import org.xtimms.shirizu.utils.lang.removeFirstAndLast
const val DURATION_ENTER = 400
const val DURATION_EXIT = 200
const val initialOffset = 0.10f
fun PathInterpolator.toEasing(): Easing {
return Easing { f -> this.getInterpolation(f) }
}
@Composable
fun Navigation(
coil: ImageLoader,
loggers: Set<FileLogger>,
navController: NavHostController,
isCompactScreen: Boolean,
modifier: Modifier,
padding: PaddingValues,
listState: LazyListState,
) {
val navigateBack: () -> Unit = { navController.popBackStack() }
val navigateToDetails: (Long) -> Unit = {
navController.navigate(
DETAILS_DESTINATION.replace(MANGA_ID_ARGUMENT, it.toString())
)
}
val navigateToLicense: (String, String?, String?) -> Unit = { name, website, content ->
navController.navigate(
LICENSE_DESTINATION
.replace(LICENSE_NAME_ARGUMENT, name)
.replace(LICENSE_WEBSITE_ARGUMENT, website.orEmpty())
.replace(LICENSE_CONTENT_ARGUMENT, content ?: "No license text")
)
}
val path = Path().apply {
moveTo(0f, 0f)
cubicTo(0.05F, 0F, 0.133333F, 0.06F, 0.166666F, 0.4F)
cubicTo(0.208333F, 0.82F, 0.25F, 1F, 1F, 1F)
}
val emphasizePathInterpolator = PathInterpolator(path)
val emphasizeEasing = emphasizePathInterpolator.toEasing()
val enterTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
val exitTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
val fadeTween = tween<Float>(durationMillis = DURATION_EXIT)
NavHost(
navController = navController,
startDestination = BottomNavDestination.Shelf.route,
modifier = modifier,
enterTransition = {
slideInHorizontally(
enterTween,
initialOffsetX = { (it * initialOffset).toInt() }) + fadeIn(fadeTween)
},
exitTransition = {
slideOutHorizontally(
exitTween,
targetOffsetX = { -(it * initialOffset).toInt() }) + fadeOut(fadeTween)
},
popEnterTransition = {
slideInHorizontally(
enterTween,
initialOffsetX = { -(it * initialOffset).toInt() }) + fadeIn(fadeTween)
},
popExitTransition = {
slideOutHorizontally(
exitTween,
targetOffsetX = { (it * initialOffset).toInt() }) + fadeOut(fadeTween)
}
) {
composable(BottomNavDestination.Shelf.route) {
ShelfView(
coil = coil,
currentPage = { 2 },
showPageTabs = true,
padding = padding,
navigateToDetails = navigateToDetails,
onRefresh = { true },
)
}
composable(BottomNavDestination.History.route) {
HistoryView(
coil = coil,
padding = padding,
navigateToDetails = navigateToDetails,
navigateToReader = { navController.navigate(READER_DESTINATION) },
listState = listState
)
}
composable(BottomNavDestination.Explore.route) {
ExploreView(
coil = coil,
navigateToDetails = navigateToDetails,
navigateToSource = {
navController.navigate(
LIST_DESTINATION.replace(PROVIDER_ARGUMENT, it.name)
)
},
navigateToSuggestions = { navController.navigate(SUGGESTIONS_DESTINATION) },
padding = padding,
listState = listState
)
}
composable(SEARCH_DESTINATION) {
SearchHostView(
isCompactScreen = isCompactScreen,
padding = if (isCompactScreen) PaddingValues() else padding,
navigateBack = navigateBack,
)
}
composable(FEED_DESTINATION) {
FeedView(
coil = coil,
navigateBack = navigateBack,
navigateToShelf = { navController.navigate(SHELF_SETTINGS_DESTINATION) }
)
}
composable(SUGGESTIONS_DESTINATION) {
SuggestionsView(
coil = coil,
navigateBack = navigateBack,
navigateToDetails = navigateToDetails
)
}
composable(SETTINGS_DESTINATION) {
SettingsView(
navigateBack = navigateBack,
navigateToAppearance = { navController.navigate(APPEARANCE_DESTINATION) },
navigateToAbout = { navController.navigate(ABOUT_DESTINATION) },
navigateToAdvanced = { navController.navigate(ADVANCED_DESTINATION) },
navigateToBackupRestoreSettings = {
navController.navigate(
BACKUP_RESTORE_DESTINATION
)
},
navigateToMangaSources = { navController.navigate(SOURCES_DESTINATION) },
navigateToNetwork = { navController.navigate(NETWORK_DESTINATION) },
navigateToServicesSettings = { navController.navigate(SERVICES_DESTINATION) },
navigateToShelfSettings = { navController.navigate(SHELF_SETTINGS_DESTINATION) },
navigateToStorage = { navController.navigate(STORAGE_DESTINATION) }
)
}
composable(APPEARANCE_DESTINATION) {
AppearanceView(
navigateBack = navigateBack,
navigateToDarkTheme = { navController.navigate(DARK_THEME_DESTINATION) },
navigateToLanguages = { navController.navigate(LANGUAGES_DESTINATION) }
)
}
composable(DARK_THEME_DESTINATION) {
DarkThemeView(
navigateBack = navigateBack
)
}
composable(LANGUAGES_DESTINATION) {
LanguagesView(
navigateBack = navigateBack
)
}
composable(SOURCES_DESTINATION) {
SourcesView(
navigateBack = navigateBack,
navigateToSourcesCatalog = { navController.navigate(CATALOG_DESTINATION) },
navigateToSourcesManagement = { /*TODO*/ }
)
}
composable(CATALOG_DESTINATION) {
SourcesCatalogView(
coil = coil,
navigateBack = navigateBack,
)
}
composable(BACKUP_RESTORE_DESTINATION) {
BackupRestoreView(
navigateBack = navigateBack,
navigateToRestoreScreen = {
navController.navigate(RESTORE_DESTINATION.replace(RESTORE_ARGUMENT, it))
}
)
}
composable(
route = RESTORE_DESTINATION,
arguments = listOf(
navArgument(RESTORE_ARGUMENT.removeFirstAndLast()) {
type = NavType.StringType
}
)
) { navEntry ->
RestoreItemsView(
uri = navEntry.arguments?.getString(PROVIDER_ARGUMENT.removeFirstAndLast()) ?: "",
navigateBack = navigateBack
)
}
composable(SHELF_SETTINGS_DESTINATION) {
ShelfSettingsView(
navigateBack = navigateBack,
navigateToCategories = { navController.navigate(CATEGORIES_DESTINATION) }
)
}
composable(CATEGORIES_DESTINATION) {
CategoriesView(
navigateBack = navigateBack,
)
}
composable(SERVICES_DESTINATION) {
ServicesView(
navigateBack = navigateBack,
navigateToSuggestionsSettings = { navController.navigate(SUGGESTIONS_SETTINGS_DESTINATION) },
navigateToStatistics = { navController.navigate(STATS_DESTINATION) }
)
}
composable(SUGGESTIONS_SETTINGS_DESTINATION) {
SuggestionsSettingsView(
navigateBack = navigateBack
)
}
composable(NETWORK_DESTINATION) {
NetworkView(
navigateBack = navigateBack,
)
}
composable(STORAGE_DESTINATION) {
StorageView(
navigateBack = navigateBack,
)
}
composable(ADVANCED_DESTINATION) {
AdvancedView(
loggers = loggers,
navigateBack = navigateBack,
navigateToStats = { navController.navigate(STATS_DESTINATION) }
)
}
composable(STATS_DESTINATION) {
StatsView(
navigateBack = navigateBack
)
}
composable(
route = LIST_DESTINATION,
arguments = listOf(
navArgument(PROVIDER_ARGUMENT.removeFirstAndLast()) {
type = NavType.StringType
}
)
) { navEntry ->
MangaListView(
coil = coil,
source = navEntry.arguments?.getString(PROVIDER_ARGUMENT.removeFirstAndLast())
?.let { source -> MangaSource.valueOf(source) } ?: MangaSource.DUMMY,
navigateBack = navigateBack,
navigateToDetails = navigateToDetails
)
}
composable(ABOUT_DESTINATION) {
AboutView(
navigateBack = navigateBack,
navigateToLicensesPage = { navController.navigate(LICENSES_DESTINATION) },
navigateToUpdatePage = { navController.navigate(UPDATES_DESTINATION) }
)
}
composable(LICENSES_DESTINATION) {
OpenSourceLicensesView(
navigateBack = navigateBack,
navigateToLicensePage = navigateToLicense
)
}
composable(
route = LICENSE_DESTINATION,
arguments = listOf(
navArgument(LICENSE_NAME_ARGUMENT.removeFirstAndLast()) {
type = NavType.StringType
},
navArgument(LICENSE_WEBSITE_ARGUMENT.removeFirstAndLast()) {
type = NavType.StringType
},
navArgument(LICENSE_CONTENT_ARGUMENT.removeFirstAndLast()) {
type = NavType.StringType
}
)
) { navEntry ->
LicenseView(
name = navEntry.arguments?.getString(LICENSE_NAME_ARGUMENT.removeFirstAndLast())
.orEmpty(),
website = navEntry.arguments?.getString(LICENSE_WEBSITE_ARGUMENT.removeFirstAndLast())
.orEmpty(),
license = navEntry.arguments?.getString(LICENSE_CONTENT_ARGUMENT.removeFirstAndLast())
?: "No license text",
navigateBack = navigateBack
)
}
composable(UPDATES_DESTINATION) {
UpdateView(
navigateBack = navigateBack,
)
}
composable(
route = DETAILS_DESTINATION,
arguments = listOf(
navArgument(MANGA_ID_ARGUMENT.removeFirstAndLast()) {
type = NavType.LongType
}
),
) { navEntry ->
DetailsView(
coil = coil,
mangaId = navEntry.arguments?.getLong(MANGA_ID_ARGUMENT.removeFirstAndLast()) ?: 0L,
navigateBack = navigateBack,
navigateToFullImage = { pictures ->
navController.navigate(
FULL_POSTER_DESTINATION.replace(PICTURES_ARGUMENT, pictures)
)
},
navigateToDetails = navigateToDetails,
navigateToSource = {
navController.navigate(
LIST_DESTINATION.replace(PROVIDER_ARGUMENT, it.name)
)
},
navigateToReader = { navController.navigate(READER_DESTINATION) }
)
}
composable(READER_DESTINATION) {
ReaderView(
navigateBack = navigateBack
)
}
composable(
FULL_POSTER_DESTINATION,
arguments = listOf(
navArgument(PICTURES_ARGUMENT.removeFirstAndLast()) { type = StringArrayNavType }
),
) { navEntry ->
FullImageView(
coil = coil,
pictures = navEntry.arguments?.getStringArray(PICTURES_ARGUMENT.removeFirstAndLast())
?: emptyArray(),
navigateBack = navigateBack
)
}
}
}

@ -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
)
}

@ -24,7 +24,7 @@ abstract class BaseActivity<B : ViewBinding> :
WindowCompat.setDecorFitsSystemWindows(window, false)
}
override fun onNewIntent(intent: Intent?) {
override fun onNewIntent(intent: Intent) {
putDataToExtras(intent)
super.onNewIntent(intent)
}

@ -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)
}
}
}

@ -63,7 +63,7 @@ abstract class KotatsuBaseViewModel : ViewModel() {
loadingCounter.decrement()
}
protected inline suspend fun <T> withLoading(block: () -> T): T = try {
protected suspend inline fun <T> withLoading(block: () -> T): T = try {
loadingCounter.increment()
block()
} finally {

@ -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

@ -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 = { },
)
}
}
}
}

@ -1,76 +0,0 @@
package org.xtimms.shirizu.core.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
import kotlinx.coroutines.launch
import org.xtimms.shirizu.core.BottomNavDestination
import org.xtimms.shirizu.core.BottomNavDestination.Companion.Icon
import org.xtimms.shirizu.sections.explore.EXPLORE_DESTINATION
import org.xtimms.shirizu.sections.history.HISTORY_DESTINATION
import org.xtimms.shirizu.sections.shelf.SHELF_DESTINATION
@Composable
fun BottomNavBar(
navController: NavController,
bottomBarState: State<Boolean>,
topBarOffsetY: Animatable<Float, AnimationVector1D>,
) {
val scope = rememberCoroutineScope()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val isVisible by remember {
derivedStateOf {
when (navBackStackEntry?.destination?.route) {
SHELF_DESTINATION, HISTORY_DESTINATION, EXPLORE_DESTINATION, null -> bottomBarState.value
else -> false
}
}
}
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it })
) {
NavigationBar {
BottomNavDestination.values.forEachIndexed { _, dest ->
val isSelected = navBackStackEntry?.destination?.route == dest.route
NavigationBarItem(
selected = isSelected,
onClick = {
scope.launch {
topBarOffsetY.animateTo(0f)
}
navController.navigate(dest.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = { dest.Icon(selected = isSelected) },
label = { Text(text = stringResource(dest.title)) }
)
}
}
}
}

@ -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),
)
}
}

@ -1,83 +0,0 @@
package org.xtimms.shirizu.core.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.LocalLibrary
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.FloatingActionButtonElevation
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
import org.xtimms.shirizu.R
import org.xtimms.shirizu.sections.history.HISTORY_DESTINATION
import org.xtimms.shirizu.sections.reader.READER_DESTINATION
@Composable
fun ContinueReadingButton(
navController: NavController,
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val isVisible by remember {
derivedStateOf {
when (navBackStackEntry?.destination?.route) {
HISTORY_DESTINATION, null -> true
else -> false
}
}
}
val fabScale by animateFloatAsState(
targetValue = when (navBackStackEntry?.destination?.route) {
HISTORY_DESTINATION, null -> 1f
else -> 0f
},
animationSpec = tween(150), label = "elevation"
)
AnimatedVisibility(
visible = isVisible,
enter = fadeIn(animationSpec = tween(300, delayMillis = 150)) +
scaleIn(
initialScale = 0.92f,
animationSpec = tween(300, delayMillis = 150)
),
exit = fadeOut(animationSpec = tween(0))
) {
androidx.compose.material3.ExtendedFloatingActionButton(
onClick = {
navController.navigate(
READER_DESTINATION
)
},
modifier = Modifier.padding(8.dp),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 4.dp
)
) {
Icon(
imageVector = Icons.Outlined.LocalLibrary,
contentDescription = null
)
Text(
text = stringResource(R.string.continue_reading),
modifier = Modifier.padding(start = 16.dp, end = 8.dp)
)
}
}
}

@ -120,7 +120,6 @@ fun ClassicDetailsToolbar(
title: String,
titleAlphaProvider: () -> Float,
navigateBack: () -> Unit,
navigateToWebBrowser: () -> Unit,
modifier: Modifier = Modifier,
backgroundAlphaProvider: () -> Float = titleAlphaProvider
) {
@ -165,16 +164,6 @@ fun ClassicDetailsToolbar(
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(

@ -16,6 +16,7 @@ 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
@ -65,7 +66,7 @@ fun ShirizuDialog(
tonalElevation: Dp = AlertDialogDefaults.TonalElevation,
properties: DialogProperties = DialogProperties()
) {
AlertDialog(
BasicAlertDialog(
onDismissRequest = onDismissRequest,
modifier = modifier,
properties = properties
@ -158,7 +159,7 @@ fun ShirizuDialogButtonVariant(
text: String,
onClick: () -> Unit
) {
Box() {
Box {
Surface(
modifier = modifier
.clickable(onClick = onClick)
@ -251,7 +252,7 @@ fun ShirizuDialogVariant(
tonalElevation: Dp = AlertDialogDefaults.TonalElevation,
properties: DialogProperties = DialogProperties()
) {
AlertDialog(
BasicAlertDialog(
onDismissRequest = onDismissRequest,
modifier = modifier,
properties = properties

@ -32,7 +32,7 @@ fun ExploreButton(
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)
)

@ -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()
}
}
}

@ -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,
)
}
}

@ -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,
)
}
}

@ -1,13 +1,48 @@
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,
@ -18,7 +53,7 @@ fun ListGroupHeader(
modifier = modifier
.padding(
horizontal = 16.dp,
vertical = 4.dp,
vertical = 8.dp,
),
style = MaterialTheme.typography.bodyLarge,
)

@ -1,6 +1,5 @@
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,12 +8,16 @@ 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.shirizu.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),
@ -23,16 +26,17 @@ enum class MangaCover(val ratio: Float) {
@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)

@ -8,6 +8,7 @@ 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
@ -22,6 +23,7 @@ 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
@ -33,47 +35,44 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.ImageLoader
import org.koitharu.kotatsu.parsers.model.Manga
import org.xtimms.shirizu.core.AsyncImageImpl
import org.xtimms.shirizu.core.ShirizuAsyncImage
private const val GridSelectedCoverAlpha = 0.76f
@Composable
fun MangaGridItem(
coil: ImageLoader,
manga: Manga,
onClick: (Manga) -> Unit,
title: String,
onClick: () -> Unit,
onLongClick: () -> Unit,
isSelected: Boolean = false,
titleMaxLines: Int = 2,
coverAlpha: Float = 1f,
) {
GridItemSelectable(
manga = manga,
isSelected = isSelected,
onClick = onClick,
onLongClick = onLongClick,
) {
Column(
modifier = Modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Box {
AsyncImageImpl(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp)
.clip(MaterialTheme.shapes.medium)
.aspectRatio(10F / 16F),
coil = coil,
model = manga.largeCoverUrl ?: manga.coverUrl,
contentDescription = null
)
}
Text(
text = manga.title,
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),
overflow = TextOverflow.Ellipsis,
maxLines = 2,
title = title,
style = MaterialTheme.typography.titleSmall,
minLines = 2,
maxLines = titleMaxLines,
)
}
}
@ -81,14 +80,12 @@ fun MangaGridItem(
@Composable
fun MangaHorizontalItem(
coil: ImageLoader,
manga: Manga,
onClick: (Manga) -> Unit,
onLongClick: () -> Unit,
isSelected: Boolean = false,
) {
GridItemSelectable(
manga = manga,
isSelected = isSelected,
onClick = { onClick(manga) },
onLongClick = onLongClick,
@ -98,14 +95,13 @@ fun MangaHorizontalItem(
horizontalAlignment = Alignment.Start
) {
Box {
AsyncImageImpl(
ShirizuAsyncImage(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp)
.clip(MaterialTheme.shapes.medium)
.aspectRatio(10F / 16F)
.height(156.dp),
coil = coil,
model = manga.largeCoverUrl ?: manga.coverUrl,
contentDescription = null
)
@ -128,7 +124,6 @@ fun MangaHorizontalItem(
private fun MangaGridCover(
modifier: Modifier = Modifier,
cover: @Composable BoxScope.() -> Unit = {},
content: @Composable (BoxScope.() -> Unit)? = null,
) {
Box(
modifier = modifier
@ -136,7 +131,6 @@ private fun MangaGridCover(
.aspectRatio(MangaCover.Book.ratio),
) {
cover()
content?.invoke(this)
}
}
@ -193,8 +187,6 @@ private fun GridItemTitle(
Text(
modifier = modifier,
text = title,
fontSize = 12.sp,
lineHeight = 18.sp,
minLines = minLines,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
@ -208,9 +200,8 @@ private fun GridItemTitle(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun GridItemSelectable(
manga: Manga,
isSelected: Boolean,
onClick: (Manga) -> Unit,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
@ -219,7 +210,7 @@ private fun GridItemSelectable(
modifier = modifier
.clip(MaterialTheme.shapes.small)
.combinedClickable(
onClick = { onClick(manga) },
onClick = onClick,
onLongClick = onLongClick,
)
.selectedOutline(isSelected = isSelected, color = MaterialTheme.colorScheme.secondary)

@ -41,7 +41,7 @@ fun ShirizuModalBottomSheet(
modifier = modifier,
onDismissRequest = onDismissRequest,
sheetState = sheetState,
windowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp),
contentWindowInsets = { WindowInsets(0.dp, 0.dp, 0.dp, 0.dp) },
) {
Column(modifier = Modifier.padding(paddingValues = horizontalPadding)) {
content()

@ -1,76 +0,0 @@
package org.xtimms.shirizu.core.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
import org.xtimms.shirizu.core.BottomNavDestination
import org.xtimms.shirizu.core.BottomNavDestination.Companion.Icon
import org.xtimms.shirizu.sections.search.SEARCH_DESTINATION
@Composable
fun NavigationRail(
navController: NavController,
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
NavigationRail(
header = {
FloatingActionButton(
onClick = {
navController.navigate(SEARCH_DESTINATION) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
) {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = null
)
}
}
) {
Column(
modifier = Modifier
.fillMaxHeight()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Bottom
) {
BottomNavDestination.railValues.forEachIndexed { index, dest ->
val isSelected = navBackStackEntry?.destination?.route == dest.route
NavigationRailItem(
selected = isSelected,
onClick = {
navController.navigate(dest.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = { dest.Icon(selected = isSelected) },
label = { Text(text = stringResource(dest.title)) }
)
}
}
}
}

@ -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
@ -67,6 +68,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.xtimms.shirizu.ui.theme.FixedAccentColors
import org.xtimms.shirizu.R
import org.xtimms.shirizu.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

@ -1,290 +0,0 @@
package org.xtimms.shirizu.core.components
import androidx.compose.animation.core.animate
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.pow
/**
* @param refreshing Whether the layout is currently refreshing
* @param onRefresh Lambda which is invoked when a swipe to refresh gesture is completed.
* @param enabled Whether the the layout should react to swipe gestures or not.
* @param indicatorPadding Content padding for the indicator, to inset the indicator in if required.
* @param content The content containing a vertically scrollable composable.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PullRefresh(
refreshing: Boolean,
enabled: () -> Boolean,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
indicatorPadding: PaddingValues = PaddingValues(0.dp),
content: @Composable () -> Unit,
) {
val state = rememberPullToRefreshState(
isRefreshing = refreshing,
extraVerticalOffset = indicatorPadding.calculateTopPadding(),
enabled = enabled,
onRefresh = onRefresh,
)
Box(modifier.nestedScroll(state.nestedScrollConnection)) {
content()
val contentPadding = remember(indicatorPadding) {
object : PaddingValues {
override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp =
indicatorPadding.calculateLeftPadding(layoutDirection)
override fun calculateTopPadding(): Dp = 0.dp
override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp =
indicatorPadding.calculateRightPadding(layoutDirection)
override fun calculateBottomPadding(): Dp =
indicatorPadding.calculateBottomPadding()
}
}
PullToRefreshContainer(
state = state,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(contentPadding),
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun rememberPullToRefreshState(
isRefreshing: Boolean,
extraVerticalOffset: Dp,
positionalThreshold: Dp = 64.dp,
enabled: () -> Boolean = { true },
onRefresh: () -> Unit,
): PullToRefreshStateImpl {
val density = LocalDensity.current
val extraVerticalOffsetPx = with(density) { extraVerticalOffset.toPx() }
val positionalThresholdPx = with(density) { positionalThreshold.toPx() }
return rememberSaveable(
extraVerticalOffset,
positionalThresholdPx,
enabled,
onRefresh,
saver = PullToRefreshStateImpl.Saver(
extraVerticalOffset = extraVerticalOffsetPx,
positionalThreshold = positionalThresholdPx,
enabled = enabled,
onRefresh = onRefresh,
),
) {
PullToRefreshStateImpl(
initialRefreshing = isRefreshing,
extraVerticalOffset = extraVerticalOffsetPx,
positionalThreshold = positionalThresholdPx,
enabled = enabled,
onRefresh = onRefresh,
)
}.also {
LaunchedEffect(isRefreshing) {
if (isRefreshing && !it.isRefreshing) {
it.startRefreshAnimated()
} else if (!isRefreshing && it.isRefreshing) {
it.endRefreshAnimated()
}
}
}
}
/**
* Creates a [PullToRefreshState].
*
* @param positionalThreshold The positional threshold, in pixels, in which a refresh is triggered
* @param extraVerticalOffset Extra vertical offset, in pixels, for the "refreshing" state
* @param initialRefreshing The initial refreshing value of [PullToRefreshState]
* @param enabled a callback used to determine whether scroll events are to be handled by this
* @param onRefresh a callback to run when pull-to-refresh action is triggered by user
* [PullToRefreshState]
*/
@OptIn(ExperimentalMaterial3Api::class)
private class PullToRefreshStateImpl(
initialRefreshing: Boolean,
private val extraVerticalOffset: Float,
override val positionalThreshold: Float,
enabled: () -> Boolean,
private val onRefresh: () -> Unit,
) : PullToRefreshState {
override val progress get() = adjustedDistancePulled / positionalThreshold
override var verticalOffset by mutableFloatStateOf(if (initialRefreshing) refreshingVerticalOffset else 0f)
override var isRefreshing by mutableStateOf(initialRefreshing)
private val refreshingVerticalOffset: Float
get() = positionalThreshold + extraVerticalOffset
override fun startRefresh() {
isRefreshing = true
verticalOffset = refreshingVerticalOffset
}
suspend fun startRefreshAnimated() {
isRefreshing = true
animateTo(refreshingVerticalOffset)
}
override fun endRefresh() {
verticalOffset = 0f
isRefreshing = false
}
suspend fun endRefreshAnimated() {
animateTo(0f)
isRefreshing = false
}
override var nestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset = when {
!enabled() -> Offset.Zero
// Swiping up
source == NestedScrollSource.Drag && available.y < 0 -> {
consumeAvailableOffset(available)
}
else -> Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset = when {
!enabled() -> Offset.Zero
// Swiping down
source == NestedScrollSource.Drag && available.y > 0 -> {
consumeAvailableOffset(available)
}
else -> Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
return Velocity(0f, onRelease(available.y))
}
}
/** Helper method for nested scroll connection */
fun consumeAvailableOffset(available: Offset): Offset {
val y = if (isRefreshing) {
0f
} else {
val newOffset = (distancePulled + available.y).coerceAtLeast(0f)
val dragConsumed = newOffset - distancePulled
distancePulled = newOffset
verticalOffset = calculateVerticalOffset() + (extraVerticalOffset * progress.coerceIn(0f, 1f))
dragConsumed
}
return Offset(0f, y)
}
/** Helper method for nested scroll connection. Calls onRefresh callback when triggered */
suspend fun onRelease(velocity: Float): Float {
if (isRefreshing) return 0f // Already refreshing, do nothing
// Trigger refresh
if (adjustedDistancePulled > positionalThreshold) {
onRefresh()
startRefreshAnimated()
} else {
animateTo(0f)
}
val consumed = when {
// We are flinging without having dragged the pull refresh (for example a fling inside
// a list) - don't consume
distancePulled == 0f -> 0f
// If the velocity is negative, the fling is upwards, and we don't want to prevent the
// the list from scrolling
velocity < 0f -> 0f
// We are showing the indicator, and the fling is downwards - consume everything
else -> velocity
}
distancePulled = 0f
return consumed
}
suspend fun animateTo(offset: Float) {
animate(initialValue = verticalOffset, targetValue = offset) { value, _ ->
verticalOffset = value
}
}
/** Provides custom vertical offset behavior for [PullToRefreshContainer] */
fun calculateVerticalOffset(): Float = when {
// If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
adjustedDistancePulled <= positionalThreshold -> adjustedDistancePulled
else -> {
// How far beyond the threshold pull has gone, as a percentage of the threshold.
val overshootPercent = abs(progress) - 1.0f
// Limit the overshoot to 200%. Linear between 0 and 200.
val linearTension = overshootPercent.coerceIn(0f, 2f)
// Non-linear tension. Increases with linearTension, but at a decreasing rate.
val tensionPercent = linearTension - linearTension.pow(2) / 4
// The additional offset beyond the threshold.
val extraOffset = positionalThreshold * tensionPercent
positionalThreshold + extraOffset
}
}
companion object {
/** The default [Saver] for [PullToRefreshStateImpl]. */
fun Saver(
extraVerticalOffset: Float,
positionalThreshold: Float,
enabled: () -> Boolean,
onRefresh: () -> Unit,
) = Saver<PullToRefreshStateImpl, Boolean>(
save = { it.isRefreshing },
restore = { isRefreshing ->
PullToRefreshStateImpl(
initialRefreshing = isRefreshing,
extraVerticalOffset = extraVerticalOffset,
positionalThreshold = positionalThreshold,
enabled = enabled,
onRefresh = onRefresh,
)
},
)
}
private var distancePulled by mutableFloatStateOf(0f)
private val adjustedDistancePulled: Float get() = distancePulled * 0.5f
}

@ -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 }

@ -93,18 +93,11 @@ fun ScaffoldWithClassicTopAppBar(
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
)

@ -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,
)
}

@ -0,0 +1,72 @@
package org.xtimms.shirizu.core.components
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.automirrored.filled.Sort
import androidx.compose.material.icons.filled.ArrowDropDown
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) }
FilterChip(
selected = true,
onClick = { expanded = true },
label = {
Text(
text = currentSortOption.label(LocalContext.current.resources),
modifier = Modifier.animateContentSize(),
)
},
leadingIcon = {
Icon(
imageVector = Icons.AutoMirrored.Filled.Sort,
contentDescription = null, // decorative
modifier = Modifier.size(16.dp),
)
},
trailingIcon = {
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null, // decorative
modifier = Modifier.size(16.dp),
)
},
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
SortDropdownMenuContent(
sortOptions = sortOptions,
currentSortOption = currentSortOption,
onItemClick = {
onSortSelected(it)
expanded = false
},
)
}
}
}

@ -0,0 +1,39 @@
package org.xtimms.shirizu.core.components
import android.content.res.Resources
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.DATE_ADDED -> resources.getString(R.string.sort_date_added)
}

@ -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.shirizu.core.AsyncImageImpl
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,
@ -50,8 +49,7 @@ fun SourceItem(
.clip(MaterialTheme.shapes.large)
.aspectRatio(1f)
) {
AsyncImageImpl(
coil = coil,
ShirizuAsyncImage(
model = faviconUrl,
contentDescription = "favicon",
contentScale = ContentScale.Crop,
@ -79,7 +77,6 @@ fun SourceItem(
fun SourceItemPreview() {
ShirizuTheme {
SourceItem(
coil = ImageLoader(LocalContext.current),
faviconUrl = "".toUri(),
title = "Test",
onClick = { }

@ -2,14 +2,8 @@ package org.xtimms.shirizu.core.components
import android.graphics.Path
import android.view.animation.PathInterpolator
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -18,10 +12,18 @@ 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
@ -36,210 +38,252 @@ 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.unit.IntOffset
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.ui.unit.sp
import kotlinx.collections.immutable.ImmutableList
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.DURATION_ENTER
import org.xtimms.shirizu.core.DURATION_EXIT
import org.xtimms.shirizu.core.initialOffset
import org.xtimms.shirizu.core.components.icons.Shirizu
import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.core.toEasing
import org.xtimms.shirizu.sections.explore.EXPLORE_DESTINATION
import org.xtimms.shirizu.sections.feed.FEED_DESTINATION
import org.xtimms.shirizu.sections.history.HISTORY_DESTINATION
import org.xtimms.shirizu.sections.search.SEARCH_DESTINATION
import org.xtimms.shirizu.sections.settings.SETTINGS_DESTINATION
import org.xtimms.shirizu.sections.shelf.SHELF_DESTINATION
import org.xtimms.shirizu.sections.stats.STATS_DESTINATION
import 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 TopAppBar(
navController: NavController,
modifier: Modifier = Modifier,
backgroundAlphaProvider: () -> Float,
searchBarColorProvider: () -> Color,
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
var expanded by remember { mutableStateOf(false) }
fun AppBar(
title: String?,
val isVisible by remember {
derivedStateOf {
when (navBackStackEntry?.destination?.route) {
SHELF_DESTINATION, HISTORY_DESTINATION, EXPLORE_DESTINATION,
null -> true
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 = {},
else -> false
}
}
scrollBehavior: TopAppBarScrollBehavior? = null,
) {
val isActionMode by remember(actionModeCounter) {
derivedStateOf { actionModeCounter > 0 }
}
val aprilFoolsDay = LocalDate.of(LocalDate.now().year, 4, 1)
val dtStart = aprilFoolsDay.format(DateTimeFormatter.ISO_DATE)
val currentDt = LocalDate.now()
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,
)
}
val isAprilFoolsDay = currentDt.format(DateTimeFormatter.ISO_DATE).equals(dtStart)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppBar(
// Title
titleContent: @Composable () -> Unit,
val path = Path().apply {
moveTo(0f, 0f)
cubicTo(0.05F, 0F, 0.133333F, 0.06F, 0.166666F, 0.4F)
cubicTo(0.208333F, 0.82F, 0.25F, 1F, 1F, 1F)
}
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 = {},
val emphasizePathInterpolator = PathInterpolator(path)
val emphasizeEasing = emphasizePathInterpolator.toEasing()
val enterTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
val exitTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
val fadeTween = tween<Float>(durationMillis = DURATION_EXIT)
AnimatedVisibility(
visible = isVisible,
enter = slideInHorizontally(
enterTween,
initialOffsetX = { -(it * initialOffset).toInt() }) + fadeIn(fadeTween),
exit = slideOutHorizontally(
exitTween,
targetOffsetX = { -(it * initialOffset).toInt() }) + fadeOut(fadeTween)
scrollBehavior: TopAppBarScrollBehavior? = null,
) {
Column(
modifier = modifier,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme
.surfaceColorAtElevation(3.dp)
.copy(
alpha = backgroundAlphaProvider()
)
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Card(
onClick = { navController.navigate(SEARCH_DESTINATION) },
modifier = modifier
.weight(1f)
.height(56.dp)
.padding(start = 16.dp),
shape = RoundedCornerShape(50),
colors = CardDefaults.cardColors().copy(containerColor = searchBarColorProvider()),
) {
Row(
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxHeight(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (isAprilFoolsDay) Icons.Outlined.SentimentSatisfiedAlt else Icons.Outlined.Search,
contentDescription = "search",
tint = MaterialTheme.colorScheme.outline
)
Text(
text = stringResource(R.string.search),
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Row(
modifier = modifier.padding(end = 16.dp),
) {
if (AppSettings.isTrackerEnabled()) {
IconButton(
onClick = { navController.navigate(FEED_DESTINATION) },
modifier = Modifier.padding(0.dp),
) {
Icon(
Icons.Outlined.RssFeed,
contentDescription = stringResource(id = R.string.feed),
tint = MaterialTheme.colorScheme.outline
)
}
IconButton(
onClick = { expanded = true },
modifier = Modifier.padding(0.dp),
) {
androidx.compose.material3.TopAppBar(
navigationIcon = {
if (isActionMode) {
IconButton(onClick = onCancelActionMode) {
Icon(
Icons.Outlined.MoreVert,
contentDescription = stringResource(id = R.string.open_menu),
tint = MaterialTheme.colorScheme.outline
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.statistics)) },
onClick = {
navController.navigate(STATS_DESTINATION)
expanded = false
},
leadingIcon = {
Icon(
imageVector = Icons.Outlined.QueryStats,
contentDescription = stringResource(id = R.string.statistics)
)
}
)
DropdownMenuItem(
text = { Text(text = stringResource(id = R.string.settings)) },
onClick = {
navController.navigate(SETTINGS_DESTINATION)
expanded = false
},
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(id = R.string.settings)
)
}
imageVector = Icons.Outlined.Close,
contentDescription = stringResource(R.string.cancel),
)
}
} else {
IconButton(
onClick = { navController.navigate(STATS_DESTINATION) },
modifier = Modifier.padding(0.dp),
) {
Icon(
Icons.Outlined.QueryStats,
contentDescription = stringResource(id = R.string.statistics),
tint = MaterialTheme.colorScheme.outline
)
}
IconButton(
onClick = { navController.navigate(SETTINGS_DESTINATION) },
modifier = Modifier.padding(0.dp),
) {
Icon(
Icons.Outlined.Settings,
contentDescription = stringResource(id = R.string.settings),
tint = MaterialTheme.colorScheme.outline
)
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) },
)
}
}
}
@ -297,10 +341,10 @@ fun SmallTopAppBarWithChips(
}
private val path = Path().apply {
moveTo(0f,0f)
moveTo(0f, 0f)
lineTo(0.7f, 0.1f)
cubicTo(0.7f, 0.1f, .95F, .5F, 1F, 1F)
moveTo(1f,1f)
moveTo(1f, 1f)
}
val fraction: (Float) -> Float = { PathInterpolator(path).getInterpolation(it) }
@ -374,4 +418,177 @@ fun AppBarTitle(
)
}
}
}
@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()
}
}

@ -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

@ -159,9 +159,7 @@ data class AnimatedItem<T>(
other as AnimatedItem<*>
if (item != other.item) return false
return true
return item == other.item
}
}

@ -5,7 +5,7 @@ import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.ui.graphics.vector.ImageVector
public val Icons.Outlined.ArrowDecisionOutline: ImageVector
val Icons.Outlined.ArrowDecisionOutline: ImageVector
get() {
if (_arrow_decision_outline != null) {
return _arrow_decision_outline!!

@ -11,7 +11,7 @@ import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
public val Icons.Outlined.Creation: ImageVector
val Icons.Outlined.Creation: ImageVector
get() {
if (_creation != null) {
return _creation!!

@ -5,7 +5,7 @@ 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!!

@ -11,7 +11,7 @@ import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
public val Icons.Filled.Kotatsu: ImageVector
val Icons.Filled.Kotatsu: ImageVector
get() {
if (_kotatsu != null) {
return _kotatsu!!

@ -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

@ -29,11 +29,11 @@ class WavyShape(
moveTo(x = 0f, y = 0f)
lineTo(size.width - amplitude, -halfPeriod * 2.5f + halfPeriod * 2 * shift)
repeat(ceil(size.height / halfPeriod + 3).toInt()) { i ->
relativeQuadraticBezierTo(
relativeQuadraticTo(
dx1 = 2 * amplitude * (if (i % 2 == 0) 1 else -1),
dy1 = halfPeriod / 2,
dx2 = 0f,
dy2 = halfPeriod,
dy2 = halfPeriod
)
}
lineTo(0f, size.height)

@ -36,9 +36,12 @@ 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 = 3
const val DATABASE_VERSION = 4
@Database(
entities = [
@ -54,6 +57,7 @@ const val DATABASE_VERSION = 3
TrackEntity::class,
TrackLogEntity::class,
StatsEntity::class,
ScrobblingEntity::class,
],
version = DATABASE_VERSION
)
@ -81,11 +85,14 @@ abstract class ShirizuDatabase : RoomDatabase() {
abstract fun getStatsDao(): StatsDao
abstract fun getScrobblingDao(): ScrobblingDao
}
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration1To2(),
Migration2To3()
Migration2To3(),
Migration3To4()
)
fun ShirizuDatabase(context: Context): ShirizuDatabase = Room

@ -11,6 +11,7 @@ import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language
import org.xtimms.shirizu.core.database.entity.FavouriteCategoryEntity
import org.xtimms.shirizu.core.database.entity.FavouriteEntity
import org.xtimms.shirizu.core.database.entity.MangaEntity
import org.xtimms.shirizu.core.model.ListSortOrder
@ -89,9 +90,6 @@ abstract class FavouritesDao {
@Query("SELECT COUNT(DISTINCT manga_id) FROM favourites WHERE deleted_at = 0")
abstract fun observeMangaCount(): Flow<Int>
@Query("SELECT COUNT(DISTINCT manga_id) FROM favourites WHERE deleted_at = 0 AND category_id = :categoryId")
abstract fun observeMangaCountInCategory(categoryId: Long): Flow<Int>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)")
abstract suspend fun findAllManga(): List<MangaEntity>
@ -99,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")
@ -107,7 +108,10 @@ 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")
@ -172,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")
}
}

@ -28,6 +28,17 @@ abstract class MangaDao {
@Query("SELECT * FROM manga WHERE source = :source")
abstract suspend fun findAllBySource(source: String): List<MangaWithTags>
@Query("SELECT author FROM manga WHERE author LIKE :query GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
abstract suspend fun findAuthors(query: String, limit: Int): List<String>
@Transaction
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
@Transaction
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND source = :source AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List<MangaWithTags>
@Upsert
abstract suspend fun upsert(manga: MangaEntity)

@ -1,6 +1,8 @@
package org.xtimms.shirizu.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction
@ -33,6 +35,10 @@ abstract class MangaSourcesDao {
@Query("UPDATE sources SET enabled = 0")
abstract suspend fun disableAllSources()
@Insert(onConflict = OnConflictStrategy.IGNORE)
@Transaction
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
@Upsert
abstract suspend fun upsert(entry: MangaSourceEntity)

@ -25,6 +25,10 @@ abstract class SuggestionDao {
@Query("SELECT * FROM suggestions ORDER BY RANDOM() LIMIT 1")
abstract suspend fun getRandom(): SuggestionWithManga?
@Transaction
@Query("SELECT * FROM suggestions ORDER BY RANDOM() LIMIT :limit")
abstract suspend fun getRandom(limit: Int): List<SuggestionWithManga>
@Query("SELECT COUNT(*) FROM suggestions")
abstract suspend fun count(): Int

@ -13,6 +13,8 @@ import org.xtimms.shirizu.core.model.MangaHistory
import org.xtimms.shirizu.core.model.MangaSource
import org.xtimms.shirizu.core.tracker.model.TrackingLogItem
import org.xtimms.shirizu.sections.shelf.FavouriteManga
import org.xtimms.shirizu.sections.shelf.ShelfCategory
import org.xtimms.shirizu.sections.shelf.ShelfManga
import org.xtimms.shirizu.utils.lang.longHashCode
import java.time.Instant
@ -44,6 +46,25 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
tags = tags,
)
fun MangaEntity.toShelfManga(tags: Set<MangaTag>) = ShelfManga(
Manga(
id = this.id,
title = this.title,
altTitle = this.altTitle,
state = this.state?.let { MangaState(it) },
rating = this.rating,
isNsfw = this.isNsfw,
url = this.url,
publicUrl = this.publicUrl,
coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl,
author = this.author,
source = MangaSource(this.source),
tags = tags,
),
category = 1
)
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
@ -56,10 +77,20 @@ fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong())
isVisibleInLibrary = isVisibleInLibrary,
)
fun FavouriteCategoryEntity.toShelfCategory(id: Long = categoryId.toLong()) = ShelfCategory(
id = id,
title = title,
mangaCount = 0
)
fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags())
fun FavouriteManga.toShelfManga() = manga.toShelfManga(tags.toMangaTags())
fun Collection<FavouriteManga>.toMangaList() = map { it.toManga() }
fun Collection<FavouriteManga>.toShelfMangaList() = map { it.toShelfManga() }
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
manga = manga,
pageId = pageId,

@ -0,0 +1,25 @@
package org.xtimms.shirizu.core.database.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration3To4 : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `scrobblings` (
`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`)
)
""".trimIndent()
)
}
}

@ -0,0 +1,18 @@
package org.xtimms.shirizu.core.model
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
data class MangaCover(
val mangaId: Long,
val source: MangaSource,
val url: String?,
)
fun Manga.asMangaCover(): MangaCover {
return MangaCover(
mangaId = id,
source = source,
url = largeCoverUrl ?: coverUrl,
)
}

@ -1,17 +0,0 @@
package org.xtimms.shirizu.core.model
import java.io.Serializable
data class ShelfCategory(
val id: Long,
val name: String,
val order: Long,
val flags: Long,
) : Serializable {
val isSystemCategory: Boolean = id == UNCATEGORIZED_ID
companion object {
const val UNCATEGORIZED_ID = 0L
}
}

@ -25,7 +25,7 @@ private val Int.ForIncoming: Int
* [materialSharedAxisX] allows to switch a layout with shared X-axis transition.
*
*/
public fun materialSharedAxisX(
fun materialSharedAxisX(
initialOffsetX: (fullWidth: Int) -> Int,
targetOffsetX: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration,
@ -40,7 +40,7 @@ public fun materialSharedAxisX(
/**
* [materialSharedAxisXIn] allows to switch a layout with shared X-axis enter transition.
*/
public fun materialSharedAxisXIn(
fun materialSharedAxisXIn(
initialOffsetX: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration,
): EnterTransition = slideInHorizontally(
@ -61,7 +61,7 @@ public fun materialSharedAxisXIn(
* [materialSharedAxisXOut] allows to switch a layout with shared X-axis exit transition.
*
*/
public fun materialSharedAxisXOut(
fun materialSharedAxisXOut(
targetOffsetX: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration,
): ExitTransition = slideOutHorizontally(

@ -0,0 +1,81 @@
package org.xtimms.shirizu.core.onboarding
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.RocketLaunch
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.ui.screens.InfoScreen
import org.xtimms.shirizu.utils.lang.materialSharedAxisX
@Composable
fun OnboardingScreen(
onComplete: () -> Unit,
) {
var currentStep by rememberSaveable { mutableIntStateOf(0) }
val steps = remember {
listOf(
SourcesStep(),
)
}
val isLastStep = currentStep == steps.lastIndex
BackHandler(enabled = currentStep != 0, onBack = { currentStep-- })
InfoScreen(
icon = Icons.Outlined.RocketLaunch,
headingText1 = stringResource(R.string.onboarding_heading),
subtitleText = stringResource(R.string.onboarding_description),
acceptText = stringResource(
if (isLastStep) {
R.string.onboarding_action_finish
} else {
R.string.onboarding_action_next
},
),
canAccept = steps[currentStep].isComplete,
onAcceptClick = {
if (isLastStep) {
onComplete()
} else {
currentStep++
}
},
) {
Box(
modifier = Modifier
.padding(vertical = 8.dp)
.clip(MaterialTheme.shapes.small)
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
AnimatedContent(
targetState = currentStep,
transitionSpec = {
materialSharedAxisX(
forward = targetState > initialState
)
},
label = "stepContent",
) {
steps[it].Content()
}
}
}
}

@ -0,0 +1,11 @@
package org.xtimms.shirizu.core.onboarding
import androidx.compose.runtime.Composable
internal interface OnboardingStep {
val isComplete: Boolean
@Composable
fun Content()
}

@ -0,0 +1,14 @@
package org.xtimms.shirizu.core.onboarding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
internal class SourcesStep : OnboardingStep {
override val isComplete: Boolean = true
@Composable
override fun Content() {
Text(text = "Hello")
}
}

@ -18,7 +18,9 @@ class NetworkState(
override fun onActive() {
invalidate()
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
}

@ -13,7 +13,6 @@ import javax.inject.Provider
@Reusable
class MangaDataRepository @Inject constructor(
private val db: ShirizuDatabase,
private val resolverProvider: Provider<MangaLinkResolver>,
) {
suspend fun findMangaById(mangaId: Long): Manga? {
@ -24,13 +23,6 @@ class MangaDataRepository @Inject constructor(
return db.getMangaDao().findByPublicUrl(publicUrl)?.toManga()
}
suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
intent.manga != null -> intent.manga
intent.mangaId != 0L -> findMangaById(intent.mangaId)
intent.uri != null -> resolverProvider.get().resolve(intent.uri)
else -> null
}
suspend fun storeManga(manga: Manga) {
db.withTransaction {
val tags = manga.tags.toEntities()

@ -1,121 +0,0 @@
package org.xtimms.shirizu.core.parser
import android.net.Uri
import coil.request.CachePolicy
import dagger.Reusable
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.almostEquals
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toRelativeUrl
import org.xtimms.shirizu.core.model.MangaSource
import org.xtimms.shirizu.data.repository.MangaSourcesRepository
import org.xtimms.shirizu.utils.lang.ifNullOrEmpty
import javax.inject.Inject
@Reusable
class MangaLinkResolver @Inject constructor(
private val repositoryFactory: MangaRepository.Factory,
private val sourcesRepository: MangaSourcesRepository,
private val dataRepository: MangaDataRepository,
) {
suspend fun resolve(uri: Uri): Manga {
return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") {
resolveAppLink(uri)
} else {
resolveExternalLink(uri)
} ?: throw NotFoundException("Cannot resolve link", uri.toString())
}
private suspend fun resolveAppLink(uri: Uri): Manga? {
require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" }
val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
val source = MangaSource(sourceName)
require(source != MangaSource.DUMMY) { "Manga source $sourceName is not supported" }
val repo = repositoryFactory.create(source)
return repo.findExact(
url = uri.getQueryParameter("url"),
title = uri.getQueryParameter("name"),
)
}
private suspend fun resolveExternalLink(uri: Uri): Manga? {
dataRepository.findMangaByPublicUrl(uri.toString())?.let {
return it
}
val host = uri.host ?: return null
val repo = sourcesRepository.allMangaSources.asSequence()
.map { source ->
repositoryFactory.create(source) as RemoteMangaRepository
}.find { repo ->
host in repo.domains
} ?: return null
return repo.findExact(uri.toString().toRelativeUrl(host), null)
}
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
if (!title.isNullOrEmpty()) {
val list = getList(0, MangaListFilter.Search(title))
if (url != null) {
list.find { it.url == url }?.let {
return it
}
}
list.minByOrNull { it.title.levenshteinDistance(title) }
?.takeIf { it.title.almostEquals(title, 0.2f) }
?.let { return it }
}
val seed = getDetailsNoCache(
getSeedManga(source, url ?: return null, title),
)
return runCatchingCancellable {
val seedTitle = seed.title.ifEmpty {
seed.altTitle
}.ifNullOrEmpty {
seed.author
} ?: return@runCatchingCancellable null
val seedList = getList(0, MangaListFilter.Search(seedTitle))
seedList.first { x -> x.url == url }
}.getOrThrow()
}
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
return if (this is RemoteMangaRepository) {
getDetails(manga, CachePolicy.READ_ONLY)
} else {
getDetails(manga)
}
}
private fun getSeedManga(source: MangaSource, url: String, title: String?) = Manga(
id = run {
var h = 1125899906842597L
source.name.forEach { c ->
h = 31 * h + c.code
}
url.forEach { c ->
h = 31 * h + c.code
}
h
},
title = title.orEmpty(),
altTitle = null,
url = url,
publicUrl = "",
rating = 0.0f,
isNsfw = source.contentType == ContentType.HENTAI,
coverUrl = "",
tags = emptySet(),
state = null,
author = null,
largeCoverUrl = null,
description = null,
chapters = null,
source = source,
)
}

@ -4,22 +4,29 @@ import android.annotation.SuppressLint
import android.content.Context
import android.util.Base64
import android.webkit.WebView
import androidx.annotation.MainThread
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.UserAgents
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.xtimms.shirizu.core.network.MangaHttpClient
import org.xtimms.shirizu.core.network.cookies.MutableCookieJar
import org.xtimms.shirizu.core.prefs.SourceSettings
import org.xtimms.shirizu.utils.system.configureForParser
import org.xtimms.shirizu.utils.system.sanitizeHeaderValue
import org.xtimms.shirizu.utils.system.toList
import java.lang.ref.WeakReference
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@ -32,8 +39,10 @@ class MangaLoaderContextImpl @Inject constructor(
private var webViewCached: WeakReference<WebView>? = null
private val webViewUserAgent by lazy { obtainWebViewUserAgent() }
@SuppressLint("SetJavaScriptEnabled")
override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) {
override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main.immediate) {
val webView = webViewCached?.get() ?: WebView(androidContext).also {
it.settings.javaScriptEnabled = true
webViewCached = WeakReference(it)
@ -49,6 +58,8 @@ class MangaLoaderContextImpl @Inject constructor(
return SourceSettings(androidContext, source)
}
override fun getDefaultUserAgent(): String = webViewUserAgent
override fun encodeBase64(data: ByteArray): String {
return Base64.encodeToString(data, Base64.NO_WRAP)
}
@ -60,4 +71,30 @@ class MangaLoaderContextImpl @Inject constructor(
override fun getPreferredLocales(): List<Locale> {
return LocaleListCompat.getAdjustedDefault().toList()
}
@MainThread
private fun obtainWebView(): WebView {
return webViewCached?.get() ?: WebView(androidContext).also {
it.configureForParser(null)
webViewCached = WeakReference(it)
}
}
private fun obtainWebViewUserAgent(): String {
val mainDispatcher = Dispatchers.Main.immediate
return if (!mainDispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
obtainWebViewUserAgentImpl()
} else {
runBlocking(mainDispatcher) {
obtainWebViewUserAgentImpl()
}
}
}
@MainThread
private fun obtainWebViewUserAgentImpl() = runCatching {
obtainWebView().settings.userAgentString.sanitizeHeaderValue()
}.onFailure { e ->
e.printStackTrace()
}.getOrDefault(UserAgents.FIREFOX_MOBILE)
}

@ -1,6 +1,7 @@
package org.xtimms.shirizu.core.parser
import androidx.annotation.AnyThread
import androidx.paging.PagingSource
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga

@ -34,7 +34,7 @@ import org.xtimms.shirizu.utils.withExtraCloseable
import java.net.HttpURLConnection
import kotlin.coroutines.coroutineContext
private const val FALLBACK_SIZE = 9999 // largest icon
private const val FALLBACK_SIZE = 99999 // largest icon
@OptIn(ExperimentalCoilApi::class)
class FaviconFetcher(

@ -3,6 +3,7 @@ package org.xtimms.shirizu.core.parser.local
import android.net.Uri
import androidx.core.net.toFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
@ -27,9 +28,10 @@ import org.xtimms.shirizu.core.parser.MangaRepository
import org.xtimms.shirizu.core.parser.local.input.LocalMangaInput
import org.xtimms.shirizu.core.parser.local.output.LocalMangaOutput
import org.xtimms.shirizu.core.parser.local.output.LocalMangaUtil
import org.xtimms.shirizu.data.LocalMangaMappingCache
import org.xtimms.shirizu.data.LocalStorageManager
import org.xtimms.shirizu.utils.AlphanumComparator
import org.xtimms.shirizu.utils.CompositeMutex2
import org.xtimms.shirizu.utils.MultiMutex
import org.xtimms.shirizu.utils.system.children
import org.xtimms.shirizu.utils.system.deleteAwait
import org.xtimms.shirizu.utils.system.filterWith
@ -48,7 +50,8 @@ class LocalMangaRepository @Inject constructor(
) : MangaRepository {
override val source = MangaSource.LOCAL
private val locks = CompositeMutex2<Long>()
private val locks = MultiMutex<Long>()
private val localMappingCache = LocalMangaMappingCache()
override val isMultipleTagsSupported: Boolean = true
override val isTagsExclusionSupported: Boolean = true
@ -133,6 +136,10 @@ class LocalMangaRepository @Inject constructor(
}
suspend fun findSavedManga(remoteManga: Manga): LocalManga? = runCatchingCancellable {
// very fast path
localMappingCache.get(remoteManga.id)?.let {
return@runCatchingCancellable it
}
// fast path
LocalMangaInput.find(storageManager.getReadableDirs(), remoteManga)?.let {
return it.getManga()
@ -154,6 +161,8 @@ class LocalMangaRepository @Inject constructor(
}
}
}.firstOrNull()?.getManga()
}.onSuccess { x: LocalManga? ->
localMappingCache[remoteManga.id] = x
}.onFailure {
it.printStackTrace()
}.getOrNull()
@ -200,6 +209,7 @@ class LocalMangaRepository @Inject constructor(
locks.unlock(id)
}
@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun getRawList(): ArrayList<LocalManga> {
val files = getAllFiles().toList() // TODO remove toList()
return coroutineScope {

@ -67,6 +67,13 @@ const val PROXY_PORT = "proxy_port"
const val PROXY_USER = "proxy_user"
const val PROXY_PASSWORD = "proxy_password"
const val MANGA = "manga_sources"
const val HENTAI = "hentai_sources"
const val COMICS = "comics_sources"
const val OTHER = "other_sources"
const val NSFW_HISTORY = "nsfw_history"
const val NOT_SPECIFIED = 0
val paletteStyles = listOf(
@ -173,6 +180,13 @@ object AppSettings {
fun isModernViewEnabled() = MODERN_VIEW.getBoolean(true)
fun isMangaContentTypeEnabled() = MANGA.getBoolean(true)
fun isHentaiContentTypeEnabled() = HENTAI.getBoolean(true)
fun isComicsContentTypeEnabled() = COMICS.getBoolean(true)
fun isOtherContentTypeEnabled() = OTHER.getBoolean(true)
fun showNsfwInHistory() = NSFW_HISTORY.getBoolean(true)
fun getGridColumnsCount(columns: Int = GRID_COLUMNS.getInt()): Float {
return when (columns) {
1 -> 1f

@ -0,0 +1,38 @@
package org.xtimms.shirizu.core.scrobbling
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import org.xtimms.shirizu.MainActivity
import org.xtimms.shirizu.core.scrobbling.services.shikimori.data.ShikimoriRepository
import org.xtimms.shirizu.core.ui.screens.LoadingScreen
import javax.inject.Inject
abstract class BaseOAuthLoginActivity : ComponentActivity() {
@Inject
internal lateinit var shikimoriRepository: ShikimoriRepository
abstract fun handleResult(data: Uri?)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
LoadingScreen()
}
handleResult(intent.data)
}
internal fun returnToSettings() {
finish()
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
startActivity(intent)
}
}

@ -0,0 +1,28 @@
package org.xtimms.shirizu.core.scrobbling
import android.net.Uri
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ScrobblingLoginActivity : BaseOAuthLoginActivity() {
override fun handleResult(data: Uri?) {
when (data?.host) {
"shikimori-auth" -> handleShikimori(data)
}
}
private fun handleShikimori(data: Uri) {
val code = data.getQueryParameter("code")
if (code != null) {
lifecycleScope.launch(Dispatchers.IO) {
shikimoriRepository.authorize(code)
returnToSettings()
}
} else {
shikimoriRepository.logout()
returnToSettings()
}
}
}

@ -0,0 +1,32 @@
package org.xtimms.shirizu.core.scrobbling.data
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerManga
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerMangaInfo
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerUser
interface ScrobblerRepository {
val oauthUrl: String
val isAuthorized: Boolean
val cachedUser: ScrobblerUser?
suspend fun authorize(code: String?)
suspend fun loadUser(): ScrobblerUser
fun logout()
suspend fun unregister(mangaId: Long)
suspend fun findManga(query: String, offset: Int): List<ScrobblerManga>
suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo
suspend fun createRate(mangaId: Long, scrobblerMangaId: Long)
suspend fun updateRate(rateId: Int, mangaId: Long, chapter: Int)
suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?)
}

@ -0,0 +1,59 @@
package org.xtimms.shirizu.core.scrobbling.data
import android.content.Context
import androidx.core.content.edit
import org.jsoup.internal.StringUtil
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerService
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerUser
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_USER = "user"
class ScrobblerStorage(context: Context, service: ScrobblerService) {
private val prefs = context.getSharedPreferences(service.name, Context.MODE_PRIVATE)
var accessToken: String?
get() = prefs.getString(KEY_ACCESS_TOKEN, null)
set(value) = prefs.edit { putString(KEY_ACCESS_TOKEN, value) }
var refreshToken: String?
get() = prefs.getString(KEY_REFRESH_TOKEN, null)
set(value) = prefs.edit { putString(KEY_REFRESH_TOKEN, value) }
var user: ScrobblerUser?
get() = prefs.getString(KEY_USER, null)?.let {
val lines = it.lines()
if (lines.size != 4) {
return@let null
}
ScrobblerUser(
id = lines[0].toLong(),
nickname = lines[1],
avatar = lines[2],
service = ScrobblerService.valueOf(lines[3]),
)
}
set(value) = prefs.edit {
if (value == null) {
remove(KEY_USER)
return@edit
}
val str = StringUtil.StringJoiner("\n")
.add(value.id)
.add(value.nickname)
.add(value.avatar)
.add(value.service.name)
.complete()
putString(KEY_USER, str)
}
operator fun get(key: String): String? = prefs.getString(key, null)
operator fun set(key: String, value: String?) = prefs.edit { putString(key, value) }
fun clear() = prefs.edit {
clear()
}
}

@ -0,0 +1,25 @@
package org.xtimms.shirizu.core.scrobbling.data
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
abstract class ScrobblingDao {
@Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
abstract suspend fun find(scrobbler: Int, mangaId: Long): ScrobblingEntity?
@Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
abstract fun observe(scrobbler: Int, mangaId: Long): Flow<ScrobblingEntity?>
@Query("SELECT * FROM scrobblings WHERE scrobbler = :scrobbler")
abstract fun observe(scrobbler: Int): Flow<List<ScrobblingEntity>>
@Upsert
abstract suspend fun upsert(entity: ScrobblingEntity)
@Query("DELETE FROM scrobblings WHERE scrobbler = :scrobbler AND manga_id = :mangaId")
abstract suspend fun delete(scrobbler: Int, mangaId: Long)
}

@ -0,0 +1,35 @@
package org.xtimms.shirizu.core.scrobbling.data
import androidx.room.ColumnInfo
import androidx.room.Entity
@Entity(
tableName = "scrobblings",
primaryKeys = ["scrobbler", "id", "manga_id"],
)
class ScrobblingEntity(
@ColumnInfo(name = "scrobbler") val scrobbler: Int,
@ColumnInfo(name = "id") val id: Int,
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "target_id") val targetId: Long,
@ColumnInfo(name = "status") val status: String?,
@ColumnInfo(name = "chapter") val chapter: Int,
@ColumnInfo(name = "comment") val comment: String?,
@ColumnInfo(name = "rating") val rating: Float,
) {
fun copy(
status: String?,
comment: String?,
rating: Float,
) = ScrobblingEntity(
scrobbler = scrobbler,
id = id,
mangaId = mangaId,
targetId = targetId,
status = status,
chapter = chapter,
comment = comment,
rating = rating,
)
}

@ -0,0 +1,162 @@
package org.xtimms.shirizu.core.scrobbling.domain
import androidx.annotation.FloatRange
import androidx.collection.LongSparseArray
import androidx.collection.getOrElse
import androidx.core.text.parseAsHtml
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xtimms.shirizu.core.database.ShirizuDatabase
import org.xtimms.shirizu.core.model.findById
import org.xtimms.shirizu.core.parser.MangaRepository
import org.xtimms.shirizu.core.scrobbling.data.ScrobblerRepository
import org.xtimms.shirizu.core.scrobbling.data.ScrobblingEntity
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerManga
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerMangaInfo
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerService
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerUser
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblingInfo
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblingStatus
import org.xtimms.shirizu.utils.lang.findKeyByValue
import org.xtimms.shirizu.utils.lang.sanitize
import java.util.EnumMap
abstract class Scrobbler(
protected val db: ShirizuDatabase,
val scrobblerService: ScrobblerService,
private val repository: ScrobblerRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
private val infoCache = LongSparseArray<ScrobblerMangaInfo>()
protected val statuses = EnumMap<ScrobblingStatus, String>(ScrobblingStatus::class.java)
val user: Flow<ScrobblerUser> = flow {
repository.cachedUser?.let {
emit(it)
}
runCatchingCancellable {
repository.loadUser()
}.onSuccess {
emit(it)
}.onFailure {
it.printStackTrace()
}
}
val isAvailable: Boolean
get() = repository.isAuthorized
suspend fun authorize(authCode: String): ScrobblerUser {
repository.authorize(authCode)
return repository.loadUser()
}
fun logout() {
repository.logout()
}
suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> {
return repository.findManga(query, offset)
}
suspend fun linkManga(mangaId: Long, targetId: Long) {
repository.createRate(mangaId, targetId)
}
suspend fun scrobble(manga: Manga, chapterId: Long) {
var chapters = manga.chapters
if (chapters.isNullOrEmpty()) {
chapters = mangaRepositoryFactory.create(manga.source).getDetails(manga).chapters
}
requireNotNull(chapters)
val chapter = checkNotNull(chapters.findById(chapterId)) {
"Chapter $chapterId not found in this manga"
}
val number = if (chapter.number > 0f) {
chapter.number.toInt()
} else {
chapters = chapters.filter { x -> x.branch == chapter.branch }
chapters.indexOf(chapter) + 1
}
val entity = db.getScrobblingDao().find(scrobblerService.id, manga.id) ?: return
repository.updateRate(entity.id, entity.mangaId, number)
}
suspend fun getScrobblingInfoOrNull(mangaId: Long): ScrobblingInfo? {
val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId) ?: return null
return entity.toScrobblingInfo()
}
abstract suspend fun updateScrobblingInfo(
mangaId: Long,
@FloatRange(from = 0.0, to = 1.0) rating: Float,
status: ScrobblingStatus?,
comment: String?,
)
fun observeScrobblingInfo(mangaId: Long): Flow<ScrobblingInfo?> {
return db.getScrobblingDao().observe(scrobblerService.id, mangaId)
.map { it?.toScrobblingInfo() }
}
fun observeAllScrobblingInfo(): Flow<List<ScrobblingInfo>> {
return db.getScrobblingDao().observe(scrobblerService.id)
.map { entities ->
coroutineScope {
entities.map {
async {
it.toScrobblingInfo()
}
}.awaitAll()
}.filterNotNull()
}
}
suspend fun unregisterScrobbling(mangaId: Long) {
repository.unregister(mangaId)
}
protected suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
return repository.getMangaInfo(id)
}
private suspend fun ScrobblingEntity.toScrobblingInfo(): ScrobblingInfo? {
val mangaInfo = infoCache.getOrElse(targetId) {
runCatchingCancellable {
getMangaInfo(targetId)
}.onFailure {
it.printStackTrace()
}.onSuccess {
infoCache.put(targetId, it)
}.getOrNull() ?: return null
}
return ScrobblingInfo(
scrobbler = scrobblerService,
mangaId = mangaId,
targetId = targetId,
status = statuses.findKeyByValue(status),
chapter = chapter,
comment = comment,
rating = rating,
title = mangaInfo.name,
coverUrl = mangaInfo.cover,
description = mangaInfo.descriptionHtml.parseAsHtml().sanitize(),
externalUrl = mangaInfo.url,
)
}
}
suspend fun Scrobbler.tryScrobble(manga: Manga, chapterId: Long): Boolean {
return runCatchingCancellable {
scrobble(manga, chapterId)
}.onFailure {
it.printStackTrace()
}.isSuccess
}

@ -0,0 +1,19 @@
package org.xtimms.shirizu.core.scrobbling.domain.model
import org.xtimms.shirizu.core.model.ListModel
data class ScrobblerManga(
val id: Long,
val name: String,
val altName: String?,
val cover: String,
val url: String,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is ScrobblerManga && other.id == id
}
override fun toString(): String {
return "ScrobblerManga #$id \"$name\" $url"
}
}

@ -0,0 +1,9 @@
package org.xtimms.shirizu.core.scrobbling.domain.model
class ScrobblerMangaInfo(
val id: Long,
val name: String,
val cover: String,
val url: String,
val descriptionHtml: String,
)

@ -0,0 +1,14 @@
package org.xtimms.shirizu.core.scrobbling.domain.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.xtimms.shirizu.R
enum class ScrobblerService(
val id: Int,
@StringRes val titleResId: Int,
@DrawableRes val iconResId: Int,
) {
SHIKIMORI(1, R.string.shikimori, R.drawable.ic_shikimori),
KITSU(2, R.string.kitsu, R.drawable.ic_kitsu)
}

@ -0,0 +1,8 @@
package org.xtimms.shirizu.core.scrobbling.domain.model
import javax.inject.Qualifier
@Qualifier
annotation class ScrobblerType(
val service: ScrobblerService
)

@ -0,0 +1,8 @@
package org.xtimms.shirizu.core.scrobbling.domain.model
data class ScrobblerUser(
val id: Long,
val nickname: String,
val avatar: String?,
val service: ScrobblerService,
)

@ -0,0 +1,22 @@
package org.xtimms.shirizu.core.scrobbling.domain.model
import org.xtimms.shirizu.core.model.ListModel
data class ScrobblingInfo(
val scrobbler: ScrobblerService,
val mangaId: Long,
val targetId: Long,
val status: ScrobblingStatus?,
val chapter: Int,
val comment: String?,
val rating: Float,
val title: String,
val coverUrl: String,
val description: CharSequence?,
val externalUrl: String,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is ScrobblingInfo && other.scrobbler == scrobbler
}
}

@ -0,0 +1,12 @@
package org.xtimms.shirizu.core.scrobbling.domain.model
import org.xtimms.shirizu.core.model.ListModel
enum class ScrobblingStatus : ListModel {
PLANNED, READING, RE_READING, COMPLETED, ON_HOLD, DROPPED;
override fun areItemsTheSame(other: ListModel): Boolean {
return other is ScrobblingStatus && other.ordinal == ordinal
}
}

@ -0,0 +1,54 @@
package org.xtimms.shirizu.core.scrobbling.services.kitsu.data
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.xtimms.shirizu.core.network.CommonHeaders
import org.xtimms.shirizu.core.scrobbling.data.ScrobblerStorage
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerService
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerType
import javax.inject.Inject
import javax.inject.Provider
class KitsuAuthenticator @Inject constructor(
@ScrobblerType(ScrobblerService.KITSU) private val storage: ScrobblerStorage,
private val repositoryProvider: Provider<KitsuRepository>,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val accessToken = storage.accessToken ?: return null
if (!isRequestWithAccessToken(response)) {
return null
}
synchronized(this) {
val newAccessToken = storage.accessToken ?: return null
if (accessToken != newAccessToken) {
return newRequestWithAccessToken(response.request, newAccessToken)
}
val updatedAccessToken = refreshAccessToken() ?: return null
return newRequestWithAccessToken(response.request, updatedAccessToken)
}
}
private fun isRequestWithAccessToken(response: Response): Boolean {
val header = response.request.header(CommonHeaders.AUTHORIZATION)
return header?.startsWith("Bearer") == true
}
private fun newRequestWithAccessToken(request: Request, accessToken: String): Request {
return request.newBuilder()
.header(CommonHeaders.AUTHORIZATION, "Bearer $accessToken")
.build()
}
private fun refreshAccessToken(): String? = runCatching {
val repository = repositoryProvider.get()
runBlocking { repository.authorize(null) }
return storage.accessToken
}.onFailure {
it.printStackTrace()
}.getOrNull()
}

@ -0,0 +1,27 @@
package org.xtimms.shirizu.core.scrobbling.services.kitsu.data
import okhttp3.Interceptor
import okhttp3.Response
import org.xtimms.shirizu.core.network.CommonHeaders
import org.xtimms.shirizu.core.scrobbling.data.ScrobblerStorage
class KitsuInterceptor(private val storage: ScrobblerStorage) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val sourceRequest = chain.request()
val request = sourceRequest.newBuilder()
request.header(CommonHeaders.CONTENT_TYPE, VND_JSON)
request.header(CommonHeaders.ACCEPT, VND_JSON)
if (!sourceRequest.url.pathSegments.contains("oauth")) {
storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
}
}
return chain.proceed(request.build())
}
companion object {
const val VND_JSON = "application/vnd.api+json"
}
}

@ -0,0 +1,244 @@
package org.xtimms.shirizu.core.scrobbling.services.kitsu.data
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okio.IOException
import org.json.JSONObject
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.parsers.util.urlEncoded
import org.xtimms.shirizu.core.database.ShirizuDatabase
import org.xtimms.shirizu.core.scrobbling.data.ScrobblerRepository
import org.xtimms.shirizu.core.scrobbling.data.ScrobblerStorage
import org.xtimms.shirizu.core.scrobbling.data.ScrobblingEntity
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerManga
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerMangaInfo
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerService
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerUser
import org.xtimms.shirizu.core.scrobbling.services.kitsu.data.KitsuInterceptor.Companion.VND_JSON
import org.xtimms.shirizu.utils.system.parseJsonOrNull
private const val BASE_WEB_URL = "https://kitsu.io"
class KitsuRepository(
@ApplicationContext context: Context,
private val okHttp: OkHttpClient,
private val storage: ScrobblerStorage,
private val db: ShirizuDatabase,
) : ScrobblerRepository {
override val oauthUrl: String = "kotatsu+kitsu://auth"
override val isAuthorized: Boolean
get() = storage.accessToken != null
override val cachedUser: ScrobblerUser?
get() {
return storage.user
}
override suspend fun authorize(code: String?) {
val body = FormBody.Builder()
if (code != null) {
body.add("grant_type", "password")
body.add("username", code.substringBefore(';'))
body.add("password", code.substringAfter(';'))
} else {
body.add("grant_type", "refresh_token")
body.add("refresh_token", checkNotNull(storage.refreshToken))
}
val request = Request.Builder()
.post(body.build())
.url("$BASE_WEB_URL/api/oauth/token")
val response = okHttp.newCall(request.build()).await().parseJson()
storage.accessToken = response.getString("access_token")
storage.refreshToken = response.getString("refresh_token")
}
override suspend fun loadUser(): ScrobblerUser {
val request = Request.Builder()
.get()
.url("$BASE_WEB_URL/api/edge/users?filter[self]=true")
val response = okHttp.newCall(request.build()).await().parseJson()
.getJSONArray("data")
.getJSONObject(0)
return ScrobblerUser(
id = response.getAsLong("id"),
nickname = response.getJSONObject("attributes").getString("name"),
avatar = response.getJSONObject("attributes").optJSONObject("avatar")?.getStringOrNull("small"),
service = ScrobblerService.KITSU,
).also { storage.user = it }
}
override fun logout() {
storage.clear()
}
override suspend fun unregister(mangaId: Long) {
return db.getScrobblingDao().delete(ScrobblerService.KITSU.id, mangaId)
}
override suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> {
val request = Request.Builder()
.get()
.url("$BASE_WEB_URL/api/edge/manga?page[limit]=20&page[offset]=$offset&filter[text]=${query.urlEncoded()}")
val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess()
return response.getJSONArray("data").mapJSON { jo ->
val attrs = jo.getJSONObject("attributes")
val titles = attrs.getJSONObject("titles").valuesToStringList()
ScrobblerManga(
id = jo.getAsLong("id"),
name = titles.first(),
altName = titles.drop(1).joinToString(),
cover = attrs.getJSONObject("posterImage").getStringOrNull("small").orEmpty(),
url = "$BASE_WEB_URL/manga/${attrs.getString("slug")}",
)
}
}
override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
val request = Request.Builder()
.get()
.url("$BASE_WEB_URL/api/edge/manga/$id")
val data = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data")
val attrs = data.getJSONObject("attributes")
return ScrobblerMangaInfo(
id = data.getAsLong("id"),
name = attrs.getString("canonicalTitle"),
cover = attrs.getJSONObject("posterImage").getString("medium"),
url = "$BASE_WEB_URL/manga/${attrs.getString("slug")}",
descriptionHtml = attrs.getString("description").replace("\\n", "<br>"),
)
}
override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) {
findExistingRate(scrobblerMangaId)?.let {
saveRate(it, mangaId)
return
}
val user = cachedUser ?: loadUser()
val payload = JSONObject()
payload.putJO("data") {
put("type", "libraryEntries")
putJO("attributes") {
put("status", "planned") // will be updated by next call
put("progress", 0)
}
putJO("relationships") {
putJO("manga") {
putJO("data") {
put("type", "manga")
put("id", scrobblerMangaId)
}
}
putJO("user") {
putJO("data") {
put("type", "users")
put("id", user.id)
}
}
}
}
val request = Request.Builder()
.url("$BASE_WEB_URL/api/edge/library-entries?include=manga")
.post(payload.toKitsuRequestBody())
val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data")
saveRate(response, mangaId)
}
override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: Int) {
val payload = JSONObject()
payload.putJO("data") {
put("type", "libraryEntries")
put("id", rateId)
putJO("attributes") {
put("progress", chapter)
}
}
val request = Request.Builder()
.url("$BASE_WEB_URL/api/edge/library-entries/$rateId?include=manga")
.patch(payload.toKitsuRequestBody())
val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data")
saveRate(response, mangaId)
}
override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) {
val payload = JSONObject()
payload.putJO("data") {
put("type", "libraryEntries")
put("id", rateId)
putJO("attributes") {
put("status", status)
put("ratingTwenty", (rating * 20).toInt().coerceIn(2, 20))
put("notes", comment)
}
}
val request = Request.Builder()
.url("$BASE_WEB_URL/api/edge/library-entries/$rateId?include=manga")
.patch(payload.toKitsuRequestBody())
val response = okHttp.newCall(request.build()).await().parseJson().ensureSuccess().getJSONObject("data")
saveRate(response, mangaId)
}
private fun JSONObject.valuesToStringList(): List<String> {
val result = ArrayList<String>(length())
for (key in keys()) {
result.add(getStringOrNull(key) ?: continue)
}
return result
}
private inline fun JSONObject.putJO(name: String, init: JSONObject.() -> Unit) {
put(name, JSONObject().apply(init))
}
private fun JSONObject.toKitsuRequestBody() = toString().toRequestBody(VND_JSON.toMediaType())
private suspend fun findExistingRate(scrobblerMangaId: Long): JSONObject? {
val userId = (cachedUser ?: loadUser()).id
val request = Request.Builder()
.get()
.url("$BASE_WEB_URL/api/edge/library-entries?filter[manga_id]=$scrobblerMangaId&filter[userId]=$userId&include=manga")
val data = okHttp.newCall(request.build()).await().parseJsonOrNull()?.optJSONArray("data") ?: return null
return data.optJSONObject(0)
}
private suspend fun saveRate(json: JSONObject, mangaId: Long) {
val attrs = json.getJSONObject("attributes")
val manga = json.getJSONObject("relationships").getJSONObject("manga").getJSONObject("data")
val entity = ScrobblingEntity(
scrobbler = ScrobblerService.KITSU.id,
id = json.getInt("id"),
mangaId = mangaId,
targetId = manga.getAsLong("id"),
status = attrs.getString("status"),
chapter = attrs.getIntOrDefault("progress", 0),
comment = attrs.getStringOrNull("notes"),
rating = (attrs.getFloatOrDefault("ratingTwenty", 0f) / 20f).coerceIn(0f, 1f),
)
db.getScrobblingDao().upsert(entity)
}
private fun JSONObject.ensureSuccess(): JSONObject {
val error = optJSONArray("errors")?.optJSONObject(0) ?: return this
val title = error.getString("title")
val detail = error.getStringOrNull("detail")
throw IOException("$title: $detail")
}
private fun JSONObject.getAsLong(name: String): Long = when (val rawValue = opt(name)) {
is Long -> rawValue
is Number -> rawValue.toLong()
is String -> rawValue.toLong()
else -> throw IllegalArgumentException("Value $rawValue at \"$name\" is not of type long")
}
}

@ -0,0 +1,42 @@
package org.xtimms.shirizu.core.scrobbling.services.kitsu.domain
import org.xtimms.shirizu.core.database.ShirizuDatabase
import org.xtimms.shirizu.core.parser.MangaRepository
import org.xtimms.shirizu.core.scrobbling.domain.Scrobbler
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerService
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblingStatus
import org.xtimms.shirizu.core.scrobbling.services.kitsu.data.KitsuRepository
import javax.inject.Inject
class KitsuScrobbler @Inject constructor(
private val repository: KitsuRepository,
db: ShirizuDatabase,
mangaRepositoryFactory: MangaRepository.Factory,
) : Scrobbler(db, ScrobblerService.KITSU, repository, mangaRepositoryFactory) {
init {
statuses[ScrobblingStatus.PLANNED] = "planned"
statuses[ScrobblingStatus.READING] = "current"
statuses[ScrobblingStatus.COMPLETED] = "completed"
statuses[ScrobblingStatus.ON_HOLD] = "on_hold"
statuses[ScrobblingStatus.DROPPED] = "dropped"
}
override suspend fun updateScrobblingInfo(
mangaId: Long,
rating: Float,
status: ScrobblingStatus?,
comment: String?
) {
val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId)
requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" }
repository.updateRate(
rateId = entity.id,
mangaId = entity.mangaId,
rating = rating,
status = statuses[status],
comment = comment,
)
}
}

@ -0,0 +1,53 @@
package org.xtimms.shirizu.core.scrobbling.services.shikimori.data
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.xtimms.shirizu.core.network.CommonHeaders
import org.xtimms.shirizu.core.scrobbling.data.ScrobblerStorage
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerService
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerType
import javax.inject.Inject
import javax.inject.Provider
class ShikimoriAuthenticator @Inject constructor(
@ScrobblerType(ScrobblerService.SHIKIMORI) private val storage: ScrobblerStorage,
private val repositoryProvider: Provider<ShikimoriRepository>,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val accessToken = storage.accessToken ?: return null
if (!isRequestWithAccessToken(response)) {
return null
}
synchronized(this) {
val newAccessToken = storage.accessToken ?: return null
if (accessToken != newAccessToken) {
return newRequestWithAccessToken(response.request, newAccessToken)
}
val updatedAccessToken = refreshAccessToken() ?: return null
return newRequestWithAccessToken(response.request, updatedAccessToken)
}
}
private fun isRequestWithAccessToken(response: Response): Boolean {
val header = response.request.header(CommonHeaders.AUTHORIZATION)
return header?.startsWith("Bearer") == true
}
private fun newRequestWithAccessToken(request: Request, accessToken: String): Request {
return request.newBuilder()
.header(CommonHeaders.AUTHORIZATION, "Bearer $accessToken")
.build()
}
private fun refreshAccessToken(): String? = runCatching {
val repository = repositoryProvider.get()
runBlocking { repository.authorize(null) }
return storage.accessToken
}.onFailure {
it.printStackTrace()
}.getOrNull()
}

@ -0,0 +1,28 @@
package org.xtimms.shirizu.core.scrobbling.services.shikimori.data
import okhttp3.Interceptor
import okhttp3.Response
import okio.IOException
import org.xtimms.shirizu.core.network.CommonHeaders
import org.xtimms.shirizu.core.scrobbling.data.ScrobblerStorage
private const val USER_AGENT_SHIKIMORI = "Kotatsu"
class ShikimoriInterceptor(private val storage: ScrobblerStorage) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val sourceRequest = chain.request()
val request = sourceRequest.newBuilder()
request.header(CommonHeaders.USER_AGENT, USER_AGENT_SHIKIMORI)
if (!sourceRequest.url.pathSegments.contains("oauth")) {
storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
}
}
val response = chain.proceed(request.build())
if (!response.isSuccessful && !response.isRedirect) {
throw IOException("${response.code} ${response.message}")
}
return response
}
}

@ -0,0 +1,218 @@
package org.xtimms.shirizu.core.scrobbling.services.shikimori.data
import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.parsers.util.parseJsonArray
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
import org.xtimms.shirizu.BuildConfig
import org.xtimms.shirizu.core.database.ShirizuDatabase
import org.xtimms.shirizu.core.scrobbling.data.ScrobblerRepository
import org.xtimms.shirizu.core.scrobbling.data.ScrobblerStorage
import org.xtimms.shirizu.core.scrobbling.data.ScrobblingEntity
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerManga
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerMangaInfo
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerService
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerType
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerUser
import org.xtimms.shirizu.utils.system.toRequestBody
import javax.inject.Inject
import javax.inject.Singleton
private const val DOMAIN = "shikimori.one"
private const val REDIRECT_URI = "shrz://shikimori-auth"
private const val BASE_URL = "https://$DOMAIN/"
private const val MANGA_PAGE_SIZE = 10
@Singleton
class ShikimoriRepository @Inject constructor(
@ScrobblerType(ScrobblerService.SHIKIMORI) private val okHttp: OkHttpClient,
@ScrobblerType(ScrobblerService.SHIKIMORI) private val storage: ScrobblerStorage,
private val db: ShirizuDatabase,
) : ScrobblerRepository {
private val clientId = BuildConfig.SHIKIMORI_CLIENT_ID
private val clientSecret = BuildConfig.SHIKIMORI_CLIENT_SECRET
override val oauthUrl: String
get() = "${BASE_URL}oauth/authorize?client_id=$clientId&" +
"redirect_uri=$REDIRECT_URI&response_type=code&scope="
override val isAuthorized: Boolean
get() = storage.accessToken != null
override suspend fun authorize(code: String?) {
val body = FormBody.Builder()
body.add("client_id", clientId)
body.add("client_secret", clientSecret)
if (code != null) {
body.add("grant_type", "authorization_code")
body.add("redirect_uri", REDIRECT_URI)
body.add("code", code)
} else {
body.add("grant_type", "refresh_token")
body.add("refresh_token", checkNotNull(storage.refreshToken))
}
val request = Request.Builder()
.post(body.build())
.url("${BASE_URL}oauth/token")
val response = okHttp.newCall(request.build()).await().parseJson()
storage.accessToken = response.getString("access_token")
storage.refreshToken = response.getString("refresh_token")
}
override suspend fun loadUser(): ScrobblerUser {
val request = Request.Builder()
.get()
.url("${BASE_URL}api/users/whoami")
val response = okHttp.newCall(request.build()).await().parseJson()
return ShikimoriUser(response).also { storage.user = it }
}
override val cachedUser: ScrobblerUser?
get() {
return storage.user
}
override suspend fun unregister(mangaId: Long) {
return db.getScrobblingDao().delete(ScrobblerService.SHIKIMORI.id, mangaId)
}
override fun logout() {
storage.clear()
}
override suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> {
val page = offset / MANGA_PAGE_SIZE
val pageOffset = offset % MANGA_PAGE_SIZE
val url = BASE_URL.toHttpUrl().newBuilder()
.addPathSegment("api")
.addPathSegment("mangas")
.addEncodedQueryParameter("page", (page + 1).toString())
.addEncodedQueryParameter("limit", MANGA_PAGE_SIZE.toString())
.addEncodedQueryParameter("censored", false.toString())
.addQueryParameter("search", query)
.build()
val request = Request.Builder().url(url).get().build()
val response = okHttp.newCall(request).await().parseJsonArray()
val list = response.mapJSON { ScrobblerManga(it) }
return if (pageOffset != 0) list.drop(pageOffset) else list
}
override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) {
val user = cachedUser ?: loadUser()
val payload = JSONObject()
payload.put(
"user_rate",
JSONObject().apply {
put("target_id", scrobblerMangaId)
put("target_type", "Manga")
put("user_id", user.id)
},
)
val url = BASE_URL.toHttpUrl().newBuilder()
.addPathSegment("api")
.addPathSegment("v2")
.addPathSegment("user_rates")
.build()
val request = Request.Builder().url(url).post(payload.toRequestBody()).build()
val response = okHttp.newCall(request).await().parseJson()
saveRate(response, mangaId)
}
override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: Int) {
val payload = JSONObject()
payload.put(
"user_rate",
JSONObject().apply {
put("chapters", chapter)
},
)
val url = BASE_URL.toHttpUrl().newBuilder()
.addPathSegment("api")
.addPathSegment("v2")
.addPathSegment("user_rates")
.addPathSegment(rateId.toString())
.build()
val request = Request.Builder().url(url).patch(payload.toRequestBody()).build()
val response = okHttp.newCall(request).await().parseJson()
saveRate(response, mangaId)
}
override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) {
val payload = JSONObject()
payload.put(
"user_rate",
JSONObject().apply {
put("score", rating.toString())
if (comment != null) {
put("text", comment)
}
if (status != null) {
put("status", status)
}
},
)
val url = BASE_URL.toHttpUrl().newBuilder()
.addPathSegment("api")
.addPathSegment("v2")
.addPathSegment("user_rates")
.addPathSegment(rateId.toString())
.build()
val request = Request.Builder().url(url).patch(payload.toRequestBody()).build()
val response = okHttp.newCall(request).await().parseJson()
saveRate(response, mangaId)
}
override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
val request = Request.Builder()
.get()
.url("${BASE_URL}api/mangas/$id")
val response = okHttp.newCall(request.build()).await().parseJson()
return ScrobblerMangaInfo(response)
}
private suspend fun saveRate(json: JSONObject, mangaId: Long) {
val entity = ScrobblingEntity(
scrobbler = ScrobblerService.SHIKIMORI.id,
id = json.getInt("id"),
mangaId = mangaId,
targetId = json.getLong("target_id"),
status = json.getString("status"),
chapter = json.getInt("chapters"),
comment = json.getString("text"),
rating = (json.getDouble("score").toFloat() / 10f).coerceIn(0f, 1f),
)
db.getScrobblingDao().upsert(entity)
}
private fun ScrobblerManga(json: JSONObject) = ScrobblerManga(
id = json.getLong("id"),
name = json.getString("name"),
altName = json.getStringOrNull("russian"),
cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl(DOMAIN),
url = json.getString("url").toAbsoluteUrl(DOMAIN),
)
private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo(
id = json.getLong("id"),
name = json.getString("name"),
cover = json.getJSONObject("image").getString("preview").toAbsoluteUrl(DOMAIN),
url = json.getString("url").toAbsoluteUrl(DOMAIN),
descriptionHtml = json.getString("description_html"),
)
@Suppress("FunctionName")
private fun ShikimoriUser(json: JSONObject) = ScrobblerUser(
id = json.getLong("id"),
nickname = json.getString("nickname"),
avatar = json.getStringOrNull("avatar"),
service = ScrobblerService.SHIKIMORI,
)
}

@ -0,0 +1,46 @@
package org.xtimms.shirizu.core.scrobbling.services.shikimori.domain
import org.xtimms.shirizu.core.database.ShirizuDatabase
import org.xtimms.shirizu.core.parser.MangaRepository
import org.xtimms.shirizu.core.scrobbling.domain.Scrobbler
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblerService
import org.xtimms.shirizu.core.scrobbling.domain.model.ScrobblingStatus
import org.xtimms.shirizu.core.scrobbling.services.shikimori.data.ShikimoriRepository
import javax.inject.Inject
import javax.inject.Singleton
private const val RATING_MAX = 10f
@Singleton
class ShikimoriScrobbler @Inject constructor(
private val repository: ShikimoriRepository,
db: ShirizuDatabase,
mangaRepositoryFactory: MangaRepository.Factory,
) : Scrobbler(db, ScrobblerService.SHIKIMORI, repository, mangaRepositoryFactory) {
init {
statuses[ScrobblingStatus.PLANNED] = "planned"
statuses[ScrobblingStatus.READING] = "watching"
statuses[ScrobblingStatus.RE_READING] = "rewatching"
statuses[ScrobblingStatus.COMPLETED] = "completed"
statuses[ScrobblingStatus.ON_HOLD] = "on_hold"
statuses[ScrobblingStatus.DROPPED] = "dropped"
}
override suspend fun updateScrobblingInfo(
mangaId: Long,
rating: Float,
status: ScrobblingStatus?,
comment: String?,
) {
val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId)
requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" }
repository.updateRate(
rateId = entity.id,
mangaId = entity.mangaId,
rating = rating * RATING_MAX,
status = statuses[status],
comment = comment,
)
}
}

@ -1,7 +1,6 @@
package org.xtimms.shirizu.core.tracker
import androidx.annotation.VisibleForTesting
import androidx.collection.MutableLongSet
import coil.request.CachePolicy
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@ -12,7 +11,7 @@ import org.xtimms.shirizu.core.tracker.model.MangaTracking
import org.xtimms.shirizu.core.tracker.model.MangaUpdates
import org.xtimms.shirizu.data.repository.HistoryRepository
import org.xtimms.shirizu.data.repository.TrackingRepository
import org.xtimms.shirizu.utils.CompositeMutex2
import org.xtimms.shirizu.utils.MultiMutex
import org.xtimms.shirizu.work.tracker.TrackerNotificationChannels
import org.xtimms.shirizu.work.tracker.TrackingItem
import javax.inject.Inject
@ -119,7 +118,7 @@ class Tracker @Inject constructor(
private companion object {
private val mangaMutex = CompositeMutex2<Long>()
private val mangaMutex = MultiMutex<Long>()
@OptIn(ExperimentalContracts::class)
suspend inline fun <T> withMangaLock(id: Long, action: () -> T): T {

@ -57,7 +57,7 @@ import org.xtimms.shirizu.utils.material.combineColors
fun InfoScreen(
icon: ImageVector,
headingText1: String,
headingText2: String,
headingText2: String? = null,
subtitleText: String,
acceptText: String,
onAcceptClick: () -> Unit,
@ -182,10 +182,12 @@ fun InfoScreen(
else -> headingText2
}
AnimatedContent(targetState = heading, label = "heading animation") {
Text(
text = it,
style = MaterialTheme.typography.headlineLarge,
)
if (it != null) {
Text(
text = it,
style = MaterialTheme.typography.headlineLarge,
)
}
}
Text(
text = subtitleText,

@ -0,0 +1,99 @@
package org.xtimms.shirizu.core.ui.screens
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Tab
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.zIndex
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch
import org.xtimms.shirizu.core.components.Scaffold
import org.xtimms.shirizu.core.components.TabText
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TabbedScreen(
@StringRes titleRes: Int,
tabs: ImmutableList<TabContent>,
startIndex: Int? = null,
searchQuery: String? = null,
onChangeSearchQuery: (String?) -> Unit = {},
) {
val scope = rememberCoroutineScope()
val state = rememberPagerState { tabs.size }
val snackbarHostState = remember { SnackbarHostState() }
val scroll = rememberLazyListState()
val navigator = LocalNavigator.currentOrThrow
LaunchedEffect(startIndex) {
if (startIndex != null) {
state.scrollToPage(startIndex)
}
}
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { contentPadding ->
Column(
modifier = Modifier.padding(
top = contentPadding.calculateTopPadding(),
start = contentPadding.calculateStartPadding(LocalLayoutDirection.current),
end = contentPadding.calculateEndPadding(LocalLayoutDirection.current),
),
) {
PrimaryTabRow(
selectedTabIndex = state.currentPage,
modifier = Modifier.zIndex(1f),
) {
tabs.forEachIndexed { index, tab ->
Tab(
selected = state.currentPage == index,
onClick = { scope.launch { state.animateScrollToPage(index) } },
text = { TabText(text = stringResource(tab.titleRes), badgeCount = tab.badgeNumber) },
unselectedContentColor = MaterialTheme.colorScheme.onSurface,
)
}
}
HorizontalPager(
modifier = Modifier.fillMaxSize(),
state = state,
verticalAlignment = Alignment.Top,
) { page ->
tabs[page].content(
PaddingValues(bottom = contentPadding.calculateBottomPadding()),
snackbarHostState,
)
}
}
}
}
data class TabContent(
@StringRes val titleRes: Int,
val badgeNumber: Int? = null,
val searchEnabled: Boolean = false,
val content: @Composable (contentPadding: PaddingValues, snackbarHostState: SnackbarHostState) -> Unit,
)

@ -22,7 +22,7 @@ class CrashActivity : ComponentActivity() {
val exception = GlobalExceptionHandler.getThrowableFromIntent(intent)
setContent {
SettingsProvider(LocalWindowWidthState.current) {
SettingsProvider {
ShirizuTheme(
darkTheme = LocalDarkTheme.current.isDarkTheme(),
isDynamicColorEnabled = LocalDynamicColorSwitch.current,

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

Loading…
Cancel
Save