diff --git a/.editorconfig b/.editorconfig
index e99fe5d60..afb2723bf 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -5,7 +5,7 @@ charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = tab
-insert_final_newline = false
+insert_final_newline = true
max_line_length = 120
tab_width = 4
# noinspection EditorConfigKeyCorrectness
diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
new file mode 100644
index 000000000..e32589680
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE.md
@@ -0,0 +1,29 @@
+**PLEASE READ THIS**
+
+I acknowledge that:
+
+- I have updated to the latest version of the app (https://github.com/KotatsuApp/Kotatsu/releases/latest)
+- If this is an issue with a parser, that I should be opening an issue in https://github.com/KotatsuApp/kotatsu-parsers
+- I have searched the existing issues and this is new ticket **NOT** a duplicate or related to another open or closed issue
+- I will fill out the title and the information in this template
+
+Note that the issue will be automatically closed if you do not fill out the title or requested information.
+
+**DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT**
+
+---
+
+## Device information
+* Kotatsu version: ?
+* Android version: ?
+* Device: ?
+
+## Steps to reproduce
+1. First step
+2. Second step
+
+## Issue/Request
+?
+
+## Other details
+Additional details and attachments.
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index b6d4254a1..9af821d54 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,5 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: ⚠️ Source issue
- url: https://github.com/nv95/kotatsu-parsers/issues/new
- about: Issues and requests for sources should be opened in the kotatsu-parsers repository instead
\ No newline at end of file
+ url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
+ about: If you have troubles with a manga parser or want to propose new manga source, please open an issue in the kotatsu-parsers repository instead
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/report_bug.yml b/.github/ISSUE_TEMPLATE/report_bug.yml
new file mode 100644
index 000000000..261f51945
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/report_bug.yml
@@ -0,0 +1,64 @@
+name: 🐞 Bug report
+description: Report a bug in Kotatsu
+labels: [bug]
+body:
+
+ - type: textarea
+ id: summary
+ attributes:
+ label: Brief summary
+ description: Please describe, what went wrong
+ validations:
+ required: true
+
+ - type: textarea
+ id: reproduce-steps
+ attributes:
+ label: Steps to reproduce
+ description: Please provide a way to reproduce this issue. Screenshots or videos can be very helpful
+ placeholder: |
+ Example:
+ 1. First step
+ 2. Second step
+ 3. Issue here
+ validations:
+ required: false
+
+
+ - type: input
+ id: kotatsu-version
+ attributes:
+ label: Kotatsu version
+ description: You can find your Kotatsu version in **Settings → About**.
+ placeholder: |
+ Example: "3.3"
+ validations:
+ required: true
+
+ - type: input
+ id: android-version
+ attributes:
+ label: Android version
+ description: You can find this somewhere in your Android settings.
+ placeholder: |
+ Example: "12.0"
+ validations:
+ required: true
+
+ - type: input
+ id: device
+ attributes:
+ label: Device
+ description: List your device and model.
+ placeholder: |
+ Example: "LG Nexus 5X"
+ validations:
+ required: false
+
+ - type: checkboxes
+ id: acknowledgements
+ attributes:
+ label: Acknowledgements
+ options:
+ - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
+ required: true
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/report_issue.yml b/.github/ISSUE_TEMPLATE/report_issue.yml
deleted file mode 100644
index c640ff3c0..000000000
--- a/.github/ISSUE_TEMPLATE/report_issue.yml
+++ /dev/null
@@ -1,93 +0,0 @@
-name: 🐞 Issue report
-description: Report an issue in Kotatsu
-labels: [bug]
-body:
-
- - type: textarea
- id: reproduce-steps
- attributes:
- label: Steps to reproduce
- description: Provide an example of the issue.
- placeholder: |
- Example:
- 1. First step
- 2. Second step
- 3. Issue here
- validations:
- required: true
-
- - type: textarea
- id: expected-behavior
- attributes:
- label: Expected behavior
- description: Explain what you should expect to happen.
- placeholder: |
- Example:
- "This should happen..."
- validations:
- required: true
-
- - type: textarea
- id: actual-behavior
- attributes:
- label: Actual behavior
- description: Explain what actually happens.
- placeholder: |
- Example:
- "This happened instead..."
- validations:
- required: true
-
- - type: input
- id: kotatsu-version
- attributes:
- label: Kotatsu version
- description: You can find your Kotatsu version in **Settings → About**.
- placeholder: |
- Example: "3.2.3"
- validations:
- required: true
-
- - type: input
- id: android-version
- attributes:
- label: Android version
- description: You can find this somewhere in your Android settings.
- placeholder: |
- Example: "Android 12"
- validations:
- required: true
-
- - type: input
- id: device
- attributes:
- label: Device
- description: List your device and model.
- placeholder: |
- Example: "LG Nexus 5X"
- validations:
- required: true
-
- - type: textarea
- id: other-details
- attributes:
- label: Other details
- placeholder: |
- Additional details and attachments.
-
- - type: checkboxes
- id: acknowledgements
- attributes:
- label: Acknowledgements
- description: Read this carefully, we will close and ignore your issue if you skimmed through this.
- options:
- - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
- required: true
- - label: I have written a short but informative title.
- required: true
- - label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
- required: true
- - label: I have updated the app to version **[3.2.3](https://github.com/nv95/Kotatsu/releases/latest)**.
- required: true
- - label: I will fill out all of the requested information in this form.
- required: true
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/request_feature.yml b/.github/ISSUE_TEMPLATE/request_feature.yml
index bae1b501c..a8539d394 100644
--- a/.github/ISSUE_TEMPLATE/request_feature.yml
+++ b/.github/ISSUE_TEMPLATE/request_feature.yml
@@ -1,5 +1,5 @@
name: ⭐ Feature request
-description: Suggest a feature to improve Kotatsu
+description: Suggest a new idea how to improve Kotatsu
labels: [feature request]
body:
@@ -14,13 +14,6 @@ body:
validations:
required: true
- - type: textarea
- id: other-details
- attributes:
- label: Other details
- placeholder: |
- Additional details and attachments.
-
- type: checkboxes
id: acknowledgements
attributes:
@@ -28,12 +21,4 @@ body:
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
options:
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
- required: true
- - label: I have written a short but informative title.
- required: true
- - label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
- required: true
- - label: I have updated the app to version **[3.2.3](https://github.com/nv95/Kotatsu/releases/latest)**.
- required: true
- - label: I will fill out all of the requested information in this form.
required: true
\ No newline at end of file
diff --git a/.github/workflows/issue_moderator.yml b/.github/workflows/issue_moderator.yml
new file mode 100644
index 000000000..ef256ed06
--- /dev/null
+++ b/.github/workflows/issue_moderator.yml
@@ -0,0 +1,29 @@
+name: Issue moderator
+
+on:
+ issues:
+ types: [opened, edited, reopened]
+ issue_comment:
+ types: [created]
+
+jobs:
+ moderate:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Moderate issues
+ uses: tachiyomiorg/issue-moderator-action@v1
+ with:
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+ auto-close-rules: |
+ [
+ {
+ "type": "body",
+ "regex": ".*DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT.*",
+ "message": "The acknowledgment section was not removed."
+ },
+ {
+ "type": "body",
+ "regex": ".*\\* (Kotatsu version|Android version|Device): \\?.*",
+ "message": "Requested information in the template was not filled out."
+ }
+ ]
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 3ba4daee9..5611db9cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,11 +6,14 @@
/.idea/dictionaries
/.idea/modules.xml
/.idea/misc.xml
+/.idea/discord.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/kotlinScripting.xml
/.idea/deploymentTargetDropDown.xml
+/.idea/androidTestResultsUserPreferences.xml
+/.idea/render.experimental.xml
.DS_Store
/build
/captures
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 6e5389ed9..a0de2a152 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -7,7 +7,7 @@
-
+
diff --git a/.idea/icon.svg b/.idea/icon.svg
new file mode 100644
index 000000000..61006f3ad
--- /dev/null
+++ b/.idea/icon.svg
@@ -0,0 +1,287 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/render.experimental.xml b/.idea/render.experimental.xml
deleted file mode 100644
index 5cad4e0be..000000000
--- a/.idea/render.experimental.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/README.md b/README.md
index cea436ae3..82e4fada7 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
-# Kotatsu
+# Kotatsu
Kotatsu is a free and open source manga reader for Android.
-   [](https://hosted.weblate.org/engage/kotatsu/) [](http://4pda.ru/forum/index.php?showtopic=697669) [](https://discord.gg/NNJ5RgVBC5)
+   [](https://hosted.weblate.org/engage/kotatsu/) [](http://4pda.ru/forum/index.php?showtopic=697669) [](https://discord.gg/NNJ5RgVBC5)
### Download
@@ -10,50 +10,48 @@ Kotatsu is a free and open source manga reader for Android.
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/org.koitharu.kotatsu)
-Download APK from Github Releases:
+Download APK directly from GitHub:
-- [Latest release](https://github.com/nv95/Kotatsu/releases/latest)
-- [Legacy build](https://github.com/nv95/Kotatsu/releases/tag/v0.4-legacy) (with Android 4.1+ support)
+- **[Latest release](https://github.com/KotatsuApp/Kotatsu/releases/latest)**
### Main Features
* Online manga catalogues
-* Search manga by name and genre
-* Reading history
+* Search manga by name and genres
+* Reading history and bookmarks
* Favourites organized by user-defined categories
* Downloading manga and reading it offline. Third-party CBZ archives also supported
* Tablet-optimized material design UI
* Standard and Webtoon-optimized reader
* Notifications about new chapters with updates feed
-* Available in multiple languages
-* Password protect access to the app
+* Shikimori integration (manga tracking)
+* Password/fingerprint protect access to the app
### Screenshots
-|  |  |  |
-|-----------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|
-|  |  |  |
+|  |  |  |
+|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
+|  |  |  |
-|  |  |
-|-----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
+|  |  |
+|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
### Localization
-
-
-
+[ ](https://hosted.weblate.org/engage/kotatsu/)
-Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages, please head over to the Weblate project page
+Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages,
+please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)
### License
-[](http://www.gnu.org/licenses/gpl-3.0.en.html)
-Kotatsu is Free Software: You can use, study share and improve it at your
-will. Specifically you can redistribute and/or modify it under the terms of the
-[GNU General Public License](https://www.gnu.org/licenses/gpl.html) as
-published by the Free Software Foundation, either version 3 of the License, or
-(at your option) any later version.
+[](http://www.gnu.org/licenses/gpl-3.0.en.html)
-### Disclaimer
+You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications
+to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build &
+install instructions.
-The developers of this application does not have any affiliation with the content providers available.
+### DMCA disclaimer
+
+The developers of this application does not have any affiliation with the content available in the app.
+It is collecting from the sources freely available through any web browser.
diff --git a/app/build.gradle b/app/build.gradle
index e81717951..b9300fa75 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -14,8 +14,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 32
- versionCode 408
- versionName '3.3-beta1'
+ versionCode 416
+ versionName '3.4.4'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -24,6 +24,14 @@ android {
arg 'room.schemaLocation', "$projectDir/schemas".toString()
}
}
+
+ // define this values in your local.properties file
+ buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\""
+ buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\""
+
+ if (currentBranch() == "feature/nextgen") {
+ applicationIdSuffix = '.next'
+ }
}
buildTypes {
debug {
@@ -60,62 +68,81 @@ android {
disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged'
}
testOptions {
- unitTests.includeAndroidResources = true
- unitTests.returnDefaultValues = false
+ unitTests.includeAndroidResources true
+ unitTests.returnDefaultValues false
+ kotlinOptions {
+ freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
+ }
+ }
+}
+afterEvaluate {
+ compileDebugKotlin {
+ kotlinOptions {
+ freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
+ }
}
}
dependencies {
- implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
- implementation('com.github.nv95:kotatsu-parsers:05a93e2380') {
+ implementation('com.github.nv95:kotatsu-parsers:6af8cec134') {
exclude group: 'org.json', module: 'json'
}
- implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1'
- implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
- implementation 'androidx.core:core-ktx:1.7.0'
- implementation 'androidx.activity:activity-ktx:1.4.0'
- implementation 'androidx.fragment:fragment-ktx:1.4.1'
- implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
- implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1'
- implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1'
- implementation 'androidx.lifecycle:lifecycle-service:2.4.1'
- implementation 'androidx.lifecycle:lifecycle-process:2.4.1'
- implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
+ implementation 'androidx.core:core-ktx:1.8.0'
+ implementation 'androidx.activity:activity-ktx:1.5.0'
+ implementation 'androidx.fragment:fragment-ktx:1.5.0'
+ implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0'
+ implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.0'
+ implementation 'androidx.lifecycle:lifecycle-service:2.5.0'
+ implementation 'androidx.lifecycle:lifecycle-process:2.5.0'
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1'
- implementation 'com.google.android.material:material:1.7.0-alpha01'
+ implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04'
+ implementation 'com.google.android.material:material:1.7.0-alpha03'
//noinspection LifecycleAnnotationProcessorWithJava8
- kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1'
+ kapt 'androidx.lifecycle:lifecycle-compiler:2.5.0'
implementation 'androidx.room:room-runtime:2.4.2'
implementation 'androidx.room:room-ktx:2.4.2'
kapt 'androidx.room:room-compiler:2.4.2'
- implementation 'com.squareup.okhttp3:okhttp:4.9.3'
+ implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
- implementation 'com.squareup.okio:okio:3.1.0'
+ implementation 'com.squareup.okio:okio:3.2.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'io.insert-koin:koin-android:3.2.0'
- implementation 'io.coil-kt:coil-base:2.0.0'
+ implementation 'io.coil-kt:coil-base:2.1.0'
+ implementation 'io.coil-kt:coil-svg:2.1.0'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.solkin:disk-lru-cache:1.4'
+ implementation 'ch.acra:acra-mail:5.9.5'
+ implementation 'ch.acra:acra-dialog:5.9.5'
+
+ debugImplementation 'org.jsoup:jsoup:1.15.2'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
testImplementation 'junit:junit:4.13.2'
- testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1'
- testImplementation 'io.insert-koin:koin-test-junit4:3.2.0'
+ testImplementation 'org.json:json:20220320'
+ testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test:core-ktx:1.4.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
+
+ androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
+ androidTestImplementation 'io.insert-koin:koin-test:3.2.0'
+ androidTestImplementation 'io.insert-koin:koin-test-junit4:3.2.0'
+
androidTestImplementation 'androidx.room:room-testing:2.4.2'
+ androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.13.0'
}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/categories/simple.json b/app/src/androidTest/assets/categories/simple.json
new file mode 100644
index 000000000..90f6ecf1a
--- /dev/null
+++ b/app/src/androidTest/assets/categories/simple.json
@@ -0,0 +1,8 @@
+{
+ "id": 4,
+ "title": "Read later",
+ "sortKey": 1,
+ "order": "NEWEST",
+ "createdAt": 1335906000000,
+ "isTrackingEnabled": true
+}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/manga/bad_ids.json b/app/src/androidTest/assets/manga/bad_ids.json
new file mode 100644
index 000000000..d0f9001a0
--- /dev/null
+++ b/app/src/androidTest/assets/manga/bad_ids.json
@@ -0,0 +1,163 @@
+{
+ "id": -2096681732556647985,
+ "title": "Странствия Эманон",
+ "url": "/stranstviia_emanon",
+ "publicUrl": "https://readmanga.io/stranstviia_emanon",
+ "rating": 0.9400894,
+ "isNsfw": true,
+ "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
+ "tags": [
+ {
+ "title": "Сверхъестественное",
+ "key": "supernatural",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Сэйнэн",
+ "key": "seinen",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Повседневность",
+ "key": "slice_of_life",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Приключения",
+ "key": "adventure",
+ "source": "READMANGA_RU"
+ }
+ ],
+ "state": "FINISHED",
+ "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
+ "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n Начало истории читайте в \"Воспоминаниях Эманон\". \n
",
+ "chapters": [
+ {
+ "id": 1552943969433540704,
+ "name": "1 - 1",
+ "number": 1,
+ "url": "/stranstviia_emanon/vol1/1",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433540705,
+ "name": "1 - 2",
+ "number": 2,
+ "url": "/stranstviia_emanon/vol1/2",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433540706,
+ "name": "1 - 3",
+ "number": 3,
+ "url": "/stranstviia_emanon/vol1/3",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433540707,
+ "name": "1 - 4",
+ "number": 4,
+ "url": "/stranstviia_emanon/vol1/4",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433540708,
+ "name": "1 - 5",
+ "number": 5,
+ "url": "/stranstviia_emanon/vol1/5",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433541665,
+ "name": "2 - 1",
+ "number": 6,
+ "url": "/stranstviia_emanon/vol2/1",
+ "scanlator": "Sup!",
+ "uploadDate": 1415570400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433541666,
+ "name": "2 - 2",
+ "number": 7,
+ "url": "/stranstviia_emanon/vol2/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1419976800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433541667,
+ "name": "2 - 3",
+ "number": 8,
+ "url": "/stranstviia_emanon/vol2/3",
+ "scanlator": "Sup!",
+ "uploadDate": 1427922000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433541668,
+ "name": "2 - 4",
+ "number": 9,
+ "url": "/stranstviia_emanon/vol2/4",
+ "scanlator": "Sup!",
+ "uploadDate": 1436907600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433541669,
+ "name": "2 - 5",
+ "number": 10,
+ "url": "/stranstviia_emanon/vol2/5",
+ "scanlator": "Sup!",
+ "uploadDate": 1446674400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433541670,
+ "name": "2 - 6",
+ "number": 11,
+ "url": "/stranstviia_emanon/vol2/6",
+ "scanlator": "Sup!",
+ "uploadDate": 1451512800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433542626,
+ "name": "3 - 1",
+ "number": 12,
+ "url": "/stranstviia_emanon/vol3/1",
+ "scanlator": "Sup!",
+ "uploadDate": 1461618000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433542627,
+ "name": "3 - 2",
+ "number": 13,
+ "url": "/stranstviia_emanon/vol3/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1461618000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 1552943969433542628,
+ "name": "3 - 3",
+ "number": 14,
+ "url": "/stranstviia_emanon/vol3/3",
+ "scanlator": "",
+ "uploadDate": 1465851600000,
+ "source": "READMANGA_RU"
+ }
+ ],
+ "source": "READMANGA_RU"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/manga/empty.json b/app/src/androidTest/assets/manga/empty.json
new file mode 100644
index 000000000..369f0e237
--- /dev/null
+++ b/app/src/androidTest/assets/manga/empty.json
@@ -0,0 +1,36 @@
+{
+ "id": -2096681732556647985,
+ "title": "Странствия Эманон",
+ "url": "/stranstviia_emanon",
+ "publicUrl": "https://readmanga.io/stranstviia_emanon",
+ "rating": 0.9400894,
+ "isNsfw": true,
+ "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
+ "tags": [
+ {
+ "title": "Сверхъестественное",
+ "key": "supernatural",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Сэйнэн",
+ "key": "seinen",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Повседневность",
+ "key": "slice_of_life",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Приключения",
+ "key": "adventure",
+ "source": "READMANGA_RU"
+ }
+ ],
+ "state": "FINISHED",
+ "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
+ "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n Начало истории читайте в \"Воспоминаниях Эманон\". \n
",
+ "chapters": [],
+ "source": "READMANGA_RU"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/manga/first_chapters.json b/app/src/androidTest/assets/manga/first_chapters.json
new file mode 100644
index 000000000..697dec9c8
--- /dev/null
+++ b/app/src/androidTest/assets/manga/first_chapters.json
@@ -0,0 +1,136 @@
+{
+ "id": -2096681732556647985,
+ "title": "Странствия Эманон",
+ "url": "/stranstviia_emanon",
+ "publicUrl": "https://readmanga.io/stranstviia_emanon",
+ "rating": 0.9400894,
+ "isNsfw": true,
+ "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
+ "tags": [
+ {
+ "title": "Сверхъестественное",
+ "key": "supernatural",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Сэйнэн",
+ "key": "seinen",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Повседневность",
+ "key": "slice_of_life",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Приключения",
+ "key": "adventure",
+ "source": "READMANGA_RU"
+ }
+ ],
+ "state": "FINISHED",
+ "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
+ "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n Начало истории читайте в \"Воспоминаниях Эманон\". \n
",
+ "chapters": [
+ {
+ "id": 3552943969433540704,
+ "name": "1 - 1",
+ "number": 1,
+ "url": "/stranstviia_emanon/vol1/1",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540705,
+ "name": "1 - 2",
+ "number": 2,
+ "url": "/stranstviia_emanon/vol1/2",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540706,
+ "name": "1 - 3",
+ "number": 3,
+ "url": "/stranstviia_emanon/vol1/3",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540707,
+ "name": "1 - 4",
+ "number": 4,
+ "url": "/stranstviia_emanon/vol1/4",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540708,
+ "name": "1 - 5",
+ "number": 5,
+ "url": "/stranstviia_emanon/vol1/5",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541665,
+ "name": "2 - 1",
+ "number": 6,
+ "url": "/stranstviia_emanon/vol2/1",
+ "scanlator": "Sup!",
+ "uploadDate": 1415570400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541666,
+ "name": "2 - 2",
+ "number": 7,
+ "url": "/stranstviia_emanon/vol2/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1419976800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541667,
+ "name": "2 - 3",
+ "number": 8,
+ "url": "/stranstviia_emanon/vol2/3",
+ "scanlator": "Sup!",
+ "uploadDate": 1427922000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541668,
+ "name": "2 - 4",
+ "number": 9,
+ "url": "/stranstviia_emanon/vol2/4",
+ "scanlator": "Sup!",
+ "uploadDate": 1436907600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541669,
+ "name": "2 - 5",
+ "number": 10,
+ "url": "/stranstviia_emanon/vol2/5",
+ "scanlator": "Sup!",
+ "uploadDate": 1446674400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541670,
+ "name": "2 - 6",
+ "number": 11,
+ "url": "/stranstviia_emanon/vol2/6",
+ "scanlator": "Sup!",
+ "uploadDate": 1451512800000,
+ "source": "READMANGA_RU"
+ }
+ ],
+ "source": "READMANGA_RU"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/manga/full.json b/app/src/androidTest/assets/manga/full.json
new file mode 100644
index 000000000..9667baa9c
--- /dev/null
+++ b/app/src/androidTest/assets/manga/full.json
@@ -0,0 +1,163 @@
+{
+ "id": -2096681732556647985,
+ "title": "Странствия Эманон",
+ "url": "/stranstviia_emanon",
+ "publicUrl": "https://readmanga.io/stranstviia_emanon",
+ "rating": 0.9400894,
+ "isNsfw": true,
+ "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
+ "tags": [
+ {
+ "title": "Сверхъестественное",
+ "key": "supernatural",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Сэйнэн",
+ "key": "seinen",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Повседневность",
+ "key": "slice_of_life",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Приключения",
+ "key": "adventure",
+ "source": "READMANGA_RU"
+ }
+ ],
+ "state": "FINISHED",
+ "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
+ "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n Начало истории читайте в \"Воспоминаниях Эманон\". \n
",
+ "chapters": [
+ {
+ "id": 3552943969433540704,
+ "name": "1 - 1",
+ "number": 1,
+ "url": "/stranstviia_emanon/vol1/1",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540705,
+ "name": "1 - 2",
+ "number": 2,
+ "url": "/stranstviia_emanon/vol1/2",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540706,
+ "name": "1 - 3",
+ "number": 3,
+ "url": "/stranstviia_emanon/vol1/3",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540707,
+ "name": "1 - 4",
+ "number": 4,
+ "url": "/stranstviia_emanon/vol1/4",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540708,
+ "name": "1 - 5",
+ "number": 5,
+ "url": "/stranstviia_emanon/vol1/5",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541665,
+ "name": "2 - 1",
+ "number": 6,
+ "url": "/stranstviia_emanon/vol2/1",
+ "scanlator": "Sup!",
+ "uploadDate": 1415570400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541666,
+ "name": "2 - 2",
+ "number": 7,
+ "url": "/stranstviia_emanon/vol2/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1419976800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541667,
+ "name": "2 - 3",
+ "number": 8,
+ "url": "/stranstviia_emanon/vol2/3",
+ "scanlator": "Sup!",
+ "uploadDate": 1427922000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541668,
+ "name": "2 - 4",
+ "number": 9,
+ "url": "/stranstviia_emanon/vol2/4",
+ "scanlator": "Sup!",
+ "uploadDate": 1436907600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541669,
+ "name": "2 - 5",
+ "number": 10,
+ "url": "/stranstviia_emanon/vol2/5",
+ "scanlator": "Sup!",
+ "uploadDate": 1446674400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541670,
+ "name": "2 - 6",
+ "number": 11,
+ "url": "/stranstviia_emanon/vol2/6",
+ "scanlator": "Sup!",
+ "uploadDate": 1451512800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433542626,
+ "name": "3 - 1",
+ "number": 12,
+ "url": "/stranstviia_emanon/vol3/1",
+ "scanlator": "Sup!",
+ "uploadDate": 1461618000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433542627,
+ "name": "3 - 2",
+ "number": 13,
+ "url": "/stranstviia_emanon/vol3/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1461618000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433542628,
+ "name": "3 - 3",
+ "number": 14,
+ "url": "/stranstviia_emanon/vol3/3",
+ "scanlator": "",
+ "uploadDate": 1465851600000,
+ "source": "READMANGA_RU"
+ }
+ ],
+ "source": "READMANGA_RU"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/manga/header.json b/app/src/androidTest/assets/manga/header.json
new file mode 100644
index 000000000..dc56dbf8e
--- /dev/null
+++ b/app/src/androidTest/assets/manga/header.json
@@ -0,0 +1,35 @@
+{
+ "id": -2096681732556647985,
+ "title": "Странствия Эманон",
+ "url": "/stranstviia_emanon",
+ "publicUrl": "https://readmanga.io/stranstviia_emanon",
+ "rating": 0.9400894,
+ "isNsfw": true,
+ "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
+ "tags": [
+ {
+ "title": "Сверхъестественное",
+ "key": "supernatural",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Сэйнэн",
+ "key": "seinen",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Повседневность",
+ "key": "slice_of_life",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Приключения",
+ "key": "adventure",
+ "source": "READMANGA_RU"
+ }
+ ],
+ "state": "FINISHED",
+ "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
+ "description": null,
+ "source": "READMANGA_RU"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/manga/without_middle_chapter.json b/app/src/androidTest/assets/manga/without_middle_chapter.json
new file mode 100644
index 000000000..97d797b53
--- /dev/null
+++ b/app/src/androidTest/assets/manga/without_middle_chapter.json
@@ -0,0 +1,154 @@
+{
+ "id": -2096681732556647985,
+ "title": "Странствия Эманон",
+ "url": "/stranstviia_emanon",
+ "publicUrl": "https://readmanga.io/stranstviia_emanon",
+ "rating": 0.9400894,
+ "isNsfw": true,
+ "coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
+ "tags": [
+ {
+ "title": "Сверхъестественное",
+ "key": "supernatural",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Сэйнэн",
+ "key": "seinen",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Повседневность",
+ "key": "slice_of_life",
+ "source": "READMANGA_RU"
+ },
+ {
+ "title": "Приключения",
+ "key": "adventure",
+ "source": "READMANGA_RU"
+ }
+ ],
+ "state": "FINISHED",
+ "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
+ "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n Начало истории читайте в \"Воспоминаниях Эманон\". \n
",
+ "chapters": [
+ {
+ "id": 3552943969433540704,
+ "name": "1 - 1",
+ "number": 1,
+ "url": "/stranstviia_emanon/vol1/1",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540705,
+ "name": "1 - 2",
+ "number": 2,
+ "url": "/stranstviia_emanon/vol1/2",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540706,
+ "name": "1 - 3",
+ "number": 3,
+ "url": "/stranstviia_emanon/vol1/3",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540707,
+ "name": "1 - 4",
+ "number": 4,
+ "url": "/stranstviia_emanon/vol1/4",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433540708,
+ "name": "1 - 5",
+ "number": 5,
+ "url": "/stranstviia_emanon/vol1/5",
+ "scanlator": "Sad-Robot",
+ "uploadDate": 1342731600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541666,
+ "name": "2 - 2",
+ "number": 7,
+ "url": "/stranstviia_emanon/vol2/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1419976800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541667,
+ "name": "2 - 3",
+ "number": 8,
+ "url": "/stranstviia_emanon/vol2/3",
+ "scanlator": "Sup!",
+ "uploadDate": 1427922000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541668,
+ "name": "2 - 4",
+ "number": 9,
+ "url": "/stranstviia_emanon/vol2/4",
+ "scanlator": "Sup!",
+ "uploadDate": 1436907600000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541669,
+ "name": "2 - 5",
+ "number": 10,
+ "url": "/stranstviia_emanon/vol2/5",
+ "scanlator": "Sup!",
+ "uploadDate": 1446674400000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433541670,
+ "name": "2 - 6",
+ "number": 11,
+ "url": "/stranstviia_emanon/vol2/6",
+ "scanlator": "Sup!",
+ "uploadDate": 1451512800000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433542626,
+ "name": "3 - 1",
+ "number": 12,
+ "url": "/stranstviia_emanon/vol3/1",
+ "scanlator": "Sup!",
+ "uploadDate": 1461618000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433542627,
+ "name": "3 - 2",
+ "number": 13,
+ "url": "/stranstviia_emanon/vol3/2",
+ "scanlator": "Sup!",
+ "uploadDate": 1461618000000,
+ "source": "READMANGA_RU"
+ },
+ {
+ "id": 3552943969433542628,
+ "name": "3 - 3",
+ "number": 14,
+ "url": "/stranstviia_emanon/vol3/3",
+ "scanlator": "",
+ "uploadDate": 1465851600000,
+ "source": "READMANGA_RU"
+ }
+ ],
+ "source": "READMANGA_RU"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/Instrumentation.kt b/app/src/androidTest/java/org/koitharu/kotatsu/Instrumentation.kt
new file mode 100644
index 000000000..b9ef582c1
--- /dev/null
+++ b/app/src/androidTest/java/org/koitharu/kotatsu/Instrumentation.kt
@@ -0,0 +1,9 @@
+package org.koitharu.kotatsu
+
+import android.app.Instrumentation
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+
+suspend fun Instrumentation.awaitForIdle() = suspendCoroutine { cont ->
+ waitForIdle { cont.resume(Unit) }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/SampleData.kt b/app/src/androidTest/java/org/koitharu/kotatsu/SampleData.kt
new file mode 100644
index 000000000..b7d4ad7e3
--- /dev/null
+++ b/app/src/androidTest/java/org/koitharu/kotatsu/SampleData.kt
@@ -0,0 +1,54 @@
+package org.koitharu.kotatsu
+
+import androidx.test.platform.app.InstrumentationRegistry
+import com.squareup.moshi.*
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import okio.buffer
+import okio.source
+import org.koitharu.kotatsu.core.model.FavouriteCategory
+import org.koitharu.kotatsu.parsers.model.Manga
+import java.util.*
+import kotlin.reflect.KClass
+
+object SampleData {
+
+ private val moshi = Moshi.Builder()
+ .add(DateAdapter())
+ .add(KotlinJsonAdapterFactory())
+ .build()
+
+ val manga: Manga = loadAsset("manga/header.json", Manga::class)
+
+ val mangaDetails: Manga = loadAsset("manga/full.json", Manga::class)
+
+ val tag = mangaDetails.tags.elementAt(2)
+
+ val chapter = checkNotNull(mangaDetails.chapters)[2]
+
+ val favouriteCategory: FavouriteCategory = loadAsset("categories/simple.json", FavouriteCategory::class)
+
+ fun loadAsset(name: String, cls: KClass): T {
+ val assets = InstrumentationRegistry.getInstrumentation().context.assets
+ return assets.open(name).use {
+ moshi.adapter(cls.java).fromJson(it.source().buffer())
+ } ?: throw RuntimeException("Cannot read asset from json \"$name\"")
+ }
+
+ private class DateAdapter : JsonAdapter() {
+
+ @FromJson
+ override fun fromJson(reader: JsonReader): Date? {
+ val ms = reader.nextLong()
+ return if (ms == 0L) {
+ null
+ } else {
+ Date(ms)
+ }
+ }
+
+ @ToJson
+ override fun toJson(writer: JsonWriter, value: Date?) {
+ writer.value(value?.time ?: 0L)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt
index f0f37c2a1..f072236ff 100644
--- a/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt
+++ b/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt
@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core.db
import androidx.room.testing.MigrationTestHelper
-import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
@@ -9,6 +8,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.koitharu.kotatsu.core.db.migrations.*
import java.io.IOException
+import kotlin.test.assertEquals
@RunWith(AndroidJUnit4::class)
class MangaDatabaseTest {
@@ -16,28 +16,24 @@ class MangaDatabaseTest {
@get:Rule
val helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
- MangaDatabase::class.java.canonicalName,
- FrameworkSQLiteOpenHelperFactory()
+ MangaDatabase::class.java,
)
@Test
@Throws(IOException::class)
fun migrateAll() {
- helper.createDatabase(TEST_DB, 1).apply {
- // TODO execSQL("")
- close()
- }
+ assertEquals(DATABASE_VERSION, migrations.last().endVersion)
+ helper.createDatabase(TEST_DB, 1).close()
for (migration in migrations) {
helper.runMigrationsAndValidate(
TEST_DB,
migration.endVersion,
true,
migration
- )
+ ).close()
}
}
-
private companion object {
const val TEST_DB = "test-db"
@@ -50,6 +46,12 @@ class MangaDatabaseTest {
Migration5To6(),
Migration6To7(),
Migration7To8(),
+ Migration8To9(),
+ Migration9To10(),
+ Migration10To11(),
+ Migration11To12(),
+ Migration12To13(),
+ Migration13To14(),
)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/core/os/ShortcutsUpdaterTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/core/os/ShortcutsUpdaterTest.kt
new file mode 100644
index 000000000..ec4c04edc
--- /dev/null
+++ b/app/src/androidTest/java/org/koitharu/kotatsu/core/os/ShortcutsUpdaterTest.kt
@@ -0,0 +1,65 @@
+package org.koitharu.kotatsu.core.os
+
+import android.content.pm.ShortcutInfo
+import android.content.pm.ShortcutManager
+import android.os.Build
+import androidx.core.content.getSystemService
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.koin.test.KoinTest
+import org.koin.test.inject
+import org.koitharu.kotatsu.SampleData
+import org.koitharu.kotatsu.awaitForIdle
+import org.koitharu.kotatsu.core.db.MangaDatabase
+import org.koitharu.kotatsu.history.domain.HistoryRepository
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@RunWith(AndroidJUnit4::class)
+class ShortcutsUpdaterTest : KoinTest {
+
+ private val historyRepository by inject()
+ private val shortcutsUpdater by inject()
+ private val database by inject()
+
+ @Before
+ fun setUp() {
+ database.clearAllTables()
+ }
+
+ @Test
+ fun testUpdateShortcuts() = runTest {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
+ return@runTest
+ }
+ awaitUpdate()
+ assertTrue(getShortcuts().isEmpty())
+ historyRepository.addOrUpdate(
+ manga = SampleData.manga,
+ chapterId = SampleData.chapter.id,
+ page = 4,
+ scroll = 2,
+ percent = 0.3f
+ )
+ awaitUpdate()
+
+ val shortcuts = getShortcuts()
+ assertEquals(1, shortcuts.size)
+ }
+
+ private fun getShortcuts(): List {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val manager = checkNotNull(context.getSystemService())
+ return manager.dynamicShortcuts.filterNot { it.id == "com.squareup.leakcanary.dynamic_shortcut" }
+ }
+
+ private suspend fun awaitUpdate() {
+ val instrumentation = InstrumentationRegistry.getInstrumentation()
+ instrumentation.awaitForIdle()
+ shortcutsUpdater.await()
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt
new file mode 100644
index 000000000..1d0ca5498
--- /dev/null
+++ b/app/src/androidTest/java/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt
@@ -0,0 +1,67 @@
+package org.koitharu.kotatsu.settings.backup
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.koin.test.KoinTest
+import org.koin.test.get
+import org.koin.test.inject
+import org.koitharu.kotatsu.SampleData
+import org.koitharu.kotatsu.core.backup.BackupRepository
+import org.koitharu.kotatsu.core.db.MangaDatabase
+import org.koitharu.kotatsu.core.db.entity.toMangaTags
+import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
+import org.koitharu.kotatsu.history.domain.HistoryRepository
+import kotlin.test.*
+
+@RunWith(AndroidJUnit4::class)
+class AppBackupAgentTest : KoinTest {
+
+ private val historyRepository by inject()
+ private val favouritesRepository by inject()
+ private val backupRepository by inject()
+ private val database by inject()
+
+ @Before
+ fun setUp() {
+ database.clearAllTables()
+ }
+
+ @Test
+ fun testBackupRestore() = runTest {
+ val category = favouritesRepository.createCategory(
+ title = SampleData.favouriteCategory.title,
+ sortOrder = SampleData.favouriteCategory.order,
+ isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled,
+ )
+ favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga))
+ historyRepository.addOrUpdate(
+ manga = SampleData.mangaDetails,
+ chapterId = SampleData.mangaDetails.chapters!![2].id,
+ page = 3,
+ scroll = 40,
+ percent = 0.2f,
+ )
+ val history = checkNotNull(historyRepository.getOne(SampleData.manga))
+
+ val agent = AppBackupAgent()
+ val backup = agent.createBackupFile(get(), backupRepository)
+
+ database.clearAllTables()
+ assertTrue(favouritesRepository.getAllManga().isEmpty())
+ assertNull(historyRepository.getLastOrNull())
+
+ backup.inputStream().use {
+ agent.restoreBackupFile(it.fd, backup.length(), backupRepository)
+ }
+
+ assertEquals(category, favouritesRepository.getCategory(category.id))
+ assertEquals(history, historyRepository.getOne(SampleData.manga))
+ assertContentEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id))
+
+ val allTags = database.tagsDao.findTags(SampleData.tag.source.name).toMangaTags()
+ assertContains(allTags, SampleData.tag)
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt
new file mode 100644
index 000000000..a1ea460f6
--- /dev/null
+++ b/app/src/androidTest/java/org/koitharu/kotatsu/tracker/domain/TrackerTest.kt
@@ -0,0 +1,183 @@
+package org.koitharu.kotatsu.tracker.domain
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.koin.test.KoinTest
+import org.koin.test.inject
+import org.koitharu.kotatsu.SampleData
+import org.koitharu.kotatsu.base.domain.MangaDataRepository
+import org.koitharu.kotatsu.parsers.model.Manga
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+@RunWith(AndroidJUnit4::class)
+class TrackerTest : KoinTest {
+
+ private val repository by inject()
+ private val dataRepository by inject()
+ private val tracker by inject()
+
+ @Test
+ fun noUpdates() = runTest {
+ val manga = loadManga("full.json")
+ tracker.deleteTrack(manga.id)
+
+ tracker.checkUpdates(manga, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(manga.id))
+ tracker.checkUpdates(manga, commit = true).apply {
+ assertTrue(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(manga.id))
+ }
+
+ @Test
+ fun hasUpdates() = runTest {
+ val mangaFirst = loadManga("first_chapters.json")
+ val mangaFull = loadManga("full.json")
+ tracker.deleteTrack(mangaFirst.id)
+
+ tracker.checkUpdates(mangaFirst, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
+ tracker.checkUpdates(mangaFull, commit = true).apply {
+ assertTrue(isValid)
+ assertEquals(3, newChapters.size)
+ }
+ assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
+ tracker.checkUpdates(mangaFull, commit = true).apply {
+ assertTrue(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
+ }
+
+ @Test
+ fun badIds() = runTest {
+ val mangaFirst = loadManga("first_chapters.json")
+ val mangaBad = loadManga("bad_ids.json")
+ tracker.deleteTrack(mangaFirst.id)
+
+ tracker.checkUpdates(mangaFirst, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
+ tracker.checkUpdates(mangaBad, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
+ tracker.checkUpdates(mangaFirst, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
+ }
+
+ @Test
+ fun badIds2() = runTest {
+ val mangaFirst = loadManga("first_chapters.json")
+ val mangaBad = loadManga("bad_ids.json")
+ val mangaFull = loadManga("full.json")
+ tracker.deleteTrack(mangaFirst.id)
+
+ tracker.checkUpdates(mangaFirst, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
+ tracker.checkUpdates(mangaFull, commit = true).apply {
+ assertTrue(isValid)
+ assertEquals(3, newChapters.size)
+ }
+ assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
+ tracker.checkUpdates(mangaBad, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
+ }
+
+ @Test
+ fun fullReset() = runTest {
+ val mangaFull = loadManga("full.json")
+ val mangaFirst = loadManga("first_chapters.json")
+ val mangaEmpty = loadManga("empty.json")
+ tracker.deleteTrack(mangaFull.id)
+
+ assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
+ tracker.checkUpdates(mangaFull, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
+ tracker.checkUpdates(mangaEmpty, commit = true).apply {
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
+ tracker.checkUpdates(mangaFirst, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
+ tracker.checkUpdates(mangaFull, commit = true).apply {
+ assertTrue(isValid)
+ assertEquals(3, newChapters.size)
+ }
+ assertEquals(3, repository.getNewChaptersCount(mangaFull.id))
+ tracker.checkUpdates(mangaEmpty, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFull.id))
+ }
+
+ @Test
+ fun syncWithHistory() = runTest {
+ val mangaFull = loadManga("full.json")
+ val mangaFirst = loadManga("first_chapters.json")
+ tracker.deleteTrack(mangaFull.id)
+
+ tracker.checkUpdates(mangaFirst, commit = true).apply {
+ assertFalse(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
+ tracker.checkUpdates(mangaFull, commit = true).apply {
+ assertTrue(isValid)
+ assertEquals(3, newChapters.size)
+ }
+ assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
+
+ var chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
+ repository.syncWithHistory(mangaFull, chapter.id)
+
+ assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
+
+ chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex) }
+ repository.syncWithHistory(mangaFull, chapter.id)
+
+ assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
+
+ tracker.checkUpdates(mangaFull, commit = true).apply {
+ assertTrue(isValid)
+ assert(newChapters.isEmpty())
+ }
+ assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
+ }
+
+ private suspend fun loadManga(name: String): Manga {
+ val manga = SampleData.loadAsset("manga/$name", Manga::class)
+ dataRepository.storeManga(manga)
+ return manga
+ }
+}
\ No newline at end of file
diff --git a/app/src/debug/java/org/koitharu/kotatsu/core/parser/DummyParser.kt b/app/src/debug/java/org/koitharu/kotatsu/core/parser/DummyParser.kt
index 4323e3a5f..9ebcba9f4 100644
--- a/app/src/debug/java/org/koitharu/kotatsu/core/parser/DummyParser.kt
+++ b/app/src/debug/java/org/koitharu/kotatsu/core/parser/DummyParser.kt
@@ -25,7 +25,7 @@ class DummyParser(override val context: MangaLoaderContext) : MangaParser(MangaS
offset: Int,
query: String?,
tags: Set?,
- sortOrder: SortOrder?
+ sortOrder: SortOrder,
): List {
TODO("Not yet implemented")
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index bf3dfbecf..880e595c3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,6 +9,7 @@
+
@@ -63,9 +64,31 @@
+
+
+
+
+ android:exported="true"
+ android:label="@string/settings">
+
+
+
+
+
+
+
+
+
-
-
+
-
\ No newline at end of file
+
diff --git a/app/src/main/java/com/google/android/material/appbar/KotatsuAppBarLayout.kt b/app/src/main/java/com/google/android/material/appbar/KotatsuAppBarLayout.kt
new file mode 100644
index 000000000..cb2e1968b
--- /dev/null
+++ b/app/src/main/java/com/google/android/material/appbar/KotatsuAppBarLayout.kt
@@ -0,0 +1,151 @@
+package com.google.android.material.appbar
+
+import android.animation.AnimatorSet
+import android.animation.ValueAnimator
+import android.annotation.SuppressLint
+import android.content.Context
+import android.util.AttributeSet
+import android.widget.TextView
+import androidx.annotation.FloatRange
+import com.google.android.material.animation.AnimationUtils
+import com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener
+import com.google.android.material.shape.MaterialShapeDrawable
+import org.koitharu.kotatsu.R
+import com.google.android.material.R as materialR
+
+/**
+ * [AppBarLayout] with our own lift state handler and custom title alpha.
+ *
+ * Inside this package to access some package-private methods.
+ */
+class KotatsuAppBarLayout @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null
+) : AppBarLayout(context, attrs) {
+
+ private var lifted = true
+
+ private val toolbar by lazy { findViewById(R.id.toolbar) }
+
+ @FloatRange(from = 0.0, to = 1.0)
+ var titleTextAlpha = 1F
+ set(value) {
+ field = value
+ titleTextView?.alpha = field
+ }
+
+ private var titleTextView: TextView? = null
+ set(value) {
+ field = value
+ field?.alpha = titleTextAlpha
+ }
+
+ private var animatorSet: AnimatorSet? = null
+
+ private var statusBarForegroundAnimator: ValueAnimator? = null
+ private val offsetListener = OnOffsetChangedListener { appBarLayout, verticalOffset ->
+ // Show status bar foreground when offset
+ val foreground = (appBarLayout?.statusBarForeground as? MaterialShapeDrawable) ?: return@OnOffsetChangedListener
+ val start = foreground.alpha
+ val end = if (verticalOffset != 0) 255 else 0
+
+ statusBarForegroundAnimator?.cancel()
+ if (animatorSet?.isRunning == true) {
+ foreground.alpha = end
+ return@OnOffsetChangedListener
+ }
+ if (start != end) {
+ statusBarForegroundAnimator = ValueAnimator.ofInt(start, end).apply {
+ duration = resources.getInteger(materialR.integer.app_bar_elevation_anim_duration).toLong()
+ interpolator = AnimationUtils.LINEAR_INTERPOLATOR
+ addUpdateListener {
+ foreground.alpha = it.animatedValue as Int
+ }
+ start()
+ }
+ }
+ }
+
+ var isTransparentWhenNotLifted = false
+ set(value) {
+ if (field != value) {
+ field = value
+ updateStates()
+ }
+ }
+
+ override fun isLiftOnScroll(): Boolean = false
+
+ override fun isLifted(): Boolean = lifted
+
+ override fun setLifted(lifted: Boolean): Boolean {
+ return if (this.lifted != lifted) {
+ this.lifted = lifted
+ updateStates()
+ true
+ } else {
+ false
+ }
+ }
+
+ override fun setLiftedState(lifted: Boolean, force: Boolean): Boolean = false
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ addOnOffsetChangedListener(offsetListener)
+ toolbar.background.alpha = 0 // Use app bar background
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+ removeOnOffsetChangedListener(offsetListener)
+ }
+
+ @SuppressLint("Recycle")
+ private fun updateStates() {
+ val animators = mutableListOf()
+
+ val fromElevation = elevation
+ val toElevation = if (lifted) {
+ resources.getDimension(materialR.dimen.design_appbar_elevation)
+ } else {
+ 0F
+ }
+ if (fromElevation != toElevation) {
+ ValueAnimator.ofFloat(fromElevation, toElevation).apply {
+ addUpdateListener {
+ elevation = it.animatedValue as Float
+ (statusBarForeground as? MaterialShapeDrawable)?.elevation = it.animatedValue as Float
+ }
+ animators.add(this)
+ }
+ }
+
+ val transparent = if (lifted) false else isTransparentWhenNotLifted
+ val fromAlpha = (background as? MaterialShapeDrawable)?.alpha ?: background.alpha
+ val toAlpha = if (transparent) 0 else 255
+ if (fromAlpha != toAlpha) {
+ ValueAnimator.ofInt(fromAlpha, toAlpha).apply {
+ addUpdateListener {
+ val value = it.animatedValue as Int
+ background.alpha = value
+ }
+ animators.add(this)
+ }
+ }
+
+ if (animators.isNotEmpty()) {
+ animatorSet?.cancel()
+ animatorSet = AnimatorSet().apply {
+ duration = resources.getInteger(materialR.integer.app_bar_elevation_anim_duration).toLong()
+ interpolator = AnimationUtils.LINEAR_INTERPOLATOR
+ playTogether(*animators.toTypedArray())
+ start()
+ }
+ }
+ }
+
+ init {
+ statusBarForeground = MaterialShapeDrawable.createWithElevationOverlay(context)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt
index 86057387d..957dc47ea 100644
--- a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt
@@ -1,30 +1,40 @@
package org.koitharu.kotatsu
import android.app.Application
+import android.content.Context
import android.os.StrictMode
import androidx.appcompat.app.AppCompatDelegate
import androidx.fragment.app.strictmode.FragmentStrictMode
+import androidx.room.InvalidationTracker
+import org.acra.ReportField
+import org.acra.config.dialog
+import org.acra.config.mailSender
+import org.acra.data.StringFormat
+import org.acra.ktx.initAcra
import org.koin.android.ext.android.get
+import org.koin.android.ext.android.getKoin
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
import org.koitharu.kotatsu.bookmarks.bookmarksModule
+import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.databaseModule
import org.koitharu.kotatsu.core.github.githubModule
import org.koitharu.kotatsu.core.network.networkModule
import org.koitharu.kotatsu.core.prefs.AppSettings
-import org.koitharu.kotatsu.core.ui.AppCrashHandler
import org.koitharu.kotatsu.core.ui.uiModule
import org.koitharu.kotatsu.details.detailsModule
+import org.koitharu.kotatsu.explore.exploreModule
import org.koitharu.kotatsu.favourites.favouritesModule
import org.koitharu.kotatsu.history.historyModule
+import org.koitharu.kotatsu.library.libraryModule
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.local.localModule
import org.koitharu.kotatsu.main.mainModule
-import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.readerModule
import org.koitharu.kotatsu.remotelist.remoteListModule
+import org.koitharu.kotatsu.scrobbling.shikimori.shikimoriModule
import org.koitharu.kotatsu.search.searchModule
import org.koitharu.kotatsu.settings.settingsModule
import org.koitharu.kotatsu.suggestions.suggestionsModule
@@ -40,9 +50,9 @@ class KotatsuApp : Application() {
enableStrictMode()
}
initKoin()
- Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext))
AppCompatDelegate.setDefaultNightMode(get().theme)
- registerActivityLifecycleCallbacks(get())
+ setupActivityLifecycleCallbacks()
+ setupDatabaseObservers()
}
private fun initKoin() {
@@ -66,11 +76,61 @@ class KotatsuApp : Application() {
appWidgetModule,
suggestionsModule,
syncModule,
+ shikimoriModule,
bookmarksModule,
+ libraryModule,
+ exploreModule,
)
}
}
+ override fun attachBaseContext(base: Context?) {
+ super.attachBaseContext(base)
+ initAcra {
+ buildConfigClass = BuildConfig::class.java
+ reportFormat = StringFormat.KEY_VALUE_LIST
+ reportContent = listOf(
+ ReportField.PACKAGE_NAME,
+ ReportField.APP_VERSION_CODE,
+ ReportField.APP_VERSION_NAME,
+ ReportField.ANDROID_VERSION,
+ ReportField.PHONE_MODEL,
+ ReportField.CRASH_CONFIGURATION,
+ ReportField.STACK_TRACE,
+ ReportField.CUSTOM_DATA,
+ ReportField.SHARED_PREFERENCES,
+ )
+ dialog {
+ text = getString(R.string.crash_text)
+ title = getString(R.string.error_occurred)
+ positiveButtonText = getString(R.string.send)
+ resIcon = R.drawable.ic_alert_outline
+ resTheme = android.R.style.Theme_Material_Light_Dialog_Alert
+ }
+ mailSender {
+ mailTo = getString(R.string.email_error_report)
+ reportAsFile = true
+ reportFileName = "stacktrace.txt"
+ }
+ }
+ }
+
+ private fun setupDatabaseObservers() {
+ val observers = getKoin().getAll()
+ val database = get()
+ val tracker = database.invalidationTracker
+ observers.forEach {
+ tracker.addObserver(it)
+ }
+ }
+
+ private fun setupActivityLifecycleCallbacks() {
+ val callbacks = getKoin().getAll()
+ callbacks.forEach {
+ registerActivityLifecycleCallbacks(it)
+ }
+ }
+
private fun enableStrictMode() {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
@@ -96,4 +156,4 @@ class KotatsuApp : Application() {
.detectFragmentTagUsage()
.build()
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/ReversibleHandle.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/ReversibleHandle.kt
index 43c9bf7e4..d416216f4 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/domain/ReversibleHandle.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/ReversibleHandle.kt
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.base.domain
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
fun interface ReversibleHandle {
@@ -10,7 +11,11 @@ fun interface ReversibleHandle {
}
fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default) {
- reverse()
+ runCatching {
+ reverse()
+ }.onFailure {
+ it.printStackTraceDebug()
+ }
}
operator fun ReversibleHandle.plus(other: ReversibleHandle) = ReversibleHandle {
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt
index 2fcfeac76..f97708e00 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt
@@ -25,6 +25,7 @@ import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.settings.SettingsActivity
abstract class BaseActivity :
AppCompatActivity(),
@@ -43,9 +44,13 @@ abstract class BaseActivity :
override fun onCreate(savedInstanceState: Bundle?) {
val settings = get()
+ val isAmoled = settings.isAmoledTheme
+ val isDynamic = settings.isDynamicTheme
+ // TODO support DialogWhenLarge theme
when {
- settings.isAmoledTheme -> setTheme(R.style.ThemeOverlay_Kotatsu_AMOLED)
- settings.isDynamicTheme -> setTheme(R.style.Theme_Kotatsu_Monet)
+ isAmoled && isDynamic -> setTheme(R.style.Theme_Kotatsu_Monet_Amoled)
+ isAmoled -> setTheme(R.style.Theme_Kotatsu_Amoled)
+ isDynamic -> setTheme(R.style.Theme_Kotatsu_Monet)
}
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt
index 75503afc5..c8c4051b3 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt
@@ -2,11 +2,11 @@ package org.koitharu.kotatsu.base.ui
import android.app.Dialog
import android.os.Bundle
+import android.util.DisplayMetrics
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
-import androidx.appcompat.app.AppCompatDialog
import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding
import com.google.android.material.bottomsheet.BottomSheetBehavior
@@ -14,6 +14,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog
+import org.koitharu.kotatsu.utils.ext.displayCompat
import com.google.android.material.R as materialR
abstract class BaseBottomSheet : BottomSheetDialogFragment() {
@@ -33,6 +34,20 @@ abstract class BaseBottomSheet : BottomSheetDialogFragment() {
): View {
val binding = onInflateView(inflater, container)
viewBinding = binding
+
+ // Enforce max width for tablets
+ val width = resources.getDimensionPixelSize(R.dimen.bottom_sheet_width)
+ if (width > 0) {
+ behavior?.maxWidth = width
+ }
+
+ // Set peek height to 50% display height
+ requireContext().displayCompat?.let {
+ val metrics = DisplayMetrics()
+ it.getRealMetrics(metrics)
+ behavior?.peekHeight = (metrics.heightPixels * 0.4).toInt()
+ }
+
return binding.root
}
@@ -42,10 +57,15 @@ abstract class BaseBottomSheet : BottomSheetDialogFragment() {
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- return if (resources.getBoolean(R.bool.is_tablet)) {
- AppCompatDialog(context, R.style.Theme_Kotatsu_Dialog)
- } else {
- AppBottomSheetDialog(requireContext(), theme)
+ return AppBottomSheetDialog(requireContext(), theme)
+ }
+
+ fun addBottomSheetCallback(callback: BottomSheetBehavior.BottomSheetCallback) {
+ val b = behavior ?: return
+ b.addBottomSheetCallback(callback)
+ val rootView = dialog?.findViewById(materialR.id.design_bottom_sheet)
+ if (rootView != null) {
+ callback.onStateChanged(rootView, b.state)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BasePreferenceFragment.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BasePreferenceFragment.kt
index 2125d044c..7db0f6e22 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BasePreferenceFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BasePreferenceFragment.kt
@@ -6,14 +6,12 @@ import androidx.annotation.CallSuper
import androidx.annotation.StringRes
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
-import androidx.fragment.app.Fragment
import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.prefs.AppSettings
-import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.SettingsHeadersFragment
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/AppBottomSheetDialog.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/AppBottomSheetDialog.kt
index d3b911ace..8b6da8d3d 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/AppBottomSheetDialog.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/AppBottomSheetDialog.kt
@@ -21,7 +21,7 @@ class AppBottomSheetDialog(context: Context, theme: Int) : BottomSheetDialog(con
if (drawEdgeToEdge) {
// Copied from super.onAttachedToWindow:
val edgeToEdgeFlags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
- // Fix super-class's window flag bug by respecting the intial system UI visibility:
+ // Fix super-class's window flag bug by respecting the initial system UI visibility:
window.decorView.systemUiVisibility = edgeToEdgeFlags or initialSystemUiVisibility
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/RememberSelectionDialogListener.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/RememberSelectionDialogListener.kt
new file mode 100644
index 000000000..7783a564b
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/RememberSelectionDialogListener.kt
@@ -0,0 +1,13 @@
+package org.koitharu.kotatsu.base.ui.dialog
+
+import android.content.DialogInterface
+
+class RememberSelectionDialogListener(initialValue: Int) : DialogInterface.OnClickListener {
+
+ var selection: Int = initialValue
+ private set
+
+ override fun onClick(dialog: DialogInterface?, which: Int) {
+ selection = which
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/TextInputDialog.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/TextInputDialog.kt
deleted file mode 100644
index 4b5c02ca6..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/TextInputDialog.kt
+++ /dev/null
@@ -1,89 +0,0 @@
-package org.koitharu.kotatsu.base.ui.dialog
-
-import android.content.Context
-import android.content.DialogInterface
-import android.text.InputFilter
-import android.view.LayoutInflater
-import androidx.annotation.StringRes
-import androidx.appcompat.app.AlertDialog
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import org.koitharu.kotatsu.databinding.DialogInputBinding
-
-class TextInputDialog private constructor(
- private val delegate: AlertDialog,
-) : DialogInterface by delegate {
-
- fun show() = delegate.show()
-
- class Builder(context: Context) {
-
- private val binding = DialogInputBinding.inflate(LayoutInflater.from(context))
-
- private val delegate = MaterialAlertDialogBuilder(context)
- .setView(binding.root)
-
- fun setTitle(@StringRes titleResId: Int): Builder {
- delegate.setTitle(titleResId)
- return this
- }
-
- fun setTitle(title: CharSequence): Builder {
- delegate.setTitle(title)
- return this
- }
-
- fun setHint(@StringRes hintResId: Int): Builder {
- binding.inputEdit.hint = binding.root.context.getString(hintResId)
- return this
- }
-
- fun setMaxLength(maxLength: Int, strict: Boolean): Builder {
- with(binding.inputLayout) {
- counterMaxLength = maxLength
- isCounterEnabled = maxLength > 0
- }
- if (strict && maxLength > 0) {
- binding.inputEdit.filters += InputFilter.LengthFilter(maxLength)
- }
- return this
- }
-
- fun setInputType(inputType: Int): Builder {
- binding.inputEdit.inputType = inputType
- return this
- }
-
- fun setText(text: String): Builder {
- binding.inputEdit.setText(text)
- binding.inputEdit.setSelection(text.length)
- return this
- }
-
- fun setPositiveButton(
- @StringRes textId: Int,
- listener: (DialogInterface, String) -> Unit
- ): Builder {
- delegate.setPositiveButton(textId) { dialog, _ ->
- listener(dialog, binding.inputEdit.text?.toString().orEmpty())
- }
- return this
- }
-
- fun setNegativeButton(
- @StringRes textId: Int,
- listener: DialogInterface.OnClickListener? = null
- ): Builder {
- delegate.setNegativeButton(textId, listener)
- return this
- }
-
- fun setOnCancelListener(listener: DialogInterface.OnCancelListener): Builder {
- delegate.setOnCancelListener(listener)
- return this
- }
-
- fun create() =
- TextInputDialog(delegate.create())
-
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/ListSelectionController.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/ListSelectionController.kt
new file mode 100644
index 000000000..b46da465f
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/ListSelectionController.kt
@@ -0,0 +1,200 @@
+package org.koitharu.kotatsu.base.ui.list
+
+import android.app.Activity
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.view.ActionMode
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.recyclerview.widget.RecyclerView
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryOwner
+import kotlinx.coroutines.Dispatchers
+import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
+import kotlin.coroutines.EmptyCoroutineContext
+
+private const val KEY_SELECTION = "selection"
+private const val PROVIDER_NAME = "selection_decoration"
+
+class ListSelectionController(
+ private val activity: Activity,
+ private val decoration: AbstractSelectionItemDecoration,
+ private val registryOwner: SavedStateRegistryOwner,
+ private val callback: Callback2,
+) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
+
+ private var actionMode: ActionMode? = null
+
+ val count: Int
+ get() = decoration.checkedItemsCount
+
+ init {
+ registryOwner.lifecycle.addObserver(StateEventObserver())
+ }
+
+ fun snapshot(): Set {
+ return peekCheckedIds().toSet()
+ }
+
+ fun peekCheckedIds(): Set {
+ return decoration.checkedItemsIds
+ }
+
+ fun clear() {
+ decoration.clearSelection()
+ notifySelectionChanged()
+ }
+
+ fun addAll(ids: Collection) {
+ if (ids.isEmpty()) {
+ return
+ }
+ decoration.checkAll(ids)
+ notifySelectionChanged()
+ }
+
+ fun attachToRecyclerView(recyclerView: RecyclerView) {
+ recyclerView.addItemDecoration(decoration)
+ }
+
+ override fun saveState(): Bundle {
+ val bundle = Bundle(1)
+ bundle.putLongArray(KEY_SELECTION, peekCheckedIds().toLongArray())
+ return bundle
+ }
+
+ fun onItemClick(id: Long): Boolean {
+ if (decoration.checkedItemsCount != 0) {
+ decoration.toggleItemChecked(id)
+ if (decoration.checkedItemsCount == 0) {
+ actionMode?.finish()
+ } else {
+ actionMode?.invalidate()
+ }
+ notifySelectionChanged()
+ return true
+ }
+ return false
+ }
+
+ fun onItemLongClick(id: Long): Boolean {
+ startActionMode()
+ return actionMode?.also {
+ decoration.setItemIsChecked(id, true)
+ notifySelectionChanged()
+ } != null
+ }
+
+ override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
+ return callback.onCreateActionMode(this, mode, menu)
+ }
+
+ override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
+ return callback.onPrepareActionMode(this, mode, menu)
+ }
+
+ override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
+ return callback.onActionItemClicked(this, mode, item)
+ }
+
+ override fun onDestroyActionMode(mode: ActionMode) {
+ callback.onDestroyActionMode(this, mode)
+ clear()
+ actionMode = null
+ }
+
+ private fun startActionMode() {
+ if (actionMode == null) {
+ actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
+ }
+ }
+
+ private fun notifySelectionChanged() {
+ val count = decoration.checkedItemsCount
+ callback.onSelectionChanged(this, count)
+ if (count == 0) {
+ actionMode?.finish()
+ } else {
+ actionMode?.invalidate()
+ }
+ }
+
+ private fun restoreState(ids: Collection) {
+ if (ids.isEmpty() || decoration.checkedItemsCount != 0) {
+ return
+ }
+ decoration.checkAll(ids)
+ startActionMode()
+ notifySelectionChanged()
+ }
+
+ @Deprecated("")
+ interface Callback : Callback2 {
+
+ fun onSelectionChanged(count: Int)
+
+ fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean
+
+ fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean
+
+ fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean
+
+ fun onDestroyActionMode(mode: ActionMode) = Unit
+
+ override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
+ onSelectionChanged(count)
+ }
+
+ override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
+ return onCreateActionMode(mode, menu)
+ }
+
+ override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
+ return onPrepareActionMode(mode, menu)
+ }
+
+ override fun onActionItemClicked(
+ controller: ListSelectionController,
+ mode: ActionMode,
+ item: MenuItem
+ ): Boolean = onActionItemClicked(mode, item)
+
+ override fun onDestroyActionMode(controller: ListSelectionController, mode: ActionMode) {
+ onDestroyActionMode(mode)
+ }
+ }
+
+ interface Callback2 {
+
+ fun onSelectionChanged(controller: ListSelectionController, count: Int)
+
+ fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean
+
+ fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean
+
+ fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean
+
+ fun onDestroyActionMode(controller: ListSelectionController, mode: ActionMode) = Unit
+ }
+
+ private inner class StateEventObserver : LifecycleEventObserver {
+
+ override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
+ if (event == Lifecycle.Event.ON_CREATE) {
+ val registry = registryOwner.savedStateRegistry
+ registry.registerSavedStateProvider(PROVIDER_NAME, this@ListSelectionController)
+ val state = registry.consumeRestoredStateForKey(PROVIDER_NAME)
+ if (state != null) {
+ Dispatchers.Main.dispatch(EmptyCoroutineContext) { // == Handler.post
+ if (source.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
+ restoreState(state.getLongArray(KEY_SELECTION)?.toList().orEmpty())
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/SectionedSelectionController.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/SectionedSelectionController.kt
new file mode 100644
index 000000000..d514bd4fd
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/SectionedSelectionController.kt
@@ -0,0 +1,204 @@
+package org.koitharu.kotatsu.base.ui.list
+
+import android.app.Activity
+import android.os.Bundle
+import android.util.ArrayMap
+import android.view.Menu
+import android.view.MenuItem
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.view.ActionMode
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.recyclerview.widget.RecyclerView
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryOwner
+import kotlinx.coroutines.Dispatchers
+import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
+import kotlin.coroutines.EmptyCoroutineContext
+
+private const val PROVIDER_NAME = "selection_decoration_sectioned"
+
+class SectionedSelectionController(
+ private val activity: Activity,
+ private val registryOwner: SavedStateRegistryOwner,
+ private val callback: Callback,
+) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
+
+ private var actionMode: ActionMode? = null
+
+ private var pendingData: MutableMap>? = null
+ private val decorations = ArrayMap()
+
+ val count: Int
+ get() = decorations.values.sumOf { it.checkedItemsCount }
+
+ init {
+ registryOwner.lifecycle.addObserver(StateEventObserver())
+ }
+
+ fun snapshot(): Map> {
+ return decorations.mapValues { it.value.checkedItemsIds.toSet() }
+ }
+
+ fun peekCheckedIds(): Map> {
+ return decorations.mapValues { it.value.checkedItemsIds }
+ }
+
+ fun clear() {
+ decorations.values.forEach {
+ it.clearSelection()
+ }
+ notifySelectionChanged()
+ }
+
+ fun attachToRecyclerView(section: T, recyclerView: RecyclerView) {
+ val decoration = getDecoration(section)
+ val pendingIds = pendingData?.remove(section.toString())
+ if (!pendingIds.isNullOrEmpty()) {
+ decoration.checkAll(pendingIds)
+ startActionMode()
+ notifySelectionChanged()
+ }
+ recyclerView.addItemDecoration(decoration)
+ if (pendingData?.isEmpty() == true) {
+ pendingData = null
+ }
+ }
+
+ override fun saveState(): Bundle {
+ val bundle = Bundle(decorations.size)
+ for ((k, v) in decorations) {
+ bundle.putLongArray(k.toString(), v.checkedItemsIds.toLongArray())
+ }
+ return bundle
+ }
+
+ fun onItemClick(section: T, id: Long): Boolean {
+ val decoration = getDecoration(section)
+ if (isInSelectionMode()) {
+ decoration.toggleItemChecked(id)
+ if (isInSelectionMode()) {
+ actionMode?.invalidate()
+ } else {
+ actionMode?.finish()
+ }
+ notifySelectionChanged()
+ return true
+ }
+ return false
+ }
+
+ fun onItemLongClick(section: T, id: Long): Boolean {
+ val decoration = getDecoration(section)
+ startActionMode()
+ return actionMode?.also {
+ decoration.setItemIsChecked(id, true)
+ notifySelectionChanged()
+ } != null
+ }
+
+ fun getSectionCount(section: T): Int {
+ return decorations[section]?.checkedItemsCount ?: 0
+ }
+
+ fun addToSelection(section: T, ids: Collection): Boolean {
+ val decoration = getDecoration(section)
+ startActionMode()
+ return actionMode?.also {
+ decoration.checkAll(ids)
+ notifySelectionChanged()
+ } != null
+ }
+
+ fun clearSelection(section: T) {
+ decorations[section]?.clearSelection() ?: return
+ notifySelectionChanged()
+ }
+
+ override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
+ return callback.onCreateActionMode(mode, menu)
+ }
+
+ override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
+ return callback.onPrepareActionMode(mode, menu)
+ }
+
+ override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
+ return callback.onActionItemClicked(mode, item)
+ }
+
+ override fun onDestroyActionMode(mode: ActionMode) {
+ callback.onDestroyActionMode(mode)
+ clear()
+ actionMode = null
+ }
+
+ private fun startActionMode() {
+ if (actionMode == null) {
+ actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
+ }
+ }
+
+ private fun isInSelectionMode(): Boolean {
+ return decorations.values.any { x -> x.checkedItemsCount > 0 }
+ }
+
+ private fun notifySelectionChanged() {
+ val count = this.count
+ callback.onSelectionChanged(count)
+ if (count == 0) {
+ actionMode?.finish()
+ } else {
+ actionMode?.invalidate()
+ }
+ }
+
+ private fun restoreState(ids: MutableMap>) {
+ if (ids.isEmpty() || isInSelectionMode()) {
+ return
+ }
+ for ((k, v) in decorations) {
+ val items = ids.remove(k.toString())
+ if (!items.isNullOrEmpty()) {
+ v.checkAll(items)
+ }
+ }
+ pendingData = ids
+ if (isInSelectionMode()) {
+ startActionMode()
+ notifySelectionChanged()
+ }
+ }
+
+ private fun getDecoration(section: T): AbstractSelectionItemDecoration {
+ return decorations.getOrPut(section) {
+ callback.onCreateItemDecoration(section)
+ }
+ }
+
+ interface Callback : ListSelectionController.Callback {
+
+ fun onCreateItemDecoration(section: T): AbstractSelectionItemDecoration
+ }
+
+ private inner class StateEventObserver : LifecycleEventObserver {
+
+ override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
+ if (event == Lifecycle.Event.ON_CREATE) {
+ val registry = registryOwner.savedStateRegistry
+ registry.registerSavedStateProvider(PROVIDER_NAME, this@SectionedSelectionController)
+ val state = registry.consumeRestoredStateForKey(PROVIDER_NAME)
+ if (state != null) {
+ Dispatchers.Main.dispatch(EmptyCoroutineContext) { // == Handler.post
+ if (source.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
+ restoreState(
+ state.keySet().associateWithTo(HashMap()) { state.getLongArray(it)?.toList().orEmpty() }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt
index ac624d3c6..1974f6a5d 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractSelectionItemDecoration.kt
@@ -12,7 +12,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
private val bounds = Rect()
private val boundsF = RectF()
- private val selection = HashSet()
+ protected val selection = HashSet()
protected var hasBackground: Boolean = true
protected var hasForeground: Boolean = false
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/BubbleAnimator.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/BubbleAnimator.kt
new file mode 100644
index 000000000..591fd6b99
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/BubbleAnimator.kt
@@ -0,0 +1,82 @@
+package org.koitharu.kotatsu.base.ui.list.fastscroll
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.view.View
+import android.view.ViewAnimationUtils
+import android.view.animation.AccelerateInterpolator
+import android.view.animation.DecelerateInterpolator
+import androidx.core.view.isInvisible
+import androidx.core.view.isVisible
+import org.koitharu.kotatsu.utils.ext.animatorDurationScale
+import org.koitharu.kotatsu.utils.ext.measureWidth
+import kotlin.math.hypot
+
+class BubbleAnimator(
+ private val bubble: View,
+) {
+
+ private val animationDuration = (bubble.resources.getInteger(android.R.integer.config_shortAnimTime) *
+ bubble.context.animatorDurationScale).toLong()
+ private var animator: Animator? = null
+ private var isHiding = false
+
+ fun show() {
+ if (bubble.isVisible && !isHiding) {
+ return
+ }
+ isHiding = false
+ animator?.cancel()
+ animator = ViewAnimationUtils.createCircularReveal(
+ bubble,
+ bubble.measureWidth(),
+ bubble.measuredHeight,
+ 0f,
+ hypot(bubble.width.toDouble(), bubble.height.toDouble()).toFloat(),
+ ).apply {
+ bubble.isVisible = true
+ duration = animationDuration
+ interpolator = DecelerateInterpolator()
+ start()
+ }
+ }
+
+ fun hide() {
+ if (!bubble.isVisible || isHiding) {
+ return
+ }
+ animator?.cancel()
+ isHiding = true
+ animator = ViewAnimationUtils.createCircularReveal(
+ bubble,
+ bubble.width,
+ bubble.height,
+ hypot(bubble.width.toDouble(), bubble.height.toDouble()).toFloat(),
+ 0f,
+ ).apply {
+ duration = animationDuration
+ interpolator = AccelerateInterpolator()
+ addListener(HideListener())
+ start()
+ }
+ }
+
+ private inner class HideListener : AnimatorListenerAdapter() {
+
+ private var isCancelled = false
+
+ override fun onAnimationCancel(animation: Animator?) {
+ super.onAnimationCancel(animation)
+ isCancelled = true
+ }
+
+ override fun onAnimationEnd(animation: Animator?) {
+ super.onAnimationEnd(animation)
+ if (!isCancelled && animation === this@BubbleAnimator.animator) {
+ bubble.isInvisible = true
+ isHiding = false
+ this@BubbleAnimator.animator = null
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/FastScrollRecyclerView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/FastScrollRecyclerView.kt
new file mode 100644
index 000000000..5a7c1274e
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/FastScrollRecyclerView.kt
@@ -0,0 +1,45 @@
+package org.koitharu.kotatsu.base.ui.list.fastscroll
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.ViewGroup
+import androidx.annotation.AttrRes
+import androidx.recyclerview.widget.RecyclerView
+import org.koitharu.kotatsu.R
+
+class FastScrollRecyclerView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ @AttrRes defStyleAttr: Int = androidx.recyclerview.R.attr.recyclerViewStyle,
+) : RecyclerView(context, attrs, defStyleAttr) {
+
+ val fastScroller = FastScroller(context, attrs)
+
+ init {
+ fastScroller.id = R.id.fast_scroller
+ fastScroller.layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ }
+
+ override fun setAdapter(adapter: Adapter<*>?) {
+ super.setAdapter(adapter)
+ fastScroller.setSectionIndexer(adapter as? FastScroller.SectionIndexer)
+ }
+
+ override fun setVisibility(visibility: Int) {
+ super.setVisibility(visibility)
+ fastScroller.visibility = visibility
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ fastScroller.attachRecyclerView(this)
+ }
+
+ override fun onDetachedFromWindow() {
+ fastScroller.detachRecyclerView()
+ super.onDetachedFromWindow()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/FastScroller.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/FastScroller.kt
new file mode 100644
index 000000000..23c79d7a2
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/FastScroller.kt
@@ -0,0 +1,521 @@
+package org.koitharu.kotatsu.base.ui.list.fastscroll
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.res.TypedArray
+import android.graphics.Color
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import android.util.TypedValue
+import android.view.LayoutInflater
+import android.view.MotionEvent
+import android.view.ViewGroup
+import android.widget.*
+import androidx.annotation.*
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.constraintlayout.widget.ConstraintSet
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.content.ContextCompat
+import androidx.core.content.withStyledAttributes
+import androidx.core.view.GravityCompat
+import androidx.core.view.isGone
+import androidx.core.view.isVisible
+import androidx.recyclerview.widget.RecyclerView
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.databinding.FastScrollerBinding
+import org.koitharu.kotatsu.utils.ext.getThemeColor
+import org.koitharu.kotatsu.utils.ext.isLayoutReversed
+import kotlin.math.roundToInt
+import com.google.android.material.R as materialR
+
+private const val SCROLLBAR_HIDE_DELAY = 1000L
+private const val TRACK_SNAP_RANGE = 5
+
+@Suppress("MemberVisibilityCanBePrivate", "unused")
+class FastScroller @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ @AttrRes defStyleAttr: Int = R.attr.fastScrollerStyle,
+) : LinearLayout(context, attrs, defStyleAttr) {
+
+ enum class BubbleSize(@DrawableRes val drawableId: Int, @DimenRes val textSizeId: Int) {
+ NORMAL(R.drawable.fastscroll_bubble, R.dimen.fastscroll_bubble_text_size),
+ SMALL(R.drawable.fastscroll_bubble_small, R.dimen.fastscroll_bubble_text_size_small)
+ }
+
+ private val binding = FastScrollerBinding.inflate(LayoutInflater.from(context), this)
+
+ private val scrollbarPaddingEnd = context.resources.getDimension(R.dimen.fastscroll_scrollbar_padding_end)
+
+ @ColorInt
+ private var bubbleColor = 0
+
+ @ColorInt
+ private var handleColor = 0
+
+ private var bubbleHeight = 0
+ private var handleHeight = 0
+ private var viewHeight = 0
+ private var hideScrollbar = true
+ private var showBubble = true
+ private var showBubbleAlways = false
+ private var bubbleSize = BubbleSize.NORMAL
+ private var bubbleImage: Drawable? = null
+ private var handleImage: Drawable? = null
+ private var trackImage: Drawable? = null
+ private var recyclerView: RecyclerView? = null
+ private val scrollbarAnimator = ScrollbarAnimator(binding.scrollbar, scrollbarPaddingEnd)
+ private val bubbleAnimator = BubbleAnimator(binding.bubble)
+
+ private var fastScrollListener: FastScrollListener? = null
+ private var sectionIndexer: SectionIndexer? = null
+
+ private val scrollbarHider = Runnable {
+ hideBubble()
+ hideScrollbar()
+ }
+
+ private val scrollListener: RecyclerView.OnScrollListener = object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ if (!binding.thumb.isSelected && isEnabled) {
+ val y = recyclerView.scrollProportion
+ setViewPositions(y)
+
+ if (showBubbleAlways) {
+ val targetPos = getRecyclerViewTargetPosition(y)
+ sectionIndexer?.let { binding.bubble.text = it.getSectionText(recyclerView.context, targetPos) }
+ }
+ }
+ }
+
+ override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
+ super.onScrollStateChanged(recyclerView, newState)
+
+ if (isEnabled) {
+ when (newState) {
+ RecyclerView.SCROLL_STATE_DRAGGING -> {
+ handler.removeCallbacks(scrollbarHider)
+ showScrollbar()
+ if (showBubbleAlways && sectionIndexer != null) showBubble()
+ }
+ RecyclerView.SCROLL_STATE_IDLE -> if (hideScrollbar && !binding.thumb.isSelected) {
+ handler.postDelayed(scrollbarHider, SCROLLBAR_HIDE_DELAY)
+ }
+ }
+ }
+ }
+ }
+
+ private val RecyclerView.scrollProportion: Float
+ get() {
+ val rangeDiff = computeVerticalScrollRange() - computeVerticalScrollExtent()
+ val proportion = computeVerticalScrollOffset() / if (rangeDiff > 0) rangeDiff.toFloat() else 1f
+ return viewHeight * proportion
+ }
+
+ init {
+ clipChildren = false
+ orientation = HORIZONTAL
+
+ @ColorInt var bubbleColor = context.getThemeColor(materialR.attr.colorControlNormal, Color.DKGRAY)
+ @ColorInt var handleColor = bubbleColor
+ @ColorInt var trackColor = context.getThemeColor(materialR.attr.colorOutline, Color.LTGRAY)
+ @ColorInt var textColor = context.getThemeColor(android.R.attr.textColorPrimaryInverse, Color.WHITE)
+
+ var showTrack = false
+
+ context.withStyledAttributes(attrs, R.styleable.FastScroller, defStyleAttr) {
+ bubbleColor = getColor(R.styleable.FastScroller_bubbleColor, bubbleColor)
+ handleColor = getColor(R.styleable.FastScroller_thumbColor, handleColor)
+ trackColor = getColor(R.styleable.FastScroller_trackColor, trackColor)
+ textColor = getColor(R.styleable.FastScroller_bubbleTextColor, textColor)
+ hideScrollbar = getBoolean(R.styleable.FastScroller_hideScrollbar, hideScrollbar)
+ showBubble = getBoolean(R.styleable.FastScroller_showBubble, showBubble)
+ showBubbleAlways = getBoolean(R.styleable.FastScroller_showBubbleAlways, showBubbleAlways)
+ showTrack = getBoolean(R.styleable.FastScroller_showTrack, showTrack)
+ bubbleSize = getBubbleSize(R.styleable.FastScroller_bubbleSize, BubbleSize.NORMAL)
+ val textSize = getDimension(R.styleable.FastScroller_bubbleTextSize, bubbleSize.textSize)
+ binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
+ }
+
+ setTrackColor(trackColor)
+ setHandleColor(handleColor)
+ setBubbleColor(bubbleColor)
+ setBubbleTextColor(textColor)
+ setHideScrollbar(hideScrollbar)
+ setBubbleVisible(showBubble, showBubbleAlways)
+ setTrackVisible(showTrack)
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) {
+ super.onSizeChanged(w, h, oldW, oldH)
+ viewHeight = h
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ val setYPositions: () -> Unit = {
+ val y = event.y
+ setViewPositions(y)
+ setRecyclerViewPosition(y)
+ }
+
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN -> {
+ if (event.x.toInt() !in binding.scrollbar.left..binding.scrollbar.right) return false
+
+ requestDisallowInterceptTouchEvent(true)
+ setHandleSelected(true)
+
+ handler.removeCallbacks(scrollbarHider)
+ showScrollbar()
+ if (showBubble && sectionIndexer != null) showBubble()
+
+ fastScrollListener?.onFastScrollStart(this)
+
+ setYPositions()
+ return true
+ }
+ MotionEvent.ACTION_MOVE -> {
+ setYPositions()
+ return true
+ }
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
+ requestDisallowInterceptTouchEvent(false)
+ setHandleSelected(false)
+
+ if (hideScrollbar) handler.postDelayed(scrollbarHider, SCROLLBAR_HIDE_DELAY)
+ if (!showBubbleAlways) hideBubble()
+
+ fastScrollListener?.onFastScrollStop(this)
+
+ return true
+ }
+ }
+
+ return super.onTouchEvent(event)
+ }
+
+ /**
+ * Set the enabled state of this view.
+ *
+ * @param enabled True if this view is enabled, false otherwise
+ */
+ override fun setEnabled(enabled: Boolean) {
+ super.setEnabled(enabled)
+ isVisible = enabled
+ }
+
+ /**
+ * Set the [ViewGroup.LayoutParams] associated with this view. These supply
+ * parameters to the *parent* of this view specifying how it should be arranged.
+ *
+ * @param params The [ViewGroup.LayoutParams] for this view, cannot be null
+ */
+ override fun setLayoutParams(params: ViewGroup.LayoutParams) {
+ params.width = LayoutParams.WRAP_CONTENT
+ super.setLayoutParams(params)
+ }
+
+ /**
+ * Set the [ViewGroup.LayoutParams] associated with this view. These supply
+ * parameters to the *parent* of this view specifying how it should be arranged.
+ *
+ * @param viewGroup The parent [ViewGroup] for this view, cannot be null
+ */
+ fun setLayoutParams(viewGroup: ViewGroup) {
+ val recyclerViewId = recyclerView?.id ?: NO_ID
+ val marginTop = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_top)
+ val marginBottom = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_bottom)
+
+ require(recyclerViewId != NO_ID) { "RecyclerView must have a view ID" }
+
+ when (viewGroup) {
+ is ConstraintLayout -> {
+ val endId = if (recyclerView?.parent === parent) recyclerViewId else ConstraintSet.PARENT_ID
+ val startId = id
+
+ ConstraintSet().apply {
+ clone(viewGroup)
+ connect(startId, ConstraintSet.TOP, endId, ConstraintSet.TOP)
+ connect(startId, ConstraintSet.BOTTOM, endId, ConstraintSet.BOTTOM)
+ connect(startId, ConstraintSet.END, endId, ConstraintSet.END)
+ applyTo(viewGroup)
+ }
+
+ layoutParams = (layoutParams as ConstraintLayout.LayoutParams).apply {
+ height = 0
+ setMargins(0, marginTop, 0, marginBottom)
+ }
+ }
+ is CoordinatorLayout -> layoutParams = (layoutParams as CoordinatorLayout.LayoutParams).apply {
+ height = LayoutParams.MATCH_PARENT
+ anchorGravity = GravityCompat.END
+ anchorId = recyclerViewId
+ setMargins(0, marginTop, 0, marginBottom)
+ }
+ is FrameLayout -> layoutParams = (layoutParams as FrameLayout.LayoutParams).apply {
+ height = LayoutParams.MATCH_PARENT
+ gravity = GravityCompat.END
+ setMargins(0, marginTop, 0, marginBottom)
+ }
+ is RelativeLayout -> layoutParams = (layoutParams as RelativeLayout.LayoutParams).apply {
+ height = 0
+ addRule(RelativeLayout.ALIGN_TOP, recyclerViewId)
+ addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId)
+ addRule(RelativeLayout.ALIGN_END, recyclerViewId)
+ setMargins(0, marginTop, 0, marginBottom)
+ }
+ else -> throw IllegalArgumentException("Parent ViewGroup must be a ConstraintLayout, CoordinatorLayout, FrameLayout, or RelativeLayout")
+ }
+
+ updateViewHeights()
+ }
+
+ /**
+ * Set the [RecyclerView] associated with this [FastScroller]. This allows the
+ * FastScroller to set its layout parameters and listen for scroll changes.
+ *
+ * @param recyclerView The [RecyclerView] to attach, cannot be null
+ * @see detachRecyclerView
+ */
+ fun attachRecyclerView(recyclerView: RecyclerView) {
+ if (this.recyclerView != null) {
+ detachRecyclerView()
+ }
+ this.recyclerView = recyclerView
+
+ if (parent is ViewGroup) {
+ setLayoutParams(parent as ViewGroup)
+ } else if (recyclerView.parent is ViewGroup) {
+ val viewGroup = recyclerView.parent as ViewGroup
+ viewGroup.addView(this)
+ setLayoutParams(viewGroup)
+ }
+
+ recyclerView.addOnScrollListener(scrollListener)
+
+ // set initial positions for bubble and thumb
+ post { setViewPositions(this.recyclerView?.scrollProportion ?: 0f) }
+ }
+
+ /**
+ * Clears references to the attached [RecyclerView] and stops listening for scroll changes.
+ *
+ * @see attachRecyclerView
+ */
+ fun detachRecyclerView() {
+ recyclerView?.removeOnScrollListener(scrollListener)
+ recyclerView = null
+ }
+
+ /**
+ * Set a new [FastScrollListener] that will listen to fast scroll events.
+ *
+ * @param fastScrollListener The new [FastScrollListener] to set, or null to set none
+ */
+ fun setFastScrollListener(fastScrollListener: FastScrollListener?) {
+ this.fastScrollListener = fastScrollListener
+ }
+
+ /**
+ * Set a new [SectionIndexer] that provides section text for this [FastScroller].
+ *
+ * @param sectionIndexer The new [SectionIndexer] to set, or null to set none
+ */
+ fun setSectionIndexer(sectionIndexer: SectionIndexer?) {
+ this.sectionIndexer = sectionIndexer
+ }
+
+ /**
+ * Hide the scrollbar when not scrolling.
+ *
+ * @param hideScrollbar True to hide the scrollbar, false to show
+ */
+ fun setHideScrollbar(hideScrollbar: Boolean) {
+ if (this.hideScrollbar != hideScrollbar) {
+ this.hideScrollbar = hideScrollbar
+ binding.scrollbar.isGone = hideScrollbar
+ }
+ }
+
+ /**
+ * Show the scroll track while scrolling.
+ *
+ * @param visible True to show scroll track, false to hide
+ */
+ fun setTrackVisible(visible: Boolean) {
+ binding.track.isVisible = visible
+ }
+
+ /**
+ * Set the color of the scroll track.
+ *
+ * @param color The color for the scroll track
+ */
+ fun setTrackColor(@ColorInt color: Int) {
+ if (trackImage == null) {
+ trackImage = ContextCompat.getDrawable(context, R.drawable.fastscroll_track)
+ }
+
+ trackImage?.let {
+ it.setTint(color)
+ binding.track.setImageDrawable(it)
+ }
+ }
+
+ /**
+ * Set the color of the scroll thumb.
+ *
+ * @param color The color for the scroll thumb
+ */
+ fun setHandleColor(@ColorInt color: Int) {
+ handleColor = color
+
+ if (handleImage == null) {
+ handleImage = ContextCompat.getDrawable(context, R.drawable.fastscroll_handle)
+ }
+
+ handleImage?.let {
+ it.setTint(handleColor)
+ binding.thumb.setImageDrawable(it)
+ }
+ }
+
+ /**
+ * Show the section bubble while scrolling.
+ *
+ * @param visible True to show the bubble, false to hide
+ * @param always True to always show the bubble, false to only show on thumb touch
+ */
+ @JvmOverloads
+ fun setBubbleVisible(visible: Boolean, always: Boolean = false) {
+ showBubble = visible
+ showBubbleAlways = visible && always
+ }
+
+ /**
+ * Set the background color of the section bubble.
+ *
+ * @param color The background color for the section bubble
+ */
+ fun setBubbleColor(@ColorInt color: Int) {
+ bubbleColor = color
+
+ if (bubbleImage == null) {
+ bubbleImage = ContextCompat.getDrawable(context, bubbleSize.drawableId)
+ }
+
+ bubbleImage?.let {
+ it.setTint(bubbleColor)
+ binding.bubble.background = it
+ }
+ }
+
+ /**
+ * Set the text color of the section bubble.
+ *
+ * @param color The text color for the section bubble
+ */
+ fun setBubbleTextColor(@ColorInt color: Int) = binding.bubble.setTextColor(color)
+
+ /**
+ * Set the scaled pixel text size of the section bubble.
+ *
+ * @param size The scaled pixel text size for the section bubble
+ */
+ fun setBubbleTextSize(size: Int) {
+ binding.bubble.textSize = size.toFloat()
+ }
+
+ private fun getRecyclerViewTargetPosition(y: Float) = recyclerView?.let { recyclerView ->
+ val itemCount = recyclerView.adapter?.itemCount ?: 0
+
+ val proportion = when {
+ binding.thumb.y == 0f -> 0f
+ binding.thumb.y + handleHeight >= viewHeight - TRACK_SNAP_RANGE -> 1f
+ else -> y / viewHeight.toFloat()
+ }
+
+ var scrolledItemCount = (proportion * itemCount).roundToInt()
+
+ if (recyclerView.layoutManager.isLayoutReversed) {
+ scrolledItemCount = itemCount - scrolledItemCount
+ }
+
+ if (itemCount > 0) scrolledItemCount.coerceIn(0, itemCount - 1) else 0
+ } ?: 0
+
+ private fun setRecyclerViewPosition(y: Float) {
+ val layoutManager = recyclerView?.layoutManager ?: return
+ val targetPos = getRecyclerViewTargetPosition(y)
+ layoutManager.scrollToPosition(targetPos)
+ if (showBubble) sectionIndexer?.let { binding.bubble.text = it.getSectionText(context, targetPos) }
+ }
+
+ private fun setViewPositions(y: Float) {
+ bubbleHeight = binding.bubble.measuredHeight
+ handleHeight = binding.thumb.measuredHeight
+
+ val bubbleHandleHeight = bubbleHeight + handleHeight / 2f
+
+ if (showBubble && viewHeight >= bubbleHandleHeight) {
+ binding.bubble.y = (y - bubbleHeight).coerceIn(0f, viewHeight - bubbleHandleHeight)
+ }
+
+ if (viewHeight >= handleHeight) {
+ binding.thumb.y = (y - handleHeight / 2).coerceIn(0f, viewHeight - handleHeight.toFloat())
+ }
+ }
+
+ private fun updateViewHeights() {
+ val measureSpec = MeasureSpec.makeMeasureSpec(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED)
+ binding.bubble.measure(measureSpec, measureSpec)
+ bubbleHeight = binding.bubble.measuredHeight
+ binding.thumb.measure(measureSpec, measureSpec)
+ handleHeight = binding.thumb.measuredHeight
+ }
+
+ private fun showBubble() {
+ bubbleAnimator.show()
+ }
+
+ private fun hideBubble() {
+ bubbleAnimator.hide()
+ }
+
+ private fun showScrollbar() {
+ if (recyclerView?.run { canScrollVertically(1) || canScrollVertically(-1) } == true) {
+ scrollbarAnimator.show()
+ }
+ }
+
+ private fun hideScrollbar() {
+ scrollbarAnimator.hide()
+ }
+
+ private fun setHandleSelected(selected: Boolean) {
+ binding.thumb.isSelected = selected
+ handleImage?.setTint(if (selected) bubbleColor else handleColor)
+ }
+
+ private fun TypedArray.getBubbleSize(@StyleableRes index: Int, defaultValue: BubbleSize): BubbleSize {
+ val ordinal = getInt(index, -1)
+ return BubbleSize.values().getOrNull(ordinal) ?: defaultValue
+ }
+
+ private val BubbleSize.textSize
+ @Px get() = resources.getDimension(textSizeId)
+
+ interface FastScrollListener {
+
+ fun onFastScrollStart(fastScroller: FastScroller)
+
+ fun onFastScrollStop(fastScroller: FastScroller)
+ }
+
+ interface SectionIndexer {
+
+ fun getSectionText(context: Context, position: Int): CharSequence
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/ScrollbarAnimator.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/ScrollbarAnimator.kt
new file mode 100644
index 000000000..a00fc90b9
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/fastscroll/ScrollbarAnimator.kt
@@ -0,0 +1,69 @@
+package org.koitharu.kotatsu.base.ui.list.fastscroll
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.view.View
+import android.view.ViewPropertyAnimator
+import androidx.core.view.isInvisible
+import androidx.core.view.isVisible
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.utils.ext.animatorDurationScale
+
+class ScrollbarAnimator(
+ private val scrollbar: View,
+ private val scrollbarPaddingEnd: Float,
+) {
+
+ private val animationDuration = (scrollbar.resources.getInteger(R.integer.config_defaultAnimTime) *
+ scrollbar.context.animatorDurationScale).toLong()
+ private var animator: ViewPropertyAnimator? = null
+ private var isHiding = false
+
+ fun show() {
+ if (scrollbar.isVisible && !isHiding) {
+ return
+ }
+ isHiding = false
+ animator?.cancel()
+ scrollbar.translationX = scrollbarPaddingEnd
+ scrollbar.isVisible = true
+ animator = scrollbar
+ .animate()
+ .translationX(0f)
+ .alpha(1f)
+ .setDuration(animationDuration)
+ }
+
+ fun hide() {
+ if (!scrollbar.isVisible || isHiding) {
+ return
+ }
+ animator?.cancel()
+ isHiding = true
+ animator = scrollbar
+ .animate()
+ .translationX(scrollbarPaddingEnd)
+ .alpha(0f)
+ .setDuration(animationDuration)
+ .setListener(HideListener())
+ }
+
+ private inner class HideListener : AnimatorListenerAdapter() {
+
+ private var isCancelled = false
+
+ override fun onAnimationCancel(animation: Animator?) {
+ super.onAnimationCancel(animation)
+ isCancelled = true
+ }
+
+ override fun onAnimationEnd(animation: Animator?) {
+ super.onAnimationEnd(animation)
+ if (!isCancelled && animation === this@ScrollbarAnimator.animator) {
+ scrollbar.isInvisible = true
+ isHiding = false
+ this@ScrollbarAnimator.animator = null
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActivityRecreationHandle.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActivityRecreationHandle.kt
new file mode 100644
index 000000000..f072da2fa
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ActivityRecreationHandle.kt
@@ -0,0 +1,34 @@
+package org.koitharu.kotatsu.base.ui.util
+
+import android.app.Activity
+import android.app.Application.ActivityLifecycleCallbacks
+import android.os.Bundle
+import java.util.*
+
+class ActivityRecreationHandle : ActivityLifecycleCallbacks {
+
+ private val activities = WeakHashMap()
+
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
+ activities[activity] = Unit
+ }
+
+ override fun onActivityStarted(activity: Activity) = Unit
+
+ override fun onActivityResumed(activity: Activity) = Unit
+
+ override fun onActivityPaused(activity: Activity) = Unit
+
+ override fun onActivityStopped(activity: Activity) = Unit
+
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
+
+ override fun onActivityDestroyed(activity: Activity) {
+ activities.remove(activity)
+ }
+
+ fun recreateAll() {
+ val snapshot = activities.keys.toList()
+ snapshot.forEach { it.recreate() }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ReversibleAction.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ReversibleAction.kt
new file mode 100644
index 000000000..57bb80a78
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/ReversibleAction.kt
@@ -0,0 +1,9 @@
+package org.koitharu.kotatsu.base.ui.util
+
+import androidx.annotation.StringRes
+import org.koitharu.kotatsu.base.domain.ReversibleHandle
+
+class ReversibleAction(
+ @StringRes val stringResId: Int,
+ val handle: ReversibleHandle?,
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/WindowInsetsDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/WindowInsetsDelegate.kt
index 5f1f0cd5e..c0d9c8c53 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/WindowInsetsDelegate.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/WindowInsetsDelegate.kt
@@ -16,10 +16,7 @@ class WindowInsetsDelegate(
private var lastInsets: Insets? = null
- override fun onApplyWindowInsets(v: View?, insets: WindowInsetsCompat?): WindowInsetsCompat? {
- if (insets == null) {
- return null
- }
+ override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val handledInsets = interceptingWindowInsetsListener?.onApplyWindowInsets(v, insets) ?: insets
val newInsets = if (handleImeInsets) {
Insets.max(
@@ -49,7 +46,7 @@ class WindowInsetsDelegate(
) {
view.removeOnLayoutChangeListener(this)
if (lastInsets == null) { // Listener may not be called
- onApplyWindowInsets(view, ViewCompat.getRootWindowInsets(view))
+ onApplyWindowInsets(view, ViewCompat.getRootWindowInsets(view) ?: return)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt
index c20b615cf..73e42259e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt
@@ -9,6 +9,7 @@ import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable
import com.google.android.material.chip.ChipGroup
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.utils.ext.castOrNull
class ChipsView @JvmOverloads constructor(
context: Context,
@@ -18,10 +19,10 @@ class ChipsView @JvmOverloads constructor(
private var isLayoutSuppressedCompat = false
private var isLayoutCalledOnSuppressed = false
- private var chipOnClickListener = OnClickListener {
+ private val chipOnClickListener = OnClickListener {
onChipClickListener?.onChipClick(it as Chip, it.tag)
}
- private var chipOnCloseListener = OnClickListener {
+ private val chipOnCloseListener = OnClickListener {
onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag)
}
var onChipClickListener: OnChipClickListener? = null
@@ -60,15 +61,27 @@ class ChipsView @JvmOverloads constructor(
}
}
+ fun getCheckedData(cls: Class): Set {
+ val result = LinkedHashSet(childCount)
+ for (child in children) {
+ if (child is Chip && child.isChecked) {
+ result += cls.castOrNull(child.tag) ?: continue
+ }
+ }
+ return result
+ }
+
private fun bindChip(chip: Chip, model: ChipModel) {
chip.text = model.title
if (model.icon == 0) {
chip.isChipIconVisible = false
} else {
- chip.isCheckedIconVisible = true
+ chip.isChipIconVisible = true
chip.setChipIconResource(model.icon)
}
- chip.isClickable = onChipClickListener != null
+ chip.isClickable = onChipClickListener != null || model.isCheckable
+ chip.isCheckable = model.isCheckable
+ chip.isChecked = model.isChecked
chip.tag = model.data
}
@@ -76,11 +89,12 @@ class ChipsView @JvmOverloads constructor(
val chip = Chip(context)
val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip)
chip.setChipDrawable(drawable)
+ chip.isCheckedIconVisible = true
+ chip.setCheckedIconResource(R.drawable.ic_check)
chip.isCloseIconVisible = onChipCloseClickListener != null
chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false)
chip.setOnClickListener(chipOnClickListener)
- chip.isCheckable = false
addView(chip)
return chip
}
@@ -98,7 +112,9 @@ class ChipsView @JvmOverloads constructor(
class ChipModel(
@DrawableRes val icon: Int,
val title: CharSequence,
- val data: Any? = null
+ val isCheckable: Boolean,
+ val isChecked: Boolean,
+ val data: Any? = null,
) {
override fun equals(other: Any?): Boolean {
@@ -109,6 +125,8 @@ class ChipsView @JvmOverloads constructor(
if (icon != other.icon) return false
if (title != other.title) return false
+ if (isCheckable != other.isCheckable) return false
+ if (isChecked != other.isChecked) return false
if (data != other.data) return false
return true
@@ -117,7 +135,9 @@ class ChipsView @JvmOverloads constructor(
override fun hashCode(): Int {
var result = icon
result = 31 * result + title.hashCode()
- result = 31 * result + data.hashCode()
+ result = 31 * result + isCheckable.hashCode()
+ result = 31 * result + isChecked.hashCode()
+ result = 31 * result + (data?.hashCode() ?: 0)
return result
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt
index 9c85787a7..56c4a7d88 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt
@@ -17,19 +17,27 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.LayoutInflater
-import android.widget.Button
import android.widget.FrameLayout
-import android.widget.TextView
+import androidx.annotation.ColorInt
import androidx.annotation.StringRes
+import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.postDelayed
+import com.google.android.material.color.MaterialColors
+import com.google.android.material.shape.MaterialShapeDrawable
+import com.google.android.material.shape.ShapeAppearanceModel
+import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.databinding.FadingSnackbarLayoutBinding
+import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
+import com.google.android.material.R as materialR
+
+private const val SHORT_DURATION_MS = 1_500L
+private const val LONG_DURATION_MS = 2_750L
-private const val ENTER_DURATION = 300L
-private const val EXIT_DURATION = 200L
-private const val SHORT_DURATION = 1_500L
-private const val LONG_DURATION = 2_750L
/**
* A custom snackbar implementation allowing more control over placement and entry/exit animations.
*
@@ -40,16 +48,15 @@ private const val LONG_DURATION = 2_750L
class FadingSnackbar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
- defStyleAttr: Int = 0
+ defStyleAttr: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr) {
- private val message: TextView
- private val action: Button
+ private val binding = FadingSnackbarLayoutBinding.inflate(LayoutInflater.from(context), this)
+ private val enterDuration = context.resources.getInteger(R.integer.config_defaultAnimTime).toLong()
+ private val exitDuration = context.resources.getInteger(android.R.integer.config_shortAnimTime).toLong()
init {
- val view = LayoutInflater.from(context).inflate(R.layout.fading_snackbar_layout, this, true)
- message = view.findViewById(R.id.snackbar_text)
- action = view.findViewById(R.id.snackbar_action)
+ binding.snackbarLayout.background = createThemedBackground()
}
fun dismiss() {
@@ -57,38 +64,71 @@ class FadingSnackbar @JvmOverloads constructor(
animate()
.alpha(0f)
.withEndAction { visibility = GONE }
- .duration = EXIT_DURATION
+ .duration = exitDuration
}
}
fun show(
- messageText: CharSequence? = null,
- @StringRes actionId: Int? = null,
- longDuration: Boolean = true,
- actionClick: () -> Unit = { dismiss() },
- dismissListener: () -> Unit = { }
+ messageText: CharSequence?,
+ @StringRes actionId: Int = 0,
+ duration: Int = Snackbar.LENGTH_SHORT,
+ onActionClick: (FadingSnackbar.() -> Unit)? = null,
+ onDismiss: (() -> Unit)? = null,
) {
- message.text = messageText
- if (actionId != null) {
- action.run {
+ binding.snackbarText.text = messageText
+ if (actionId != 0) {
+ with(binding.snackbarAction) {
visibility = VISIBLE
text = context.getString(actionId)
setOnClickListener {
- actionClick()
+ onActionClick?.invoke(this@FadingSnackbar) ?: dismiss()
}
}
} else {
- action.visibility = GONE
+ binding.snackbarAction.visibility = GONE
}
alpha = 0f
visibility = VISIBLE
animate()
.alpha(1f)
- .duration = ENTER_DURATION
- val showDuration = ENTER_DURATION + if (longDuration) LONG_DURATION else SHORT_DURATION
- postDelayed(showDuration) {
+ .duration = enterDuration
+ if (duration == Snackbar.LENGTH_INDEFINITE) {
+ return
+ }
+ val durationMs = enterDuration + if (duration == Snackbar.LENGTH_LONG) LONG_DURATION_MS else SHORT_DURATION_MS
+ postDelayed(durationMs) {
dismiss()
- dismissListener()
+ onDismiss?.invoke()
}
}
+
+ private fun createThemedBackground(): Drawable {
+ val backgroundColor = MaterialColors.layer(this, materialR.attr.colorSurface, materialR.attr.colorOnSurface, 1f)
+ val shapeAppearanceModel = ShapeAppearanceModel.builder(
+ context,
+ materialR.style.ShapeAppearance_Material3_Corner_ExtraSmall,
+ 0
+ ).build()
+ val background = createMaterialShapeDrawableBackground(
+ backgroundColor,
+ shapeAppearanceModel,
+ )
+ val backgroundTint = context.getThemeColorStateList(materialR.attr.colorSurfaceInverse)
+ return if (backgroundTint != null) {
+ val wrappedDrawable = DrawableCompat.wrap(background)
+ DrawableCompat.setTintList(wrappedDrawable, backgroundTint)
+ wrappedDrawable
+ } else {
+ DrawableCompat.wrap(background)
+ }
+ }
+
+ private fun createMaterialShapeDrawableBackground(
+ @ColorInt backgroundColor: Int,
+ shapeAppearanceModel: ShapeAppearanceModel,
+ ): MaterialShapeDrawable {
+ val background = MaterialShapeDrawable(shapeAppearanceModel)
+ background.fillColor = ColorStateList.valueOf(backgroundColor)
+ return background
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/HideBottomNavigationOnScrollBehavior.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/HideBottomNavigationOnScrollBehavior.kt
new file mode 100644
index 000000000..b4f1df4e7
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/HideBottomNavigationOnScrollBehavior.kt
@@ -0,0 +1,104 @@
+package org.koitharu.kotatsu.base.ui.widgets
+
+import android.animation.ValueAnimator
+import android.content.Context
+import android.util.AttributeSet
+import android.view.View
+import android.view.ViewGroup
+import android.view.animation.DecelerateInterpolator
+import androidx.appcompat.widget.Toolbar
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.view.ViewCompat
+import com.google.android.material.appbar.AppBarLayout
+import com.google.android.material.bottomnavigation.BottomNavigationView
+import org.koitharu.kotatsu.utils.ext.animatorDurationScale
+import org.koitharu.kotatsu.utils.ext.findChild
+import org.koitharu.kotatsu.utils.ext.measureHeight
+import kotlin.math.roundToLong
+
+class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
+ context: Context? = null,
+ attrs: AttributeSet? = null,
+) : CoordinatorLayout.Behavior(context, attrs) {
+
+ @ViewCompat.NestedScrollType
+ private var lastStartedType: Int = 0
+
+ private var offsetAnimator: ValueAnimator? = null
+
+ private var dyRatio = 1F
+
+ override fun layoutDependsOn(parent: CoordinatorLayout, child: BottomNavigationView, dependency: View): Boolean {
+ return dependency is AppBarLayout
+ }
+
+ override fun onDependentViewChanged(
+ parent: CoordinatorLayout,
+ child: BottomNavigationView,
+ dependency: View,
+ ): Boolean {
+ val appBarSize = dependency.measureHeight()
+ dyRatio = if (appBarSize > 0) {
+ child.measureHeight().toFloat() / appBarSize
+ } else {
+ 1F
+ }
+ return false
+ }
+
+ override fun onStartNestedScroll(
+ coordinatorLayout: CoordinatorLayout,
+ child: BottomNavigationView,
+ directTargetChild: View,
+ target: View,
+ axes: Int,
+ type: Int,
+ ): Boolean {
+ if (axes != ViewCompat.SCROLL_AXIS_VERTICAL) {
+ return false
+ }
+ lastStartedType = type
+ offsetAnimator?.cancel()
+ return true
+ }
+
+ override fun onNestedPreScroll(
+ coordinatorLayout: CoordinatorLayout,
+ child: BottomNavigationView,
+ target: View,
+ dx: Int,
+ dy: Int,
+ consumed: IntArray,
+ type: Int,
+ ) {
+ super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
+ child.translationY = (child.translationY + (dy * dyRatio)).coerceIn(0F, child.height.toFloat())
+ }
+
+ override fun onStopNestedScroll(
+ coordinatorLayout: CoordinatorLayout,
+ child: BottomNavigationView,
+ target: View,
+ type: Int,
+ ) {
+ if (lastStartedType == ViewCompat.TYPE_TOUCH || type == ViewCompat.TYPE_NON_TOUCH) {
+ animateBottomNavigationVisibility(child, child.translationY < child.height / 2)
+ }
+ }
+
+ private fun animateBottomNavigationVisibility(child: BottomNavigationView, isVisible: Boolean) {
+ offsetAnimator?.cancel()
+ offsetAnimator = ValueAnimator().apply {
+ interpolator = DecelerateInterpolator()
+ duration = (150 * child.context.animatorDurationScale).roundToLong()
+ addUpdateListener {
+ child.translationY = it.animatedValue as Float
+ }
+ }
+ offsetAnimator?.setFloatValues(
+ child.translationY,
+ if (isVisible) 0F else child.height.toFloat(),
+ )
+ offsetAnimator?.start()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/KotatsuBottomNavigationView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/KotatsuBottomNavigationView.kt
new file mode 100644
index 000000000..7ee897e80
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/KotatsuBottomNavigationView.kt
@@ -0,0 +1,156 @@
+package org.koitharu.kotatsu.base.ui.widgets
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.animation.TimeInterpolator
+import android.content.Context
+import android.os.Parcel
+import android.os.Parcelable
+import android.util.AttributeSet
+import android.view.ViewPropertyAnimator
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.view.doOnLayout
+import androidx.core.view.updateLayoutParams
+import androidx.customview.view.AbsSavedState
+import androidx.interpolator.view.animation.FastOutLinearInInterpolator
+import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
+import androidx.lifecycle.findViewTreeLifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.bottomnavigation.BottomNavigationView
+import org.koitharu.kotatsu.utils.ext.applySystemAnimatorScale
+import com.google.android.material.R as materialR
+
+class KotatsuBottomNavigationView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = materialR.attr.bottomNavigationStyle,
+ defStyleRes: Int = materialR.style.Widget_Design_BottomNavigationView,
+) : BottomNavigationView(context, attrs, defStyleAttr, defStyleRes) {
+
+ private var currentAnimator: ViewPropertyAnimator? = null
+
+ private var currentState = STATE_UP
+
+ init {
+ // Hide on scroll
+ doOnLayout {
+ findViewTreeLifecycleOwner()?.lifecycleScope?.let {
+ updateLayoutParams {
+ behavior = HideBottomNavigationOnScrollBehavior()
+ }
+ }
+ }
+ }
+
+ override fun onSaveInstanceState(): Parcelable {
+ val superState = super.onSaveInstanceState()
+ return SavedState(superState).also {
+ it.currentState = currentState
+ it.translationY = translationY
+ }
+ }
+
+ override fun onRestoreInstanceState(state: Parcelable?) {
+ if (state is SavedState) {
+ super.onRestoreInstanceState(state.superState)
+ super.setTranslationY(state.translationY)
+ currentState = state.currentState
+ } else {
+ super.onRestoreInstanceState(state)
+ }
+ }
+
+ override fun setTranslationY(translationY: Float) {
+ // Disallow translation change when state down
+ if (currentState == STATE_DOWN) return
+ super.setTranslationY(translationY)
+ }
+
+ /**
+ * Shows this view up.
+ */
+ fun slideUp() = post {
+ currentAnimator?.cancel()
+ clearAnimation()
+
+ currentState = STATE_UP
+ animateTranslation(
+ 0F,
+ SLIDE_UP_ANIMATION_DURATION,
+ LinearOutSlowInInterpolator(),
+ )
+ }
+
+ /**
+ * Hides this view down. [setTranslationY] won't work until [slideUp] is called.
+ */
+ fun slideDown() = post {
+ currentAnimator?.cancel()
+ clearAnimation()
+
+ currentState = STATE_DOWN
+ animateTranslation(
+ height.toFloat(),
+ SLIDE_DOWN_ANIMATION_DURATION,
+ FastOutLinearInInterpolator(),
+ )
+ }
+
+ private fun animateTranslation(targetY: Float, duration: Long, interpolator: TimeInterpolator) {
+ currentAnimator = animate()
+ .translationY(targetY)
+ .setInterpolator(interpolator)
+ .setDuration(duration)
+ .applySystemAnimatorScale(context)
+ .setListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator?) {
+ currentAnimator = null
+ postInvalidate()
+ }
+ },
+ )
+ }
+
+ internal class SavedState : AbsSavedState {
+ var currentState = STATE_UP
+ var translationY = 0F
+
+ constructor(superState: Parcelable) : super(superState)
+
+ constructor(source: Parcel, loader: ClassLoader?) : super(source, loader) {
+ currentState = source.readInt()
+ translationY = source.readFloat()
+ }
+
+ override fun writeToParcel(out: Parcel, flags: Int) {
+ super.writeToParcel(out, flags)
+ out.writeInt(currentState)
+ out.writeFloat(translationY)
+ }
+
+ companion object {
+ @JvmField
+ val CREATOR: Parcelable.ClassLoaderCreator = object : Parcelable.ClassLoaderCreator {
+ override fun createFromParcel(source: Parcel, loader: ClassLoader): SavedState {
+ return SavedState(source, loader)
+ }
+
+ override fun createFromParcel(source: Parcel): SavedState {
+ return SavedState(source, null)
+ }
+
+ override fun newArray(size: Int): Array {
+ return newArray(size)
+ }
+ }
+ }
+ }
+
+ companion object {
+ private const val STATE_DOWN = 1
+ private const val STATE_UP = 2
+
+ private const val SLIDE_UP_ANIMATION_DURATION = 225L
+ private const val SLIDE_DOWN_ANIMATION_DURATION = 175L
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/KotatsuCoordinatorLayout.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/KotatsuCoordinatorLayout.kt
new file mode 100644
index 000000000..8f7b0be10
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/KotatsuCoordinatorLayout.kt
@@ -0,0 +1,111 @@
+package org.koitharu.kotatsu.base.ui.widgets
+
+import android.content.Context
+import android.os.Parcel
+import android.os.Parcelable
+import android.util.AttributeSet
+import android.view.View
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.view.doOnLayout
+import androidx.customview.view.AbsSavedState
+import com.google.android.material.appbar.AppBarLayout
+import org.koitharu.kotatsu.utils.ext.findChild
+
+class KotatsuCoordinatorLayout @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = androidx.coordinatorlayout.R.attr.coordinatorLayoutStyle
+) : CoordinatorLayout(context, attrs, defStyleAttr) {
+
+ private var appBarLayout: AppBarLayout? = null
+
+ /**
+ * If true, [AppBarLayout] child will be lifted on nested scroll.
+ */
+ var isLiftAppBarOnScroll = true
+
+ /**
+ * Internal check
+ */
+ private val canLiftAppBarOnScroll
+ get() = isLiftAppBarOnScroll
+
+ override fun onNestedScroll(
+ target: View,
+ dxConsumed: Int,
+ dyConsumed: Int,
+ dxUnconsumed: Int,
+ dyUnconsumed: Int,
+ type: Int,
+ consumed: IntArray
+ ) {
+ super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, type, consumed)
+ if (canLiftAppBarOnScroll) {
+ appBarLayout?.isLifted = dyConsumed != 0 || dyUnconsumed >= 0
+ }
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ appBarLayout = findChild()
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+ appBarLayout = null
+ }
+
+ override fun onSaveInstanceState(): Parcelable? {
+ val superState = super.onSaveInstanceState()
+ return if (superState != null) {
+ SavedState(superState).also {
+ it.appBarLifted = appBarLayout?.isLifted ?: false
+ }
+ } else {
+ superState
+ }
+ }
+
+ override fun onRestoreInstanceState(state: Parcelable?) {
+ if (state is SavedState) {
+ super.onRestoreInstanceState(state.superState)
+ doOnLayout {
+ appBarLayout?.isLifted = state.appBarLifted
+ }
+ } else {
+ super.onRestoreInstanceState(state)
+ }
+ }
+
+ internal class SavedState : AbsSavedState {
+ var appBarLifted = false
+
+ constructor(superState: Parcelable) : super(superState)
+
+ constructor(source: Parcel, loader: ClassLoader?) : super(source, loader) {
+ appBarLifted = source.readByte().toInt() == 1
+ }
+
+ override fun writeToParcel(out: Parcel, flags: Int) {
+ super.writeToParcel(out, flags)
+ out.writeByte((if (appBarLifted) 1 else 0).toByte())
+ }
+
+ companion object {
+ @JvmField
+ val CREATOR: Parcelable.ClassLoaderCreator = object : Parcelable.ClassLoaderCreator {
+ override fun createFromParcel(source: Parcel, loader: ClassLoader): SavedState {
+ return SavedState(source, loader)
+ }
+
+ override fun createFromParcel(source: Parcel): SavedState {
+ return SavedState(source, null)
+ }
+
+ override fun newArray(size: Int): Array {
+ return newArray(size)
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SegmentedBarView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SegmentedBarView.kt
new file mode 100644
index 000000000..0a4a89d98
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/SegmentedBarView.kt
@@ -0,0 +1,89 @@
+package org.koitharu.kotatsu.base.ui.widgets
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Outline
+import android.graphics.Paint
+import android.util.AttributeSet
+import android.view.View
+import android.view.ViewOutlineProvider
+import androidx.annotation.ColorInt
+import androidx.annotation.FloatRange
+import androidx.core.graphics.ColorUtils
+import org.koitharu.kotatsu.parsers.util.replaceWith
+import org.koitharu.kotatsu.utils.ext.resolveDp
+import kotlin.random.Random
+
+class SegmentedBarView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+) : View(context, attrs, defStyleAttr) {
+
+ private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ private val segmentsData = ArrayList()
+ private val minSegmentSize = context.resources.resolveDp(3f)
+
+ var segments: List
+ get() = segmentsData
+ set(value) {
+ segmentsData.replaceWith(value)
+ invalidate()
+ }
+
+ init {
+ paint.style = Paint.Style.FILL
+ outlineProvider = OutlineProvider()
+ clipToOutline = true
+
+ if (isInEditMode) {
+ segments = List(Random.nextInt(3, 5)) {
+ Segment(
+ percent = Random.nextFloat(),
+ color = ColorUtils.HSLToColor(floatArrayOf(Random.nextInt(0, 360).toFloat(), 0.5f, 0.5f)),
+ )
+ }
+ }
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ var x = 0f
+ val w = width.toFloat()
+ for (segment in segmentsData) {
+ paint.color = segment.color
+ val segmentWidth = (w * segment.percent).coerceAtLeast(minSegmentSize)
+ canvas.drawRect(x, 0f, x + segmentWidth, height.toFloat(), paint)
+ x += segmentWidth
+ }
+ }
+
+ class Segment(
+ @FloatRange(from = 0.0, to = 1.0) val percent: Float,
+ @ColorInt val color: Int,
+ ) {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Segment
+
+ if (percent != other.percent) return false
+ if (color != other.color) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = percent.hashCode()
+ result = 31 * result + color
+ return result
+ }
+ }
+
+ private class OutlineProvider : ViewOutlineProvider() {
+ override fun getOutline(view: View, outline: Outline) {
+ outline.setRoundRect(0, 0, view.width, view.height, view.height / 2f)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/BookmarksModule.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/BookmarksModule.kt
index 4a8294765..b21099509 100644
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/BookmarksModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/BookmarksModule.kt
@@ -1,10 +1,14 @@
package org.koitharu.kotatsu.bookmarks
+import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
+import org.koitharu.kotatsu.bookmarks.ui.BookmarksViewModel
val bookmarksModule
get() = module {
factory { BookmarksRepository(get()) }
+
+ viewModel { BookmarksViewModel(get()) }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt
index 0959b3362..f4bbba185 100644
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarkEntity.kt
@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
),
]
)
-class BookmarkEntity(
+data class BookmarkEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "page_id", index = true) val pageId: Long,
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@@ -25,4 +25,5 @@ class BookmarkEntity(
@ColumnInfo(name = "scroll") val scroll: Int,
@ColumnInfo(name = "image") val imageUrl: String,
@ColumnInfo(name = "created_at") val createdAt: Long,
+ @ColumnInfo(name = "percent") val percent: Float,
)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt
index dd023be7a..076b19a3c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt
@@ -1,20 +1,27 @@
package org.koitharu.kotatsu.bookmarks.data
-import androidx.room.Dao
-import androidx.room.Delete
-import androidx.room.Insert
-import androidx.room.Query
+import androidx.room.*
import kotlinx.coroutines.flow.Flow
+import org.koitharu.kotatsu.core.db.entity.MangaWithTags
@Dao
abstract class BookmarksDao {
+ @Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
+ abstract suspend fun find(mangaId: Long, pageId: Long): BookmarkEntity?
+
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page")
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY created_at DESC")
abstract fun observe(mangaId: Long): Flow>
+ @Transaction
+ @Query(
+ "SELECT * FROM manga JOIN bookmarks ON bookmarks.manga_id = manga.manga_id ORDER BY bookmarks.created_at"
+ )
+ abstract fun observe(): Flow>>
+
@Insert
abstract suspend fun insert(entity: BookmarkEntity)
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt
index 981aa05ea..a8b2e0912 100644
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/data/EntityMapping.kt
@@ -1,15 +1,9 @@
package org.koitharu.kotatsu.bookmarks.data
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
-import org.koitharu.kotatsu.core.db.entity.toManga
-import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.*
-fun BookmarkWithManga.toBookmark() = bookmark.toBookmark(
- manga.toManga(tags.toMangaTags())
-)
-
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
manga = manga,
pageId = pageId,
@@ -18,6 +12,7 @@ fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
scroll = scroll,
imageUrl = imageUrl,
createdAt = Date(createdAt),
+ percent = percent,
)
fun Bookmark.toEntity() = BookmarkEntity(
@@ -28,4 +23,11 @@ fun Bookmark.toEntity() = BookmarkEntity(
scroll = scroll,
imageUrl = imageUrl,
createdAt = createdAt.time,
-)
\ No newline at end of file
+ percent = percent,
+)
+
+fun Collection.toBookmarks(manga: Manga) = map {
+ it.toBookmark(manga)
+}
+
+fun Collection.ids() = map { it.pageId }
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt
index 0b76c6537..5b6ff3bf0 100644
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/Bookmark.kt
@@ -11,6 +11,7 @@ class Bookmark(
val scroll: Int,
val imageUrl: String,
val createdAt: Date,
+ val percent: Float,
) {
override fun equals(other: Any?): Boolean {
@@ -26,6 +27,7 @@ class Bookmark(
if (scroll != other.scroll) return false
if (imageUrl != other.imageUrl) return false
if (createdAt != other.createdAt) return false
+ if (percent != other.percent) return false
return true
}
@@ -38,6 +40,7 @@ class Bookmark(
result = 31 * result + scroll
result = 31 * result + imageUrl.hashCode()
result = 31 * result + createdAt.hashCode()
+ result = 31 * result + percent.hashCode()
return result
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt
index df63c03aa..ff8842647 100644
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/domain/BookmarksRepository.kt
@@ -1,15 +1,21 @@
package org.koitharu.kotatsu.bookmarks.domain
+import android.database.SQLException
import androidx.room.withTransaction
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
+import org.koitharu.kotatsu.base.domain.ReversibleHandle
+import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.bookmarks.data.toBookmark
+import org.koitharu.kotatsu.bookmarks.data.toBookmarks
import org.koitharu.kotatsu.bookmarks.data.toEntity
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity
+import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.mapItems
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
class BookmarksRepository(
private val db: MangaDatabase,
@@ -23,6 +29,17 @@ class BookmarksRepository(
return db.bookmarksDao.observe(manga.id).mapItems { it.toBookmark(manga) }
}
+ fun observeBookmarks(): Flow>> {
+ return db.bookmarksDao.observe().map { map ->
+ val res = LinkedHashMap>(map.size)
+ for ((k, v) in map) {
+ val manga = k.toManga()
+ res[manga] = v.toBookmarks(manga)
+ }
+ res
+ }
+ }
+
suspend fun addBookmark(bookmark: Bookmark) {
db.withTransaction {
val tags = bookmark.manga.tags.toEntities()
@@ -35,4 +52,38 @@ class BookmarksRepository(
suspend fun removeBookmark(mangaId: Long, pageId: Long) {
db.bookmarksDao.delete(mangaId, pageId)
}
+
+ suspend fun removeBookmarks(ids: Map>): ReversibleHandle {
+ val entities = ArrayList(ids.size)
+ db.withTransaction {
+ val dao = db.bookmarksDao
+ for ((manga, idSet) in ids) {
+ for (pageId in idSet) {
+ val e = dao.find(manga.id, pageId)
+ if (e != null) {
+ entities.add(e)
+ }
+ dao.delete(manga.id, pageId)
+ }
+ }
+ }
+ return BookmarksRestorer(entities)
+ }
+
+ private inner class BookmarksRestorer(
+ private val entities: Collection,
+ ) : ReversibleHandle {
+
+ override suspend fun reverse() {
+ db.withTransaction {
+ for (e in entities) {
+ try {
+ db.bookmarksDao.insert(e)
+ } catch (e: SQLException) {
+ e.printStackTraceDebug()
+ }
+ }
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksActivity.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksActivity.kt
new file mode 100644
index 000000000..ee6c53efb
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksActivity.kt
@@ -0,0 +1,41 @@
+package org.koitharu.kotatsu.bookmarks.ui
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.core.graphics.Insets
+import androidx.core.view.updatePadding
+import androidx.fragment.app.commit
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.BaseActivity
+import org.koitharu.kotatsu.databinding.ActivityContainerBinding
+
+class BookmarksActivity : BaseActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(ActivityContainerBinding.inflate(layoutInflater))
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ val fm = supportFragmentManager
+ if (fm.findFragmentById(R.id.container) == null) {
+ fm.commit {
+ val fragment = BookmarksFragment.newInstance()
+ replace(R.id.container, fragment)
+ }
+ }
+ }
+
+ override fun onWindowInsetsChanged(insets: Insets) {
+ with(binding.toolbar) {
+ updatePadding(
+ left = insets.left,
+ right = insets.right
+ )
+ }
+ }
+
+ companion object {
+
+ fun newIntent(context: Context) = Intent(context, BookmarksActivity::class.java)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksFragment.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksFragment.kt
new file mode 100644
index 000000000..73d2079c5
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksFragment.kt
@@ -0,0 +1,177 @@
+package org.koitharu.kotatsu.bookmarks.ui
+
+import android.os.Bundle
+import android.view.*
+import androidx.appcompat.view.ActionMode
+import androidx.core.graphics.Insets
+import androidx.core.view.updatePadding
+import com.google.android.material.snackbar.Snackbar
+import org.koin.android.ext.android.get
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.domain.reverseAsync
+import org.koitharu.kotatsu.base.ui.BaseFragment
+import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
+import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
+import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
+import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
+import org.koitharu.kotatsu.base.ui.util.ReversibleAction
+import org.koitharu.kotatsu.bookmarks.data.ids
+import org.koitharu.kotatsu.bookmarks.domain.Bookmark
+import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksGroupAdapter
+import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
+import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
+import org.koitharu.kotatsu.details.ui.DetailsActivity
+import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
+import org.koitharu.kotatsu.list.ui.model.ListModel
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.reader.ui.ReaderActivity
+import org.koitharu.kotatsu.utils.ext.getDisplayMessage
+import org.koitharu.kotatsu.utils.ext.invalidateNestedItemDecorations
+import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
+
+class BookmarksFragment : BaseFragment(), ListStateHolderListener,
+ OnListItemClickListener, SectionedSelectionController.Callback {
+
+ private val viewModel by viewModel()
+ private var adapter: BookmarksGroupAdapter? = null
+ private var selectionController: SectionedSelectionController? = null
+
+ override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): FragmentListSimpleBinding {
+ return FragmentListSimpleBinding.inflate(inflater, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ selectionController = SectionedSelectionController(
+ activity = requireActivity(),
+ registryOwner = this,
+ callback = this,
+ )
+ adapter = BookmarksGroupAdapter(
+ lifecycleOwner = viewLifecycleOwner,
+ coil = get(),
+ listener = this,
+ selectionController = checkNotNull(selectionController),
+ bookmarkClickListener = this,
+ groupClickListener = OnGroupClickListener(),
+ )
+ binding.recyclerView.adapter = adapter
+ binding.recyclerView.setHasFixedSize(true)
+ val spacingDecoration = SpacingItemDecoration(view.resources.getDimensionPixelOffset(R.dimen.grid_spacing))
+ binding.recyclerView.addItemDecoration(spacingDecoration)
+
+ viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
+ viewModel.onError.observe(viewLifecycleOwner, ::onError)
+ viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ adapter = null
+ selectionController = null
+ }
+
+ override fun onItemClick(item: Bookmark, view: View) {
+ if (selectionController?.onItemClick(item.manga, item.pageId) != true) {
+ val intent = ReaderActivity.newIntent(view.context, item)
+ startActivity(intent, scaleUpActivityOptionsOf(view).toBundle())
+ }
+ }
+
+ override fun onItemLongClick(item: Bookmark, view: View): Boolean {
+ return selectionController?.onItemLongClick(item.manga, item.pageId) ?: false
+ }
+
+ override fun onRetryClick(error: Throwable) = Unit
+
+ override fun onEmptyActionClick() = Unit
+
+ override fun onSelectionChanged(count: Int) {
+ binding.recyclerView.invalidateNestedItemDecorations()
+ }
+
+ override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
+ mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
+ return true
+ }
+
+ override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
+ mode.title = selectionController?.count?.toString()
+ return true
+ }
+
+ override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
+ return when (item.itemId) {
+ R.id.action_remove -> {
+ val ids = selectionController?.snapshot() ?: return false
+ viewModel.removeBookmarks(ids)
+ mode.finish()
+ true
+ }
+ else -> false
+ }
+ }
+
+ override fun onCreateItemDecoration(section: Manga): AbstractSelectionItemDecoration {
+ return BookmarksSelectionDecoration(requireContext())
+ }
+
+ override fun onWindowInsetsChanged(insets: Insets) {
+ binding.root.updatePadding(
+ left = insets.left,
+ right = insets.right,
+ )
+ binding.recyclerView.updatePadding(
+ bottom = insets.bottom,
+ )
+ }
+
+ private fun onListChanged(list: List) {
+ adapter?.items = list
+ }
+
+ private fun onError(e: Throwable) {
+ Snackbar.make(
+ binding.recyclerView,
+ e.getDisplayMessage(resources),
+ Snackbar.LENGTH_SHORT
+ ).show()
+ }
+
+ private fun onActionDone(action: ReversibleAction) {
+ val handle = action.handle
+ val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
+ val snackbar = Snackbar.make(binding.recyclerView, action.stringResId, length)
+ if (handle != null) {
+ snackbar.setAction(R.string.undo) { handle.reverseAsync() }
+ }
+ snackbar.show()
+ }
+
+ private inner class OnGroupClickListener : OnListItemClickListener {
+
+ override fun onItemClick(item: BookmarksGroup, view: View) {
+ val controller = selectionController
+ if (controller != null && controller.count > 0) {
+ if (controller.getSectionCount(item.manga) == item.bookmarks.size) {
+ controller.clearSelection(item.manga)
+ } else {
+ controller.addToSelection(item.manga, item.bookmarks.ids())
+ }
+ return
+ }
+ val intent = DetailsActivity.newIntent(view.context, item.manga)
+ startActivity(intent)
+ }
+
+ override fun onItemLongClick(item: BookmarksGroup, view: View): Boolean {
+ return selectionController?.addToSelection(item.manga, item.bookmarks.ids()) ?: false
+ }
+ }
+
+ companion object {
+
+ fun newInstance() = BookmarksFragment()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksSelectionDecoration.kt
new file mode 100644
index 000000000..025acb882
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksSelectionDecoration.kt
@@ -0,0 +1,18 @@
+package org.koitharu.kotatsu.bookmarks.ui
+
+import android.content.Context
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+import org.koitharu.kotatsu.bookmarks.domain.Bookmark
+import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
+import org.koitharu.kotatsu.utils.ext.getItem
+
+class BookmarksSelectionDecoration(context: Context) : MangaSelectionDecoration(context) {
+
+ override fun getItemId(parent: RecyclerView, child: View): Long {
+ val holder = parent.getChildViewHolder(child) ?: return RecyclerView.NO_ID
+ val item = holder.getItem(Bookmark::class.java) ?: return RecyclerView.NO_ID
+ return item.pageId
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksViewModel.kt
new file mode 100644
index 000000000..623bfc834
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksViewModel.kt
@@ -0,0 +1,52 @@
+package org.koitharu.kotatsu.bookmarks.ui
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.map
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.BaseViewModel
+import org.koitharu.kotatsu.base.ui.util.ReversibleAction
+import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
+import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
+import org.koitharu.kotatsu.list.ui.model.EmptyState
+import org.koitharu.kotatsu.list.ui.model.ListModel
+import org.koitharu.kotatsu.list.ui.model.LoadingState
+import org.koitharu.kotatsu.list.ui.model.toErrorState
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.utils.SingleLiveEvent
+import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+
+class BookmarksViewModel(
+ private val repository: BookmarksRepository,
+) : BaseViewModel() {
+
+ val onActionDone = SingleLiveEvent()
+
+ val content: LiveData> = repository.observeBookmarks()
+ .map { list ->
+ if (list.isEmpty()) {
+ listOf(
+ EmptyState(
+ icon = R.drawable.ic_empty_favourites,
+ textPrimary = R.string.no_bookmarks_yet,
+ textSecondary = R.string.no_bookmarks_summary,
+ actionStringRes = 0,
+ )
+ )
+ } else list.map { (manga, bookmarks) ->
+ BookmarksGroup(manga, bookmarks)
+ }
+ }
+ .catch { e -> e.toErrorState(canRetry = false) }
+ .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
+
+
+ fun removeBookmarks(ids: Map>) {
+ launchJob(Dispatchers.Default) {
+ val handle = repository.removeBookmarks(ids)
+ onActionDone.postCall(ReversibleAction(R.string.bookmarks_removed, handle))
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarkListAD.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt
similarity index 61%
rename from app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarkListAD.kt
rename to app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt
index f8aa0e638..b02ce9750 100644
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarkListAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt
@@ -1,16 +1,14 @@
-package org.koitharu.kotatsu.bookmarks.ui
+package org.koitharu.kotatsu.bookmarks.ui.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
-import coil.request.Disposable
-import coil.size.Scale
-import coil.util.CoilUtils
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
+import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer
@@ -23,29 +21,24 @@ fun bookmarkListAD(
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) }
) {
- var imageRequest: Disposable? = null
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
binding.root.setOnClickListener(listener)
binding.root.setOnLongClickListener(listener)
bind {
- imageRequest?.dispose()
- imageRequest = binding.imageViewThumb.newImageRequest(item.imageUrl)
- .referer(item.manga.publicUrl)
- .placeholder(R.drawable.ic_placeholder)
- .fallback(R.drawable.ic_placeholder)
- .error(R.drawable.ic_placeholder)
- .allowRgb565(true)
- .scale(Scale.FILL)
- .lifecycle(lifecycleOwner)
- .enqueueWith(coil)
+ binding.imageViewThumb.newImageRequest(item.imageUrl)?.run {
+ referer(item.manga.publicUrl)
+ placeholder(R.drawable.ic_placeholder)
+ fallback(R.drawable.ic_placeholder)
+ error(R.drawable.ic_placeholder)
+ allowRgb565(true)
+ lifecycle(lifecycleOwner)
+ enqueueWith(coil)
+ }
}
onViewRecycled {
- imageRequest?.dispose()
- imageRequest = null
- CoilUtils.dispose(binding.imageViewThumb)
- binding.imageViewThumb.setImageDrawable(null)
+ binding.imageViewThumb.disposeImageRequest()
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksAdapter.kt
similarity index 86%
rename from app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksAdapter.kt
rename to app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksAdapter.kt
index 92040bc97..2f3022b8e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/BookmarksAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksAdapter.kt
@@ -1,4 +1,4 @@
-package org.koitharu.kotatsu.bookmarks.ui
+package org.koitharu.kotatsu.bookmarks.ui.adapter
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
@@ -19,7 +19,7 @@ class BookmarksAdapter(
private class DiffCallback : DiffUtil.ItemCallback() {
override fun areItemsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
- return oldItem.manga.id == newItem.manga.id && oldItem.chapterId == newItem.chapterId
+ return oldItem.manga.id == newItem.manga.id && oldItem.pageId == newItem.pageId
}
override fun areContentsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksGroupAD.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksGroupAD.kt
new file mode 100644
index 000000000..b71d83662
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksGroupAD.kt
@@ -0,0 +1,65 @@
+package org.koitharu.kotatsu.bookmarks.ui.adapter
+
+import android.view.View
+import androidx.lifecycle.LifecycleOwner
+import androidx.recyclerview.widget.RecyclerView
+import coil.ImageLoader
+import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
+import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
+import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
+import org.koitharu.kotatsu.bookmarks.domain.Bookmark
+import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
+import org.koitharu.kotatsu.databinding.ItemBookmarksGroupBinding
+import org.koitharu.kotatsu.list.ui.model.ListModel
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.utils.ext.*
+
+fun bookmarksGroupAD(
+ coil: ImageLoader,
+ lifecycleOwner: LifecycleOwner,
+ sharedPool: RecyclerView.RecycledViewPool,
+ selectionController: SectionedSelectionController,
+ bookmarkClickListener: OnListItemClickListener,
+ groupClickListener: OnListItemClickListener,
+) = adapterDelegateViewBinding(
+ { layoutInflater, parent -> ItemBookmarksGroupBinding.inflate(layoutInflater, parent, false) }
+) {
+
+ val viewListenerAdapter = object : View.OnClickListener, View.OnLongClickListener {
+ override fun onClick(v: View) = groupClickListener.onItemClick(item, v)
+ override fun onLongClick(v: View) = groupClickListener.onItemLongClick(item, v)
+ }
+
+ val adapter = BookmarksAdapter(coil, lifecycleOwner, bookmarkClickListener)
+ binding.recyclerView.setRecycledViewPool(sharedPool)
+ binding.recyclerView.adapter = adapter
+ val spacingDecoration = SpacingItemDecoration(context.resources.getDimensionPixelOffset(R.dimen.grid_spacing))
+ binding.recyclerView.addItemDecoration(spacingDecoration)
+ binding.root.setOnClickListener(viewListenerAdapter)
+ binding.root.setOnLongClickListener(viewListenerAdapter)
+
+ bind { payloads ->
+ if (payloads.isEmpty()) {
+ binding.recyclerView.clearItemDecorations()
+ binding.recyclerView.addItemDecoration(spacingDecoration)
+ selectionController.attachToRecyclerView(item.manga, binding.recyclerView)
+ }
+ binding.imageViewCover.newImageRequest(item.manga.coverUrl)?.run {
+ referer(item.manga.publicUrl)
+ placeholder(R.drawable.ic_placeholder)
+ fallback(R.drawable.ic_placeholder)
+ error(R.drawable.ic_placeholder)
+ allowRgb565(true)
+ lifecycle(lifecycleOwner)
+ enqueueWith(coil)
+ }
+ binding.textViewTitle.text = item.manga.title
+ adapter.items = item.bookmarks
+ }
+
+ onViewRecycled {
+ binding.imageViewCover.disposeImageRequest()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksGroupAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksGroupAdapter.kt
new file mode 100644
index 000000000..c34b2fe54
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarksGroupAdapter.kt
@@ -0,0 +1,67 @@
+package org.koitharu.kotatsu.bookmarks.ui.adapter
+
+import androidx.lifecycle.LifecycleOwner
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
+import coil.ImageLoader
+import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
+import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
+import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
+import org.koitharu.kotatsu.bookmarks.domain.Bookmark
+import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
+import org.koitharu.kotatsu.list.ui.adapter.*
+import org.koitharu.kotatsu.list.ui.model.ListModel
+import org.koitharu.kotatsu.parsers.model.Manga
+import kotlin.jvm.internal.Intrinsics
+
+class BookmarksGroupAdapter(
+ coil: ImageLoader,
+ lifecycleOwner: LifecycleOwner,
+ selectionController: SectionedSelectionController,
+ listener: ListStateHolderListener,
+ bookmarkClickListener: OnListItemClickListener,
+ groupClickListener: OnListItemClickListener,
+) : AsyncListDifferDelegationAdapter(DiffCallback()) {
+
+ init {
+ val pool = RecyclerView.RecycledViewPool()
+ delegatesManager
+ .addDelegate(
+ bookmarksGroupAD(
+ coil = coil,
+ lifecycleOwner = lifecycleOwner,
+ sharedPool = pool,
+ selectionController = selectionController,
+ bookmarkClickListener = bookmarkClickListener,
+ groupClickListener = groupClickListener,
+ )
+ )
+ .addDelegate(loadingStateAD())
+ .addDelegate(loadingFooterAD())
+ .addDelegate(emptyStateListAD(listener))
+ .addDelegate(errorStateListAD(listener))
+ }
+
+ private class DiffCallback : DiffUtil.ItemCallback() {
+
+ override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
+ return when {
+ oldItem is BookmarksGroup && newItem is BookmarksGroup -> {
+ oldItem.manga.id == newItem.manga.id
+ }
+ else -> oldItem.javaClass == newItem.javaClass
+ }
+ }
+
+ override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
+ return Intrinsics.areEqual(oldItem, newItem)
+ }
+
+ override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? {
+ return when {
+ oldItem is BookmarksGroup && newItem is BookmarksGroup -> Unit
+ else -> super.getChangePayload(oldItem, newItem)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/model/BookmarksGroup.kt b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/model/BookmarksGroup.kt
new file mode 100644
index 000000000..c9dc2e129
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/bookmarks/ui/model/BookmarksGroup.kt
@@ -0,0 +1,31 @@
+package org.koitharu.kotatsu.bookmarks.ui.model
+
+import org.koitharu.kotatsu.bookmarks.domain.Bookmark
+import org.koitharu.kotatsu.list.ui.model.ListModel
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.util.areItemsEquals
+
+class BookmarksGroup(
+ val manga: Manga,
+ val bookmarks: List,
+) : ListModel {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as BookmarksGroup
+
+ if (manga != other.manga) return false
+
+ return bookmarks.areItemsEquals(other.bookmarks) { a, b ->
+ a.imageUrl == b.imageUrl
+ }
+ }
+
+ override fun hashCode(): Int {
+ var result = manga.hashCode()
+ result = 31 * result + bookmarks.sumOf { it.imageUrl.hashCode() }
+ return result
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt
index 442808bad..601aac6e7 100644
--- a/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt
@@ -11,11 +11,11 @@ import android.view.MenuItem
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
-import com.google.android.material.R as materialR
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.network.UserAgentInterceptor
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
+import com.google.android.material.R as materialR
@SuppressLint("SetJavaScriptEnabled")
class BrowserActivity : BaseActivity(), BrowserCallback {
@@ -59,8 +59,9 @@ class BrowserActivity : BaseActivity(), BrowserCallback
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.opt_browser, menu)
- return super.onCreateOptionsMenu(menu)
+ return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt
index 4b42b4c60..27dd10255 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt
@@ -1,14 +1,12 @@
package org.koitharu.kotatsu.core.backup
+import androidx.room.withTransaction
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase
-import org.koitharu.kotatsu.core.db.entity.MangaEntity
-import org.koitharu.kotatsu.core.db.entity.TagEntity
-import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
-import org.koitharu.kotatsu.favourites.data.FavouriteEntity
-import org.koitharu.kotatsu.history.data.HistoryEntity
+import org.koitharu.kotatsu.parsers.util.json.JSONIterator
+import org.koitharu.kotatsu.parsers.util.json.mapJSON
private const val PAGE_SIZE = 10
@@ -24,11 +22,11 @@ class BackupRepository(private val db: MangaDatabase) {
}
offset += history.size
for (item in history) {
- val manga = item.manga.toJson()
+ val manga = JsonSerializer(item.manga).toJson()
val tags = JSONArray()
- item.tags.forEach { tags.put(it.toJson()) }
+ item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
manga.put("tags", tags)
- val json = item.history.toJson()
+ val json = JsonSerializer(item.history).toJson()
json.put("manga", manga)
entry.data.put(json)
}
@@ -40,7 +38,7 @@ class BackupRepository(private val db: MangaDatabase) {
val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray())
val categories = db.favouriteCategoriesDao.findAll()
for (item in categories) {
- entry.data.put(item.toJson())
+ entry.data.put(JsonSerializer(item).toJson())
}
return entry
}
@@ -55,11 +53,11 @@ class BackupRepository(private val db: MangaDatabase) {
}
offset += favourites.size
for (item in favourites) {
- val manga = item.manga.toJson()
+ val manga = JsonSerializer(item.manga).toJson()
val tags = JSONArray()
- item.tags.forEach { tags.put(it.toJson()) }
+ item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
manga.put("tags", tags)
- val json = item.favourite.toJson()
+ val json = JsonSerializer(item.favourite).toJson()
json.put("manga", manga)
entry.data.put(json)
}
@@ -77,59 +75,54 @@ class BackupRepository(private val db: MangaDatabase) {
return entry
}
- private fun MangaEntity.toJson(): JSONObject {
- val jo = JSONObject()
- jo.put("id", id)
- jo.put("title", title)
- jo.put("alt_title", altTitle)
- jo.put("url", url)
- jo.put("public_url", publicUrl)
- jo.put("rating", rating)
- jo.put("nsfw", isNsfw)
- jo.put("cover_url", coverUrl)
- jo.put("large_cover_url", largeCoverUrl)
- jo.put("state", state)
- jo.put("author", author)
- jo.put("source", source)
- return jo
+ suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
+ val result = CompositeResult()
+ for (item in entry.data.JSONIterator()) {
+ val mangaJson = item.getJSONObject("manga")
+ val manga = JsonDeserializer(mangaJson).toMangaEntity()
+ val tags = mangaJson.getJSONArray("tags").mapJSON {
+ JsonDeserializer(it).toTagEntity()
+ }
+ val history = JsonDeserializer(item).toHistoryEntity()
+ result += runCatching {
+ db.withTransaction {
+ db.tagsDao.upsert(tags)
+ db.mangaDao.upsert(manga, tags)
+ db.historyDao.upsert(history)
+ }
+ }
+ }
+ return result
}
- private fun TagEntity.toJson(): JSONObject {
- val jo = JSONObject()
- jo.put("id", id)
- jo.put("title", title)
- jo.put("key", key)
- jo.put("source", source)
- return jo
+ suspend fun restoreCategories(entry: BackupEntry): CompositeResult {
+ val result = CompositeResult()
+ for (item in entry.data.JSONIterator()) {
+ val category = JsonDeserializer(item).toFavouriteCategoryEntity()
+ result += runCatching {
+ db.favouriteCategoriesDao.upsert(category)
+ }
+ }
+ return result
}
- private fun HistoryEntity.toJson(): JSONObject {
- val jo = JSONObject()
- jo.put("manga_id", mangaId)
- jo.put("created_at", createdAt)
- jo.put("updated_at", updatedAt)
- jo.put("chapter_id", chapterId)
- jo.put("page", page)
- jo.put("scroll", scroll)
- return jo
- }
-
- private fun FavouriteCategoryEntity.toJson(): JSONObject {
- val jo = JSONObject()
- jo.put("category_id", categoryId)
- jo.put("created_at", createdAt)
- jo.put("sort_key", sortKey)
- jo.put("title", title)
- jo.put("order", order)
- jo.put("track", track)
- return jo
- }
-
- private fun FavouriteEntity.toJson(): JSONObject {
- val jo = JSONObject()
- jo.put("manga_id", mangaId)
- jo.put("category_id", categoryId)
- jo.put("created_at", createdAt)
- return jo
+ suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
+ val result = CompositeResult()
+ for (item in entry.data.JSONIterator()) {
+ val mangaJson = item.getJSONObject("manga")
+ val manga = JsonDeserializer(mangaJson).toMangaEntity()
+ val tags = mangaJson.getJSONArray("tags").mapJSON {
+ JsonDeserializer(it).toTagEntity()
+ }
+ val favourite = JsonDeserializer(item).toFavouriteEntity()
+ result += runCatching {
+ db.withTransaction {
+ db.tagsDao.upsert(tags)
+ db.mangaDao.upsert(manga, tags)
+ db.favouritesDao.upsert(favourite)
+ }
+ }
+ }
+ return result
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt
index f01dc73d9..8a6217d04 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt
@@ -15,11 +15,11 @@ class BackupZipOutput(val file: File) : Closeable {
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
- suspend fun put(entry: BackupEntry) {
+ suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
output.put(entry.name, entry.data.toString(2))
}
- suspend fun finish() {
+ suspend fun finish() = runInterruptible(Dispatchers.IO) {
output.finish()
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt
new file mode 100644
index 000000000..42aa846b1
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt
@@ -0,0 +1,68 @@
+package org.koitharu.kotatsu.core.backup
+
+import org.json.JSONObject
+import org.koitharu.kotatsu.core.db.entity.MangaEntity
+import org.koitharu.kotatsu.core.db.entity.TagEntity
+import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
+import org.koitharu.kotatsu.favourites.data.FavouriteEntity
+import org.koitharu.kotatsu.history.data.HistoryEntity
+import org.koitharu.kotatsu.parsers.model.SortOrder
+import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
+import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
+import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
+import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
+
+class JsonDeserializer(private val json: JSONObject) {
+
+ fun toFavouriteEntity() = FavouriteEntity(
+ mangaId = json.getLong("manga_id"),
+ categoryId = json.getLong("category_id"),
+ sortKey = json.getIntOrDefault("sort_key", 0),
+ createdAt = json.getLong("created_at"),
+ deletedAt = 0L,
+ )
+
+ fun toMangaEntity() = MangaEntity(
+ id = json.getLong("id"),
+ title = json.getString("title"),
+ altTitle = json.getStringOrNull("alt_title"),
+ url = json.getString("url"),
+ publicUrl = json.getStringOrNull("public_url").orEmpty(),
+ rating = json.getDouble("rating").toFloat(),
+ isNsfw = json.getBooleanOrDefault("nsfw", false),
+ coverUrl = json.getString("cover_url"),
+ largeCoverUrl = json.getStringOrNull("large_cover_url"),
+ state = json.getStringOrNull("state"),
+ author = json.getStringOrNull("author"),
+ source = json.getString("source")
+ )
+
+ fun toTagEntity() = TagEntity(
+ id = json.getLong("id"),
+ title = json.getString("title"),
+ key = json.getString("key"),
+ source = json.getString("source")
+ )
+
+ fun toHistoryEntity() = HistoryEntity(
+ mangaId = json.getLong("manga_id"),
+ createdAt = json.getLong("created_at"),
+ updatedAt = json.getLong("updated_at"),
+ chapterId = json.getLong("chapter_id"),
+ page = json.getInt("page"),
+ scroll = json.getDouble("scroll").toFloat(),
+ percent = json.getFloatOrDefault("percent", -1f),
+ deletedAt = 0L,
+ )
+
+ fun toFavouriteCategoryEntity() = FavouriteCategoryEntity(
+ categoryId = json.getInt("category_id"),
+ createdAt = json.getLong("created_at"),
+ sortKey = json.getInt("sort_key"),
+ title = json.getString("title"),
+ order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
+ track = json.getBooleanOrDefault("track", true),
+ isVisibleInLibrary = json.getBooleanOrDefault("show_in_lib", true),
+ deletedAt = 0L,
+ )
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/JsonSerializer.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/JsonSerializer.kt
new file mode 100644
index 000000000..3d4d2f068
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/JsonSerializer.kt
@@ -0,0 +1,72 @@
+package org.koitharu.kotatsu.core.backup
+
+import org.json.JSONObject
+import org.koitharu.kotatsu.core.db.entity.MangaEntity
+import org.koitharu.kotatsu.core.db.entity.TagEntity
+import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
+import org.koitharu.kotatsu.favourites.data.FavouriteEntity
+import org.koitharu.kotatsu.history.data.HistoryEntity
+
+class JsonSerializer private constructor(private val json: JSONObject) {
+
+ constructor(e: FavouriteEntity) : this(
+ JSONObject().apply {
+ put("manga_id", e.mangaId)
+ put("category_id", e.categoryId)
+ put("sort_key", e.sortKey)
+ put("created_at", e.createdAt)
+ }
+ )
+
+ constructor(e: FavouriteCategoryEntity) : this(
+ JSONObject().apply {
+ put("category_id", e.categoryId)
+ put("created_at", e.createdAt)
+ put("sort_key", e.sortKey)
+ put("title", e.title)
+ put("order", e.order)
+ put("track", e.track)
+ put("show_in_lib", e.isVisibleInLibrary)
+ }
+ )
+
+ constructor(e: HistoryEntity) : this(
+ JSONObject().apply {
+ put("manga_id", e.mangaId)
+ put("created_at", e.createdAt)
+ put("updated_at", e.updatedAt)
+ put("chapter_id", e.chapterId)
+ put("page", e.page)
+ put("scroll", e.scroll)
+ put("percent", e.percent)
+ }
+ )
+
+ constructor(e: TagEntity) : this(
+ JSONObject().apply {
+ put("id", e.id)
+ put("title", e.title)
+ put("key", e.key)
+ put("source", e.source)
+ }
+ )
+
+ constructor(e: MangaEntity) : this(
+ JSONObject().apply {
+ put("id", e.id)
+ put("title", e.title)
+ put("alt_title", e.altTitle)
+ put("url", e.url)
+ put("public_url", e.publicUrl)
+ put("rating", e.rating)
+ put("nsfw", e.isNsfw)
+ put("cover_url", e.coverUrl)
+ put("large_cover_url", e.largeCoverUrl)
+ put("state", e.state)
+ put("author", e.author)
+ put("source", e.source)
+ }
+ )
+
+ fun toJson(): JSONObject = json
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt
deleted file mode 100644
index f4db32cd0..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt
+++ /dev/null
@@ -1,118 +0,0 @@
-package org.koitharu.kotatsu.core.backup
-
-import androidx.room.withTransaction
-import org.json.JSONObject
-import org.koitharu.kotatsu.core.db.MangaDatabase
-import org.koitharu.kotatsu.core.db.entity.MangaEntity
-import org.koitharu.kotatsu.core.db.entity.TagEntity
-import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
-import org.koitharu.kotatsu.favourites.data.FavouriteEntity
-import org.koitharu.kotatsu.history.data.HistoryEntity
-import org.koitharu.kotatsu.parsers.model.SortOrder
-import org.koitharu.kotatsu.parsers.util.json.JSONIterator
-import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
-import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
-import org.koitharu.kotatsu.parsers.util.json.mapJSON
-
-class RestoreRepository(private val db: MangaDatabase) {
-
- suspend fun upsertHistory(entry: BackupEntry): CompositeResult {
- val result = CompositeResult()
- for (item in entry.data.JSONIterator()) {
- val mangaJson = item.getJSONObject("manga")
- val manga = parseManga(mangaJson)
- val tags = mangaJson.getJSONArray("tags").mapJSON {
- parseTag(it)
- }
- val history = parseHistory(item)
- result += runCatching {
- db.withTransaction {
- db.tagsDao.upsert(tags)
- db.mangaDao.upsert(manga, tags)
- db.historyDao.upsert(history)
- }
- }
- }
- return result
- }
-
- suspend fun upsertCategories(entry: BackupEntry): CompositeResult {
- val result = CompositeResult()
- for (item in entry.data.JSONIterator()) {
- val category = parseCategory(item)
- result += runCatching {
- db.favouriteCategoriesDao.upsert(category)
- }
- }
- return result
- }
-
- suspend fun upsertFavourites(entry: BackupEntry): CompositeResult {
- val result = CompositeResult()
- for (item in entry.data.JSONIterator()) {
- val mangaJson = item.getJSONObject("manga")
- val manga = parseManga(mangaJson)
- val tags = mangaJson.getJSONArray("tags").mapJSON {
- parseTag(it)
- }
- val favourite = parseFavourite(item)
- result += runCatching {
- db.withTransaction {
- db.tagsDao.upsert(tags)
- db.mangaDao.upsert(manga, tags)
- db.favouritesDao.upsert(favourite)
- }
- }
- }
- return result
- }
-
- private fun parseManga(json: JSONObject) = MangaEntity(
- id = json.getLong("id"),
- title = json.getString("title"),
- altTitle = json.getStringOrNull("alt_title"),
- url = json.getString("url"),
- publicUrl = json.getStringOrNull("public_url").orEmpty(),
- rating = json.getDouble("rating").toFloat(),
- isNsfw = json.getBooleanOrDefault("nsfw", false),
- coverUrl = json.getString("cover_url"),
- largeCoverUrl = json.getStringOrNull("large_cover_url"),
- state = json.getStringOrNull("state"),
- author = json.getStringOrNull("author"),
- source = json.getString("source"),
- )
-
- private fun parseTag(json: JSONObject) = TagEntity(
- id = json.getLong("id"),
- title = json.getString("title"),
- key = json.getString("key"),
- source = json.getString("source"),
- )
-
- private fun parseHistory(json: JSONObject) = HistoryEntity(
- mangaId = json.getLong("manga_id"),
- createdAt = json.getLong("created_at"),
- updatedAt = json.getLong("updated_at"),
- chapterId = json.getLong("chapter_id"),
- page = json.getInt("page"),
- scroll = json.getDouble("scroll").toFloat(),
- deletedAt = 0L,
- )
-
- private fun parseCategory(json: JSONObject) = FavouriteCategoryEntity(
- categoryId = json.getInt("category_id"),
- createdAt = json.getLong("created_at"),
- sortKey = json.getInt("sort_key"),
- title = json.getString("title"),
- order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
- track = json.getBooleanOrDefault("track", true),
- deletedAt = 0L,
- )
-
- private fun parseFavourite(json: JSONObject) = FavouriteEntity(
- mangaId = json.getLong("manga_id"),
- categoryId = json.getLong("category_id"),
- createdAt = json.getLong("created_at"),
- deletedAt = 0L,
- )
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt
index 1afe800a7..e13b0d26a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt
@@ -10,8 +10,16 @@ class DatabasePrePopulateCallback(private val resources: Resources) : RoomDataba
override fun onCreate(db: SupportSQLiteDatabase) {
db.execSQL(
- "INSERT INTO favourite_categories (created_at, sort_key, title, `order`, `track`, `deleted_at`) VALUES (?,?,?,?,?,?)",
- arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later), SortOrder.NEWEST.name, 1, 0L)
+ "INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track, show_in_lib, `deleted_at`) VALUES (?,?,?,?,?,?,?)",
+ arrayOf(
+ System.currentTimeMillis(),
+ 1,
+ resources.getString(R.string.read_later),
+ SortOrder.NEWEST.name,
+ 1,
+ 1,
+ 0L,
+ )
)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt
index 2f0184a8f..b5738c731 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt
@@ -6,8 +6,14 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
-import org.koitharu.kotatsu.core.db.dao.*
-import org.koitharu.kotatsu.core.db.entity.*
+import org.koitharu.kotatsu.core.db.dao.MangaDao
+import org.koitharu.kotatsu.core.db.dao.PreferencesDao
+import org.koitharu.kotatsu.core.db.dao.TagsDao
+import org.koitharu.kotatsu.core.db.dao.TrackLogsDao
+import org.koitharu.kotatsu.core.db.entity.MangaEntity
+import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
+import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
+import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.db.migrations.*
import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
@@ -15,16 +21,22 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.FavouritesDao
import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity
+import org.koitharu.kotatsu.scrobbling.data.ScrobblingDao
+import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity
import org.koitharu.kotatsu.suggestions.data.SuggestionDao
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
+import org.koitharu.kotatsu.tracker.data.TrackEntity
+import org.koitharu.kotatsu.tracker.data.TrackLogEntity
+import org.koitharu.kotatsu.tracker.data.TracksDao
-const val DATABASE_VERSION = 12
+const val DATABASE_VERSION = 14
@Database(
entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
+ ScrobblingEntity::class,
],
version = DATABASE_VERSION,
)
@@ -49,6 +61,8 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val suggestionDao: SuggestionDao
abstract val bookmarksDao: BookmarksDao
+
+ abstract val scrobblingDao: ScrobblingDao
}
fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder(
@@ -67,6 +81,8 @@ fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder(
Migration9To10(),
Migration10To11(),
Migration11To12(),
+ Migration12To13(),
+ Migration13To14(),
).addCallback(
DatabasePrePopulateCallback(context.resources)
-).build()
\ No newline at end of file
+).build()
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/Tables.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/Tables.kt
index 1e47f1e9a..7fd8c8786 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/Tables.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/Tables.kt
@@ -5,4 +5,4 @@ const val TABLE_MANGA = "manga"
const val TABLE_TAGS = "tags"
const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories"
const val TABLE_HISTORY = "history"
-const val TABLE_MANGA_TAGS = "manga_tags"
\ No newline at end of file
+const val TABLE_MANGA_TAGS = "manga_tags"
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt
index 8ddc723d5..ade35613b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TrackLogsDao.kt
@@ -1,8 +1,8 @@
package org.koitharu.kotatsu.core.db.dao
import androidx.room.*
-import org.koitharu.kotatsu.core.db.entity.TrackLogEntity
-import org.koitharu.kotatsu.core.db.entity.TrackLogWithManga
+import org.koitharu.kotatsu.tracker.data.TrackLogEntity
+import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
@Dao
interface TrackLogsDao {
@@ -21,7 +21,7 @@ interface TrackLogsDao {
suspend fun removeAll(mangaId: Long)
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
- suspend fun cleanup()
+ suspend fun gc()
@Query("SELECT COUNT(*) FROM track_logs")
suspend fun count(): Int
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt
index 51cd77b2e..af938a813 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt
@@ -1,11 +1,9 @@
package org.koitharu.kotatsu.core.db.entity
-import java.util.*
-import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.parsers.model.*
-import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
+import org.koitharu.kotatsu.utils.ext.longHashCode
// Entity to model
@@ -35,13 +33,6 @@ fun MangaEntity.toManga(tags: Set) = Manga(
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
-fun TrackLogWithManga.toTrackingLogItem() = TrackingLogItem(
- id = trackLog.id,
- chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
- manga = manga.toManga(tags.toMangaTags()),
- createdAt = Date(trackLog.createdAt)
-)
-
// Model to entity
fun Manga.toEntity() = MangaEntity(
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt
index d3b64295a..e2c474399 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt
@@ -6,7 +6,7 @@ import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.TABLE_MANGA
@Entity(tableName = TABLE_MANGA)
-class MangaEntity(
+data class MangaEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val id: Long,
@ColumnInfo(name = "title") val title: String,
@@ -19,5 +19,5 @@ class MangaEntity(
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
@ColumnInfo(name = "state") val state: String?,
@ColumnInfo(name = "author") val author: String?,
- @ColumnInfo(name = "source") val source: String
-)
\ No newline at end of file
+ @ColumnInfo(name = "source") val source: String,
+)
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt
index 8c35c376e..dd814dace 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt
@@ -12,4 +12,23 @@ class MangaWithTags(
associateBy = Junction(MangaTagsEntity::class)
)
val tags: List,
-)
\ No newline at end of file
+) {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as MangaWithTags
+
+ if (manga != other.manga) return false
+ if (tags != other.tags) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = manga.hashCode()
+ result = 31 * result + tags.hashCode()
+ return result
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt
index 23f61d6d8..6c7907da6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt
@@ -6,10 +6,10 @@ import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.TABLE_TAGS
@Entity(tableName = TABLE_TAGS)
-class TagEntity(
+data class TagEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "tag_id") val id: Long,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "key") val key: String,
- @ColumnInfo(name = "source") val source: String
-)
\ No newline at end of file
+ @ColumnInfo(name = "source") val source: String,
+)
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogWithManga.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogWithManga.kt
deleted file mode 100644
index 7a6e145a4..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogWithManga.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package org.koitharu.kotatsu.core.db.entity
-
-import androidx.room.Embedded
-import androidx.room.Junction
-import androidx.room.Relation
-
-class TrackLogWithManga(
- @Embedded val trackLog: TrackLogEntity,
- @Relation(
- parentColumn = "manga_id",
- entityColumn = "manga_id"
- )
- val manga: MangaEntity,
- @Relation(
- parentColumn = "manga_id",
- entityColumn = "tag_id",
- associateBy = Junction(MangaTagsEntity::class)
- )
- val tags: List,
-)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt
index 18704cbce..ae82764b2 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt
@@ -6,8 +6,22 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration11To12 : Migration(11, 12) {
override fun migrate(database: SupportSQLiteDatabase) {
- database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
- database.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
- database.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
+ database.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()
+ )
+ database.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
+ database.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration12To13.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration12To13.kt
new file mode 100644
index 000000000..9f8fa723a
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration12To13.kt
@@ -0,0 +1,12 @@
+package org.koitharu.kotatsu.core.db.migrations
+
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+
+class Migration12To13 : Migration(12, 13) {
+
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `show_in_lib` INTEGER NOT NULL DEFAULT 1")
+ database.execSQL("ALTER TABLE favourites ADD COLUMN `sort_key` INTEGER NOT NULL DEFAULT 0")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration13To14.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration13To14.kt
new file mode 100644
index 000000000..204e20843
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration13To14.kt
@@ -0,0 +1,13 @@
+package org.koitharu.kotatsu.core.db.migrations
+
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+
+class Migration13To14 : Migration(13, 14) {
+
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
+ database.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
+ database.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt
index 5a8cd055c..ef20b4fb0 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CloudFlareProtectedException.kt
@@ -1,8 +1,6 @@
package org.koitharu.kotatsu.core.exceptions
-import androidx.annotation.StringRes
import okio.IOException
-import org.koitharu.kotatsu.R
class CloudFlareProtectedException(
val url: String
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CompositeException.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CompositeException.kt
new file mode 100644
index 000000000..e8c554107
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/CompositeException.kt
@@ -0,0 +1,8 @@
+package org.koitharu.kotatsu.core.exceptions
+
+import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
+
+class CompositeException(val errors: Collection) : Exception() {
+
+ override val message: String = errors.mapNotNullToSet { it.message }.joinToString()
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/github/GithubRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/GithubRepository.kt
index 8b9f4e793..0176823db 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/github/GithubRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/github/GithubRepository.kt
@@ -10,7 +10,7 @@ class GithubRepository(private val okHttp: OkHttpClient) {
suspend fun getLatestVersion(): AppVersion {
val request = Request.Builder()
.get()
- .url("https://api.github.com/repos/nv95/Kotatsu/releases/latest")
+ .url("https://api.github.com/repos/KotatsuApp/Kotatsu/releases/latest")
val json = okHttp.newCall(request.build()).await().parseJson()
val asset = json.getJSONArray("assets").getJSONObject(0)
return AppVersion(
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt
index 798ec2fbd..307cb50cd 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt
@@ -1,9 +1,9 @@
package org.koitharu.kotatsu.core.model
import android.os.Parcelable
-import java.util.*
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.parsers.model.SortOrder
+import java.util.*
@Parcelize
data class FavouriteCategory(
@@ -13,4 +13,5 @@ data class FavouriteCategory(
val order: SortOrder,
val createdAt: Date,
val isTrackingEnabled: Boolean,
+ val isVisibleInLibrary: Boolean,
) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt
index 95f736f2b..9ac085183 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt
@@ -11,4 +11,5 @@ data class MangaHistory(
val chapterId: Long,
val page: Int,
val scroll: Int,
+ val percent: Float,
) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt
new file mode 100644
index 000000000..9bd4ef5cf
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt
@@ -0,0 +1,10 @@
+package org.koitharu.kotatsu.core.model
+
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.toTitleCase
+import java.util.*
+
+fun MangaSource.getLocaleTitle(): String? {
+ val lc = Locale(locale ?: return null)
+ return lc.getDisplayLanguage(lc).toTitleCase(lc)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaTracking.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaTracking.kt
deleted file mode 100644
index 77fdc5925..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaTracking.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package org.koitharu.kotatsu.core.model
-
-import android.os.Parcelable
-import kotlinx.parcelize.Parcelize
-import org.koitharu.kotatsu.parsers.model.Manga
-import java.util.*
-
-data class MangaTracking(
- val manga: Manga,
- val knownChaptersCount: Int,
- val lastChapterId: Long,
- val lastNotifiedChapterId: Long,
- val lastCheck: Date?
-)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaChapters.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaChapters.kt
index db9ebb9c7..473b45320 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaChapters.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaChapters.kt
@@ -3,14 +3,13 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import org.koitharu.kotatsu.parsers.model.MangaChapter
-import org.koitharu.kotatsu.utils.ext.createList
class ParcelableMangaChapters(
val chapters: List,
) : Parcelable {
constructor(parcel: Parcel) : this(
- createList(parcel.readInt()) { parcel.readMangaChapter() }
+ List(parcel.readInt()) { parcel.readMangaChapter() }
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaPages.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaPages.kt
index 4717132f5..3230ec59b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaPages.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaPages.kt
@@ -3,14 +3,13 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import org.koitharu.kotatsu.parsers.model.MangaPage
-import org.koitharu.kotatsu.utils.ext.createList
class ParcelableMangaPages(
val pages: List,
) : Parcelable {
constructor(parcel: Parcel) : this(
- createList(parcel.readInt()) { parcel.readMangaPage() }
+ List(parcel.readInt()) { parcel.readMangaPage() }
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaTags.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaTags.kt
index 0ef0f74e0..bd5490e0a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaTags.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaTags.kt
@@ -3,14 +3,14 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import org.koitharu.kotatsu.parsers.model.MangaTag
-import org.koitharu.kotatsu.utils.ext.createSet
+import org.koitharu.kotatsu.utils.ext.Set
class ParcelableMangaTags(
val tags: Set,
) : Parcelable {
constructor(parcel: Parcel) : this(
- createSet(parcel.readInt()) { parcel.readMangaTag() }
+ Set(parcel.readInt()) { parcel.readMangaTag() }
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt
index d9c8281d7..a32a94c83 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/network/CloudFlareInterceptor.kt
@@ -17,7 +17,7 @@ class CloudFlareInterceptor : Interceptor {
if (response.code == HTTP_FORBIDDEN || response.code == HTTP_UNAVAILABLE) {
if (response.header(HEADER_SERVER)?.startsWith(SERVER_CLOUDFLARE) == true) {
response.closeQuietly()
- throw CloudFlareProtectedException(chain.request().url.toString())
+ throw CloudFlareProtectedException(response.request.url.toString())
}
}
return response
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt
index de4404f1a..9b27ccfc2 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt
@@ -10,6 +10,7 @@ object CommonHeaders {
const val CONTENT_DISPOSITION = "Content-Disposition"
const val COOKIE = "Cookie"
const val CONTENT_ENCODING = "Content-Encoding"
+ const val AUTHORIZATION = "Authorization"
val CACHE_CONTROL_DISABLED: CacheControl
get() = CacheControl.Builder().noStore().build()
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/GZipInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/GZipInterceptor.kt
new file mode 100644
index 000000000..1e53a4e7e
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/network/GZipInterceptor.kt
@@ -0,0 +1,14 @@
+package org.koitharu.kotatsu.core.network
+
+import okhttp3.Interceptor
+import okhttp3.Response
+import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING
+
+class GZipInterceptor : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val newRequest = chain.request().newBuilder()
+ newRequest.addHeader(CONTENT_ENCODING, "gzip")
+ return chain.proceed(newRequest.build())
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt
index 2af4c215e..1eb6a1021 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt
@@ -21,9 +21,10 @@ val networkModule
cookieJar(get())
dns(DoHManager(cache, get()))
cache(cache)
+ addInterceptor(GZipInterceptor())
addInterceptor(UserAgentInterceptor())
addInterceptor(CloudFlareInterceptor())
}.build()
}
single { MangaLoaderContextImpl(get(), get(), get()) }
- }
\ No newline at end of file
+ }
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsUpdater.kt
similarity index 67%
rename from app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsRepository.kt
rename to app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsUpdater.kt
index 9e8b18a52..a201295c5 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsUpdater.kt
@@ -6,38 +6,42 @@ import android.content.pm.ShortcutManager
import android.media.ThumbnailUtils
import android.os.Build
import android.util.Size
+import androidx.annotation.VisibleForTesting
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
+import androidx.room.InvalidationTracker
import coil.ImageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository
+import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity
+import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
+import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.requireBitmap
-class ShortcutsRepository(
+class ShortcutsUpdater(
private val context: Context,
private val coil: ImageLoader,
private val historyRepository: HistoryRepository,
private val mangaRepository: MangaDataRepository,
-) {
+) : InvalidationTracker.Observer(TABLE_HISTORY) {
- private val iconSize by lazy {
- getIconSize(context)
- }
+ private val iconSize by lazy { getIconSize(context) }
+ private var shortcutsUpdateJob: Job? = null
- suspend fun updateShortcuts() {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) return
- val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
- val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity)
- .filter { x -> x.title.isNotEmpty() }
- .map { buildShortcutInfo(it).build().toShortcutInfo() }
- manager.dynamicShortcuts = shortcuts
+ override fun onInvalidated(tables: MutableSet) {
+ val prevJob = shortcutsUpdateJob
+ shortcutsUpdateJob = processLifecycleScope.launch(Dispatchers.Default) {
+ prevJob?.join()
+ updateShortcutsImpl()
+ }
}
suspend fun requestPinShortcut(manga: Manga): Boolean {
@@ -48,17 +52,30 @@ class ShortcutsRepository(
)
}
+ @VisibleForTesting
+ suspend fun await(): Boolean {
+ return shortcutsUpdateJob?.join() != null
+ }
+
+ private suspend fun updateShortcutsImpl() = runCatching {
+ val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
+ val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity)
+ .filter { x -> x.title.isNotEmpty() }
+ .map { buildShortcutInfo(it).build().toShortcutInfo() }
+ manager.dynamicShortcuts = shortcuts
+ }.onFailure {
+ it.printStackTraceDebug()
+ }
+
private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat.Builder {
val icon = runCatching {
- withContext(Dispatchers.IO) {
- val bmp = coil.execute(
- ImageRequest.Builder(context)
- .data(manga.coverUrl)
- .size(iconSize.width, iconSize.height)
- .build()
- ).requireBitmap()
- ThumbnailUtils.extractThumbnail(bmp, iconSize.width, iconSize.height, 0)
- }
+ val bmp = coil.execute(
+ ImageRequest.Builder(context)
+ .data(manga.coverUrl)
+ .size(iconSize.width, iconSize.height)
+ .build()
+ ).requireBitmap()
+ ThumbnailUtils.extractThumbnail(bmp, iconSize.width, iconSize.height, 0)
}.fold(
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) }
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt
deleted file mode 100644
index ba5412a50..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package org.koitharu.kotatsu.core.parser
-
-import android.net.Uri
-import coil.map.Mapper
-import coil.request.Options
-import okhttp3.HttpUrl
-import okhttp3.HttpUrl.Companion.toHttpUrl
-import org.koitharu.kotatsu.parsers.model.MangaSource
-
-class FaviconMapper : Mapper {
-
- override fun map(data: Uri, options: Options): HttpUrl? {
- if (data.scheme != "favicon") {
- return null
- }
- val mangaSource = MangaSource.valueOf(data.schemeSpecificPart)
- val repo = MangaRepository(mangaSource) as RemoteMangaRepository
- return repo.getFaviconUrl().toHttpUrl()
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt
index ca4cc495b..90c84d5a8 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt
@@ -13,12 +13,9 @@ interface MangaRepository {
val sortOrders: Set
- suspend fun getList(
- offset: Int,
- query: String? = null,
- tags: Set? = null,
- sortOrder: SortOrder? = null,
- ): List
+ suspend fun getList(offset: Int, query: String): List
+
+ suspend fun getList(offset: Int, tags: Set?, sortOrder: SortOrder?): List
suspend fun getDetails(manga: Manga): Manga
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt
index 14a113e24..f98634436 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt
@@ -20,12 +20,13 @@ class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository {
getConfig().defaultSortOrder = value
}
- override suspend fun getList(
- offset: Int,
- query: String?,
- tags: Set?,
- sortOrder: SortOrder?,
- ): List = parser.getList(offset, query, tags, sortOrder)
+ override suspend fun getList(offset: Int, query: String): List {
+ return parser.getList(offset, query)
+ }
+
+ override suspend fun getList(offset: Int, tags: Set?, sortOrder: SortOrder?): List {
+ return parser.getList(offset, tags, sortOrder)
+ }
override suspend fun getDetails(manga: Manga): Manga = parser.getDetails(manga)
@@ -35,7 +36,7 @@ class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository {
override suspend fun getTags(): Set = parser.getTags()
- fun getFaviconUrl(): String = parser.getFaviconUrl()
+ suspend fun getFavicons(): Favicons = parser.getFavicons()
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt
new file mode 100644
index 000000000..8162d16ac
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt
@@ -0,0 +1,159 @@
+package org.koitharu.kotatsu.core.parser.favicon
+
+import android.content.Context
+import android.net.Uri
+import android.webkit.MimeTypeMap
+import coil.ImageLoader
+import coil.decode.DataSource
+import coil.decode.ImageSource
+import coil.disk.DiskCache
+import coil.fetch.FetchResult
+import coil.fetch.Fetcher
+import coil.fetch.SourceResult
+import coil.network.HttpException
+import coil.request.Options
+import coil.size.Size
+import coil.size.pxOrElse
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.ResponseBody
+import okhttp3.internal.closeQuietly
+import org.koitharu.kotatsu.core.network.CommonHeaders
+import org.koitharu.kotatsu.core.parser.MangaRepository
+import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
+import org.koitharu.kotatsu.local.data.CacheDir
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.await
+import java.net.HttpURLConnection
+
+private const val FALLBACK_SIZE = 9999 // largest icon
+
+class FaviconFetcher(
+ private val okHttpClient: OkHttpClient,
+ private val diskCache: Lazy,
+ private val mangaSource: MangaSource,
+ private val options: Options,
+) : Fetcher {
+
+ private val diskCacheKey
+ get() = options.diskCacheKey ?: "${mangaSource.name}[${mangaSource.ordinal}]x${options.size.toCacheKey()}"
+
+ private val fileSystem
+ get() = checkNotNull(diskCache.value).fileSystem
+
+ override suspend fun fetch(): FetchResult {
+ getCached(options)?.let { return it }
+ val repo = MangaRepository(mangaSource) as RemoteMangaRepository
+ val favicons = repo.getFavicons()
+ val sizePx = maxOf(
+ options.size.width.pxOrElse { FALLBACK_SIZE },
+ options.size.height.pxOrElse { FALLBACK_SIZE },
+ )
+ val icon = checkNotNull(favicons.find(sizePx)) { "No favicons found" }
+ val response = loadIcon(icon.url, favicons.referer)
+ val responseBody = response.requireBody()
+ val source = writeToDiskCache(responseBody)?.toImageSource() ?: responseBody.toImageSource()
+ return SourceResult(
+ source = source,
+ mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(icon.type),
+ dataSource = response.toDataSource(),
+ )
+ }
+
+ private suspend fun loadIcon(url: String, referer: String): Response {
+ val request = Request.Builder()
+ .url(url)
+ .get()
+ .header(CommonHeaders.REFERER, referer)
+ @Suppress("UNCHECKED_CAST")
+ options.tags.asMap().forEach { request.tag(it.key as Class, it.value) }
+ val response = okHttpClient.newCall(request.build()).await()
+ if (!response.isSuccessful && response.code != HttpURLConnection.HTTP_NOT_MODIFIED) {
+ response.body?.closeQuietly()
+ throw HttpException(response)
+ }
+ return response
+ }
+
+ private fun getCached(options: Options): SourceResult? {
+ if (!options.diskCachePolicy.readEnabled) {
+ return null
+ }
+ val snapshot = diskCache.value?.get(diskCacheKey) ?: return null
+ return SourceResult(
+ source = snapshot.toImageSource(),
+ mimeType = null,
+ dataSource = DataSource.DISK,
+ )
+ }
+
+ private fun writeToDiskCache(body: ResponseBody): DiskCache.Snapshot? {
+ if (!options.diskCachePolicy.writeEnabled || body.contentLength() == 0L) {
+ return null
+ }
+ val editor = diskCache.value?.edit(diskCacheKey) ?: return null
+ try {
+ fileSystem.write(editor.data) {
+ body.source().readAll(this)
+ }
+ return editor.commitAndGet()
+ } catch (e: Throwable) {
+ try {
+ editor.abort()
+ } catch (abortingError: Throwable) {
+ e.addSuppressed(abortingError)
+ }
+ body.closeQuietly()
+ throw e
+ } finally {
+ body.closeQuietly()
+ }
+ }
+
+ private fun DiskCache.Snapshot.toImageSource(): ImageSource {
+ return ImageSource(data, fileSystem, diskCacheKey, this)
+ }
+
+ private fun ResponseBody.toImageSource(): ImageSource {
+ return ImageSource(source(), options.context, FaviconMetadata(mangaSource))
+ }
+
+ private fun Response.toDataSource(): DataSource {
+ return if (networkResponse != null) DataSource.NETWORK else DataSource.DISK
+ }
+
+ private fun Response.requireBody(): ResponseBody {
+ return checkNotNull(body) { "response body == null" }
+ }
+
+ private fun Size.toCacheKey() = buildString {
+ append(width.toString())
+ append('x')
+ append(height.toString())
+ }
+
+ class Factory(
+ context: Context,
+ private val okHttpClient: OkHttpClient,
+ ) : Fetcher.Factory {
+
+ private val diskCache = lazy {
+ val rootDir = context.externalCacheDir ?: context.cacheDir
+ DiskCache.Builder()
+ .directory(rootDir.resolve(CacheDir.FAVICONS.dir))
+ .build()
+ }
+
+ override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
+ return if (data.scheme == URI_SCHEME_FAVICON) {
+ val mangaSource = MangaSource.valueOf(data.schemeSpecificPart)
+ FaviconFetcher(okHttpClient, diskCache, mangaSource, options)
+ } else {
+ null
+ }
+ }
+ }
+
+ class FaviconMetadata(val source: MangaSource) : ImageSource.Metadata()
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/favicon/FaviconUri.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/favicon/FaviconUri.kt
new file mode 100644
index 000000000..48f393325
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/favicon/FaviconUri.kt
@@ -0,0 +1,8 @@
+package org.koitharu.kotatsu.core.parser.favicon
+
+import android.net.Uri
+import org.koitharu.kotatsu.parsers.model.MangaSource
+
+const val URI_SCHEME_FAVICON = "favicon"
+
+fun MangaSource.faviconUri(): Uri = Uri.fromParts(URI_SCHEME_FAVICON, name, null)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
index 6b24b25e1..cbb330d6e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
@@ -4,21 +4,18 @@ import android.content.Context
import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.Uri
-import android.os.Build
import android.provider.Settings
import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.arraySetOf
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.channels.trySendBlocking
-import kotlinx.coroutines.flow.callbackFlow
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.getEnumValue
+import org.koitharu.kotatsu.utils.ext.observe
import org.koitharu.kotatsu.utils.ext.putEnumValue
import org.koitharu.kotatsu.utils.ext.toUriOrNull
import java.io.File
@@ -41,7 +38,7 @@ class AppSettings(context: Context) {
get() = Collections.unmodifiableSet(remoteSources)
var listMode: ListMode
- get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.DETAILED_LIST)
+ get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
var defaultSection: AppSection
@@ -52,7 +49,7 @@ class AppSettings(context: Context) {
get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
val isDynamicTheme: Boolean
- get() = prefs.getBoolean(KEY_DYNAMIC_THEME, false)
+ get() = DynamicColors.isDynamicColorAvailable() && prefs.getBoolean(KEY_DYNAMIC_THEME, false)
val isAmoledTheme: Boolean
get() = prefs.getBoolean(KEY_THEME_AMOLED, false)
@@ -105,13 +102,20 @@ class AppSettings(context: Context) {
val isReaderModeDetectionEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_MODE_DETECT, true)
- var historyGrouping: Boolean
+ var isHistoryGroupingEnabled: Boolean
get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true)
set(value) = prefs.edit { putBoolean(KEY_HISTORY_GROUPING, value) }
+ val isReadingIndicatorsEnabled: Boolean
+ get() = prefs.getBoolean(KEY_READING_INDICATORS, true)
+
val isHistoryExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_HISTORY_EXCLUDE_NSFW, false)
+ var isIncognitoModeEnabled: Boolean
+ get() = prefs.getBoolean(KEY_INCOGNITO_MODE, false)
+ set(value) = prefs.edit { putBoolean(KEY_INCOGNITO_MODE, value) }
+
var chaptersReverse: Boolean
get() = prefs.getBoolean(KEY_REVERSE_CHAPTERS, false)
set(value) = prefs.edit { putBoolean(KEY_REVERSE_CHAPTERS, value) }
@@ -126,6 +130,13 @@ class AppSettings(context: Context) {
get() = prefs.getString(KEY_APP_PASSWORD, null)
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
+ var isBiometricProtectionEnabled: Boolean
+ get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true)
+ set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
+
+ val isExitConfirmationEnabled: Boolean
+ get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false)
+
var sourcesOrder: List
get() = prefs.getString(KEY_SOURCES_ORDER, null)
?.split('|')
@@ -152,7 +163,7 @@ class AppSettings(context: Context) {
}
fun markKnownSources(sources: Collection) {
- sourcesOrder = sourcesOrder + sources.map { it.name }
+ sourcesOrder = (sourcesOrder + sources.map { it.name }).distinct()
}
val isPagesNumbersEnabled: Boolean
@@ -188,10 +199,6 @@ class AppSettings(context: Context) {
val isSuggestionsExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false)
- var isSearchSingleSource: Boolean
- get() = prefs.getBoolean(KEY_SEARCH_SINGLE_SOURCE, false)
- set(value) = prefs.edit { putBoolean(KEY_SEARCH_SINGLE_SOURCE, value) }
-
val dnsOverHttps: DoHProvider
get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE)
@@ -243,15 +250,7 @@ class AppSettings(context: Context) {
prefs.unregisterOnSharedPreferenceChangeListener(listener)
}
- fun observe() = callbackFlow {
- val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
- trySendBlocking(key)
- }
- prefs.registerOnSharedPreferenceChangeListener(listener)
- awaitClose {
- prefs.unregisterOnSharedPreferenceChangeListener(listener)
- }
- }
+ fun observe() = prefs.observe()
companion object {
@@ -294,11 +293,13 @@ class AppSettings(context: Context) {
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
const val KEY_APP_PASSWORD = "app_password"
const val KEY_PROTECT_APP = "protect_app"
+ const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio"
const val KEY_APP_VERSION = "app_version"
const val KEY_ZOOM_MODE = "zoom_mode"
const val KEY_BACKUP = "backup"
const val KEY_RESTORE = "restore"
const val KEY_HISTORY_GROUPING = "history_grouping"
+ const val KEY_READING_INDICATORS = "reading_indicators"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
const val KEY_PAGES_NUMBERS = "pages_numbers"
@@ -307,30 +308,22 @@ class AppSettings(context: Context) {
const val KEY_SUGGESTIONS = "suggestions"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
- const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
+ const val KEY_SHIKIMORI = "shikimori"
const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
const val KEY_DOH = "doh"
+ const val KEY_EXIT_CONFIRM = "exit_confirm"
+ const val KEY_INCOGNITO_MODE = "incognito"
const val KEY_SYNC = "sync"
// About
const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
const val KEY_APP_TRANSLATION = "about_app_translation"
- const val KEY_FEEDBACK_4PDA = "about_feedback_4pda"
- const val KEY_FEEDBACK_DISCORD = "about_feedback_discord"
- const val KEY_FEEDBACK_GITHUB = "about_feedback_github"
private const val NETWORK_NEVER = 0
private const val NETWORK_ALWAYS = 1
private const val NETWORK_NON_METERED = 2
-
- val isDynamicColorAvailable: Boolean
- get() = DynamicColors.isDynamicColorAvailable() ||
- (isSamsung && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
-
- private val isSamsung
- get() = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/AppCrashHandler.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/AppCrashHandler.kt
deleted file mode 100644
index fb3216cb2..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/core/ui/AppCrashHandler.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package org.koitharu.kotatsu.core.ui
-
-import android.content.Context
-import android.content.Intent
-import android.util.Log
-import kotlin.system.exitProcess
-import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
-
-class AppCrashHandler(private val applicationContext: Context) : Thread.UncaughtExceptionHandler {
-
- override fun uncaughtException(t: Thread, e: Throwable) {
- val intent = CrashActivity.newIntent(applicationContext, e)
- intent.flags = (Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
- try {
- applicationContext.startActivity(intent)
- } catch (t: Throwable) {
- t.printStackTraceDebug()
- }
- Log.e("CRASH", e.message, e)
- exitProcess(1)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/CrashActivity.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/CrashActivity.kt
deleted file mode 100644
index 7d4d31878..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/core/ui/CrashActivity.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-package org.koitharu.kotatsu.core.ui
-
-import android.app.Activity
-import android.content.ActivityNotFoundException
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.databinding.ActivityCrashBinding
-import org.koitharu.kotatsu.main.ui.MainActivity
-import org.koitharu.kotatsu.parsers.util.ellipsize
-import org.koitharu.kotatsu.utils.ShareHelper
-
-class CrashActivity : Activity(), View.OnClickListener {
-
- private lateinit var binding: ActivityCrashBinding
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- binding = ActivityCrashBinding.inflate(layoutInflater)
- setContentView(binding.root)
- binding.textView.text = intent.getStringExtra(Intent.EXTRA_TEXT)
- binding.buttonClose.setOnClickListener(this)
- binding.buttonRestart.setOnClickListener(this)
- binding.buttonReport.setOnClickListener(this)
- }
-
- override fun onCreateOptionsMenu(menu: Menu?): Boolean {
- menuInflater.inflate(R.menu.opt_crash, menu)
- return super.onCreateOptionsMenu(menu)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- R.id.action_share -> {
- ShareHelper(this).shareText(binding.textView.text.toString())
- }
- else -> return super.onOptionsItemSelected(item)
- }
- return true
- }
-
- override fun onClick(v: View) {
- when (v.id) {
- R.id.button_close -> {
- finish()
- }
- R.id.button_restart -> {
- val intent = Intent(applicationContext, MainActivity::class.java)
- intent.flags = (Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
- startActivity(intent)
- finish()
- }
- R.id.button_report -> {
- val intent = Intent(Intent.ACTION_VIEW)
- intent.data = Uri.parse("https://github.com/nv95/Kotatsu/issues")
- try {
- startActivity(Intent.createChooser(intent, getString(R.string.report_github)))
- } catch (_: ActivityNotFoundException) {
- }
- }
- }
- }
-
- companion object {
-
- private const val MAX_TRACE_SIZE = 131071
-
- fun newIntent(context: Context, error: Throwable): Intent {
- val crashInfo = error
- .stackTraceToString()
- .trimIndent()
- .ellipsize(MAX_TRACE_SIZE)
- val intent = Intent(context, CrashActivity::class.java)
- intent.putExtra(Intent.EXTRA_TEXT, crashInfo)
- return intent
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt
index 03bafa077..614c9d3b7 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt
@@ -15,6 +15,8 @@ sealed class DateTimeAgo : ListModel {
override fun format(resources: Resources): String {
return resources.getString(R.string.just_now)
}
+
+ override fun toString() = "just_now"
}
class MinutesAgo(val minutes: Int) : DateTimeAgo() {
@@ -31,6 +33,8 @@ sealed class DateTimeAgo : ListModel {
}
override fun hashCode(): Int = minutes
+
+ override fun toString() = "minutes_ago_$minutes"
}
class HoursAgo(val hours: Int) : DateTimeAgo() {
@@ -46,18 +50,24 @@ sealed class DateTimeAgo : ListModel {
}
override fun hashCode(): Int = hours
+
+ override fun toString() = "hours_ago_$hours"
}
object Today : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getString(R.string.today)
}
+
+ override fun toString() = "today"
}
object Yesterday : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getString(R.string.yesterday)
}
+
+ override fun toString() = "yesterday"
}
class DaysAgo(val days: Int) : DateTimeAgo() {
@@ -73,6 +83,8 @@ sealed class DateTimeAgo : ListModel {
}
override fun hashCode(): Int = days
+
+ override fun toString() = "days_ago_$days"
}
class Absolute(private val date: Date) : DateTimeAgo() {
@@ -97,11 +109,15 @@ sealed class DateTimeAgo : ListModel {
override fun hashCode(): Int {
return day
}
+
+ override fun toString() = "abs_$day"
}
object LongAgo : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getString(R.string.long_ago)
}
+
+ override fun toString() = "long_ago"
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt
index 54529b37c..8505ab567 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt
@@ -1,15 +1,21 @@
package org.koitharu.kotatsu.core.ui
+import android.text.Html
import coil.ComponentRegistry
import coil.ImageLoader
+import coil.decode.SvgDecoder
import coil.disk.DiskCache
+import coil.util.DebugLogger
import kotlinx.coroutines.Dispatchers
import okhttp3.OkHttpClient
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
-import org.koitharu.kotatsu.core.parser.FaviconMapper
+import org.koitharu.kotatsu.BuildConfig
+import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher
+import org.koitharu.kotatsu.utils.ext.isLowRamDevice
+import org.koitharu.kotatsu.utils.image.CoilImageGetter
val uiModule
get() = module {
@@ -29,12 +35,19 @@ val uiModule
ImageLoader.Builder(androidContext())
.okHttpClient(httpClientFactory)
.interceptorDispatcher(Dispatchers.Default)
+ .fetcherDispatcher(Dispatchers.IO)
+ .decoderDispatcher(Dispatchers.Default)
+ .transformationDispatcher(Dispatchers.Default)
.diskCache(diskCacheFactory)
+ .logger(if (BuildConfig.DEBUG) DebugLogger() else null)
+ .allowRgb565(isLowRamDevice(androidContext()))
.components(
ComponentRegistry.Builder()
+ .add(SvgDecoder.Factory())
.add(CbzFetcher.Factory())
- .add(FaviconMapper())
+ .add(FaviconFetcher.Factory(androidContext(), get()))
.build()
).build()
}
+ factory { CoilImageGetter(androidContext(), get()) }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt
index 916b75de1..5091ee0e0 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/DetailsModule.kt
@@ -8,6 +8,6 @@ val detailsModule
get() = module {
viewModel { intent ->
- DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get())
+ DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get(), get(), get())
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt
index a9a57e30e..c0e5328ac 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt
@@ -1,20 +1,21 @@
package org.koitharu.kotatsu.details.ui
-import android.app.ActivityOptions
import android.os.Bundle
import android.view.*
import android.widget.AdapterView
import android.widget.Spinner
-import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.SearchView
import androidx.core.graphics.Insets
+import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
+import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
+import org.koitharu.kotatsu.base.ui.list.ListSelectionController
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
@@ -27,26 +28,22 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback
+import org.koitharu.kotatsu.utils.ext.addMenuProvider
+import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import kotlin.math.roundToInt
class ChaptersFragment :
BaseFragment(),
OnListItemClickListener,
- ActionMode.Callback,
AdapterView.OnItemSelectedListener,
MenuItem.OnActionExpandListener,
- SearchView.OnQueryTextListener {
+ SearchView.OnQueryTextListener,
+ ListSelectionController.Callback {
private val viewModel by sharedViewModel()
private var chaptersAdapter: ChaptersAdapter? = null
- private var actionMode: ActionMode? = null
- private var selectionDecoration: ChaptersSelectionDecoration? = null
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
+ private var selectionController: ListSelectionController? = null
override fun onInflateView(
inflater: LayoutInflater,
@@ -56,9 +53,14 @@ class ChaptersFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
chaptersAdapter = ChaptersAdapter(this)
- selectionDecoration = ChaptersSelectionDecoration(view.context)
+ selectionController = ListSelectionController(
+ activity = requireActivity(),
+ decoration = ChaptersSelectionDecoration(view.context),
+ registryOwner = this,
+ callback = this,
+ )
with(binding.recyclerViewChapters) {
- addItemDecoration(selectionDecoration!!)
+ checkNotNull(selectionController).attachToRecyclerView(this)
setHasFixedSize(true)
adapter = chaptersAdapter
}
@@ -72,75 +74,36 @@ class ChaptersFragment :
binding.textViewHolder.isVisible = it
activity?.invalidateOptionsMenu()
}
+ addMenuProvider(ChaptersMenuProvider())
}
override fun onDestroyView() {
chaptersAdapter = null
- selectionDecoration = null
+ selectionController = null
binding.spinnerBranches?.adapter = null
super.onDestroyView()
}
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- super.onCreateOptionsMenu(menu, inflater)
- inflater.inflate(R.menu.opt_chapters, menu)
- val searchMenuItem = menu.findItem(R.id.action_search)
- searchMenuItem.setOnActionExpandListener(this)
- val searchView = searchMenuItem.actionView as SearchView
- searchView.setOnQueryTextListener(this)
- searchView.setIconifiedByDefault(false)
- searchView.queryHint = searchMenuItem.title
- }
-
- override fun onPrepareOptionsMenu(menu: Menu) {
- super.onPrepareOptionsMenu(menu)
- menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true
- menu.findItem(R.id.action_search).isVisible = viewModel.isChaptersEmpty.value == false
- }
-
- override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
- R.id.action_reversed -> {
- viewModel.setChaptersReversed(!item.isChecked)
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
-
override fun onItemClick(item: ChapterListItem, view: View) {
- if (selectionDecoration?.checkedItemsCount != 0) {
- selectionDecoration?.toggleItemChecked(item.chapter.id)
- if (selectionDecoration?.checkedItemsCount == 0) {
- actionMode?.finish()
- } else {
- actionMode?.invalidate()
- binding.recyclerViewChapters.invalidateItemDecorations()
- }
+ if (selectionController?.onItemClick(item.chapter.id) == true) {
return
}
if (item.hasFlag(ChapterListItem.FLAG_MISSING)) {
(activity as? DetailsActivity)?.showChapterMissingDialog(item.chapter.id)
return
}
- val options = ActivityOptions.makeScaleUpAnimation(view, 0, 0, view.width, view.height)
startActivity(
ReaderActivity.newIntent(
context = view.context,
manga = viewModel.manga.value ?: return,
state = ReaderState(item.chapter.id, 0, 0),
),
- options.toBundle()
+ scaleUpActivityOptionsOf(view).toBundle()
)
}
override fun onItemLongClick(item: ChapterListItem, view: View): Boolean {
- if (actionMode == null) {
- actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
- }
- return actionMode?.also {
- selectionDecoration?.setItemIsChecked(item.chapter.id, true)
- binding.recyclerViewChapters.invalidateItemDecorations()
- it.invalidate()
- } != null
+ return selectionController?.onItemLongClick(item.chapter.id) ?: false
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
@@ -149,13 +112,13 @@ class ChaptersFragment :
DownloadService.start(
context ?: return false,
viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
- selectionDecoration?.checkedItemsIds?.toSet()
+ selectionController?.snapshot(),
)
mode.finish()
true
}
R.id.action_delete -> {
- val ids = selectionDecoration?.checkedItemsIds
+ val ids = selectionController?.peekCheckedIds()
val manga = viewModel.manga.value
when {
ids.isNullOrEmpty() || manga == null -> Unit
@@ -174,9 +137,7 @@ class ChaptersFragment :
}
R.id.action_select_all -> {
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
- selectionDecoration?.checkAll(ids)
- binding.recyclerViewChapters.invalidateItemDecorations()
- mode.invalidate()
+ selectionController?.addAll(ids)
true
}
else -> false
@@ -196,7 +157,7 @@ class ChaptersFragment :
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
- val selectedIds = selectionDecoration?.checkedItemsIds ?: return false
+ val selectedIds = selectionController?.peekCheckedIds() ?: return false
val items = chaptersAdapter?.items?.filter { x -> x.chapter.id in selectedIds }.orEmpty()
menu.findItem(R.id.action_save).isVisible = items.none { x ->
x.chapter.source == MangaSource.LOCAL
@@ -208,10 +169,8 @@ class ChaptersFragment :
return true
}
- override fun onDestroyActionMode(mode: ActionMode?) {
- selectionDecoration?.clearSelection()
+ override fun onSelectionChanged(count: Int) {
binding.recyclerViewChapters.invalidateItemDecorations()
- actionMode = null
}
override fun onMenuItemActionExpand(item: MenuItem?): Boolean = true
@@ -233,6 +192,9 @@ class ChaptersFragment :
binding.recyclerViewChapters.updatePadding(
bottom = insets.bottom + (binding.spinnerBranches?.height ?: 0),
)
+ binding.recyclerViewChapters.fastScroller.updateLayoutParams {
+ bottomMargin = insets.bottom
+ }
}
private fun initSpinner(spinner: Spinner) {
@@ -268,4 +230,30 @@ class ChaptersFragment :
private fun onLoadingStateChanged(isLoading: Boolean) {
binding.progressBar.isVisible = isLoading
}
+
+ private inner class ChaptersMenuProvider : MenuProvider {
+
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.opt_chapters, menu)
+ val searchMenuItem = menu.findItem(R.id.action_search)
+ searchMenuItem.setOnActionExpandListener(this@ChaptersFragment)
+ val searchView = searchMenuItem.actionView as SearchView
+ searchView.setOnQueryTextListener(this@ChaptersFragment)
+ searchView.setIconifiedByDefault(false)
+ searchView.queryHint = searchMenuItem.title
+ }
+
+ override fun onPrepareMenu(menu: Menu) {
+ menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true
+ menu.findItem(R.id.action_search).isVisible = viewModel.isChaptersEmpty.value == false
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
+ R.id.action_reversed -> {
+ viewModel.setChaptersReversed(!menuItem.isChecked)
+ true
+ }
+ else -> false
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
index f6ecc6f0d..e821d08dc 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
@@ -15,14 +15,13 @@ import android.widget.Toast
import androidx.appcompat.view.ActionMode
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.Insets
-import androidx.core.net.toFile
-import androidx.core.net.toUri
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.launch
@@ -35,7 +34,7 @@ import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
-import org.koitharu.kotatsu.core.os.ShortcutsRepository
+import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
import org.koitharu.kotatsu.download.ui.service.DownloadService
@@ -44,9 +43,11 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
-import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
-import org.koitharu.kotatsu.utils.ShareHelper
+import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet
+import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
+import org.koitharu.kotatsu.utils.ext.isReportable
+import org.koitharu.kotatsu.utils.ext.report
class DetailsActivity :
BaseActivity(),
@@ -84,7 +85,7 @@ class DetailsActivity :
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
viewModel.onError.observe(this, ::onError)
viewModel.onShowToast.observe(this) {
- binding.snackbar.show(messageText = getString(it), longDuration = false)
+ binding.snackbar.show(messageText = getString(it))
}
registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
@@ -117,6 +118,21 @@ class DetailsActivity :
Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
finishAfterTransition()
}
+ e.isReportable() -> {
+ binding.snackbar.show(
+ messageText = e.getDisplayMessage(resources),
+ actionId = R.string.report,
+ duration = if (viewModel.manga.value?.chapters == null) {
+ Snackbar.LENGTH_INDEFINITE
+ } else {
+ Snackbar.LENGTH_LONG
+ },
+ onActionClick = {
+ e.report("DetailsActivity::onError")
+ dismiss()
+ }
+ )
+ }
else -> {
binding.snackbar.show(e.getDisplayMessage(resources))
}
@@ -127,9 +143,6 @@ class DetailsActivity :
binding.snackbar.updatePadding(
bottom = insets.bottom
)
- binding.toolbar.updateLayoutParams {
- topMargin = insets.top
- }
binding.root.updatePadding(
left = insets.left,
right = insets.right
@@ -148,34 +161,22 @@ class DetailsActivity :
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.opt_details, menu)
- return super.onCreateOptionsMenu(menu)
+ return true
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
val manga = viewModel.manga.value
- menu.findItem(R.id.action_save).isVisible =
- manga?.source != null && manga.source != MangaSource.LOCAL
- menu.findItem(R.id.action_delete).isVisible =
- manga?.source == MangaSource.LOCAL
- menu.findItem(R.id.action_browser).isVisible =
- manga?.source != MangaSource.LOCAL
- menu.findItem(R.id.action_shortcut).isVisible =
- ShortcutManagerCompat.isRequestPinShortcutSupported(this)
+ menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != MangaSource.LOCAL
+ menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL
+ menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL
+ menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(this)
+ menu.findItem(R.id.action_shiki_track).isVisible = viewModel.isScrobblingAvailable
return super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
- R.id.action_share -> {
- viewModel.manga.value?.let {
- if (it.source == MangaSource.LOCAL) {
- ShareHelper(this).shareCbz(listOf(it.url.toUri().toFile()))
- } else {
- ShareHelper(this).shareMangaLink(it)
- }
- }
- true
- }
R.id.action_delete -> {
val title = viewModel.manga.value?.title.orEmpty()
MaterialAlertDialogBuilder(this)
@@ -208,14 +209,20 @@ class DetailsActivity :
}
R.id.action_related -> {
viewModel.manga.value?.let {
- startActivity(GlobalSearchActivity.newIntent(this, it.title))
+ startActivity(MultiSearchActivity.newIntent(this, it.title))
+ }
+ true
+ }
+ R.id.action_shiki_track -> {
+ viewModel.manga.value?.let {
+ ScrobblingSelectorBottomSheet.show(supportFragmentManager, it)
}
true
}
R.id.action_shortcut -> {
viewModel.manga.value?.let {
lifecycleScope.launch {
- if (!get().requestPinShortcut(it)) {
+ if (!get().requestPinShortcut(it)) {
binding.snackbar.show(getString(R.string.operation_not_supported))
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt
index 54ae95a5b..5599762ae 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt
@@ -1,15 +1,14 @@
package org.koitharu.kotatsu.details.ui
-import android.app.ActivityOptions
import android.os.Bundle
-import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.view.*
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
+import androidx.core.net.toFile
import androidx.core.net.toUri
-import androidx.core.text.parseAsHtml
+import androidx.core.view.MenuProvider
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
@@ -26,10 +25,12 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
-import org.koitharu.kotatsu.bookmarks.ui.BookmarksAdapter
+import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
+import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingInfoBottomSheet
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
+import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -37,9 +38,11 @@ import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.utils.FileSize
+import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.*
class DetailsFragment :
@@ -52,11 +55,6 @@ class DetailsFragment :
private val viewModel by sharedViewModel()
private val coil by inject(mode = LazyThreadSafetyMode.NONE)
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
-
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -69,6 +67,7 @@ class DetailsFragment :
binding.buttonRead.setOnClickListener(this)
binding.buttonRead.setOnLongClickListener(this)
binding.imageViewCover.setOnClickListener(this)
+ binding.scrobblingLayout.root.setOnClickListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.chipsTags.onChipClickListener = this
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
@@ -76,16 +75,16 @@ class DetailsFragment :
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged)
viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
- }
-
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- super.onCreateOptionsMenu(menu, inflater)
- inflater.inflate(R.menu.opt_details_info, menu)
+ viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
+ viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
+ addMenuProvider(DetailsMenuProvider())
}
override fun onItemClick(item: Bookmark, view: View) {
- val options = ActivityOptions.makeScaleUpAnimation(view, 0, 0, view.width, view.height)
- startActivity(ReaderActivity.newIntent(view.context, item), options.toBundle())
+ startActivity(
+ ReaderActivity.newIntent(view.context, item),
+ scaleUpActivityOptionsOf(view).toBundle(),
+ )
}
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
@@ -108,8 +107,6 @@ class DetailsFragment :
textViewTitle.text = manga.title
textViewSubtitle.textAndVisible = manga.altTitle
textViewAuthor.textAndVisible = manga.author
- textViewDescription.text = manga.description?.parseAsHtml()?.takeUnless(Spanned::isBlank)
- ?: getString(R.string.no_description)
when (manga.state) {
MangaState.FINISHED -> {
textViewState.apply {
@@ -172,6 +169,14 @@ class DetailsFragment :
}
}
+ private fun onDescriptionChanged(description: CharSequence?) {
+ if (description.isNullOrBlank()) {
+ binding.textViewDescription.setText(R.string.no_description)
+ } else {
+ binding.textViewDescription.text = description
+ }
+ }
+
private fun onHistoryChanged(history: MangaHistory?) {
with(binding.buttonRead) {
if (history == null) {
@@ -182,6 +187,7 @@ class DetailsFragment :
setIconResource(R.drawable.ic_play)
}
}
+ binding.progressView.setPercent(history?.percent ?: PROGRESS_NONE, animate = true)
}
private fun onFavouriteChanged(isFavourite: Boolean) {
@@ -215,12 +221,38 @@ class DetailsFragment :
}
}
+ private fun onScrobblingInfoChanged(scrobbling: ScrobblingInfo?) {
+ with(binding.scrobblingLayout) {
+ root.isVisible = scrobbling != null
+ if (scrobbling == null) {
+ CoilUtils.dispose(imageViewCover)
+ return
+ }
+ imageViewCover.newImageRequest(scrobbling.coverUrl)?.run {
+ placeholder(R.drawable.ic_placeholder)
+ fallback(R.drawable.ic_placeholder)
+ error(R.drawable.ic_placeholder)
+ lifecycle(viewLifecycleOwner)
+ enqueueWith(coil)
+ }
+ textViewTitle.text = scrobbling.title
+ textViewTitle.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, scrobbling.scrobbler.iconResId, 0)
+ ratingBar.rating = scrobbling.rating * ratingBar.numStars
+ textViewStatus.text = scrobbling.status?.let {
+ resources.getStringArray(R.array.scrobbling_statuses).getOrNull(it.ordinal)
+ }
+ }
+ }
+
override fun onClick(v: View) {
val manga = viewModel.manga.value ?: return
when (v.id) {
R.id.button_favorite -> {
FavouriteCategoriesBottomSheet.show(childFragmentManager, manga)
}
+ R.id.scrobbling_layout -> {
+ ScrobblingInfoBottomSheet.show(childFragmentManager)
+ }
R.id.button_read -> {
val chapterId = viewModel.readingHistory.value?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
@@ -245,10 +277,9 @@ class DetailsFragment :
)
}
R.id.imageView_cover -> {
- val options = ActivityOptions.makeScaleUpAnimation(v, 0, 0, v.width, v.height)
startActivity(
ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }),
- options.toBundle()
+ scaleUpActivityOptionsOf(v).toBundle()
)
}
}
@@ -296,7 +327,9 @@ class DetailsFragment :
override fun onWindowInsetsChanged(insets: Insets) {
binding.root.updatePadding(
- bottom = insets.bottom,
+ left = insets.left,
+ right = insets.right,
+ bottom = insets.bottom
)
}
@@ -307,6 +340,8 @@ class DetailsFragment :
title = tag.title,
icon = 0,
data = tag,
+ isCheckable = false,
+ isChecked = false,
)
}
)
@@ -321,7 +356,7 @@ class DetailsFragment :
val request = ImageRequest.Builder(context ?: return)
.target(binding.imageViewCover)
.data(imageUrl)
- .crossfade(true)
+ .crossfade(context)
.referer(manga.publicUrl)
.lifecycle(viewLifecycleOwner)
lastResult?.drawable?.let {
@@ -329,4 +364,26 @@ class DetailsFragment :
} ?: request.fallback(R.drawable.ic_placeholder)
request.enqueueWith(coil)
}
+
+ private inner class DetailsMenuProvider : MenuProvider {
+
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.opt_details_info, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
+ R.id.action_share -> {
+ viewModel.manga.value?.let {
+ val context = requireContext()
+ if (it.source == MangaSource.LOCAL) {
+ ShareHelper(context).shareCbz(listOf(it.url.toUri().toFile()))
+ } else {
+ ShareHelper(context).shareMangaLink(it)
+ }
+ }
+ true
+ }
+ else -> false
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt
index 382899c3a..6c46cc25e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt
@@ -1,8 +1,16 @@
package org.koitharu.kotatsu.details.ui
-import androidx.lifecycle.*
-import kotlinx.coroutines.*
+import android.text.Html
+import androidx.core.text.parseAsHtml
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.asFlow
+import androidx.lifecycle.asLiveData
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
@@ -19,6 +27,8 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
+import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@@ -30,10 +40,12 @@ class DetailsViewModel(
private val historyRepository: HistoryRepository,
favouritesRepository: FavouritesRepository,
private val localMangaRepository: LocalMangaRepository,
- private val trackingRepository: TrackingRepository,
+ trackingRepository: TrackingRepository,
mangaDataRepository: MangaDataRepository,
private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings,
+ private val scrobbler: Scrobbler,
+ private val imageGetter: Html.ImageGetter,
) : BaseViewModel() {
private val delegate = MangaDetailsDelegate(
@@ -54,9 +66,8 @@ class DetailsViewModel(
private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
- private val newChapters = viewModelScope.async(Dispatchers.Default) {
- trackingRepository.getNewChaptersCount(delegate.mangaId)
- }
+ private val newChapters = trackingRepository.observeNewChaptersCount(delegate.mangaId)
+ .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val chaptersQuery = MutableStateFlow("")
@@ -65,31 +76,51 @@ class DetailsViewModel(
val manga = delegate.manga.filterNotNull().asLiveData(viewModelScope.coroutineContext)
val favouriteCategories = favourite.asLiveData(viewModelScope.coroutineContext)
- val newChaptersCount = liveData(viewModelScope.coroutineContext) { emit(newChapters.await()) }
+ val newChaptersCount = newChapters.asLiveData(viewModelScope.coroutineContext)
val readingHistory = history.asLiveData(viewModelScope.coroutineContext)
val isChaptersReversed = chaptersReversed.asLiveData(viewModelScope.coroutineContext)
val bookmarks = delegate.manga.flatMapLatest {
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
+ }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
+
+ val description = delegate.manga
+ .distinctUntilChangedBy { it?.description.orEmpty() }
+ .transformLatest {
+ val description = it?.description
+ if (description.isNullOrEmpty()) {
+ emit(null)
+ } else {
+ emit(description.parseAsHtml())
+ emit(description.parseAsHtml(imageGetter = imageGetter))
+ }
+ }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
val onMangaRemoved = SingleLiveEvent()
+ val isScrobblingAvailable: Boolean
+ get() = scrobbler.isAvailable
+
+ val scrobblingInfo = scrobbler.observeScrobblingInfo(delegate.mangaId)
+ .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
val branches: LiveData> = delegate.manga.map {
val chapters = it?.chapters ?: return@map emptyList()
chapters.mapToSet { x -> x.branch }.sortedWith(BranchComparator())
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
+ }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val selectedBranchIndex = combine(
branches.asFlow(),
delegate.selectedBranch
) { branches, selected ->
branches.indexOf(selected)
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
+ }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, -1)
- val isChaptersEmpty: LiveData = delegate.manga.map { m ->
- m != null && m.chapters.isNullOrEmpty()
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false)
+ val isChaptersEmpty: LiveData = combine(
+ delegate.manga,
+ isLoading.asFlow(),
+ ) { m, loading ->
+ m != null && m.chapters.isNullOrEmpty() && !loading
+ }.asLiveDataDistinct(viewModelScope.coroutineContext, false)
val chapters = combine(
combine(
@@ -97,8 +128,9 @@ class DetailsViewModel(
delegate.relatedManga,
history,
delegate.selectedBranch,
- ) { manga, related, history, branch ->
- delegate.mapChapters(manga, related, history, newChapters.await(), branch)
+ newChapters,
+ ) { manga, related, history, branch, news ->
+ delegate.mapChapters(manga, related, history, news, branch)
},
chaptersReversed,
chaptersQuery,
@@ -179,6 +211,25 @@ class DetailsViewModel(
}
}
+ fun updateScrobbling(rating: Float, status: ScrobblingStatus?) {
+ launchJob(Dispatchers.Default) {
+ scrobbler.updateScrobblingInfo(
+ mangaId = delegate.mangaId,
+ rating = rating,
+ status = status,
+ comment = null,
+ )
+ }
+ }
+
+ fun unregisterScrobbling() {
+ launchJob(Dispatchers.Default) {
+ scrobbler.unregisterScrobbling(
+ mangaId = delegate.mangaId
+ )
+ }
+ }
+
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
delegate.doLoad()
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt
index 07f03dbda..c6c45ecc1 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/MangaDetailsDelegate.kt
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.details.ui
import androidx.core.os.LocaleListCompat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import org.acra.ACRA
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException
@@ -13,6 +14,7 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
+import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -20,6 +22,7 @@ import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.utils.ext.iterator
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
+import org.koitharu.kotatsu.utils.ext.setCurrentManga
class MangaDetailsDelegate(
private val intent: MangaIntent,
@@ -32,6 +35,7 @@ class MangaDetailsDelegate(
private val mangaData = MutableStateFlow(intent.manga)
val selectedBranch = MutableStateFlow(null)
+
// Remote manga for saved and saved for remote
val relatedManga = MutableStateFlow(null)
val manga: StateFlow
@@ -41,6 +45,7 @@ class MangaDetailsDelegate(
suspend fun doLoad() {
var manga = mangaDataRepository.resolveIntent(intent)
?: throw MangaNotFoundException("Cannot find manga")
+ ACRA.setCurrentManga(manga)
mangaData.value = manga
manga = MangaRepository(manga.source).getDetails(manga)
// find default branch
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt
index 033b9ed92..766ffb0e3 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt
@@ -1,14 +1,16 @@
package org.koitharu.kotatsu.details.ui.adapter
+import android.content.Context
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
+import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import kotlin.jvm.internal.Intrinsics
class ChaptersAdapter(
onItemClickListener: OnListItemClickListener,
-) : AsyncListDifferDelegationAdapter(DiffCallback()) {
+) : AsyncListDifferDelegationAdapter(DiffCallback()), FastScroller.SectionIndexer {
init {
setHasStableIds(true)
@@ -39,4 +41,8 @@ class ChaptersAdapter(
return null
}
}
+
+ override fun getSectionText(context: Context, position: Int): CharSequence {
+ return items[position].chapter.number.toString()
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt
new file mode 100644
index 000000000..691bd7ad6
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoBottomSheet.kt
@@ -0,0 +1,149 @@
+package org.koitharu.kotatsu.details.ui.scrobbling
+
+import android.content.Intent
+import android.os.Bundle
+import android.text.method.LinkMovementMethod
+import android.view.LayoutInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.widget.AdapterView
+import android.widget.RatingBar
+import android.widget.Toast
+import androidx.appcompat.widget.PopupMenu
+import androidx.core.net.toUri
+import androidx.fragment.app.FragmentManager
+import coil.ImageLoader
+import coil.request.ImageRequest
+import org.koin.android.ext.android.inject
+import org.koin.androidx.viewmodel.ext.android.sharedViewModel
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.BaseBottomSheet
+import org.koitharu.kotatsu.databinding.SheetScrobblingBinding
+import org.koitharu.kotatsu.details.ui.DetailsViewModel
+import org.koitharu.kotatsu.image.ui.ImageActivity
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo
+import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus
+import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet
+import org.koitharu.kotatsu.utils.ext.crossfade
+import org.koitharu.kotatsu.utils.ext.enqueueWith
+import org.koitharu.kotatsu.utils.ext.getDisplayMessage
+import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
+
+class ScrobblingInfoBottomSheet :
+ BaseBottomSheet(),
+ AdapterView.OnItemSelectedListener,
+ RatingBar.OnRatingBarChangeListener,
+ View.OnClickListener,
+ PopupMenu.OnMenuItemClickListener {
+
+ private val viewModel by sharedViewModel()
+ private val coil by inject(mode = LazyThreadSafetyMode.NONE)
+ private var menu: PopupMenu? = null
+
+ override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingBinding {
+ return SheetScrobblingBinding.inflate(inflater, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
+ viewModel.onError.observe(viewLifecycleOwner) {
+ Toast.makeText(view.context, it.getDisplayMessage(view.resources), Toast.LENGTH_SHORT).show()
+ }
+
+ binding.spinnerStatus.onItemSelectedListener = this
+ binding.ratingBar.onRatingBarChangeListener = this
+ binding.buttonMenu.setOnClickListener(this)
+ binding.imageViewCover.setOnClickListener(this)
+ binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
+
+ menu = PopupMenu(view.context, binding.buttonMenu).apply {
+ inflate(R.menu.opt_scrobbling)
+ setOnMenuItemClickListener(this@ScrobblingInfoBottomSheet)
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ menu = null
+ }
+
+ override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
+ viewModel.updateScrobbling(
+ rating = binding.ratingBar.rating / binding.ratingBar.numStars,
+ status = enumValues().getOrNull(position),
+ )
+ }
+
+ override fun onNothingSelected(parent: AdapterView<*>?) = Unit
+
+ override fun onRatingChanged(ratingBar: RatingBar, rating: Float, fromUser: Boolean) {
+ if (fromUser) {
+ viewModel.updateScrobbling(
+ rating = rating / ratingBar.numStars,
+ status = enumValues().getOrNull(binding.spinnerStatus.selectedItemPosition),
+ )
+ }
+ }
+
+ override fun onClick(v: View) {
+ when (v.id) {
+ R.id.button_menu -> menu?.show()
+ R.id.imageView_cover -> {
+ val coverUrl = viewModel.scrobblingInfo.value?.coverUrl ?: return
+ val options = scaleUpActivityOptionsOf(v)
+ startActivity(ImageActivity.newIntent(v.context, coverUrl), options.toBundle())
+ }
+ }
+ }
+
+ private fun onScrobblingInfoChanged(scrobbling: ScrobblingInfo?) {
+ if (scrobbling == null) {
+ dismissAllowingStateLoss()
+ return
+ }
+ binding.textViewTitle.text = scrobbling.title
+ binding.ratingBar.rating = scrobbling.rating * binding.ratingBar.numStars
+ binding.textViewDescription.text = scrobbling.description
+ binding.spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1)
+ ImageRequest.Builder(context ?: return)
+ .target(binding.imageViewCover)
+ .data(scrobbling.coverUrl)
+ .crossfade(context)
+ .lifecycle(viewLifecycleOwner)
+ .placeholder(R.drawable.ic_placeholder)
+ .fallback(R.drawable.ic_placeholder)
+ .error(R.drawable.ic_placeholder)
+ .enqueueWith(coil)
+ }
+
+ companion object {
+
+ private const val TAG = "ScrobblingInfoBottomSheet"
+
+ fun show(fm: FragmentManager) = ScrobblingInfoBottomSheet().show(fm, TAG)
+ }
+
+ override fun onMenuItemClick(item: MenuItem): Boolean {
+ when (item.itemId) {
+ R.id.action_browser -> {
+ val url = viewModel.scrobblingInfo.value?.externalUrl ?: return false
+ val intent = Intent(Intent.ACTION_VIEW, url.toUri())
+ startActivity(
+ Intent.createChooser(intent, getString(R.string.open_in_browser))
+ )
+ }
+ R.id.action_unregister -> {
+ viewModel.unregisterScrobbling()
+ dismiss()
+ }
+ R.id.action_edit -> {
+ val manga = viewModel.manga.value ?: return false
+ ScrobblingSelectorBottomSheet.show(parentFragmentManager, manga)
+ dismiss()
+ }
+ }
+ return true
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt
index d079eb51f..bc8ab323c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt
@@ -5,7 +5,6 @@ import android.net.ConnectivityManager
import android.webkit.MimeTypeMap
import coil.ImageLoader
import coil.request.ImageRequest
-import coil.size.Scale
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Semaphore
@@ -204,7 +203,6 @@ class DownloadManager(
.data(manga.coverUrl)
.referer(manga.publicUrl)
.size(coverWidth, coverHeight)
- .scale(Scale.FILL)
.build()
).drawable
}.getOrNull()
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt
index 1a86d0153..476f6efb6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt
@@ -27,13 +27,14 @@ fun downloadItemAD(
bind {
job?.cancel()
job = item.progressAsFlow().onFirst { state ->
- binding.imageViewCover.newImageRequest(state.manga.coverUrl)
- .referer(state.manga.publicUrl)
- .placeholder(state.cover)
- .fallback(R.drawable.ic_placeholder)
- .error(R.drawable.ic_placeholder)
- .allowRgb565(true)
- .enqueueWith(coil)
+ binding.imageViewCover.newImageRequest(state.manga.coverUrl)?.run {
+ referer(state.manga.publicUrl)
+ placeholder(state.cover)
+ fallback(R.drawable.ic_placeholder)
+ error(R.drawable.ic_placeholder)
+ allowRgb565(true)
+ enqueueWith(coil)
+ }
}.onEach { state ->
binding.textViewTitle.text = state.manga.title
when (state) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt
index a424e7086..a8f0744bd 100644
--- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt
@@ -11,8 +11,8 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
+import com.google.android.material.R as materialR
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.core.ui.CrashActivity
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.DownloadsActivity
@@ -20,7 +20,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
-import com.google.android.material.R as materialR
class DownloadNotification(private val context: Context, startId: Int) {
@@ -92,14 +91,6 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setContentText(message)
builder.setAutoCancel(true)
builder.setOngoing(false)
- builder.setContentIntent(
- PendingIntent.getActivity(
- context,
- state.manga.hashCode(),
- CrashActivity.newIntent(context, state.error),
- PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
- )
- )
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ExploreModule.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ExploreModule.kt
new file mode 100644
index 000000000..32d55740b
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/explore/ExploreModule.kt
@@ -0,0 +1,14 @@
+package org.koitharu.kotatsu.explore
+
+import org.koin.androidx.viewmodel.dsl.viewModel
+import org.koin.dsl.module
+import org.koitharu.kotatsu.explore.domain.ExploreRepository
+import org.koitharu.kotatsu.explore.ui.ExploreViewModel
+
+val exploreModule
+ get() = module {
+
+ factory { ExploreRepository(get(), get()) }
+
+ viewModel { ExploreViewModel(get(), get()) }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt b/app/src/main/java/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt
new file mode 100644
index 000000000..730f38a29
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/explore/domain/ExploreRepository.kt
@@ -0,0 +1,40 @@
+package org.koitharu.kotatsu.explore.domain
+
+import org.koitharu.kotatsu.core.parser.MangaRepository
+import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.history.domain.HistoryRepository
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.SortOrder
+
+class ExploreRepository(
+ private val settings: AppSettings,
+ private val historyRepository: HistoryRepository,
+) {
+
+ suspend fun findRandomManga(tagsLimit: Int): Manga {
+ val blacklistTagRegex = settings.getSuggestionsTagsBlacklistRegex()
+ val allTags = historyRepository.getPopularTags(tagsLimit).filterNot {
+ blacklistTagRegex?.containsMatchIn(it.title) ?: false
+ }
+ val tag = allTags.randomOrNull()
+ val source = checkNotNull(tag?.source ?: settings.getMangaSources(includeHidden = false).randomOrNull()) {
+ "No sources found"
+ }
+ val repo = MangaRepository(source)
+ val list = repo.getList(
+ offset = 0,
+ sortOrder = if (SortOrder.UPDATED in repo.sortOrders) SortOrder.UPDATED else null,
+ tags = setOfNotNull(tag),
+ ).shuffled()
+ for (item in list) {
+ if (settings.isSuggestionsExcludeNsfw && item.isNsfw) {
+ continue
+ }
+ if (blacklistTagRegex != null && item.tags.any { x -> blacklistTagRegex.containsMatchIn(x.title) }) {
+ continue
+ }
+ return item
+ }
+ return list.random()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt
new file mode 100644
index 000000000..5c61a640a
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt
@@ -0,0 +1,127 @@
+package org.koitharu.kotatsu.explore.ui
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.graphics.Insets
+import androidx.core.view.updatePadding
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.snackbar.Snackbar
+import org.koin.android.ext.android.get
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.BaseFragment
+import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
+import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
+import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity
+import org.koitharu.kotatsu.databinding.FragmentExploreBinding
+import org.koitharu.kotatsu.details.ui.DetailsActivity
+import org.koitharu.kotatsu.explore.ui.adapter.ExploreAdapter
+import org.koitharu.kotatsu.explore.ui.adapter.ExploreListEventListener
+import org.koitharu.kotatsu.explore.ui.model.ExploreItem
+import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
+import org.koitharu.kotatsu.history.ui.HistoryActivity
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.search.ui.MangaListActivity
+import org.koitharu.kotatsu.settings.SettingsActivity
+import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
+import org.koitharu.kotatsu.utils.ext.getDisplayMessage
+
+class ExploreFragment : BaseFragment(),
+ RecyclerViewOwner,
+ ExploreListEventListener,
+ OnListItemClickListener {
+
+ private val viewModel by viewModel()
+ private var exploreAdapter: ExploreAdapter? = null
+ private var paddingHorizontal = 0
+
+ override val recyclerView: RecyclerView
+ get() = binding.recyclerView
+
+ override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): FragmentExploreBinding {
+ return FragmentExploreBinding.inflate(inflater, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ exploreAdapter = ExploreAdapter(get(), viewLifecycleOwner, this, this)
+ with(binding.recyclerView) {
+ adapter = exploreAdapter
+ setHasFixedSize(true)
+ val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
+ paddingHorizontal = spacing
+ }
+ viewModel.content.observe(viewLifecycleOwner) {
+ exploreAdapter?.items = it
+ }
+ viewModel.onError.observe(viewLifecycleOwner, ::onError)
+ viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ exploreAdapter = null
+ }
+
+ override fun onWindowInsetsChanged(insets: Insets) {
+ binding.root.updatePadding(
+ left = insets.left,
+ right = insets.right,
+ )
+ binding.recyclerView.updatePadding(
+ left = insets.left + paddingHorizontal,
+ right = insets.right + paddingHorizontal,
+ bottom = insets.bottom,
+ )
+ }
+
+ override fun onManageClick(view: View) {
+ startActivity(SettingsActivity.newManageSourcesIntent(view.context))
+ }
+
+ override fun onClick(v: View) {
+ val intent = when (v.id) {
+ R.id.button_history -> HistoryActivity.newIntent(v.context)
+ R.id.button_local -> MangaListActivity.newIntent(v.context, MangaSource.LOCAL)
+ R.id.button_bookmarks -> BookmarksActivity.newIntent(v.context)
+ R.id.button_suggestions -> SuggestionsActivity.newIntent(v.context)
+ R.id.button_favourites -> FavouriteCategoriesActivity.newIntent(v.context)
+ R.id.button_random -> {
+ viewModel.openRandom()
+ return
+ }
+ else -> return
+ }
+ startActivity(intent)
+ }
+
+ override fun onItemClick(item: ExploreItem.Source, view: View) {
+ val intent = MangaListActivity.newIntent(view.context, item.source)
+ startActivity(intent)
+ }
+
+ override fun onRetryClick(error: Throwable) = Unit
+
+ override fun onEmptyActionClick() = onManageClick(requireView())
+
+ private fun onError(e: Throwable) {
+ Snackbar.make(
+ binding.recyclerView,
+ e.getDisplayMessage(resources),
+ Snackbar.LENGTH_SHORT
+ ).show()
+ }
+
+ private fun onOpenManga(manga: Manga) {
+ val intent = DetailsActivity.newIntent(context ?: return, manga)
+ startActivity(intent)
+ }
+
+ companion object {
+
+ fun newInstance() = ExploreFragment()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt
new file mode 100644
index 000000000..b00c63755
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt
@@ -0,0 +1,69 @@
+package org.koitharu.kotatsu.explore.ui
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.asFlow
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.*
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.BaseViewModel
+import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.explore.domain.ExploreRepository
+import org.koitharu.kotatsu.explore.ui.model.ExploreItem
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.utils.SingleLiveEvent
+import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+
+class ExploreViewModel(
+ private val settings: AppSettings,
+ private val exploreRepository: ExploreRepository,
+) : BaseViewModel() {
+
+ val onOpenManga = SingleLiveEvent()
+
+ val content: LiveData> = isLoading.asFlow().flatMapLatest { loading ->
+ if (loading) {
+ flowOf(listOf(ExploreItem.Loading))
+ } else {
+ createContentFlow()
+ }
+ }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(ExploreItem.Loading))
+
+ fun openRandom() {
+ launchLoadingJob(Dispatchers.Default) {
+ val manga = exploreRepository.findRandomManga(tagsLimit = 8)
+ onOpenManga.postCall(manga)
+ }
+ }
+
+ private fun createContentFlow() = settings.observe()
+ .filter {
+ it == AppSettings.KEY_SOURCES_HIDDEN ||
+ it == AppSettings.KEY_SOURCES_ORDER ||
+ it == AppSettings.KEY_SUGGESTIONS
+ }
+ .onStart { emit("") }
+ .map { settings.getMangaSources(includeHidden = false) }
+ .distinctUntilChanged()
+ .map { buildList(it) }
+
+ private fun buildList(sources: List): List {
+ val result = ArrayList(sources.size + 3)
+ result += ExploreItem.Buttons(
+ isSuggestionsEnabled = settings.isSuggestionsEnabled,
+ )
+ result += ExploreItem.Header(R.string.remote_sources, sources.isNotEmpty())
+ if (sources.isNotEmpty()) {
+ sources.mapTo(result) { ExploreItem.Source(it) }
+ } else {
+ result += ExploreItem.EmptyHint(
+ icon = R.drawable.ic_empty_search,
+ textPrimary = R.string.no_manga_sources,
+ textSecondary = R.string.no_manga_sources_text,
+ actionStringRes = R.string.manage,
+ )
+ }
+ return result
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt
new file mode 100644
index 000000000..10a40c900
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapter.kt
@@ -0,0 +1,21 @@
+package org.koitharu.kotatsu.explore.ui.adapter
+
+import androidx.lifecycle.LifecycleOwner
+import coil.ImageLoader
+import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
+import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
+import org.koitharu.kotatsu.explore.ui.model.ExploreItem
+
+class ExploreAdapter(
+ coil: ImageLoader,
+ lifecycleOwner: LifecycleOwner,
+ listener: ExploreListEventListener,
+ clickListener: OnListItemClickListener,
+) : AsyncListDifferDelegationAdapter(
+ ExploreDiffCallback(),
+ exploreButtonsAD(listener),
+ exploreSourcesHeaderAD(listener),
+ exploreSourceItemAD(coil, clickListener, lifecycleOwner),
+ exploreEmptyHintListAD(listener),
+ exploreLoadingAD(),
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt
new file mode 100644
index 000000000..aa9e6d84f
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt
@@ -0,0 +1,108 @@
+package org.koitharu.kotatsu.explore.ui.adapter
+
+import android.view.View
+import androidx.core.view.isVisible
+import androidx.lifecycle.LifecycleOwner
+import coil.ImageLoader
+import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
+import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
+import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
+import org.koitharu.kotatsu.core.parser.favicon.faviconUri
+import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding
+import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding
+import org.koitharu.kotatsu.databinding.ItemExploreHeaderBinding
+import org.koitharu.kotatsu.databinding.ItemExploreSourceBinding
+import org.koitharu.kotatsu.explore.ui.model.ExploreItem
+import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
+import org.koitharu.kotatsu.utils.ext.disposeImageRequest
+import org.koitharu.kotatsu.utils.ext.enqueueWith
+import org.koitharu.kotatsu.utils.ext.newImageRequest
+import org.koitharu.kotatsu.utils.ext.setTextAndVisible
+import org.koitharu.kotatsu.utils.image.FaviconFallbackDrawable
+
+fun exploreButtonsAD(
+ clickListener: View.OnClickListener,
+) = adapterDelegateViewBinding(
+ { layoutInflater, parent -> ItemExploreButtonsBinding.inflate(layoutInflater, parent, false) }
+) {
+
+ binding.buttonBookmarks.setOnClickListener(clickListener)
+ binding.buttonHistory.setOnClickListener(clickListener)
+ binding.buttonLocal.setOnClickListener(clickListener)
+ binding.buttonSuggestions.setOnClickListener(clickListener)
+ binding.buttonFavourites.setOnClickListener(clickListener)
+ binding.buttonRandom.setOnClickListener(clickListener)
+
+ bind {
+ binding.buttonSuggestions.isVisible = item.isSuggestionsEnabled
+ }
+}
+
+fun exploreSourcesHeaderAD(
+ listener: ExploreListEventListener,
+) = adapterDelegateViewBinding(
+ { layoutInflater, parent -> ItemExploreHeaderBinding.inflate(layoutInflater, parent, false) }
+) {
+
+ val listenerAdapter = View.OnClickListener {
+ listener.onManageClick(itemView)
+ }
+
+ binding.buttonMore.setOnClickListener(listenerAdapter)
+
+ bind {
+ binding.textViewTitle.setText(item.titleResId)
+ binding.buttonMore.isVisible = item.isButtonVisible
+ }
+}
+
+fun exploreSourceItemAD(
+ coil: ImageLoader,
+ listener: OnListItemClickListener,
+ lifecycleOwner: LifecycleOwner,
+) = adapterDelegateViewBinding(
+ { layoutInflater, parent -> ItemExploreSourceBinding.inflate(layoutInflater, parent, false) },
+ on = { item, _, _ -> item is ExploreItem.Source }
+) {
+
+ val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
+
+ binding.root.setOnClickListener(eventListener)
+ binding.root.setOnLongClickListener(eventListener)
+
+ bind {
+ binding.textViewTitle.text = item.source.title
+ val fallbackIcon = FaviconFallbackDrawable(context, item.source.name)
+ binding.imageViewIcon.newImageRequest(item.source.faviconUri())?.run {
+ fallback(fallbackIcon)
+ placeholder(fallbackIcon)
+ error(fallbackIcon)
+ lifecycle(lifecycleOwner)
+ enqueueWith(coil)
+ }
+ }
+
+ onViewRecycled {
+ binding.imageViewIcon.disposeImageRequest()
+ }
+}
+
+fun exploreEmptyHintListAD(
+ listener: ListStateHolderListener,
+) = adapterDelegateViewBinding(
+ { inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) }
+) {
+
+ binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() }
+
+ bind {
+ binding.icon.setImageResource(item.icon)
+ binding.textPrimary.setText(item.textPrimary)
+ binding.textSecondary.setTextAndVisible(item.textSecondary)
+ binding.buttonRetry.setTextAndVisible(item.actionStringRes)
+ }
+}
+
+fun exploreLoadingAD() = adapterDelegate(R.layout.item_loading_state) {}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreDiffCallback.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreDiffCallback.kt
new file mode 100644
index 000000000..352edd401
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreDiffCallback.kt
@@ -0,0 +1,27 @@
+package org.koitharu.kotatsu.explore.ui.adapter
+
+import androidx.recyclerview.widget.DiffUtil
+import org.koitharu.kotatsu.explore.ui.model.ExploreItem
+
+class ExploreDiffCallback : DiffUtil.ItemCallback() {
+
+ override fun areItemsTheSame(oldItem: ExploreItem, newItem: ExploreItem): Boolean {
+ return when {
+ oldItem.javaClass != newItem.javaClass -> false
+ oldItem is ExploreItem.Buttons && newItem is ExploreItem.Buttons -> true
+ oldItem is ExploreItem.Loading && newItem is ExploreItem.Loading -> true
+ oldItem is ExploreItem.EmptyHint && newItem is ExploreItem.EmptyHint -> true
+ oldItem is ExploreItem.Source && newItem is ExploreItem.Source -> {
+ oldItem.source == newItem.source
+ }
+ oldItem is ExploreItem.Header && newItem is ExploreItem.Header -> {
+ oldItem.titleResId == newItem.titleResId
+ }
+ else -> false
+ }
+ }
+
+ override fun areContentsTheSame(oldItem: ExploreItem, newItem: ExploreItem): Boolean {
+ return oldItem == newItem
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreListEventListener.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreListEventListener.kt
new file mode 100644
index 000000000..4bd122086
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreListEventListener.kt
@@ -0,0 +1,9 @@
+package org.koitharu.kotatsu.explore.ui.adapter
+
+import android.view.View
+import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
+
+interface ExploreListEventListener : ListStateHolderListener, View.OnClickListener {
+
+ fun onManageClick(view: View)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/model/ExploreItem.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/model/ExploreItem.kt
new file mode 100644
index 000000000..dcf871c8d
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/model/ExploreItem.kt
@@ -0,0 +1,84 @@
+package org.koitharu.kotatsu.explore.ui.model
+
+import android.net.Uri
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import org.koitharu.kotatsu.list.ui.model.EmptyState
+import org.koitharu.kotatsu.list.ui.model.ListModel
+import org.koitharu.kotatsu.parsers.model.MangaSource
+
+sealed interface ExploreItem : ListModel {
+
+ class Buttons(
+ val isSuggestionsEnabled: Boolean
+ ) : ExploreItem {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Buttons
+
+ if (isSuggestionsEnabled != other.isSuggestionsEnabled) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ return isSuggestionsEnabled.hashCode()
+ }
+ }
+
+ class Header(
+ @StringRes val titleResId: Int,
+ val isButtonVisible: Boolean,
+ ) : ExploreItem {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Header
+
+ if (titleResId != other.titleResId) return false
+ if (isButtonVisible != other.isButtonVisible) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = titleResId
+ result = 31 * result + isButtonVisible.hashCode()
+ return result
+ }
+ }
+
+ class Source(
+ val source: MangaSource,
+ ) : ExploreItem {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Source
+
+ if (source != other.source) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ return source.hashCode()
+ }
+ }
+
+ class EmptyHint(
+ @DrawableRes icon: Int,
+ @StringRes textPrimary: Int,
+ @StringRes textSecondary: Int,
+ @StringRes actionStringRes: Int,
+ ) : EmptyState(icon, textPrimary, textSecondary, actionStringRes), ExploreItem
+
+ object Loading : ExploreItem
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt
index 6d880abb9..f3bc159a8 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt
@@ -11,10 +11,10 @@ import org.koitharu.kotatsu.favourites.ui.list.FavouritesListViewModel
val favouritesModule
get() = module {
- factory { FavouritesRepository(get(), get()) }
+ single { FavouritesRepository(get(), get()) }
viewModel { categoryId ->
- FavouritesListViewModel(categoryId.get(), get(), get(), get())
+ FavouritesListViewModel(categoryId.get(), get(), get(), get(), get())
}
viewModel { FavouritesCategoriesViewModel(get(), get()) }
viewModel { manga ->
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt
index c6a65c78e..36308d4c6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt
@@ -1,9 +1,9 @@
package org.koitharu.kotatsu.favourites.data
-import java.util.*
import org.koitharu.kotatsu.core.db.entity.SortOrder
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.parsers.model.SortOrder
+import java.util.*
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
id = id,
@@ -12,4 +12,5 @@ fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong())
order = SortOrder(order, SortOrder.NEWEST),
createdAt = Date(createdAt),
isTrackingEnabled = track,
+ isVisibleInLibrary = isVisibleInLibrary,
)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt
index 916e04d24..b4b94737f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt
@@ -15,6 +15,17 @@ abstract class FavouriteCategoriesDao {
@Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 ORDER BY sort_key")
abstract fun observeAll(): Flow>
+ @MapInfo(valueColumn = "cover")
+ @Query(
+ """
+ SELECT favourite_categories.*, manga.cover_url AS cover
+ FROM favourite_categories JOIN manga ON manga.manga_id IN
+ (SELECT manga_id FROM favourites WHERE favourites.category_id == favourite_categories.category_id)
+ ORDER BY favourite_categories.sort_key
+ """
+ )
+ abstract fun observeAllWithDetails(): Flow>>
+
@Query("SELECT * FROM favourite_categories WHERE category_id = :id AND deleted_at = 0")
abstract fun observe(id: Long): Flow
@@ -33,6 +44,12 @@ abstract class FavouriteCategoriesDao {
@Query("UPDATE favourite_categories SET `order` = :order WHERE category_id = :id")
abstract suspend fun updateOrder(id: Long, order: String)
+ @Query("UPDATE favourite_categories SET `track` = :isEnabled WHERE category_id = :id")
+ abstract suspend fun updateTracking(id: Long, isEnabled: Boolean)
+
+ @Query("UPDATE favourite_categories SET `show_in_lib` = :isEnabled WHERE category_id = :id")
+ abstract suspend fun updateLibVisibility(id: Long, isEnabled: Boolean)
+
@Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id")
abstract suspend fun updateSortKey(id: Long, sortKey: Int)
@@ -52,4 +69,4 @@ abstract class FavouriteCategoriesDao {
insert(entity)
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt
index abf480bed..befd29a72 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt
@@ -6,7 +6,7 @@ import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
@Entity(tableName = TABLE_FAVOURITE_CATEGORIES)
-class FavouriteCategoryEntity(
+data class FavouriteCategoryEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "category_id") val categoryId: Int,
@ColumnInfo(name = "created_at") val createdAt: Long,
@@ -14,5 +14,35 @@ class FavouriteCategoryEntity(
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "order") val order: String,
@ColumnInfo(name = "track") val track: Boolean,
+ @ColumnInfo(name = "show_in_lib") val isVisibleInLibrary: Boolean,
@ColumnInfo(name = "deleted_at") val deletedAt: Long,
-)
\ No newline at end of file
+) {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as FavouriteCategoryEntity
+
+ if (categoryId != other.categoryId) return false
+ if (createdAt != other.createdAt) return false
+ if (sortKey != other.sortKey) return false
+ if (title != other.title) return false
+ if (order != other.order) return false
+ if (track != other.track) return false
+ if (isVisibleInLibrary != other.isVisibleInLibrary) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = categoryId
+ result = 31 * result + createdAt.hashCode()
+ result = 31 * result + sortKey
+ result = 31 * result + title.hashCode()
+ result = 31 * result + order.hashCode()
+ result = 31 * result + track.hashCode()
+ result = 31 * result + isVisibleInLibrary.hashCode()
+ return result
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt
index 4fdf1e943..87f8afa55 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt
@@ -24,9 +24,10 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
)
]
)
-class FavouriteEntity(
+data class FavouriteEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "category_id", index = true) val categoryId: Long,
+ @ColumnInfo(name = "sort_key") val sortKey: Int,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "deleted_at") val deletedAt: Long,
-)
\ No newline at end of file
+)
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
index bfaae8474..2f48167fc 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
@@ -50,6 +50,13 @@ class FavouritesRepository(
}.distinctUntilChanged()
}
+ fun observeCategoriesWithDetails(): Flow>> {
+ return db.favouriteCategoriesDao.observeAllWithDetails()
+ .map {
+ it.mapKeys { (k, _) -> k.toFavouriteCategory() }
+ }
+ }
+
fun observeCategory(id: Long): Flow {
return db.favouriteCategoriesDao.observe(id)
.map { it?.toFavouriteCategory() }
@@ -72,6 +79,7 @@ class FavouritesRepository(
order = sortOrder.name,
track = isTrackerEnabled,
deletedAt = 0L,
+ isVisibleInLibrary = true,
)
val id = db.favouriteCategoriesDao.insert(entity)
val category = entity.toFavouriteCategory(id)
@@ -83,11 +91,23 @@ class FavouritesRepository(
db.favouriteCategoriesDao.update(id, title, sortOrder.name, isTrackerEnabled)
}
+ suspend fun updateCategory(id: Long, isVisibleInLibrary: Boolean) {
+ db.favouriteCategoriesDao.updateLibVisibility(id, isVisibleInLibrary)
+ }
+
suspend fun removeCategory(id: Long) {
db.favouriteCategoriesDao.delete(id)
channels.deleteChannel(id)
}
+ suspend fun removeCategories(ids: Collection) {
+ db.withTransaction {
+ for (id in ids) {
+ removeCategory(id)
+ }
+ }
+ }
+
suspend fun setCategoryOrder(id: Long, order: SortOrder) {
db.favouriteCategoriesDao.updateOrder(id, order.name)
}
@@ -111,6 +131,7 @@ class FavouritesRepository(
mangaId = manga.id,
categoryId = categoryId,
createdAt = System.currentTimeMillis(),
+ sortKey = 0,
deletedAt = 0L,
)
db.favouritesDao.insert(entity)
@@ -158,4 +179,4 @@ class FavouritesRepository(
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesActivity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesActivity.kt
new file mode 100644
index 000000000..c33b2dc6a
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesActivity.kt
@@ -0,0 +1,53 @@
+package org.koitharu.kotatsu.favourites.ui
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.core.graphics.Insets
+import androidx.core.view.updatePadding
+import androidx.fragment.app.commit
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.BaseActivity
+import org.koitharu.kotatsu.core.model.FavouriteCategory
+import org.koitharu.kotatsu.databinding.ActivityContainerBinding
+import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment
+import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
+
+class FavouritesActivity : BaseActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(ActivityContainerBinding.inflate(layoutInflater))
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ val categoryTitle = intent.getStringExtra(EXTRA_TITLE)
+ if (categoryTitle != null) {
+ title = categoryTitle
+ }
+ val fm = supportFragmentManager
+ if (fm.findFragmentById(R.id.container) == null) {
+ fm.commit {
+ val fragment = FavouritesListFragment.newInstance(intent.getLongExtra(EXTRA_CATEGORY_ID, NO_ID))
+ replace(R.id.container, fragment)
+ }
+ }
+ }
+
+ override fun onWindowInsetsChanged(insets: Insets) {
+ binding.toolbar.updatePadding(
+ left = insets.left,
+ right = insets.right,
+ )
+ }
+
+ companion object {
+
+ private const val EXTRA_CATEGORY_ID = "cat_id"
+ private const val EXTRA_TITLE = "title"
+
+ fun newIntent(context: Context) = Intent(context, FavouritesActivity::class.java)
+
+ fun newIntent(context: Context, category: FavouriteCategory) = Intent(context, FavouritesActivity::class.java)
+ .putExtra(EXTRA_CATEGORY_ID, category.id)
+ .putExtra(EXTRA_TITLE, category.title)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt
deleted file mode 100644
index 8e9d945a0..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt
+++ /dev/null
@@ -1,205 +0,0 @@
-package org.koitharu.kotatsu.favourites.ui
-
-import android.os.Bundle
-import android.view.*
-import androidx.appcompat.view.ActionMode
-import androidx.appcompat.widget.PopupMenu
-import androidx.core.graphics.Insets
-import androidx.core.view.children
-import androidx.core.view.isVisible
-import androidx.core.view.updateLayoutParams
-import androidx.core.view.updatePadding
-import com.google.android.material.snackbar.Snackbar
-import com.google.android.material.tabs.TabLayout
-import com.google.android.material.tabs.TabLayoutMediator
-import org.koin.androidx.viewmodel.ext.android.viewModel
-import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.base.ui.BaseFragment
-import org.koitharu.kotatsu.base.ui.util.ActionModeListener
-import org.koitharu.kotatsu.core.model.FavouriteCategory
-import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding
-import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding
-import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
-import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
-import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
-import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
-import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
-import org.koitharu.kotatsu.main.ui.AppBarOwner
-import org.koitharu.kotatsu.utils.ext.getDisplayMessage
-import org.koitharu.kotatsu.utils.ext.measureHeight
-import org.koitharu.kotatsu.utils.ext.resolveDp
-
-class FavouritesContainerFragment :
- BaseFragment(),
- FavouritesTabLongClickListener,
- CategoriesEditDelegate.CategoriesEditCallback,
- ActionModeListener,
- View.OnClickListener {
-
- private val viewModel by viewModel()
- private val editDelegate by lazy(LazyThreadSafetyMode.NONE) {
- CategoriesEditDelegate(requireContext(), this)
- }
- private var pagerAdapter: FavouritesPagerAdapter? = null
- private var stubBinding: ItemEmptyStateBinding? = null
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setHasOptionsMenu(true)
- }
-
- override fun onInflateView(
- inflater: LayoutInflater,
- container: ViewGroup?
- ) = FragmentFavouritesBinding.inflate(inflater, container, false)
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- val adapter = FavouritesPagerAdapter(this, this)
- viewModel.visibleCategories.value?.let(::onCategoriesChanged)
- binding.pager.adapter = adapter
- pagerAdapter = adapter
- TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
- actionModeDelegate.addListener(this, viewLifecycleOwner)
-
- viewModel.visibleCategories.observe(viewLifecycleOwner, ::onCategoriesChanged)
- viewModel.onError.observe(viewLifecycleOwner, ::onError)
- }
-
- override fun onDestroyView() {
- pagerAdapter = null
- stubBinding = null
- super.onDestroyView()
- }
-
- override fun onActionModeStarted(mode: ActionMode) {
- binding.pager.isUserInputEnabled = false
- binding.tabs.setTabsEnabled(false)
- }
-
- override fun onActionModeFinished(mode: ActionMode) {
- binding.pager.isUserInputEnabled = true
- binding.tabs.setTabsEnabled(true)
- }
-
- override fun onWindowInsetsChanged(insets: Insets) {
- val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
- binding.root.updatePadding(
- top = headerHeight - insets.top
- )
- binding.pager.updatePadding(
- // 8 dp is needed so that the top of the list is not attached to tabs (visible when ActionMode is active)
- top = -headerHeight + resources.resolveDp(8)
- )
- binding.tabs.apply {
- updatePadding(
- left = insets.left,
- right = insets.right
- )
- updateLayoutParams {
- topMargin = insets.top
- }
- }
- }
-
- private fun onCategoriesChanged(categories: List) {
- pagerAdapter?.replaceData(categories)
- if (categories.isEmpty()) {
- binding.pager.isVisible = false
- binding.tabs.isVisible = false
- showStub()
- } else {
- binding.pager.isVisible = true
- binding.tabs.isVisible = true
- (stubBinding?.root ?: binding.stubEmptyState).isVisible = false
- }
- }
-
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- inflater.inflate(R.menu.opt_favourites, menu)
- super.onCreateOptionsMenu(menu, inflater)
- }
-
- override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
- R.id.action_categories -> {
- context?.let {
- startActivity(CategoriesActivity.newIntent(it))
- }
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
-
- private fun onError(e: Throwable) {
- Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
- }
-
- override fun onTabLongClick(tabView: View, item: CategoryListModel): Boolean {
- when (item) {
- is CategoryListModel.All -> showAllCategoriesMenu(tabView)
- is CategoryListModel.CategoryItem -> showCategoryMenu(tabView, item.category)
- }
- return true
- }
-
- override fun onClick(v: View) {
- when (v.id) {
- R.id.button_retry -> startActivity(FavouritesCategoryEditActivity.newIntent(v.context))
- }
- }
-
- override fun onDeleteCategory(category: FavouriteCategory) {
- viewModel.deleteCategory(category.id)
- }
-
- private fun TabLayout.setTabsEnabled(enabled: Boolean) {
- val tabStrip = getChildAt(0) as? ViewGroup ?: return
- for (tab in tabStrip.children) {
- tab.isEnabled = enabled
- }
- }
-
- private fun showCategoryMenu(tabView: View, category: FavouriteCategory) {
- val menu = PopupMenu(tabView.context, tabView)
- menu.inflate(R.menu.popup_category)
- menu.setOnMenuItemClickListener {
- when (it.itemId) {
- R.id.action_remove -> editDelegate.deleteCategory(category)
- R.id.action_edit -> startActivity(FavouritesCategoryEditActivity.newIntent(tabView.context, category.id))
- else -> return@setOnMenuItemClickListener false
- }
- true
- }
- menu.show()
- }
-
- private fun showAllCategoriesMenu(tabView: View) {
- val menu = PopupMenu(tabView.context, tabView)
- menu.inflate(R.menu.popup_category_all)
- menu.setOnMenuItemClickListener {
- when (it.itemId) {
- R.id.action_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext()))
- R.id.action_hide -> viewModel.setAllCategoriesVisible(false)
- }
- true
- }
- menu.show()
- }
-
- private fun showStub() {
- val stub = stubBinding ?: ItemEmptyStateBinding.bind(binding.stubEmptyState.inflate())
- stub.root.isVisible = true
- stub.icon.setImageResource(R.drawable.ic_heart_outline)
- stub.textPrimary.setText(R.string.text_empty_holder_primary)
- stub.textSecondary.setText(R.string.empty_favourite_categories)
- stub.buttonRetry.setText(R.string.add)
- stub.buttonRetry.isVisible = true
- stub.buttonRetry.setOnClickListener(this)
- stubBinding = stub
- }
-
- companion object {
-
- fun newInstance() = FavouritesContainerFragment()
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesPagerAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesPagerAdapter.kt
deleted file mode 100644
index 329e06751..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesPagerAdapter.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-package org.koitharu.kotatsu.favourites.ui
-
-import android.view.View
-import androidx.fragment.app.Fragment
-import androidx.recyclerview.widget.AsyncListDiffer
-import androidx.recyclerview.widget.DiffUtil
-import androidx.viewpager2.adapter.FragmentStateAdapter
-import com.google.android.material.tabs.TabLayout
-import com.google.android.material.tabs.TabLayoutMediator
-import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
-import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment
-
-class FavouritesPagerAdapter(
- fragment: Fragment,
- private val longClickListener: FavouritesTabLongClickListener
-) : FragmentStateAdapter(fragment.childFragmentManager, fragment.viewLifecycleOwner.lifecycle),
- TabLayoutMediator.TabConfigurationStrategy,
- View.OnLongClickListener {
-
- private val differ = AsyncListDiffer(this, DiffCallback())
-
- override fun getItemCount() = differ.currentList.size
-
- override fun createFragment(position: Int): Fragment {
- val item = differ.currentList[position]
- return FavouritesListFragment.newInstance(item.id)
- }
-
- override fun getItemId(position: Int): Long {
- return differ.currentList[position].id
- }
-
- override fun containsItem(itemId: Long): Boolean {
- return differ.currentList.any { it.id == itemId }
- }
-
- override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
- val item = differ.currentList[position]
- tab.text = when (item) {
- is CategoryListModel.All -> tab.view.context.getString(R.string.all_favourites)
- is CategoryListModel.CategoryItem -> item.category.title
- }
- tab.view.tag = item.id
- tab.view.setOnLongClickListener(this)
- }
-
- fun replaceData(data: List) {
- differ.submitList(data)
- }
-
- override fun onLongClick(v: View): Boolean {
- val itemId = v.tag as? Long ?: return false
- val item = differ.currentList.find { x -> x.id == itemId } ?: return false
- return longClickListener.onTabLongClick(v, item)
- }
-
- private class DiffCallback : DiffUtil.ItemCallback() {
-
- override fun areItemsTheSame(
- oldItem: CategoryListModel,
- newItem: CategoryListModel
- ): Boolean = when {
- oldItem is CategoryListModel.All && newItem is CategoryListModel.All -> true
- oldItem is CategoryListModel.CategoryItem && newItem is CategoryListModel.CategoryItem -> {
- oldItem.category.id == newItem.category.id
- }
- else -> false
- }
-
- override fun areContentsTheSame(
- oldItem: CategoryListModel,
- newItem: CategoryListModel
- ): Boolean = oldItem == newItem
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesTabLongClickListener.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesTabLongClickListener.kt
deleted file mode 100644
index 13fca87c9..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesTabLongClickListener.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package org.koitharu.kotatsu.favourites.ui
-
-import android.view.View
-import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
-
-fun interface FavouritesTabLongClickListener {
-
- fun onTabLongClick(tabView: View, item: CategoryListModel): Boolean
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/AllCategoriesToggleListener.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/AllCategoriesToggleListener.kt
deleted file mode 100644
index 380722b84..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/AllCategoriesToggleListener.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package org.koitharu.kotatsu.favourites.ui.categories
-
-interface AllCategoriesToggleListener {
-
- fun onAllCategoriesToggle(isVisible: Boolean)
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt
deleted file mode 100644
index 7a5620158..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-package org.koitharu.kotatsu.favourites.ui.categories
-
-import androidx.recyclerview.widget.DiffUtil
-import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
-import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
-import org.koitharu.kotatsu.core.model.FavouriteCategory
-import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
-import org.koitharu.kotatsu.favourites.ui.categories.adapter.allCategoriesAD
-import org.koitharu.kotatsu.favourites.ui.categories.adapter.categoryAD
-
-class CategoriesAdapter(
- onItemClickListener: OnListItemClickListener,
- allCategoriesToggleListener: AllCategoriesToggleListener,
-) : AsyncListDifferDelegationAdapter(DiffCallback()) {
-
- init {
- delegatesManager.addDelegate(categoryAD(onItemClickListener))
- .addDelegate(allCategoriesAD(allCategoriesToggleListener))
- setHasStableIds(true)
- }
-
- override fun getItemId(position: Int): Long {
- return items[position].id
- }
-
- private class DiffCallback : DiffUtil.ItemCallback() {
-
- override fun areItemsTheSame(
- oldItem: CategoryListModel,
- newItem: CategoryListModel,
- ): Boolean = oldItem.id == newItem.id
-
- override fun areContentsTheSame(
- oldItem: CategoryListModel,
- newItem: CategoryListModel,
- ): Boolean = oldItem == newItem
-
- override fun getChangePayload(
- oldItem: CategoryListModel,
- newItem: CategoryListModel,
- ): Any? = when {
- oldItem is CategoryListModel.All && newItem is CategoryListModel.All -> Unit
- oldItem is CategoryListModel.CategoryItem &&
- newItem is CategoryListModel.CategoryItem &&
- oldItem.category.title != newItem.category.title -> null
- else -> Unit
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt
deleted file mode 100644
index f7a98c078..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-package org.koitharu.kotatsu.favourites.ui.categories
-
-import android.content.Context
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.core.model.FavouriteCategory
-
-class CategoriesEditDelegate(
- private val context: Context,
- private val callback: CategoriesEditCallback
-) {
-
- fun deleteCategory(category: FavouriteCategory) {
- MaterialAlertDialogBuilder(context)
- .setMessage(context.getString(R.string.category_delete_confirm, category.title))
- .setTitle(R.string.remove_category)
- .setNegativeButton(android.R.string.cancel, null)
- .setPositiveButton(R.string.remove) { _, _ ->
- callback.onDeleteCategory(category)
- }.create()
- .show()
- }
-
- interface CategoriesEditCallback {
-
- fun onDeleteCategory(category: FavouriteCategory)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionCallback.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionCallback.kt
new file mode 100644
index 000000000..c6cbd4be7
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionCallback.kt
@@ -0,0 +1,64 @@
+package org.koitharu.kotatsu.favourites.ui.categories
+
+import android.view.Menu
+import android.view.MenuItem
+import androidx.appcompat.view.ActionMode
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.list.ListSelectionController
+import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
+import com.google.android.material.R as materialR
+
+class CategoriesSelectionCallback(
+ private val recyclerView: RecyclerView,
+ private val viewModel: FavouritesCategoriesViewModel,
+) : ListSelectionController.Callback2 {
+
+ override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
+ recyclerView.invalidateItemDecorations()
+ }
+
+ override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
+ mode.menuInflater.inflate(R.menu.mode_category, menu)
+ return true
+ }
+
+ override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
+ val isOneItem = controller.count == 1
+ menu.findItem(R.id.action_edit)?.isVisible = isOneItem
+ mode.title = controller.count.toString()
+ return true
+ }
+
+ override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
+ return when (item.itemId) {
+ R.id.action_edit -> {
+ val id = controller.peekCheckedIds().singleOrNull() ?: return false
+ val context = recyclerView.context
+ val intent = FavouritesCategoryEditActivity.newIntent(context, id)
+ context.startActivity(intent)
+ mode.finish()
+ true
+ }
+ R.id.action_remove -> {
+ confirmDeleteCategories(controller.snapshot(), mode)
+ true
+ }
+ else -> false
+ }
+ }
+
+ private fun confirmDeleteCategories(ids: Set, mode: ActionMode) {
+ val context = recyclerView.context
+ MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
+ .setMessage(R.string.categories_delete_confirm)
+ .setTitle(R.string.remove_category)
+ .setIcon(R.drawable.ic_delete)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(R.string.remove) { _, _ ->
+ viewModel.deleteCategories(ids)
+ mode.finish()
+ }.show()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionDecoration.kt
new file mode 100644
index 000000000..ebeaf648a
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionDecoration.kt
@@ -0,0 +1,57 @@
+package org.koitharu.kotatsu.favourites.ui.categories
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.RectF
+import android.view.View
+import androidx.core.graphics.ColorUtils
+import androidx.recyclerview.widget.RecyclerView
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
+import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
+import org.koitharu.kotatsu.utils.ext.getItem
+import org.koitharu.kotatsu.utils.ext.getThemeColor
+import com.google.android.material.R as materialR
+
+class CategoriesSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
+
+ private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ private val radius = context.resources.getDimension(R.dimen.list_selector_corner)
+ private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
+ private val fillColor = ColorUtils.setAlphaComponent(
+ ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
+ 0x74
+ )
+ private val padding = context.resources.getDimension(R.dimen.grid_spacing_outer)
+
+ init {
+ paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width)
+ hasForeground = true
+ hasBackground = false
+ isIncludeDecorAndMargins = false
+ }
+
+ override fun getItemId(parent: RecyclerView, child: View): Long {
+ val holder = parent.getChildViewHolder(child) ?: return RecyclerView.NO_ID
+ val item = holder.getItem(CategoryListModel::class.java) ?: return RecyclerView.NO_ID
+ return item.category.id
+ }
+
+ override fun onDrawForeground(
+ canvas: Canvas,
+ parent: RecyclerView,
+ child: View,
+ bounds: RectF,
+ state: RecyclerView.State,
+ ) {
+ bounds.inset(padding, padding)
+ paint.color = fillColor
+ paint.style = Paint.Style.FILL
+ canvas.drawRoundRect(bounds, radius, radius, paint)
+ paint.color = strokeColor
+ paint.style = Paint.Style.STROKE
+ canvas.drawRoundRect(bounds, radius, radius, paint)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt
similarity index 52%
rename from app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt
rename to app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt
index 80fd2a137..f7ad11271 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt
@@ -3,9 +3,12 @@ package org.koitharu.kotatsu.favourites.ui.categories
import android.content.Context
import android.content.Intent
import android.os.Bundle
+import android.transition.Fade
+import android.transition.TransitionManager
+import android.view.Menu
+import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
-import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
@@ -13,76 +16,114 @@ import androidx.core.view.updatePadding
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
+import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
-import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
+import org.koitharu.kotatsu.base.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
-import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
+import org.koitharu.kotatsu.favourites.ui.FavouritesActivity
+import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoriesAdapter
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
+import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
+import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight
+import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
-class CategoriesActivity :
+class FavouriteCategoriesActivity :
BaseActivity(),
- OnListItemClickListener,
+ FavouriteCategoriesListListener,
View.OnClickListener,
- CategoriesEditDelegate.CategoriesEditCallback,
- AllCategoriesToggleListener {
+ ListStateHolderListener {
private val viewModel by viewModel()
private lateinit var adapter: CategoriesAdapter
- private lateinit var reorderHelper: ItemTouchHelper
- private lateinit var editDelegate: CategoriesEditDelegate
+ private lateinit var selectionController: ListSelectionController
+ private var reorderHelper: ItemTouchHelper? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityCategoriesBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
- adapter = CategoriesAdapter(this, this)
- editDelegate = CategoriesEditDelegate(this, this)
+ adapter = CategoriesAdapter(get(), this, this, this)
+ selectionController = ListSelectionController(
+ activity = this,
+ decoration = CategoriesSelectionDecoration(this),
+ registryOwner = this,
+ callback = CategoriesSelectionCallback(binding.recyclerView, viewModel),
+ )
+ binding.buttonDone.setOnClickListener(this)
+ selectionController.attachToRecyclerView(binding.recyclerView)
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter
binding.fabAdd.setOnClickListener(this)
- reorderHelper = ItemTouchHelper(ReorderHelperCallback())
- reorderHelper.attachToRecyclerView(binding.recyclerView)
- viewModel.allCategories.observe(this, ::onCategoriesChanged)
+ viewModel.detalizedCategories.observe(this, ::onCategoriesChanged)
viewModel.onError.observe(this, ::onError)
+ viewModel.isInReorderMode.observe(this, ::onReorderModeChanged)
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ super.onCreateOptionsMenu(menu)
+ menuInflater.inflate(R.menu.opt_categories, menu)
+ return true
+ }
+
+ override fun onPrepareOptionsMenu(menu: Menu): Boolean {
+ menu.findItem(R.id.action_reorder)?.isVisible = !viewModel.isInReorderMode()
+ return super.onPrepareOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ R.id.action_reorder -> {
+ viewModel.setReorderMode(true)
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ override fun onBackPressed() {
+ if (viewModel.isInReorderMode()) {
+ viewModel.setReorderMode(false)
+ } else {
+ super.onBackPressed()
+ }
}
override fun onClick(v: View) {
when (v.id) {
+ R.id.button_done -> viewModel.setReorderMode(false)
R.id.fab_add -> startActivity(FavouritesCategoryEditActivity.newIntent(this))
}
}
override fun onItemClick(item: FavouriteCategory, view: View) {
- val menu = PopupMenu(view.context, view)
- menu.inflate(R.menu.popup_category)
- menu.setOnMenuItemClickListener { menuItem ->
- when (menuItem.itemId) {
- R.id.action_remove -> editDelegate.deleteCategory(item)
- R.id.action_edit -> startActivity(FavouritesCategoryEditActivity.newIntent(this, item.id))
- }
- true
+ if (viewModel.isInReorderMode() || selectionController.onItemClick(item.id)) {
+ return
}
- menu.show()
+ val intent = FavouritesActivity.newIntent(this, item)
+ val options = scaleUpActivityOptionsOf(view)
+ startActivity(intent, options.toBundle())
}
override fun onItemLongClick(item: FavouriteCategory, view: View): Boolean {
- val viewHolder = binding.recyclerView.findContainingViewHolder(view) ?: return false
- reorderHelper.startDrag(viewHolder)
- return true
+ return !viewModel.isInReorderMode() && selectionController.onItemLongClick(item.id)
}
- override fun onAllCategoriesToggle(isVisible: Boolean) {
- viewModel.setAllCategoriesVisible(isVisible)
+ override fun onDragHandleTouch(holder: RecyclerView.ViewHolder): Boolean {
+ return reorderHelper?.startDrag(holder) != null
}
+ override fun onRetryClick(error: Throwable) = Unit
+
+ override fun onEmptyActionClick() = Unit
+
override fun onWindowInsetsChanged(insets: Insets) {
binding.fabAdd.updateLayoutParams {
rightMargin = topMargin + insets.right
@@ -96,9 +137,8 @@ class CategoriesActivity :
)
}
- private fun onCategoriesChanged(categories: List) {
+ private fun onCategoriesChanged(categories: List) {
adapter.items = categories
- binding.textViewHolder.isVisible = categories.isEmpty()
}
private fun onError(e: Throwable) {
@@ -106,8 +146,25 @@ class CategoriesActivity :
.show()
}
- override fun onDeleteCategory(category: FavouriteCategory) {
- viewModel.deleteCategory(category.id)
+ private fun onReorderModeChanged(isReorderMode: Boolean) {
+ val transition = Fade().apply {
+ duration = resources.getInteger(android.R.integer.config_shortAnimTime).toLong()
+ }
+ TransitionManager.beginDelayedTransition(binding.toolbar, transition)
+ reorderHelper?.attachToRecyclerView(null)
+ reorderHelper = if (isReorderMode) {
+ selectionController.clear()
+ binding.fabAdd.hide()
+ ItemTouchHelper(ReorderHelperCallback()).apply {
+ attachToRecyclerView(binding.recyclerView)
+ }
+ } else {
+ binding.fabAdd.show()
+ null
+ }
+ binding.recyclerView.isNestedScrollingEnabled = !isReorderMode
+ invalidateOptionsMenu()
+ binding.buttonDone.isVisible = isReorderMode
}
private inner class ReorderHelperCallback : ItemTouchHelper.SimpleCallback(
@@ -152,6 +209,6 @@ class CategoriesActivity :
SortOrder.RATING,
)
- fun newIntent(context: Context) = Intent(context, CategoriesActivity::class.java)
+ fun newIntent(context: Context) = Intent(context, FavouriteCategoriesActivity::class.java)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesListListener.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesListListener.kt
new file mode 100644
index 000000000..7819d0112
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesListListener.kt
@@ -0,0 +1,10 @@
+package org.koitharu.kotatsu.favourites.ui.categories
+
+import androidx.recyclerview.widget.RecyclerView
+import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
+import org.koitharu.kotatsu.core.model.FavouriteCategory
+
+interface FavouriteCategoriesListListener : OnListItemClickListener {
+
+ fun onDragHandleTouch(holder: RecyclerView.ViewHolder): Boolean
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt
index 1e24d033f..c0b6a9927 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt
@@ -1,16 +1,19 @@
package org.koitharu.kotatsu.favourites.ui.categories
+import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.base.ui.BaseViewModel
-import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings
-import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
+import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+import org.koitharu.kotatsu.utils.ext.mapItems
+import org.koitharu.kotatsu.utils.ext.requireValue
import java.util.*
class FavouritesCategoriesViewModel(
@@ -19,20 +22,33 @@ class FavouritesCategoriesViewModel(
) : BaseViewModel() {
private var reorderJob: Job? = null
+ private val isReorder = MutableStateFlow(false)
- val allCategories = combine(
- repository.observeCategories(),
- observeAllCategoriesVisible(),
- ) { list, showAll ->
- mapCategories(list, showAll, true)
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
-
- val visibleCategories = combine(
- repository.observeCategories(),
- observeAllCategoriesVisible(),
- ) { list, showAll ->
- mapCategories(list, showAll, showAll && list.isNotEmpty())
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
+ val isInReorderMode = isReorder.asLiveData(viewModelScope.coroutineContext)
+
+ val allCategories = repository.observeCategories()
+ .mapItems {
+ CategoryListModel(
+ mangaCount = 0,
+ covers = listOf(),
+ category = it,
+ isReorderMode = false,
+ )
+ }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
+
+ val detalizedCategories = combine(
+ repository.observeCategoriesWithDetails(),
+ isReorder,
+ ) { list, reordering ->
+ list.map { (category, covers) ->
+ CategoryListModel(
+ mangaCount = covers.size,
+ covers = covers.take(3),
+ category = category,
+ isReorderMode = reordering,
+ )
+ }
+ }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
fun deleteCategory(id: Long) {
launchJob {
@@ -40,38 +56,33 @@ class FavouritesCategoriesViewModel(
}
}
+ fun deleteCategories(ids: Set) {
+ launchJob {
+ repository.removeCategories(ids)
+ }
+ }
+
fun setAllCategoriesVisible(isVisible: Boolean) {
settings.isAllFavouritesVisible = isVisible
}
+ fun isInReorderMode(): Boolean = isReorder.value
+
+ fun setReorderMode(isReorderMode: Boolean) {
+ isReorder.value = isReorderMode
+ }
+
fun reorderCategories(oldPos: Int, newPos: Int) {
val prevJob = reorderJob
reorderJob = launchJob(Dispatchers.Default) {
prevJob?.join()
- val items = allCategories.value ?: error("This should not happen")
- val ids = items.mapTo(ArrayList(items.size)) { it.id }
+ val items = detalizedCategories.requireValue()
+ val ids = items.mapNotNullTo(ArrayList(items.size)) {
+ (it as? CategoryListModel)?.category?.id
+ }
Collections.swap(ids, oldPos, newPos)
ids.remove(0L)
repository.reorderCategories(ids)
}
}
-
- private fun mapCategories(
- categories: List,
- isAllCategoriesVisible: Boolean,
- withAllCategoriesItem: Boolean,
- ): List {
- val result = ArrayList(categories.size + 1)
- if (withAllCategoriesItem) {
- result.add(CategoryListModel.All(isAllCategoriesVisible))
- }
- categories.mapTo(result) {
- CategoryListModel.CategoryItem(it)
- }
- return result
- }
-
- private fun observeAllCategoriesVisible() = settings.observeAsFlow(AppSettings.KEY_ALL_FAVOURITES_VISIBLE) {
- isAllFavouritesVisible
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/AllCategoriesAD.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/AllCategoriesAD.kt
deleted file mode 100644
index 113198bfa..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/AllCategoriesAD.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package org.koitharu.kotatsu.favourites.ui.categories.adapter
-
-import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
-import org.koitharu.kotatsu.databinding.ItemCategoriesAllBinding
-import org.koitharu.kotatsu.favourites.ui.categories.AllCategoriesToggleListener
-
-fun allCategoriesAD(
- allCategoriesToggleListener: AllCategoriesToggleListener,
-) = adapterDelegateViewBinding(
- { inflater, parent -> ItemCategoriesAllBinding.inflate(inflater, parent, false) }
-) {
-
- binding.imageViewToggle.setOnClickListener {
- allCategoriesToggleListener.onAllCategoriesToggle(!item.isVisible)
- }
-
- bind {
- binding.imageViewToggle.isChecked = item.isVisible
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt
new file mode 100644
index 000000000..0e5394f8f
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt
@@ -0,0 +1,59 @@
+package org.koitharu.kotatsu.favourites.ui.categories.adapter
+
+import androidx.lifecycle.LifecycleOwner
+import androidx.recyclerview.widget.DiffUtil
+import coil.ImageLoader
+import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
+import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
+import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
+import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
+import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
+import org.koitharu.kotatsu.list.ui.model.ListModel
+import kotlin.jvm.internal.Intrinsics
+
+class CategoriesAdapter(
+ coil: ImageLoader,
+ lifecycleOwner: LifecycleOwner,
+ onItemClickListener: FavouriteCategoriesListListener,
+ listListener: ListStateHolderListener,
+) : AsyncListDifferDelegationAdapter(DiffCallback()) {
+
+ init {
+ delegatesManager.addDelegate(categoryAD(coil, lifecycleOwner, onItemClickListener))
+ .addDelegate(emptyStateListAD(listListener))
+ .addDelegate(loadingStateAD())
+ }
+
+ private class DiffCallback : DiffUtil.ItemCallback() {
+
+ override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
+ return when {
+ oldItem is CategoryListModel && newItem is CategoryListModel -> {
+ oldItem.category.id == newItem.category.id
+ }
+ else -> oldItem.javaClass == newItem.javaClass
+ }
+ }
+
+ override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
+ return Intrinsics.areEqual(oldItem, newItem)
+ }
+
+ override fun getChangePayload(oldItem: ListModel, newItem: ListModel): Any? {
+ return when {
+ oldItem is CategoryListModel && newItem is CategoryListModel -> {
+ if (oldItem.category == newItem.category &&
+ oldItem.mangaCount == newItem.mangaCount &&
+ oldItem.covers == newItem.covers &&
+ oldItem.isReorderMode != newItem.isReorderMode
+ ) {
+ Unit
+ } else {
+ super.getChangePayload(oldItem, newItem)
+ }
+ }
+ else -> super.getChangePayload(oldItem, newItem)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt
index e64e36e5a..41f0f142a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt
@@ -1,30 +1,90 @@
package org.koitharu.kotatsu.favourites.ui.categories.adapter
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
import android.view.MotionEvent
+import android.view.View
+import android.view.View.*
+import androidx.core.graphics.ColorUtils
+import androidx.core.view.isVisible
+import androidx.core.widget.ImageViewCompat
+import androidx.lifecycle.LifecycleOwner
+import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
-import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
-import org.koitharu.kotatsu.core.model.FavouriteCategory
+import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemCategoryBinding
+import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
+import org.koitharu.kotatsu.list.ui.model.ListModel
+import org.koitharu.kotatsu.utils.ext.*
fun categoryAD(
- clickListener: OnListItemClickListener
-) = adapterDelegateViewBinding(
+ coil: ImageLoader,
+ lifecycleOwner: LifecycleOwner,
+ clickListener: FavouriteCategoriesListListener,
+) = adapterDelegateViewBinding(
{ inflater, parent -> ItemCategoryBinding.inflate(inflater, parent, false) }
) {
- binding.imageViewMore.setOnClickListener {
- clickListener.onItemClick(item.category, it)
+ val eventListener = object : OnClickListener, OnLongClickListener, OnTouchListener {
+ override fun onClick(v: View) = clickListener.onItemClick(item.category, binding.imageViewCover1)
+ override fun onLongClick(v: View) = clickListener.onItemLongClick(item.category, binding.imageViewCover1)
+ override fun onTouch(v: View?, event: MotionEvent): Boolean = item.isReorderMode &&
+ event.actionMasked == MotionEvent.ACTION_DOWN &&
+ clickListener.onDragHandleTouch(this@adapterDelegateViewBinding)
}
- @Suppress("ClickableViewAccessibility")
- binding.imageViewHandle.setOnTouchListener { _, event ->
- if (event.actionMasked == MotionEvent.ACTION_DOWN) {
- clickListener.onItemLongClick(item.category, itemView)
+ val backgroundColor = context.getThemeColor(android.R.attr.colorBackground)
+ ImageViewCompat.setImageTintList(
+ binding.imageViewCover3,
+ ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153))
+ )
+ ImageViewCompat.setImageTintList(
+ binding.imageViewCover2,
+ ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 76))
+ )
+ binding.imageViewCover2.backgroundTintList =
+ ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 76))
+ binding.imageViewCover3.backgroundTintList =
+ ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153))
+ val fallback = ColorDrawable(Color.TRANSPARENT)
+ val coverViews = arrayOf(binding.imageViewCover1, binding.imageViewCover2, binding.imageViewCover3)
+ val crossFadeDuration = (context.resources.getInteger(R.integer.config_defaultAnimTime) *
+ context.animatorDurationScale).toInt()
+ itemView.setOnClickListener(eventListener)
+ itemView.setOnLongClickListener(eventListener)
+ itemView.setOnTouchListener(eventListener)
+
+ bind { payloads ->
+ binding.imageViewHandle.isVisible = item.isReorderMode
+ if (payloads.isNotEmpty()) {
+ return@bind
+ }
+ binding.textViewTitle.text = item.category.title
+ binding.textViewSubtitle.text = if (item.mangaCount == 0) {
+ getString(R.string.empty)
} else {
- false
+ context.resources.getQuantityString(
+ R.plurals.items,
+ item.mangaCount,
+ item.mangaCount,
+ )
+ }
+ repeat(coverViews.size) { i ->
+ coverViews[i].newImageRequest(item.covers.getOrNull(i))?.run {
+ placeholder(R.drawable.ic_placeholder)
+ fallback(fallback)
+ crossfade(crossFadeDuration * (i + 1))
+ error(R.drawable.ic_placeholder)
+ allowRgb565(true)
+ lifecycle(lifecycleOwner)
+ enqueueWith(coil)
+ }
}
}
- bind {
- binding.textViewTitle.text = item.category.title
+ onViewRecycled {
+ coverViews.forEach {
+ it.disposeImageRequest()
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt
index 899b73e1c..abc5e6314 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt
@@ -3,59 +3,36 @@ package org.koitharu.kotatsu.favourites.ui.categories.adapter
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.list.ui.model.ListModel
-sealed interface CategoryListModel : ListModel {
+class CategoryListModel(
+ val mangaCount: Int,
+ val covers: List,
+ val category: FavouriteCategory,
+ val isReorderMode: Boolean,
+) : ListModel {
- val id: Long
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
- class All(
- val isVisible: Boolean,
- ) : CategoryListModel {
+ other as CategoryListModel
- override val id: Long = 0L
+ if (mangaCount != other.mangaCount) return false
+ if (isReorderMode != other.isReorderMode) return false
+ if (covers != other.covers) return false
+ if (category.id != other.category.id) return false
+ if (category.title != other.category.title) return false
+ if (category.order != other.category.order) return false
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
-
- other as All
-
- if (isVisible != other.isVisible) return false
-
- return true
- }
-
- override fun hashCode(): Int {
- return isVisible.hashCode()
- }
+ return true
}
- class CategoryItem(
- val category: FavouriteCategory,
- ) : CategoryListModel {
-
- override val id: Long
- get() = category.id
-
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
-
- other as CategoryItem
-
- if (category.id != other.category.id) return false
- if (category.title != other.category.title) return false
- if (category.order != other.category.order) return false
- if (category.isTrackingEnabled != other.category.isTrackingEnabled) return false
-
- return true
- }
-
- override fun hashCode(): Int {
- var result = category.id.hashCode()
- result = 31 * result + category.title.hashCode()
- result = 31 * result + category.order.hashCode()
- result = 31 * result + category.isTrackingEnabled.hashCode()
- return result
- }
+ override fun hashCode(): Int {
+ var result = mangaCount
+ result = 31 * result + isReorderMode.hashCode()
+ result = 31 * result + covers.hashCode()
+ result = 31 * result + category.id.hashCode()
+ result = 31 * result + category.title.hashCode()
+ result = 31 * result + category.order.hashCode()
+ return result
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt
index 27239c0dc..24cb1f533 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt
@@ -3,13 +3,16 @@ package org.koitharu.kotatsu.favourites.ui.categories.edit
import android.content.Context
import android.content.Intent
import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
+import android.text.Editable
+import android.text.TextWatcher
import android.view.View
+import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
+import android.widget.Filter
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
+import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
@@ -18,11 +21,12 @@ import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.databinding.ActivityCategoryEditBinding
-import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
+import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
-class FavouritesCategoryEditActivity : BaseActivity(), AdapterView.OnItemClickListener {
+class FavouritesCategoryEditActivity : BaseActivity(), AdapterView.OnItemClickListener,
+ View.OnClickListener, TextWatcher {
private val viewModel by viewModel {
parametersOf(intent.getLongExtra(EXTRA_ID, NO_ID))
@@ -37,6 +41,9 @@ class FavouritesCategoryEditActivity : BaseActivity
setHomeAsUpIndicator(com.google.android.material.R.drawable.abc_ic_clear_material)
}
initSortSpinner()
+ binding.buttonDone.setOnClickListener(this)
+ binding.editName.addTextChangedListener(this)
+ afterTextChanged(binding.editName.text)
viewModel.onSaved.observe(this) { finishAfterTransition() }
viewModel.category.observe(this, ::onCategoryChanged)
@@ -60,22 +67,22 @@ class FavouritesCategoryEditActivity : BaseActivity
}
}
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.opt_config, menu)
- menu.findItem(R.id.action_done)?.setTitle(R.string.save)
- return super.onCreateOptionsMenu(menu)
- }
-
- override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
- R.id.action_done -> {
- viewModel.save(
- title = binding.editName.text?.toString().orEmpty(),
+ override fun onClick(v: View) {
+ when (v.id) {
+ R.id.button_done -> viewModel.save(
+ title = binding.editName.text?.toString()?.trim().orEmpty(),
sortOrder = getSelectedSortOrder(),
isTrackerEnabled = binding.switchTracker.isChecked,
)
- true
}
- else -> super.onOptionsItemSelected(item)
+ }
+
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
+
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
+
+ override fun afterTextChanged(s: Editable?) {
+ binding.buttonDone.isEnabled = !s.isNullOrBlank()
}
override fun onWindowInsetsChanged(insets: Insets) {
@@ -84,13 +91,13 @@ class FavouritesCategoryEditActivity : BaseActivity
right = insets.right,
bottom = insets.bottom,
)
- binding.toolbar.updatePadding(
- top = insets.top,
- )
+ binding.toolbar.updateLayoutParams {
+ topMargin = insets.top
+ }
}
override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
- selectedSortOrder = CategoriesActivity.SORT_ORDERS.getOrNull(position)
+ selectedSortOrder = FavouriteCategoriesActivity.SORT_ORDERS.getOrNull(position)
}
private fun onCategoryChanged(category: FavouriteCategory?) {
@@ -120,17 +127,30 @@ class FavouritesCategoryEditActivity : BaseActivity
}
private fun initSortSpinner() {
- val entries = CategoriesActivity.SORT_ORDERS.map { getString(it.titleRes) }
- val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, entries)
+ val entries = FavouriteCategoriesActivity.SORT_ORDERS.map { getString(it.titleRes) }
+ val adapter = SortAdapter(this, entries)
binding.editSort.setAdapter(adapter)
binding.editSort.onItemClickListener = this
}
private fun getSelectedSortOrder(): SortOrder {
selectedSortOrder?.let { return it }
- val entries = CategoriesActivity.SORT_ORDERS.map { getString(it.titleRes) }
+ val entries = FavouriteCategoriesActivity.SORT_ORDERS.map { getString(it.titleRes) }
val index = entries.indexOf(binding.editSort.text.toString())
- return CategoriesActivity.SORT_ORDERS.getOrNull(index) ?: SortOrder.NEWEST
+ return FavouriteCategoriesActivity.SORT_ORDERS.getOrNull(index) ?: SortOrder.NEWEST
+ }
+
+ private class SortAdapter(
+ context: Context,
+ entries: List,
+ ) : ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, entries) {
+
+ override fun getFilter(): Filter = EmptyFilter
+
+ private object EmptyFilter : Filter() {
+ override fun performFiltering(constraint: CharSequence?) = FilterResults()
+ override fun publishResults(constraint: CharSequence?, results: FilterResults?) = Unit
+ }
}
companion object {
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt
index 78446dd14..9f67964bd 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt
@@ -42,6 +42,7 @@ class FavouritesCategoryEditViewModel(
isTrackerEnabled: Boolean,
) {
launchLoadingJob {
+ check(title.isNotEmpty())
if (categoryId == NO_ID) {
repository.createCategory(title, sortOrder, isTrackerEnabled)
} else {
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt
index aa2bacbbe..f8b0238fa 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesBottomSheet.kt
@@ -2,21 +2,17 @@ package org.koitharu.kotatsu.favourites.ui.categories.select
import android.os.Bundle
import android.view.LayoutInflater
-import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
-import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.FragmentManager
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
-import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.databinding.DialogFavoriteCategoriesBinding
-import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
@@ -27,8 +23,7 @@ import org.koitharu.kotatsu.utils.ext.withArgs
class FavouriteCategoriesBottomSheet :
BaseBottomSheet(),
OnListItemClickListener,
- CategoriesEditDelegate.CategoriesEditCallback,
- Toolbar.OnMenuItemClickListener, View.OnClickListener {
+ View.OnClickListener {
private val viewModel by viewModel {
parametersOf(requireNotNull(arguments?.getParcelableArrayList(KEY_MANGA_LIST)).map { it.manga })
@@ -45,7 +40,7 @@ class FavouriteCategoriesBottomSheet :
super.onViewCreated(view, savedInstanceState)
adapter = MangaCategoriesAdapter(this)
binding.recyclerViewCategories.adapter = adapter
- binding.toolbar.setOnMenuItemClickListener(this)
+ binding.buttonDone.setOnClickListener(this)
binding.itemCreate.setOnClickListener(this)
viewModel.content.observe(viewLifecycleOwner, this::onContentChanged)
@@ -57,19 +52,10 @@ class FavouriteCategoriesBottomSheet :
super.onDestroyView()
}
- override fun onMenuItemClick(item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.action_done -> {
- dismiss()
- true
- }
- else -> false
- }
- }
-
override fun onClick(v: View) {
when (v.id) {
R.id.item_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext()))
+ R.id.button_done -> dismiss()
}
}
@@ -77,8 +63,6 @@ class FavouriteCategoriesBottomSheet :
viewModel.setChecked(item.id, !item.isChecked)
}
- override fun onDeleteCategory(category: FavouriteCategory) = Unit
-
private fun onContentChanged(categories: List) {
adapter?.items = categories
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt
index b9f906549..0984c47d8 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt
@@ -26,7 +26,7 @@ class MangaCategoriesViewModel(
isChecked = it.id in checked
)
}
- }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
+ }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
fun setChecked(categoryId: Long, isChecked: Boolean) {
launchJob(Dispatchers.Default) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt
index 6d31df17d..cbb569de8 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt
@@ -2,11 +2,10 @@ package org.koitharu.kotatsu.favourites.ui.list
import android.os.Bundle
import android.view.Menu
-import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.view.ActionMode
-import androidx.core.view.iterator
+import androidx.appcompat.widget.PopupMenu
import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
@@ -14,12 +13,12 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.core.ui.titleRes
-import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
+import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.withArgs
-class FavouritesListFragment : MangaListFragment() {
+class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener {
override val viewModel by viewModel {
parametersOf(categoryId)
@@ -38,41 +37,19 @@ class FavouritesListFragment : MangaListFragment() {
override fun onScrolledToEnd() = Unit
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- super.onCreateOptionsMenu(menu, inflater)
- if (categoryId != NO_ID) {
- inflater.inflate(R.menu.opt_favourites_list, menu)
- menu.findItem(R.id.action_order)?.subMenu?.let { submenu ->
- for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
- val menuItem = submenu.add(R.id.group_order, Menu.NONE, i, item.titleRes)
- menuItem.isCheckable = true
- }
- submenu.setGroupCheckable(R.id.group_order, true, true)
- }
+ override fun onFilterClick(view: View?) {
+ val menu = PopupMenu(view?.context ?: return, view)
+ menu.setOnMenuItemClickListener(this)
+ for ((i, item) in FavouriteCategoriesActivity.SORT_ORDERS.withIndex()) {
+ menu.menu.add(Menu.NONE, Menu.NONE, i, item.titleRes)
}
+ menu.show()
}
- override fun onPrepareOptionsMenu(menu: Menu) {
- super.onPrepareOptionsMenu(menu)
- menu.findItem(R.id.action_order)?.subMenu?.let { submenu ->
- val selectedOrder = viewModel.sortOrder.value
- for (item in submenu) {
- val order = CategoriesActivity.SORT_ORDERS.getOrNull(item.order)
- item.isChecked = order == selectedOrder
- }
- }
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when {
- item.itemId == R.id.action_order -> false
- item.groupId == R.id.group_order -> {
- val order = CategoriesActivity.SORT_ORDERS.getOrNull(item.order) ?: return false
- viewModel.setSortOrder(order)
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
+ override fun onMenuItemClick(item: MenuItem): Boolean {
+ val order = FavouriteCategoriesActivity.SORT_ORDERS.getOrNull(item.order) ?: return false
+ viewModel.setSortOrder(order)
+ return true
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
@@ -116,4 +93,4 @@ class FavouritesListFragment : MangaListFragment() {
putLong(ARG_CATEGORY_ID, categoryId)
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt
index dce0a8803..c444cfa09 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt
@@ -13,7 +13,9 @@ import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
-import org.koitharu.kotatsu.list.domain.CountersProvider
+import org.koitharu.kotatsu.history.domain.HistoryRepository
+import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
+import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState
@@ -28,18 +30,19 @@ class FavouritesListViewModel(
private val categoryId: Long,
private val repository: FavouritesRepository,
private val trackingRepository: TrackingRepository,
- settings: AppSettings,
-) : MangaListViewModel(settings), CountersProvider {
+ private val historyRepository: HistoryRepository,
+ private val settings: AppSettings,
+) : MangaListViewModel(settings), ListExtraProvider {
var categoryName: String? = null
private set
- var sortOrder: LiveData = if (categoryId == NO_ID) {
+ val sortOrder: LiveData = if (categoryId == NO_ID) {
MutableLiveData(null)
} else {
repository.observeCategory(categoryId)
.map { it?.order }
- .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
+ .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
}
override val content = combine(
@@ -53,7 +56,7 @@ class FavouritesListViewModel(
when {
list.isEmpty() -> listOf(
EmptyState(
- icon = R.drawable.ic_heart_outline,
+ icon = R.drawable.ic_empty_favourites,
textPrimary = R.string.text_empty_holder_primary,
textSecondary = if (categoryId == NO_ID) {
R.string.you_have_not_favourites_yet
@@ -113,4 +116,12 @@ class FavouritesListViewModel(
override suspend fun getCounter(mangaId: Long): Int {
return trackingRepository.getNewChaptersCount(mangaId)
}
-}
\ No newline at end of file
+
+ override suspend fun getProgress(mangaId: Long): Float {
+ return if (settings.isReadingIndicatorsEnabled) {
+ historyRepository.getProgress(mangaId)
+ } else {
+ PROGRESS_NONE
+ }
+ }
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt
index 246cb3a5f..029d024f0 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt
@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel
val historyModule
get() = module {
- factory { HistoryRepository(get(), get(), get()) }
- viewModel { HistoryListViewModel(get(), get(), get(), get()) }
+ single { HistoryRepository(get(), get(), get(), getAll()) }
+
+ viewModel { HistoryListViewModel(get(), get(), get()) }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/EntityMapping.kt
index c03300b99..0e5624559 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/data/EntityMapping.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/data/EntityMapping.kt
@@ -1,12 +1,13 @@
package org.koitharu.kotatsu.history.data
-import java.util.*
import org.koitharu.kotatsu.core.model.MangaHistory
+import java.util.*
fun HistoryEntity.toMangaHistory() = MangaHistory(
createdAt = Date(createdAt),
updatedAt = Date(updatedAt),
chapterId = chapterId,
page = page,
- scroll = scroll.toInt()
+ scroll = scroll.toInt(),
+ percent = percent,
)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt
index ba68054e4..10a912494 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt
@@ -46,19 +46,20 @@ abstract class HistoryDao {
@Query("UPDATE history SET deleted_at = :now WHERE deleted_at = 0")
abstract suspend fun clear(now: Long = System.currentTimeMillis())
+ @Query("SELECT percent FROM history WHERE manga_id = :id AND deleted_at = 0")
+ abstract suspend fun findProgress(id: Long): Float?
+
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(entity: HistoryEntity): Long
- @Query(
- "UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, updated_at = :updatedAt " +
- "WHERE manga_id = :mangaId"
- )
+ @Query("UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, percent = :percent, updated_at = :updatedAt WHERE manga_id = :mangaId")
abstract suspend fun update(
mangaId: Long,
page: Int,
chapterId: Long,
scroll: Float,
- updatedAt: Long
+ percent: Float,
+ updatedAt: Long,
): Int
@Query("UPDATE history SET deleted_at = :now WHERE manga_id = :mangaId")
@@ -69,8 +70,17 @@ abstract class HistoryDao {
@Query("DELETE FROM history WHERE deleted_at != 0 AND deleted_at < :maxDeletionTime")
abstract suspend fun gc(maxDeletionTime: Long)
- suspend fun update(entity: HistoryEntity) =
- update(entity.mangaId, entity.page, entity.chapterId, entity.scroll, entity.updatedAt)
+ @Query("DELETE FROM history WHERE created_at >= :minDate")
+ abstract suspend fun deleteAfter(minDate: Long)
+
+ suspend fun update(entity: HistoryEntity) = update(
+ mangaId = entity.mangaId,
+ page = entity.page,
+ chapterId = entity.chapterId,
+ scroll = entity.scroll,
+ percent = entity.percent,
+ updatedAt = entity.updatedAt
+ )
@Transaction
open suspend fun upsert(entity: HistoryEntity): Boolean {
@@ -88,4 +98,4 @@ abstract class HistoryDao {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt
index 6ce1492e3..c499e8c37 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt
@@ -18,7 +18,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
)
]
)
-class HistoryEntity(
+data class HistoryEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "created_at") val createdAt: Long,
@@ -26,5 +26,6 @@ class HistoryEntity(
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "page") val page: Int,
@ColumnInfo(name = "scroll") val scroll: Float,
+ @ColumnInfo(name = "percent") val percent: Float,
@ColumnInfo(name = "deleted_at") val deletedAt: Long,
-)
\ No newline at end of file
+)
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt
index 57e4d6b75..52a34fd91 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt
@@ -13,13 +13,18 @@ import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.toMangaHistory
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
+import org.koitharu.kotatsu.scrobbling.domain.Scrobbler
+import org.koitharu.kotatsu.scrobbling.domain.tryScrobble
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.mapItems
+const val PROGRESS_NONE = -1f
+
class HistoryRepository(
private val db: MangaDatabase,
private val trackingRepository: TrackingRepository,
private val settings: AppSettings,
+ private val scrobblers: List,
) {
suspend fun getList(offset: Int, limit: Int = 20): List {
@@ -59,8 +64,8 @@ class HistoryRepository(
.distinctUntilChanged()
}
- suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
- if (manga.isNsfw && settings.isHistoryExcludeNsfw) {
+ suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int, percent: Float) {
+ if (manga.isNsfw && settings.isHistoryExcludeNsfw || settings.isIncognitoModeEnabled) {
return
}
val tags = manga.tags.toEntities()
@@ -75,10 +80,15 @@ class HistoryRepository(
chapterId = chapterId,
page = page,
scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
+ percent = percent,
deletedAt = 0L,
)
)
- trackingRepository.upsert(manga)
+ trackingRepository.syncWithHistory(manga, chapterId)
+ val chapter = manga.chapters?.find { x -> x.id == chapterId }
+ if (chapter != null) {
+ scrobblers.forEach { it.tryScrobble(manga.id, chapter) }
+ }
}
}
@@ -86,6 +96,10 @@ class HistoryRepository(
return db.historyDao.find(manga.id)?.toMangaHistory()
}
+ suspend fun getProgress(mangaId: Long): Float {
+ return db.historyDao.findProgress(mangaId) ?: PROGRESS_NONE
+ }
+
suspend fun clear() {
db.historyDao.clear()
}
@@ -94,6 +108,10 @@ class HistoryRepository(
db.historyDao.delete(manga.id)
}
+ suspend fun deleteAfter(minDate: Long) {
+ db.historyDao.delete(minDate)
+ }
+
suspend fun delete(ids: Collection): ReversibleHandle {
db.withTransaction {
for (id in ids) {
@@ -126,4 +144,4 @@ class HistoryRepository(
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryActivity.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryActivity.kt
new file mode 100644
index 000000000..b81bae96e
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryActivity.kt
@@ -0,0 +1,45 @@
+package org.koitharu.kotatsu.history.ui
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.ViewGroup
+import androidx.core.graphics.Insets
+import androidx.core.view.updateLayoutParams
+import androidx.core.view.updatePadding
+import androidx.fragment.app.commit
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.BaseActivity
+import org.koitharu.kotatsu.databinding.ActivityContainerBinding
+
+class HistoryActivity : BaseActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(ActivityContainerBinding.inflate(layoutInflater))
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ val fm = supportFragmentManager
+ if (fm.findFragmentById(R.id.container) == null) {
+ fm.commit {
+ val fragment = HistoryListFragment.newInstance()
+ replace(R.id.container, fragment)
+ }
+ }
+ }
+
+ override fun onWindowInsetsChanged(insets: Insets) {
+ binding.toolbar.updateLayoutParams {
+ leftMargin = insets.left
+ rightMargin = insets.right
+ }
+ binding.root.updatePadding(
+ left = insets.left,
+ right = insets.right,
+ )
+ }
+
+ companion object {
+
+ fun newIntent(context: Context) = Intent(context, HistoryActivity::class.java)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListAdapter.kt
new file mode 100644
index 000000000..b44f5db90
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListAdapter.kt
@@ -0,0 +1,27 @@
+package org.koitharu.kotatsu.history.ui
+
+import android.content.Context
+import androidx.lifecycle.LifecycleOwner
+import coil.ImageLoader
+import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller
+import org.koitharu.kotatsu.core.ui.DateTimeAgo
+import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
+import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
+
+class HistoryListAdapter(
+ coil: ImageLoader,
+ lifecycleOwner: LifecycleOwner,
+ listener: MangaListListener
+) : MangaListAdapter(coil, lifecycleOwner, listener), FastScroller.SectionIndexer {
+
+ override fun getSectionText(context: Context, position: Int): CharSequence {
+ val list = items
+ for (i in (0..position).reversed()) {
+ val item = list[i]
+ if (item is DateTimeAgo) {
+ return item.format(context.resources)
+ }
+ }
+ return ""
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt
index 27f4a86ca..bdd424098 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt
@@ -2,18 +2,18 @@ package org.koitharu.kotatsu.history.ui
import android.os.Bundle
import android.view.Menu
-import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.view.ActionMode
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
+import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.utils.ext.addMenuProvider
class HistoryListFragment : MangaListFragment() {
@@ -22,6 +22,7 @@ class HistoryListFragment : MangaListFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
+ addMenuProvider(HistoryListMenuProvider(view.context, viewModel))
viewModel.isGroupingEnabled.observe(viewLifecycleOwner) {
activity?.invalidateOptionsMenu()
}
@@ -30,37 +31,6 @@ class HistoryListFragment : MangaListFragment() {
override fun onScrolledToEnd() = Unit
- override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
- inflater.inflate(R.menu.opt_history, menu)
- super.onCreateOptionsMenu(menu, inflater)
- }
-
- override fun onPrepareOptionsMenu(menu: Menu) {
- super.onPrepareOptionsMenu(menu)
- menu.findItem(R.id.action_history_grouping)?.isChecked =
- viewModel.isGroupingEnabled.value == true
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.action_clear_history -> {
- MaterialAlertDialogBuilder(context ?: return false)
- .setTitle(R.string.clear_history)
- .setMessage(R.string.text_clear_history_prompt)
- .setNegativeButton(android.R.string.cancel, null)
- .setPositiveButton(R.string.clear) { _, _ ->
- viewModel.clearHistory()
- }.show()
- true
- }
- R.id.action_history_grouping -> {
- viewModel.setGrouping(!item.isChecked)
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_history, menu)
return super.onCreateActionMode(mode, menu)
@@ -84,6 +54,8 @@ class HistoryListFragment : MangaListFragment() {
}
}
+ override fun onCreateAdapter() = HistoryListAdapter(get(), viewLifecycleOwner, this)
+
private fun onItemsRemoved(reversibleHandle: ReversibleHandle) {
Snackbar.make(binding.recyclerView, R.string.removed_from_history, Snackbar.LENGTH_LONG)
.setAction(R.string.undo) { reversibleHandle.reverseAsync() }
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt
new file mode 100644
index 000000000..fe0daa58c
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt
@@ -0,0 +1,43 @@
+package org.koitharu.kotatsu.history.ui
+
+import android.content.Context
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import androidx.core.view.MenuProvider
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.koitharu.kotatsu.R
+import com.google.android.material.R as materialR
+
+class HistoryListMenuProvider(
+ private val context: Context,
+ private val viewModel: HistoryListViewModel,
+) : MenuProvider {
+
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.opt_history, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
+ R.id.action_clear_history -> {
+ MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
+ .setTitle(R.string.clear_history)
+ .setMessage(R.string.text_clear_history_prompt)
+ .setIcon(R.drawable.ic_delete)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(R.string.clear) { _, _ ->
+ viewModel.clearHistory()
+ }.show()
+ true
+ }
+ R.id.action_history_grouping -> {
+ viewModel.setGrouping(!menuItem.isChecked)
+ true
+ }
+ else -> false
+ }
+
+ override fun onPrepareMenu(menu: Menu) {
+ menu.findItem(R.id.action_history_grouping).isChecked = viewModel.isGroupingEnabled.value == true
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt
index 8e958baba..468d4ae91 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt
@@ -2,8 +2,6 @@ package org.koitharu.kotatsu.history.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
-import java.util.*
-import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
@@ -11,14 +9,13 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle
-import org.koitharu.kotatsu.base.domain.plus
-import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.MangaWithHistory
+import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
@@ -26,18 +23,19 @@ import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst
+import java.util.*
+import java.util.concurrent.TimeUnit
class HistoryListViewModel(
private val repository: HistoryRepository,
private val settings: AppSettings,
- private val shortcutsRepository: ShortcutsRepository,
private val trackingRepository: TrackingRepository,
) : MangaListViewModel(settings) {
val isGroupingEnabled = MutableLiveData()
val onItemsRemoved = SingleLiveEvent()
- private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { historyGrouping }
+ private val historyGrouping = settings.observeAsFlow(AppSettings.KEY_HISTORY_GROUPING) { isHistoryGroupingEnabled }
.onEach { isGroupingEnabled.postValue(it) }
override val content = combine(
@@ -48,7 +46,7 @@ class HistoryListViewModel(
when {
list.isEmpty() -> listOf(
EmptyState(
- icon = R.drawable.ic_history,
+ icon = R.drawable.ic_empty_history,
textPrimary = R.string.text_history_holder_primary,
textSecondary = R.string.text_history_holder_secondary,
actionStringRes = 0,
@@ -71,7 +69,6 @@ class HistoryListViewModel(
fun clearHistory() {
launchLoadingJob {
repository.clear()
- shortcutsRepository.updateShortcuts()
}
}
@@ -80,16 +77,13 @@ class HistoryListViewModel(
return
}
launchJob(Dispatchers.Default) {
- val handle = repository.delete(ids) + ReversibleHandle {
- shortcutsRepository.updateShortcuts()
- }
- shortcutsRepository.updateShortcuts()
+ val handle = repository.delete(ids)
onItemsRemoved.postCall(handle)
}
}
fun setGrouping(isGroupingEnabled: Boolean) {
- settings.historyGrouping = isGroupingEnabled
+ settings.isHistoryGroupingEnabled = isGroupingEnabled
}
private suspend fun mapList(
@@ -98,10 +92,8 @@ class HistoryListViewModel(
mode: ListMode
): List {
val result = ArrayList(if (grouped) (list.size * 1.4).toInt() else list.size + 1)
+ val showPercent = settings.isReadingIndicatorsEnabled
var prevDate: DateTimeAgo? = null
- if (!grouped) {
- result += ListHeader(null, R.string.history, null)
- }
for ((manga, history) in list) {
if (grouped) {
val date = timeAgo(history.updatedAt)
@@ -111,10 +103,11 @@ class HistoryListViewModel(
prevDate = date
}
val counter = trackingRepository.getNewChaptersCount(manga.id)
+ val percent = if (showPercent) history.percent else PROGRESS_NONE
result += when (mode) {
- ListMode.LIST -> manga.toListModel(counter)
- ListMode.DETAILED_LIST -> manga.toListDetailedModel(counter)
- ListMode.GRID -> manga.toGridModel(counter)
+ ListMode.LIST -> manga.toListModel(counter, percent)
+ ListMode.DETAILED_LIST -> manga.toListDetailedModel(counter, percent)
+ ListMode.GRID -> manga.toGridModel(counter, percent)
}
}
return result
@@ -132,4 +125,4 @@ class HistoryListViewModel(
else -> DateTimeAgo.LongAgo
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt
new file mode 100644
index 000000000..bedc80871
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt
@@ -0,0 +1,142 @@
+package org.koitharu.kotatsu.history.ui.util
+
+import android.content.Context
+import android.graphics.*
+import android.graphics.drawable.Drawable
+import androidx.annotation.StyleRes
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.core.graphics.ColorUtils
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
+import org.koitharu.kotatsu.utils.ext.scale
+
+class ReadingProgressDrawable(
+ context: Context,
+ @StyleRes styleResId: Int,
+) : Drawable() {
+
+ private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ private val checkDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_check)
+ private val lineColor: Int
+ private val outlineColor: Int
+ private val backgroundColor: Int
+ private val textColor: Int
+ private val textPattern = context.getString(R.string.percent_string_pattern)
+ private val textBounds = Rect()
+ private val tempRect = Rect()
+ private val hasBackground: Boolean
+ private val hasOutline: Boolean
+ private val hasText: Boolean
+ private val desiredHeight: Int
+ private val desiredWidth: Int
+ private val autoFitTextSize: Boolean
+
+ var progress: Float = PROGRESS_NONE
+ set(value) {
+ field = value
+ text = textPattern.format((value * 100f).toInt().toString())
+ paint.getTextBounds(text, 0, text.length, textBounds)
+ invalidateSelf()
+ }
+ private var text = ""
+
+ init {
+ val ta = context.obtainStyledAttributes(styleResId, R.styleable.ProgressDrawable)
+ desiredHeight = ta.getDimensionPixelSize(R.styleable.ProgressDrawable_android_height, -1)
+ desiredWidth = ta.getDimensionPixelSize(R.styleable.ProgressDrawable_android_width, -1)
+ autoFitTextSize = ta.getBoolean(R.styleable.ProgressDrawable_autoFitTextSize, false)
+ lineColor = ta.getColor(R.styleable.ProgressDrawable_android_strokeColor, Color.BLACK)
+ outlineColor = ta.getColor(R.styleable.ProgressDrawable_outlineColor, Color.TRANSPARENT)
+ backgroundColor = ColorUtils.setAlphaComponent(
+ ta.getColor(R.styleable.ProgressDrawable_android_fillColor, Color.TRANSPARENT),
+ (255 * ta.getFloat(R.styleable.ProgressDrawable_android_fillAlpha, 0f)).toInt(),
+ )
+ textColor = ta.getColor(R.styleable.ProgressDrawable_android_textColor, lineColor)
+ paint.strokeCap = Paint.Cap.ROUND
+ paint.textAlign = Paint.Align.CENTER
+ paint.textSize = ta.getDimension(R.styleable.ProgressDrawable_android_textSize, paint.textSize)
+ paint.strokeWidth = ta.getDimension(R.styleable.ProgressDrawable_strokeWidth, 1f)
+ ta.recycle()
+ hasBackground = Color.alpha(backgroundColor) != 0
+ hasOutline = Color.alpha(outlineColor) != 0
+ hasText = Color.alpha(textColor) != 0 && paint.textSize > 0
+ checkDrawable?.setTint(textColor)
+ }
+
+ override fun onBoundsChange(bounds: Rect) {
+ super.onBoundsChange(bounds)
+ if (autoFitTextSize) {
+ val innerWidth = bounds.width() - (paint.strokeWidth * 2f)
+ paint.textSize = getTextSizeForWidth(innerWidth, "100%")
+ paint.getTextBounds(text, 0, text.length, textBounds)
+ invalidateSelf()
+ }
+ }
+
+ override fun draw(canvas: Canvas) {
+ if (progress < 0f) {
+ return
+ }
+ val cx = bounds.exactCenterX()
+ val cy = bounds.exactCenterY()
+ val radius = minOf(bounds.width(), bounds.height()) / 2f
+ if (hasBackground) {
+ paint.style = Paint.Style.FILL
+ paint.color = backgroundColor
+ canvas.drawCircle(cx, cy, radius, paint)
+ }
+ val innerRadius = radius - paint.strokeWidth / 2.5f
+ paint.style = Paint.Style.STROKE
+ if (hasOutline) {
+ paint.color = outlineColor
+ canvas.drawCircle(cx, cy, innerRadius, paint)
+ }
+ paint.color = lineColor
+ canvas.drawArc(
+ cx - innerRadius,
+ cy - innerRadius,
+ cx + innerRadius,
+ cy + innerRadius,
+ -90f,
+ 360f * progress,
+ false,
+ paint,
+ )
+ if (hasText) {
+ if (checkDrawable != null && progress >= 1f - Math.ulp(progress)) {
+ tempRect.set(bounds)
+ tempRect.scale(0.6)
+ checkDrawable.bounds = tempRect
+ checkDrawable.draw(canvas)
+ } else {
+ paint.style = Paint.Style.FILL
+ paint.color = textColor
+ val ty = bounds.height() / 2f + textBounds.height() / 2f - textBounds.bottom
+ canvas.drawText(text, cx, ty, paint)
+ }
+ }
+ }
+
+ override fun setAlpha(alpha: Int) {
+ paint.alpha = alpha
+ }
+
+ override fun setColorFilter(colorFilter: ColorFilter?) {
+ paint.colorFilter = colorFilter
+ }
+
+ @Suppress("DeprecatedCallableAddReplaceWith")
+ @Deprecated("Deprecated in Java")
+ override fun getOpacity() = PixelFormat.TRANSLUCENT
+
+ override fun getIntrinsicHeight() = desiredHeight
+
+ override fun getIntrinsicWidth() = desiredWidth
+
+ private fun getTextSizeForWidth(width: Float, text: String): Float {
+ val testTextSize = 48f
+ paint.textSize = testTextSize
+ paint.getTextBounds(text, 0, text.length, tempRect)
+ return testTextSize * width / tempRect.width()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/util/ReadingProgressView.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/util/ReadingProgressView.kt
new file mode 100644
index 000000000..2bb906c99
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/util/ReadingProgressView.kt
@@ -0,0 +1,114 @@
+package org.koitharu.kotatsu.history.ui.util
+
+import android.animation.Animator
+import android.animation.ValueAnimator
+import android.content.Context
+import android.graphics.Outline
+import android.util.AttributeSet
+import android.view.View
+import android.view.ViewOutlineProvider
+import android.view.animation.AccelerateDecelerateInterpolator
+import androidx.annotation.AttrRes
+import androidx.annotation.StyleRes
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
+
+class ReadingProgressView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ @AttrRes defStyleAttr: Int = 0,
+) : View(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener {
+
+ private var percentAnimator: ValueAnimator? = null
+ private val animationDuration = context.resources.getInteger(android.R.integer.config_shortAnimTime).toLong()
+
+ @StyleRes
+ private val drawableStyle: Int
+
+ var percent: Float
+ get() = peekProgressDrawable()?.progress ?: PROGRESS_NONE
+ set(value) {
+ cancelAnimation()
+ getProgressDrawable().progress = value
+ }
+
+ init {
+ val ta = context.obtainStyledAttributes(attrs, R.styleable.ReadingProgressView, defStyleAttr, 0)
+ drawableStyle = ta.getResourceId(R.styleable.ReadingProgressView_progressStyle, R.style.ProgressDrawable)
+ ta.recycle()
+ outlineProvider = OutlineProvider()
+ if (isInEditMode) {
+ percent = 0.27f
+ }
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+ percentAnimator?.run {
+ if (isRunning) end()
+ }
+ percentAnimator = null
+ }
+
+ override fun onAnimationUpdate(animation: ValueAnimator) {
+ val p = animation.animatedValue as Float
+ getProgressDrawable().progress = p
+ }
+
+ override fun onAnimationStart(animation: Animator?) = Unit
+
+ override fun onAnimationEnd(animation: Animator?) {
+ if (percentAnimator === animation) {
+ percentAnimator = null
+ }
+ }
+
+ override fun onAnimationCancel(animation: Animator?) = Unit
+
+ override fun onAnimationRepeat(animation: Animator?) = Unit
+
+ fun setPercent(value: Float, animate: Boolean) {
+ val currentDrawable = peekProgressDrawable()
+ if (!animate || currentDrawable == null || value == PROGRESS_NONE) {
+ percent = value
+ return
+ }
+ percentAnimator?.cancel()
+ percentAnimator = ValueAnimator.ofFloat(
+ currentDrawable.progress.coerceAtLeast(0f),
+ value
+ ).apply {
+ duration = animationDuration
+ interpolator = AccelerateDecelerateInterpolator()
+ addUpdateListener(this@ReadingProgressView)
+ addListener(this@ReadingProgressView)
+ start()
+ }
+ }
+
+ private fun cancelAnimation() {
+ percentAnimator?.cancel()
+ percentAnimator = null
+ }
+
+ private fun peekProgressDrawable(): ReadingProgressDrawable? {
+ return background as? ReadingProgressDrawable
+ }
+
+ private fun getProgressDrawable(): ReadingProgressDrawable {
+ var d = peekProgressDrawable()
+ if (d != null) {
+ return d
+ }
+ d = ReadingProgressDrawable(context, drawableStyle)
+ background = d
+ return d
+ }
+
+ private class OutlineProvider : ViewOutlineProvider() {
+
+ override fun getOutline(view: View, outline: Outline) {
+ outline.setOval(0, 0, view.width, view.height)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/library/LibraryModule.kt b/app/src/main/java/org/koitharu/kotatsu/library/LibraryModule.kt
new file mode 100644
index 000000000..bac79d44b
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/library/LibraryModule.kt
@@ -0,0 +1,16 @@
+package org.koitharu.kotatsu.library
+
+import org.koin.androidx.viewmodel.dsl.viewModel
+import org.koin.dsl.module
+import org.koitharu.kotatsu.library.domain.LibraryRepository
+import org.koitharu.kotatsu.library.ui.LibraryViewModel
+import org.koitharu.kotatsu.library.ui.config.LibraryCategoriesConfigViewModel
+
+val libraryModule
+ get() = module {
+
+ factory { LibraryRepository(get()) }
+
+ viewModel { LibraryViewModel(get(), get(), get(), get()) }
+ viewModel { LibraryCategoriesConfigViewModel(get()) }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/library/domain/LibraryRepository.kt b/app/src/main/java/org/koitharu/kotatsu/library/domain/LibraryRepository.kt
new file mode 100644
index 000000000..c698c36a3
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/library/domain/LibraryRepository.kt
@@ -0,0 +1,40 @@
+package org.koitharu.kotatsu.library.domain
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import org.koitharu.kotatsu.core.db.MangaDatabase
+import org.koitharu.kotatsu.core.db.entity.toManga
+import org.koitharu.kotatsu.core.db.entity.toMangaTags
+import org.koitharu.kotatsu.core.model.FavouriteCategory
+import org.koitharu.kotatsu.favourites.data.FavouriteManga
+import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
+import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
+import org.koitharu.kotatsu.history.domain.HistoryRepository
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.SortOrder
+
+class LibraryRepository(
+ private val db: MangaDatabase,
+) {
+
+
+ fun observeFavourites(order: SortOrder): Flow>> {
+ return db.favouritesDao.observeAll(order)
+ .map { list -> groupByCategory(list) }
+ }
+
+ private fun groupByCategory(list: List): Map> {
+ val map = HashMap>()
+ for (item in list) {
+ val manga = item.manga.toManga(item.tags.toMangaTags())
+ for (category in item.categories) {
+ if (!category.isVisibleInLibrary) {
+ continue
+ }
+ map.getOrPut(category.toFavouriteCategory()) { ArrayList() }
+ .add(manga)
+ }
+ }
+ return map
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/library/ui/LibraryFragment.kt b/app/src/main/java/org/koitharu/kotatsu/library/ui/LibraryFragment.kt
new file mode 100644
index 000000000..0334957ed
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/library/ui/LibraryFragment.kt
@@ -0,0 +1,192 @@
+package org.koitharu.kotatsu.library.ui
+
+import android.os.Bundle
+import android.view.*
+import androidx.appcompat.view.ActionMode
+import androidx.core.graphics.Insets
+import androidx.core.view.updatePadding
+import com.google.android.material.snackbar.Snackbar
+import org.koin.android.ext.android.get
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.domain.reverseAsync
+import org.koitharu.kotatsu.base.ui.BaseFragment
+import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
+import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
+import org.koitharu.kotatsu.base.ui.util.ReversibleAction
+import org.koitharu.kotatsu.databinding.FragmentLibraryBinding
+import org.koitharu.kotatsu.details.ui.DetailsActivity
+import org.koitharu.kotatsu.download.ui.service.DownloadService
+import org.koitharu.kotatsu.favourites.ui.FavouritesActivity
+import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
+import org.koitharu.kotatsu.history.ui.HistoryActivity
+import org.koitharu.kotatsu.library.ui.adapter.LibraryAdapter
+import org.koitharu.kotatsu.library.ui.adapter.LibraryListEventListener
+import org.koitharu.kotatsu.library.ui.model.LibrarySectionModel
+import org.koitharu.kotatsu.list.ui.ItemSizeResolver
+import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
+import org.koitharu.kotatsu.list.ui.model.ListModel
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.util.flattenTo
+import org.koitharu.kotatsu.utils.ShareHelper
+import org.koitharu.kotatsu.utils.ext.addMenuProvider
+import org.koitharu.kotatsu.utils.ext.getDisplayMessage
+import org.koitharu.kotatsu.utils.ext.invalidateNestedItemDecorations
+
+class LibraryFragment : BaseFragment(), LibraryListEventListener,
+ SectionedSelectionController.Callback {
+
+ private val viewModel by viewModel()
+ private var adapter: LibraryAdapter? = null
+ private var selectionController: SectionedSelectionController? = null
+
+ override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): FragmentLibraryBinding {
+ return FragmentLibraryBinding.inflate(inflater, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ val sizeResolver = ItemSizeResolver(resources, get())
+ selectionController = SectionedSelectionController(
+ activity = requireActivity(),
+ registryOwner = this,
+ callback = this,
+ )
+ adapter = LibraryAdapter(
+ lifecycleOwner = viewLifecycleOwner,
+ coil = get(),
+ listener = this,
+ sizeResolver = sizeResolver,
+ selectionController = checkNotNull(selectionController),
+ )
+ binding.recyclerView.adapter = adapter
+ binding.recyclerView.setHasFixedSize(true)
+ addMenuProvider(LibraryMenuProvider(view.context, childFragmentManager, viewModel))
+
+ viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
+ viewModel.onError.observe(viewLifecycleOwner, ::onError)
+ viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ adapter = null
+ selectionController = null
+ }
+
+ override fun onItemClick(item: Manga, section: LibrarySectionModel, view: View) {
+ if (selectionController?.onItemClick(section, item.id) != true) {
+ val intent = DetailsActivity.newIntent(view.context, item)
+ startActivity(intent)
+ }
+ }
+
+ override fun onItemLongClick(item: Manga, section: LibrarySectionModel, view: View): Boolean {
+ return selectionController?.onItemLongClick(section, item.id) ?: false
+ }
+
+ override fun onSectionClick(section: LibrarySectionModel, view: View) {
+ val intent = when (section) {
+ is LibrarySectionModel.History -> HistoryActivity.newIntent(view.context)
+ is LibrarySectionModel.Favourites -> FavouritesActivity.newIntent(view.context, section.category)
+ }
+ startActivity(intent)
+ }
+
+ override fun onRetryClick(error: Throwable) = Unit
+
+ override fun onEmptyActionClick() = Unit
+
+ override fun onWindowInsetsChanged(insets: Insets) {
+ binding.root.updatePadding(
+ left = insets.left,
+ right = insets.right,
+ )
+ binding.recyclerView.updatePadding(
+ bottom = insets.bottom,
+ )
+ }
+
+ override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
+ mode.menuInflater.inflate(R.menu.mode_remote, menu)
+ return true
+ }
+
+ override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
+ mode.title = selectionController?.count?.toString()
+ return true
+ }
+
+ override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
+ val ctx = context ?: return false
+ return when (item.itemId) {
+ R.id.action_share -> {
+ ShareHelper(ctx).shareMangaLinks(collectSelectedItems())
+ mode.finish()
+ true
+ }
+ R.id.action_favourite -> {
+ FavouriteCategoriesBottomSheet.show(childFragmentManager, collectSelectedItems())
+ mode.finish()
+ true
+ }
+ R.id.action_save -> {
+ DownloadService.confirmAndStart(ctx, collectSelectedItems())
+ mode.finish()
+ true
+ }
+ else -> false
+ }
+ }
+
+ override fun onSelectionChanged(count: Int) {
+ binding.recyclerView.invalidateNestedItemDecorations()
+ }
+
+ override fun onCreateItemDecoration(section: LibrarySectionModel): AbstractSelectionItemDecoration {
+ return MangaSelectionDecoration(requireContext())
+ }
+
+ private fun collectSelectedItemsMap(): Map> {
+ val snapshot = selectionController?.snapshot()
+ if (snapshot.isNullOrEmpty()) {
+ return emptyMap()
+ }
+ return snapshot.mapValues { (_, ids) -> viewModel.getManga(ids) }
+ }
+
+ private fun collectSelectedItems(): Set {
+ val snapshot = selectionController?.snapshot()
+ if (snapshot.isNullOrEmpty()) {
+ return emptySet()
+ }
+ return viewModel.getManga(snapshot.values.flattenTo(HashSet()))
+ }
+
+ private fun onListChanged(list: List) {
+ adapter?.items = list
+ }
+
+ private fun onError(e: Throwable) {
+ Snackbar.make(
+ binding.recyclerView,
+ e.getDisplayMessage(resources),
+ Snackbar.LENGTH_SHORT
+ ).show()
+ }
+
+ private fun onActionDone(action: ReversibleAction) {
+ val handle = action.handle
+ val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
+ val snackbar = Snackbar.make(binding.recyclerView, action.stringResId, length)
+ if (handle != null) {
+ snackbar.setAction(R.string.undo) { handle.reverseAsync() }
+ }
+ snackbar.show()
+ }
+
+ companion object {
+
+ fun newInstance() = LibraryFragment()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/library/ui/LibraryMenuProvider.kt b/app/src/main/java/org/koitharu/kotatsu/library/ui/LibraryMenuProvider.kt
new file mode 100644
index 000000000..0bce5a7bf
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/library/ui/LibraryMenuProvider.kt
@@ -0,0 +1,67 @@
+package org.koitharu.kotatsu.library.ui
+
+import android.content.Context
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import androidx.core.view.MenuProvider
+import androidx.fragment.app.FragmentManager
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.dialog.RememberSelectionDialogListener
+import org.koitharu.kotatsu.library.ui.config.LibraryCategoriesConfigSheet
+import org.koitharu.kotatsu.utils.ext.startOfDay
+import java.util.*
+import java.util.concurrent.TimeUnit
+import com.google.android.material.R as materialR
+
+class LibraryMenuProvider(
+ private val context: Context,
+ private val fragmentManager: FragmentManager,
+ private val viewModel: LibraryViewModel,
+) : MenuProvider {
+
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.opt_library, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return when (menuItem.itemId) {
+ R.id.action_clear_history -> {
+ showClearHistoryDialog()
+ true
+ }
+ R.id.action_categories -> {
+ LibraryCategoriesConfigSheet.show(fragmentManager)
+ true
+ }
+ else -> false
+ }
+ }
+
+ private fun showClearHistoryDialog() {
+ val selectionListener = RememberSelectionDialogListener(2)
+ MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
+ .setTitle(R.string.clear_history)
+ .setSingleChoiceItems(
+ arrayOf(
+ context.getString(R.string.last_2_hours),
+ context.getString(R.string.today),
+ context.getString(R.string.clear_all_history),
+ ),
+ selectionListener.selection,
+ selectionListener,
+ )
+ .setIcon(R.drawable.ic_delete)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(R.string.clear) { _, _ ->
+ val minDate = when (selectionListener.selection) {
+ 0 -> System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2)
+ 1 -> Date().startOfDay()
+ 2 -> 0L
+ else -> return@setPositiveButton
+ }
+ viewModel.clearHistory(minDate)
+ }.show()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/library/ui/LibraryViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/library/ui/LibraryViewModel.kt
new file mode 100644
index 000000000..5762d16b7
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/library/ui/LibraryViewModel.kt
@@ -0,0 +1,161 @@
+package org.koitharu.kotatsu.library.ui
+
+import androidx.collection.ArraySet
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.combine
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.BaseViewModel
+import org.koitharu.kotatsu.base.ui.util.ReversibleAction
+import org.koitharu.kotatsu.core.model.FavouriteCategory
+import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.core.prefs.ListMode
+import org.koitharu.kotatsu.core.ui.DateTimeAgo
+import org.koitharu.kotatsu.history.domain.HistoryRepository
+import org.koitharu.kotatsu.history.domain.MangaWithHistory
+import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
+import org.koitharu.kotatsu.library.domain.LibraryRepository
+import org.koitharu.kotatsu.library.ui.model.LibrarySectionModel
+import org.koitharu.kotatsu.list.domain.ListExtraProvider
+import org.koitharu.kotatsu.list.ui.model.*
+import org.koitharu.kotatsu.parsers.model.Manga
+import org.koitharu.kotatsu.parsers.model.SortOrder
+import org.koitharu.kotatsu.tracker.domain.TrackingRepository
+import org.koitharu.kotatsu.utils.SingleLiveEvent
+import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+import org.koitharu.kotatsu.utils.ext.daysDiff
+import java.util.*
+
+private const val HISTORY_MAX_SEGMENTS = 2
+
+class LibraryViewModel(
+ private val repository: LibraryRepository,
+ private val historyRepository: HistoryRepository,
+ private val trackingRepository: TrackingRepository,
+ private val settings: AppSettings,
+) : BaseViewModel(), ListExtraProvider {
+
+ val onActionDone = SingleLiveEvent()
+
+ val content: LiveData> = combine(
+ historyRepository.observeAllWithHistory(),
+ repository.observeFavourites(SortOrder.NEWEST),
+ ) { history, favourites ->
+ mapList(history, favourites)
+ }.catch { e ->
+ e.toErrorState(canRetry = false)
+ }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
+
+ override suspend fun getCounter(mangaId: Long): Int {
+ return trackingRepository.getNewChaptersCount(mangaId)
+ }
+
+ override suspend fun getProgress(mangaId: Long): Float {
+ return if (settings.isReadingIndicatorsEnabled) {
+ historyRepository.getProgress(mangaId)
+ } else {
+ PROGRESS_NONE
+ }
+ }
+
+ fun getManga(ids: Set): Set {
+ val snapshot = content.value ?: return emptySet()
+ val result = ArraySet(ids.size)
+ for (section in snapshot) {
+ if (section !is LibrarySectionModel) {
+ continue
+ }
+ for (item in section.items) {
+ if (item.id in ids) {
+ result.add(item.manga)
+ if (result.size == ids.size) {
+ return result
+ }
+ }
+ }
+ }
+ return result
+ }
+
+ fun removeFromHistory(ids: Set) {
+ if (ids.isEmpty()) {
+ return
+ }
+ launchJob(Dispatchers.Default) {
+ val handle = historyRepository.delete(ids)
+ onActionDone.postCall(ReversibleAction(R.string.removed_from_history, handle))
+ }
+ }
+
+ fun clearHistory(minDate: Long) {
+ launchJob(Dispatchers.Default) {
+ val stringRes = if (minDate <= 0) {
+ historyRepository.clear()
+ R.string.history_cleared
+ } else {
+ historyRepository.deleteAfter(minDate)
+ R.string.removed_from_history
+ }
+ onActionDone.postCall(ReversibleAction(stringRes, null))
+ }
+ }
+
+ private suspend fun mapList(
+ history: List,
+ favourites: Map>,
+ ): List {
+ val result = ArrayList(favourites.keys.size + 1)
+ if (history.isNotEmpty()) {
+ result += mapHistory(history)
+ }
+ for ((category, list) in favourites) {
+ result += LibrarySectionModel.Favourites(list.toUi(ListMode.GRID, this), category, R.string.show_all)
+ }
+ return result
+ }
+
+ private suspend fun mapHistory(list: List): List