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"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="AppInsightsSettings"> <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> </component>
</project> </project>

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

@ -3,4 +3,280 @@
<component name="ScreenshotViewer"> <component name="ScreenshotViewer">
<option name="frameScreenshot" value="true" /> <option name="frameScreenshot" value="true" />
</component> </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> </project>

@ -15,16 +15,17 @@ Shirizu (シリーズ, from Japanese - series) - An attempt to write an Android
No, nothing works. No, nothing works.
## Screens ## Screens
| Shelf | Details | History | | Library | Explore | Search |
|:--------------------------------------------:|:------------------------------------------------:|:------------------------------------------------:| |:------------------------------------------:|:--------------------------------------------:|:-------------------------------------------:|
| ![Shelf light theme](./images/shelf.png) | ![Details light theme](./images/details.png) | ![History light theme](./images/history.png) | | ![Shelf light theme](./images/library.png) | ![Details light theme](./images/explore.png) | ![History light theme](./images/search.png) |
| ![Shelf dark theme](./images/shelf_dark.png) | ![Details dark theme](./images/details_dark.png) | ![History dark theme](./images/history_dark.png) |
## Acknowledgements ## Acknowledgements
- [Kotatsu](https://github.com/KotatsuApp/Kotatsu) - UI, parsers, under the hood - [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 - [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 ## License

@ -20,6 +20,11 @@ val acraAuthLogin: String =
val acraAuthPassword: String = val acraAuthPassword: String =
gradleLocalProperties(rootDir).getProperty("authPassword") ?: "\"acra_password\"" gradleLocalProperties(rootDir).getProperty("authPassword") ?: "\"acra_password\""
val shikimoriClientId: String =
gradleLocalProperties(rootDir).getProperty("shikimoriClientId") ?: "\"shikimori\""
val shikimoriClientSecret: String =
gradleLocalProperties(rootDir).getProperty("shikimoriClientSecret") ?: "\"shikimori\""
android { android {
namespace = "org.xtimms.shirizu" namespace = "org.xtimms.shirizu"
compileSdk = 34 compileSdk = 34
@ -39,6 +44,9 @@ android {
buildConfigField("String", "ACRA_AUTH_LOGIN", acraAuthLogin) buildConfigField("String", "ACRA_AUTH_LOGIN", acraAuthLogin)
buildConfigField("String", "ACRA_AUTH_PASSWORD", acraAuthPassword) buildConfigField("String", "ACRA_AUTH_PASSWORD", acraAuthPassword)
buildConfigField("String", "SHIKIMORI_CLIENT_ID", shikimoriClientId)
buildConfigField("String", "SHIKIMORI_CLIENT_SECRET", shikimoriClientSecret)
testInstrumentationRunner = "org.xtimms.shirizu.HiltTestRunner" testInstrumentationRunner = "org.xtimms.shirizu.HiltTestRunner"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@ -107,32 +115,52 @@ android {
androidResources { androidResources {
generateLocaleConfig = true generateLocaleConfig = true
} }
kapt {
correctErrorTypes = true
}
} }
dependencies { dependencies {
// AndroidX
implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.core:core-splashscreen:1.0.1") implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-process:2.7.0") implementation("androidx.lifecycle:lifecycle-process:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2") 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.animation:animation-graphics")
implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-text-google-fonts")
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material:material-icons-extended:1.6.5") implementation("androidx.compose.material:material-icons-extended:1.6.5")
implementation("androidx.compose.material3:material3-android:1.2.1") implementation("androidx.compose.material3:material3-android:1.2.1")
implementation("androidx.compose.material3:material3-window-size-class: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.hilt:hilt-navigation-compose:1.2.0")
implementation("androidx.navigation:navigation-compose:2.7.7") 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.preference:preference-ktx:1.2.1")
implementation("androidx.profileinstaller:profileinstaller:1.3.1") implementation("androidx.profileinstaller:profileinstaller:1.3.1")
implementation("androidx.room:room-runtime:2.6.1") implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1") implementation("androidx.room:room-ktx:2.6.1")
implementation("androidx.work:work-runtime-ktx:2.9.0") implementation("androidx.work:work-runtime-ktx:2.9.0")
implementation("androidx.room:room-testing:2.6.1") implementation("androidx.room:room-testing:2.6.1")
implementation("androidx.webkit:webkit:1.11.0")
ksp("androidx.room:room-compiler:2.6.1") 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("ch.acra:acra-http:5.9.7")
implementation("com.github.solkin:disk-lru-cache:1.4") implementation("com.github.solkin:disk-lru-cache:1.4")
implementation("com.google.android.material:material:1.11.0") 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:0.32.0")
implementation("com.google.accompanist:accompanist-pager-indicators: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.accompanist:accompanist-permissions:0.32.0")
implementation("com.google.dagger:hilt-android:2.51") implementation("com.google.dagger:hilt-android:2.51.1")
kapt("com.google.dagger:hilt-compiler:2.51") kapt("com.google.dagger:hilt-compiler:2.51.1")
implementation("androidx.hilt:hilt-work:1.2.0") implementation("androidx.hilt:hilt-work:1.2.0")
kapt("androidx.hilt:hilt-compiler: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") exclude(group = "org.json", module = "json")
} }
implementation("com.mikepenz:aboutlibraries-compose-m3:10.10.0") implementation("com.mikepenz:aboutlibraries-compose-m3:10.10.0")
@ -164,10 +192,11 @@ dependencies {
androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00")) androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("com.squareup.moshi:moshi-kotlin:1.15.1") androidTestImplementation("com.squareup.moshi:moshi-kotlin:1.15.1")
androidTestImplementation("com.google.dagger:hilt-android-testing:2.50") androidTestImplementation("com.google.dagger:hilt-android-testing:2.51.1")
kaptAndroidTest("com.google.dagger:hilt-android-compiler:2.50") kaptAndroidTest("com.google.dagger:hilt-android-compiler:2.51.1")
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("androidx.compose.ui:ui-test-manifest")
debugImplementation("com.github.koitharu:workinspector:5778dd1747")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
} }
@ -178,6 +207,11 @@ androidComponents {
variantBuilder.enable = variantBuilder.productFlavors.containsAll(listOf("default" to "dev")) 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. // 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 <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" /> android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission <uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE" android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
@ -35,6 +36,7 @@
android:label="@string/app_name" android:label="@string/app_name"
android:largeHeap="true" android:largeHeap="true"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Shirizu" android:theme="@style/Theme.Shirizu"
tools:targetApi="tiramisu"> tools:targetApi="tiramisu">
@ -50,11 +52,28 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity
android:name=".crash.CrashActivity" android:name=".crash.CrashActivity"
android:exported="false" android:exported="false"
android:process=":error_handler" /> 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 <service
android:name="androidx.work.impl.foreground.SystemForegroundService" android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync" android:foregroundServiceType="dataSync"

@ -11,6 +11,7 @@ import androidx.core.content.getSystemService
import androidx.hilt.work.HiltWorkerFactory import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration import androidx.work.Configuration
import androidx.work.WorkManager import androidx.work.WorkManager
import coil.ImageLoader
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import com.tencent.mmkv.MMKV import com.tencent.mmkv.MMKV
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
@ -50,6 +51,9 @@ class App : Application(), Configuration.Provider {
@Inject @Inject
lateinit var workManagerProvider: Provider<WorkManager> lateinit var workManagerProvider: Provider<WorkManager>
@Inject
lateinit var imageLoader: ImageLoader
override val workManagerConfiguration: Configuration override val workManagerConfiguration: Configuration
get() = Configuration.Builder() get() = Configuration.Builder()
.setWorkerFactory(workerFactory) .setWorkerFactory(workerFactory)

@ -13,10 +13,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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.ui.theme.SEED
import org.xtimms.shirizu.core.prefs.AppSettings import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.core.prefs.DarkThemePreference import org.xtimms.shirizu.core.prefs.DarkThemePreference
import org.xtimms.shirizu.core.prefs.paletteStyles 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.LocalTonalPalettes
import org.xtimms.shirizu.ui.monet.PaletteStyle import org.xtimms.shirizu.ui.monet.PaletteStyle
import org.xtimms.shirizu.ui.monet.TonalPalettes.Companion.toTonalPalettes import org.xtimms.shirizu.ui.monet.TonalPalettes.Companion.toTonalPalettes
@ -32,9 +36,14 @@ val LocalDynamicColorSwitch = compositionLocalOf { false }
val LocalPaletteStyleIndex = compositionLocalOf { 0 } val LocalPaletteStyleIndex = compositionLocalOf { 0 }
val LocalWindowInsets = compositionLocalOf { PaddingValues(0.dp) } val LocalWindowInsets = compositionLocalOf { PaddingValues(0.dp) }
val LocalWindowWidthState = staticCompositionLocalOf { WindowWidthSizeClass.Compact } 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 @Composable
fun SettingsProvider(windowWidthSizeClass: WindowWidthSizeClass, content: @Composable () -> Unit) { fun SettingsProvider(content: @Composable () -> Unit) {
AppSettings.AppSettingsStateFlow.collectAsState().value.run { AppSettings.AppSettingsStateFlow.collectAsState().value.run {
CompositionLocalProvider( CompositionLocalProvider(
LocalDarkTheme provides darkTheme, LocalDarkTheme provides darkTheme,
@ -46,7 +55,6 @@ fun SettingsProvider(windowWidthSizeClass: WindowWidthSizeClass, content: @Compo
else Color(seedColor).toTonalPalettes( else Color(seedColor).toTonalPalettes(
paletteStyles.getOrElse(paletteStyleIndex) { PaletteStyle.TonalSpot } paletteStyles.getOrElse(paletteStyleIndex) { PaletteStyle.TonalSpot }
), ),
LocalWindowWidthState provides windowWidthSizeClass,
LocalDynamicColorSwitch provides isDynamicColorEnabled, LocalDynamicColorSwitch provides isDynamicColorEnabled,
content = content content = content
) )

@ -7,84 +7,120 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.Animatable import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.expandVertically
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.icons.Icons
import androidx.compose.foundation.layout.systemBars import androidx.compose.material.icons.outlined.RssFeed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.NavigationBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.NavigationRail
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.util.fastForEach
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.navigation.NavHostController import cafe.adriel.voyager.navigator.LocalNavigator
import androidx.navigation.compose.rememberNavController 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 coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job 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.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.xtimms.shirizu.core.Navigation import org.xtimms.shirizu.core.components.AppBar
import org.xtimms.shirizu.core.components.BottomNavBar import org.xtimms.shirizu.core.components.AppBarActions
import org.xtimms.shirizu.core.components.ContinueReadingButton import org.xtimms.shirizu.core.components.AppToolbar
import org.xtimms.shirizu.core.components.NavigationRail import org.xtimms.shirizu.core.components.Scaffold
import org.xtimms.shirizu.core.components.TopAppBar import org.xtimms.shirizu.core.components.icons.Creation
import org.xtimms.shirizu.core.logs.FileLogger 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.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.ui.dialogs.UpdateDialogImpl
import org.xtimms.shirizu.core.updates.Updater 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.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.setLanguage
import org.xtimms.shirizu.utils.system.suspendToast import org.xtimms.shirizu.utils.system.suspendToast
import javax.inject.Inject import javax.inject.Inject
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @OptIn(ExperimentalMaterial3Api::class)
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val isReady: MutableState<Boolean> = mutableStateOf(false) private val isReady: MutableState<Boolean> = mutableStateOf(false)
private val isDone: MutableState<Boolean> = mutableStateOf(false) private val isDone: MutableState<Boolean> = mutableStateOf(false)
private var navigator: Navigator? = null
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
@Inject @Inject
lateinit var loggers: Set<@JvmSuppressWildcards FileLogger> lateinit var loggers: Set<@JvmSuppressWildcards FileLogger>
@Inject
lateinit var shikimoriRepository: ShikimoriRepository
@Inject
lateinit var kitsuRepository: KitsuRepository
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen().setKeepOnScreenCondition { !isDone.value } installSplashScreen().setKeepOnScreenCondition { !isDone.value }
enableEdgeToEdge() enableEdgeToEdge()
@ -110,10 +146,6 @@ class MainActivity : ComponentActivity() {
var showUpdateDialog by rememberSaveable { mutableStateOf(false) } var showUpdateDialog by rememberSaveable { mutableStateOf(false) }
var currentDownloadStatus by remember { mutableStateOf(Updater.DownloadStatus.NotYet as Updater.DownloadStatus) } 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 = val settings =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
Updater.installLatestApk(context) Updater.installLatestApk(context)
@ -141,63 +173,97 @@ class MainActivity : ComponentActivity() {
isReady.value = true isReady.value = true
} }
if (isReady.value) { if (isReady.value) {
SettingsProvider(windowSizeClass.widthSizeClass) { CompositionLocalProvider(
ShirizuTheme( LocalImageLoader provides coil,
darkTheme = LocalDarkTheme.current.isDarkTheme(), LocalLoggers provides loggers,
isDynamicColorEnabled = LocalDynamicColorSwitch.current, LocalShikimoriRepository provides shikimoriRepository,
isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled, LocalKitsuRepository provides kitsuRepository
) { ) {
MainView( SettingsProvider {
coil = coil, ShirizuTheme(
loggers = loggers, darkTheme = LocalDarkTheme.current.isDarkTheme(),
isCompactScreen = isCompactScreen, isDynamicColorEnabled = LocalDynamicColorSwitch.current,
navController = navController isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled,
) ) {
LaunchedEffect(Unit) { Navigator(
isDone.value = true screen = MainScreen,
} disposeBehavior = NavigatorDisposeBehavior(
LaunchedEffect(Unit) { disposeNestedNavigators = false,
if (!AppSettings.isAutoUpdateEnabled()) disposeSteps = true
return@LaunchedEffect ),
launch(Dispatchers.IO) { ) { navigator ->
runCatching { LaunchedEffect(navigator) {
Updater.checkForUpdate(context)?.let { this@MainActivity.navigator = navigator
latestRelease = it }
showUpdateDialog = true
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) { LaunchedEffect(Unit) {
UpdateDialogImpl( isDone.value = true
onDismissRequest = { }
showUpdateDialog = false
updateJob?.cancel() LaunchedEffect(Unit) {
}, if (!AppSettings.isAutoUpdateEnabled())
title = latestRelease.name.toString(), return@LaunchedEffect
onConfirmUpdate = { launch(Dispatchers.IO) {
updateJob = scope.launch(Dispatchers.IO) { runCatching {
runCatching { Updater.checkForUpdate(context)?.let {
Updater.downloadApk(context, latestRelease) latestRelease = it
.collect { downloadStatus -> showUpdateDialog = true
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
} }
}.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) 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) putDataToExtras(intent)
super.onNewIntent(intent) super.onNewIntent(intent)
} }
@ -222,93 +299,214 @@ class MainActivity : ComponentActivity() {
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable object MainScreen : Screen() {
fun MainView(
coil: ImageLoader, private val librarySearchEvent = Channel<String>()
loggers: Set<FileLogger>, private val openTabEvent = Channel<Tab>()
isCompactScreen: Boolean, private val showBottomNavEvent = Channel<Boolean>()
navController: NavHostController,
) { private const val TabNavigatorKey = "HomeTabs"
val bottomBarState = remember { mutableStateOf(true) }
val topBarOffsetY = remember { Animatable(0f) } private val tabs = listOf(
LibraryTab(),
val scroll = rememberLazyListState() // ShelfTab,
// HistoryTab,
Scaffold( ExploreTab(),
topBar = { SearchTab
if (isCompactScreen) { )
val isScrolled by remember {
derivedStateOf { scroll.firstVisibleItemScrollOffset > 0 } @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 = { val goToLibraryTab = { tabNavigator.current = LibraryTab() }
if (isCompactScreen) { BackHandler(
BottomNavBar( enabled = tabNavigator.current != LibraryTab(),
navController = navController, onBack = goToLibraryTab,
bottomBarState = bottomBarState, )
topBarOffsetY = topBarOffsetY,
) 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)
}, @Composable
contentWindowInsets = WindowInsets.systemBars private fun RowScope.NavigationBarItem(tab: org.xtimms.shirizu.utils.lang.Tab) {
.only(WindowInsetsSides.Horizontal) val tabNavigator = LocalTabNavigator.current
) { padding -> val navigator = LocalNavigator.currentOrThrow
if (!isCompactScreen) { val scope = rememberCoroutineScope()
val systemBarsPadding = WindowInsets.systemBars.asPaddingValues() val selected = tabNavigator.current::class == tab::class
Row( NavigationBarItem(
modifier = Modifier.padding(padding) selected = selected,
) { onClick = {
NavigationRail( if (!selected) {
navController = navController 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, alwaysShowLabel = true,
loggers = loggers, )
navController = navController, }
isCompactScreen = false,
modifier = Modifier.nestedScroll(TopAppBarDefaults.pinnedScrollBehavior().nestedScrollConnection), @Composable
padding = PaddingValues( fun NavigationRailItem(tab: org.xtimms.shirizu.utils.lang.Tab) {
start = padding.calculateStartPadding(LocalLayoutDirection.current), val tabNavigator = LocalTabNavigator.current
top = systemBarsPadding.calculateTopPadding(), val navigator = LocalNavigator.currentOrThrow
end = padding.calculateEndPadding(LocalLayoutDirection.current), val scope = rememberCoroutineScope()
bottom = systemBarsPadding.calculateBottomPadding() val selected = tabNavigator.current::class == tab::class
), NavigationRailItem(
listState = scroll 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 { alwaysShowLabel = true,
Navigation( )
coil = coil, }
loggers = loggers,
navController = navController, @Composable
isCompactScreen = true, private fun NavigationIconItem(tab: org.xtimms.shirizu.utils.lang.Tab) {
modifier = Modifier.padding( Icon(
start = padding.calculateStartPadding(LocalLayoutDirection.current), painter = tab.options.icon!!,
end = padding.calculateEndPadding(LocalLayoutDirection.current), contentDescription = tab.options.title,
), // TODO: https://issuetracker.google.com/u/0/issues/316327367
padding = padding, tint = LocalContentColor.current,
listState = scroll )
) }
}
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) WindowCompat.setDecorFitsSystemWindows(window, false)
} }
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent) {
putDataToExtras(intent) putDataToExtras(intent)
super.onNewIntent(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() loadingCounter.decrement()
} }
protected inline suspend fun <T> withLoading(block: () -> T): T = try { protected suspend inline fun <T> withLoading(block: () -> T): T = try {
loadingCounter.increment() loadingCounter.increment()
block() block()
} finally { } 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, title: String,
titleAlphaProvider: () -> Float, titleAlphaProvider: () -> Float,
navigateBack: () -> Unit, navigateBack: () -> Unit,
navigateToWebBrowser: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
backgroundAlphaProvider: () -> Float = titleAlphaProvider backgroundAlphaProvider: () -> Float = titleAlphaProvider
) { ) {
@ -165,16 +164,6 @@ fun ClassicDetailsToolbar(
Icon(imageVector = Icons.Outlined.Download, contentDescription = null) 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( colors = TopAppBarDefaults.topAppBarColors(

@ -16,6 +16,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SignalCellularConnectedNoInternet4Bar import androidx.compose.material.icons.outlined.SignalCellularConnectedNoInternet4Bar
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
@ -65,7 +66,7 @@ fun ShirizuDialog(
tonalElevation: Dp = AlertDialogDefaults.TonalElevation, tonalElevation: Dp = AlertDialogDefaults.TonalElevation,
properties: DialogProperties = DialogProperties() properties: DialogProperties = DialogProperties()
) { ) {
AlertDialog( BasicAlertDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
modifier = modifier, modifier = modifier,
properties = properties properties = properties
@ -158,7 +159,7 @@ fun ShirizuDialogButtonVariant(
text: String, text: String,
onClick: () -> Unit onClick: () -> Unit
) { ) {
Box() { Box {
Surface( Surface(
modifier = modifier modifier = modifier
.clickable(onClick = onClick) .clickable(onClick = onClick)
@ -251,7 +252,7 @@ fun ShirizuDialogVariant(
tonalElevation: Dp = AlertDialogDefaults.TonalElevation, tonalElevation: Dp = AlertDialogDefaults.TonalElevation,
properties: DialogProperties = DialogProperties() properties: DialogProperties = DialogProperties()
) { ) {
AlertDialog( BasicAlertDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
modifier = modifier, modifier = modifier,
properties = properties properties = properties

@ -32,7 +32,7 @@ fun ExploreButton(
Card( Card(
onClick = onClick, onClick = onClick,
modifier = modifier.padding(start = 8.dp, end = 8.dp), modifier = modifier.padding(start = 8.dp, end = 8.dp),
shape = RoundedCornerShape(50), shape = RoundedCornerShape(25),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) 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 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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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 @Composable
fun ListGroupHeader( fun ListGroupHeader(
text: String, text: String,
@ -18,7 +53,7 @@ fun ListGroupHeader(
modifier = modifier modifier = modifier
.padding( .padding(
horizontal = 16.dp, horizontal = 16.dp,
vertical = 4.dp, vertical = 8.dp,
), ),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
) )

@ -1,6 +1,5 @@
package org.xtimms.shirizu.core.components package org.xtimms.shirizu.core.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -9,12 +8,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import coil.ImageLoader import coil.ImageLoader
import coil.compose.AsyncImage 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) { enum class MangaCover(val ratio: Float) {
Square(1f / 1f), Square(1f / 1f),
@ -23,16 +26,17 @@ enum class MangaCover(val ratio: Float) {
@Composable @Composable
operator fun invoke( operator fun invoke(
coil: ImageLoader, data: Any?,
data: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentDescription: String = "", contentDescription: String = "",
shape: Shape = MaterialTheme.shapes.small, shape: Shape = MaterialTheme.shapes.small,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
) { ) {
AsyncImageImpl( AsyncImage(
coil = coil, imageLoader = LocalImageLoader.current,
model = data, model = data,
placeholder = ColorPainter(CoverPlaceholderColor),
error = rememberResourceBitmapPainter(id = R.drawable.cover_error),
contentDescription = contentDescription, contentDescription = contentDescription,
modifier = modifier modifier = modifier
.aspectRatio(ratio) .aspectRatio(ratio)

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

@ -41,7 +41,7 @@ fun ShirizuModalBottomSheet(
modifier = modifier, modifier = modifier,
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
sheetState = sheetState, 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)) { Column(modifier = Modifier.padding(paddingValues = horizontalPadding)) {
content() 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.layout.width
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Call import androidx.compose.material.icons.outlined.Call
import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Check
@ -67,6 +68,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import org.xtimms.shirizu.ui.theme.FixedAccentColors import org.xtimms.shirizu.ui.theme.FixedAccentColors
import org.xtimms.shirizu.R import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.ShirizuAsyncImage
import org.xtimms.shirizu.ui.monet.LocalTonalPalettes import org.xtimms.shirizu.ui.monet.LocalTonalPalettes
import org.xtimms.shirizu.ui.monet.TonalPalettes.Companion.toTonalPalettes import org.xtimms.shirizu.ui.monet.TonalPalettes.Companion.toTonalPalettes
import org.xtimms.shirizu.ui.theme.PreviewThemeLight 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, contentWindowInsets: WindowInsets = WindowInsets.systemBars,
content: @Composable (PaddingValues) -> Unit content: @Composable (PaddingValues) -> Unit
) { ) {
val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( org.xtimms.shirizu.core.components.Scaffold(
rememberTopAppBarState(),
canScroll = { true }
)
Scaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection),
topBar = { topBar = {
ClassicTopAppBar( ClassicTopAppBar(
title = title, title = title,
scrollBehavior = topAppBarScrollBehavior, scrollBehavior = it,
actions = actions, actions = actions,
navigateBack = navigateBack 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.compose.ui.unit.sp
import androidx.core.net.toUri import androidx.core.net.toUri
import coil.ImageLoader import coil.ImageLoader
import org.xtimms.shirizu.core.AsyncImageImpl import org.xtimms.shirizu.core.ShirizuAsyncImage
import org.xtimms.shirizu.ui.theme.ShirizuTheme import org.xtimms.shirizu.ui.theme.ShirizuTheme
@Composable @Composable
fun SourceItem( fun SourceItem(
coil: ImageLoader,
faviconUrl: Uri, faviconUrl: Uri,
title: String, title: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -50,8 +49,7 @@ fun SourceItem(
.clip(MaterialTheme.shapes.large) .clip(MaterialTheme.shapes.large)
.aspectRatio(1f) .aspectRatio(1f)
) { ) {
AsyncImageImpl( ShirizuAsyncImage(
coil = coil,
model = faviconUrl, model = faviconUrl,
contentDescription = "favicon", contentDescription = "favicon",
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
@ -79,7 +77,6 @@ fun SourceItem(
fun SourceItemPreview() { fun SourceItemPreview() {
ShirizuTheme { ShirizuTheme {
SourceItem( SourceItem(
coil = ImageLoader(LocalContext.current),
faviconUrl = "".toUri(), faviconUrl = "".toUri(),
title = "Test", title = "Test",
onClick = { } onClick = { }

@ -2,14 +2,8 @@ package org.xtimms.shirizu.core.components
import android.graphics.Path import android.graphics.Path
import android.view.animation.PathInterpolator 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.basicMarquee
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape 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.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.MoreVert
import androidx.compose.material.icons.outlined.QueryStats import androidx.compose.material.icons.outlined.QueryStats
import androidx.compose.material.icons.outlined.RssFeed 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.SuggestionChip import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberTooltipState
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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.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.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.compose.ui.unit.sp
import androidx.navigation.compose.currentBackStackEntryAsState import kotlinx.collections.immutable.ImmutableList
import org.xtimms.shirizu.R import org.xtimms.shirizu.R
import org.xtimms.shirizu.core.DURATION_ENTER import org.xtimms.shirizu.core.components.icons.Shirizu
import org.xtimms.shirizu.core.DURATION_EXIT
import org.xtimms.shirizu.core.initialOffset
import org.xtimms.shirizu.core.prefs.AppSettings import org.xtimms.shirizu.core.prefs.AppSettings
import org.xtimms.shirizu.core.toEasing import org.xtimms.shirizu.ui.theme.ShirizuTheme
import org.xtimms.shirizu.sections.explore.EXPLORE_DESTINATION import org.xtimms.shirizu.utils.composable.secondaryItemAlpha
import org.xtimms.shirizu.sections.feed.FEED_DESTINATION
import org.xtimms.shirizu.sections.history.HISTORY_DESTINATION
import org.xtimms.shirizu.sections.search.SEARCH_DESTINATION
import org.xtimms.shirizu.sections.settings.SETTINGS_DESTINATION
import org.xtimms.shirizu.sections.shelf.SHELF_DESTINATION
import org.xtimms.shirizu.sections.stats.STATS_DESTINATION
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TopAppBar( fun AppBar(
navController: NavController, title: String?,
modifier: Modifier = Modifier,
backgroundAlphaProvider: () -> Float,
searchBarColorProvider: () -> Color,
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
var expanded by remember { mutableStateOf(false) }
val isVisible by remember { modifier: Modifier = Modifier,
derivedStateOf { backgroundColor: Color? = null,
when (navBackStackEntry?.destination?.route) { // Text
SHELF_DESTINATION, HISTORY_DESTINATION, EXPLORE_DESTINATION, subtitle: String? = null,
null -> true // 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) AppBar(
val dtStart = aprilFoolsDay.format(DateTimeFormatter.ISO_DATE) modifier = modifier,
val currentDt = LocalDate.now() 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 { modifier: Modifier = Modifier,
moveTo(0f, 0f) backgroundColor: Color? = null,
cubicTo(0.05F, 0F, 0.133333F, 0.06F, 0.166666F, 0.4F) // Up button
cubicTo(0.208333F, 0.82F, 0.25F, 1F, 1F, 1F) navigateUp: (() -> Unit)? = null,
} navigationIcon: ImageVector? = null,
// Menu
actions: @Composable RowScope.() -> Unit = {},
// Action mode
isActionMode: Boolean = false,
onCancelActionMode: () -> Unit = {},
val emphasizePathInterpolator = PathInterpolator(path) scrollBehavior: TopAppBarScrollBehavior? = null,
val emphasizeEasing = emphasizePathInterpolator.toEasing() ) {
Column(
val enterTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing) modifier = modifier,
val exitTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
val fadeTween = tween<Float>(durationMillis = DURATION_EXIT)
AnimatedVisibility(
visible = isVisible,
enter = slideInHorizontally(
enterTween,
initialOffsetX = { -(it * initialOffset).toInt() }) + fadeIn(fadeTween),
exit = slideOutHorizontally(
exitTween,
targetOffsetX = { -(it * initialOffset).toInt() }) + fadeOut(fadeTween)
) { ) {
Row( androidx.compose.material3.TopAppBar(
modifier = Modifier navigationIcon = {
.fillMaxWidth() if (isActionMode) {
.background( IconButton(onClick = onCancelActionMode) {
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),
) {
Icon( Icon(
Icons.Outlined.MoreVert, imageVector = Icons.Outlined.Close,
contentDescription = stringResource(id = R.string.open_menu), contentDescription = stringResource(R.string.cancel),
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)
)
}
) )
} }
} else { } else {
IconButton( navigateUp?.let {
onClick = { navController.navigate(STATS_DESTINATION) }, IconButton(onClick = it) {
modifier = Modifier.padding(0.dp), UpIcon(navigationIcon = navigationIcon)
) { }
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
)
} }
} }
},
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 { private val path = Path().apply {
moveTo(0f,0f) moveTo(0f, 0f)
lineTo(0.7f, 0.1f) lineTo(0.7f, 0.1f)
cubicTo(0.7f, 0.1f, .95F, .5F, 1F, 1F) cubicTo(0.7f, 0.1f, .95F, .5F, 1F, 1F)
moveTo(1f,1f) moveTo(1f, 1f)
} }
val fraction: (Float) -> Float = { PathInterpolator(path).getInterpolation(it) } val fraction: (Float) -> Float = { PathInterpolator(path).getInterpolation(it) }
@ -375,3 +419,176 @@ 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<*> other as AnimatedItem<*>
if (item != other.item) return false return item == other.item
return true
} }
} }

@ -5,7 +5,7 @@ import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath import androidx.compose.material.icons.materialPath
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
public val Icons.Outlined.ArrowDecisionOutline: ImageVector val Icons.Outlined.ArrowDecisionOutline: ImageVector
get() { get() {
if (_arrow_decision_outline != null) { if (_arrow_decision_outline != null) {
return _arrow_decision_outline!! 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.graphics.vector.path
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
public val Icons.Outlined.Creation: ImageVector val Icons.Outlined.Creation: ImageVector
get() { get() {
if (_creation != null) { if (_creation != null) {
return _creation!! return _creation!!

@ -5,7 +5,7 @@ import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath import androidx.compose.material.icons.materialPath
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
public val Icons.Outlined.Dice: ImageVector val Icons.Outlined.Dice: ImageVector
get() { get() {
if (_dice != null) { if (_dice != null) {
return _dice!! 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.graphics.vector.path
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
public val Icons.Filled.Kotatsu: ImageVector val Icons.Filled.Kotatsu: ImageVector
get() { get() {
if (_kotatsu != null) { if (_kotatsu != null) {
return _kotatsu!! 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) moveTo(x = 0f, y = 0f)
lineTo(size.width - amplitude, -halfPeriod * 2.5f + halfPeriod * 2 * shift) lineTo(size.width - amplitude, -halfPeriod * 2.5f + halfPeriod * 2 * shift)
repeat(ceil(size.height / halfPeriod + 3).toInt()) { i -> repeat(ceil(size.height / halfPeriod + 3).toInt()) { i ->
relativeQuadraticBezierTo( relativeQuadraticTo(
dx1 = 2 * amplitude * (if (i % 2 == 0) 1 else -1), dx1 = 2 * amplitude * (if (i % 2 == 0) 1 else -1),
dy1 = halfPeriod / 2, dy1 = halfPeriod / 2,
dx2 = 0f, dx2 = 0f,
dy2 = halfPeriod, dy2 = halfPeriod
) )
} }
lineTo(0f, size.height) 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.entity.TrackLogEntity
import org.xtimms.shirizu.core.database.migrations.Migration1To2 import org.xtimms.shirizu.core.database.migrations.Migration1To2
import org.xtimms.shirizu.core.database.migrations.Migration2To3 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 import org.xtimms.shirizu.utils.lang.processLifecycleScope
const val DATABASE_VERSION = 3 const val DATABASE_VERSION = 4
@Database( @Database(
entities = [ entities = [
@ -54,6 +57,7 @@ const val DATABASE_VERSION = 3
TrackEntity::class, TrackEntity::class,
TrackLogEntity::class, TrackLogEntity::class,
StatsEntity::class, StatsEntity::class,
ScrobblingEntity::class,
], ],
version = DATABASE_VERSION version = DATABASE_VERSION
) )
@ -81,11 +85,14 @@ abstract class ShirizuDatabase : RoomDatabase() {
abstract fun getStatsDao(): StatsDao abstract fun getStatsDao(): StatsDao
abstract fun getScrobblingDao(): ScrobblingDao
} }
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf( fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration1To2(), Migration1To2(),
Migration2To3() Migration2To3(),
Migration3To4()
) )
fun ShirizuDatabase(context: Context): ShirizuDatabase = Room fun ShirizuDatabase(context: Context): ShirizuDatabase = Room

@ -11,6 +11,7 @@ import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language 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.FavouriteEntity
import org.xtimms.shirizu.core.database.entity.MangaEntity import org.xtimms.shirizu.core.database.entity.MangaEntity
import org.xtimms.shirizu.core.model.ListSortOrder 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") @Query("SELECT COUNT(DISTINCT manga_id) FROM favourites WHERE deleted_at = 0")
abstract fun observeMangaCount(): Flow<Int> 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)") @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)")
abstract suspend fun findAllManga(): List<MangaEntity> 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") @Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id")
abstract suspend fun find(id: Long): FavouriteManga? 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 @Transaction
@Deprecated("Ignores order") @Deprecated("Ignores order")
@Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id") @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") @Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :id AND deleted_at = 0")
abstract fun observeIds(id: Long): Flow<List<Long>> 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> 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") @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.NEWEST -> "favourites.created_at DESC"
ListSortOrder.ALPHABETIC -> "manga.title ASC" ListSortOrder.ALPHABETIC -> "manga.title ASC"
ListSortOrder.PROGRESS -> "IFNULL((SELECT percent FROM history WHERE history.manga_id = manga.manga_id), 0) DESC" 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") @Query("SELECT * FROM manga WHERE source = :source")
abstract suspend fun findAllBySource(source: String): List<MangaWithTags> 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 @Upsert
abstract suspend fun upsert(manga: MangaEntity) abstract suspend fun upsert(manga: MangaEntity)

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

@ -25,6 +25,10 @@ abstract class SuggestionDao {
@Query("SELECT * FROM suggestions ORDER BY RANDOM() LIMIT 1") @Query("SELECT * FROM suggestions ORDER BY RANDOM() LIMIT 1")
abstract suspend fun getRandom(): SuggestionWithManga? 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") @Query("SELECT COUNT(*) FROM suggestions")
abstract suspend fun count(): Int 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.model.MangaSource
import org.xtimms.shirizu.core.tracker.model.TrackingLogItem import org.xtimms.shirizu.core.tracker.model.TrackingLogItem
import org.xtimms.shirizu.sections.shelf.FavouriteManga 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 org.xtimms.shirizu.utils.lang.longHashCode
import java.time.Instant import java.time.Instant
@ -44,6 +46,25 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
tags = tags, 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 MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory( fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
@ -56,10 +77,20 @@ fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong())
isVisibleInLibrary = isVisibleInLibrary, 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.toManga() = manga.toManga(tags.toMangaTags())
fun FavouriteManga.toShelfManga() = manga.toShelfManga(tags.toMangaTags())
fun Collection<FavouriteManga>.toMangaList() = map { it.toManga() } fun Collection<FavouriteManga>.toMangaList() = map { it.toManga() }
fun Collection<FavouriteManga>.toShelfMangaList() = map { it.toShelfManga() }
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark( fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
manga = manga, manga = manga,
pageId = pageId, 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. * [materialSharedAxisX] allows to switch a layout with shared X-axis transition.
* *
*/ */
public fun materialSharedAxisX( fun materialSharedAxisX(
initialOffsetX: (fullWidth: Int) -> Int, initialOffsetX: (fullWidth: Int) -> Int,
targetOffsetX: (fullWidth: Int) -> Int, targetOffsetX: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration, durationMillis: Int = MotionConstants.DefaultMotionDuration,
@ -40,7 +40,7 @@ public fun materialSharedAxisX(
/** /**
* [materialSharedAxisXIn] allows to switch a layout with shared X-axis enter transition. * [materialSharedAxisXIn] allows to switch a layout with shared X-axis enter transition.
*/ */
public fun materialSharedAxisXIn( fun materialSharedAxisXIn(
initialOffsetX: (fullWidth: Int) -> Int, initialOffsetX: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration, durationMillis: Int = MotionConstants.DefaultMotionDuration,
): EnterTransition = slideInHorizontally( ): EnterTransition = slideInHorizontally(
@ -61,7 +61,7 @@ public fun materialSharedAxisXIn(
* [materialSharedAxisXOut] allows to switch a layout with shared X-axis exit transition. * [materialSharedAxisXOut] allows to switch a layout with shared X-axis exit transition.
* *
*/ */
public fun materialSharedAxisXOut( fun materialSharedAxisXOut(
targetOffsetX: (fullWidth: Int) -> Int, targetOffsetX: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration, durationMillis: Int = MotionConstants.DefaultMotionDuration,
): ExitTransition = slideOutHorizontally( ): 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() { override fun onActive() {
invalidate() invalidate()
val request = NetworkRequest.Builder() val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
.build() .build()
connectivityManager.registerNetworkCallback(request, callback) connectivityManager.registerNetworkCallback(request, callback)
} }

@ -13,7 +13,6 @@ import javax.inject.Provider
@Reusable @Reusable
class MangaDataRepository @Inject constructor( class MangaDataRepository @Inject constructor(
private val db: ShirizuDatabase, private val db: ShirizuDatabase,
private val resolverProvider: Provider<MangaLinkResolver>,
) { ) {
suspend fun findMangaById(mangaId: Long): Manga? { suspend fun findMangaById(mangaId: Long): Manga? {
@ -24,13 +23,6 @@ class MangaDataRepository @Inject constructor(
return db.getMangaDao().findByPublicUrl(publicUrl)?.toManga() 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) { suspend fun storeManga(manga: Manga) {
db.withTransaction { db.withTransaction {
val tags = manga.tags.toEntities() 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.content.Context
import android.util.Base64 import android.util.Base64
import android.webkit.WebView import android.webkit.WebView
import androidx.annotation.MainThread
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource 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.MangaHttpClient
import org.xtimms.shirizu.core.network.cookies.MutableCookieJar import org.xtimms.shirizu.core.network.cookies.MutableCookieJar
import org.xtimms.shirizu.core.prefs.SourceSettings 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 org.xtimms.shirizu.utils.system.toList
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
@ -32,8 +39,10 @@ class MangaLoaderContextImpl @Inject constructor(
private var webViewCached: WeakReference<WebView>? = null private var webViewCached: WeakReference<WebView>? = null
private val webViewUserAgent by lazy { obtainWebViewUserAgent() }
@SuppressLint("SetJavaScriptEnabled") @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 { val webView = webViewCached?.get() ?: WebView(androidContext).also {
it.settings.javaScriptEnabled = true it.settings.javaScriptEnabled = true
webViewCached = WeakReference(it) webViewCached = WeakReference(it)
@ -49,6 +58,8 @@ class MangaLoaderContextImpl @Inject constructor(
return SourceSettings(androidContext, source) return SourceSettings(androidContext, source)
} }
override fun getDefaultUserAgent(): String = webViewUserAgent
override fun encodeBase64(data: ByteArray): String { override fun encodeBase64(data: ByteArray): String {
return Base64.encodeToString(data, Base64.NO_WRAP) return Base64.encodeToString(data, Base64.NO_WRAP)
} }
@ -60,4 +71,30 @@ class MangaLoaderContextImpl @Inject constructor(
override fun getPreferredLocales(): List<Locale> { override fun getPreferredLocales(): List<Locale> {
return LocaleListCompat.getAdjustedDefault().toList() 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 package org.xtimms.shirizu.core.parser
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.paging.PagingSource
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga

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

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

@ -67,6 +67,13 @@ const val PROXY_PORT = "proxy_port"
const val PROXY_USER = "proxy_user" const val PROXY_USER = "proxy_user"
const val PROXY_PASSWORD = "proxy_password" 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 const val NOT_SPECIFIED = 0
val paletteStyles = listOf( val paletteStyles = listOf(
@ -173,6 +180,13 @@ object AppSettings {
fun isModernViewEnabled() = MODERN_VIEW.getBoolean(true) 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 { fun getGridColumnsCount(columns: Int = GRID_COLUMNS.getInt()): Float {
return when (columns) { return when (columns) {
1 -> 1f 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 package org.xtimms.shirizu.core.tracker
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.collection.MutableLongSet
import coil.request.CachePolicy import coil.request.CachePolicy
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable 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.core.tracker.model.MangaUpdates
import org.xtimms.shirizu.data.repository.HistoryRepository import org.xtimms.shirizu.data.repository.HistoryRepository
import org.xtimms.shirizu.data.repository.TrackingRepository 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.TrackerNotificationChannels
import org.xtimms.shirizu.work.tracker.TrackingItem import org.xtimms.shirizu.work.tracker.TrackingItem
import javax.inject.Inject import javax.inject.Inject
@ -119,7 +118,7 @@ class Tracker @Inject constructor(
private companion object { private companion object {
private val mangaMutex = CompositeMutex2<Long>() private val mangaMutex = MultiMutex<Long>()
@OptIn(ExperimentalContracts::class) @OptIn(ExperimentalContracts::class)
suspend inline fun <T> withMangaLock(id: Long, action: () -> T): T { suspend inline fun <T> withMangaLock(id: Long, action: () -> T): T {

@ -57,7 +57,7 @@ import org.xtimms.shirizu.utils.material.combineColors
fun InfoScreen( fun InfoScreen(
icon: ImageVector, icon: ImageVector,
headingText1: String, headingText1: String,
headingText2: String, headingText2: String? = null,
subtitleText: String, subtitleText: String,
acceptText: String, acceptText: String,
onAcceptClick: () -> Unit, onAcceptClick: () -> Unit,
@ -182,10 +182,12 @@ fun InfoScreen(
else -> headingText2 else -> headingText2
} }
AnimatedContent(targetState = heading, label = "heading animation") { AnimatedContent(targetState = heading, label = "heading animation") {
Text( if (it != null) {
text = it, Text(
style = MaterialTheme.typography.headlineLarge, text = it,
) style = MaterialTheme.typography.headlineLarge,
)
}
} }
Text( Text(
text = subtitleText, 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) val exception = GlobalExceptionHandler.getThrowableFromIntent(intent)
setContent { setContent {
SettingsProvider(LocalWindowWidthState.current) { SettingsProvider {
ShirizuTheme( ShirizuTheme(
darkTheme = LocalDarkTheme.current.isDarkTheme(), darkTheme = LocalDarkTheme.current.isDarkTheme(),
isDynamicColorEnabled = LocalDynamicColorSwitch.current, isDynamicColorEnabled = LocalDynamicColorSwitch.current,

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

Loading…
Cancel
Save