Partially migrate to Voyager navigation library
This commit is contained in:
37
.idea/appInsightsSettings.xml
generated
37
.idea/appInsightsSettings.xml
generated
@@ -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>
|
||||||
8
.idea/deploymentTargetDropDown.xml
generated
8
.idea/deploymentTargetDropDown.xml
generated
@@ -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>
|
||||||
|
|||||||
276
.idea/other.xml
generated
276
.idea/other.xml
generated
@@ -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>
|
||||||
11
README.md
11
README.md
@@ -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 |
|
||||||
|:--------------------------------------------:|:------------------------------------------------:|:------------------------------------------------:|
|
|:------------------------------------------:|:--------------------------------------------:|:-------------------------------------------:|
|
||||||
|  |  |  |
|
|  |  |  |
|
||||||
|  |  |  |
|
|
||||||
|
|
||||||
## 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowOnboarding()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
isDone.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (!AppSettings.isAutoUpdateEnabled())
|
||||||
|
return@LaunchedEffect
|
||||||
|
launch(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
Updater.checkForUpdate(context)?.let {
|
||||||
|
latestRelease = it
|
||||||
|
showUpdateDialog = true
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
it.printStackTrace()
|
||||||
}
|
}
|
||||||
}.onFailure {
|
|
||||||
it.printStackTrace()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (showUpdateDialog) {
|
if (showUpdateDialog) {
|
||||||
UpdateDialogImpl(
|
UpdateDialogImpl(
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
showUpdateDialog = false
|
showUpdateDialog = false
|
||||||
updateJob?.cancel()
|
updateJob?.cancel()
|
||||||
},
|
},
|
||||||
title = latestRelease.name.toString(),
|
title = latestRelease.name.toString(),
|
||||||
onConfirmUpdate = {
|
onConfirmUpdate = {
|
||||||
updateJob = scope.launch(Dispatchers.IO) {
|
updateJob = scope.launch(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
Updater.downloadApk(context, latestRelease)
|
Updater.downloadApk(context, latestRelease)
|
||||||
.collect { downloadStatus ->
|
.collect { downloadStatus ->
|
||||||
currentDownloadStatus = downloadStatus
|
currentDownloadStatus = downloadStatus
|
||||||
if (downloadStatus is Updater.DownloadStatus.Finished) {
|
if (downloadStatus is Updater.DownloadStatus.Finished) {
|
||||||
launcher.launch(Manifest.permission.REQUEST_INSTALL_PACKAGES)
|
launcher.launch(Manifest.permission.REQUEST_INSTALL_PACKAGES)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}.onFailure {
|
||||||
}.onFailure {
|
it.printStackTrace()
|
||||||
it.printStackTrace()
|
currentDownloadStatus =
|
||||||
currentDownloadStatus = Updater.DownloadStatus.NotYet
|
Updater.DownloadStatus.NotYet
|
||||||
context.suspendToast(R.string.app_update_failed)
|
context.suspendToast(R.string.app_update_failed)
|
||||||
return@launch
|
return@launch
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
releaseNote = latestRelease.body.toString(),
|
||||||
releaseNote = latestRelease.body.toString(),
|
downloadStatus = currentDownloadStatus
|
||||||
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,
|
|
||||||
loggers: Set<FileLogger>,
|
|
||||||
isCompactScreen: Boolean,
|
|
||||||
navController: NavHostController,
|
|
||||||
) {
|
|
||||||
val bottomBarState = remember { mutableStateOf(true) }
|
|
||||||
val topBarOffsetY = remember { Animatable(0f) }
|
|
||||||
|
|
||||||
val scroll = rememberLazyListState()
|
private val librarySearchEvent = Channel<String>()
|
||||||
|
private val openTabEvent = Channel<Tab>()
|
||||||
|
private val showBottomNavEvent = Channel<Boolean>()
|
||||||
|
|
||||||
Scaffold(
|
private const val TabNavigatorKey = "HomeTabs"
|
||||||
topBar = {
|
|
||||||
if (isCompactScreen) {
|
private val tabs = listOf(
|
||||||
val isScrolled by remember {
|
LibraryTab(),
|
||||||
derivedStateOf { scroll.firstVisibleItemScrollOffset > 0 }
|
// ShelfTab,
|
||||||
|
// HistoryTab,
|
||||||
|
ExploreTab(),
|
||||||
|
SearchTab
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Content() {
|
||||||
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
TabNavigator(
|
||||||
|
tab = LibraryTab(),
|
||||||
|
key = TabNavigatorKey,
|
||||||
|
) { tabNavigator ->
|
||||||
|
// Provide usable navigator to content screen
|
||||||
|
CompositionLocalProvider(LocalNavigator provides navigator) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = { scrollBehavior ->
|
||||||
|
if (!isTabletUi()) {
|
||||||
|
AppToolbar(
|
||||||
|
actions = {
|
||||||
|
AppBarActions(
|
||||||
|
persistentListOf(
|
||||||
|
AppBar.Action(
|
||||||
|
title = stringResource(R.string.suggestions),
|
||||||
|
icon = Icons.Outlined.Creation,
|
||||||
|
onClick = {
|
||||||
|
navigator.push(SuggestionsScreen)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
AppBar.Action(
|
||||||
|
title = stringResource(R.string.feed),
|
||||||
|
icon = Icons.Outlined.RssFeed,
|
||||||
|
onClick = {
|
||||||
|
navigator.push(FeedScreen)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
AppBar.Action(
|
||||||
|
title = stringResource(R.string.settings),
|
||||||
|
icon = Icons.Outlined.Settings,
|
||||||
|
onClick = {
|
||||||
|
navigator.push(SettingsScreen)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
scrollBehavior = if (tabNavigator.current is NoLiftingAppBarScreen) {
|
||||||
|
null
|
||||||
|
} else scrollBehavior
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
startBar = {
|
||||||
|
if (isTabletUi()) {
|
||||||
|
NavigationRail {
|
||||||
|
tabs.fastForEach {
|
||||||
|
NavigationRailItem(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
if (!isTabletUi()) {
|
||||||
|
val bottomNavVisible by produceState(initialValue = true) {
|
||||||
|
showBottomNavEvent.receiveAsFlow().collectLatest { value = it }
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = bottomNavVisible,
|
||||||
|
enter = expandVertically(),
|
||||||
|
exit = shrinkVertically(),
|
||||||
|
) {
|
||||||
|
NavigationBar {
|
||||||
|
tabs.fastForEach {
|
||||||
|
NavigationBarItem(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentWindowInsets = WindowInsets(0),
|
||||||
|
) { contentPadding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(contentPadding)
|
||||||
|
.consumeWindowInsets(contentPadding),
|
||||||
|
) {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = tabNavigator.current,
|
||||||
|
transitionSpec = {
|
||||||
|
materialSharedAxisX(forward = true)
|
||||||
|
},
|
||||||
|
label = "tabContent",
|
||||||
|
) {
|
||||||
|
tabNavigator.saveableState(key = "currentTab", it) {
|
||||||
|
it.Content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val animatedBgAlpha by animateFloatAsState(
|
|
||||||
if (isScrolled) 1f else 0f,
|
|
||||||
label = "Top Bar Background",
|
|
||||||
)
|
|
||||||
val animatedSearchBarColor by animateColorAsState(
|
|
||||||
if (isScrolled) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
|
|
||||||
label = "Top Bar Background",
|
|
||||||
)
|
|
||||||
TopAppBar(
|
|
||||||
navController = navController,
|
|
||||||
modifier = Modifier
|
|
||||||
.statusBarsPadding()
|
|
||||||
.padding(0.dp, 16.dp),
|
|
||||||
backgroundAlphaProvider = { animatedBgAlpha },
|
|
||||||
searchBarColorProvider = { animatedSearchBarColor }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
bottomBar = {
|
val goToLibraryTab = { tabNavigator.current = LibraryTab() }
|
||||||
if (isCompactScreen) {
|
BackHandler(
|
||||||
BottomNavBar(
|
enabled = tabNavigator.current != LibraryTab(),
|
||||||
navController = navController,
|
onBack = goToLibraryTab,
|
||||||
bottomBarState = bottomBarState,
|
|
||||||
topBarOffsetY = topBarOffsetY,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
floatingActionButton = {
|
|
||||||
ContinueReadingButton(navController = navController)
|
|
||||||
},
|
|
||||||
contentWindowInsets = WindowInsets.systemBars
|
|
||||||
.only(WindowInsetsSides.Horizontal)
|
|
||||||
) { padding ->
|
|
||||||
if (!isCompactScreen) {
|
|
||||||
val systemBarsPadding = WindowInsets.systemBars.asPaddingValues()
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(padding)
|
|
||||||
) {
|
|
||||||
NavigationRail(
|
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
Navigation(
|
|
||||||
coil = coil,
|
|
||||||
loggers = loggers,
|
|
||||||
navController = navController,
|
|
||||||
isCompactScreen = false,
|
|
||||||
modifier = Modifier.nestedScroll(TopAppBarDefaults.pinnedScrollBehavior().nestedScrollConnection),
|
|
||||||
padding = PaddingValues(
|
|
||||||
start = padding.calculateStartPadding(LocalLayoutDirection.current),
|
|
||||||
top = systemBarsPadding.calculateTopPadding(),
|
|
||||||
end = padding.calculateEndPadding(LocalLayoutDirection.current),
|
|
||||||
bottom = systemBarsPadding.calculateBottomPadding()
|
|
||||||
),
|
|
||||||
listState = scroll
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Navigation(
|
|
||||||
coil = coil,
|
|
||||||
loggers = loggers,
|
|
||||||
navController = navController,
|
|
||||||
isCompactScreen = true,
|
|
||||||
modifier = Modifier.padding(
|
|
||||||
start = padding.calculateStartPadding(LocalLayoutDirection.current),
|
|
||||||
end = padding.calculateEndPadding(LocalLayoutDirection.current),
|
|
||||||
),
|
|
||||||
padding = padding,
|
|
||||||
listState = scroll
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.NavigationBarItem(tab: org.xtimms.shirizu.utils.lang.Tab) {
|
||||||
|
val tabNavigator = LocalTabNavigator.current
|
||||||
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val selected = tabNavigator.current::class == tab::class
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = selected,
|
||||||
|
onClick = {
|
||||||
|
if (!selected) {
|
||||||
|
tabNavigator.current = tab
|
||||||
|
} else {
|
||||||
|
scope.launch { tab.onReselect(navigator) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = { NavigationIconItem(tab) },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = tab.options.title,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
alwaysShowLabel = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NavigationRailItem(tab: org.xtimms.shirizu.utils.lang.Tab) {
|
||||||
|
val tabNavigator = LocalTabNavigator.current
|
||||||
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val selected = tabNavigator.current::class == tab::class
|
||||||
|
NavigationRailItem(
|
||||||
|
selected = selected,
|
||||||
|
onClick = {
|
||||||
|
if (!selected) {
|
||||||
|
tabNavigator.current = tab
|
||||||
|
} else {
|
||||||
|
scope.launch { tab.onReselect(navigator) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = { NavigationIconItem(tab) },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = tab.options.title,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
alwaysShowLabel = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NavigationIconItem(tab: org.xtimms.shirizu.utils.lang.Tab) {
|
||||||
|
Icon(
|
||||||
|
painter = tab.options.icon!!,
|
||||||
|
contentDescription = tab.options.title,
|
||||||
|
// TODO: https://issuetracker.google.com/u/0/issues/316327367
|
||||||
|
tint = LocalContentColor.current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun showBottomNav(show: Boolean) {
|
||||||
|
showBottomNavEvent.send(show)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Tab {
|
||||||
|
data object Library : Tab
|
||||||
|
// data object Shelf : Tab
|
||||||
|
// data object History : Tab
|
||||||
|
data object Explore : Tab
|
||||||
|
data object Search : Tab
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
282
app/src/main/java/org/xtimms/shirizu/core/components/Carousel.kt
Normal file
282
app/src/main/java/org/xtimms/shirizu/core/components/Carousel.kt
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
package org.xtimms.shirizu.core.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider
|
||||||
|
import androidx.compose.foundation.gestures.snapping.SnapPosition
|
||||||
|
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.FirstBaseline
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.xtimms.shirizu.LocalImageLoader
|
||||||
|
import org.xtimms.shirizu.R
|
||||||
|
import org.xtimms.shirizu.core.components.icons.Creation
|
||||||
|
import org.xtimms.shirizu.core.ui.screens.EmptyScreen
|
||||||
|
import org.xtimms.shirizu.ui.theme.ShirizuTheme
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MangaCarouselWithHeader(
|
||||||
|
items: List<Manga>,
|
||||||
|
title: String,
|
||||||
|
refreshing: Boolean,
|
||||||
|
onItemClick: (Manga) -> Unit,
|
||||||
|
onMoreClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
if (refreshing || items.isNotEmpty()) {
|
||||||
|
Header(
|
||||||
|
title = title,
|
||||||
|
loading = refreshing,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onMoreClick,
|
||||||
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.secondary,
|
||||||
|
),
|
||||||
|
modifier = Modifier.alignBy(FirstBaseline),
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = R.string.more))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (items.isNotEmpty()) {
|
||||||
|
MangaCarousel(
|
||||||
|
items = items,
|
||||||
|
onItemClick = onItemClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag("search_carousel")
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(MaterialTheme.shapes.extraLarge)
|
||||||
|
) {
|
||||||
|
EmptyScreen(
|
||||||
|
icon = Icons.Outlined.Creation,
|
||||||
|
title = R.string.nothing_here,
|
||||||
|
description = R.string.empty_carousel_hint,
|
||||||
|
modifier = Modifier.height(IntrinsicSize.Min)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun MangaCarousel(
|
||||||
|
items: List<Manga>,
|
||||||
|
onItemClick: (Manga) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val lazyListState = rememberLazyListState()
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
state = lazyListState,
|
||||||
|
modifier = modifier
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
.clip(MaterialTheme.shapes.extraLarge),
|
||||||
|
flingBehavior = rememberSnapFlingBehavior(
|
||||||
|
snapLayoutInfoProvider = remember(lazyListState) {
|
||||||
|
SnapLayoutInfoProvider(
|
||||||
|
lazyListState = lazyListState,
|
||||||
|
snapPosition = SnapPosition.Start,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = items,
|
||||||
|
key = { it.id },
|
||||||
|
) { item ->
|
||||||
|
BackdropCard(
|
||||||
|
manga = item,
|
||||||
|
onClick = { onItemClick(item) },
|
||||||
|
alignment = remember {
|
||||||
|
ParallaxAlignment(
|
||||||
|
horizontalBias = {
|
||||||
|
val layoutInfo = lazyListState.layoutInfo
|
||||||
|
val itemInfo = layoutInfo.visibleItemsInfo.first {
|
||||||
|
it.key == item.id
|
||||||
|
}
|
||||||
|
|
||||||
|
val adjustedOffset = itemInfo.offset - layoutInfo.viewportStartOffset
|
||||||
|
(adjustedOffset / itemInfo.size.toFloat()).coerceIn(-1f, 1f)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.testTag("search_carousel_item")
|
||||||
|
.animateItem()
|
||||||
|
.width(156.dp)
|
||||||
|
.aspectRatio(2 / 3f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BackdropCard(
|
||||||
|
manga: Manga,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
alignment: Alignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
BackdropCardContent(
|
||||||
|
manga = manga,
|
||||||
|
alignment = alignment,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BackdropCardContent(
|
||||||
|
manga: Manga,
|
||||||
|
alignment: Alignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
AsyncImage(
|
||||||
|
imageLoader = LocalImageLoader.current,
|
||||||
|
model = manga.largeCoverUrl ?: manga.coverUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.matchParentSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
alignment = alignment,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(
|
||||||
|
Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.drawForegroundGradientScrim(MaterialTheme.colorScheme.surfaceDim),
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = manga.title,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.align(Alignment.BottomStart),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
class ParallaxAlignment(
|
||||||
|
private val horizontalBias: () -> Float = { 0f },
|
||||||
|
private val verticalBias: () -> Float = { 0f },
|
||||||
|
) : Alignment {
|
||||||
|
override fun align(
|
||||||
|
size: IntSize,
|
||||||
|
space: IntSize,
|
||||||
|
layoutDirection: LayoutDirection,
|
||||||
|
): IntOffset {
|
||||||
|
// Convert to Px first and only round at the end, to avoid rounding twice while calculating
|
||||||
|
// the new positions
|
||||||
|
val centerX = (space.width - size.width).toFloat() / 2f
|
||||||
|
val centerY = (space.height - size.height).toFloat() / 2f
|
||||||
|
val resolvedHorizontalBias = if (layoutDirection == LayoutDirection.Ltr) {
|
||||||
|
horizontalBias()
|
||||||
|
} else {
|
||||||
|
-1 * horizontalBias()
|
||||||
|
}
|
||||||
|
|
||||||
|
val x = centerX * (1 + resolvedHorizontalBias)
|
||||||
|
val y = centerY * (1 + verticalBias())
|
||||||
|
return IntOffset(x.roundToInt(), y.roundToInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a vertical gradient scrim in the foreground.
|
||||||
|
*
|
||||||
|
* @param color The color of the gradient scrim.
|
||||||
|
* @param decay The exponential decay to apply to the gradient. Defaults to `3.0f` which is
|
||||||
|
* a cubic decay.
|
||||||
|
* @param numStops The number of color stops to draw in the gradient. Higher numbers result in
|
||||||
|
* the higher visual quality at the cost of draw performance. Defaults to `16`.
|
||||||
|
*/
|
||||||
|
fun Modifier.drawForegroundGradientScrim(
|
||||||
|
color: Color,
|
||||||
|
decay: Float = 1.0f,
|
||||||
|
numStops: Int = 16,
|
||||||
|
startY: Float = 0f,
|
||||||
|
endY: Float = 1f,
|
||||||
|
): Modifier = composed {
|
||||||
|
val colors = remember(color, numStops) {
|
||||||
|
val baseAlpha = color.alpha
|
||||||
|
List(numStops) { i ->
|
||||||
|
val x = i * 1f / (numStops - 1)
|
||||||
|
val opacity = x.pow(decay)
|
||||||
|
color.copy(alpha = baseAlpha * opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawRect(
|
||||||
|
topLeft = Offset(x = 0f, y = startY * size.height),
|
||||||
|
size = size.copy(height = (endY - startY) * size.height),
|
||||||
|
brush = Brush.verticalGradient(colors = colors),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
333
app/src/main/java/org/xtimms/shirizu/core/components/Scaffold.kt
Normal file
333
app/src/main/java/org/xtimms/shirizu/core/components/Scaffold.kt
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
package org.xtimms.shirizu.core.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.MutableWindowInsets
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
|
import androidx.compose.foundation.layout.calculateEndPadding
|
||||||
|
import androidx.compose.foundation.layout.calculateStartPadding
|
||||||
|
import androidx.compose.foundation.layout.exclude
|
||||||
|
import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FabPosition
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ScaffoldDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
|
import androidx.compose.material3.contentColorFor
|
||||||
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.layout.SubcomposeLayout
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.max
|
||||||
|
import androidx.compose.ui.util.fastForEach
|
||||||
|
import androidx.compose.ui.util.fastMap
|
||||||
|
import androidx.compose.ui.util.fastMaxBy
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
/**
|
||||||
|
* From Mihon
|
||||||
|
*
|
||||||
|
* <a href="https://material.io/design/layout/understanding-layout.html" class="external" target="_blank">Material Design layout</a>.
|
||||||
|
*
|
||||||
|
* Scaffold implements the basic material design visual layout structure.
|
||||||
|
*
|
||||||
|
* This component provides API to put together several material components to construct your
|
||||||
|
* screen, by ensuring proper layout strategy for them and collecting necessary data so these
|
||||||
|
* components will work together correctly.
|
||||||
|
*
|
||||||
|
* Simple example of a Scaffold with [SmallTopAppBar], [FloatingActionButton]:
|
||||||
|
*
|
||||||
|
* @sample androidx.compose.material3.samples.SimpleScaffoldWithTopBar
|
||||||
|
*
|
||||||
|
* To show a [Snackbar], use [SnackbarHostState.showSnackbar].
|
||||||
|
*
|
||||||
|
* @sample androidx.compose.material3.samples.ScaffoldWithSimpleSnackbar
|
||||||
|
*
|
||||||
|
* @param modifier the [Modifier] to be applied to this scaffold
|
||||||
|
* @param topBar top app bar of the screen, typically a [SmallTopAppBar]
|
||||||
|
* @param startBar side bar on the start of the screen, typically a [NavigationRail]
|
||||||
|
* @param bottomBar bottom bar of the screen, typically a [NavigationBar]
|
||||||
|
* @param snackbarHost component to host [Snackbar]s that are pushed to be shown via
|
||||||
|
* [SnackbarHostState.showSnackbar], typically a [SnackbarHost]
|
||||||
|
* @param floatingActionButton Main action button of the screen, typically a [FloatingActionButton]
|
||||||
|
* @param floatingActionButtonPosition position of the FAB on the screen. See [FabPosition].
|
||||||
|
* @param containerColor the color used for the background of this scaffold. Use [Color.Transparent]
|
||||||
|
* to have no color.
|
||||||
|
* @param contentColor the preferred color for content inside this scaffold. Defaults to either the
|
||||||
|
* matching content color for [containerColor], or to the current [LocalContentColor] if
|
||||||
|
* [containerColor] is not a color from the theme.
|
||||||
|
* @param contentWindowInsets window insets to be passed to content slot via PaddingValues params.
|
||||||
|
* Scaffold will take the insets into account from the top/bottom only if the topBar/ bottomBar
|
||||||
|
* are not present, as the scaffold expect topBar/bottomBar to handle insets instead
|
||||||
|
* @param content content of the screen. The lambda receives a [PaddingValues] that should be
|
||||||
|
* applied to the content root via [Modifier.padding] and [Modifier.consumeWindowInsets] to
|
||||||
|
* properly offset top and bottom bars. If using [Modifier.verticalScroll], apply this modifier to
|
||||||
|
* the child of the scroll, and not on the scroll itself.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@ExperimentalMaterial3Api
|
||||||
|
@Composable
|
||||||
|
fun Scaffold(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
topBarScrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(
|
||||||
|
rememberTopAppBarState(),
|
||||||
|
),
|
||||||
|
topBar: @Composable (TopAppBarScrollBehavior) -> Unit = {},
|
||||||
|
bottomBar: @Composable () -> Unit = {},
|
||||||
|
startBar: @Composable () -> Unit = {},
|
||||||
|
snackbarHost: @Composable () -> Unit = {},
|
||||||
|
floatingActionButton: @Composable () -> Unit = {},
|
||||||
|
floatingActionButtonPosition: FabPosition = FabPosition.End,
|
||||||
|
containerColor: Color = MaterialTheme.colorScheme.background,
|
||||||
|
contentColor: Color = contentColorFor(containerColor),
|
||||||
|
contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||||
|
content: @Composable (PaddingValues) -> Unit,
|
||||||
|
) {
|
||||||
|
val remainingWindowInsets = remember { MutableWindowInsets() }
|
||||||
|
androidx.compose.material3.Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.nestedScroll(topBarScrollBehavior.nestedScrollConnection)
|
||||||
|
.onConsumedWindowInsetsChanged {
|
||||||
|
remainingWindowInsets.insets = contentWindowInsets.exclude(
|
||||||
|
it,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.then(modifier),
|
||||||
|
color = containerColor,
|
||||||
|
contentColor = contentColor,
|
||||||
|
) {
|
||||||
|
ScaffoldLayout(
|
||||||
|
fabPosition = floatingActionButtonPosition,
|
||||||
|
topBar = { topBar(topBarScrollBehavior) },
|
||||||
|
startBar = startBar,
|
||||||
|
bottomBar = bottomBar,
|
||||||
|
content = content,
|
||||||
|
snackbar = snackbarHost,
|
||||||
|
contentWindowInsets = remainingWindowInsets,
|
||||||
|
fab = floatingActionButton,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout for a [Scaffold]'s content.
|
||||||
|
*
|
||||||
|
* @param fabPosition [FabPosition] for the FAB (if present)
|
||||||
|
* @param topBar the content to place at the top of the [Scaffold], typically a [SmallTopAppBar]
|
||||||
|
* @param content the main 'body' of the [Scaffold]
|
||||||
|
* @param snackbar the [Snackbar] displayed on top of the [content]
|
||||||
|
* @param fab the [FloatingActionButton] displayed on top of the [content], below the [snackbar]
|
||||||
|
* and above the [bottomBar]
|
||||||
|
* @param bottomBar the content to place at the bottom of the [Scaffold], on top of the
|
||||||
|
* [content], typically a [NavigationBar].
|
||||||
|
*/
|
||||||
|
@Suppress("CyclomaticComplexMethod")
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ScaffoldLayout(
|
||||||
|
fabPosition: FabPosition,
|
||||||
|
topBar: @Composable () -> Unit,
|
||||||
|
startBar: @Composable () -> Unit,
|
||||||
|
content: @Composable (PaddingValues) -> Unit,
|
||||||
|
snackbar: @Composable () -> Unit,
|
||||||
|
fab: @Composable () -> Unit,
|
||||||
|
contentWindowInsets: WindowInsets,
|
||||||
|
bottomBar: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
SubcomposeLayout { constraints ->
|
||||||
|
val layoutWidth = constraints.maxWidth
|
||||||
|
val layoutHeight = constraints.maxHeight
|
||||||
|
|
||||||
|
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
|
||||||
|
|
||||||
|
val topBarConstraints = looseConstraints.copy(maxHeight = Constraints.Infinity)
|
||||||
|
|
||||||
|
layout(layoutWidth, layoutHeight) {
|
||||||
|
val leftInset = contentWindowInsets.getLeft(this@SubcomposeLayout, layoutDirection)
|
||||||
|
val rightInset = contentWindowInsets.getRight(this@SubcomposeLayout, layoutDirection)
|
||||||
|
val bottomInset = contentWindowInsets.getBottom(this@SubcomposeLayout)
|
||||||
|
|
||||||
|
val startBarPlaceables = subcompose(ScaffoldLayoutContent.StartBar, startBar).fastMap {
|
||||||
|
it.measure(looseConstraints)
|
||||||
|
}
|
||||||
|
val startBarWidth = startBarPlaceables.fastMaxBy { it.width }?.width ?: 0
|
||||||
|
|
||||||
|
val insetLayoutWidth = layoutWidth - leftInset - rightInset - startBarWidth
|
||||||
|
|
||||||
|
val topBarPlaceables = subcompose(ScaffoldLayoutContent.TopBar, topBar).fastMap {
|
||||||
|
it.measure(topBarConstraints)
|
||||||
|
}
|
||||||
|
|
||||||
|
val topBarHeight = topBarPlaceables.fastMaxBy { it.height }?.height ?: 0
|
||||||
|
|
||||||
|
val snackbarPlaceables = subcompose(ScaffoldLayoutContent.Snackbar, snackbar).fastMap {
|
||||||
|
it.measure(looseConstraints)
|
||||||
|
}
|
||||||
|
|
||||||
|
val snackbarHeight = snackbarPlaceables.fastMaxBy { it.height }?.height ?: 0
|
||||||
|
val snackbarWidth = snackbarPlaceables.fastMaxBy { it.width }?.width ?: 0
|
||||||
|
|
||||||
|
val snackbarLeft = if (snackbarPlaceables.isNotEmpty()) {
|
||||||
|
(insetLayoutWidth - snackbarWidth) / 2 + leftInset
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
val fabPlaceables =
|
||||||
|
subcompose(ScaffoldLayoutContent.Fab, fab).fastMap { measurable ->
|
||||||
|
measurable.measure(looseConstraints)
|
||||||
|
}
|
||||||
|
|
||||||
|
val fabWidth = fabPlaceables.fastMaxBy { it.width }?.width ?: 0
|
||||||
|
val fabHeight = fabPlaceables.fastMaxBy { it.height }?.height ?: 0
|
||||||
|
|
||||||
|
val fabPlacement = if (fabPlaceables.isNotEmpty() && fabWidth != 0 && fabHeight != 0) {
|
||||||
|
// FAB distance from the left of the layout, taking into account LTR / RTL
|
||||||
|
val fabLeftOffset = if (fabPosition == FabPosition.End) {
|
||||||
|
if (layoutDirection == LayoutDirection.Ltr) {
|
||||||
|
layoutWidth - FabSpacing.roundToPx() - fabWidth - rightInset
|
||||||
|
} else {
|
||||||
|
FabSpacing.roundToPx() + leftInset
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
leftInset + ((insetLayoutWidth - fabWidth) / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
FabPlacement(
|
||||||
|
left = fabLeftOffset,
|
||||||
|
width = fabWidth,
|
||||||
|
height = fabHeight,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val bottomBarPlaceables = subcompose(ScaffoldLayoutContent.BottomBar) {
|
||||||
|
bottomBar()
|
||||||
|
}.fastMap { it.measure(looseConstraints) }
|
||||||
|
|
||||||
|
val bottomBarHeight = bottomBarPlaceables
|
||||||
|
.fastMaxBy { it.height }
|
||||||
|
?.height
|
||||||
|
?.takeIf { it != 0 }
|
||||||
|
val fabOffsetFromBottom = fabPlacement?.let {
|
||||||
|
max(bottomBarHeight ?: 0, bottomInset) + it.height + FabSpacing.roundToPx()
|
||||||
|
}
|
||||||
|
|
||||||
|
val snackbarOffsetFromBottom = if (snackbarHeight != 0) {
|
||||||
|
snackbarHeight + (fabOffsetFromBottom ?: max(bottomBarHeight ?: 0, bottomInset))
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
val bodyContentPlaceables = subcompose(ScaffoldLayoutContent.MainContent) {
|
||||||
|
val insets = contentWindowInsets.asPaddingValues(this@SubcomposeLayout)
|
||||||
|
val fabOffsetDp = fabOffsetFromBottom?.toDp() ?: 0.dp
|
||||||
|
val bottomBarHeightPx = bottomBarHeight ?: 0
|
||||||
|
val innerPadding = PaddingValues(
|
||||||
|
top =
|
||||||
|
if (topBarPlaceables.isEmpty()) {
|
||||||
|
insets.calculateTopPadding()
|
||||||
|
} else {
|
||||||
|
topBarHeight.toDp()
|
||||||
|
},
|
||||||
|
bottom =
|
||||||
|
if (bottomBarPlaceables.isEmpty() || bottomBarHeightPx == 0) {
|
||||||
|
max(insets.calculateBottomPadding(), fabOffsetDp)
|
||||||
|
} else {
|
||||||
|
max(bottomBarHeightPx.toDp(), fabOffsetDp)
|
||||||
|
},
|
||||||
|
start = max(
|
||||||
|
insets.calculateStartPadding((this@SubcomposeLayout).layoutDirection),
|
||||||
|
startBarWidth.toDp(),
|
||||||
|
),
|
||||||
|
end = insets.calculateEndPadding((this@SubcomposeLayout).layoutDirection),
|
||||||
|
)
|
||||||
|
content(innerPadding)
|
||||||
|
}.fastMap { it.measure(looseConstraints) }
|
||||||
|
|
||||||
|
// Placing to control drawing order to match default elevation of each placeable
|
||||||
|
|
||||||
|
bodyContentPlaceables.fastForEach {
|
||||||
|
it.place(0, 0)
|
||||||
|
}
|
||||||
|
startBarPlaceables.fastForEach {
|
||||||
|
it.placeRelative(0, 0)
|
||||||
|
}
|
||||||
|
topBarPlaceables.fastForEach {
|
||||||
|
it.place(0, 0)
|
||||||
|
}
|
||||||
|
snackbarPlaceables.fastForEach {
|
||||||
|
it.place(
|
||||||
|
snackbarLeft,
|
||||||
|
layoutHeight - snackbarOffsetFromBottom,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// The bottom bar is always at the bottom of the layout
|
||||||
|
bottomBarPlaceables.fastForEach {
|
||||||
|
it.place(0, layoutHeight - (bottomBarHeight ?: 0))
|
||||||
|
}
|
||||||
|
// Explicitly not using placeRelative here as `leftOffset` already accounts for RTL
|
||||||
|
fabPlaceables.fastForEach {
|
||||||
|
it.place(fabPlacement?.left ?: 0, layoutHeight - (fabOffsetFromBottom ?: 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The possible positions for a [FloatingActionButton] attached to a [Scaffold].
|
||||||
|
*/
|
||||||
|
@ExperimentalMaterial3Api
|
||||||
|
@JvmInline
|
||||||
|
value class FabPosition internal constructor(@Suppress("unused") private val value: Int) {
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Position FAB at the bottom of the screen in the center, above the [NavigationBar] (if it
|
||||||
|
* exists)
|
||||||
|
*/
|
||||||
|
val Center = FabPosition(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position FAB at the bottom of the screen at the end, above the [NavigationBar] (if it
|
||||||
|
* exists)
|
||||||
|
*/
|
||||||
|
val End = FabPosition(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return when (this) {
|
||||||
|
Center -> "FabPosition.Center"
|
||||||
|
else -> "FabPosition.End"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placement information for a [FloatingActionButton] inside a [Scaffold].
|
||||||
|
*
|
||||||
|
* @property left the FAB's offset from the left edge of the bottom bar, already adjusted for RTL
|
||||||
|
* support
|
||||||
|
* @property width the width of the FAB
|
||||||
|
* @property height the height of the FAB
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
internal class FabPlacement(
|
||||||
|
val left: Int,
|
||||||
|
val width: Int,
|
||||||
|
val height: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
// FAB spacing above the bottom bar / bottom of the Scaffold
|
||||||
|
private val FabSpacing = 16.dp
|
||||||
|
|
||||||
|
private enum class ScaffoldLayoutContent { TopBar, MainContent, Snackbar, Fab, BottomBar, StartBar }
|
||||||
@@ -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,
|
modifier: Modifier = Modifier,
|
||||||
backgroundAlphaProvider: () -> Float,
|
backgroundColor: Color? = null,
|
||||||
searchBarColorProvider: () -> Color,
|
// Text
|
||||||
|
subtitle: String? = null,
|
||||||
|
// Up button
|
||||||
|
navigateUp: (() -> Unit)? = null,
|
||||||
|
navigationIcon: ImageVector? = null,
|
||||||
|
// Menu
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
|
// Action mode
|
||||||
|
actionModeCounter: Int = 0,
|
||||||
|
onCancelActionMode: () -> Unit = {},
|
||||||
|
actionModeActions: @Composable RowScope.() -> Unit = {},
|
||||||
|
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||||
) {
|
) {
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
val isActionMode by remember(actionModeCounter) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
derivedStateOf { actionModeCounter > 0 }
|
||||||
|
}
|
||||||
|
|
||||||
val isVisible by remember {
|
AppBar(
|
||||||
derivedStateOf {
|
modifier = modifier,
|
||||||
when (navBackStackEntry?.destination?.route) {
|
backgroundColor = backgroundColor,
|
||||||
SHELF_DESTINATION, HISTORY_DESTINATION, EXPLORE_DESTINATION,
|
titleContent = {
|
||||||
null -> true
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
else -> false
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AppBar(
|
||||||
|
// Title
|
||||||
|
titleContent: @Composable () -> Unit,
|
||||||
|
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
backgroundColor: Color? = null,
|
||||||
|
// Up button
|
||||||
|
navigateUp: (() -> Unit)? = null,
|
||||||
|
navigationIcon: ImageVector? = null,
|
||||||
|
// Menu
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
|
// Action mode
|
||||||
|
isActionMode: Boolean = false,
|
||||||
|
onCancelActionMode: () -> Unit = {},
|
||||||
|
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
if (isActionMode) {
|
||||||
|
IconButton(onClick = onCancelActionMode) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Close,
|
||||||
|
contentDescription = stringResource(R.string.cancel),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navigateUp?.let {
|
||||||
|
IconButton(onClick = it) {
|
||||||
|
UpIcon(navigationIcon = navigationIcon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = titleContent,
|
||||||
|
actions = actions,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = backgroundColor
|
||||||
|
?: MaterialTheme.colorScheme.surfaceColorAtElevation(
|
||||||
|
elevation = if (isActionMode) 3.dp else 0.dp,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AppToolbar(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||||
|
) {
|
||||||
|
AppBar(
|
||||||
|
modifier = modifier,
|
||||||
|
titleContent = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
imageVector = Icons.Filled.Shirizu,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.app_name),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight(900)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = { key("actions") { actions() } },
|
||||||
|
isActionMode = false,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AppBarActions(
|
||||||
|
actions: ImmutableList<AppBar.AppBarAction>,
|
||||||
|
) {
|
||||||
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
actions.filterIsInstance<AppBar.Action>().map {
|
||||||
|
TooltipBox(
|
||||||
|
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
||||||
|
tooltip = {
|
||||||
|
PlainTooltip {
|
||||||
|
Text(it.title)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
state = rememberTooltipState(),
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = it.onClick,
|
||||||
|
enabled = it.enabled,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = it.icon,
|
||||||
|
tint = it.iconTint ?: LocalContentColor.current,
|
||||||
|
contentDescription = it.title,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val aprilFoolsDay = LocalDate.of(LocalDate.now().year, 4, 1)
|
val overflowActions = actions.filterIsInstance<AppBar.OverflowAction>()
|
||||||
val dtStart = aprilFoolsDay.format(DateTimeFormatter.ISO_DATE)
|
if (overflowActions.isNotEmpty()) {
|
||||||
val currentDt = LocalDate.now()
|
TooltipBox(
|
||||||
|
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
||||||
val isAprilFoolsDay = currentDt.format(DateTimeFormatter.ISO_DATE).equals(dtStart)
|
tooltip = {
|
||||||
|
PlainTooltip {
|
||||||
val path = Path().apply {
|
Text(stringResource(R.string.action_menu_overflow_description))
|
||||||
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)
|
state = rememberTooltipState(),
|
||||||
}
|
|
||||||
|
|
||||||
val emphasizePathInterpolator = PathInterpolator(path)
|
|
||||||
val emphasizeEasing = emphasizePathInterpolator.toEasing()
|
|
||||||
|
|
||||||
val enterTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
|
|
||||||
val exitTween = tween<IntOffset>(durationMillis = DURATION_ENTER, easing = emphasizeEasing)
|
|
||||||
val fadeTween = tween<Float>(durationMillis = DURATION_EXIT)
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = isVisible,
|
|
||||||
enter = slideInHorizontally(
|
|
||||||
enterTween,
|
|
||||||
initialOffsetX = { -(it * initialOffset).toInt() }) + fadeIn(fadeTween),
|
|
||||||
exit = slideOutHorizontally(
|
|
||||||
exitTween,
|
|
||||||
targetOffsetX = { -(it * initialOffset).toInt() }) + fadeOut(fadeTween)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
MaterialTheme.colorScheme
|
|
||||||
.surfaceColorAtElevation(3.dp)
|
|
||||||
.copy(
|
|
||||||
alpha = backgroundAlphaProvider()
|
|
||||||
)
|
|
||||||
),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
) {
|
||||||
Card(
|
IconButton(
|
||||||
onClick = { navController.navigate(SEARCH_DESTINATION) },
|
onClick = { showMenu = !showMenu },
|
||||||
modifier = modifier
|
|
||||||
.weight(1f)
|
|
||||||
.height(56.dp)
|
|
||||||
.padding(start = 16.dp),
|
|
||||||
shape = RoundedCornerShape(50),
|
|
||||||
colors = CardDefaults.cardColors().copy(containerColor = searchBarColorProvider()),
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Icon(
|
||||||
modifier = Modifier
|
Icons.Outlined.MoreVert,
|
||||||
.padding(horizontal = 16.dp)
|
contentDescription = stringResource(R.string.action_menu_overflow_description),
|
||||||
.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),
|
|
||||||
) {
|
DropdownMenu(
|
||||||
if (AppSettings.isTrackerEnabled()) {
|
expanded = showMenu,
|
||||||
IconButton(
|
onDismissRequest = { showMenu = false },
|
||||||
onClick = { navController.navigate(FEED_DESTINATION) },
|
) {
|
||||||
modifier = Modifier.padding(0.dp),
|
overflowActions.map {
|
||||||
) {
|
DropdownMenuItem(
|
||||||
Icon(
|
onClick = {
|
||||||
Icons.Outlined.RssFeed,
|
it.onClick()
|
||||||
contentDescription = stringResource(id = R.string.feed),
|
showMenu = false
|
||||||
tint = MaterialTheme.colorScheme.outline
|
},
|
||||||
)
|
text = { Text(it.title, fontWeight = FontWeight.Normal) },
|
||||||
}
|
)
|
||||||
IconButton(
|
|
||||||
onClick = { expanded = true },
|
|
||||||
modifier = Modifier.padding(0.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Outlined.MoreVert,
|
|
||||||
contentDescription = stringResource(id = R.string.open_menu),
|
|
||||||
tint = MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(text = stringResource(id = R.string.statistics)) },
|
|
||||||
onClick = {
|
|
||||||
navController.navigate(STATS_DESTINATION)
|
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.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 {
|
|
||||||
IconButton(
|
|
||||||
onClick = { navController.navigate(STATS_DESTINATION) },
|
|
||||||
modifier = Modifier.padding(0.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Outlined.QueryStats,
|
|
||||||
contentDescription = stringResource(id = R.string.statistics),
|
|
||||||
tint = MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(
|
|
||||||
onClick = { navController.navigate(SETTINGS_DESTINATION) },
|
|
||||||
modifier = Modifier.padding(0.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Outlined.Settings,
|
|
||||||
contentDescription = stringResource(id = R.string.settings),
|
|
||||||
tint = MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user