mirror of
https://github.com/mihonapp/mihon.git
synced 2025-10-09 12:59:34 +02:00
Compare commits
187 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
e4de208cf7 | ||
|
f1193866f4 | ||
|
3782e1b414 | ||
|
c4407eda0e | ||
|
33e0121a2a | ||
|
b93337cb3d | ||
|
358adb8cd1 | ||
|
22f851173b | ||
|
982ebcf777 | ||
|
e62cd0e816 | ||
|
1365b28106 | ||
|
7ec28eb3bd | ||
|
ff9dfe45ed | ||
|
967750ba58 | ||
|
269af7fe2b | ||
|
62eec15fe6 | ||
|
2ef8ae11c9 | ||
|
4f1faf49f3 | ||
|
6d717ea88b | ||
|
fbb5e6b92f | ||
|
8f5f29e737 | ||
|
a4b9c704b6 | ||
|
9352201b03 | ||
|
c715e981bf | ||
|
7f56555d63 | ||
|
8636b7a685 | ||
|
a49670bf0d | ||
|
61cee5c5e0 | ||
|
d805f0cd2a | ||
|
ce07259e8e | ||
|
2f10e7beaa | ||
|
4ef8fb9588 | ||
|
084e626669 | ||
|
f93ccaaaa4 | ||
|
5585388e2d | ||
|
d60241690b | ||
|
84aa07b7f0 | ||
|
0cc1224094 | ||
|
a5a0d83302 | ||
|
1fde0275e3 | ||
|
a992f2d467 | ||
|
d8dd170d1b | ||
|
6953090dab | ||
|
ab452a9945 | ||
|
7dd595f16e | ||
|
6eb2a022f1 | ||
|
d61c66c286 | ||
|
1e4ee14608 | ||
|
63943debc2 | ||
|
d2c1ff6adf | ||
|
b9e02e92be | ||
|
103218681a | ||
|
07136d3969 | ||
|
4962deeb0c | ||
|
cecf4596f9 | ||
|
0c77afbe03 | ||
|
d126b84f95 | ||
|
2df3382148 | ||
|
ee19050cc0 | ||
|
8de1fa854d | ||
|
019fc08da2 | ||
|
288f577a45 | ||
|
0290a2d815 | ||
|
a47d4ebbdd | ||
|
89954e68e3 | ||
|
c3b590cd3d | ||
|
cb3c5e9c9c | ||
|
7fa2834009 | ||
|
95e3c22429 | ||
|
8bd70342fc | ||
|
92ec6b17a3 | ||
|
591e9c1356 | ||
|
7fed9c2ccf | ||
|
ccb554c877 | ||
|
5235713d83 | ||
|
4692010400 | ||
|
be528ba12b | ||
|
405e536cbf | ||
|
8714653a2f | ||
|
2b126f1ff5 | ||
|
5919f34fc9 | ||
|
a4df33caf9 | ||
|
3580d2da6c | ||
|
77eb558742 | ||
|
e1f6d14393 | ||
|
2e180005a0 | ||
|
0f59fc1dd4 | ||
|
d1055475e2 | ||
|
32470657dd | ||
|
158896cfa9 | ||
|
92b376d9af | ||
|
1a2f09a622 | ||
|
209e982fe4 | ||
|
0109102901 | ||
|
4117a51674 | ||
|
4090a61d08 | ||
|
c406513557 | ||
|
625c85cbd6 | ||
|
737ceeea57 | ||
|
213b673b13 | ||
|
7933c9eeb7 | ||
|
f0de8f973b | ||
|
e8c6e3364d | ||
|
c12bdbae8e | ||
|
33d407ee9c | ||
|
ef8c3ca119 | ||
|
0cb3a4aeb6 | ||
|
8b45ef0e5d | ||
|
86ebf55815 | ||
|
ddf282b103 | ||
|
744b809d45 | ||
|
cd2ce44efa | ||
|
cae7c3dc58 | ||
|
c0074402e7 | ||
|
7deeabe844 | ||
|
536393a6d9 | ||
|
f8cb506137 | ||
|
98230ed30f | ||
|
d721a4321b | ||
|
1ac4b72cfe | ||
|
9331f2b93f | ||
|
99c2a99973 | ||
|
001716e34b | ||
|
37e19edf8a | ||
|
8b7f355988 | ||
|
0b77733673 | ||
|
9be558d6c0 | ||
|
0c8c5dbba6 | ||
|
1c982c2a01 | ||
|
eeab61fc94 | ||
|
a036407c75 | ||
|
615d93f780 | ||
|
9750c1e4bd | ||
|
e2915a1f69 | ||
|
f6617a7a22 | ||
|
ef37a4c80b | ||
|
df2b4c754b | ||
|
6632a12228 | ||
|
0cb1925cf1 | ||
|
a31b3b7bbf | ||
|
fea85241af | ||
|
e273a26c9b | ||
|
818e6931c6 | ||
|
ecc6ede081 | ||
|
fefa8f8498 | ||
|
f1e2efcb37 | ||
|
180318f57d | ||
|
ed749de806 | ||
|
bb33b0029e | ||
|
3a19e449b1 | ||
|
818edf2776 | ||
|
47d2646751 | ||
|
a1a7d67afb | ||
|
2090a380e0 | ||
|
8e5cfe9d0a | ||
|
3249228c49 | ||
|
d9c4b56336 | ||
|
5e029b1fe6 | ||
|
12abd9938b | ||
|
c1225a5ef9 | ||
|
a594ad392d | ||
|
2259164fde | ||
|
80de032819 | ||
|
0d35b6fdaf | ||
|
f81da3dcce | ||
|
2ce9fa0271 | ||
|
300bee865f | ||
|
542d30c14a | ||
|
5d2110f3fb | ||
|
4e68339783 | ||
|
c8ffabc84a | ||
|
77e79233ab | ||
|
8a21148578 | ||
|
e91db86fae | ||
|
556290f2d3 | ||
|
8b947919ac | ||
|
b62a9b40eb | ||
|
a6b532ee57 | ||
|
8fbe630308 | ||
|
132d77aa99 | ||
|
b00bbe91be | ||
|
3e5d3d099f | ||
|
941dde341e | ||
|
365d167eac | ||
|
d4aaf6521e | ||
|
f7046a503b | ||
|
953c4e7bc0 |
@@ -7,7 +7,7 @@ indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.xml]
|
||||
[*.{xml,sq,sqm}]
|
||||
indent_size = 4
|
||||
|
||||
# noinspection EditorConfigKeyCorrectness
|
||||
@@ -23,6 +23,9 @@ ij_kotlin_name_count_to_use_star_import_for_members = 2147483647
|
||||
ktlint_code_style = intellij_idea
|
||||
ktlint_function_naming_ignore_when_annotated_with = Composable
|
||||
ktlint_standard_class-signature = disabled
|
||||
ktlint_standard_comment-wrapping = disabled
|
||||
ktlint_standard_discouraged-comment-location = disabled
|
||||
ktlint_standard_function-expression-body = disabled
|
||||
ktlint_standard_function-signature = disabled
|
||||
ktlint_standard_type-argument-comment = disabled
|
||||
ktlint_standard_type-parameter-comment = disabled
|
||||
|
@@ -1,8 +1,7 @@
|
||||
name: ⭐ Feature request
|
||||
description: Suggest a feature to improve Mihon
|
||||
labels: [Feature request]
|
||||
labels: [feature request]
|
||||
body:
|
||||
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
@@ -31,7 +30,7 @@ body:
|
||||
required: true
|
||||
- label: I have written a short but informative title.
|
||||
required: true
|
||||
- label: I have updated the app to version **[0.18.0](https://github.com/mihonapp/mihon/releases/latest)**.
|
||||
- label: I have updated the app to version **[0.19.0](https://github.com/mihonapp/mihon/releases/latest)**.
|
||||
required: true
|
||||
- label: I will fill out all of the requested information in this form.
|
||||
required: true
|
@@ -1,8 +1,7 @@
|
||||
name: 🐞 Issue report
|
||||
description: Report an issue in Mihon
|
||||
labels: [Bug]
|
||||
labels: [bug]
|
||||
body:
|
||||
|
||||
- type: textarea
|
||||
id: reproduce-steps
|
||||
attributes:
|
||||
@@ -53,7 +52,7 @@ body:
|
||||
label: Mihon version
|
||||
description: You can find your Mihon version in **More → About**.
|
||||
placeholder: |
|
||||
Example: "0.18.0"
|
||||
Example: "0.19.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -96,7 +95,7 @@ body:
|
||||
required: true
|
||||
- label: I have gone through the [FAQ](https://mihon.app/docs/faq/general) and [troubleshooting guide](https://mihon.app/docs/guides/troubleshooting/).
|
||||
required: true
|
||||
- label: I have updated the app to version **[0.18.0](https://github.com/mihonapp/mihon/releases/latest)**.
|
||||
- label: I have updated the app to version **[0.19.0](https://github.com/mihonapp/mihon/releases/latest)**.
|
||||
required: true
|
||||
- label: I have filled out all of the requested information in this form, including specific version numbers.
|
||||
required: true
|
6
.github/workflows/build_pull_request.yml
vendored
6
.github/workflows/build_pull_request.yml
vendored
@@ -26,16 +26,16 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0
|
||||
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: temurin
|
||||
|
||||
- name: Set up gradle
|
||||
uses: gradle/actions/setup-gradle@94baf225fe0a508e581a564467443d0e2379123b # v4.3.0
|
||||
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
|
||||
|
||||
- name: Check code format
|
||||
run: ./gradlew spotlessCheck
|
||||
|
37
.github/workflows/build_push.yml
vendored
37
.github/workflows/build_push.yml
vendored
@@ -20,13 +20,13 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: temurin
|
||||
|
||||
- name: Set up gradle
|
||||
uses: gradle/actions/setup-gradle@94baf225fe0a508e581a564467443d0e2379123b # v4.3.0
|
||||
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
|
||||
|
||||
- name: Check code format
|
||||
run: ./gradlew spotlessCheck
|
||||
@@ -75,45 +75,22 @@ jobs:
|
||||
set -e
|
||||
|
||||
mv app/build/outputs/apk/release/app-universal-release-unsigned-signed.apk mihon-${{ env.VERSION_TAG }}.apk
|
||||
sha=`sha256sum mihon-${{ env.VERSION_TAG }}.apk | awk '{ print $1 }'`
|
||||
echo "APK_UNIVERSAL_SHA=$sha" >> $GITHUB_ENV
|
||||
|
||||
mv app/build/outputs/apk/release/app-arm64-v8a-release-unsigned-signed.apk mihon-arm64-v8a-${{ env.VERSION_TAG }}.apk
|
||||
sha=`sha256sum mihon-arm64-v8a-${{ env.VERSION_TAG }}.apk | awk '{ print $1 }'`
|
||||
echo "APK_ARM64_V8A_SHA=$sha" >> $GITHUB_ENV
|
||||
|
||||
mv app/build/outputs/apk/release/app-armeabi-v7a-release-unsigned-signed.apk mihon-armeabi-v7a-${{ env.VERSION_TAG }}.apk
|
||||
sha=`sha256sum mihon-armeabi-v7a-${{ env.VERSION_TAG }}.apk | awk '{ print $1 }'`
|
||||
echo "APK_ARMEABI_V7A_SHA=$sha" >> $GITHUB_ENV
|
||||
|
||||
mv app/build/outputs/apk/release/app-x86-release-unsigned-signed.apk mihon-x86-${{ env.VERSION_TAG }}.apk
|
||||
sha=`sha256sum mihon-x86-${{ env.VERSION_TAG }}.apk | awk '{ print $1 }'`
|
||||
echo "APK_X86_SHA=$sha" >> $GITHUB_ENV
|
||||
|
||||
mv app/build/outputs/apk/release/app-x86_64-release-unsigned-signed.apk mihon-x86_64-${{ env.VERSION_TAG }}.apk
|
||||
sha=`sha256sum mihon-x86_64-${{ env.VERSION_TAG }}.apk | awk '{ print $1 }'`
|
||||
echo "APK_X86_64_SHA=$sha" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Release
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'mihonapp/mihon'
|
||||
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1
|
||||
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
|
||||
with:
|
||||
tag_name: ${{ env.VERSION_TAG }}
|
||||
name: Mihon ${{ env.VERSION_TAG }}
|
||||
body: |
|
||||
---
|
||||
|
||||
### Checksums
|
||||
|
||||
| Variant | SHA-256 |
|
||||
| ------- | ------- |
|
||||
| Universal | ${{ env.APK_UNIVERSAL_SHA }}
|
||||
| arm64-v8a | ${{ env.APK_ARM64_V8A_SHA }}
|
||||
| armeabi-v7a | ${{ env.APK_ARMEABI_V7A_SHA }}
|
||||
| x86 | ${{ env.APK_X86_SHA }} |
|
||||
| x86_64 | ${{ env.APK_X86_64_SHA }} |
|
||||
|
||||
## If you are unsure which version to choose then go with mihon-${{ env.VERSION_TAG }}.apk
|
||||
<!-->
|
||||
> [!TIP]
|
||||
>
|
||||
> ### If you are unsure which version to download then go with `mihon-${{ env.VERSION_TAG }}.apk`
|
||||
files: |
|
||||
mihon-${{ env.VERSION_TAG }}.apk
|
||||
mihon-arm64-v8a-${{ env.VERSION_TAG }}.apk
|
||||
|
62
CHANGELOG.md
62
CHANGELOG.md
@@ -12,6 +12,65 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.19.0] - 2025-08-04
|
||||
### Added
|
||||
- Add more Kaomoji for empty/error screens ([@ianfhunter](https://github.com/ianfhunter/)) ([#1909](https://github.com/mihonapp/mihon/pull/1909))
|
||||
- Add user manga notes ([@imkunet](https://github.com/imkunet), [@AntsyLich](https://github.com/AntsyLich)) ([#428](https://github.com/mihonapp/mihon/pull/428))
|
||||
- Fix user notes not restoring when manga doesn't exist in DB ([@AntsyLich](https://github.com/AntsyLich)) ([#1945](https://github.com/mihonapp/mihon/pull/1945))
|
||||
- Add markdown support for manga descriptions ([@Secozzi](https://github.com/Secozzi)) ([#1948](https://github.com/mihonapp/mihon/pull/1948))
|
||||
- Use simpler markdown flavour ([@Secozzi](https://github.com/Secozzi)) ([#2000](https://github.com/mihonapp/mihon/pull/2000))
|
||||
- Use Github markdown flavour for Github releases & fix bullet list alignment ([@Secozzi](https://github.com/Secozzi)) ([#2024](https://github.com/mihonapp/mihon/pull/2024))
|
||||
- Add option to toggle image loading ([@Secozzi](https://github.com/Secozzi)) ([#2076](https://github.com/mihonapp/mihon/pull/2076))
|
||||
- Add Nord Theme ([@Riztard](https://github.com/Riztard)) ([#1951](https://github.com/mihonapp/mihon/pull/1951))
|
||||
- Option to keep read manga when clearing database ([@AwkwardPeak7](https://github.com/AwkwardPeak7)) ([#1979](https://github.com/mihonapp/mihon/pull/1979))
|
||||
- Add advanced option to always update manga title from source ([@FlaminSarge](https://github.com/FlaminSarge)) ([#1182](https://github.com/mihonapp/mihon/pull/1182))
|
||||
- Full predictive back support ([@AntsyLich](https://github.com/AntsyLich)) ([#2085](https://github.com/mihonapp/mihon/pull/2085))
|
||||
- Add Catppuccin theme (mocha for dark and latte for light, mauve accent) ([@claymorwan](https://github.com/claymorwan/)) ([#2117](https://github.com/mihonapp/mihon/pull/2117))
|
||||
- Manga mass migration ([@AntsyLich](https://github.com/AntsyLich), [@jobobby04](https://github.com/jobobby04)) ([#2110](https://github.com/mihonapp/mihon/pull/2110), [#2336](https://github.com/mihonapp/mihon/pull/2336), [#2338](https://github.com/mihonapp/mihon/pull/2338), [`f119386`](https://github.com/mihonapp/mihon/commit/f119386))
|
||||
|
||||
### Improved
|
||||
- Significantly improve browsing speed (near instantaneous) ([@AntsyLich](https://github.com/AntsyLich)) ([#1946](https://github.com/mihonapp/mihon/pull/1946))
|
||||
- Deduplicate entries when browsing ([@AntsyLich](https://github.com/AntsyLich)) ([#1957](https://github.com/mihonapp/mihon/pull/1957))
|
||||
- Update non-library manga data when browsing ([@AntsyLich](https://github.com/AntsyLich)) ([#1967](https://github.com/mihonapp/mihon/pull/1967))
|
||||
- Surface image loading error in Reader ([@AwkwardPeak7](https://github.com/AwkwardPeak7)) ([#1981](https://github.com/mihonapp/mihon/pull/1981))
|
||||
- Include source headers when opening failed images from reader ([@AwkwardPeak7](https://github.com/AwkwardPeak7)) ([#2004](https://github.com/mihonapp/mihon/pull/2004))
|
||||
- Added autofill support to tracker login dialog ([@AntsyLich](https://github.com/AntsyLich)) ([#2069](https://github.com/mihonapp/mihon/pull/2069))
|
||||
- Added option to hide missing chapter count ([@User826](https://github.com/User826), [@AntsyLich](https://github.com/AntsyLich)) ([#2108](https://github.com/mihonapp/mihon/pull/2108))
|
||||
- Use median to determine smart update interval, making it more resilient to long hiatuses ([@Kladki](https://github.com/Kladki)) ([#2251](https://github.com/mihonapp/mihon/pull/2251))
|
||||
- Optimize library code to potentially better handle big user libraries ([@AntsyLich](https://github.com/AntsyLich)) ([#2329](https://github.com/mihonapp/mihon/pull/2329), [#2341](https://github.com/mihonapp/mihon/pull/2341))
|
||||
|
||||
### Changed
|
||||
- Display all similarly named duplicates in duplicate manga dialogue ([@NarwhalHorns](https://github.com/NarwhalHorns), [@AntsyLich](https://github.com/AntsyLich)) ([#1861](https://github.com/mihonapp/mihon/pull/1861))
|
||||
- Display chapter count on items in duplicate manga dialogue ([@NarwhalHorns](https://github.com/NarwhalHorns)) ([#1963](https://github.com/mihonapp/mihon/pull/1963))
|
||||
- Update Facebook and Reddit icons ([@Joehuu](https://github.com/Joehuu)) ([#1994](https://github.com/mihonapp/mihon/pull/1994))
|
||||
- Switch default user agent to Android Chrome ([@AntsyLich](https://github.com/AntsyLich)) ([#2048](https://github.com/mihonapp/mihon/pull/2048))
|
||||
- Changed log in button text when processing tracker login ([@AntsyLich](https://github.com/AntsyLich)) ([#2069](https://github.com/mihonapp/mihon/pull/2069))
|
||||
- Disable reader's 'Keep screen on' setting by default ([@AntsyLich](https://github.com/AntsyLich)) ([#2095](https://github.com/mihonapp/mihon/pull/2095))
|
||||
- Update manga without chapters even if restricted by source ([@AntsyLich](https://github.com/AntsyLich)) ([#2224](https://github.com/mihonapp/mihon/pull/224))
|
||||
- Make local source default chapter sorting match file explorer behavior ([@AntsyLich](https://github.com/AntsyLich)) ([#2224](https://github.com/mihonapp/mihon/pull/224))
|
||||
- Include Manga `initialized` status in backup ([@AwkwardPeak7](https://github.com/AwkwardPeak7)) ([#2285](https://github.com/mihonapp/mihon/pull/2285))
|
||||
|
||||
### Fixes
|
||||
- Fix Bangumi search results including novels ([@MajorTanya](https://github.com/MajorTanya)) ([#1885](https://github.com/mihonapp/mihon/pull/1885))
|
||||
- Fix next chapter button occasionally jumping to the last page of the current chapter ([@perokhe](https://github.com/perokhe)) ([#1920](https://github.com/mihonapp/mihon/pull/1920))
|
||||
- Fix page number not appearing when opening chapter ([@perokhe](https://github.com/perokhe)) ([#1936](https://github.com/mihonapp/mihon/pull/1936))
|
||||
- Fix backup sharing from notifications not working when app is in background ([@JaymanR](https://github.com/JaymanR))([#1929](https://github.com/mihonapp/mihon/pull/1929))
|
||||
- Fix mark existing duplicate read chapters as read option not working in some cases ([@AntsyLich](https://github.com/AntsyLich)) ([#1944](https://github.com/mihonapp/mihon/pull/1944))
|
||||
- Fix app bar action tooltips blocking clicks ([@Bartuzen](https://github.com/Bartuzen)) ([#1928](https://github.com/mihonapp/mihon/pull/1928))
|
||||
- Fix unintended app permissions due to Firebase misconfiguration ([@AntsyLich](https://github.com/AntsyLich)) ([#1960](https://github.com/mihonapp/mihon/pull/1960))
|
||||
- Fix navigation issue after migrating a duplicated entry from History tab ([@cuong-tran](https://github.com/cuong-tran)) ([#1980](https://github.com/mihonapp/mihon/pull/1980))
|
||||
- Fix duplicate requests in WebView due to empty reasonPhrase ([@AwkwardPeak7](https://github.com/AwkwardPeak7)) ([#2003](https://github.com/mihonapp/mihon/pull/2003))
|
||||
- Fix content under source browse screen top appbar is interactable ([@AntsyLich](https://github.com/AntsyLich)) ([#2026](https://github.com/mihonapp/mihon/pull/2026))
|
||||
- Fix crash when trying use source sort filter without a pre-selection ([@AntsyLich](https://github.com/AntsyLich)) ([#2036](https://github.com/mihonapp/mihon/pull/2036))
|
||||
- Fix empty layout not appearing in browse source screen in some cases ([@NarwhalHorns](https://github.com/NarwhalHorns)) ([#2043](https://github.com/mihonapp/mihon/pull/2043))
|
||||
- Fix Pill not following the local text style ([@AntsyLich](https://github.com/AntsyLich)) ([`f8cb506`](https://github.com/mihonapp/mihon/commit/f8cb506))
|
||||
- Fix downloader stopping after failing to create download directory of a manga ([@AntsyLich](https://github.com/AntsyLich)) ([#2068](https://github.com/mihonapp/mihon/pull/2068))
|
||||
- Fix pressing `Enter` while searching also triggering navigation back on physical keyboards ([@AwkwardPeak7](https://github.com/AwkwardPeak7)) ([#2077](https://github.com/mihonapp/mihon/pull/2077))
|
||||
- Ensure app waits for Cloudflare challenge to complete before continuing ([@AwkwardPeak7](https://github.com/AwkwardPeak7)) ([#2200](https://github.com/mihonapp/mihon/pull/2200))
|
||||
|
||||
### Removed
|
||||
- Remove Okhttp networking from WebView Screen ([@AwkwardPeak7](https://github.com/AwkwardPeak7)) ([#2020](https://github.com/mihonapp/mihon/pull/2020))
|
||||
|
||||
## [v0.18.0] - 2025-03-20
|
||||
### Added
|
||||
- Add option to always decode long strip images with SSIV ([@AntsyLich](https://github.com/AntsyLich)) ([`c5655e8`](https://github.com/mihonapp/mihon/commit/c5655e8803bc32d0931657f0b7bc6afeab70feaf))
|
||||
@@ -323,7 +382,8 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
|
||||
- Branding to Mihon ([@AntsyLich](https://github.com/AntsyLich))
|
||||
- Minimum supported Android version to 8 ([@AntsyLich](https://github.com/AntsyLich)) ([`dfb3091`](https://github.com/mihonapp/mihon/commit/dfb3091e380dda3e9bfb64bf5c9a685cf3a03d0e))
|
||||
|
||||
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.18.0...main
|
||||
[unreleased]: https://github.com/mihonapp/mihon/compare/v0.19.0...main
|
||||
[v0.18.0]: https://github.com/mihonapp/mihon/compare/v0.18.0...v0.19.0
|
||||
[v0.18.0]: https://github.com/mihonapp/mihon/compare/v0.17.1...v0.18.0
|
||||
[v0.17.1]: https://github.com/mihonapp/mihon/compare/v0.17.0...v0.17.1
|
||||
[v0.17.0]: https://github.com/mihonapp/mihon/compare/v0.16.5...v0.17.0
|
||||
|
@@ -10,7 +10,7 @@
|
||||
Discover and read manga, webtoons, comics, and more – easier than ever on your Android device.
|
||||
|
||||
[](https://discord.gg/mihon)
|
||||
[](https://github.com/mihonapp/mihon/releases)
|
||||
[](https://mihon.app/download)
|
||||
|
||||
[](https://github.com/mihonapp/mihon/actions/workflows/build_push.yml)
|
||||
[](/LICENSE)
|
||||
@@ -18,8 +18,8 @@ Discover and read manga, webtoons, comics, and more – easier than ever on your
|
||||
|
||||
## Download
|
||||
|
||||
[](https://github.com/mihonapp/mihon/releases)
|
||||
[](https://github.com/mihonapp/mihon-preview/releases)
|
||||
[](https://mihon.app/download)
|
||||
[](https://mihon.app/download)
|
||||
|
||||
*Requires Android 8.0 or higher.*
|
||||
|
||||
|
@@ -26,8 +26,8 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "app.mihon"
|
||||
|
||||
versionCode = 11
|
||||
versionName = "0.18.0"
|
||||
versionCode = 12
|
||||
versionName = "0.19.0"
|
||||
|
||||
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
|
||||
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
|
||||
@@ -262,7 +262,7 @@ dependencies {
|
||||
exclude(group = "androidx.viewpager", module = "viewpager")
|
||||
}
|
||||
implementation(libs.insetter)
|
||||
implementation(libs.bundles.richtext)
|
||||
implementation(libs.richeditor.compose)
|
||||
implementation(libs.aboutLibraries.compose)
|
||||
implementation(libs.bundles.voyager)
|
||||
implementation(libs.compose.materialmotion)
|
||||
@@ -270,6 +270,7 @@ dependencies {
|
||||
implementation(libs.compose.webview)
|
||||
implementation(libs.compose.grid)
|
||||
implementation(libs.reorderable)
|
||||
implementation(libs.bundles.markdown)
|
||||
|
||||
// Logging
|
||||
implementation(libs.logcat)
|
||||
@@ -277,8 +278,12 @@ dependencies {
|
||||
// Shizuku
|
||||
implementation(libs.bundles.shizuku)
|
||||
|
||||
// String similarity
|
||||
implementation(libs.stringSimilarity)
|
||||
|
||||
// Tests
|
||||
testImplementation(libs.bundles.test)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
|
||||
// For detecting memory leaks; see https://square.github.io/leakcanary/
|
||||
// debugImplementation(libs.leakcanary.android)
|
||||
@@ -288,14 +293,6 @@ dependencies {
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
beforeVariants { variantBuilder ->
|
||||
// Disables standardBenchmark
|
||||
if (variantBuilder.buildType == "benchmark") {
|
||||
variantBuilder.enable = variantBuilder.productFlavors.containsAll(
|
||||
listOf("default" to "dev"),
|
||||
)
|
||||
}
|
||||
}
|
||||
onVariants(selector().withFlavor("default" to "standard")) {
|
||||
// Only excluding in standard flavor because this breaks
|
||||
// Layout Inspector's Compose tree
|
||||
|
@@ -35,6 +35,7 @@ import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
|
||||
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
|
||||
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
||||
import mihon.domain.extensionrepo.service.ExtensionRepoService
|
||||
import mihon.domain.migration.usecases.MigrateMangaUseCase
|
||||
import mihon.domain.upcoming.interactor.GetUpcomingManga
|
||||
import tachiyomi.data.category.CategoryRepositoryImpl
|
||||
import tachiyomi.data.chapter.ChapterRepositoryImpl
|
||||
@@ -79,6 +80,7 @@ import tachiyomi.domain.manga.interactor.GetMangaWithChapters
|
||||
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
|
||||
import tachiyomi.domain.manga.interactor.ResetViewerFlags
|
||||
import tachiyomi.domain.manga.interactor.SetMangaChapterFlags
|
||||
import tachiyomi.domain.manga.interactor.UpdateMangaNotes
|
||||
import tachiyomi.domain.manga.repository.MangaRepository
|
||||
import tachiyomi.domain.release.interactor.GetApplicationRelease
|
||||
import tachiyomi.domain.release.service.ReleaseService
|
||||
@@ -129,9 +131,15 @@ class DomainModule : InjektModule {
|
||||
addFactory { SetMangaViewerFlags(get()) }
|
||||
addFactory { NetworkToLocalManga(get()) }
|
||||
addFactory { UpdateManga(get(), get()) }
|
||||
addFactory { UpdateMangaNotes(get()) }
|
||||
addFactory { SetMangaCategories(get()) }
|
||||
addFactory { GetExcludedScanlators(get()) }
|
||||
addFactory { SetExcludedScanlators(get()) }
|
||||
addFactory {
|
||||
MigrateMangaUseCase(
|
||||
get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(),
|
||||
)
|
||||
}
|
||||
|
||||
addSingletonFactory<ReleaseService> { ReleaseServiceImpl(get(), get()) }
|
||||
addFactory { GetApplicationRelease(get(), get()) }
|
||||
|
@@ -2,7 +2,9 @@ package eu.kanade.domain.manga.interactor
|
||||
|
||||
import eu.kanade.domain.manga.model.hasCustomCover
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
import tachiyomi.domain.manga.interactor.FetchInterval
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.manga.model.MangaUpdate
|
||||
@@ -31,6 +33,8 @@ class UpdateManga(
|
||||
remoteManga: SManga,
|
||||
manualFetch: Boolean,
|
||||
coverCache: CoverCache = Injekt.get(),
|
||||
libraryPreferences: LibraryPreferences = Injekt.get(),
|
||||
downloadManager: DownloadManager = Injekt.get(),
|
||||
): Boolean {
|
||||
val remoteTitle = try {
|
||||
remoteManga.title
|
||||
@@ -38,8 +42,13 @@ class UpdateManga(
|
||||
""
|
||||
}
|
||||
|
||||
// if the manga isn't a favorite, set its title from source and update in db
|
||||
val title = if (remoteTitle.isEmpty() || localManga.favorite) null else remoteTitle
|
||||
// if the manga isn't a favorite (or 'update titles' preference is enabled), set its title from source and update in db
|
||||
val title =
|
||||
if (remoteTitle.isNotEmpty() && (!localManga.favorite || libraryPreferences.updateMangaTitles().get())) {
|
||||
remoteTitle
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val coverLastModified =
|
||||
when {
|
||||
@@ -59,7 +68,7 @@ class UpdateManga(
|
||||
|
||||
val thumbnailUrl = remoteManga.thumbnail_url?.takeIf { it.isNotEmpty() }
|
||||
|
||||
return mangaRepository.update(
|
||||
val success = mangaRepository.update(
|
||||
MangaUpdate(
|
||||
id = localManga.id,
|
||||
title = title,
|
||||
@@ -74,6 +83,10 @@ class UpdateManga(
|
||||
initialized = true,
|
||||
),
|
||||
)
|
||||
if (success && title != null) {
|
||||
downloadManager.renameManga(localManga, title)
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
suspend fun awaitUpdateFetchInterval(
|
||||
|
@@ -69,22 +69,6 @@ fun Manga.copyFrom(other: SManga): Manga {
|
||||
)
|
||||
}
|
||||
|
||||
fun SManga.toDomainManga(sourceId: Long): Manga {
|
||||
return Manga.create().copy(
|
||||
url = url,
|
||||
title = title,
|
||||
artist = artist,
|
||||
author = author,
|
||||
description = description,
|
||||
genre = getGenres(),
|
||||
status = status.toLong(),
|
||||
thumbnailUrl = thumbnail_url,
|
||||
updateStrategy = update_strategy,
|
||||
initialized = initialized,
|
||||
source = sourceId,
|
||||
)
|
||||
}
|
||||
|
||||
fun Manga.hasCustomCover(coverCache: CoverCache = Injekt.get()): Boolean {
|
||||
return coverCache.getCustomCoverFile(id).exists()
|
||||
}
|
||||
|
@@ -2,16 +2,18 @@ package eu.kanade.domain.source.service
|
||||
|
||||
import eu.kanade.domain.source.interactor.SetMigrateSorting
|
||||
import eu.kanade.tachiyomi.util.system.LocaleHelper
|
||||
import mihon.domain.migration.models.MigrationFlag
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
import tachiyomi.core.common.preference.PreferenceStore
|
||||
import tachiyomi.core.common.preference.getEnum
|
||||
import tachiyomi.core.common.preference.getLongArray
|
||||
import tachiyomi.domain.library.model.LibraryDisplayMode
|
||||
|
||||
class SourcePreferences(
|
||||
private val preferenceStore: PreferenceStore,
|
||||
) {
|
||||
|
||||
fun sourceDisplayMode() = preferenceStore.getObject(
|
||||
fun sourceDisplayMode() = preferenceStore.getObjectFromString(
|
||||
"pref_display_mode_catalogue",
|
||||
LibraryDisplayMode.default,
|
||||
LibraryDisplayMode.Serializer::serialize,
|
||||
@@ -55,4 +57,21 @@ class SourcePreferences(
|
||||
Preference.appStateKey("has_filters_toggle_state"),
|
||||
false,
|
||||
)
|
||||
|
||||
fun migrationSources() = preferenceStore.getLongArray("migration_sources", emptyList())
|
||||
|
||||
fun migrationFlags() = preferenceStore.getObjectFromInt(
|
||||
key = "migration_flags",
|
||||
defaultValue = MigrationFlag.entries.toSet(),
|
||||
serializer = { MigrationFlag.toBit(it) },
|
||||
deserializer = { value: Int -> MigrationFlag.fromBit(value) },
|
||||
)
|
||||
|
||||
fun migrationDeepSearchMode() = preferenceStore.getBoolean("migration_deep_search", false)
|
||||
|
||||
fun migrationPrioritizeByChapters() = preferenceStore.getBoolean("migration_prioritize_by_chapters", false)
|
||||
|
||||
fun migrationHideUnmatched() = preferenceStore.getBoolean("migration_hide_unmatched", false)
|
||||
|
||||
fun migrationHideWithoutUpdates() = preferenceStore.getBoolean("migration_hide_without_updates", false)
|
||||
}
|
||||
|
@@ -34,6 +34,8 @@ class UiPreferences(
|
||||
|
||||
fun tabletUiMode() = preferenceStore.getEnum("tablet_ui_mode", TabletUiMode.AUTOMATIC)
|
||||
|
||||
fun imagesInDescription() = preferenceStore.getBoolean("pref_render_images_description", true)
|
||||
|
||||
companion object {
|
||||
fun dateFormat(format: String): DateTimeFormatter = when (format) {
|
||||
"" -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
|
||||
|
@@ -1,18 +1,16 @@
|
||||
package eu.kanade.domain.ui.model
|
||||
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
|
||||
import tachiyomi.i18n.MR
|
||||
|
||||
enum class AppTheme(val titleRes: StringResource?) {
|
||||
DEFAULT(MR.strings.label_default),
|
||||
MONET(MR.strings.theme_monet),
|
||||
CATPPUCCIN(MR.strings.theme_catppuccin),
|
||||
GREEN_APPLE(MR.strings.theme_greenapple),
|
||||
LAVENDER(MR.strings.theme_lavender),
|
||||
MIDNIGHT_DUSK(MR.strings.theme_midnightdusk),
|
||||
|
||||
// TODO: re-enable for preview
|
||||
NORD(MR.strings.theme_nord.takeUnless { isReleaseBuildType }),
|
||||
NORD(MR.strings.theme_nord),
|
||||
STRAWBERRY_DAIQUIRI(MR.strings.theme_strawberrydaiquiri),
|
||||
TAKO(MR.strings.theme_tako),
|
||||
TEALTURQUOISE(MR.strings.theme_tealturquoise),
|
||||
|
@@ -73,10 +73,18 @@ fun BrowseSourceContent(
|
||||
}
|
||||
}
|
||||
|
||||
if (mangaList.itemCount <= 0 && errorState != null && errorState is LoadState.Error) {
|
||||
if (mangaList.itemCount == 0 && mangaList.loadState.refresh is LoadState.Loading) {
|
||||
LoadingScreen(Modifier.padding(contentPadding))
|
||||
return
|
||||
}
|
||||
|
||||
if (mangaList.itemCount == 0) {
|
||||
EmptyScreen(
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
message = getErrorMessage(errorState),
|
||||
message = when (errorState) {
|
||||
is LoadState.Error -> getErrorMessage(errorState)
|
||||
else -> stringResource(MR.strings.no_results_found)
|
||||
},
|
||||
actions = if (source is LocalSource) {
|
||||
persistentListOf(
|
||||
EmptyScreenAction(
|
||||
@@ -109,13 +117,6 @@ fun BrowseSourceContent(
|
||||
return
|
||||
}
|
||||
|
||||
if (mangaList.itemCount == 0 && mangaList.loadState.refresh is LoadState.Loading) {
|
||||
LoadingScreen(
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
when (displayMode) {
|
||||
LibraryDisplayMode.ComfortableGrid -> {
|
||||
BrowseSourceComfortableGrid(
|
||||
|
@@ -40,6 +40,7 @@ fun GlobalSearchScreen(
|
||||
navigateUp = navigateUp,
|
||||
onChangeSearchQuery = onChangeSearchQuery,
|
||||
onSearch = onSearch,
|
||||
hideSourceFilter = false,
|
||||
sourceFilter = state.sourceFilter,
|
||||
onChangeSearchFilter = onChangeSearchFilter,
|
||||
onlyShowHasResults = state.onlyShowHasResults,
|
||||
|
@@ -1,84 +0,0 @@
|
||||
package eu.kanade.presentation.browse
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.manga.components.BaseMangaListItem
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.manga.MigrateMangaScreenModel
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.FastScrollLazyColumn
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.screens.EmptyScreen
|
||||
|
||||
@Composable
|
||||
fun MigrateMangaScreen(
|
||||
navigateUp: () -> Unit,
|
||||
title: String?,
|
||||
state: MigrateMangaScreenModel.State,
|
||||
onClickItem: (Manga) -> Unit,
|
||||
onClickCover: (Manga) -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { scrollBehavior ->
|
||||
AppBar(
|
||||
title = title,
|
||||
navigateUp = navigateUp,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
) { contentPadding ->
|
||||
if (state.isEmpty) {
|
||||
EmptyScreen(
|
||||
stringRes = MR.strings.empty_screen,
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
)
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
MigrateMangaContent(
|
||||
contentPadding = contentPadding,
|
||||
state = state,
|
||||
onClickItem = onClickItem,
|
||||
onClickCover = onClickCover,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MigrateMangaContent(
|
||||
contentPadding: PaddingValues,
|
||||
state: MigrateMangaScreenModel.State,
|
||||
onClickItem: (Manga) -> Unit,
|
||||
onClickCover: (Manga) -> Unit,
|
||||
) {
|
||||
FastScrollLazyColumn(
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
items(state.titles) { manga ->
|
||||
MigrateMangaItem(
|
||||
manga = manga,
|
||||
onClickItem = onClickItem,
|
||||
onClickCover = onClickCover,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MigrateMangaItem(
|
||||
manga: Manga,
|
||||
onClickItem: (Manga) -> Unit,
|
||||
onClickCover: (Manga) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BaseMangaListItem(
|
||||
modifier = modifier,
|
||||
manga = manga,
|
||||
onClickItem = { onClickItem(manga) },
|
||||
onClickCover = { onClickCover(manga) },
|
||||
)
|
||||
}
|
@@ -32,6 +32,7 @@ fun MigrateSearchScreen(
|
||||
navigateUp = navigateUp,
|
||||
onChangeSearchQuery = onChangeSearchQuery,
|
||||
onSearch = onSearch,
|
||||
hideSourceFilter = true,
|
||||
sourceFilter = state.sourceFilter,
|
||||
onChangeSearchFilter = onChangeSearchFilter,
|
||||
onlyShowHasResults = state.onlyShowHasResults,
|
||||
|
@@ -40,6 +40,7 @@ fun GlobalSearchToolbar(
|
||||
navigateUp: () -> Unit,
|
||||
onChangeSearchQuery: (String?) -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
hideSourceFilter: Boolean,
|
||||
sourceFilter: SourceFilter,
|
||||
onChangeSearchFilter: (SourceFilter) -> Unit,
|
||||
onlyShowHasResults: Boolean,
|
||||
@@ -73,6 +74,7 @@ fun GlobalSearchToolbar(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
|
||||
) {
|
||||
// TODO: make this UX better; it only applies when triggering a new search
|
||||
if (!hideSourceFilter) {
|
||||
FilterChip(
|
||||
selected = sourceFilter == SourceFilter.PinnedOnly,
|
||||
onClick = { onChangeSearchFilter(SourceFilter.PinnedOnly) },
|
||||
@@ -105,6 +107,7 @@ fun GlobalSearchToolbar(
|
||||
)
|
||||
|
||||
VerticalDivider()
|
||||
}
|
||||
|
||||
FilterChip(
|
||||
selected = onlyShowHasResults,
|
||||
|
@@ -1,10 +1,9 @@
|
||||
package eu.kanade.presentation.components
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.window.Dialog
|
||||
@@ -28,20 +27,14 @@ fun NavigatorAdaptiveSheet(
|
||||
screen = screen,
|
||||
content = { sheetNavigator ->
|
||||
AdaptiveSheet(
|
||||
enableSwipeDismiss = enableSwipeDismiss(sheetNavigator),
|
||||
onDismissRequest = onDismissRequest,
|
||||
enableSwipeDismiss = enableSwipeDismiss(sheetNavigator),
|
||||
) {
|
||||
ScreenTransition(
|
||||
navigator = sheetNavigator,
|
||||
transition = {
|
||||
fadeIn(animationSpec = tween(220, delayMillis = 90)) togetherWith
|
||||
fadeOut(animationSpec = tween(90))
|
||||
},
|
||||
)
|
||||
|
||||
BackHandler(
|
||||
enabled = sheetNavigator.size > 1,
|
||||
onBack = sheetNavigator::pop,
|
||||
enterTransition = { fadeIn(animationSpec = tween(220, delayMillis = 90)) },
|
||||
exitTransition = { fadeOut(animationSpec = tween(90)) },
|
||||
sizeTransform = { SizeTransform() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,10 +72,10 @@ fun AdaptiveSheet(
|
||||
properties = dialogProperties,
|
||||
) {
|
||||
AdaptiveSheetImpl(
|
||||
modifier = modifier,
|
||||
isTabletUi = isTabletUi,
|
||||
enableSwipeDismiss = enableSwipeDismiss,
|
||||
onDismissRequest = onDismissRequest,
|
||||
modifier = modifier,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
@@ -36,6 +36,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -201,6 +202,7 @@ fun AppBarActions(
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
focusable = false,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = it.onClick,
|
||||
@@ -225,6 +227,7 @@ fun AppBarActions(
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
focusable = false,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { showMenu = !showMenu },
|
||||
@@ -289,6 +292,7 @@ fun SearchToolbar(
|
||||
onSearch(searchQuery)
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
focusManager.moveFocus(FocusDirection.Next)
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
@@ -352,6 +356,7 @@ fun SearchToolbar(
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
focusable = false,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
@@ -371,6 +376,7 @@ fun SearchToolbar(
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
focusable = false,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
|
@@ -1,9 +1,11 @@
|
||||
package eu.kanade.presentation.components
|
||||
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import eu.kanade.presentation.manga.DownloadAction
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import tachiyomi.i18n.MR
|
||||
@@ -15,7 +17,41 @@ fun DownloadDropdownMenu(
|
||||
expanded: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
onDownloadClicked: (DownloadAction) -> Unit,
|
||||
offset: DpOffset? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (offset != null) {
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = onDismissRequest,
|
||||
modifier = modifier,
|
||||
offset = offset,
|
||||
content = {
|
||||
DownloadDropdownMenuItems(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onDownloadClicked = onDownloadClicked,
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = onDismissRequest,
|
||||
modifier = modifier,
|
||||
content = {
|
||||
DownloadDropdownMenuItems(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onDownloadClicked = onDownloadClicked,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.DownloadDropdownMenuItems(
|
||||
onDismissRequest: () -> Unit,
|
||||
onDownloadClicked: (DownloadAction) -> Unit,
|
||||
) {
|
||||
val options = persistentListOf(
|
||||
DownloadAction.NEXT_1_CHAPTER to pluralStringResource(MR.plurals.download_amount, 1, 1),
|
||||
@@ -25,11 +61,6 @@ fun DownloadDropdownMenu(
|
||||
DownloadAction.UNREAD_CHAPTERS to stringResource(MR.strings.download_unread),
|
||||
)
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = onDismissRequest,
|
||||
modifier = modifier,
|
||||
) {
|
||||
options.map { (downloadAction, string) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = string) },
|
||||
@@ -39,5 +70,4 @@ fun DownloadDropdownMenu(
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.util.fastAny
|
||||
import eu.kanade.tachiyomi.ui.library.LibraryItem
|
||||
import tachiyomi.domain.library.model.LibraryManga
|
||||
import tachiyomi.domain.manga.model.MangaCover
|
||||
@@ -15,7 +14,7 @@ internal fun LibraryComfortableGrid(
|
||||
items: List<LibraryItem>,
|
||||
columns: Int,
|
||||
contentPadding: PaddingValues,
|
||||
selection: List<LibraryManga>,
|
||||
selection: Set<Long>,
|
||||
onClick: (LibraryManga) -> Unit,
|
||||
onLongClick: (LibraryManga) -> Unit,
|
||||
onClickContinueReading: ((LibraryManga) -> Unit)?,
|
||||
@@ -35,7 +34,7 @@ internal fun LibraryComfortableGrid(
|
||||
) { libraryItem ->
|
||||
val manga = libraryItem.libraryManga.manga
|
||||
MangaComfortableGridItem(
|
||||
isSelected = selection.fastAny { it.id == libraryItem.libraryManga.id },
|
||||
isSelected = manga.id in selection,
|
||||
title = manga.title,
|
||||
coverData = MangaCover(
|
||||
mangaId = manga.id,
|
||||
|
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.util.fastAny
|
||||
import eu.kanade.tachiyomi.ui.library.LibraryItem
|
||||
import tachiyomi.domain.library.model.LibraryManga
|
||||
import tachiyomi.domain.manga.model.MangaCover
|
||||
@@ -16,7 +15,7 @@ internal fun LibraryCompactGrid(
|
||||
showTitle: Boolean,
|
||||
columns: Int,
|
||||
contentPadding: PaddingValues,
|
||||
selection: List<LibraryManga>,
|
||||
selection: Set<Long>,
|
||||
onClick: (LibraryManga) -> Unit,
|
||||
onLongClick: (LibraryManga) -> Unit,
|
||||
onClickContinueReading: ((LibraryManga) -> Unit)?,
|
||||
@@ -36,7 +35,7 @@ internal fun LibraryCompactGrid(
|
||||
) { libraryItem ->
|
||||
val manga = libraryItem.libraryManga.manga
|
||||
MangaCompactGridItem(
|
||||
isSelected = selection.fastAny { it.id == libraryItem.libraryManga.id },
|
||||
isSelected = manga.id in selection,
|
||||
title = manga.title.takeIf { showTitle },
|
||||
coverData = MangaCover(
|
||||
mangaId = manga.id,
|
||||
|
@@ -29,22 +29,22 @@ import kotlin.time.Duration.Companion.seconds
|
||||
fun LibraryContent(
|
||||
categories: List<Category>,
|
||||
searchQuery: String?,
|
||||
selection: List<LibraryManga>,
|
||||
selection: Set<Long>,
|
||||
contentPadding: PaddingValues,
|
||||
currentPage: () -> Int,
|
||||
currentPage: Int,
|
||||
hasActiveFilters: Boolean,
|
||||
showPageTabs: Boolean,
|
||||
onChangeCurrentPage: (Int) -> Unit,
|
||||
onMangaClicked: (Long) -> Unit,
|
||||
onClickManga: (Long) -> Unit,
|
||||
onContinueReadingClicked: ((LibraryManga) -> Unit)?,
|
||||
onToggleSelection: (LibraryManga) -> Unit,
|
||||
onToggleRangeSelection: (LibraryManga) -> Unit,
|
||||
onRefresh: (Category?) -> Boolean,
|
||||
onToggleSelection: (Category, LibraryManga) -> Unit,
|
||||
onToggleRangeSelection: (Category, LibraryManga) -> Unit,
|
||||
onRefresh: () -> Boolean,
|
||||
onGlobalSearchClicked: () -> Unit,
|
||||
getNumberOfMangaForCategory: (Category) -> Int?,
|
||||
getItemCountForCategory: (Category) -> Int?,
|
||||
getDisplayMode: (Int) -> PreferenceMutableState<LibraryDisplayMode>,
|
||||
getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
|
||||
getLibraryForPage: (Int) -> List<LibraryItem>,
|
||||
getItemsForCategory: (Category) -> List<LibraryItem>,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
@@ -53,13 +53,12 @@ fun LibraryContent(
|
||||
end = contentPadding.calculateEndPadding(LocalLayoutDirection.current),
|
||||
),
|
||||
) {
|
||||
val coercedCurrentPage = remember { currentPage().coerceAtMost(categories.lastIndex) }
|
||||
val pagerState = rememberPagerState(coercedCurrentPage) { categories.size }
|
||||
val pagerState = rememberPagerState(currentPage) { categories.size }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) }
|
||||
|
||||
if (showPageTabs && categories.size > 1) {
|
||||
if (showPageTabs && categories.isNotEmpty()) {
|
||||
LaunchedEffect(categories) {
|
||||
if (categories.size <= pagerState.currentPage) {
|
||||
pagerState.scrollToPage(categories.size - 1)
|
||||
@@ -68,23 +67,20 @@ fun LibraryContent(
|
||||
LibraryTabs(
|
||||
categories = categories,
|
||||
pagerState = pagerState,
|
||||
getNumberOfMangaForCategory = getNumberOfMangaForCategory,
|
||||
) { scope.launch { pagerState.animateScrollToPage(it) } }
|
||||
}
|
||||
|
||||
val notSelectionMode = selection.isEmpty()
|
||||
val onClickManga = { manga: LibraryManga ->
|
||||
if (notSelectionMode) {
|
||||
onMangaClicked(manga.manga.id)
|
||||
} else {
|
||||
onToggleSelection(manga)
|
||||
getItemCountForCategory = getItemCountForCategory,
|
||||
onTabItemClick = {
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(it)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
PullRefresh(
|
||||
refreshing = isRefreshing,
|
||||
enabled = selection.isEmpty(),
|
||||
onRefresh = {
|
||||
val started = onRefresh(categories[currentPage()])
|
||||
val started = onRefresh()
|
||||
if (!started) return@PullRefresh
|
||||
scope.launch {
|
||||
// Fake refresh status but hide it after a second as it's a long running task
|
||||
@@ -93,19 +89,25 @@ fun LibraryContent(
|
||||
isRefreshing = false
|
||||
}
|
||||
},
|
||||
enabled = notSelectionMode,
|
||||
) {
|
||||
LibraryPager(
|
||||
state = pagerState,
|
||||
contentPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()),
|
||||
hasActiveFilters = hasActiveFilters,
|
||||
selectedManga = selection,
|
||||
selection = selection,
|
||||
searchQuery = searchQuery,
|
||||
onGlobalSearchClicked = onGlobalSearchClicked,
|
||||
getCategoryForPage = { page -> categories[page] },
|
||||
getDisplayMode = getDisplayMode,
|
||||
getColumnsForOrientation = getColumnsForOrientation,
|
||||
getLibraryForPage = getLibraryForPage,
|
||||
onClickManga = onClickManga,
|
||||
getItemsForCategory = getItemsForCategory,
|
||||
onClickManga = { category, manga ->
|
||||
if (selection.isNotEmpty()) {
|
||||
onToggleSelection(category, manga)
|
||||
} else {
|
||||
onClickManga(manga.manga.id)
|
||||
}
|
||||
},
|
||||
onLongClickManga = onToggleRangeSelection,
|
||||
onClickContinueReading = onContinueReadingClicked,
|
||||
)
|
||||
|
@@ -7,7 +7,6 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastAny
|
||||
import eu.kanade.tachiyomi.ui.library.LibraryItem
|
||||
import tachiyomi.domain.library.model.LibraryManga
|
||||
import tachiyomi.domain.manga.model.MangaCover
|
||||
@@ -18,7 +17,7 @@ import tachiyomi.presentation.core.util.plus
|
||||
internal fun LibraryList(
|
||||
items: List<LibraryItem>,
|
||||
contentPadding: PaddingValues,
|
||||
selection: List<LibraryManga>,
|
||||
selection: Set<Long>,
|
||||
onClick: (LibraryManga) -> Unit,
|
||||
onLongClick: (LibraryManga) -> Unit,
|
||||
onClickContinueReading: ((LibraryManga) -> Unit)?,
|
||||
@@ -45,7 +44,7 @@ internal fun LibraryList(
|
||||
) { libraryItem ->
|
||||
val manga = libraryItem.libraryManga.manga
|
||||
MangaListItem(
|
||||
isSelected = selection.fastAny { it.id == libraryItem.libraryManga.id },
|
||||
isSelected = manga.id in selection,
|
||||
title = manga.title,
|
||||
coverData = MangaCover(
|
||||
mangaId = manga.id,
|
||||
|
@@ -20,6 +20,7 @@ import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.core.preference.PreferenceMutableState
|
||||
import eu.kanade.tachiyomi.ui.library.LibraryItem
|
||||
import tachiyomi.domain.category.model.Category
|
||||
import tachiyomi.domain.library.model.LibraryDisplayMode
|
||||
import tachiyomi.domain.library.model.LibraryManga
|
||||
import tachiyomi.i18n.MR
|
||||
@@ -31,14 +32,15 @@ fun LibraryPager(
|
||||
state: PagerState,
|
||||
contentPadding: PaddingValues,
|
||||
hasActiveFilters: Boolean,
|
||||
selectedManga: List<LibraryManga>,
|
||||
selection: Set<Long>,
|
||||
searchQuery: String?,
|
||||
onGlobalSearchClicked: () -> Unit,
|
||||
getCategoryForPage: (Int) -> Category,
|
||||
getDisplayMode: (Int) -> PreferenceMutableState<LibraryDisplayMode>,
|
||||
getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
|
||||
getLibraryForPage: (Int) -> List<LibraryItem>,
|
||||
onClickManga: (LibraryManga) -> Unit,
|
||||
onLongClickManga: (LibraryManga) -> Unit,
|
||||
getItemsForCategory: (Category) -> List<LibraryItem>,
|
||||
onClickManga: (Category, LibraryManga) -> Unit,
|
||||
onLongClickManga: (Category, LibraryManga) -> Unit,
|
||||
onClickContinueReading: ((LibraryManga) -> Unit)?,
|
||||
) {
|
||||
HorizontalPager(
|
||||
@@ -50,9 +52,10 @@ fun LibraryPager(
|
||||
// To make sure only one offscreen page is being composed
|
||||
return@HorizontalPager
|
||||
}
|
||||
val library = getLibraryForPage(page)
|
||||
val category = getCategoryForPage(page)
|
||||
val items = getItemsForCategory(category)
|
||||
|
||||
if (library.isEmpty()) {
|
||||
if (items.isEmpty()) {
|
||||
LibraryPagerEmptyScreen(
|
||||
searchQuery = searchQuery,
|
||||
hasActiveFilters = hasActiveFilters,
|
||||
@@ -72,12 +75,15 @@ fun LibraryPager(
|
||||
remember { mutableIntStateOf(0) }
|
||||
}
|
||||
|
||||
val onClickManga: (LibraryManga) -> Unit = { onClickManga(category, it) }
|
||||
val onLongClickManga: (LibraryManga) -> Unit = { onLongClickManga(category, it) }
|
||||
|
||||
when (displayMode) {
|
||||
LibraryDisplayMode.List -> {
|
||||
LibraryList(
|
||||
items = library,
|
||||
items = items,
|
||||
contentPadding = contentPadding,
|
||||
selection = selectedManga,
|
||||
selection = selection,
|
||||
onClick = onClickManga,
|
||||
onLongClick = onLongClickManga,
|
||||
onClickContinueReading = onClickContinueReading,
|
||||
@@ -87,11 +93,11 @@ fun LibraryPager(
|
||||
}
|
||||
LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> {
|
||||
LibraryCompactGrid(
|
||||
items = library,
|
||||
items = items,
|
||||
showTitle = displayMode is LibraryDisplayMode.CompactGrid,
|
||||
columns = columns,
|
||||
contentPadding = contentPadding,
|
||||
selection = selectedManga,
|
||||
selection = selection,
|
||||
onClick = onClickManga,
|
||||
onLongClick = onLongClickManga,
|
||||
onClickContinueReading = onClickContinueReading,
|
||||
@@ -101,10 +107,10 @@ fun LibraryPager(
|
||||
}
|
||||
LibraryDisplayMode.ComfortableGrid -> {
|
||||
LibraryComfortableGrid(
|
||||
items = library,
|
||||
items = items,
|
||||
columns = columns,
|
||||
contentPadding = contentPadding,
|
||||
selection = selectedManga,
|
||||
selection = selection,
|
||||
onClick = onClickManga,
|
||||
onLongClick = onLongClickManga,
|
||||
onClickContinueReading = onClickContinueReading,
|
||||
|
@@ -18,13 +18,11 @@ import tachiyomi.presentation.core.components.material.TabText
|
||||
internal fun LibraryTabs(
|
||||
categories: List<Category>,
|
||||
pagerState: PagerState,
|
||||
getNumberOfMangaForCategory: (Category) -> Int?,
|
||||
getItemCountForCategory: (Category) -> Int?,
|
||||
onTabItemClick: (Int) -> Unit,
|
||||
) {
|
||||
val currentPageIndex = pagerState.currentPage.coerceAtMost(categories.lastIndex)
|
||||
Column(
|
||||
modifier = Modifier.zIndex(1f),
|
||||
) {
|
||||
Column(modifier = Modifier.zIndex(2f)) {
|
||||
PrimaryScrollableTabRow(
|
||||
selectedTabIndex = currentPageIndex,
|
||||
edgePadding = 0.dp,
|
||||
@@ -39,7 +37,7 @@ internal fun LibraryTabs(
|
||||
text = {
|
||||
TabText(
|
||||
text = category.visualName,
|
||||
badgeCount = getNumberOfMangaForCategory(category),
|
||||
badgeCount = getItemCountForCategory(category),
|
||||
)
|
||||
},
|
||||
unselectedContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
|
@@ -1,44 +1,95 @@
|
||||
package eu.kanade.presentation.manga
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Brush
|
||||
import androidx.compose.material.icons.filled.PersonOutline
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.Book
|
||||
import androidx.compose.material.icons.outlined.SwapVert
|
||||
import androidx.compose.material.icons.outlined.AttachMoney
|
||||
import androidx.compose.material.icons.outlined.Block
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.outlined.Done
|
||||
import androidx.compose.material.icons.outlined.DoneAll
|
||||
import androidx.compose.material.icons.outlined.Pause
|
||||
import androidx.compose.material.icons.outlined.Schedule
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.util.fastMaxOfOrNull
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import eu.kanade.presentation.components.AdaptiveSheet
|
||||
import eu.kanade.presentation.components.TabbedDialogPaddings
|
||||
import eu.kanade.presentation.manga.components.MangaCover
|
||||
import eu.kanade.presentation.more.settings.LocalPreferenceMinHeight
|
||||
import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.manga.model.MangaWithChapterCount
|
||||
import tachiyomi.domain.source.model.StubSource
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.Badge
|
||||
import tachiyomi.presentation.core.components.BadgeGroup
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.i18n.pluralStringResource
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.util.secondaryItemAlpha
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
@Composable
|
||||
fun DuplicateMangaDialog(
|
||||
duplicates: List<MangaWithChapterCount>,
|
||||
onDismissRequest: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
onOpenManga: () -> Unit,
|
||||
onMigrate: () -> Unit,
|
||||
onOpenManga: (manga: Manga) -> Unit,
|
||||
onMigrate: (manga: Manga) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val sourceManager = remember { Injekt.get<SourceManager>() }
|
||||
val minHeight = LocalPreferenceMinHeight.current
|
||||
val horizontalPadding = PaddingValues(horizontal = TabbedDialogPaddings.Horizontal)
|
||||
val horizontalPaddingModifier = Modifier.padding(horizontalPadding)
|
||||
|
||||
AdaptiveSheet(
|
||||
modifier = modifier,
|
||||
@@ -46,45 +97,45 @@ fun DuplicateMangaDialog(
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
vertical = TabbedDialogPaddings.Vertical,
|
||||
horizontal = TabbedDialogPaddings.Horizontal,
|
||||
)
|
||||
.padding(vertical = TabbedDialogPaddings.Vertical)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.medium),
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(TitlePadding),
|
||||
text = stringResource(MR.strings.are_you_sure),
|
||||
text = stringResource(MR.strings.possible_duplicates_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier
|
||||
.then(horizontalPaddingModifier)
|
||||
.padding(top = MaterialTheme.padding.small),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(MR.strings.confirm_add_duplicate_manga),
|
||||
text = stringResource(MR.strings.possible_duplicates_summary),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.then(horizontalPaddingModifier),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(PaddingSize))
|
||||
|
||||
TextPreferenceWidget(
|
||||
title = stringResource(MR.strings.action_show_manga),
|
||||
icon = Icons.Outlined.Book,
|
||||
onPreferenceClick = {
|
||||
onDismissRequest()
|
||||
onOpenManga()
|
||||
},
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
TextPreferenceWidget(
|
||||
title = stringResource(MR.strings.action_migrate_duplicate),
|
||||
icon = Icons.Outlined.SwapVert,
|
||||
onPreferenceClick = {
|
||||
onDismissRequest()
|
||||
onMigrate()
|
||||
},
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
|
||||
modifier = Modifier.height(getMaximumMangaCardHeight(duplicates)),
|
||||
contentPadding = horizontalPadding,
|
||||
) {
|
||||
items(
|
||||
items = duplicates,
|
||||
key = { it.manga.id },
|
||||
) {
|
||||
DuplicateMangaListItem(
|
||||
duplicate = it,
|
||||
getSource = { sourceManager.getOrStub(it.manga.source) },
|
||||
onMigrate = { onMigrate(it.manga) },
|
||||
onDismissRequest = onDismissRequest,
|
||||
onOpenManga = { onOpenManga(it.manga) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = horizontalPaddingModifier) {
|
||||
HorizontalDivider()
|
||||
|
||||
TextPreferenceWidget(
|
||||
@@ -94,33 +145,262 @@ fun DuplicateMangaDialog(
|
||||
onDismissRequest()
|
||||
onConfirm()
|
||||
},
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.sizeIn(minHeight = minHeight)
|
||||
.clickable { onDismissRequest.invoke() }
|
||||
.padding(ButtonPadding)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
OutlinedButton(onClick = onDismissRequest, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp),
|
||||
text = stringResource(MR.strings.action_cancel),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.clip(CircleShape),
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onDismissRequest,
|
||||
modifier = Modifier
|
||||
.then(horizontalPaddingModifier)
|
||||
.padding(bottom = MaterialTheme.padding.medium)
|
||||
.heightIn(min = minHeight)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(vertical = MaterialTheme.padding.extraSmall),
|
||||
text = stringResource(MR.strings.action_cancel),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val PaddingSize = 16.dp
|
||||
@Composable
|
||||
private fun DuplicateMangaListItem(
|
||||
duplicate: MangaWithChapterCount,
|
||||
getSource: () -> Source,
|
||||
onDismissRequest: () -> Unit,
|
||||
onOpenManga: () -> Unit,
|
||||
onMigrate: () -> Unit,
|
||||
) {
|
||||
val source = getSource()
|
||||
val manga = duplicate.manga
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(MangaCardWidth)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.combinedClickable(
|
||||
onLongClick = { onOpenManga() },
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
onMigrate()
|
||||
},
|
||||
)
|
||||
.padding(MaterialTheme.padding.small),
|
||||
) {
|
||||
Box {
|
||||
MangaCover.Book(
|
||||
data = ImageRequest.Builder(LocalContext.current)
|
||||
.data(manga)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
BadgeGroup(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.align(Alignment.TopStart),
|
||||
) {
|
||||
Badge(
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
textColor = MaterialTheme.colorScheme.onSecondary,
|
||||
text = pluralStringResource(
|
||||
MR.plurals.manga_num_chapters,
|
||||
duplicate.chapterCount.toInt(),
|
||||
duplicate.chapterCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val ButtonPadding = PaddingValues(top = 16.dp, bottom = 16.dp)
|
||||
private val TitlePadding = PaddingValues(bottom = 16.dp, top = 8.dp)
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.padding.extraSmall))
|
||||
|
||||
Text(
|
||||
text = manga.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
)
|
||||
|
||||
if (!manga.author.isNullOrBlank()) {
|
||||
MangaDetailRow(
|
||||
text = manga.author!!,
|
||||
iconImageVector = Icons.Filled.PersonOutline,
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
|
||||
if (!manga.artist.isNullOrBlank() && manga.author != manga.artist) {
|
||||
MangaDetailRow(
|
||||
text = manga.artist!!,
|
||||
iconImageVector = Icons.Filled.Brush,
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
|
||||
MangaDetailRow(
|
||||
text = when (manga.status) {
|
||||
SManga.ONGOING.toLong() -> stringResource(MR.strings.ongoing)
|
||||
SManga.COMPLETED.toLong() -> stringResource(MR.strings.completed)
|
||||
SManga.LICENSED.toLong() -> stringResource(MR.strings.licensed)
|
||||
SManga.PUBLISHING_FINISHED.toLong() -> stringResource(MR.strings.publishing_finished)
|
||||
SManga.CANCELLED.toLong() -> stringResource(MR.strings.cancelled)
|
||||
SManga.ON_HIATUS.toLong() -> stringResource(MR.strings.on_hiatus)
|
||||
else -> stringResource(MR.strings.unknown)
|
||||
},
|
||||
iconImageVector = when (manga.status) {
|
||||
SManga.ONGOING.toLong() -> Icons.Outlined.Schedule
|
||||
SManga.COMPLETED.toLong() -> Icons.Outlined.DoneAll
|
||||
SManga.LICENSED.toLong() -> Icons.Outlined.AttachMoney
|
||||
SManga.PUBLISHING_FINISHED.toLong() -> Icons.Outlined.Done
|
||||
SManga.CANCELLED.toLong() -> Icons.Outlined.Close
|
||||
SManga.ON_HIATUS.toLong() -> Icons.Outlined.Pause
|
||||
else -> Icons.Outlined.Block
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
if (source is StubSource) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = source.name,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MangaDetailRow(
|
||||
text: String,
|
||||
iconImageVector: ImageVector,
|
||||
maxLines: Int = 1,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.secondaryItemAlpha()
|
||||
.padding(top = MaterialTheme.padding.extraSmall),
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = iconImageVector,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(MangaDetailsIconWidth),
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = maxLines,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getMaximumMangaCardHeight(duplicates: List<MangaWithChapterCount>): Dp {
|
||||
val density = LocalDensity.current
|
||||
val typography = MaterialTheme.typography
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
|
||||
val smallPadding = with(density) { MaterialTheme.padding.small.roundToPx() }
|
||||
val extraSmallPadding = with(density) { MaterialTheme.padding.extraSmall.roundToPx() }
|
||||
|
||||
val width = with(density) { MangaCardWidth.roundToPx() - (2 * smallPadding) }
|
||||
val iconWidth = with(density) { MangaDetailsIconWidth.roundToPx() }
|
||||
|
||||
val coverHeight = width / MangaCover.Book.ratio
|
||||
val constraints = Constraints(maxWidth = width)
|
||||
val detailsConstraints = Constraints(maxWidth = width - iconWidth - extraSmallPadding)
|
||||
|
||||
return remember(
|
||||
duplicates,
|
||||
density,
|
||||
typography,
|
||||
textMeasurer,
|
||||
smallPadding,
|
||||
extraSmallPadding,
|
||||
coverHeight,
|
||||
constraints,
|
||||
detailsConstraints,
|
||||
) {
|
||||
duplicates.fastMaxOfOrNull {
|
||||
calculateMangaCardHeight(
|
||||
manga = it.manga,
|
||||
density = density,
|
||||
typography = typography,
|
||||
textMeasurer = textMeasurer,
|
||||
smallPadding = smallPadding,
|
||||
extraSmallPadding = extraSmallPadding,
|
||||
coverHeight = coverHeight,
|
||||
constraints = constraints,
|
||||
detailsConstraints = detailsConstraints,
|
||||
)
|
||||
}
|
||||
?: 0.dp
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateMangaCardHeight(
|
||||
manga: Manga,
|
||||
density: Density,
|
||||
typography: Typography,
|
||||
textMeasurer: TextMeasurer,
|
||||
smallPadding: Int,
|
||||
extraSmallPadding: Int,
|
||||
coverHeight: Float,
|
||||
constraints: Constraints,
|
||||
detailsConstraints: Constraints,
|
||||
): Dp {
|
||||
val titleHeight = textMeasurer.measureHeight(manga.title, typography.titleSmall, 2, constraints)
|
||||
val authorHeight = if (!manga.author.isNullOrBlank()) {
|
||||
textMeasurer.measureHeight(manga.author!!, typography.bodySmall, 2, detailsConstraints)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val artistHeight = if (!manga.artist.isNullOrBlank() && manga.author != manga.artist) {
|
||||
textMeasurer.measureHeight(manga.artist!!, typography.bodySmall, 2, detailsConstraints)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val statusHeight = textMeasurer.measureHeight("", typography.bodySmall, 2, detailsConstraints)
|
||||
val sourceHeight = textMeasurer.measureHeight("", typography.labelSmall, 1, constraints)
|
||||
|
||||
val totalHeight = coverHeight + titleHeight + authorHeight + artistHeight + statusHeight + sourceHeight
|
||||
return with(density) { ((2 * smallPadding) + totalHeight + (5 * extraSmallPadding)).toDp() }
|
||||
}
|
||||
|
||||
private fun TextMeasurer.measureHeight(
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
maxLines: Int,
|
||||
constraints: Constraints,
|
||||
): Int = measure(
|
||||
text = text,
|
||||
style = style,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = maxLines,
|
||||
constraints = constraints,
|
||||
)
|
||||
.size
|
||||
.height
|
||||
|
||||
private val MangaCardWidth = 150.dp
|
||||
private val MangaDetailsIconWidth = 16.dp
|
||||
|
@@ -0,0 +1,45 @@
|
||||
package eu.kanade.presentation.manga
|
||||
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.components.AppBarTitle
|
||||
import eu.kanade.presentation.manga.components.MangaNotesTextArea
|
||||
import eu.kanade.tachiyomi.ui.manga.notes.MangaNotesScreen
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
|
||||
@Composable
|
||||
fun MangaNotesScreen(
|
||||
state: MangaNotesScreen.State,
|
||||
navigateUp: () -> Unit,
|
||||
onUpdate: (String) -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { topBarScrollBehavior ->
|
||||
AppBar(
|
||||
titleContent = {
|
||||
AppBarTitle(
|
||||
title = stringResource(MR.strings.action_edit_notes),
|
||||
subtitle = state.manga.title,
|
||||
)
|
||||
},
|
||||
navigateUp = navigateUp,
|
||||
scrollBehavior = topBarScrollBehavior,
|
||||
)
|
||||
},
|
||||
) { contentPadding ->
|
||||
MangaNotesTextArea(
|
||||
state = state,
|
||||
onUpdate = onUpdate,
|
||||
modifier = Modifier
|
||||
.padding(contentPadding)
|
||||
.consumeWindowInsets(contentPadding)
|
||||
.imePadding(),
|
||||
)
|
||||
}
|
||||
}
|
@@ -112,6 +112,7 @@ fun MangaScreen(
|
||||
onEditCategoryClicked: (() -> Unit)?,
|
||||
onEditFetchIntervalClicked: (() -> Unit)?,
|
||||
onMigrateClicked: (() -> Unit)?,
|
||||
onEditNotesClicked: () -> Unit,
|
||||
|
||||
// For bottom action menu
|
||||
onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
|
||||
@@ -160,6 +161,7 @@ fun MangaScreen(
|
||||
onEditCategoryClicked = onEditCategoryClicked,
|
||||
onEditIntervalClicked = onEditFetchIntervalClicked,
|
||||
onMigrateClicked = onMigrateClicked,
|
||||
onEditNotesClicked = onEditNotesClicked,
|
||||
onMultiBookmarkClicked = onMultiBookmarkClicked,
|
||||
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
|
||||
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
|
||||
@@ -195,6 +197,7 @@ fun MangaScreen(
|
||||
onEditCategoryClicked = onEditCategoryClicked,
|
||||
onEditIntervalClicked = onEditFetchIntervalClicked,
|
||||
onMigrateClicked = onMigrateClicked,
|
||||
onEditNotesClicked = onEditNotesClicked,
|
||||
onMultiBookmarkClicked = onMultiBookmarkClicked,
|
||||
onMultiMarkAsReadClicked = onMultiMarkAsReadClicked,
|
||||
onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked,
|
||||
@@ -240,6 +243,7 @@ private fun MangaScreenSmallImpl(
|
||||
onEditCategoryClicked: (() -> Unit)?,
|
||||
onEditIntervalClicked: (() -> Unit)?,
|
||||
onMigrateClicked: (() -> Unit)?,
|
||||
onEditNotesClicked: () -> Unit,
|
||||
|
||||
// For bottom action menu
|
||||
onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
|
||||
@@ -265,13 +269,9 @@ private fun MangaScreenSmallImpl(
|
||||
)
|
||||
}
|
||||
|
||||
BackHandler(onBack = {
|
||||
if (isAnySelected) {
|
||||
BackHandler(enabled = isAnySelected) {
|
||||
onAllChapterSelected(false)
|
||||
} else {
|
||||
navigateUp()
|
||||
}
|
||||
})
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -302,6 +302,7 @@ private fun MangaScreenSmallImpl(
|
||||
onClickEditCategory = onEditCategoryClicked,
|
||||
onClickRefresh = onRefresh,
|
||||
onClickMigrate = onMigrateClicked,
|
||||
onClickEditNotes = onEditNotesClicked,
|
||||
actionModeCounter = selectedChapterCount,
|
||||
onCancelActionMode = { onAllChapterSelected(false) },
|
||||
onSelectAll = { onAllChapterSelected(true) },
|
||||
@@ -414,8 +415,10 @@ private fun MangaScreenSmallImpl(
|
||||
defaultExpandState = state.isFromSource,
|
||||
description = state.manga.description,
|
||||
tagsProvider = { state.manga.genre },
|
||||
notes = state.manga.notes,
|
||||
onTagSearch = onTagSearch,
|
||||
onCopyTagToClipboard = onCopyTagToClipboard,
|
||||
onEditNotes = onEditNotesClicked,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -484,6 +487,7 @@ fun MangaScreenLargeImpl(
|
||||
onEditCategoryClicked: (() -> Unit)?,
|
||||
onEditIntervalClicked: (() -> Unit)?,
|
||||
onMigrateClicked: (() -> Unit)?,
|
||||
onEditNotesClicked: () -> Unit,
|
||||
|
||||
// For bottom action menu
|
||||
onMultiBookmarkClicked: (List<Chapter>, bookmarked: Boolean) -> Unit,
|
||||
@@ -515,13 +519,9 @@ fun MangaScreenLargeImpl(
|
||||
|
||||
val chapterListState = rememberLazyListState()
|
||||
|
||||
BackHandler(onBack = {
|
||||
if (isAnySelected) {
|
||||
BackHandler(enabled = isAnySelected) {
|
||||
onAllChapterSelected(false)
|
||||
} else {
|
||||
navigateUp()
|
||||
}
|
||||
})
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -539,6 +539,7 @@ fun MangaScreenLargeImpl(
|
||||
onClickEditCategory = onEditCategoryClicked,
|
||||
onClickRefresh = onRefresh,
|
||||
onClickMigrate = onMigrateClicked,
|
||||
onClickEditNotes = onEditNotesClicked,
|
||||
onCancelActionMode = { onAllChapterSelected(false) },
|
||||
actionModeCounter = selectedChapterCount,
|
||||
onSelectAll = { onAllChapterSelected(true) },
|
||||
@@ -640,8 +641,10 @@ fun MangaScreenLargeImpl(
|
||||
defaultExpandState = true,
|
||||
description = state.manga.description,
|
||||
tagsProvider = { state.manga.genre },
|
||||
notes = state.manga.notes,
|
||||
onTagSearch = onTagSearch,
|
||||
onCopyTagToClipboard = onCopyTagToClipboard,
|
||||
onEditNotes = onEditNotesClicked,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
@@ -9,6 +9,7 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
@@ -28,7 +29,10 @@ import androidx.compose.material.icons.outlined.BookmarkRemove
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.DoneAll
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.MoreVert
|
||||
import androidx.compose.material.icons.outlined.RemoveDone
|
||||
import androidx.compose.material.icons.outlined.SwapCalls
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -48,8 +52,10 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.presentation.components.DownloadDropdownMenu
|
||||
import eu.kanade.presentation.components.DropdownMenu
|
||||
import eu.kanade.presentation.manga.DownloadAction
|
||||
import eu.kanade.tachiyomi.R
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -185,7 +191,7 @@ private fun RowScope.Button(
|
||||
targetValue = if (toConfirm) 2f else 1f,
|
||||
label = "weight",
|
||||
)
|
||||
Column(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.weight(animatedWeight)
|
||||
@@ -195,6 +201,9 @@ private fun RowScope.Button(
|
||||
onLongClick = onLongClick,
|
||||
onClick = onClick,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
@@ -214,6 +223,7 @@ private fun RowScope.Button(
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
content?.invoke()
|
||||
}
|
||||
}
|
||||
@@ -226,6 +236,7 @@ fun LibraryBottomActionMenu(
|
||||
onMarkAsUnreadClicked: () -> Unit,
|
||||
onDownloadClicked: ((DownloadAction) -> Unit)?,
|
||||
onDeleteClicked: () -> Unit,
|
||||
onMigrateClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
@@ -240,17 +251,18 @@ fun LibraryBottomActionMenu(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val confirm = remember { mutableStateListOf(false, false, false, false, false) }
|
||||
val confirm = remember { mutableStateListOf(false, false, false, false, false, false) }
|
||||
var resetJob: Job? = remember { null }
|
||||
val onLongClickItem: (Int) -> Unit = { toConfirmIndex ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
(0..<5).forEach { i -> confirm[i] = i == toConfirmIndex }
|
||||
(0..5).forEach { i -> confirm[i] = i == toConfirmIndex }
|
||||
resetJob?.cancel()
|
||||
resetJob = scope.launch {
|
||||
delay(1.seconds)
|
||||
if (isActive) confirm[toConfirmIndex] = false
|
||||
}
|
||||
}
|
||||
val itemOverflow = onDownloadClicked != null
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(
|
||||
@@ -289,22 +301,57 @@ fun LibraryBottomActionMenu(
|
||||
onLongClick = { onLongClickItem(3) },
|
||||
onClick = { downloadExpanded = !downloadExpanded },
|
||||
) {
|
||||
val onDismissRequest = { downloadExpanded = false }
|
||||
DownloadDropdownMenu(
|
||||
expanded = downloadExpanded,
|
||||
onDismissRequest = onDismissRequest,
|
||||
onDismissRequest = { downloadExpanded = false },
|
||||
onDownloadClicked = onDownloadClicked,
|
||||
offset = BottomBarMenuDpOffset,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!itemOverflow) {
|
||||
Button(
|
||||
title = stringResource(MR.strings.migrate),
|
||||
icon = Icons.Outlined.SwapCalls,
|
||||
toConfirm = confirm[4],
|
||||
onLongClick = { onLongClickItem(4) },
|
||||
onClick = onMigrateClicked,
|
||||
)
|
||||
Button(
|
||||
title = stringResource(MR.strings.action_delete),
|
||||
icon = Icons.Outlined.Delete,
|
||||
toConfirm = confirm[4],
|
||||
onLongClick = { onLongClickItem(4) },
|
||||
toConfirm = confirm[5],
|
||||
onLongClick = { onLongClickItem(5) },
|
||||
onClick = onDeleteClicked,
|
||||
)
|
||||
} else {
|
||||
var overflowMenuOpen by remember { mutableStateOf(false) }
|
||||
Button(
|
||||
title = stringResource(MR.strings.label_more),
|
||||
icon = Icons.Outlined.MoreVert,
|
||||
toConfirm = false,
|
||||
onLongClick = {},
|
||||
onClick = { overflowMenuOpen = true },
|
||||
) {
|
||||
DropdownMenu(
|
||||
expanded = overflowMenuOpen,
|
||||
onDismissRequest = { overflowMenuOpen = false },
|
||||
offset = BottomBarMenuDpOffset,
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(MR.strings.migrate)) },
|
||||
onClick = onMigrateClicked,
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(MR.strings.action_delete)) },
|
||||
onClick = onDeleteClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val BottomBarMenuDpOffset = DpOffset(0.dp, 0.dp)
|
||||
|
@@ -2,6 +2,9 @@ package eu.kanade.presentation.manga.components
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -24,18 +27,22 @@ import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.core.view.updatePadding
|
||||
import coil3.asDrawable
|
||||
import coil3.imageLoader
|
||||
@@ -48,11 +55,14 @@ import eu.kanade.presentation.components.DropdownMenu
|
||||
import eu.kanade.presentation.manga.EditCoverAction
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import soup.compose.material.motion.MotionConstants
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.util.PredictiveBack
|
||||
import tachiyomi.presentation.core.util.clickableNoIndication
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Composable
|
||||
fun MangaCoverDialog(
|
||||
@@ -151,10 +161,32 @@ fun MangaCoverDialog(
|
||||
val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() }
|
||||
val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() }
|
||||
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
PredictiveBackHandler { progress ->
|
||||
try {
|
||||
progress.collect { backEvent ->
|
||||
scale = lerp(1f, 0.8f, PredictiveBack.transform(backEvent.progress))
|
||||
}
|
||||
onDismissRequest()
|
||||
} catch (e: CancellationException) {
|
||||
animate(
|
||||
initialValue = scale,
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(durationMillis = MotionConstants.DefaultMotionDuration),
|
||||
) { value, _ ->
|
||||
scale = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickableNoIndication(onClick = onDismissRequest),
|
||||
.clickableNoIndication(onClick = onDismissRequest)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
},
|
||||
) {
|
||||
AndroidView(
|
||||
factory = {
|
||||
@@ -171,15 +203,13 @@ fun MangaCoverDialog(
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
.target { image ->
|
||||
val drawable = image.asDrawable(view.context.resources)
|
||||
|
||||
// Copy bitmap in case it came from memory cache
|
||||
// Because SSIV needs to thoroughly read the image
|
||||
val copy = (drawable as? BitmapDrawable)?.let {
|
||||
BitmapDrawable(
|
||||
view.context.resources,
|
||||
it.bitmap.copy(Bitmap.Config.HARDWARE, false),
|
||||
)
|
||||
} ?: drawable
|
||||
val copy = (drawable as? BitmapDrawable)
|
||||
?.bitmap
|
||||
?.copy(Bitmap.Config.HARDWARE, false)
|
||||
?.toDrawable(view.context.resources)
|
||||
?: drawable
|
||||
view.setImage(copy, ReaderPageImageView.Config(zoomDuration = 500))
|
||||
}
|
||||
.build()
|
||||
|
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Brush
|
||||
@@ -68,8 +69,11 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -77,10 +81,17 @@ import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import com.mikepenz.markdown.model.markdownAnnotator
|
||||
import com.mikepenz.markdown.model.markdownAnnotatorConfig
|
||||
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
||||
import eu.kanade.domain.ui.UiPreferences
|
||||
import eu.kanade.presentation.components.DropdownMenu
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
||||
import org.intellij.markdown.MarkdownElementTypes
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
import org.intellij.markdown.ast.findChildOfType
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.DISABLED_ALPHA
|
||||
@@ -90,12 +101,12 @@ import tachiyomi.presentation.core.i18n.pluralStringResource
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.util.clickableNoIndication
|
||||
import tachiyomi.presentation.core.util.secondaryItemAlpha
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val whitespaceLineRegex = Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE))
|
||||
|
||||
@Composable
|
||||
fun MangaInfoBox(
|
||||
isTabletUi: Boolean,
|
||||
@@ -236,8 +247,10 @@ fun ExpandableMangaDescription(
|
||||
defaultExpandState: Boolean,
|
||||
description: String?,
|
||||
tagsProvider: () -> List<String>?,
|
||||
notes: String,
|
||||
onTagSearch: (String) -> Unit,
|
||||
onCopyTagToClipboard: (tag: String) -> Unit,
|
||||
onEditNotes: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
@@ -246,15 +259,12 @@ fun ExpandableMangaDescription(
|
||||
}
|
||||
val desc =
|
||||
description.takeIf { !it.isNullOrBlank() } ?: stringResource(MR.strings.description_placeholder)
|
||||
val trimmedDescription = remember(desc) {
|
||||
desc
|
||||
.replace(whitespaceLineRegex, "\n")
|
||||
.trimEnd()
|
||||
}
|
||||
|
||||
MangaSummary(
|
||||
expandedDescription = desc,
|
||||
shrunkDescription = trimmedDescription,
|
||||
description = desc,
|
||||
expanded = expanded,
|
||||
notes = notes,
|
||||
onEditNotesClicked = onEditNotes,
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
@@ -555,13 +565,55 @@ private fun ColumnScope.MangaContentInfo(
|
||||
}
|
||||
}
|
||||
|
||||
private fun descriptionAnnotator(loadImages: Boolean, linkStyle: SpanStyle) = markdownAnnotator(
|
||||
annotate = { content, child ->
|
||||
if (!loadImages && child.type == MarkdownElementTypes.IMAGE) {
|
||||
val inlineLink = child.findChildOfType(MarkdownElementTypes.INLINE_LINK)
|
||||
|
||||
val url = inlineLink?.findChildOfType(MarkdownElementTypes.LINK_DESTINATION)
|
||||
?.getUnescapedTextInNode(content)
|
||||
?: inlineLink?.findChildOfType(MarkdownElementTypes.AUTOLINK)
|
||||
?.findChildOfType(MarkdownTokenTypes.AUTOLINK)
|
||||
?.getUnescapedTextInNode(content)
|
||||
?: return@markdownAnnotator false
|
||||
|
||||
val textNode = inlineLink?.findChildOfType(MarkdownElementTypes.LINK_TITLE)
|
||||
?: inlineLink?.findChildOfType(MarkdownElementTypes.LINK_TEXT)
|
||||
val altText = textNode?.findChildOfType(MarkdownTokenTypes.TEXT)
|
||||
?.getUnescapedTextInNode(content).orEmpty()
|
||||
|
||||
withLink(LinkAnnotation.Url(url = url)) {
|
||||
pushStyle(linkStyle)
|
||||
appendInlineContent(MARKDOWN_INLINE_IMAGE_TAG)
|
||||
append(altText)
|
||||
pop()
|
||||
}
|
||||
|
||||
return@markdownAnnotator true
|
||||
}
|
||||
|
||||
if (child.type in DISALLOWED_MARKDOWN_TYPES) {
|
||||
append(content.substring(child.startOffset, child.endOffset))
|
||||
return@markdownAnnotator true
|
||||
}
|
||||
|
||||
false
|
||||
},
|
||||
config = markdownAnnotatorConfig(
|
||||
eolAsNewLine = true,
|
||||
),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun MangaSummary(
|
||||
expandedDescription: String,
|
||||
shrunkDescription: String,
|
||||
description: String,
|
||||
notes: String,
|
||||
expanded: Boolean,
|
||||
onEditNotesClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val preferences = remember { Injekt.get<UiPreferences>() }
|
||||
val loadImages = remember { preferences.imagesInDescription().get() }
|
||||
val animProgress by animateFloatAsState(
|
||||
targetValue = if (expanded) 1f else 0f,
|
||||
label = "summary",
|
||||
@@ -571,27 +623,50 @@ private fun MangaSummary(
|
||||
contents = listOf(
|
||||
{
|
||||
Text(
|
||||
text = "\n\n", // Shows at least 3 lines
|
||||
// Shows at least 3 lines if no notes
|
||||
// when there are notes show 6
|
||||
text = if (notes.isBlank()) "\n\n" else "\n\n\n\n\n",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
{
|
||||
Text(
|
||||
text = expandedDescription,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
Column {
|
||||
MangaNotesSection(
|
||||
content = notes,
|
||||
expanded = true,
|
||||
onEditNotes = onEditNotesClicked,
|
||||
)
|
||||
},
|
||||
{
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = if (expanded) expandedDescription else shrunkDescription,
|
||||
maxLines = Int.MAX_VALUE,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
MarkdownRender(
|
||||
content = description,
|
||||
modifier = Modifier.secondaryItemAlpha(),
|
||||
annotator = descriptionAnnotator(
|
||||
loadImages = loadImages,
|
||||
linkStyle = getMarkdownLinkStyle().toSpanStyle(),
|
||||
),
|
||||
loadImages = loadImages,
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
Column {
|
||||
MangaNotesSection(
|
||||
content = notes,
|
||||
expanded = expanded,
|
||||
onEditNotes = onEditNotesClicked,
|
||||
)
|
||||
SelectionContainer {
|
||||
MarkdownRender(
|
||||
content = description,
|
||||
modifier = Modifier.secondaryItemAlpha(),
|
||||
annotator = descriptionAnnotator(
|
||||
loadImages = loadImages,
|
||||
linkStyle = getMarkdownLinkStyle().toSpanStyle(),
|
||||
),
|
||||
loadImages = loadImages,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
val colors = listOf(Color.Transparent, MaterialTheme.colorScheme.background)
|
||||
Box(
|
||||
|
@@ -0,0 +1,60 @@
|
||||
package eu.kanade.presentation.manga.components
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import com.mohamedrejeb.richeditor.model.rememberRichTextState
|
||||
import com.mohamedrejeb.richeditor.ui.material3.RichText
|
||||
|
||||
private val FADE_TIME = tween<Float>(500)
|
||||
|
||||
@Composable
|
||||
fun MangaNotesDisplay(
|
||||
content: String,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val alpha = remember { Animatable(1f) }
|
||||
var contentUpdatedOnce by remember { mutableStateOf(false) }
|
||||
|
||||
val richTextState = rememberRichTextState()
|
||||
val primaryColor = MaterialTheme.colorScheme.primary
|
||||
LaunchedEffect(content) {
|
||||
richTextState.setMarkdown(content)
|
||||
|
||||
if (!contentUpdatedOnce) {
|
||||
contentUpdatedOnce = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
alpha.snapTo(targetValue = 0f)
|
||||
alpha.animateTo(targetValue = 1f, animationSpec = FADE_TIME)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
richTextState.config.unorderedListIndent = 4
|
||||
richTextState.config.orderedListIndent = 20
|
||||
}
|
||||
LaunchedEffect(primaryColor) {
|
||||
richTextState.config.linkColor = primaryColor
|
||||
}
|
||||
|
||||
SelectionContainer {
|
||||
RichText(
|
||||
modifier = modifier
|
||||
// Only animate size if the notes changes
|
||||
.then(if (contentUpdatedOnce) Modifier.animateContentSize() else Modifier)
|
||||
.alpha(alpha.value),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
state = richTextState,
|
||||
)
|
||||
}
|
||||
}
|
@@ -0,0 +1,90 @@
|
||||
package eu.kanade.presentation.manga.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.EditNote
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.Button
|
||||
import tachiyomi.presentation.core.components.material.ButtonDefaults
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
|
||||
@Composable
|
||||
fun MangaNotesSection(
|
||||
content: String,
|
||||
expanded: Boolean,
|
||||
onEditNotes: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (content.isBlank()) return
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
MangaNotesDisplay(
|
||||
content = content,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
)
|
||||
if (expanded) {
|
||||
Button(
|
||||
onClick = onEditNotes,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.EditNote,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
stringResource(MR.strings.action_edit_notes),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
top = if (expanded) 0.dp else 12.dp,
|
||||
bottom = if (expanded) 16.dp else 12.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun MangaNotesSectionPreview() {
|
||||
MangaNotesSection(
|
||||
onEditNotes = {},
|
||||
expanded = true,
|
||||
content = "# Hello world\ntest1234 hi there!",
|
||||
)
|
||||
}
|
@@ -0,0 +1,224 @@
|
||||
package eu.kanade.presentation.manga.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted
|
||||
import androidx.compose.material.icons.outlined.FormatBold
|
||||
import androidx.compose.material.icons.outlined.FormatItalic
|
||||
import androidx.compose.material.icons.outlined.FormatListNumbered
|
||||
import androidx.compose.material.icons.outlined.FormatUnderlined
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mohamedrejeb.richeditor.model.rememberRichTextState
|
||||
import com.mohamedrejeb.richeditor.ui.material3.RichTextEditor
|
||||
import com.mohamedrejeb.richeditor.ui.material3.RichTextEditorDefaults.richTextEditorColors
|
||||
import eu.kanade.tachiyomi.ui.manga.notes.MangaNotesScreen
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private const val MAX_LENGTH = 250
|
||||
private const val MAX_LENGTH_WARN = MAX_LENGTH * 0.9
|
||||
|
||||
@Composable
|
||||
fun MangaNotesTextArea(
|
||||
state: MangaNotesScreen.State,
|
||||
onUpdate: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val richTextState = rememberRichTextState()
|
||||
val primaryColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
DisposableEffect(scope, richTextState) {
|
||||
snapshotFlow { richTextState.annotatedString }
|
||||
.debounce(0.25.seconds)
|
||||
.distinctUntilChanged()
|
||||
.map { richTextState.toMarkdown() }
|
||||
.onEach { onUpdate(it) }
|
||||
.launchIn(scope)
|
||||
|
||||
onDispose {
|
||||
onUpdate(richTextState.toMarkdown())
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
richTextState.setMarkdown(state.notes)
|
||||
richTextState.config.unorderedListIndent = 4
|
||||
richTextState.config.orderedListIndent = 20
|
||||
}
|
||||
LaunchedEffect(primaryColor) {
|
||||
richTextState.config.linkColor = primaryColor
|
||||
}
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(focusRequester) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
val textLength = remember(richTextState.annotatedString) { richTextState.toText().length }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(horizontal = MaterialTheme.padding.small)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
RichTextEditor(
|
||||
state = richTextState,
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
maxLength = MAX_LENGTH,
|
||||
placeholder = {
|
||||
Text(text = stringResource(MR.strings.notes_placeholder))
|
||||
},
|
||||
colors = richTextEditorColors(
|
||||
containerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
contentPadding = PaddingValues(
|
||||
horizontal = MaterialTheme.padding.medium,
|
||||
),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.padding(vertical = MaterialTheme.padding.small)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
LazyRow(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
item {
|
||||
MangaNotesTextAreaButton(
|
||||
onClick = { richTextState.toggleSpanStyle(SpanStyle(fontWeight = FontWeight.Bold)) },
|
||||
isSelected = richTextState.currentSpanStyle.fontWeight == FontWeight.Bold,
|
||||
icon = Icons.Outlined.FormatBold,
|
||||
)
|
||||
}
|
||||
item {
|
||||
MangaNotesTextAreaButton(
|
||||
onClick = { richTextState.toggleSpanStyle(SpanStyle(fontStyle = FontStyle.Italic)) },
|
||||
isSelected = richTextState.currentSpanStyle.fontStyle == FontStyle.Italic,
|
||||
icon = Icons.Outlined.FormatItalic,
|
||||
)
|
||||
}
|
||||
item {
|
||||
MangaNotesTextAreaButton(
|
||||
onClick = {
|
||||
richTextState.toggleSpanStyle(SpanStyle(textDecoration = TextDecoration.Underline))
|
||||
},
|
||||
isSelected = richTextState.currentSpanStyle.textDecoration
|
||||
?.contains(TextDecoration.Underline)
|
||||
?: false,
|
||||
icon = Icons.Outlined.FormatUnderlined,
|
||||
)
|
||||
}
|
||||
item {
|
||||
VerticalDivider(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = MaterialTheme.padding.extraSmall)
|
||||
.height(MaterialTheme.padding.large),
|
||||
)
|
||||
}
|
||||
item {
|
||||
MangaNotesTextAreaButton(
|
||||
onClick = { richTextState.toggleUnorderedList() },
|
||||
isSelected = richTextState.isUnorderedList,
|
||||
icon = Icons.AutoMirrored.Outlined.FormatListBulleted,
|
||||
)
|
||||
}
|
||||
item {
|
||||
MangaNotesTextAreaButton(
|
||||
onClick = { richTextState.toggleOrderedList() },
|
||||
isSelected = richTextState.isOrderedList,
|
||||
icon = Icons.Outlined.FormatListNumbered,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = (MAX_LENGTH - textLength).toString(),
|
||||
color = if (textLength > MAX_LENGTH_WARN) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
Color.Unspecified
|
||||
},
|
||||
modifier = Modifier.padding(MaterialTheme.padding.extraSmall),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MangaNotesTextAreaButton(
|
||||
onClick: () -> Unit,
|
||||
icon: ImageVector,
|
||||
isSelected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.clickable(
|
||||
onClick = onClick,
|
||||
enabled = true,
|
||||
role = Role.Button,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = icon.name,
|
||||
tint = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.background(color = if (isSelected) MaterialTheme.colorScheme.onBackground else Color.Transparent)
|
||||
.padding(MaterialTheme.padding.extraSmall),
|
||||
)
|
||||
}
|
||||
}
|
@@ -37,6 +37,7 @@ fun MangaToolbar(
|
||||
onClickEditCategory: (() -> Unit)?,
|
||||
onClickRefresh: () -> Unit,
|
||||
onClickMigrate: (() -> Unit)?,
|
||||
onClickEditNotes: () -> Unit,
|
||||
|
||||
// For action mode
|
||||
actionModeCounter: Int,
|
||||
@@ -140,6 +141,12 @@ fun MangaToolbar(
|
||||
),
|
||||
)
|
||||
}
|
||||
add(
|
||||
AppBar.OverflowAction(
|
||||
title = stringResource(MR.strings.action_notes),
|
||||
onClick = onClickEditNotes,
|
||||
),
|
||||
)
|
||||
}
|
||||
.build(),
|
||||
)
|
||||
|
@@ -0,0 +1,292 @@
|
||||
package eu.kanade.presentation.manga.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Image
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.FirstBaseline
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mikepenz.markdown.coil3.Coil3ImageTransformerImpl
|
||||
import com.mikepenz.markdown.compose.LocalBulletListHandler
|
||||
import com.mikepenz.markdown.compose.Markdown
|
||||
import com.mikepenz.markdown.compose.components.markdownComponents
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownBulletList
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownDivider
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownOrderedList
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownTable
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownTableHeader
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownTableRow
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownText
|
||||
import com.mikepenz.markdown.compose.elements.listDepth
|
||||
import com.mikepenz.markdown.model.DefaultMarkdownColors
|
||||
import com.mikepenz.markdown.model.DefaultMarkdownInlineContent
|
||||
import com.mikepenz.markdown.model.DefaultMarkdownTypography
|
||||
import com.mikepenz.markdown.model.MarkdownAnnotator
|
||||
import com.mikepenz.markdown.model.MarkdownColors
|
||||
import com.mikepenz.markdown.model.MarkdownPadding
|
||||
import com.mikepenz.markdown.model.MarkdownTypography
|
||||
import com.mikepenz.markdown.model.NoOpImageTransformerImpl
|
||||
import com.mikepenz.markdown.model.markdownAnnotator
|
||||
import com.mikepenz.markdown.model.rememberMarkdownState
|
||||
import org.intellij.markdown.MarkdownTokenTypes.Companion.HTML_TAG
|
||||
import org.intellij.markdown.flavours.MarkdownFlavourDescriptor
|
||||
import org.intellij.markdown.flavours.commonmark.CommonMarkFlavourDescriptor
|
||||
import org.intellij.markdown.flavours.commonmark.CommonMarkMarkerProcessor
|
||||
import org.intellij.markdown.flavours.gfm.table.GitHubTableMarkerProvider
|
||||
import org.intellij.markdown.parser.MarkerProcessor
|
||||
import org.intellij.markdown.parser.MarkerProcessorFactory
|
||||
import org.intellij.markdown.parser.ProductionHolder
|
||||
import org.intellij.markdown.parser.constraints.CommonMarkdownConstraints
|
||||
import org.intellij.markdown.parser.constraints.MarkdownConstraints
|
||||
import org.intellij.markdown.parser.markerblocks.MarkerBlockProvider
|
||||
import org.intellij.markdown.parser.markerblocks.providers.AtxHeaderProvider
|
||||
import org.intellij.markdown.parser.markerblocks.providers.BlockQuoteProvider
|
||||
import org.intellij.markdown.parser.markerblocks.providers.CodeBlockProvider
|
||||
import org.intellij.markdown.parser.markerblocks.providers.CodeFenceProvider
|
||||
import org.intellij.markdown.parser.markerblocks.providers.HorizontalRuleProvider
|
||||
import org.intellij.markdown.parser.markerblocks.providers.ListMarkerProvider
|
||||
import org.intellij.markdown.parser.markerblocks.providers.SetextHeaderProvider
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
|
||||
const val MARKDOWN_INLINE_IMAGE_TAG = "MARKDOWN_INLINE_IMAGE"
|
||||
|
||||
@Composable
|
||||
fun MarkdownRender(
|
||||
content: String,
|
||||
modifier: Modifier = Modifier,
|
||||
flavour: MarkdownFlavourDescriptor = SimpleMarkdownFlavourDescriptor,
|
||||
annotator: MarkdownAnnotator = remember { markdownAnnotator() },
|
||||
loadImages: Boolean = true,
|
||||
) {
|
||||
Markdown(
|
||||
markdownState = rememberMarkdownState(
|
||||
content = content,
|
||||
flavour = flavour,
|
||||
immediate = true,
|
||||
),
|
||||
annotator = annotator,
|
||||
colors = getMarkdownColors(),
|
||||
typography = getMarkdownTypography(),
|
||||
padding = markdownPadding,
|
||||
components = markdownComponents,
|
||||
imageTransformer = remember(loadImages) {
|
||||
if (loadImages) Coil3ImageTransformerImpl else NoOpImageTransformerImpl()
|
||||
},
|
||||
inlineContent = getMarkdownInlineContent(),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
private fun getMarkdownColors(): MarkdownColors {
|
||||
val codeBackground = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
|
||||
return DefaultMarkdownColors(
|
||||
text = MaterialTheme.colorScheme.onSurface,
|
||||
codeText = Color.Unspecified,
|
||||
inlineCodeText = Color.Unspecified,
|
||||
linkText = Color.Unspecified,
|
||||
codeBackground = codeBackground,
|
||||
inlineCodeBackground = codeBackground,
|
||||
dividerColor = MaterialTheme.colorScheme.outlineVariant,
|
||||
tableText = Color.Unspecified,
|
||||
tableBackground = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
fun getMarkdownLinkStyle() = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
private fun getMarkdownTypography(): MarkdownTypography {
|
||||
val link = getMarkdownLinkStyle()
|
||||
return DefaultMarkdownTypography(
|
||||
h1 = MaterialTheme.typography.headlineMedium,
|
||||
h2 = MaterialTheme.typography.headlineSmall,
|
||||
h3 = MaterialTheme.typography.titleLarge,
|
||||
h4 = MaterialTheme.typography.titleMedium,
|
||||
h5 = MaterialTheme.typography.titleSmall,
|
||||
h6 = MaterialTheme.typography.bodyLarge,
|
||||
text = MaterialTheme.typography.bodyMedium,
|
||||
code = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
inlineCode = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
|
||||
quote = MaterialTheme.typography.bodyMedium.plus(SpanStyle(fontStyle = FontStyle.Italic)),
|
||||
paragraph = MaterialTheme.typography.bodyMedium,
|
||||
ordered = MaterialTheme.typography.bodyMedium,
|
||||
bullet = MaterialTheme.typography.bodyMedium,
|
||||
list = MaterialTheme.typography.bodyMedium,
|
||||
link = link,
|
||||
textLink = TextLinkStyles(style = link.toSpanStyle()),
|
||||
table = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
private val markdownPadding = object : MarkdownPadding {
|
||||
override val block: Dp = 2.dp
|
||||
override val blockQuote: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 0.dp)
|
||||
override val blockQuoteBar: PaddingValues.Absolute = PaddingValues.Absolute(
|
||||
left = 4.dp,
|
||||
top = 2.dp,
|
||||
right = 4.dp,
|
||||
bottom = 2.dp,
|
||||
)
|
||||
override val blockQuoteText: PaddingValues = PaddingValues(vertical = 4.dp)
|
||||
override val codeBlock: PaddingValues = PaddingValues(8.dp)
|
||||
override val list: Dp = 0.dp
|
||||
override val listIndent: Dp = 8.dp
|
||||
override val listItemBottom: Dp = 0.dp
|
||||
override val listItemTop: Dp = 0.dp
|
||||
}
|
||||
|
||||
private val markdownComponents = markdownComponents(
|
||||
horizontalRule = {
|
||||
MarkdownDivider(
|
||||
modifier = Modifier
|
||||
.padding(vertical = MaterialTheme.padding.extraSmall)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
orderedList = { ol ->
|
||||
Column(modifier = Modifier.padding(start = MaterialTheme.padding.small)) {
|
||||
MarkdownOrderedList(
|
||||
content = ol.content,
|
||||
node = ol.node,
|
||||
style = ol.typography.ordered,
|
||||
depth = ol.listDepth,
|
||||
markerModifier = { Modifier.alignBy(FirstBaseline) },
|
||||
listModifier = { Modifier.alignBy(FirstBaseline) },
|
||||
)
|
||||
}
|
||||
},
|
||||
unorderedList = { ul ->
|
||||
val markers = listOf("•", "◦", "▸", "▹")
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalBulletListHandler provides { _, _, _, _, _ -> "${markers[ul.listDepth % markers.size]} " },
|
||||
) {
|
||||
Column(modifier = Modifier.padding(start = MaterialTheme.padding.small)) {
|
||||
MarkdownBulletList(
|
||||
content = ul.content,
|
||||
node = ul.node,
|
||||
style = ul.typography.bullet,
|
||||
markerModifier = { Modifier.alignBy(FirstBaseline) },
|
||||
listModifier = { Modifier.alignBy(FirstBaseline) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
table = { t ->
|
||||
MarkdownTable(
|
||||
content = t.content,
|
||||
node = t.node,
|
||||
style = t.typography.text,
|
||||
headerBlock = { content, header, tableWidth, style ->
|
||||
MarkdownTableHeader(
|
||||
content = content,
|
||||
header = header,
|
||||
tableWidth = tableWidth,
|
||||
style = style,
|
||||
maxLines = Int.MAX_VALUE,
|
||||
)
|
||||
},
|
||||
rowBlock = { content, header, tableWidth, style ->
|
||||
MarkdownTableRow(
|
||||
content = content,
|
||||
header = header,
|
||||
tableWidth = tableWidth,
|
||||
style = style,
|
||||
maxLines = Int.MAX_VALUE,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
custom = { type, model ->
|
||||
if (type in DISALLOWED_MARKDOWN_TYPES) {
|
||||
MarkdownText(
|
||||
content = model.content.substring(model.node.startOffset, model.node.endOffset),
|
||||
style = model.typography.text,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
private fun getMarkdownInlineContent() = DefaultMarkdownInlineContent(
|
||||
inlineContent = mapOf(
|
||||
MARKDOWN_INLINE_IMAGE_TAG to InlineTextContent(
|
||||
placeholder = Placeholder(
|
||||
width = MaterialTheme.typography.bodyMedium.fontSize * 1.25,
|
||||
height = MaterialTheme.typography.bodyMedium.fontSize * 1.25,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter,
|
||||
),
|
||||
children = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Image,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private object SimpleMarkdownFlavourDescriptor : CommonMarkFlavourDescriptor() {
|
||||
override val markerProcessorFactory: MarkerProcessorFactory = SimpleMarkdownProcessFactory
|
||||
}
|
||||
|
||||
private object SimpleMarkdownProcessFactory : MarkerProcessorFactory {
|
||||
override fun createMarkerProcessor(productionHolder: ProductionHolder): MarkerProcessor<*> {
|
||||
return SimpleMarkdownMarkerProcessor(productionHolder, CommonMarkdownConstraints.BASE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `CommonMarkFlavour`, but with html blocks and reference links removed and
|
||||
* table support added
|
||||
*/
|
||||
private class SimpleMarkdownMarkerProcessor(
|
||||
productionHolder: ProductionHolder,
|
||||
constraints: MarkdownConstraints,
|
||||
) : CommonMarkMarkerProcessor(productionHolder, constraints) {
|
||||
private val markerBlockProviders = listOf(
|
||||
CodeBlockProvider(),
|
||||
HorizontalRuleProvider(),
|
||||
CodeFenceProvider(),
|
||||
SetextHeaderProvider(),
|
||||
BlockQuoteProvider(),
|
||||
ListMarkerProvider(),
|
||||
AtxHeaderProvider(),
|
||||
GitHubTableMarkerProvider(),
|
||||
)
|
||||
|
||||
override fun getMarkerBlockProviders(): List<MarkerBlockProvider<StateInfo>> {
|
||||
return markerBlockProviders
|
||||
}
|
||||
}
|
||||
|
||||
val DISALLOWED_MARKDOWN_TYPES = arrayOf(HTML_TAG)
|
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
|
||||
import androidx.compose.material.icons.automirrored.outlined.Label
|
||||
import androidx.compose.material.icons.outlined.AttachMoney
|
||||
import androidx.compose.material.icons.outlined.CloudOff
|
||||
import androidx.compose.material.icons.outlined.GetApp
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
@@ -145,6 +146,13 @@ fun MoreScreen(
|
||||
onPreferenceClick = { uriHandler.openUri(Constants.URL_HELP) },
|
||||
)
|
||||
}
|
||||
item {
|
||||
TextPreferenceWidget(
|
||||
title = stringResource(MR.strings.label_donate),
|
||||
icon = Icons.Outlined.AttachMoney,
|
||||
onPreferenceClick = { uriHandler.openUri(Constants.URL_DONATE) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
package eu.kanade.presentation.more
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -13,13 +14,10 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import com.halilibo.richtext.markdown.Markdown
|
||||
import com.halilibo.richtext.ui.RichTextStyle
|
||||
import com.halilibo.richtext.ui.material3.RichText
|
||||
import com.halilibo.richtext.ui.string.RichTextStringStyle
|
||||
import eu.kanade.presentation.manga.components.MarkdownRender
|
||||
import eu.kanade.presentation.theme.TachiyomiPreviewTheme
|
||||
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
@@ -42,17 +40,15 @@ fun NewUpdateScreen(
|
||||
rejectText = stringResource(MR.strings.action_not_now),
|
||||
onRejectClick = onRejectUpdate,
|
||||
) {
|
||||
RichText(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = MaterialTheme.padding.large),
|
||||
style = RichTextStyle(
|
||||
stringStyle = RichTextStringStyle(
|
||||
linkStyle = SpanStyle(color = MaterialTheme.colorScheme.primary),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Markdown(content = changelogInfo)
|
||||
MarkdownRender(
|
||||
content = changelogInfo,
|
||||
flavour = GFMFlavourDescriptor(),
|
||||
)
|
||||
|
||||
TextButton(
|
||||
onClick = onOpenInBrowser,
|
||||
|
@@ -42,7 +42,9 @@ fun OnboardingScreen(
|
||||
}
|
||||
val isLastStep = currentStep == steps.lastIndex
|
||||
|
||||
BackHandler(enabled = currentStep != 0, onBack = { currentStep-- })
|
||||
BackHandler(enabled = currentStep != 0) {
|
||||
currentStep--
|
||||
}
|
||||
|
||||
InfoScreen(
|
||||
icon = Icons.Outlined.RocketLaunch,
|
||||
|
@@ -63,6 +63,7 @@ import tachiyomi.core.common.util.lang.launchNonCancellable
|
||||
import tachiyomi.core.common.util.lang.withUIContext
|
||||
import tachiyomi.core.common.util.system.ImageUtil
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
import tachiyomi.domain.manga.interactor.ResetViewerFlags
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
@@ -85,6 +86,7 @@ object SettingsAdvancedScreen : SearchableSettings {
|
||||
|
||||
val basePreferences = remember { Injekt.get<BasePreferences>() }
|
||||
val networkPreferences = remember { Injekt.get<NetworkPreferences>() }
|
||||
val libraryPreferences = remember { Injekt.get<LibraryPreferences>() }
|
||||
|
||||
return listOf(
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
@@ -125,7 +127,7 @@ object SettingsAdvancedScreen : SearchableSettings {
|
||||
getBackgroundActivityGroup(),
|
||||
getDataGroup(),
|
||||
getNetworkGroup(networkPreferences = networkPreferences),
|
||||
getLibraryGroup(),
|
||||
getLibraryGroup(libraryPreferences = libraryPreferences),
|
||||
getReaderGroup(basePreferences = basePreferences),
|
||||
getExtensionsGroup(basePreferences = basePreferences),
|
||||
)
|
||||
@@ -286,7 +288,9 @@ object SettingsAdvancedScreen : SearchableSettings {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getLibraryGroup(): Preference.PreferenceGroup {
|
||||
private fun getLibraryGroup(
|
||||
libraryPreferences: LibraryPreferences,
|
||||
): Preference.PreferenceGroup {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -314,6 +318,11 @@ object SettingsAdvancedScreen : SearchableSettings {
|
||||
}
|
||||
},
|
||||
),
|
||||
Preference.PreferenceItem.SwitchPreference(
|
||||
preference = libraryPreferences.updateMangaTitles(),
|
||||
title = stringResource(MR.strings.pref_update_library_manga_titles),
|
||||
subtitle = stringResource(MR.strings.pref_update_library_manga_titles_summary),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
@@ -145,6 +145,10 @@ object SettingsAppearanceScreen : SearchableSettings {
|
||||
formattedNow,
|
||||
),
|
||||
),
|
||||
Preference.PreferenceItem.SwitchPreference(
|
||||
preference = uiPreferences.imagesInDescription(),
|
||||
title = stringResource(MR.strings.pref_display_images_description),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
@@ -256,6 +256,10 @@ object SettingsLibraryScreen : SearchableSettings {
|
||||
),
|
||||
title = stringResource(MR.strings.pref_mark_duplicate_read_chapter_read),
|
||||
),
|
||||
Preference.PreferenceItem.SwitchPreference(
|
||||
preference = libraryPreferences.hideMissingChapters(),
|
||||
title = stringResource(MR.strings.pref_hide_missing_chapter_indicators),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
@@ -30,8 +30,11 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.autofill.ContentType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.semantics.contentType
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
@@ -220,7 +223,9 @@ object SettingsTrackingScreen : SearchableSettings {
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.semantics { contentType = ContentType.Username + ContentType.EmailAddress },
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text(text = stringResource(uNameStringRes)) },
|
||||
@@ -231,7 +236,9 @@ object SettingsTrackingScreen : SearchableSettings {
|
||||
|
||||
var hidePassword by remember { mutableStateOf(true) }
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.semantics { contentType = ContentType.Password },
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(text = stringResource(MR.strings.password)) },
|
||||
@@ -280,7 +287,7 @@ object SettingsTrackingScreen : SearchableSettings {
|
||||
}
|
||||
},
|
||||
) {
|
||||
val id = if (processing) MR.strings.loading else MR.strings.login
|
||||
val id = if (processing) MR.strings.logging_in else MR.strings.login
|
||||
Text(text = stringResource(id))
|
||||
}
|
||||
},
|
||||
|
@@ -6,7 +6,7 @@ import androidx.compose.ui.Modifier
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||
import com.mikepenz.aboutlibraries.ui.compose.m3.util.htmlReadyLicenseContent
|
||||
import com.mikepenz.aboutlibraries.ui.compose.util.htmlReadyLicenseContent
|
||||
import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.util.Screen
|
||||
import tachiyomi.i18n.MR
|
||||
|
@@ -1,8 +1,10 @@
|
||||
package eu.kanade.presentation.more.settings.screen.advanced
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -12,13 +14,17 @@ import androidx.compose.material.icons.outlined.SelectAll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -39,6 +45,7 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.core.common.util.lang.launchUI
|
||||
import tachiyomi.core.common.util.lang.toLong
|
||||
import tachiyomi.core.common.util.lang.withNonCancellableContext
|
||||
import tachiyomi.data.Database
|
||||
import tachiyomi.domain.source.interactor.GetSourcesWithNonLibraryManga
|
||||
@@ -47,6 +54,7 @@ import tachiyomi.domain.source.model.SourceWithCount
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.LazyColumnWithAction
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.screens.EmptyScreen
|
||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||
@@ -68,13 +76,45 @@ class ClearDatabaseScreen : Screen() {
|
||||
is ClearDatabaseScreenModel.State.Loading -> LoadingScreen()
|
||||
is ClearDatabaseScreenModel.State.Ready -> {
|
||||
if (s.showConfirmation) {
|
||||
var keepReadManga by remember { mutableStateOf(true) }
|
||||
AlertDialog(
|
||||
title = {
|
||||
Text(text = stringResource(MR.strings.are_you_sure))
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
|
||||
) {
|
||||
Text(text = stringResource(MR.strings.clear_database_text))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(MR.strings.clear_db_exclude_read),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(
|
||||
checked = keepReadManga,
|
||||
onCheckedChange = { keepReadManga = it },
|
||||
)
|
||||
}
|
||||
if (!keepReadManga) {
|
||||
Text(
|
||||
text = stringResource(MR.strings.clear_database_history_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismissRequest = model::hideConfirmation,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launchUI {
|
||||
model.removeMangaBySourceId()
|
||||
model.removeMangaBySourceId(keepReadManga)
|
||||
model.clearSelection()
|
||||
model.hideConfirmation()
|
||||
context.toast(MR.strings.clear_database_completed)
|
||||
@@ -89,9 +129,6 @@ class ClearDatabaseScreen : Screen() {
|
||||
Text(text = stringResource(MR.strings.action_cancel))
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Text(text = stringResource(MR.strings.clear_database_confirmation))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -203,9 +240,9 @@ private class ClearDatabaseScreenModel : StateScreenModel<ClearDatabaseScreenMod
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeMangaBySourceId() = withNonCancellableContext {
|
||||
suspend fun removeMangaBySourceId(keepReadManga: Boolean) = withNonCancellableContext {
|
||||
val state = state.value as? State.Ready ?: return@withNonCancellableContext
|
||||
database.mangasQueries.deleteMangasNotInLibraryBySourceIds(state.selection)
|
||||
database.mangasQueries.deleteNonLibraryManga(state.selection, keepReadManga.toLong())
|
||||
database.historyQueries.removeResettedHistory()
|
||||
}
|
||||
|
||||
|
@@ -25,7 +25,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -86,7 +85,8 @@ internal fun BasePreferenceWidget(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Modifier.highlightBackground(highlighted: Boolean): Modifier = composed {
|
||||
@Composable
|
||||
internal fun Modifier.highlightBackground(highlighted: Boolean): Modifier {
|
||||
var highlightFlag by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
if (highlighted) {
|
||||
@@ -116,7 +116,7 @@ internal fun Modifier.highlightBackground(highlighted: Boolean): Modifier = comp
|
||||
},
|
||||
label = "highlight",
|
||||
)
|
||||
Modifier.background(color = highlight)
|
||||
return this.background(color = highlight)
|
||||
}
|
||||
|
||||
internal val TrailingWidgetBuffer = 16.dp
|
||||
|
@@ -9,6 +9,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import eu.kanade.domain.ui.UiPreferences
|
||||
import eu.kanade.domain.ui.model.AppTheme
|
||||
import eu.kanade.presentation.theme.colorscheme.BaseColorScheme
|
||||
import eu.kanade.presentation.theme.colorscheme.CatppuccinColorScheme
|
||||
import eu.kanade.presentation.theme.colorscheme.GreenAppleColorScheme
|
||||
import eu.kanade.presentation.theme.colorscheme.LavenderColorScheme
|
||||
import eu.kanade.presentation.theme.colorscheme.MidnightDuskColorScheme
|
||||
@@ -77,6 +78,7 @@ private fun getThemeColorScheme(
|
||||
|
||||
private val colorSchemes: Map<AppTheme, BaseColorScheme> = mapOf(
|
||||
AppTheme.DEFAULT to TachiyomiColorScheme,
|
||||
AppTheme.CATPPUCCIN to CatppuccinColorScheme,
|
||||
AppTheme.GREEN_APPLE to GreenAppleColorScheme,
|
||||
AppTheme.LAVENDER to LavenderColorScheme,
|
||||
AppTheme.MIDNIGHT_DUSK to MidnightDuskColorScheme,
|
||||
|
@@ -0,0 +1,103 @@
|
||||
package eu.kanade.presentation.theme.colorscheme
|
||||
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Colors for Catppuccin theme
|
||||
* MIT License
|
||||
* Copyright (c) 2021 Catppuccin
|
||||
* https://catppuccin.com
|
||||
* M3 colors generated by Material Theme Builder (https://goo.gle/material-theme-builder-web)
|
||||
*
|
||||
* Key colors (dark):
|
||||
* Primary #CBA6F4
|
||||
* Secondary #CBA6F4
|
||||
* Tertiary #CBA6F4
|
||||
* Neutral #181825
|
||||
|
||||
* Key colors (light):
|
||||
* Primary #8839EF
|
||||
* Secondary #8839EF
|
||||
* Tertiary #8839EF
|
||||
* Neutral #E6E9EF
|
||||
*/
|
||||
internal object CatppuccinColorScheme : BaseColorScheme() {
|
||||
|
||||
override val darkScheme = darkColorScheme(
|
||||
primary = Color(0xFFCBA6F7),
|
||||
onPrimary = Color(0xFF11111B),
|
||||
primaryContainer = Color(0xFFCBA6F7),
|
||||
onPrimaryContainer = Color(0xFF11111B),
|
||||
secondary = Color(0xFFCBA6F7), // Unread badge
|
||||
onSecondary = Color(0xFF11111B), // Unread badge text
|
||||
secondaryContainer = Color(0xFF313244), // Navigation bar selector pill & progress indicator (remaining)
|
||||
onSecondaryContainer = Color(0xFFCBA6F7), // Navigation bar selector icon
|
||||
tertiary = Color(0xFFCBA6F7), // Volume and brightness bars, Downloaded badge
|
||||
onTertiary = Color(0xFF11111B), // Downloaded badge text
|
||||
tertiaryContainer = Color(0xFF1E1E2E),
|
||||
onTertiaryContainer = Color(0xFFCDD6F4),
|
||||
error = Color(0xFFF38BA8),
|
||||
onError = Color(0xFF11111B),
|
||||
errorContainer = Color(0xFFFF0558),
|
||||
onErrorContainer = Color(0xFFEF9FB4),
|
||||
background = Color(0xFF181825),
|
||||
onBackground = Color(0xFFCDD6F4),
|
||||
surface = Color(0xFF181825),
|
||||
onSurface = Color(0xFFCDD6F4),
|
||||
surfaceVariant = Color(0xFF1E1E2E), // Navigation bar background (ThemePrefWidget)
|
||||
onSurfaceVariant = Color(0xFFCDD6F4), // Button (unselected)
|
||||
outline = Color(0xFFCBA6F7),
|
||||
outlineVariant = Color(0xFF585B70), // Outlines for buttons
|
||||
scrim = Color(0xFF11111B),
|
||||
inverseSurface = Color(0xFFEFF1F5), // Snackbar or whatever they called
|
||||
inverseOnSurface = Color(0xFF4C4F69), // Snackbar text
|
||||
inversePrimary = Color(0xFF8839EF), // Snackbar accent
|
||||
surfaceDim = Color(0xFF181825),
|
||||
surfaceBright = Color(0xFF313244),
|
||||
surfaceContainerLowest = Color(0xFF181825),
|
||||
surfaceContainerLow = Color(0xFF1E1E2E), // Repo cards
|
||||
surfaceContainer = Color(0xFF1E1E2E),
|
||||
surfaceContainerHigh = Color(0xFF1E1E2E), // Filter menu
|
||||
surfaceContainerHighest = Color(0xFF313244), // Untoggleg button bg
|
||||
)
|
||||
|
||||
override val lightScheme = lightColorScheme(
|
||||
primary = Color(0xFF8839EF),
|
||||
onPrimary = Color(0xFFDCE0E8),
|
||||
primaryContainer = Color(0xFF8839EF),
|
||||
onPrimaryContainer = Color(0xFFDCE0E8),
|
||||
secondary = Color(0xFF8839EF), // Unread badge
|
||||
onSecondary = Color(0xFFDCE0E8), // Unread badge text
|
||||
secondaryContainer = Color(0xFFCDD0DA), // Navigation bar selector pill & progress indicator (remaining)
|
||||
onSecondaryContainer = Color(0xFF8839EF), // Navigation bar selector icon
|
||||
tertiary = Color(0xFF8839EF), // Volume and brightness bars, Downloaded badge
|
||||
onTertiary = Color(0xFFDCE0E8), // Downloaded badge text
|
||||
tertiaryContainer = Color(0xFFEFF1F5),
|
||||
onTertiaryContainer = Color(0xFF4C4F69),
|
||||
error = Color(0xFFD20F39),
|
||||
onError = Color(0xFFDCE0E8),
|
||||
errorContainer = Color(0xFF68001C),
|
||||
onErrorContainer = Color(0xFFD61C41),
|
||||
background = Color(0xFFE6E9EF),
|
||||
onBackground = Color(0xFF4C4F69),
|
||||
surface = Color(0xFFE6E9EF),
|
||||
onSurface = Color(0xFF4C4F69),
|
||||
surfaceVariant = Color(0xFFEFF1F5), // Navigation bar background (ThemePrefWidget)
|
||||
onSurfaceVariant = Color(0xFF4C4F69), // Button (unselected)
|
||||
outline = Color(0xFF8839EF),
|
||||
outlineVariant = Color(0xFFACB0BE), // Outlines for buttons
|
||||
scrim = Color(0xFFDCE0E8),
|
||||
inverseSurface = Color(0xFF1E1E2E), // Snackbar
|
||||
inverseOnSurface = Color(0xFFCDD6F4), // Snackbar text
|
||||
inversePrimary = Color(0xFFCBA6F7), // Snackbar accent
|
||||
surfaceDim = Color(0xFFE6E9EF),
|
||||
surfaceBright = Color(0xFFCDD0DA),
|
||||
surfaceContainerLowest = Color(0xFFE6E9EF),
|
||||
surfaceContainerLow = Color(0xFFEFF1F5), // Repo cards
|
||||
surfaceContainer = Color(0xFFEFF1F5), // Navigation bar background
|
||||
surfaceContainerHigh = Color(0xFFEFF1F5), // Filter menu
|
||||
surfaceContainerHighest = Color(0xFFCDD0DA), // Untoggleg bg
|
||||
)
|
||||
}
|
@@ -57,7 +57,9 @@ fun UpdateScreen(
|
||||
onUpdateSelected: (UpdatesItem, Boolean, Boolean, Boolean) -> Unit,
|
||||
onOpenChapter: (UpdatesItem) -> Unit,
|
||||
) {
|
||||
BackHandler(enabled = state.selectionMode, onBack = { onSelectAll(false) })
|
||||
BackHandler(enabled = state.selectionMode) {
|
||||
onSelectAll(false)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { scrollBehavior ->
|
||||
|
@@ -1,12 +1,46 @@
|
||||
package eu.kanade.presentation.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.activity.BackEventCompat
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.SeekableTransitionState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.rememberTransition
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import cafe.adriel.voyager.core.annotation.InternalVoyagerApi
|
||||
import cafe.adriel.voyager.core.model.ScreenModel
|
||||
import cafe.adriel.voyager.core.model.ScreenModelStore
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
@@ -15,18 +49,28 @@ import cafe.adriel.voyager.core.screen.uniqueScreenKey
|
||||
import cafe.adriel.voyager.core.stack.StackEvent
|
||||
import cafe.adriel.voyager.navigator.Navigator
|
||||
import cafe.adriel.voyager.transitions.ScreenTransitionContent
|
||||
import eu.kanade.tachiyomi.util.view.getWindowRadius
|
||||
import kotlinx.coroutines.CoroutineName
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.dropWhile
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import soup.compose.material.motion.animation.materialSharedAxisX
|
||||
import soup.compose.material.motion.animation.materialSharedAxisXIn
|
||||
import soup.compose.material.motion.animation.materialSharedAxisXOut
|
||||
import soup.compose.material.motion.animation.rememberSlideDistance
|
||||
import tachiyomi.presentation.core.util.PredictiveBack
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
/**
|
||||
* For invoking back press to the parent activity
|
||||
*/
|
||||
@SuppressLint("ComposeCompositionLocalUsage")
|
||||
val LocalBackPress: ProvidableCompositionLocal<(() -> Unit)?> = staticCompositionLocalOf { null }
|
||||
|
||||
interface Tab : cafe.adriel.voyager.navigator.tab.Tab {
|
||||
@@ -54,39 +98,278 @@ interface AssistContentScreen {
|
||||
fun onProvideAssistUrl(): String?
|
||||
}
|
||||
|
||||
@OptIn(InternalVoyagerApi::class)
|
||||
@Composable
|
||||
fun DefaultNavigatorScreenTransition(
|
||||
navigator: Navigator,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val slideDistance = rememberSlideDistance()
|
||||
val screenCandidatesToDispose = rememberSaveable(saver = screenCandidatesToDisposeSaver()) {
|
||||
mutableStateOf(emptySet())
|
||||
}
|
||||
val currentScreens = navigator.items
|
||||
DisposableEffect(currentScreens) {
|
||||
onDispose {
|
||||
val newScreenKeys = navigator.items.map { it.key }
|
||||
screenCandidatesToDispose.value += currentScreens.filter { it.key !in newScreenKeys }
|
||||
}
|
||||
}
|
||||
|
||||
val slideDistance = rememberSlideDistance(slideDistance = 30.dp)
|
||||
ScreenTransition(
|
||||
navigator = navigator,
|
||||
transition = {
|
||||
materialSharedAxisX(
|
||||
forward = navigator.lastEvent != StackEvent.Pop,
|
||||
slideDistance = slideDistance,
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
enterTransition = {
|
||||
if (it == SwipeEdge.Right) {
|
||||
materialSharedAxisXIn(forward = false, slideDistance = slideDistance)
|
||||
} else {
|
||||
materialSharedAxisXIn(forward = true, slideDistance = slideDistance)
|
||||
}
|
||||
},
|
||||
exitTransition = {
|
||||
if (it == SwipeEdge.Right) {
|
||||
materialSharedAxisXOut(forward = false, slideDistance = slideDistance)
|
||||
} else {
|
||||
materialSharedAxisXOut(forward = true, slideDistance = slideDistance)
|
||||
}
|
||||
},
|
||||
popEnterTransition = {
|
||||
if (it == SwipeEdge.Right) {
|
||||
materialSharedAxisXIn(forward = true, slideDistance = slideDistance)
|
||||
} else {
|
||||
materialSharedAxisXIn(forward = false, slideDistance = slideDistance)
|
||||
}
|
||||
},
|
||||
popExitTransition = {
|
||||
if (it == SwipeEdge.Right) {
|
||||
materialSharedAxisXOut(forward = true, slideDistance = slideDistance)
|
||||
} else {
|
||||
materialSharedAxisXOut(forward = false, slideDistance = slideDistance)
|
||||
}
|
||||
},
|
||||
content = { screen ->
|
||||
if (this.transition.targetState == this.transition.currentState) {
|
||||
LaunchedEffect(Unit) {
|
||||
val newScreens = navigator.items.map { it.key }
|
||||
val screensToDispose = screenCandidatesToDispose.value.filterNot { it.key in newScreens }
|
||||
if (screensToDispose.isNotEmpty()) {
|
||||
screensToDispose.forEach { navigator.dispose(it) }
|
||||
navigator.clearEvent()
|
||||
}
|
||||
screenCandidatesToDispose.value = emptySet()
|
||||
}
|
||||
}
|
||||
screen.Content()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
enum class SwipeEdge {
|
||||
Unknown,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
private enum class AnimationType {
|
||||
Pop,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScreenTransition(
|
||||
navigator: Navigator,
|
||||
transition: AnimatedContentTransitionScope<Screen>.() -> ContentTransform,
|
||||
modifier: Modifier = Modifier,
|
||||
enterTransition: AnimatedContentTransitionScope<Screen>.(SwipeEdge) -> EnterTransition = { fadeIn() },
|
||||
exitTransition: AnimatedContentTransitionScope<Screen>.(SwipeEdge) -> ExitTransition = { fadeOut() },
|
||||
popEnterTransition: AnimatedContentTransitionScope<Screen>.(SwipeEdge) -> EnterTransition = enterTransition,
|
||||
popExitTransition: AnimatedContentTransitionScope<Screen>.(SwipeEdge) -> ExitTransition = exitTransition,
|
||||
sizeTransform: (AnimatedContentTransitionScope<Screen>.() -> SizeTransform?)? = null,
|
||||
flingAnimationSpec: () -> AnimationSpec<Float> = { spring(stiffness = Spring.StiffnessLow) },
|
||||
content: ScreenTransitionContent = { it.Content() },
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = navigator.lastItem,
|
||||
transitionSpec = transition,
|
||||
val view = LocalView.current
|
||||
val viewConfig = LocalViewConfiguration.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val state = remember {
|
||||
ScreenTransitionState(
|
||||
navigator = navigator,
|
||||
scope = scope,
|
||||
flingAnimationSpec = flingAnimationSpec(),
|
||||
windowCornerRadius = view.getWindowRadius().toFloat(),
|
||||
)
|
||||
}
|
||||
val transitionState = remember { SeekableTransitionState(navigator.lastItem) }
|
||||
val transition = rememberTransition(transitionState = transitionState)
|
||||
|
||||
if (state.isPredictiveBack || state.isAnimating) {
|
||||
LaunchedEffect(state.progress) {
|
||||
if (!state.isPredictiveBack) return@LaunchedEffect
|
||||
val previousEntry = navigator.items.getOrNull(navigator.size - 2)
|
||||
if (previousEntry != null) {
|
||||
transitionState.seekTo(fraction = state.progress, targetState = previousEntry)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LaunchedEffect(navigator) {
|
||||
snapshotFlow { navigator.lastItem }
|
||||
.collect {
|
||||
state.cancelCancelAnimation()
|
||||
if (it != transitionState.currentState) {
|
||||
transitionState.animateTo(it)
|
||||
} else {
|
||||
transitionState.snapTo(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PredictiveBackHandler(enabled = navigator.canPop) { backEvent ->
|
||||
state.cancelCancelAnimation()
|
||||
var startOffset: Offset? = null
|
||||
backEvent
|
||||
.dropWhile {
|
||||
if (startOffset == null) startOffset = Offset(it.touchX, it.touchY)
|
||||
if (state.isAnimating) return@dropWhile true
|
||||
// Touch slop check
|
||||
val diff = Offset(it.touchX, it.touchY) - startOffset!!
|
||||
diff.x.absoluteValue < viewConfig.touchSlop && diff.y.absoluteValue < viewConfig.touchSlop
|
||||
}
|
||||
.onCompletion {
|
||||
if (it == null) {
|
||||
state.finish()
|
||||
} else {
|
||||
state.cancel()
|
||||
}
|
||||
}
|
||||
.collect {
|
||||
state.setPredictiveBackProgress(
|
||||
progress = it.progress,
|
||||
swipeEdge = when (it.swipeEdge) {
|
||||
BackEventCompat.EDGE_LEFT -> SwipeEdge.Left
|
||||
BackEventCompat.EDGE_RIGHT -> SwipeEdge.Right
|
||||
else -> SwipeEdge.Unknown
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
transition.AnimatedContent(
|
||||
modifier = modifier,
|
||||
label = "transition",
|
||||
) { screen ->
|
||||
navigator.saveableState("transition", screen) {
|
||||
content(screen)
|
||||
transitionSpec = {
|
||||
val pop = navigator.lastEvent == StackEvent.Pop || state.isPredictiveBack
|
||||
ContentTransform(
|
||||
targetContentEnter = if (pop) {
|
||||
popEnterTransition(state.swipeEdge)
|
||||
} else {
|
||||
enterTransition(state.swipeEdge)
|
||||
},
|
||||
initialContentExit = if (pop) {
|
||||
popExitTransition(state.swipeEdge)
|
||||
} else {
|
||||
exitTransition(state.swipeEdge)
|
||||
},
|
||||
targetContentZIndex = if (pop) 0f else 1f,
|
||||
sizeTransform = sizeTransform?.invoke(this),
|
||||
)
|
||||
},
|
||||
contentKey = { it.key },
|
||||
) {
|
||||
navigator.saveableState("transition", it) {
|
||||
content(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
private class ScreenTransitionState(
|
||||
private val navigator: Navigator,
|
||||
private val scope: CoroutineScope,
|
||||
private val flingAnimationSpec: AnimationSpec<Float>,
|
||||
windowCornerRadius: Float,
|
||||
) {
|
||||
var isPredictiveBack: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var progress: Float by mutableFloatStateOf(0f)
|
||||
private set
|
||||
|
||||
var swipeEdge: SwipeEdge by mutableStateOf(SwipeEdge.Unknown)
|
||||
private set
|
||||
|
||||
private var animationJob: Pair<Job, AnimationType>? by mutableStateOf(null)
|
||||
|
||||
val isAnimating: Boolean
|
||||
get() = animationJob?.first?.isActive == true
|
||||
|
||||
val windowCornerShape = RoundedCornerShape(windowCornerRadius)
|
||||
|
||||
private fun reset() {
|
||||
this.isPredictiveBack = false
|
||||
this.swipeEdge = SwipeEdge.Unknown
|
||||
this.animationJob = null
|
||||
}
|
||||
|
||||
fun setPredictiveBackProgress(progress: Float, swipeEdge: SwipeEdge) {
|
||||
this.progress = lerp(0f, 0.65f, PredictiveBack.transform(progress))
|
||||
this.swipeEdge = swipeEdge
|
||||
this.isPredictiveBack = true
|
||||
}
|
||||
|
||||
fun finish() {
|
||||
if (!isPredictiveBack) {
|
||||
navigator.pop()
|
||||
return
|
||||
}
|
||||
animationJob = scope.launch {
|
||||
try {
|
||||
animate(
|
||||
initialValue = progress,
|
||||
targetValue = 1f,
|
||||
animationSpec = flingAnimationSpec,
|
||||
block = { i, _ -> progress = i },
|
||||
)
|
||||
navigator.pop()
|
||||
} catch (e: CancellationException) {
|
||||
// Cancelled
|
||||
progress = 0f
|
||||
} finally {
|
||||
reset()
|
||||
}
|
||||
} to AnimationType.Pop
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
if (!isPredictiveBack) {
|
||||
return
|
||||
}
|
||||
animationJob = scope.launch {
|
||||
try {
|
||||
animate(
|
||||
initialValue = progress,
|
||||
targetValue = 0f,
|
||||
animationSpec = flingAnimationSpec,
|
||||
block = { i, _ -> progress = i },
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
// Cancelled
|
||||
progress = 1f
|
||||
} finally {
|
||||
reset()
|
||||
}
|
||||
} to AnimationType.Cancel
|
||||
}
|
||||
|
||||
fun cancelCancelAnimation() {
|
||||
if (animationJob?.second == AnimationType.Cancel) {
|
||||
animationJob?.first?.cancel()
|
||||
animationJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun screenCandidatesToDisposeSaver(): Saver<MutableState<Set<Screen>>, List<Screen>> {
|
||||
return Saver(
|
||||
save = { it.value.toList() },
|
||||
restore = { mutableStateOf(it.toSet()) },
|
||||
)
|
||||
}
|
||||
|
@@ -3,7 +3,6 @@ package eu.kanade.presentation.webview
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.graphics.Bitmap
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -27,7 +26,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kevinnzou.web.AccompanistWebViewClient
|
||||
@@ -39,19 +37,13 @@ import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.components.AppBarActions
|
||||
import eu.kanade.presentation.components.WarningBanner
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.util.system.WebViewUtil
|
||||
import eu.kanade.tachiyomi.util.system.getHtml
|
||||
import eu.kanade.tachiyomi.util.system.setDefaultSettings
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Request
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
@Composable
|
||||
fun WebViewScreenContent(
|
||||
onNavigateUp: () -> Unit,
|
||||
@@ -65,11 +57,8 @@ fun WebViewScreenContent(
|
||||
) {
|
||||
val state = rememberWebViewState(url = url, additionalHttpHeaders = headers)
|
||||
val navigator = rememberWebViewNavigator()
|
||||
val context = LocalContext.current
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val network = remember { Injekt.get<NetworkHelper>() }
|
||||
val spoofedPackageName = remember { WebViewUtil.spoofedPackageName(context) }
|
||||
|
||||
var currentUrl by remember { mutableStateOf(url) }
|
||||
var showCloudflareHelp by remember { mutableStateOf(false) }
|
||||
@@ -124,40 +113,6 @@ fun WebViewScreenContent(
|
||||
}
|
||||
return super.shouldOverrideUrlLoading(view, request)
|
||||
}
|
||||
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
): WebResourceResponse? {
|
||||
return try {
|
||||
val internalRequest = Request.Builder().apply {
|
||||
url(request!!.url.toString())
|
||||
request.requestHeaders.forEach { (key, value) ->
|
||||
if (key == "X-Requested-With" && value in setOf(context.packageName, spoofedPackageName)) {
|
||||
return@forEach
|
||||
}
|
||||
addHeader(key, value)
|
||||
}
|
||||
method(request.method, null)
|
||||
}.build()
|
||||
|
||||
val response = network.nonCloudflareClient.newCall(internalRequest).execute()
|
||||
|
||||
val contentType = response.body.contentType()?.let { "${it.type}/${it.subtype}" } ?: "text/html"
|
||||
val contentEncoding = response.body.contentType()?.charset()?.name() ?: "utf-8"
|
||||
|
||||
WebResourceResponse(
|
||||
contentType,
|
||||
contentEncoding,
|
||||
response.code,
|
||||
response.message,
|
||||
response.headers.associate { it.first to it.second },
|
||||
response.body.byteStream(),
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
super.shouldInterceptRequest(view, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -80,7 +80,7 @@ class BackupNotifier(private val context: Context) {
|
||||
addAction(
|
||||
R.drawable.ic_share_24dp,
|
||||
context.stringResource(MR.strings.action_share),
|
||||
NotificationReceiver.shareBackupPendingBroadcast(context, file.uri),
|
||||
NotificationReceiver.shareBackupPendingActivity(context, file.uri),
|
||||
)
|
||||
|
||||
show(Notifications.ID_BACKUP_COMPLETE)
|
||||
|
@@ -99,4 +99,6 @@ private fun Manga.toBackupManga() =
|
||||
lastModifiedAt = this.lastModifiedAt,
|
||||
favoriteModifiedAt = this.favoriteModifiedAt,
|
||||
version = this.version,
|
||||
notes = this.notes,
|
||||
initialized = this.initialized,
|
||||
)
|
||||
|
@@ -38,8 +38,11 @@ data class BackupManga(
|
||||
@ProtoNumber(105) var updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE,
|
||||
@ProtoNumber(106) var lastModifiedAt: Long = 0,
|
||||
@ProtoNumber(107) var favoriteModifiedAt: Long? = null,
|
||||
// Mihon values start here
|
||||
@ProtoNumber(108) var excludedScanlators: List<String> = emptyList(),
|
||||
@ProtoNumber(109) var version: Long = 0,
|
||||
@ProtoNumber(110) var notes: String = "",
|
||||
@ProtoNumber(111) var initialized: Boolean = false,
|
||||
) {
|
||||
fun getMangaImpl(): Manga {
|
||||
return Manga.create().copy(
|
||||
@@ -60,6 +63,8 @@ data class BackupManga(
|
||||
lastModifiedAt = this@BackupManga.lastModifiedAt,
|
||||
favoriteModifiedAt = this@BackupManga.favoriteModifiedAt,
|
||||
version = this@BackupManga.version,
|
||||
notes = this@BackupManga.notes,
|
||||
initialized = this@BackupManga.initialized,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -129,6 +129,7 @@ class MangaRestorer(
|
||||
updateStrategy = manga.updateStrategy.let(UpdateStrategyColumnAdapter::encode),
|
||||
version = manga.version,
|
||||
isSyncing = 1,
|
||||
notes = manga.notes,
|
||||
)
|
||||
}
|
||||
return manga
|
||||
@@ -138,9 +139,7 @@ class MangaRestorer(
|
||||
manga: Manga,
|
||||
): Manga {
|
||||
return manga.copy(
|
||||
initialized = manga.description != null,
|
||||
id = insertManga(manga),
|
||||
version = manga.version,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -261,6 +260,7 @@ class MangaRestorer(
|
||||
dateAdded = manga.dateAdded,
|
||||
updateStrategy = manga.updateStrategy,
|
||||
version = manga.version,
|
||||
notes = manga.notes,
|
||||
)
|
||||
mangasQueries.selectLastInsertedRowId()
|
||||
}
|
||||
|
@@ -282,6 +282,41 @@ class DownloadCache(
|
||||
notifyChanges()
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a manga in this cache.
|
||||
*
|
||||
* @param manga the manga being renamed.
|
||||
* @param mangaUniFile the manga's new directory.
|
||||
* @param newTitle the manga's new title.
|
||||
*/
|
||||
suspend fun renameManga(manga: Manga, mangaUniFile: UniFile, newTitle: String) {
|
||||
rootDownloadsDirMutex.withLock {
|
||||
val sourceDir = rootDownloadsDir.sourceDirs[manga.source] ?: return
|
||||
val oldMangaDirName = provider.getMangaDirName(manga.title)
|
||||
var oldChapterDirs: MutableSet<String>? = null
|
||||
// Save the old name's cached chapter dirs
|
||||
if (sourceDir.mangaDirs.containsKey(oldMangaDirName)) {
|
||||
oldChapterDirs = sourceDir.mangaDirs[oldMangaDirName]?.chapterDirs
|
||||
sourceDir.mangaDirs -= oldMangaDirName
|
||||
}
|
||||
|
||||
// Retrieve/create the cached manga directory for new name
|
||||
val newMangaDirName = provider.getMangaDirName(newTitle)
|
||||
var mangaDir = sourceDir.mangaDirs[newMangaDirName]
|
||||
if (mangaDir == null) {
|
||||
mangaDir = MangaDirectory(mangaUniFile)
|
||||
sourceDir.mangaDirs += newMangaDirName to mangaDir
|
||||
}
|
||||
|
||||
// Add the old chapters to new name's cache
|
||||
if (!oldChapterDirs.isNullOrEmpty()) {
|
||||
mangaDir.chapterDirs += oldChapterDirs
|
||||
}
|
||||
}
|
||||
|
||||
notifyChanges()
|
||||
}
|
||||
|
||||
suspend fun removeSource(source: Source) {
|
||||
rootDownloadsDirMutex.withLock {
|
||||
rootDownloadsDir.sourceDirs -= source.id
|
||||
|
@@ -169,7 +169,7 @@ class DownloadManager(
|
||||
|
||||
return files.sortedBy { it.name }
|
||||
.mapIndexed { i, file ->
|
||||
Page(i, uri = file.uri).apply { status = Page.State.READY }
|
||||
Page(i, uri = file.uri).apply { status = Page.State.Ready }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,6 +327,38 @@ class DownloadManager(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames manga download folder
|
||||
*
|
||||
* @param manga the manga
|
||||
* @param newTitle the new manga title.
|
||||
*/
|
||||
suspend fun renameManga(manga: Manga, newTitle: String) {
|
||||
val source = sourceManager.getOrStub(manga.source)
|
||||
val oldFolder = provider.findMangaDir(manga.title, source) ?: return
|
||||
val newName = provider.getMangaDirName(newTitle)
|
||||
|
||||
if (oldFolder.name == newName) return
|
||||
|
||||
// just to be safe, don't allow downloads for this manga while renaming it
|
||||
downloader.removeFromQueue(manga)
|
||||
|
||||
val capitalizationChanged = oldFolder.name.equals(newName, ignoreCase = true)
|
||||
if (capitalizationChanged) {
|
||||
val tempName = newName + Downloader.TMP_DIR_SUFFIX
|
||||
if (!oldFolder.renameTo(tempName)) {
|
||||
logcat(LogPriority.ERROR) { "Failed to rename manga download folder: ${oldFolder.name}" }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (oldFolder.renameTo(newName)) {
|
||||
cache.renameManga(manga, oldFolder, newTitle)
|
||||
} else {
|
||||
logcat(LogPriority.ERROR) { "Failed to rename manga download folder: ${oldFolder.name}" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames an already downloaded chapter
|
||||
*
|
||||
@@ -337,7 +369,10 @@ class DownloadManager(
|
||||
*/
|
||||
suspend fun renameChapter(source: Source, manga: Manga, oldChapter: Chapter, newChapter: Chapter) {
|
||||
val oldNames = provider.getValidChapterDirNames(oldChapter.name, oldChapter.scanlator)
|
||||
val mangaDir = provider.getMangaDir(manga.title, source)
|
||||
val mangaDir = provider.getMangaDir(manga.title, source).getOrElse { e ->
|
||||
logcat(LogPriority.ERROR, e) { "Manga download folder doesn't exist. Skipping renaming after source sync" }
|
||||
return
|
||||
}
|
||||
|
||||
// Assume there's only 1 version of the chapter name formats present
|
||||
val oldDownload = oldNames.asSequence()
|
||||
|
@@ -14,6 +14,7 @@ import tachiyomi.domain.storage.service.StorageManager
|
||||
import tachiyomi.i18n.MR
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* This class is used to provide the directories where the downloads should be saved.
|
||||
@@ -35,20 +36,36 @@ class DownloadProvider(
|
||||
* @param mangaTitle the title of the manga to query.
|
||||
* @param source the source of the manga.
|
||||
*/
|
||||
internal fun getMangaDir(mangaTitle: String, source: Source): UniFile {
|
||||
try {
|
||||
return downloadsDir!!
|
||||
.createDirectory(getSourceDirName(source))!!
|
||||
.createDirectory(getMangaDirName(mangaTitle))!!
|
||||
} catch (e: Throwable) {
|
||||
logcat(LogPriority.ERROR, e) { "Invalid download directory" }
|
||||
throw Exception(
|
||||
context.stringResource(
|
||||
MR.strings.invalid_location,
|
||||
downloadsDir?.displayablePath ?: "",
|
||||
),
|
||||
internal fun getMangaDir(mangaTitle: String, source: Source): Result<UniFile> {
|
||||
val downloadsDir = downloadsDir
|
||||
if (downloadsDir == null) {
|
||||
logcat(LogPriority.ERROR) { "Failed to create download directory" }
|
||||
return Result.failure(
|
||||
IOException(context.stringResource(MR.strings.storage_failed_to_create_download_directory)),
|
||||
)
|
||||
}
|
||||
|
||||
val sourceDirName = getSourceDirName(source)
|
||||
val sourceDir = downloadsDir.createDirectory(sourceDirName)
|
||||
if (sourceDir == null) {
|
||||
val displayablePath = downloadsDir.displayablePath + "/$sourceDirName"
|
||||
logcat(LogPriority.ERROR) { "Failed to create source download directory: $displayablePath" }
|
||||
return Result.failure(
|
||||
IOException(context.stringResource(MR.strings.storage_failed_to_create_directory, displayablePath)),
|
||||
)
|
||||
}
|
||||
|
||||
val mangaDirName = getMangaDirName(mangaTitle)
|
||||
val mangaDir = sourceDir.createDirectory(mangaDirName)
|
||||
if (mangaDir == null) {
|
||||
val displayablePath = sourceDir.displayablePath + "/$mangaDirName"
|
||||
logcat(LogPriority.ERROR) { "Failed to create manga download directory: $displayablePath" }
|
||||
return Result.failure(
|
||||
IOException(context.stringResource(MR.strings.storage_failed_to_create_directory, displayablePath)),
|
||||
)
|
||||
}
|
||||
|
||||
return Result.success(mangaDir)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -315,7 +315,11 @@ class Downloader(
|
||||
* @param download the chapter to be downloaded.
|
||||
*/
|
||||
private suspend fun downloadChapter(download: Download) {
|
||||
val mangaDir = provider.getMangaDir(download.manga.title, download.source)
|
||||
val mangaDir = provider.getMangaDir(download.manga.title, download.source).getOrElse { e ->
|
||||
download.status = Download.State.ERROR
|
||||
notifier.onError(e.message, download.chapter.name, download.manga.title, download.manga.id)
|
||||
return
|
||||
}
|
||||
|
||||
val availSpace = DiskUtil.getAvailableStorageSpace(mangaDir)
|
||||
if (availSpace != -1L && availSpace < MIN_DISK_SPACE) {
|
||||
@@ -361,11 +365,11 @@ class Downloader(
|
||||
flow {
|
||||
// Fetch image URL if necessary
|
||||
if (page.imageUrl.isNullOrEmpty()) {
|
||||
page.status = Page.State.LOAD_PAGE
|
||||
page.status = Page.State.LoadPage
|
||||
try {
|
||||
page.imageUrl = download.source.getImageUrl(page)
|
||||
} catch (e: Throwable) {
|
||||
page.status = Page.State.ERROR
|
||||
page.status = Page.State.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,12 +456,12 @@ class Downloader(
|
||||
|
||||
page.uri = file.uri
|
||||
page.progress = 100
|
||||
page.status = Page.State.READY
|
||||
page.status = Page.State.Ready
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
// Mark this page as error and allow to download the remaining
|
||||
page.progress = 0
|
||||
page.status = Page.State.ERROR
|
||||
page.status = Page.State.Error(e)
|
||||
notifier.onError(e.message, download.chapter.name, download.manga.title, download.manga.id)
|
||||
}
|
||||
}
|
||||
@@ -471,7 +475,7 @@ class Downloader(
|
||||
* @param filename the filename of the image.
|
||||
*/
|
||||
private suspend fun downloadImage(page: Page, source: HttpSource, tmpDir: UniFile, filename: String): UniFile {
|
||||
page.status = Page.State.DOWNLOAD_IMAGE
|
||||
page.status = Page.State.DownloadImage
|
||||
page.progress = 0
|
||||
return flow {
|
||||
val response = source.getImage(page)
|
||||
|
@@ -29,7 +29,7 @@ data class Download(
|
||||
get() = pages?.sumOf(Page::progress) ?: 0
|
||||
|
||||
val downloadedImages: Int
|
||||
get() = pages?.count { it.status == Page.State.READY } ?: 0
|
||||
get() = pages?.count { it.status == Page.State.Ready } ?: 0
|
||||
|
||||
@Transient
|
||||
private val _statusFlow = MutableStateFlow(State.NOT_DOWNLOADED)
|
||||
|
@@ -71,9 +71,12 @@ import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.concurrent.atomics.AtomicBoolean
|
||||
import kotlin.concurrent.atomics.AtomicInt
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
import kotlin.concurrent.atomics.incrementAndFetch
|
||||
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
class LibraryUpdateJob(private val context: Context, workerParams: WorkerParameters) :
|
||||
CoroutineWorker(context, workerParams) {
|
||||
|
||||
@@ -155,25 +158,16 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
||||
val libraryManga = getLibraryManga.await()
|
||||
|
||||
val listToUpdate = if (categoryId != -1L) {
|
||||
libraryManga.filter { it.category == categoryId }
|
||||
libraryManga.filter { categoryId in it.categories }
|
||||
} else {
|
||||
val categoriesToUpdate = libraryPreferences.updateCategories().get().map { it.toLong() }
|
||||
val includedManga = if (categoriesToUpdate.isNotEmpty()) {
|
||||
libraryManga.filter { it.category in categoriesToUpdate }
|
||||
} else {
|
||||
libraryManga
|
||||
}
|
||||
val includedCategories = libraryPreferences.updateCategories().get().map { it.toLong() }
|
||||
val excludedCategories = libraryPreferences.updateCategoriesExclude().get().map { it.toLong() }
|
||||
|
||||
val categoriesToExclude = libraryPreferences.updateCategoriesExclude().get().map { it.toLong() }
|
||||
val excludedMangaIds = if (categoriesToExclude.isNotEmpty()) {
|
||||
libraryManga.filter { it.category in categoriesToExclude }.map { it.manga.id }
|
||||
} else {
|
||||
emptyList()
|
||||
libraryManga.filter {
|
||||
val included = includedCategories.isEmpty() || it.categories.intersect(includedCategories).isNotEmpty()
|
||||
val excluded = it.categories.intersect(excludedCategories).isNotEmpty()
|
||||
included && !excluded
|
||||
}
|
||||
|
||||
includedManga
|
||||
.filterNot { it.manga.id in excludedMangaIds }
|
||||
.distinctBy { it.manga.id }
|
||||
}
|
||||
|
||||
val restrictions = libraryPreferences.autoUpdateMangaRestrictions().get()
|
||||
@@ -183,7 +177,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
||||
mangaToUpdate = listToUpdate
|
||||
.filter {
|
||||
when {
|
||||
it.manga.updateStrategy != UpdateStrategy.ALWAYS_UPDATE -> {
|
||||
it.manga.updateStrategy == UpdateStrategy.ONLY_FETCH_ONCE && it.totalChapters > 0L -> {
|
||||
skippedUpdates.add(
|
||||
it.manga to context.stringResource(MR.strings.skipped_reason_not_always_update),
|
||||
)
|
||||
@@ -240,7 +234,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
||||
*/
|
||||
private suspend fun updateChapterList() {
|
||||
val semaphore = Semaphore(5)
|
||||
val progressCount = AtomicInteger(0)
|
||||
val progressCount = AtomicInt(0)
|
||||
val currentlyUpdatingManga = CopyOnWriteArrayList<Manga>()
|
||||
val newUpdates = CopyOnWriteArrayList<Pair<Manga, Array<Chapter>>>()
|
||||
val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
|
||||
@@ -275,7 +269,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
||||
|
||||
if (chaptersToDownload.isNotEmpty()) {
|
||||
downloadChapters(manga, chaptersToDownload)
|
||||
hasDownloads.set(true)
|
||||
hasDownloads.store(true)
|
||||
}
|
||||
|
||||
libraryPreferences.newUpdatesCount().getAndSet { it + newChapters.size }
|
||||
@@ -308,7 +302,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
||||
|
||||
if (newUpdates.isNotEmpty()) {
|
||||
notifier.showUpdateNotifications(newUpdates)
|
||||
if (hasDownloads.get()) {
|
||||
if (hasDownloads.load()) {
|
||||
downloadManager.startDownloads()
|
||||
}
|
||||
}
|
||||
@@ -354,7 +348,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
||||
|
||||
private suspend fun withUpdateNotification(
|
||||
updatingManga: CopyOnWriteArrayList<Manga>,
|
||||
completed: AtomicInteger,
|
||||
completed: AtomicInt,
|
||||
manga: Manga,
|
||||
block: suspend () -> Unit,
|
||||
) = coroutineScope {
|
||||
@@ -363,7 +357,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
||||
updatingManga.add(manga)
|
||||
notifier.showProgressNotification(
|
||||
updatingManga,
|
||||
completed.get(),
|
||||
completed.load(),
|
||||
mangaToUpdate.size,
|
||||
)
|
||||
|
||||
@@ -372,10 +366,10 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
|
||||
ensureActive()
|
||||
|
||||
updatingManga.remove(manga)
|
||||
completed.getAndIncrement()
|
||||
completed.incrementAndFetch()
|
||||
notifier.showProgressNotification(
|
||||
updatingManga,
|
||||
completed.get(),
|
||||
completed.load(),
|
||||
mangaToUpdate.size,
|
||||
)
|
||||
}
|
||||
|
@@ -37,8 +37,11 @@ import tachiyomi.domain.source.service.SourceManager
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.concurrent.atomics.AtomicInt
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
import kotlin.concurrent.atomics.fetchAndIncrement
|
||||
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
class MetadataUpdateJob(private val context: Context, workerParams: WorkerParameters) :
|
||||
CoroutineWorker(context, workerParams) {
|
||||
|
||||
@@ -97,7 +100,7 @@ class MetadataUpdateJob(private val context: Context, workerParams: WorkerParame
|
||||
|
||||
private suspend fun updateMetadata() {
|
||||
val semaphore = Semaphore(5)
|
||||
val progressCount = AtomicInteger(0)
|
||||
val progressCount = AtomicInt(0)
|
||||
val currentlyUpdatingManga = CopyOnWriteArrayList<Manga>()
|
||||
|
||||
coroutineScope {
|
||||
@@ -142,7 +145,7 @@ class MetadataUpdateJob(private val context: Context, workerParams: WorkerParame
|
||||
|
||||
private suspend fun withUpdateNotification(
|
||||
updatingManga: CopyOnWriteArrayList<Manga>,
|
||||
completed: AtomicInteger,
|
||||
completed: AtomicInt,
|
||||
manga: Manga,
|
||||
block: suspend () -> Unit,
|
||||
) = coroutineScope {
|
||||
@@ -151,7 +154,7 @@ class MetadataUpdateJob(private val context: Context, workerParams: WorkerParame
|
||||
updatingManga.add(manga)
|
||||
notifier.showProgressNotification(
|
||||
updatingManga,
|
||||
completed.get(),
|
||||
completed.load(),
|
||||
mangaToUpdate.size,
|
||||
)
|
||||
|
||||
@@ -160,10 +163,10 @@ class MetadataUpdateJob(private val context: Context, workerParams: WorkerParame
|
||||
ensureActive()
|
||||
|
||||
updatingManga.remove(manga)
|
||||
completed.getAndIncrement()
|
||||
completed.fetchAndIncrement()
|
||||
notifier.showProgressNotification(
|
||||
updatingManga,
|
||||
completed.get(),
|
||||
completed.load(),
|
||||
mangaToUpdate.size,
|
||||
)
|
||||
}
|
||||
|
@@ -583,18 +583,17 @@ class NotificationReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [PendingIntent] that starts a share activity for a backup file.
|
||||
* Returns [PendingIntent] that directly launches a share activity for a backup file.
|
||||
*
|
||||
* @param context context of application
|
||||
* @param uri uri of backup file
|
||||
* @return [PendingIntent]
|
||||
*/
|
||||
internal fun shareBackupPendingBroadcast(context: Context, uri: Uri): PendingIntent {
|
||||
val intent = Intent(context, NotificationReceiver::class.java).apply {
|
||||
action = ACTION_SHARE_BACKUP
|
||||
putExtra(EXTRA_URI, uri)
|
||||
internal fun shareBackupPendingActivity(context: Context, uri: Uri): PendingIntent {
|
||||
val intent = uri.toShareIntent(context, "application/x-protobuf+gzip").apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
return PendingIntent.getBroadcast(
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
intent,
|
||||
|
@@ -104,6 +104,7 @@ class BangumiApi(
|
||||
.awaitSuccess()
|
||||
.parseAs<BGMSearchResult>()
|
||||
.data
|
||||
.filter { it.platform == null || it.platform == "漫画" }
|
||||
.map { it.toTrackSearch(trackId) }
|
||||
}
|
||||
}
|
||||
|
@@ -25,6 +25,7 @@ data class BGMSubject(
|
||||
val volumes: Long = 0,
|
||||
val eps: Long = 0,
|
||||
val rating: BGMSubjectRating?,
|
||||
val platform: String?,
|
||||
) {
|
||||
fun toTrackSearch(trackId: Long): TrackSearch = TrackSearch.create(trackId).apply {
|
||||
remote_id = this@BGMSubject.id
|
||||
|
@@ -12,16 +12,18 @@ import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlin.concurrent.atomics.AtomicReference
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
|
||||
/**
|
||||
* Base implementation class for extension installer. To be used inside a foreground [Service].
|
||||
*/
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
abstract class Installer(private val service: Service) {
|
||||
|
||||
private val extensionManager: ExtensionManager by injectLazy()
|
||||
|
||||
private var waitingInstall = AtomicReference<Entry>(null)
|
||||
private var waitingInstall = AtomicReference<Entry?>(null)
|
||||
private val queue = Collections.synchronizedList(mutableListOf<Entry>())
|
||||
|
||||
private val cancelReceiver = object : BroadcastReceiver() {
|
||||
@@ -79,7 +81,7 @@ abstract class Installer(private val service: Service) {
|
||||
* @see waitingInstall
|
||||
*/
|
||||
fun continueQueue(resultStep: InstallStep) {
|
||||
val completedEntry = waitingInstall.getAndSet(null)
|
||||
val completedEntry = waitingInstall.exchange(null)
|
||||
if (completedEntry != null) {
|
||||
extensionManager.updateInstallStep(completedEntry.downloadId, resultStep)
|
||||
checkQueue()
|
||||
@@ -115,10 +117,10 @@ abstract class Installer(private val service: Service) {
|
||||
LocalBroadcastManager.getInstance(service).unregisterReceiver(cancelReceiver)
|
||||
queue.forEach { extensionManager.updateInstallStep(it.downloadId, InstallStep.Error) }
|
||||
queue.clear()
|
||||
waitingInstall.set(null)
|
||||
waitingInstall.store(null)
|
||||
}
|
||||
|
||||
protected fun getActiveEntry(): Entry? = waitingInstall.get()
|
||||
protected fun getActiveEntry(): Entry? = waitingInstall.load()
|
||||
|
||||
/**
|
||||
* Cancels queue for the provided download ID if exists.
|
||||
@@ -126,13 +128,13 @@ abstract class Installer(private val service: Service) {
|
||||
* @param downloadId Download ID as known by [ExtensionManager]
|
||||
*/
|
||||
private fun cancelQueue(downloadId: Long) {
|
||||
val waitingInstall = this.waitingInstall.get()
|
||||
val waitingInstall = this.waitingInstall.load()
|
||||
val toCancel = queue.find { it.downloadId == downloadId } ?: waitingInstall ?: return
|
||||
if (cancelEntry(toCancel)) {
|
||||
queue.remove(toCancel)
|
||||
if (waitingInstall == toCancel) {
|
||||
// Currently processing removed entry, continue queue
|
||||
this.waitingInstall.set(null)
|
||||
this.waitingInstall.store(null)
|
||||
checkQueue()
|
||||
}
|
||||
extensionManager.updateInstallStep(downloadId, InstallStep.Idle)
|
||||
|
@@ -30,6 +30,7 @@ class ThemingDelegateImpl : ThemingDelegate {
|
||||
|
||||
private val themeResources: Map<AppTheme, Int> = mapOf(
|
||||
AppTheme.MONET to R.style.Theme_Tachiyomi_Monet,
|
||||
AppTheme.CATPPUCCIN to R.style.Theme_Tachiyomi_Catppuccin,
|
||||
AppTheme.GREEN_APPLE to R.style.Theme_Tachiyomi_GreenApple,
|
||||
AppTheme.LAVENDER to R.style.Theme_Tachiyomi_Lavender,
|
||||
AppTheme.MIDNIGHT_DUSK to R.style.Theme_Tachiyomi_MidnightDusk,
|
||||
|
@@ -1,81 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.migration
|
||||
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import eu.kanade.domain.manga.model.hasCustomCover
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.download.DownloadCache
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.i18n.MR
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
data class MigrationFlag(
|
||||
val flag: Int,
|
||||
val isDefaultSelected: Boolean,
|
||||
val titleId: StringResource,
|
||||
) {
|
||||
companion object {
|
||||
fun create(flag: Int, defaultSelectionMap: Int, titleId: StringResource): MigrationFlag {
|
||||
return MigrationFlag(
|
||||
flag = flag,
|
||||
isDefaultSelected = defaultSelectionMap and flag != 0,
|
||||
titleId = titleId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object MigrationFlags {
|
||||
|
||||
private const val CHAPTERS = 0b00001
|
||||
private const val CATEGORIES = 0b00010
|
||||
private const val CUSTOM_COVER = 0b01000
|
||||
private const val DELETE_DOWNLOADED = 0b10000
|
||||
|
||||
private val coverCache: CoverCache by injectLazy()
|
||||
private val downloadCache: DownloadCache by injectLazy()
|
||||
|
||||
fun hasChapters(value: Int): Boolean {
|
||||
return value and CHAPTERS != 0
|
||||
}
|
||||
|
||||
fun hasCategories(value: Int): Boolean {
|
||||
return value and CATEGORIES != 0
|
||||
}
|
||||
|
||||
fun hasCustomCover(value: Int): Boolean {
|
||||
return value and CUSTOM_COVER != 0
|
||||
}
|
||||
|
||||
fun hasDeleteDownloaded(value: Int): Boolean {
|
||||
return value and DELETE_DOWNLOADED != 0
|
||||
}
|
||||
|
||||
/** Returns information about applicable flags with default selections. */
|
||||
fun getFlags(manga: Manga?, defaultSelectedBitMap: Int): List<MigrationFlag> {
|
||||
val flags = mutableListOf<MigrationFlag>()
|
||||
flags += MigrationFlag.create(CHAPTERS, defaultSelectedBitMap, MR.strings.chapters)
|
||||
flags += MigrationFlag.create(CATEGORIES, defaultSelectedBitMap, MR.strings.categories)
|
||||
|
||||
if (manga != null) {
|
||||
if (manga.hasCustomCover(coverCache)) {
|
||||
flags += MigrationFlag.create(CUSTOM_COVER, defaultSelectedBitMap, MR.strings.custom_cover)
|
||||
}
|
||||
if (downloadCache.getDownloadCount(manga) > 0) {
|
||||
flags += MigrationFlag.create(DELETE_DOWNLOADED, defaultSelectedBitMap, MR.strings.delete_downloaded)
|
||||
}
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
/** Returns a bit map of selected flags. */
|
||||
fun getSelectedFlagsBitMap(
|
||||
selectedFlags: List<Boolean>,
|
||||
flags: List<MigrationFlag>,
|
||||
): Int {
|
||||
return selectedFlags
|
||||
.zip(flags)
|
||||
.filter { (isSelected, _) -> isSelected }
|
||||
.map { (_, flag) -> flag.flag }
|
||||
.reduceOrNull { acc, mask -> acc or mask } ?: 0
|
||||
}
|
||||
}
|
@@ -1,21 +1,41 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.migration.manga
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import eu.kanade.presentation.browse.MigrateMangaScreen
|
||||
import eu.kanade.presentation.components.AppBar
|
||||
import eu.kanade.presentation.manga.components.BaseMangaListItem
|
||||
import eu.kanade.presentation.util.Screen
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import mihon.feature.migration.config.MigrationConfigScreen
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.FastScrollLazyColumn
|
||||
import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.screens.EmptyScreen
|
||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||
import tachiyomi.presentation.core.util.selectedBackground
|
||||
import tachiyomi.presentation.core.util.shouldExpandFAB
|
||||
|
||||
data class MigrateMangaScreen(
|
||||
private val sourceId: Long,
|
||||
@@ -34,13 +54,59 @@ data class MigrateMangaScreen(
|
||||
return
|
||||
}
|
||||
|
||||
MigrateMangaScreen(
|
||||
navigateUp = navigator::pop,
|
||||
BackHandler(enabled = state.selectionMode) {
|
||||
screenModel.clearSelection()
|
||||
}
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
||||
Scaffold(
|
||||
topBar = { scrollBehavior ->
|
||||
AppBar(
|
||||
title = state.source!!.name,
|
||||
navigateUp = {
|
||||
if (state.selectionMode) {
|
||||
screenModel.clearSelection()
|
||||
} else {
|
||||
navigator.pop()
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (state.selectionMode) {
|
||||
ExtendedFloatingActionButton(
|
||||
text = { Text(text = stringResource(MR.strings.migrationConfigScreen_continueButtonText)) },
|
||||
icon = {
|
||||
Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowForward, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
val selection = state.selection
|
||||
screenModel.clearSelection()
|
||||
navigator.push(MigrationConfigScreen(selection))
|
||||
},
|
||||
expanded = lazyListState.shouldExpandFAB(),
|
||||
)
|
||||
}
|
||||
},
|
||||
) { contentPadding ->
|
||||
if (state.isEmpty) {
|
||||
EmptyScreen(
|
||||
stringRes = MR.strings.empty_screen,
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
)
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
MigrateMangaContent(
|
||||
lazyListState = lazyListState,
|
||||
contentPadding = contentPadding,
|
||||
state = state,
|
||||
onClickItem = { navigator.push(MigrateSearchScreen(it.id)) },
|
||||
onClickItem = screenModel::toggleSelection,
|
||||
onClickCover = { navigator.push(MangaScreen(it.id)) },
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
screenModel.events.collectLatest { event ->
|
||||
@@ -52,4 +118,43 @@ data class MigrateMangaScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MigrateMangaContent(
|
||||
lazyListState: LazyListState,
|
||||
contentPadding: PaddingValues,
|
||||
state: MigrateMangaScreenModel.State,
|
||||
onClickItem: (Manga) -> Unit,
|
||||
onClickCover: (Manga) -> Unit,
|
||||
) {
|
||||
FastScrollLazyColumn(
|
||||
state = lazyListState,
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
items(state.titles) { manga ->
|
||||
MigrateMangaItem(
|
||||
manga = manga,
|
||||
isSelected = manga.id in state.selection,
|
||||
onClickItem = onClickItem,
|
||||
onClickCover = onClickCover,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MigrateMangaItem(
|
||||
manga: Manga,
|
||||
isSelected: Boolean,
|
||||
onClickItem: (Manga) -> Unit,
|
||||
onClickCover: (Manga) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BaseMangaListItem(
|
||||
modifier = modifier.selectedBackground(isSelected),
|
||||
manga = manga,
|
||||
onClickItem = { onClickItem(manga) },
|
||||
onClickCover = { onClickCover(manga) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import logcat.LogPriority
|
||||
import mihon.core.common.utils.mutate
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import tachiyomi.domain.manga.interactor.GetFavorites
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
@@ -57,9 +58,23 @@ class MigrateMangaScreenModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSelection(item: Manga) {
|
||||
mutableState.update { state ->
|
||||
val selection = state.selection.mutate { list ->
|
||||
if (!list.remove(item.id)) list.add(item.id)
|
||||
}
|
||||
state.copy(selection = selection)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
mutableState.update { it.copy(selection = emptySet()) }
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class State(
|
||||
val source: Source? = null,
|
||||
val selection: Set<Long> = emptySet(),
|
||||
private val titleList: ImmutableList<Manga>? = null,
|
||||
) {
|
||||
|
||||
@@ -71,6 +86,8 @@ class MigrateMangaScreenModel(
|
||||
|
||||
val isEmpty: Boolean
|
||||
get() = titles.isEmpty()
|
||||
|
||||
val selectionMode = selection.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,310 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.migration.search
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Modifier
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
|
||||
import eu.kanade.domain.manga.interactor.UpdateManga
|
||||
import eu.kanade.domain.manga.model.hasCustomCover
|
||||
import eu.kanade.domain.manga.model.toSManga
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.track.EnhancedTracker
|
||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags
|
||||
import kotlinx.coroutines.flow.update
|
||||
import tachiyomi.core.common.preference.Preference
|
||||
import tachiyomi.core.common.preference.PreferenceStore
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.core.common.util.lang.withUIContext
|
||||
import tachiyomi.domain.category.interactor.GetCategories
|
||||
import tachiyomi.domain.category.interactor.SetMangaCategories
|
||||
import tachiyomi.domain.chapter.interactor.GetChaptersByMangaId
|
||||
import tachiyomi.domain.chapter.interactor.UpdateChapter
|
||||
import tachiyomi.domain.chapter.model.toChapterUpdate
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.manga.model.MangaUpdate
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
import tachiyomi.domain.track.interactor.GetTracks
|
||||
import tachiyomi.domain.track.interactor.InsertTrack
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.LabeledCheckbox
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
internal fun MigrateDialog(
|
||||
oldManga: Manga,
|
||||
newManga: Manga,
|
||||
screenModel: MigrateDialogScreenModel,
|
||||
onDismissRequest: () -> Unit,
|
||||
onClickTitle: () -> Unit,
|
||||
onPopScreen: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val state by screenModel.state.collectAsState()
|
||||
|
||||
val flags = remember { MigrationFlags.getFlags(oldManga, screenModel.migrateFlags.get()) }
|
||||
val selectedFlags = remember { flags.map { it.isDefaultSelected }.toMutableStateList() }
|
||||
|
||||
if (state.isMigrating) {
|
||||
LoadingScreen(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)),
|
||||
)
|
||||
} else {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = {
|
||||
Text(text = stringResource(MR.strings.migration_dialog_what_to_include))
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
flags.forEachIndexed { index, flag ->
|
||||
LabeledCheckbox(
|
||||
label = stringResource(flag.titleId),
|
||||
checked = selectedFlags[index],
|
||||
onCheckedChange = { selectedFlags[index] = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall),
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
onClickTitle()
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(MR.strings.action_show_manga))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launchIO {
|
||||
screenModel.migrateManga(
|
||||
oldManga,
|
||||
newManga,
|
||||
false,
|
||||
MigrationFlags.getSelectedFlagsBitMap(selectedFlags, flags),
|
||||
)
|
||||
withUIContext { onPopScreen() }
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(MR.strings.copy))
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
scope.launchIO {
|
||||
screenModel.migrateManga(
|
||||
oldManga,
|
||||
newManga,
|
||||
true,
|
||||
MigrationFlags.getSelectedFlagsBitMap(selectedFlags, flags),
|
||||
)
|
||||
|
||||
withUIContext { onPopScreen() }
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(MR.strings.migrate))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal class MigrateDialogScreenModel(
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
private val downloadManager: DownloadManager = Injekt.get(),
|
||||
private val updateManga: UpdateManga = Injekt.get(),
|
||||
private val getChaptersByMangaId: GetChaptersByMangaId = Injekt.get(),
|
||||
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(),
|
||||
private val updateChapter: UpdateChapter = Injekt.get(),
|
||||
private val getCategories: GetCategories = Injekt.get(),
|
||||
private val setMangaCategories: SetMangaCategories = Injekt.get(),
|
||||
private val getTracks: GetTracks = Injekt.get(),
|
||||
private val insertTrack: InsertTrack = Injekt.get(),
|
||||
private val coverCache: CoverCache = Injekt.get(),
|
||||
private val preferenceStore: PreferenceStore = Injekt.get(),
|
||||
) : StateScreenModel<MigrateDialogScreenModel.State>(State()) {
|
||||
|
||||
val migrateFlags: Preference<Int> by lazy {
|
||||
preferenceStore.getInt("migrate_flags", Int.MAX_VALUE)
|
||||
}
|
||||
|
||||
private val enhancedServices by lazy {
|
||||
Injekt.get<TrackerManager>().trackers.filterIsInstance<EnhancedTracker>()
|
||||
}
|
||||
|
||||
suspend fun migrateManga(
|
||||
oldManga: Manga,
|
||||
newManga: Manga,
|
||||
replace: Boolean,
|
||||
flags: Int,
|
||||
) {
|
||||
migrateFlags.set(flags)
|
||||
val source = sourceManager.get(newManga.source) ?: return
|
||||
val prevSource = sourceManager.get(oldManga.source)
|
||||
|
||||
mutableState.update { it.copy(isMigrating = true) }
|
||||
|
||||
try {
|
||||
val chapters = source.getChapterList(newManga.toSManga())
|
||||
|
||||
migrateMangaInternal(
|
||||
oldSource = prevSource,
|
||||
newSource = source,
|
||||
oldManga = oldManga,
|
||||
newManga = newManga,
|
||||
sourceChapters = chapters,
|
||||
replace = replace,
|
||||
flags = flags,
|
||||
)
|
||||
} catch (_: Throwable) {
|
||||
// Explicitly stop if an error occurred; the dialog normally gets popped at the end
|
||||
// anyway
|
||||
mutableState.update { it.copy(isMigrating = false) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun migrateMangaInternal(
|
||||
oldSource: Source?,
|
||||
newSource: Source,
|
||||
oldManga: Manga,
|
||||
newManga: Manga,
|
||||
sourceChapters: List<SChapter>,
|
||||
replace: Boolean,
|
||||
flags: Int,
|
||||
) {
|
||||
val migrateChapters = MigrationFlags.hasChapters(flags)
|
||||
val migrateCategories = MigrationFlags.hasCategories(flags)
|
||||
val migrateCustomCover = MigrationFlags.hasCustomCover(flags)
|
||||
val deleteDownloaded = MigrationFlags.hasDeleteDownloaded(flags)
|
||||
|
||||
try {
|
||||
syncChaptersWithSource.await(sourceChapters, newManga, newSource)
|
||||
} catch (_: Exception) {
|
||||
// Worst case, chapters won't be synced
|
||||
}
|
||||
|
||||
// Update chapters read, bookmark and dateFetch
|
||||
if (migrateChapters) {
|
||||
val prevMangaChapters = getChaptersByMangaId.await(oldManga.id)
|
||||
val mangaChapters = getChaptersByMangaId.await(newManga.id)
|
||||
|
||||
val maxChapterRead = prevMangaChapters
|
||||
.filter { it.read }
|
||||
.maxOfOrNull { it.chapterNumber }
|
||||
|
||||
val updatedMangaChapters = mangaChapters.map { mangaChapter ->
|
||||
var updatedChapter = mangaChapter
|
||||
if (updatedChapter.isRecognizedNumber) {
|
||||
val prevChapter = prevMangaChapters
|
||||
.find { it.isRecognizedNumber && it.chapterNumber == updatedChapter.chapterNumber }
|
||||
|
||||
if (prevChapter != null) {
|
||||
updatedChapter = updatedChapter.copy(
|
||||
dateFetch = prevChapter.dateFetch,
|
||||
bookmark = prevChapter.bookmark,
|
||||
)
|
||||
}
|
||||
|
||||
if (maxChapterRead != null && updatedChapter.chapterNumber <= maxChapterRead) {
|
||||
updatedChapter = updatedChapter.copy(read = true)
|
||||
}
|
||||
}
|
||||
|
||||
updatedChapter
|
||||
}
|
||||
|
||||
val chapterUpdates = updatedMangaChapters.map { it.toChapterUpdate() }
|
||||
updateChapter.awaitAll(chapterUpdates)
|
||||
}
|
||||
|
||||
// Update categories
|
||||
if (migrateCategories) {
|
||||
val categoryIds = getCategories.await(oldManga.id).map { it.id }
|
||||
setMangaCategories.await(newManga.id, categoryIds)
|
||||
}
|
||||
|
||||
// Update track
|
||||
getTracks.await(oldManga.id).mapNotNull { track ->
|
||||
val updatedTrack = track.copy(mangaId = newManga.id)
|
||||
|
||||
val service = enhancedServices
|
||||
.firstOrNull { it.isTrackFrom(updatedTrack, oldManga, oldSource) }
|
||||
|
||||
if (service != null) {
|
||||
service.migrateTrack(updatedTrack, newManga, newSource)
|
||||
} else {
|
||||
updatedTrack
|
||||
}
|
||||
}
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { insertTrack.awaitAll(it) }
|
||||
|
||||
// Delete downloaded
|
||||
if (deleteDownloaded) {
|
||||
if (oldSource != null) {
|
||||
downloadManager.deleteManga(oldManga, oldSource)
|
||||
}
|
||||
}
|
||||
|
||||
if (replace) {
|
||||
updateManga.awaitUpdateFavorite(oldManga.id, favorite = false)
|
||||
}
|
||||
|
||||
// Update custom cover (recheck if custom cover exists)
|
||||
if (migrateCustomCover && oldManga.hasCustomCover()) {
|
||||
coverCache.setCustomCoverToCache(newManga, coverCache.getCustomCoverFile(oldManga.id).inputStream())
|
||||
}
|
||||
|
||||
updateManga.await(
|
||||
MangaUpdate(
|
||||
id = newManga.id,
|
||||
favorite = true,
|
||||
chapterFlags = oldManga.chapterFlags,
|
||||
viewerFlags = oldManga.viewerFlags,
|
||||
dateAdded = if (replace) oldManga.dateAdded else Instant.now().toEpochMilli(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class State(
|
||||
val isMigrating: Boolean = false,
|
||||
)
|
||||
}
|
@@ -8,7 +8,10 @@ import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import eu.kanade.presentation.browse.MigrateSearchScreen
|
||||
import eu.kanade.presentation.util.Screen
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
import mihon.feature.migration.dialog.MigrateMangaDialog
|
||||
import mihon.feature.migration.list.MigrationListScreen
|
||||
|
||||
class MigrateSearchScreen(private val mangaId: Long) : Screen() {
|
||||
|
||||
@@ -19,42 +22,46 @@ class MigrateSearchScreen(private val mangaId: Long) : Screen() {
|
||||
val screenModel = rememberScreenModel { MigrateSearchScreenModel(mangaId = mangaId) }
|
||||
val state by screenModel.state.collectAsState()
|
||||
|
||||
val dialogScreenModel = rememberScreenModel { MigrateSearchScreenDialogScreenModel(mangaId = mangaId) }
|
||||
val dialogState by dialogScreenModel.state.collectAsState()
|
||||
|
||||
MigrateSearchScreen(
|
||||
state = state,
|
||||
fromSourceId = dialogState.manga?.source,
|
||||
fromSourceId = state.from?.source,
|
||||
navigateUp = navigator::pop,
|
||||
onChangeSearchQuery = screenModel::updateSearchQuery,
|
||||
onSearch = { screenModel.search() },
|
||||
getManga = { screenModel.getManga(it) },
|
||||
onChangeSearchFilter = screenModel::setSourceFilter,
|
||||
onToggleResults = screenModel::toggleFilterResults,
|
||||
onClickSource = {
|
||||
navigator.push(SourceSearchScreen(dialogState.manga!!, it.id, state.searchQuery))
|
||||
onClickSource = { navigator.push(MigrateSourceSearchScreen(state.from!!, it.id, state.searchQuery)) },
|
||||
onClickItem = {
|
||||
val migrateListScreen = navigator.items
|
||||
.filterIsInstance<MigrationListScreen>()
|
||||
.lastOrNull()
|
||||
|
||||
if (migrateListScreen == null) {
|
||||
screenModel.setMigrateDialog(mangaId, it)
|
||||
} else {
|
||||
migrateListScreen.addMatchOverride(current = mangaId, target = it.id)
|
||||
navigator.popUntil { screen -> screen is MigrationListScreen }
|
||||
}
|
||||
},
|
||||
onClickItem = { dialogScreenModel.setDialog(MigrateSearchScreenDialogScreenModel.Dialog.Migrate(it)) },
|
||||
onLongClickItem = { navigator.push(MangaScreen(it.id, true)) },
|
||||
)
|
||||
|
||||
when (val dialog = dialogState.dialog) {
|
||||
is MigrateSearchScreenDialogScreenModel.Dialog.Migrate -> {
|
||||
MigrateDialog(
|
||||
oldManga = dialogState.manga!!,
|
||||
newManga = dialog.manga,
|
||||
screenModel = rememberScreenModel { MigrateDialogScreenModel() },
|
||||
onDismissRequest = { dialogScreenModel.setDialog(null) },
|
||||
onClickTitle = {
|
||||
navigator.push(MangaScreen(dialog.manga.id, true))
|
||||
},
|
||||
onPopScreen = {
|
||||
when (val dialog = state.dialog) {
|
||||
is SearchScreenModel.Dialog.Migrate -> {
|
||||
MigrateMangaDialog(
|
||||
current = dialog.current,
|
||||
target = dialog.target,
|
||||
// Initiated from the context of [dialog.current] so we show [dialog.target].
|
||||
onClickTitle = { navigator.push(MangaScreen(dialog.target.id, true)) },
|
||||
onDismissRequest = { screenModel.clearDialog() },
|
||||
onComplete = {
|
||||
if (navigator.lastItem is MangaScreen) {
|
||||
val lastItem = navigator.lastItem
|
||||
navigator.popUntil { navigator.items.contains(lastItem) }
|
||||
navigator.push(MangaScreen(dialog.manga.id))
|
||||
navigator.push(MangaScreen(dialog.target.id))
|
||||
} else {
|
||||
navigator.replace(MangaScreen(dialog.manga.id))
|
||||
navigator.replace(MangaScreen(dialog.target.id))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
@@ -1,43 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.migration.search
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import tachiyomi.domain.manga.interactor.GetManga
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class MigrateSearchScreenDialogScreenModel(
|
||||
val mangaId: Long,
|
||||
getManga: GetManga = Injekt.get(),
|
||||
) : StateScreenModel<MigrateSearchScreenDialogScreenModel.State>(State()) {
|
||||
|
||||
init {
|
||||
screenModelScope.launch {
|
||||
val manga = getManga.await(mangaId)!!
|
||||
|
||||
mutableState.update {
|
||||
it.copy(manga = manga)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setDialog(dialog: Dialog?) {
|
||||
mutableState.update {
|
||||
it.copy(dialog = dialog)
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class State(
|
||||
val manga: Manga? = null,
|
||||
val dialog: Dialog? = null,
|
||||
)
|
||||
|
||||
sealed interface Dialog {
|
||||
data class Migrate(val manga: Manga) : Dialog
|
||||
}
|
||||
}
|
@@ -1,26 +1,39 @@
|
||||
package eu.kanade.tachiyomi.ui.browse.migration.search
|
||||
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchItemResult
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SourceFilter
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import tachiyomi.domain.manga.interactor.GetManga
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class MigrateSearchScreenModel(
|
||||
val mangaId: Long,
|
||||
getManga: GetManga = Injekt.get(),
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
private val sourcePreferences: SourcePreferences = Injekt.get(),
|
||||
) : SearchScreenModel() {
|
||||
|
||||
private val migrationSources by lazy { sourcePreferences.migrationSources().get() }
|
||||
|
||||
override val sortComparator = { map: Map<CatalogueSource, SearchItemResult> ->
|
||||
compareBy<CatalogueSource>(
|
||||
{ (map[it] as? SearchItemResult.Success)?.isEmpty ?: true },
|
||||
{ migrationSources.indexOf(it.id) },
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
screenModelScope.launch {
|
||||
val manga = getManga.await(mangaId)!!
|
||||
mutableState.update {
|
||||
it.copy(
|
||||
fromSourceId = manga.source,
|
||||
from = manga,
|
||||
searchQuery = manga.title,
|
||||
)
|
||||
}
|
||||
@@ -29,14 +42,6 @@ class MigrateSearchScreenModel(
|
||||
}
|
||||
|
||||
override fun getEnabledSources(): List<CatalogueSource> {
|
||||
return super.getEnabledSources()
|
||||
.filter { state.value.sourceFilter != SourceFilter.PinnedOnly || "${it.id}" in pinnedSources }
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ it.id != state.value.fromSourceId },
|
||||
{ "${it.id}" !in pinnedSources },
|
||||
{ "${it.name.lowercase()} (${it.lang})" },
|
||||
),
|
||||
)
|
||||
return migrationSources.mapNotNull { sourceManager.get(it) as? CatalogueSource }
|
||||
}
|
||||
}
|
||||
|
@@ -28,6 +28,8 @@ import eu.kanade.tachiyomi.ui.home.HomeScreen
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
import eu.kanade.tachiyomi.ui.webview.WebViewScreen
|
||||
import kotlinx.coroutines.launch
|
||||
import mihon.feature.migration.dialog.MigrateMangaDialog
|
||||
import mihon.feature.migration.list.MigrationListScreen
|
||||
import mihon.presentation.core.util.collectAsLazyPagingItems
|
||||
import tachiyomi.core.common.Constants
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
@@ -38,8 +40,8 @@ import tachiyomi.presentation.core.i18n.stringResource
|
||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||
import tachiyomi.source.local.LocalSource
|
||||
|
||||
data class SourceSearchScreen(
|
||||
private val oldManga: Manga,
|
||||
data class MigrateSourceSearchScreen(
|
||||
private val currentManga: Manga,
|
||||
private val sourceId: Long,
|
||||
private val query: String?,
|
||||
) : Screen() {
|
||||
@@ -82,7 +84,16 @@ data class SourceSearchScreen(
|
||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
) { paddingValues ->
|
||||
val openMigrateDialog: (Manga) -> Unit = {
|
||||
screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(newManga = it, oldManga = oldManga))
|
||||
val migrateListScreen = navigator.items
|
||||
.filterIsInstance<MigrationListScreen>()
|
||||
.lastOrNull()
|
||||
|
||||
if (migrateListScreen == null) {
|
||||
screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(target = it, current = currentManga))
|
||||
} else {
|
||||
migrateListScreen.addMatchOverride(current = currentManga.id, target = it.id)
|
||||
navigator.popUntil { screen -> screen is MigrationListScreen }
|
||||
}
|
||||
}
|
||||
BrowseSourceContent(
|
||||
source = screenModel.source,
|
||||
@@ -120,17 +131,17 @@ data class SourceSearchScreen(
|
||||
)
|
||||
}
|
||||
is BrowseSourceScreenModel.Dialog.Migrate -> {
|
||||
MigrateDialog(
|
||||
oldManga = oldManga,
|
||||
newManga = dialog.newManga,
|
||||
screenModel = rememberScreenModel { MigrateDialogScreenModel() },
|
||||
MigrateMangaDialog(
|
||||
current = currentManga,
|
||||
target = dialog.target,
|
||||
// Initiated from the context of [currentManga] so we show [dialog.target].
|
||||
onClickTitle = { navigator.push(MangaScreen(dialog.target.id)) },
|
||||
onDismissRequest = onDismissRequest,
|
||||
onClickTitle = { navigator.push(MangaScreen(dialog.newManga.id)) },
|
||||
onPopScreen = {
|
||||
onComplete = {
|
||||
scope.launch {
|
||||
navigator.popUntilRoot()
|
||||
HomeScreen.openTab(HomeScreen.Tab.Browse())
|
||||
navigator.push(MangaScreen(dialog.newManga.id))
|
||||
navigator.push(MangaScreen(dialog.target.id))
|
||||
}
|
||||
},
|
||||
)
|
@@ -28,6 +28,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
@@ -46,8 +47,6 @@ import eu.kanade.presentation.util.Screen
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.browse.extension.details.SourcePreferencesScreen
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialog
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialogScreenModel
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel.Listing
|
||||
import eu.kanade.tachiyomi.ui.category.CategoryScreen
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
@@ -55,6 +54,7 @@ import eu.kanade.tachiyomi.ui.webview.WebViewScreen
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import mihon.feature.migration.dialog.MigrateMangaDialog
|
||||
import mihon.presentation.core.util.collectAsLazyPagingItems
|
||||
import tachiyomi.core.common.Constants
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
@@ -124,7 +124,11 @@ data class BrowseSourceScreen(
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Column(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.pointerInput(Unit) {},
|
||||
) {
|
||||
BrowseSourceToolbar(
|
||||
searchQuery = state.toolbarQuery,
|
||||
onSearchQueryChange = screenModel::setToolbarQuery,
|
||||
@@ -219,14 +223,11 @@ data class BrowseSourceScreen(
|
||||
onMangaClick = { navigator.push((MangaScreen(it.id, true))) },
|
||||
onMangaLongClick = { manga ->
|
||||
scope.launchIO {
|
||||
val duplicateManga = screenModel.getDuplicateLibraryManga(manga)
|
||||
val duplicates = screenModel.getDuplicateLibraryManga(manga)
|
||||
when {
|
||||
manga.favorite -> screenModel.setDialog(BrowseSourceScreenModel.Dialog.RemoveManga(manga))
|
||||
duplicateManga != null -> screenModel.setDialog(
|
||||
BrowseSourceScreenModel.Dialog.AddDuplicateManga(
|
||||
manga,
|
||||
duplicateManga,
|
||||
),
|
||||
duplicates.isNotEmpty() -> screenModel.setDialog(
|
||||
BrowseSourceScreenModel.Dialog.AddDuplicateManga(manga, duplicates),
|
||||
)
|
||||
else -> screenModel.addFavorite(manga)
|
||||
}
|
||||
@@ -249,25 +250,21 @@ data class BrowseSourceScreen(
|
||||
}
|
||||
is BrowseSourceScreenModel.Dialog.AddDuplicateManga -> {
|
||||
DuplicateMangaDialog(
|
||||
duplicates = dialog.duplicates,
|
||||
onDismissRequest = onDismissRequest,
|
||||
onConfirm = { screenModel.addFavorite(dialog.manga) },
|
||||
onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) },
|
||||
onMigrate = {
|
||||
screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(dialog.manga, dialog.duplicate))
|
||||
},
|
||||
onOpenManga = { navigator.push(MangaScreen(it.id)) },
|
||||
onMigrate = { screenModel.setDialog(BrowseSourceScreenModel.Dialog.Migrate(dialog.manga, it)) },
|
||||
)
|
||||
}
|
||||
|
||||
is BrowseSourceScreenModel.Dialog.Migrate -> {
|
||||
MigrateDialog(
|
||||
oldManga = dialog.oldManga,
|
||||
newManga = dialog.newManga,
|
||||
screenModel = MigrateDialogScreenModel(),
|
||||
MigrateMangaDialog(
|
||||
current = dialog.current,
|
||||
target = dialog.target,
|
||||
// Initiated from the context of [dialog.target] so we show [dialog.current].
|
||||
onClickTitle = { navigator.push(MangaScreen(dialog.current.id)) },
|
||||
onDismissRequest = onDismissRequest,
|
||||
onClickTitle = { navigator.push(MangaScreen(dialog.oldManga.id)) },
|
||||
onPopScreen = {
|
||||
onDismissRequest()
|
||||
},
|
||||
)
|
||||
}
|
||||
is BrowseSourceScreenModel.Dialog.RemoveManga -> {
|
||||
|
@@ -15,7 +15,6 @@ import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import eu.kanade.core.preference.asState
|
||||
import eu.kanade.domain.manga.interactor.UpdateManga
|
||||
import eu.kanade.domain.manga.model.toDomainManga
|
||||
import eu.kanade.domain.source.interactor.GetIncognitoState
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.domain.track.interactor.AddTracks
|
||||
@@ -29,7 +28,6 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
@@ -45,8 +43,8 @@ import tachiyomi.domain.chapter.interactor.SetMangaDefaultChapterFlags
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
import tachiyomi.domain.manga.interactor.GetDuplicateLibraryManga
|
||||
import tachiyomi.domain.manga.interactor.GetManga
|
||||
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.manga.model.MangaWithChapterCount
|
||||
import tachiyomi.domain.manga.model.toMangaUpdate
|
||||
import tachiyomi.domain.source.interactor.GetRemoteManga
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
@@ -68,7 +66,6 @@ class BrowseSourceScreenModel(
|
||||
private val setMangaCategories: SetMangaCategories = Injekt.get(),
|
||||
private val setMangaDefaultChapterFlags: SetMangaDefaultChapterFlags = Injekt.get(),
|
||||
private val getManga: GetManga = Injekt.get(),
|
||||
private val networkToLocalManga: NetworkToLocalManga = Injekt.get(),
|
||||
private val updateManga: UpdateManga = Injekt.get(),
|
||||
private val addTracks: AddTracks = Injekt.get(),
|
||||
private val getIncognitoState: GetIncognitoState = Injekt.get(),
|
||||
@@ -110,12 +107,11 @@ class BrowseSourceScreenModel(
|
||||
.distinctUntilChanged()
|
||||
.map { listing ->
|
||||
Pager(PagingConfig(pageSize = 25)) {
|
||||
getRemoteManga.subscribe(sourceId, listing.query ?: "", listing.filters)
|
||||
getRemoteManga(sourceId, listing.query ?: "", listing.filters)
|
||||
}.flow.map { pagingData ->
|
||||
pagingData.map {
|
||||
networkToLocalManga.await(it.toDomainManga(sourceId))
|
||||
.let { localManga -> getManga.subscribe(localManga.url, localManga.source) }
|
||||
.filterNotNull()
|
||||
pagingData.map { manga ->
|
||||
getManga.subscribe(manga.url, manga.source)
|
||||
.map { it ?: manga }
|
||||
.stateIn(ioCoroutineScope)
|
||||
}
|
||||
.filter { !hideInLibraryItems || !it.value.favorite }
|
||||
@@ -289,8 +285,8 @@ class BrowseSourceScreenModel(
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
suspend fun getDuplicateLibraryManga(manga: Manga): Manga? {
|
||||
return getDuplicateLibraryManga.await(manga).getOrNull(0)
|
||||
suspend fun getDuplicateLibraryManga(manga: Manga): List<MangaWithChapterCount> {
|
||||
return getDuplicateLibraryManga.invoke(manga)
|
||||
}
|
||||
|
||||
private fun moveMangaToCategories(manga: Manga, vararg categories: Category) {
|
||||
@@ -340,12 +336,12 @@ class BrowseSourceScreenModel(
|
||||
sealed interface Dialog {
|
||||
data object Filter : Dialog
|
||||
data class RemoveManga(val manga: Manga) : Dialog
|
||||
data class AddDuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
|
||||
data class AddDuplicateManga(val manga: Manga, val duplicates: List<MangaWithChapterCount>) : Dialog
|
||||
data class ChangeMangaCategory(
|
||||
val manga: Manga,
|
||||
val initialSelection: ImmutableList<CheckboxState.State<Category>>,
|
||||
) : Dialog
|
||||
data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog
|
||||
data class Migrate(val target: Manga, val current: Manga) : Dialog
|
||||
}
|
||||
|
||||
@Immutable
|
||||
|
@@ -128,22 +128,24 @@ private fun FilterItem(filter: Filter<*>, onUpdate: () -> Unit) {
|
||||
) {
|
||||
Column {
|
||||
filter.values.mapIndexed { index, item ->
|
||||
val sortAscending = filter.state?.ascending
|
||||
?.takeIf { index == filter.state?.index }
|
||||
SortItem(
|
||||
label = item,
|
||||
sortDescending = filter.state?.ascending?.not()
|
||||
?.takeIf { index == filter.state?.index },
|
||||
) {
|
||||
sortDescending = if (sortAscending != null) !sortAscending else null,
|
||||
onClick = {
|
||||
val ascending = if (index == filter.state?.index) {
|
||||
!filter.state!!.ascending
|
||||
} else {
|
||||
filter.state!!.ascending
|
||||
filter.state?.ascending ?: true
|
||||
}
|
||||
filter.state = Filter.Sort.Selection(
|
||||
index = index,
|
||||
ascending = ascending,
|
||||
)
|
||||
onUpdate()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -5,7 +5,6 @@ import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.produceState
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import eu.kanade.domain.manga.model.toDomainManga
|
||||
import eu.kanade.domain.source.service.SourcePreferences
|
||||
import eu.kanade.presentation.util.ioCoroutineScope
|
||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
@@ -24,7 +23,9 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import mihon.domain.manga.model.toDomainManga
|
||||
import tachiyomi.core.common.preference.toggle
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.domain.manga.interactor.GetManga
|
||||
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
@@ -55,7 +56,7 @@ abstract class SearchScreenModel(
|
||||
|
||||
protected var extensionFilter: String? = null
|
||||
|
||||
private val sortComparator = { map: Map<CatalogueSource, SearchItemResult> ->
|
||||
open val sortComparator = { map: Map<CatalogueSource, SearchItemResult> ->
|
||||
compareBy<CatalogueSource>(
|
||||
{ (map[it] as? SearchItemResult.Success)?.isEmpty ?: true },
|
||||
{ "${it.id}" !in pinnedSources },
|
||||
@@ -165,9 +166,10 @@ abstract class SearchScreenModel(
|
||||
source.getSearchManga(1, query, source.getFilterList())
|
||||
}
|
||||
|
||||
val titles = page.mangas.map {
|
||||
networkToLocalManga.await(it.toDomainManga(source.id))
|
||||
}
|
||||
val titles = page.mangas
|
||||
.map { it.toDomainManga(source.id) }
|
||||
.distinctBy { it.url }
|
||||
.let { networkToLocalManga(it) }
|
||||
|
||||
if (isActive) {
|
||||
updateItem(source, SearchItemResult.Success(titles))
|
||||
@@ -200,18 +202,34 @@ abstract class SearchScreenModel(
|
||||
updateItems(newItems)
|
||||
}
|
||||
|
||||
fun setMigrateDialog(currentId: Long, target: Manga) {
|
||||
screenModelScope.launchIO {
|
||||
val current = getManga.await(currentId) ?: return@launchIO
|
||||
mutableState.update { it.copy(dialog = Dialog.Migrate(target, current)) }
|
||||
}
|
||||
}
|
||||
|
||||
fun clearDialog() {
|
||||
mutableState.update { it.copy(dialog = null) }
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class State(
|
||||
val fromSourceId: Long? = null,
|
||||
val from: Manga? = null,
|
||||
val searchQuery: String? = null,
|
||||
val sourceFilter: SourceFilter = SourceFilter.PinnedOnly,
|
||||
val onlyShowHasResults: Boolean = false,
|
||||
val items: PersistentMap<CatalogueSource, SearchItemResult> = persistentMapOf(),
|
||||
val dialog: Dialog? = null,
|
||||
) {
|
||||
val progress: Int = items.count { it.value !is SearchItemResult.Loading }
|
||||
val total: Int = items.size
|
||||
val filteredItems = items.filter { (_, result) -> result.isVisible(onlyShowHasResults) }
|
||||
}
|
||||
|
||||
sealed interface Dialog {
|
||||
data class Migrate(val target: Manga, val current: Manga) : Dialog
|
||||
}
|
||||
}
|
||||
|
||||
enum class SourceFilter {
|
||||
|
@@ -4,18 +4,16 @@ import androidx.compose.runtime.Immutable
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
|
||||
import eu.kanade.domain.manga.model.toDomainManga
|
||||
import eu.kanade.domain.manga.model.toSManga
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ResolvableSource
|
||||
import eu.kanade.tachiyomi.source.online.UriType
|
||||
import kotlinx.coroutines.flow.update
|
||||
import mihon.domain.manga.model.toDomainManga
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.domain.chapter.interactor.GetChapterByUrlAndMangaId
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
import tachiyomi.domain.manga.interactor.GetMangaByUrlAndSourceId
|
||||
import tachiyomi.domain.manga.interactor.NetworkToLocalManga
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
@@ -27,7 +25,6 @@ class DeepLinkScreenModel(
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
private val networkToLocalManga: NetworkToLocalManga = Injekt.get(),
|
||||
private val getChapterByUrlAndMangaId: GetChapterByUrlAndMangaId = Injekt.get(),
|
||||
private val getMangaByUrlAndSourceId: GetMangaByUrlAndSourceId = Injekt.get(),
|
||||
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(),
|
||||
) : StateScreenModel<DeepLinkScreenModel.State>(State.Loading) {
|
||||
|
||||
@@ -38,7 +35,7 @@ class DeepLinkScreenModel(
|
||||
.firstOrNull { it.getUriType(query) != UriType.Unknown }
|
||||
|
||||
val manga = source?.getManga(query)?.let {
|
||||
getMangaFromSManga(it, source.id)
|
||||
networkToLocalManga(it.toDomainManga(source.id))
|
||||
}
|
||||
|
||||
val chapter = if (source?.getUriType(query) == UriType.Chapter && manga != null) {
|
||||
@@ -73,11 +70,6 @@ class DeepLinkScreenModel(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getMangaFromSManga(sManga: SManga, sourceId: Long): Manga {
|
||||
return getMangaByUrlAndSourceId.await(sManga.url, sourceId)
|
||||
?: networkToLocalManga.await(sManga.toDomainManga(sourceId))
|
||||
}
|
||||
|
||||
sealed interface State {
|
||||
@Immutable
|
||||
data object Loading : State
|
||||
|
@@ -40,10 +40,10 @@ import tachiyomi.domain.library.service.LibraryPreferences
|
||||
import tachiyomi.domain.manga.interactor.GetDuplicateLibraryManga
|
||||
import tachiyomi.domain.manga.interactor.GetManga
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.manga.model.MangaWithChapterCount
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import kotlin.collections.map
|
||||
|
||||
class HistoryScreenModel(
|
||||
private val addTracks: AddTracks = Injekt.get(),
|
||||
@@ -175,9 +175,9 @@ class HistoryScreenModel(
|
||||
screenModelScope.launchIO {
|
||||
val manga = getManga.await(mangaId) ?: return@launchIO
|
||||
|
||||
val duplicate = getDuplicateLibraryManga.await(manga).getOrNull(0)
|
||||
if (duplicate != null) {
|
||||
mutableState.update { it.copy(dialog = Dialog.DuplicateManga(manga, duplicate)) }
|
||||
val duplicates = getDuplicateLibraryManga(manga)
|
||||
if (duplicates.isNotEmpty()) {
|
||||
mutableState.update { it.copy(dialog = Dialog.DuplicateManga(manga, duplicates)) }
|
||||
return@launchIO
|
||||
}
|
||||
|
||||
@@ -216,9 +216,9 @@ class HistoryScreenModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun showMigrateDialog(currentManga: Manga, duplicate: Manga) {
|
||||
fun showMigrateDialog(target: Manga, current: Manga) {
|
||||
mutableState.update { currentState ->
|
||||
currentState.copy(dialog = Dialog.Migrate(newManga = currentManga, oldManga = duplicate))
|
||||
currentState.copy(dialog = Dialog.Migrate(target = target, current = current))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,12 +247,12 @@ class HistoryScreenModel(
|
||||
sealed interface Dialog {
|
||||
data object DeleteAll : Dialog
|
||||
data class Delete(val history: HistoryWithRelations) : Dialog
|
||||
data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
|
||||
data class DuplicateManga(val manga: Manga, val duplicates: List<MangaWithChapterCount>) : Dialog
|
||||
data class ChangeCategory(
|
||||
val manga: Manga,
|
||||
val initialSelection: ImmutableList<CheckboxState<Category>>,
|
||||
) : Dialog
|
||||
data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog
|
||||
data class Migrate(val target: Manga, val current: Manga) : Dialog
|
||||
}
|
||||
|
||||
sealed interface Event {
|
||||
|
@@ -23,8 +23,6 @@ import eu.kanade.presentation.history.components.HistoryDeleteDialog
|
||||
import eu.kanade.presentation.manga.DuplicateMangaDialog
|
||||
import eu.kanade.presentation.util.Tab
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialog
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialogScreenModel
|
||||
import eu.kanade.tachiyomi.ui.category.CategoryScreen
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaScreen
|
||||
@@ -32,6 +30,7 @@ import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import mihon.feature.migration.dialog.MigrateMangaDialog
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.domain.chapter.model.Chapter
|
||||
import tachiyomi.i18n.MR
|
||||
@@ -98,14 +97,11 @@ data object HistoryTab : Tab {
|
||||
}
|
||||
is HistoryScreenModel.Dialog.DuplicateManga -> {
|
||||
DuplicateMangaDialog(
|
||||
duplicates = dialog.duplicates,
|
||||
onDismissRequest = onDismissRequest,
|
||||
onConfirm = {
|
||||
screenModel.addFavorite(dialog.manga)
|
||||
},
|
||||
onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) },
|
||||
onMigrate = {
|
||||
screenModel.showMigrateDialog(dialog.manga, dialog.duplicate)
|
||||
},
|
||||
onConfirm = { screenModel.addFavorite(dialog.manga) },
|
||||
onOpenManga = { navigator.push(MangaScreen(it.id)) },
|
||||
onMigrate = { screenModel.showMigrateDialog(dialog.manga, it) },
|
||||
)
|
||||
}
|
||||
is HistoryScreenModel.Dialog.ChangeCategory -> {
|
||||
@@ -119,13 +115,12 @@ data object HistoryTab : Tab {
|
||||
)
|
||||
}
|
||||
is HistoryScreenModel.Dialog.Migrate -> {
|
||||
MigrateDialog(
|
||||
oldManga = dialog.oldManga,
|
||||
newManga = dialog.newManga,
|
||||
screenModel = MigrateDialogScreenModel(),
|
||||
MigrateMangaDialog(
|
||||
current = dialog.current,
|
||||
target = dialog.target,
|
||||
// Initiated from the context of [dialog.target] so we show [dialog.current].
|
||||
onClickTitle = { navigator.push(MangaScreen(dialog.current.id)) },
|
||||
onDismissRequest = onDismissRequest,
|
||||
onClickTitle = { navigator.push(MangaScreen(dialog.oldManga.id)) },
|
||||
onPopScreen = { navigator.replace(MangaScreen(dialog.newManga.id)) },
|
||||
)
|
||||
}
|
||||
null -> {}
|
||||
|
@@ -1,8 +1,10 @@
|
||||
package eu.kanade.tachiyomi.ui.home
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
@@ -14,7 +16,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
@@ -23,13 +24,19 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import androidx.compose.ui.util.lerp
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
|
||||
@@ -49,6 +56,7 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import soup.compose.material.motion.MotionConstants
|
||||
import soup.compose.material.motion.animation.materialFadeThroughIn
|
||||
import soup.compose.material.motion.animation.materialFadeThroughOut
|
||||
import tachiyomi.domain.library.service.LibraryPreferences
|
||||
@@ -57,8 +65,10 @@ import tachiyomi.presentation.core.components.material.NavigationBar
|
||||
import tachiyomi.presentation.core.components.material.NavigationRail
|
||||
import tachiyomi.presentation.core.components.material.Scaffold
|
||||
import tachiyomi.presentation.core.i18n.pluralStringResource
|
||||
import tachiyomi.presentation.core.util.PredictiveBack
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
object HomeScreen : Screen() {
|
||||
|
||||
@@ -66,8 +76,11 @@ object HomeScreen : Screen() {
|
||||
private val openTabEvent = Channel<Tab>()
|
||||
private val showBottomNavEvent = Channel<Boolean>()
|
||||
|
||||
private const val TAB_FADE_DURATION = 200
|
||||
private const val TAB_NAVIGATOR_KEY = "HomeTabs"
|
||||
@Suppress("ConstPropertyName")
|
||||
private const val TabFadeDuration = 200
|
||||
|
||||
@Suppress("ConstPropertyName")
|
||||
private const val TabNavigatorKey = "HomeTabs"
|
||||
|
||||
private val TABS = listOf(
|
||||
LibraryTab,
|
||||
@@ -80,9 +93,11 @@ object HomeScreen : Screen() {
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
|
||||
TabNavigator(
|
||||
tab = LibraryTab,
|
||||
key = TAB_NAVIGATOR_KEY,
|
||||
key = TabNavigatorKey,
|
||||
) { tabNavigator ->
|
||||
// Provide usable navigator to content screen
|
||||
CompositionLocalProvider(LocalNavigator provides navigator) {
|
||||
@@ -119,16 +134,17 @@ object HomeScreen : Screen() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(contentPadding)
|
||||
.consumeWindowInsets(contentPadding),
|
||||
.consumeWindowInsets(contentPadding)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
},
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = tabNavigator.current,
|
||||
transitionSpec = {
|
||||
materialFadeThroughIn(
|
||||
initialScale = 1f,
|
||||
durationMillis = TAB_FADE_DURATION,
|
||||
) togetherWith
|
||||
materialFadeThroughOut(durationMillis = TAB_FADE_DURATION)
|
||||
materialFadeThroughIn(initialScale = 1f, durationMillis = TabFadeDuration) togetherWith
|
||||
materialFadeThroughOut(durationMillis = TabFadeDuration)
|
||||
},
|
||||
label = "tabContent",
|
||||
) {
|
||||
@@ -141,10 +157,32 @@ object HomeScreen : Screen() {
|
||||
}
|
||||
|
||||
val goToLibraryTab = { tabNavigator.current = LibraryTab }
|
||||
BackHandler(
|
||||
enabled = tabNavigator.current != LibraryTab,
|
||||
onBack = goToLibraryTab,
|
||||
)
|
||||
|
||||
var handlingBack by remember { mutableStateOf(false) }
|
||||
PredictiveBackHandler(
|
||||
enabled = handlingBack || tabNavigator.current::class != LibraryTab::class,
|
||||
) { progress ->
|
||||
handlingBack = true
|
||||
val currentTab = tabNavigator.current
|
||||
try {
|
||||
progress.collect { backEvent ->
|
||||
scale = lerp(1f, 0.92f, PredictiveBack.transform(backEvent.progress))
|
||||
tabNavigator.current = if (backEvent.progress > 0.25f) TABS[0] else currentTab
|
||||
}
|
||||
goToLibraryTab()
|
||||
} catch (e: CancellationException) {
|
||||
tabNavigator.current = currentTab
|
||||
} finally {
|
||||
animate(
|
||||
initialValue = scale,
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(durationMillis = MotionConstants.DefaultMotionDuration),
|
||||
) { value, _ ->
|
||||
scale = value
|
||||
}
|
||||
handlingBack = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
@@ -289,8 +327,6 @@ object HomeScreen : Screen() {
|
||||
Icon(
|
||||
painter = tab.options.icon!!,
|
||||
contentDescription = tab.options.title,
|
||||
// TODO: https://issuetracker.google.com/u/0/issues/316327367
|
||||
tint = LocalContentColor.current,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -14,6 +14,8 @@ data class LibraryItem(
|
||||
val sourceLanguage: String = "",
|
||||
private val sourceManager: SourceManager = Injekt.get(),
|
||||
) {
|
||||
val id: Long = libraryManga.id
|
||||
|
||||
/**
|
||||
* Checks if a query matches the manga
|
||||
*
|
||||
@@ -23,8 +25,7 @@ data class LibraryItem(
|
||||
fun matches(constraint: String): Boolean {
|
||||
val sourceName by lazy { sourceManager.getOrStub(libraryManga.manga.source).getNameForMangaInfo() }
|
||||
if (constraint.startsWith("id:", true)) {
|
||||
val id = constraint.substringAfter("id:").toLongOrNull()
|
||||
return libraryManga.id == id
|
||||
return id == constraint.substringAfter("id:").toLongOrNull()
|
||||
}
|
||||
return libraryManga.manga.title.contains(constraint, true) ||
|
||||
(libraryManga.manga.author?.contains(constraint, true) ?: false) ||
|
||||
|
@@ -1,19 +1,14 @@
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.util.fastAny
|
||||
import androidx.compose.ui.util.fastDistinctBy
|
||||
import androidx.compose.ui.util.fastFilter
|
||||
import androidx.compose.ui.util.fastMap
|
||||
import androidx.compose.ui.util.fastMapNotNull
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import eu.kanade.core.preference.PreferenceMutableState
|
||||
import eu.kanade.core.preference.asState
|
||||
import eu.kanade.core.util.fastFilterNot
|
||||
import eu.kanade.core.util.fastPartition
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.domain.chapter.interactor.SetReadStatus
|
||||
import eu.kanade.domain.manga.interactor.UpdateManga
|
||||
@@ -29,28 +24,26 @@ import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.chapter.getNextUnread
|
||||
import eu.kanade.tachiyomi.util.removeCovers
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.PersistentList
|
||||
import kotlinx.collections.immutable.mutate
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.dropWhile
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.updateAndGet
|
||||
import mihon.core.common.utils.mutate
|
||||
import tachiyomi.core.common.preference.CheckboxState
|
||||
import tachiyomi.core.common.preference.TriState
|
||||
import tachiyomi.core.common.util.lang.compareToWithCollator
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.core.common.util.lang.launchNonCancellable
|
||||
import tachiyomi.core.common.util.lang.withIOContext
|
||||
import tachiyomi.domain.category.interactor.GetCategories
|
||||
import tachiyomi.domain.category.interactor.SetMangaCategories
|
||||
import tachiyomi.domain.category.model.Category
|
||||
@@ -74,11 +67,6 @@ import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Typealias for the library manga, using the category as keys, and list of manga as values.
|
||||
*/
|
||||
typealias LibraryMap = Map<Category, List<LibraryItem>>
|
||||
|
||||
class LibraryScreenModel(
|
||||
private val getLibraryManga: GetLibraryManga = Injekt.get(),
|
||||
private val getCategories: GetCategories = Injekt.get(),
|
||||
@@ -97,33 +85,55 @@ class LibraryScreenModel(
|
||||
private val trackerManager: TrackerManager = Injekt.get(),
|
||||
) : StateScreenModel<LibraryScreenModel.State>(State()) {
|
||||
|
||||
var activeCategoryIndex: Int by libraryPreferences.lastUsedCategory().asState(screenModelScope)
|
||||
|
||||
init {
|
||||
mutableState.update { state ->
|
||||
state.copy(activeCategoryIndex = libraryPreferences.lastUsedCategory().get())
|
||||
}
|
||||
screenModelScope.launchIO {
|
||||
combine(
|
||||
state.map { it.searchQuery }.distinctUntilChanged().debounce(SEARCH_DEBOUNCE_MILLIS),
|
||||
getLibraryFlow(),
|
||||
getTracksPerManga.subscribe(),
|
||||
getTrackingFilterFlow(),
|
||||
downloadCache.changes,
|
||||
) { searchQuery, library, tracks, trackingFilter, _ ->
|
||||
library
|
||||
.applyFilters(tracks, trackingFilter)
|
||||
.applySort(tracks, trackingFilter.keys)
|
||||
.mapValues { (_, value) ->
|
||||
if (searchQuery != null) {
|
||||
value.filter { it.matches(searchQuery) }
|
||||
} else {
|
||||
value
|
||||
getCategories.subscribe(),
|
||||
getFavoritesFlow(),
|
||||
combine(getTracksPerManga.subscribe(), getTrackingFiltersFlow(), ::Pair),
|
||||
getLibraryItemPreferencesFlow(),
|
||||
) { searchQuery, categories, favorites, (tracksMap, trackingFilters), itemPreferences ->
|
||||
val showSystemCategory = favorites.any { it.libraryManga.categories.contains(0) }
|
||||
val filteredFavorites = favorites
|
||||
.applyFilters(tracksMap, trackingFilters, itemPreferences)
|
||||
.let { if (searchQuery == null) it else it.filter { m -> m.matches(searchQuery) } }
|
||||
|
||||
LibraryData(
|
||||
isInitialized = true,
|
||||
showSystemCategory = showSystemCategory,
|
||||
categories = categories,
|
||||
favorites = filteredFavorites,
|
||||
tracksMap = tracksMap,
|
||||
loggedInTrackerIds = trackingFilters.keys,
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { libraryData ->
|
||||
mutableState.update { state ->
|
||||
state.copy(libraryData = libraryData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
screenModelScope.launchIO {
|
||||
state
|
||||
.dropWhile { !it.libraryData.isInitialized }
|
||||
.map { it.libraryData }
|
||||
.distinctUntilChanged()
|
||||
.map { data ->
|
||||
data.favorites
|
||||
.applyGrouping(data.categories, data.showSystemCategory)
|
||||
.applySort(data.favoritesById, data.tracksMap, data.loggedInTrackerIds)
|
||||
}
|
||||
.collectLatest {
|
||||
mutableState.update { state ->
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
library = it,
|
||||
groupedFavorites = it,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -147,9 +157,8 @@ class LibraryScreenModel(
|
||||
|
||||
combine(
|
||||
getLibraryItemPreferencesFlow(),
|
||||
getTrackingFilterFlow(),
|
||||
) { prefs, trackFilter ->
|
||||
(
|
||||
getTrackingFiltersFlow(),
|
||||
) { prefs, trackFilters ->
|
||||
listOf(
|
||||
prefs.filterDownloaded,
|
||||
prefs.filterUnread,
|
||||
@@ -157,8 +166,9 @@ class LibraryScreenModel(
|
||||
prefs.filterBookmarked,
|
||||
prefs.filterCompleted,
|
||||
prefs.filterIntervalCustom,
|
||||
) + trackFilter.values
|
||||
).any { it != TriState.DISABLED }
|
||||
*trackFilters.values.toTypedArray(),
|
||||
)
|
||||
.any { it != TriState.DISABLED }
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.onEach {
|
||||
@@ -169,19 +179,19 @@ class LibraryScreenModel(
|
||||
.launchIn(screenModelScope)
|
||||
}
|
||||
|
||||
private suspend fun LibraryMap.applyFilters(
|
||||
private fun List<LibraryItem>.applyFilters(
|
||||
trackMap: Map<Long, List<Track>>,
|
||||
trackingFilter: Map<Long, TriState>,
|
||||
): LibraryMap {
|
||||
val prefs = getLibraryItemPreferencesFlow().first()
|
||||
val downloadedOnly = prefs.globalFilterDownloaded
|
||||
val skipOutsideReleasePeriod = prefs.skipOutsideReleasePeriod
|
||||
val filterDownloaded = if (downloadedOnly) TriState.ENABLED_IS else prefs.filterDownloaded
|
||||
val filterUnread = prefs.filterUnread
|
||||
val filterStarted = prefs.filterStarted
|
||||
val filterBookmarked = prefs.filterBookmarked
|
||||
val filterCompleted = prefs.filterCompleted
|
||||
val filterIntervalCustom = prefs.filterIntervalCustom
|
||||
preferences: ItemPreferences,
|
||||
): List<LibraryItem> {
|
||||
val downloadedOnly = preferences.globalFilterDownloaded
|
||||
val skipOutsideReleasePeriod = preferences.skipOutsideReleasePeriod
|
||||
val filterDownloaded = if (downloadedOnly) TriState.ENABLED_IS else preferences.filterDownloaded
|
||||
val filterUnread = preferences.filterUnread
|
||||
val filterStarted = preferences.filterStarted
|
||||
val filterBookmarked = preferences.filterBookmarked
|
||||
val filterCompleted = preferences.filterCompleted
|
||||
val filterIntervalCustom = preferences.filterIntervalCustom
|
||||
|
||||
val isNotLoggedInAnyTrack = trackingFilter.isEmpty()
|
||||
|
||||
@@ -225,7 +235,7 @@ class LibraryScreenModel(
|
||||
if (isNotLoggedInAnyTrack || trackFiltersIsIgnored) return@tracking true
|
||||
|
||||
val mangaTracks = trackMap
|
||||
.mapValues { entry -> entry.value.map { it.trackerId } }[item.libraryManga.id]
|
||||
.mapValues { entry -> entry.value.map { it.trackerId } }[item.id]
|
||||
.orEmpty()
|
||||
|
||||
val isExcluded = excludedTracks.isNotEmpty() && mangaTracks.fastAny { it in excludedTracks }
|
||||
@@ -234,7 +244,7 @@ class LibraryScreenModel(
|
||||
!isExcluded && isIncluded
|
||||
}
|
||||
|
||||
val filterFn: (LibraryItem) -> Boolean = {
|
||||
return fastFilter {
|
||||
filterFnDownloaded(it) &&
|
||||
filterFnUnread(it) &&
|
||||
filterFnStarted(it) &&
|
||||
@@ -243,13 +253,31 @@ class LibraryScreenModel(
|
||||
filterFnIntervalCustom(it) &&
|
||||
filterFnTracking(it)
|
||||
}
|
||||
|
||||
return mapValues { (_, value) -> value.fastFilter(filterFn) }
|
||||
}
|
||||
|
||||
private fun LibraryMap.applySort(trackMap: Map<Long, List<Track>>, loggedInTrackerIds: Set<Long>): LibraryMap {
|
||||
val sortAlphabetically: (LibraryItem, LibraryItem) -> Int = { i1, i2 ->
|
||||
i1.libraryManga.manga.title.lowercase().compareToWithCollator(i2.libraryManga.manga.title.lowercase())
|
||||
private fun List<LibraryItem>.applyGrouping(
|
||||
categories: List<Category>,
|
||||
showSystemCategory: Boolean,
|
||||
): Map<Category, List</* LibraryItem */ Long>> {
|
||||
val groupCache = mutableMapOf</* Category */ Long, MutableList</* LibraryItem */ Long>>()
|
||||
forEach { item ->
|
||||
item.libraryManga.categories.forEach { categoryId ->
|
||||
groupCache.getOrPut(categoryId) { mutableListOf() }.add(item.id)
|
||||
}
|
||||
}
|
||||
return categories.filter { showSystemCategory || !it.isSystemCategory }
|
||||
.associateWith { groupCache[it.id]?.toList().orEmpty() }
|
||||
}
|
||||
|
||||
private fun Map<Category, List</* LibraryItem */ Long>>.applySort(
|
||||
favoritesById: Map<Long, LibraryItem>,
|
||||
trackMap: Map<Long, List<Track>>,
|
||||
loggedInTrackerIds: Set<Long>,
|
||||
): Map<Category, List</* LibraryItem */ Long>> {
|
||||
val sortAlphabetically: (LibraryItem, LibraryItem) -> Int = { manga1, manga2 ->
|
||||
val title1 = manga1.libraryManga.manga.title.lowercase()
|
||||
val title2 = manga2.libraryManga.manga.title.lowercase()
|
||||
title1.compareToWithCollator(title2)
|
||||
}
|
||||
|
||||
val defaultTrackerScoreSortValue = -1.0
|
||||
@@ -266,39 +294,39 @@ class LibraryScreenModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun LibrarySort.comparator(): Comparator<LibraryItem> = Comparator { i1, i2 ->
|
||||
fun LibrarySort.comparator(): Comparator<LibraryItem> = Comparator { manga1, manga2 ->
|
||||
when (this.type) {
|
||||
LibrarySort.Type.Alphabetical -> {
|
||||
sortAlphabetically(i1, i2)
|
||||
sortAlphabetically(manga1, manga2)
|
||||
}
|
||||
LibrarySort.Type.LastRead -> {
|
||||
i1.libraryManga.lastRead.compareTo(i2.libraryManga.lastRead)
|
||||
manga1.libraryManga.lastRead.compareTo(manga2.libraryManga.lastRead)
|
||||
}
|
||||
LibrarySort.Type.LastUpdate -> {
|
||||
i1.libraryManga.manga.lastUpdate.compareTo(i2.libraryManga.manga.lastUpdate)
|
||||
manga1.libraryManga.manga.lastUpdate.compareTo(manga2.libraryManga.manga.lastUpdate)
|
||||
}
|
||||
LibrarySort.Type.UnreadCount -> when {
|
||||
// Ensure unread content comes first
|
||||
i1.libraryManga.unreadCount == i2.libraryManga.unreadCount -> 0
|
||||
i1.libraryManga.unreadCount == 0L -> if (this.isAscending) 1 else -1
|
||||
i2.libraryManga.unreadCount == 0L -> if (this.isAscending) -1 else 1
|
||||
else -> i1.libraryManga.unreadCount.compareTo(i2.libraryManga.unreadCount)
|
||||
manga1.libraryManga.unreadCount == manga2.libraryManga.unreadCount -> 0
|
||||
manga1.libraryManga.unreadCount == 0L -> if (this.isAscending) 1 else -1
|
||||
manga2.libraryManga.unreadCount == 0L -> if (this.isAscending) -1 else 1
|
||||
else -> manga1.libraryManga.unreadCount.compareTo(manga2.libraryManga.unreadCount)
|
||||
}
|
||||
LibrarySort.Type.TotalChapters -> {
|
||||
i1.libraryManga.totalChapters.compareTo(i2.libraryManga.totalChapters)
|
||||
manga1.libraryManga.totalChapters.compareTo(manga2.libraryManga.totalChapters)
|
||||
}
|
||||
LibrarySort.Type.LatestChapter -> {
|
||||
i1.libraryManga.latestUpload.compareTo(i2.libraryManga.latestUpload)
|
||||
manga1.libraryManga.latestUpload.compareTo(manga2.libraryManga.latestUpload)
|
||||
}
|
||||
LibrarySort.Type.ChapterFetchDate -> {
|
||||
i1.libraryManga.chapterFetchedAt.compareTo(i2.libraryManga.chapterFetchedAt)
|
||||
manga1.libraryManga.chapterFetchedAt.compareTo(manga2.libraryManga.chapterFetchedAt)
|
||||
}
|
||||
LibrarySort.Type.DateAdded -> {
|
||||
i1.libraryManga.manga.dateAdded.compareTo(i2.libraryManga.manga.dateAdded)
|
||||
manga1.libraryManga.manga.dateAdded.compareTo(manga2.libraryManga.manga.dateAdded)
|
||||
}
|
||||
LibrarySort.Type.TrackerMean -> {
|
||||
val item1Score = trackerScores[i1.libraryManga.id] ?: defaultTrackerScoreSortValue
|
||||
val item2Score = trackerScores[i2.libraryManga.id] ?: defaultTrackerScoreSortValue
|
||||
val item1Score = trackerScores[manga1.id] ?: defaultTrackerScoreSortValue
|
||||
val item2Score = trackerScores[manga2.id] ?: defaultTrackerScoreSortValue
|
||||
item1Score.compareTo(item2Score)
|
||||
}
|
||||
LibrarySort.Type.Random -> {
|
||||
@@ -312,11 +340,13 @@ class LibraryScreenModel(
|
||||
return@mapValues value.shuffled(Random(libraryPreferences.randomSortSeed().get()))
|
||||
}
|
||||
|
||||
val manga = value.mapNotNull { favoritesById[it] }
|
||||
|
||||
val comparator = key.sort.comparator()
|
||||
.let { if (key.sort.isAscending) it else it.reversed() }
|
||||
.thenComparator(sortAlphabetically)
|
||||
|
||||
value.sortedWith(comparator)
|
||||
manga.sortedWith(comparator).map { it.id }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,45 +383,37 @@ class LibraryScreenModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the categories and all its manga from the database.
|
||||
*/
|
||||
private fun getLibraryFlow(): Flow<LibraryMap> {
|
||||
val libraryMangasFlow = combine(
|
||||
private fun getFavoritesFlow(): Flow<List<LibraryItem>> {
|
||||
return combine(
|
||||
getLibraryManga.subscribe(),
|
||||
getLibraryItemPreferencesFlow(),
|
||||
downloadCache.changes,
|
||||
) { libraryMangaList, prefs, _ ->
|
||||
libraryMangaList
|
||||
.map { libraryManga ->
|
||||
// Display mode based on user preference: take it from global library setting or category
|
||||
) { libraryManga, preferences, _ ->
|
||||
libraryManga.map { manga ->
|
||||
LibraryItem(
|
||||
libraryManga,
|
||||
downloadCount = if (prefs.downloadBadge) {
|
||||
downloadManager.getDownloadCount(libraryManga.manga).toLong()
|
||||
libraryManga = manga,
|
||||
downloadCount = if (preferences.downloadBadge) {
|
||||
downloadManager.getDownloadCount(manga.manga).toLong()
|
||||
} else {
|
||||
0
|
||||
},
|
||||
unreadCount = if (prefs.unreadBadge) libraryManga.unreadCount else 0,
|
||||
isLocal = if (prefs.localBadge) libraryManga.manga.isLocal() else false,
|
||||
sourceLanguage = if (prefs.languageBadge) {
|
||||
sourceManager.getOrStub(libraryManga.manga.source).lang
|
||||
unreadCount = if (preferences.unreadBadge) {
|
||||
manga.unreadCount
|
||||
} else {
|
||||
0
|
||||
},
|
||||
isLocal = if (preferences.localBadge) {
|
||||
manga.manga.isLocal()
|
||||
} else {
|
||||
false
|
||||
},
|
||||
sourceLanguage = if (preferences.languageBadge) {
|
||||
sourceManager.getOrStub(manga.manga.source).lang
|
||||
} else {
|
||||
""
|
||||
},
|
||||
)
|
||||
}
|
||||
.groupBy { it.libraryManga.category }
|
||||
}
|
||||
|
||||
return combine(getCategories.subscribe(), libraryMangasFlow) { categories, libraryManga ->
|
||||
val displayCategories = if (libraryManga.isNotEmpty() && !libraryManga.containsKey(0)) {
|
||||
categories.fastFilterNot { it.isSystemCategory }
|
||||
} else {
|
||||
categories
|
||||
}
|
||||
|
||||
displayCategories.associateWith { libraryManga[it.id].orEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,17 +422,15 @@ class LibraryScreenModel(
|
||||
*
|
||||
* @return map of track id with the filter value
|
||||
*/
|
||||
private fun getTrackingFilterFlow(): Flow<Map<Long, TriState>> {
|
||||
private fun getTrackingFiltersFlow(): Flow<Map<Long, TriState>> {
|
||||
return trackerManager.loggedInTrackersFlow().flatMapLatest { loggedInTrackers ->
|
||||
if (loggedInTrackers.isEmpty()) return@flatMapLatest flowOf(emptyMap())
|
||||
|
||||
val prefFlows = loggedInTrackers.map { tracker ->
|
||||
libraryPreferences.filterTracking(tracker.id.toInt()).changes()
|
||||
if (loggedInTrackers.isEmpty()) {
|
||||
flowOf(emptyMap())
|
||||
} else {
|
||||
val filterFlows = loggedInTrackers.map { tracker ->
|
||||
libraryPreferences.filterTracking(tracker.id.toInt()).changes().map { tracker.id to it }
|
||||
}
|
||||
combine(prefFlows) {
|
||||
loggedInTrackers
|
||||
.mapIndexed { index, tracker -> tracker.id to it[index] }
|
||||
.toMap()
|
||||
combine(filterFlows) { it.toMap() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -443,26 +463,19 @@ class LibraryScreenModel(
|
||||
return mangaCategories.flatten().distinct().subtract(common)
|
||||
}
|
||||
|
||||
fun runDownloadActionSelection(action: DownloadAction) {
|
||||
val selection = state.value.selection
|
||||
val mangas = selection.map { it.manga }.toList()
|
||||
when (action) {
|
||||
DownloadAction.NEXT_1_CHAPTER -> downloadUnreadChapters(mangas, 1)
|
||||
DownloadAction.NEXT_5_CHAPTERS -> downloadUnreadChapters(mangas, 5)
|
||||
DownloadAction.NEXT_10_CHAPTERS -> downloadUnreadChapters(mangas, 10)
|
||||
DownloadAction.NEXT_25_CHAPTERS -> downloadUnreadChapters(mangas, 25)
|
||||
DownloadAction.UNREAD_CHAPTERS -> downloadUnreadChapters(mangas, null)
|
||||
/**
|
||||
* Queues the amount specified of unread chapters from the list of selected manga
|
||||
*/
|
||||
fun performDownloadAction(action: DownloadAction) {
|
||||
val mangas = state.value.selectedManga
|
||||
val amount = when (action) {
|
||||
DownloadAction.NEXT_1_CHAPTER -> 1
|
||||
DownloadAction.NEXT_5_CHAPTERS -> 5
|
||||
DownloadAction.NEXT_10_CHAPTERS -> 10
|
||||
DownloadAction.NEXT_25_CHAPTERS -> 25
|
||||
DownloadAction.UNREAD_CHAPTERS -> null
|
||||
}
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues the amount specified of unread chapters from the list of mangas given.
|
||||
*
|
||||
* @param mangas the list of manga.
|
||||
* @param amount the amount to queue or null to queue all
|
||||
*/
|
||||
private fun downloadUnreadChapters(mangas: List<Manga>, amount: Int?) {
|
||||
screenModelScope.launchNonCancellable {
|
||||
mangas.forEach { manga ->
|
||||
val chapters = getNextChapters.await(manga.id)
|
||||
@@ -486,11 +499,10 @@ class LibraryScreenModel(
|
||||
* Marks mangas' chapters read status.
|
||||
*/
|
||||
fun markReadSelection(read: Boolean) {
|
||||
val mangas = state.value.selection.toList()
|
||||
screenModelScope.launchNonCancellable {
|
||||
mangas.forEach { manga ->
|
||||
state.value.selectedManga.forEach { manga ->
|
||||
setReadStatus.await(
|
||||
manga = manga.manga,
|
||||
manga = manga,
|
||||
read = read,
|
||||
)
|
||||
}
|
||||
@@ -501,16 +513,14 @@ class LibraryScreenModel(
|
||||
/**
|
||||
* Remove the selected manga.
|
||||
*
|
||||
* @param mangaList the list of manga to delete.
|
||||
* @param mangas the list of manga to delete.
|
||||
* @param deleteFromLibrary whether to delete manga from library.
|
||||
* @param deleteChapters whether to delete downloaded chapters.
|
||||
*/
|
||||
fun removeMangas(mangaList: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
|
||||
fun removeMangas(mangas: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
|
||||
screenModelScope.launchNonCancellable {
|
||||
val mangaToDelete = mangaList.distinctBy { it.id }
|
||||
|
||||
if (deleteFromLibrary) {
|
||||
val toDelete = mangaToDelete.map {
|
||||
val toDelete = mangas.map {
|
||||
it.removeCovers(coverCache)
|
||||
MangaUpdate(
|
||||
favorite = false,
|
||||
@@ -521,7 +531,7 @@ class LibraryScreenModel(
|
||||
}
|
||||
|
||||
if (deleteChapters) {
|
||||
mangaToDelete.forEach { manga ->
|
||||
mangas.forEach { manga ->
|
||||
val source = sourceManager.get(manga.source) as? HttpSource
|
||||
if (source != null) {
|
||||
downloadManager.deleteManga(manga, source)
|
||||
@@ -556,38 +566,33 @@ class LibraryScreenModel(
|
||||
return libraryPreferences.displayMode().asState(screenModelScope)
|
||||
}
|
||||
|
||||
fun getColumnsPreferenceForCurrentOrientation(isLandscape: Boolean): PreferenceMutableState<Int> {
|
||||
fun getColumnsForOrientation(isLandscape: Boolean): PreferenceMutableState<Int> {
|
||||
return (if (isLandscape) libraryPreferences.landscapeColumns() else libraryPreferences.portraitColumns())
|
||||
.asState(screenModelScope)
|
||||
}
|
||||
|
||||
suspend fun getRandomLibraryItemForCurrentCategory(): LibraryItem? {
|
||||
if (state.value.categories.isEmpty()) return null
|
||||
|
||||
return withIOContext {
|
||||
state.value
|
||||
.getLibraryItemsByCategoryId(state.value.categories[activeCategoryIndex].id)
|
||||
?.randomOrNull()
|
||||
}
|
||||
fun getRandomLibraryItemForCurrentCategory(): LibraryItem? {
|
||||
val state = state.value
|
||||
return state.getItemsForCategoryId(state.activeCategory.id).randomOrNull()
|
||||
}
|
||||
|
||||
fun showSettingsDialog() {
|
||||
mutableState.update { it.copy(dialog = Dialog.SettingsSheet) }
|
||||
}
|
||||
|
||||
private var lastSelectionCategory: Long? = null
|
||||
|
||||
fun clearSelection() {
|
||||
mutableState.update { it.copy(selection = persistentListOf()) }
|
||||
lastSelectionCategory = null
|
||||
mutableState.update { it.copy(selection = setOf()) }
|
||||
}
|
||||
|
||||
fun toggleSelection(manga: LibraryManga) {
|
||||
fun toggleSelection(category: Category, manga: LibraryManga) {
|
||||
mutableState.update { state ->
|
||||
val newSelection = state.selection.mutate { list ->
|
||||
if (list.fastAny { it.id == manga.id }) {
|
||||
list.removeAll { it.id == manga.id }
|
||||
} else {
|
||||
list.add(manga)
|
||||
}
|
||||
val newSelection = state.selection.mutate { set ->
|
||||
if (!set.remove(manga.id)) set.add(manga.id)
|
||||
}
|
||||
lastSelectionCategory = category.id.takeIf { newSelection.isNotEmpty() }
|
||||
state.copy(selection = newSelection)
|
||||
}
|
||||
}
|
||||
@@ -596,60 +601,49 @@ class LibraryScreenModel(
|
||||
* Selects all mangas between and including the given manga and the last pressed manga from the
|
||||
* same category as the given manga
|
||||
*/
|
||||
fun toggleRangeSelection(manga: LibraryManga) {
|
||||
fun toggleRangeSelection(category: Category, manga: LibraryManga) {
|
||||
mutableState.update { state ->
|
||||
val newSelection = state.selection.mutate { list ->
|
||||
val lastSelected = list.lastOrNull()
|
||||
if (lastSelected?.category != manga.category) {
|
||||
list.add(manga)
|
||||
if (lastSelectionCategory != category.id) {
|
||||
list.add(manga.id)
|
||||
return@mutate
|
||||
}
|
||||
|
||||
val items = state.getLibraryItemsByCategoryId(manga.category)
|
||||
?.fastMap { it.libraryManga }.orEmpty()
|
||||
val items = state.getItemsForCategoryId(category.id).fastMap { it.id }
|
||||
val lastMangaIndex = items.indexOf(lastSelected)
|
||||
val curMangaIndex = items.indexOf(manga)
|
||||
val curMangaIndex = items.indexOf(manga.id)
|
||||
|
||||
val selectedIds = list.fastMap { it.id }
|
||||
val selectionRange = when {
|
||||
lastMangaIndex < curMangaIndex -> IntRange(lastMangaIndex, curMangaIndex)
|
||||
curMangaIndex < lastMangaIndex -> IntRange(curMangaIndex, lastMangaIndex)
|
||||
lastMangaIndex < curMangaIndex -> lastMangaIndex..curMangaIndex
|
||||
curMangaIndex < lastMangaIndex -> curMangaIndex..lastMangaIndex
|
||||
// We shouldn't reach this point
|
||||
else -> return@mutate
|
||||
}
|
||||
val newSelections = selectionRange.mapNotNull { index ->
|
||||
items[index].takeUnless { it.id in selectedIds }
|
||||
selectionRange.mapNotNull { items[it] }.let(list::addAll)
|
||||
}
|
||||
list.addAll(newSelections)
|
||||
lastSelectionCategory = category.id
|
||||
state.copy(selection = newSelection)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectAll() {
|
||||
lastSelectionCategory = null
|
||||
mutableState.update { state ->
|
||||
val newSelection = state.selection.mutate { list ->
|
||||
state.getItemsForCategoryId(state.activeCategory.id).map { it.id }.let(list::addAll)
|
||||
}
|
||||
state.copy(selection = newSelection)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectAll(index: Int) {
|
||||
fun invertSelection() {
|
||||
lastSelectionCategory = null
|
||||
mutableState.update { state ->
|
||||
val newSelection = state.selection.mutate { list ->
|
||||
val categoryId = state.categories.getOrNull(index)?.id ?: -1
|
||||
val selectedIds = list.fastMap { it.id }
|
||||
state.getLibraryItemsByCategoryId(categoryId)
|
||||
?.fastMapNotNull { item ->
|
||||
item.libraryManga.takeUnless { it.id in selectedIds }
|
||||
}
|
||||
?.let { list.addAll(it) }
|
||||
}
|
||||
state.copy(selection = newSelection)
|
||||
}
|
||||
}
|
||||
|
||||
fun invertSelection(index: Int) {
|
||||
mutableState.update { state ->
|
||||
val newSelection = state.selection.mutate { list ->
|
||||
val categoryId = state.categories[index].id
|
||||
val items = state.getLibraryItemsByCategoryId(categoryId)?.fastMap { it.libraryManga }.orEmpty()
|
||||
val selectedIds = list.fastMap { it.id }
|
||||
val (toRemove, toAdd) = items.fastPartition { it.id in selectedIds }
|
||||
val toRemoveIds = toRemove.fastMap { it.id }
|
||||
list.removeAll { it.id in toRemoveIds }
|
||||
val itemIds = state.getItemsForCategoryId(state.activeCategory.id).fastMap { it.id }
|
||||
val (toRemove, toAdd) = itemIds.partition { it in list }
|
||||
list.removeAll(toRemove)
|
||||
list.addAll(toAdd)
|
||||
}
|
||||
state.copy(selection = newSelection)
|
||||
@@ -660,13 +654,22 @@ class LibraryScreenModel(
|
||||
mutableState.update { it.copy(searchQuery = query) }
|
||||
}
|
||||
|
||||
fun updateActiveCategoryIndex(index: Int) {
|
||||
val newIndex = mutableState.updateAndGet { state ->
|
||||
state.copy(activeCategoryIndex = index)
|
||||
}
|
||||
.coercedActiveCategoryIndex
|
||||
|
||||
libraryPreferences.lastUsedCategory().set(newIndex)
|
||||
}
|
||||
|
||||
fun openChangeCategoryDialog() {
|
||||
screenModelScope.launchIO {
|
||||
// Create a copy of selected manga
|
||||
val mangaList = state.value.selection.map { it.manga }
|
||||
val mangaList = state.value.selectedManga
|
||||
|
||||
// Hide the default category because it has a different behavior than the ones from db.
|
||||
val categories = state.value.categories.filter { it.id != 0L }
|
||||
val categories = state.value.displayedCategories.filter { it.id != 0L }
|
||||
|
||||
// Get indexes of the common categories to preselect.
|
||||
val common = getCommonCategories(mangaList)
|
||||
@@ -686,8 +689,7 @@ class LibraryScreenModel(
|
||||
}
|
||||
|
||||
fun openDeleteMangaDialog() {
|
||||
val mangaList = state.value.selection.map { it.manga }
|
||||
mutableState.update { it.copy(dialog = Dialog.DeleteManga(mangaList)) }
|
||||
mutableState.update { it.copy(dialog = Dialog.DeleteManga(state.value.selectedManga)) }
|
||||
}
|
||||
|
||||
fun closeDialog() {
|
||||
@@ -720,41 +722,59 @@ class LibraryScreenModel(
|
||||
val filterIntervalCustom: TriState,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class LibraryData(
|
||||
val isInitialized: Boolean = false,
|
||||
val showSystemCategory: Boolean = false,
|
||||
val categories: List<Category> = emptyList(),
|
||||
val favorites: List<LibraryItem> = emptyList(),
|
||||
val tracksMap: Map</* Manga */ Long, List<Track>> = emptyMap(),
|
||||
val loggedInTrackerIds: Set<Long> = emptySet(),
|
||||
) {
|
||||
val favoritesById by lazy { favorites.associateBy { it.id } }
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class State(
|
||||
val isInitialized: Boolean = false,
|
||||
val isLoading: Boolean = true,
|
||||
val library: LibraryMap = emptyMap(),
|
||||
val searchQuery: String? = null,
|
||||
val selection: PersistentList<LibraryManga> = persistentListOf(),
|
||||
val selection: Set</* Manga */ Long> = setOf(),
|
||||
val hasActiveFilters: Boolean = false,
|
||||
val showCategoryTabs: Boolean = false,
|
||||
val showMangaCount: Boolean = false,
|
||||
val showMangaContinueButton: Boolean = false,
|
||||
val dialog: Dialog? = null,
|
||||
val libraryData: LibraryData = LibraryData(),
|
||||
private val activeCategoryIndex: Int = 0,
|
||||
private val groupedFavorites: Map<Category, List</* LibraryItem */ Long>> = emptyMap(),
|
||||
) {
|
||||
private val libraryCount by lazy {
|
||||
library.values
|
||||
.flatten()
|
||||
.fastDistinctBy { it.libraryManga.manga.id }
|
||||
.size
|
||||
}
|
||||
val displayedCategories: List<Category> = groupedFavorites.keys.toList()
|
||||
|
||||
val isLibraryEmpty by lazy { libraryCount == 0 }
|
||||
val coercedActiveCategoryIndex = activeCategoryIndex.coerceIn(
|
||||
minimumValue = 0,
|
||||
maximumValue = displayedCategories.lastIndex.coerceAtLeast(0),
|
||||
)
|
||||
|
||||
val activeCategory: Category by lazy { displayedCategories[coercedActiveCategoryIndex] }
|
||||
|
||||
val isLibraryEmpty = libraryData.favorites.isEmpty()
|
||||
|
||||
val selectionMode = selection.isNotEmpty()
|
||||
|
||||
val categories = library.keys.toList()
|
||||
val selectedManga by lazy { selection.mapNotNull { libraryData.favoritesById[it]?.libraryManga?.manga } }
|
||||
|
||||
fun getLibraryItemsByCategoryId(categoryId: Long): List<LibraryItem>? {
|
||||
return library.firstNotNullOfOrNull { (k, v) -> v.takeIf { k.id == categoryId } }
|
||||
fun getItemsForCategoryId(categoryId: Long): List<LibraryItem> {
|
||||
val category = displayedCategories.find { it.id == categoryId } ?: return emptyList()
|
||||
return getItemsForCategory(category)
|
||||
}
|
||||
|
||||
fun getLibraryItemsByPage(page: Int): List<LibraryItem> {
|
||||
return library.values.toTypedArray().getOrNull(page).orEmpty()
|
||||
fun getItemsForCategory(category: Category): List<LibraryItem> {
|
||||
return groupedFavorites[category].orEmpty().mapNotNull { libraryData.favoritesById[it] }
|
||||
}
|
||||
|
||||
fun getMangaCountForCategory(category: Category): Int? {
|
||||
return if (showMangaCount || !searchQuery.isNullOrEmpty()) library[category]?.size else null
|
||||
fun getItemCountForCategory(category: Category): Int? {
|
||||
return if (showMangaCount || !searchQuery.isNullOrEmpty()) groupedFavorites[category]?.size else null
|
||||
}
|
||||
|
||||
fun getToolbarTitle(
|
||||
@@ -762,18 +782,17 @@ class LibraryScreenModel(
|
||||
defaultCategoryTitle: String,
|
||||
page: Int,
|
||||
): LibraryToolbarTitle {
|
||||
val category = categories.getOrNull(page) ?: return LibraryToolbarTitle(defaultTitle)
|
||||
val category = displayedCategories.getOrNull(page) ?: return LibraryToolbarTitle(defaultTitle)
|
||||
val categoryName = category.let {
|
||||
if (it.isSystemCategory) defaultCategoryTitle else it.name
|
||||
}
|
||||
val title = if (showCategoryTabs) defaultTitle else categoryName
|
||||
val count = when {
|
||||
!showMangaCount -> null
|
||||
!showCategoryTabs -> getMangaCountForCategory(category)
|
||||
!showCategoryTabs -> getItemCountForCategory(category)
|
||||
// Whole library count
|
||||
else -> libraryCount
|
||||
else -> libraryData.favorites.size
|
||||
}
|
||||
|
||||
return LibraryToolbarTitle(title, count)
|
||||
}
|
||||
}
|
||||
|
@@ -48,6 +48,7 @@ import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import mihon.feature.migration.config.MigrationConfigScreen
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.domain.category.model.Category
|
||||
@@ -110,18 +111,17 @@ data object LibraryTab : Tab {
|
||||
val title = state.getToolbarTitle(
|
||||
defaultTitle = stringResource(MR.strings.label_library),
|
||||
defaultCategoryTitle = stringResource(MR.strings.label_default),
|
||||
page = screenModel.activeCategoryIndex,
|
||||
page = state.coercedActiveCategoryIndex,
|
||||
)
|
||||
val tabVisible = state.showCategoryTabs && state.categories.size > 1
|
||||
LibraryToolbar(
|
||||
hasActiveFilters = state.hasActiveFilters,
|
||||
selectedCount = state.selection.size,
|
||||
title = title,
|
||||
onClickUnselectAll = screenModel::clearSelection,
|
||||
onClickSelectAll = { screenModel.selectAll(screenModel.activeCategoryIndex) },
|
||||
onClickInvertSelection = { screenModel.invertSelection(screenModel.activeCategoryIndex) },
|
||||
onClickSelectAll = screenModel::selectAll,
|
||||
onClickInvertSelection = screenModel::invertSelection,
|
||||
onClickFilter = screenModel::showSettingsDialog,
|
||||
onClickRefresh = { onClickRefresh(state.categories[screenModel.activeCategoryIndex]) },
|
||||
onClickRefresh = { onClickRefresh(state.activeCategory) },
|
||||
onClickGlobalUpdate = { onClickRefresh(null) },
|
||||
onClickOpenRandomManga = {
|
||||
scope.launch {
|
||||
@@ -137,7 +137,8 @@ data object LibraryTab : Tab {
|
||||
},
|
||||
searchQuery = state.searchQuery,
|
||||
onSearchQueryChange = screenModel::search,
|
||||
scrollBehavior = scrollBehavior.takeIf { !tabVisible }, // For scroll overlay when no tab
|
||||
// For scroll overlay when no tab
|
||||
scrollBehavior = scrollBehavior.takeIf { !state.showCategoryTabs },
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
@@ -146,15 +147,22 @@ data object LibraryTab : Tab {
|
||||
onChangeCategoryClicked = screenModel::openChangeCategoryDialog,
|
||||
onMarkAsReadClicked = { screenModel.markReadSelection(true) },
|
||||
onMarkAsUnreadClicked = { screenModel.markReadSelection(false) },
|
||||
onDownloadClicked = screenModel::runDownloadActionSelection
|
||||
.takeIf { state.selection.fastAll { !it.manga.isLocal() } },
|
||||
onDownloadClicked = screenModel::performDownloadAction
|
||||
.takeIf { state.selectedManga.fastAll { !it.isLocal() } },
|
||||
onDeleteClicked = screenModel::openDeleteMangaDialog,
|
||||
onMigrateClicked = {
|
||||
val selection = state.selection
|
||||
screenModel.clearSelection()
|
||||
navigator.push(MigrationConfigScreen(selection))
|
||||
},
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
) { contentPadding ->
|
||||
when {
|
||||
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding))
|
||||
state.isLoading -> {
|
||||
LoadingScreen(Modifier.padding(contentPadding))
|
||||
}
|
||||
state.searchQuery.isNullOrEmpty() && !state.hasActiveFilters && state.isLibraryEmpty -> {
|
||||
val handler = LocalUriHandler.current
|
||||
EmptyScreen(
|
||||
@@ -171,15 +179,15 @@ data object LibraryTab : Tab {
|
||||
}
|
||||
else -> {
|
||||
LibraryContent(
|
||||
categories = state.categories,
|
||||
categories = state.displayedCategories,
|
||||
searchQuery = state.searchQuery,
|
||||
selection = state.selection,
|
||||
contentPadding = contentPadding,
|
||||
currentPage = { screenModel.activeCategoryIndex },
|
||||
currentPage = state.coercedActiveCategoryIndex,
|
||||
hasActiveFilters = state.hasActiveFilters,
|
||||
showPageTabs = state.showCategoryTabs || !state.searchQuery.isNullOrEmpty(),
|
||||
onChangeCurrentPage = { screenModel.activeCategoryIndex = it },
|
||||
onMangaClicked = { navigator.push(MangaScreen(it)) },
|
||||
onChangeCurrentPage = screenModel::updateActiveCategoryIndex,
|
||||
onClickManga = { navigator.push(MangaScreen(it)) },
|
||||
onContinueReadingClicked = { it: LibraryManga ->
|
||||
scope.launchIO {
|
||||
val chapter = screenModel.getNextUnreadChapter(it.manga)
|
||||
@@ -194,18 +202,19 @@ data object LibraryTab : Tab {
|
||||
Unit
|
||||
}.takeIf { state.showMangaContinueButton },
|
||||
onToggleSelection = screenModel::toggleSelection,
|
||||
onToggleRangeSelection = {
|
||||
screenModel.toggleRangeSelection(it)
|
||||
onToggleRangeSelection = { category, manga ->
|
||||
screenModel.toggleRangeSelection(category, manga)
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
},
|
||||
onRefresh = onClickRefresh,
|
||||
onRefresh = { onClickRefresh(state.activeCategory) },
|
||||
onGlobalSearchClicked = {
|
||||
navigator.push(GlobalSearchScreen(screenModel.state.value.searchQuery ?: ""))
|
||||
},
|
||||
getNumberOfMangaForCategory = { state.getMangaCountForCategory(it) },
|
||||
getItemCountForCategory = { state.getItemCountForCategory(it) },
|
||||
getDisplayMode = { screenModel.getDisplayMode() },
|
||||
getColumnsForOrientation = { screenModel.getColumnsPreferenceForCurrentOrientation(it) },
|
||||
) { state.getLibraryItemsByPage(it) }
|
||||
getColumnsForOrientation = { screenModel.getColumnsForOrientation(it) },
|
||||
getItemsForCategory = { state.getItemsForCategory(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,15 +222,10 @@ data object LibraryTab : Tab {
|
||||
val onDismissRequest = screenModel::closeDialog
|
||||
when (val dialog = state.dialog) {
|
||||
is LibraryScreenModel.Dialog.SettingsSheet -> run {
|
||||
val category = state.categories.getOrNull(screenModel.activeCategoryIndex)
|
||||
if (category == null) {
|
||||
onDismissRequest()
|
||||
return@run
|
||||
}
|
||||
LibrarySettingsDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
screenModel = settingsScreenModel,
|
||||
category = category,
|
||||
category = state.activeCategory,
|
||||
)
|
||||
}
|
||||
is LibraryScreenModel.Dialog.ChangeCategory -> {
|
||||
|
@@ -43,13 +43,11 @@ import eu.kanade.presentation.util.isTabletUi
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.isLocalOrStub
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialog
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateDialogScreenModel
|
||||
import eu.kanade.tachiyomi.ui.browse.migration.search.MigrateSearchScreen
|
||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen
|
||||
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen
|
||||
import eu.kanade.tachiyomi.ui.category.CategoryScreen
|
||||
import eu.kanade.tachiyomi.ui.home.HomeScreen
|
||||
import eu.kanade.tachiyomi.ui.manga.notes.MangaNotesScreen
|
||||
import eu.kanade.tachiyomi.ui.manga.track.TrackInfoDialogHomeScreen
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import eu.kanade.tachiyomi.ui.setting.SettingsScreen
|
||||
@@ -59,6 +57,8 @@ import eu.kanade.tachiyomi.util.system.toShareIntent
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.coroutines.launch
|
||||
import logcat.LogPriority
|
||||
import mihon.feature.migration.config.MigrationConfigScreen
|
||||
import mihon.feature.migration.dialog.MigrateMangaDialog
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.core.common.util.lang.withIOContext
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
@@ -162,8 +162,9 @@ class MangaScreen(
|
||||
successState.manga.favorite
|
||||
},
|
||||
onMigrateClicked = {
|
||||
navigator.push(MigrateSearchScreen(successState.manga.id))
|
||||
navigator.push(MigrationConfigScreen(successState.manga.id))
|
||||
}.takeIf { successState.manga.favorite },
|
||||
onEditNotesClicked = { navigator.push(MangaNotesScreen(manga = successState.manga)) },
|
||||
onMultiBookmarkClicked = screenModel::bookmarkChapters,
|
||||
onMultiMarkAsReadClicked = screenModel::markChaptersRead,
|
||||
onMarkPreviousAsReadClicked = screenModel::markPreviousChapterRead,
|
||||
@@ -201,23 +202,21 @@ class MangaScreen(
|
||||
|
||||
is MangaScreenModel.Dialog.DuplicateManga -> {
|
||||
DuplicateMangaDialog(
|
||||
duplicates = dialog.duplicates,
|
||||
onDismissRequest = onDismissRequest,
|
||||
onConfirm = { screenModel.toggleFavorite(onRemoved = {}, checkDuplicate = false) },
|
||||
onOpenManga = { navigator.push(MangaScreen(dialog.duplicate.id)) },
|
||||
onMigrate = {
|
||||
screenModel.showMigrateDialog(dialog.duplicate)
|
||||
},
|
||||
onOpenManga = { navigator.push(MangaScreen(it.id)) },
|
||||
onMigrate = { screenModel.showMigrateDialog(it) },
|
||||
)
|
||||
}
|
||||
|
||||
is MangaScreenModel.Dialog.Migrate -> {
|
||||
MigrateDialog(
|
||||
oldManga = dialog.oldManga,
|
||||
newManga = dialog.newManga,
|
||||
screenModel = MigrateDialogScreenModel(),
|
||||
MigrateMangaDialog(
|
||||
current = dialog.current,
|
||||
target = dialog.target,
|
||||
// Initiated from the context of [dialog.target] so we show [dialog.current].
|
||||
onClickTitle = { navigator.push(MangaScreen(dialog.current.id)) },
|
||||
onDismissRequest = onDismissRequest,
|
||||
onClickTitle = { navigator.push(MangaScreen(dialog.oldManga.id)) },
|
||||
onPopScreen = { navigator.replace(MangaScreen(dialog.newManga.id)) },
|
||||
)
|
||||
}
|
||||
MangaScreenModel.Dialog.SettingsSheet -> ChapterSettingsDialog(
|
||||
|
@@ -80,6 +80,7 @@ import tachiyomi.domain.manga.interactor.GetDuplicateLibraryManga
|
||||
import tachiyomi.domain.manga.interactor.GetMangaWithChapters
|
||||
import tachiyomi.domain.manga.interactor.SetMangaChapterFlags
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import tachiyomi.domain.manga.model.MangaWithChapterCount
|
||||
import tachiyomi.domain.manga.model.applyFilter
|
||||
import tachiyomi.domain.manga.repository.MangaRepository
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
@@ -88,8 +89,6 @@ import tachiyomi.i18n.MR
|
||||
import tachiyomi.source.local.isLocal
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import kotlin.collections.filter
|
||||
import kotlin.collections.forEach
|
||||
import kotlin.math.floor
|
||||
|
||||
class MangaScreenModel(
|
||||
@@ -231,6 +230,7 @@ class MangaScreenModel(
|
||||
excludedScanlators = getExcludedScanlators.await(mangaId),
|
||||
isRefreshingData = needRefreshInfo || needRefreshChapter,
|
||||
dialog = null,
|
||||
hideMissingChapters = libraryPreferences.hideMissingChapters().get(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -328,10 +328,10 @@ class MangaScreenModel(
|
||||
// Add to library
|
||||
// First, check if duplicate exists if callback is provided
|
||||
if (checkDuplicate) {
|
||||
val duplicate = getDuplicateLibraryManga.await(manga).getOrNull(0)
|
||||
val duplicates = getDuplicateLibraryManga(manga)
|
||||
|
||||
if (duplicate != null) {
|
||||
updateSuccessState { it.copy(dialog = Dialog.DuplicateManga(manga, duplicate)) }
|
||||
if (duplicates.isNotEmpty()) {
|
||||
updateSuccessState { it.copy(dialog = Dialog.DuplicateManga(manga, duplicates)) }
|
||||
return@launchIO
|
||||
}
|
||||
}
|
||||
@@ -1071,8 +1071,8 @@ class MangaScreenModel(
|
||||
val initialSelection: ImmutableList<CheckboxState<Category>>,
|
||||
) : Dialog
|
||||
data class DeleteChapters(val chapters: List<Chapter>) : Dialog
|
||||
data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
|
||||
data class Migrate(val newManga: Manga, val oldManga: Manga) : Dialog
|
||||
data class DuplicateManga(val manga: Manga, val duplicates: List<MangaWithChapterCount>) : Dialog
|
||||
data class Migrate(val target: Manga, val current: Manga) : Dialog
|
||||
data class SetFetchInterval(val manga: Manga) : Dialog
|
||||
data object SettingsSheet : Dialog
|
||||
data object TrackSheet : Dialog
|
||||
@@ -1101,7 +1101,7 @@ class MangaScreenModel(
|
||||
|
||||
fun showMigrateDialog(duplicate: Manga) {
|
||||
val manga = successState?.manga ?: return
|
||||
updateSuccessState { it.copy(dialog = Dialog.Migrate(newManga = manga, oldManga = duplicate)) }
|
||||
updateSuccessState { it.copy(dialog = Dialog.Migrate(target = manga, current = duplicate)) }
|
||||
}
|
||||
|
||||
fun setExcludedScanlators(excludedScanlators: Set<String>) {
|
||||
@@ -1127,6 +1127,7 @@ class MangaScreenModel(
|
||||
val isRefreshingData: Boolean = false,
|
||||
val dialog: Dialog? = null,
|
||||
val hasPromptedToAddBefore: Boolean = false,
|
||||
val hideMissingChapters: Boolean = false,
|
||||
) : State {
|
||||
val processedChapters by lazy {
|
||||
chapters.applyFilters(manga).toList()
|
||||
@@ -1137,6 +1138,10 @@ class MangaScreenModel(
|
||||
}
|
||||
|
||||
val chapterListItems by lazy {
|
||||
if (hideMissingChapters) {
|
||||
return@lazy processedChapters
|
||||
}
|
||||
|
||||
processedChapters.insertSeparators { before, after ->
|
||||
val (lowerChapter, higherChapter) = if (manga.sortDescending()) {
|
||||
after to before
|
||||
|
@@ -0,0 +1,61 @@
|
||||
package eu.kanade.tachiyomi.ui.manga.notes
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import eu.kanade.presentation.manga.MangaNotesScreen
|
||||
import eu.kanade.presentation.util.Screen
|
||||
import kotlinx.coroutines.flow.update
|
||||
import tachiyomi.core.common.util.lang.launchNonCancellable
|
||||
import tachiyomi.domain.manga.interactor.UpdateMangaNotes
|
||||
import tachiyomi.domain.manga.model.Manga
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class MangaNotesScreen(
|
||||
private val manga: Manga,
|
||||
) : Screen() {
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
|
||||
val screenModel = rememberScreenModel { Model(manga) }
|
||||
val state by screenModel.state.collectAsState()
|
||||
|
||||
MangaNotesScreen(
|
||||
state = state,
|
||||
navigateUp = navigator::pop,
|
||||
onUpdate = screenModel::updateNotes,
|
||||
)
|
||||
}
|
||||
|
||||
private class Model(
|
||||
private val manga: Manga,
|
||||
private val updateMangaNotes: UpdateMangaNotes = Injekt.get(),
|
||||
) : StateScreenModel<State>(State(manga, manga.notes)) {
|
||||
|
||||
fun updateNotes(content: String) {
|
||||
if (content == state.value.notes) return
|
||||
|
||||
mutableState.update {
|
||||
it.copy(notes = content)
|
||||
}
|
||||
|
||||
screenModelScope.launchNonCancellable {
|
||||
updateMangaNotes(manga.id, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class State(
|
||||
val manga: Manga,
|
||||
val notes: String,
|
||||
)
|
||||
}
|
@@ -33,12 +33,9 @@ class OnboardingScreen : Screen() {
|
||||
|
||||
val restoreSettingKey = stringResource(SettingsDataScreen.restorePreferenceKeyString)
|
||||
|
||||
BackHandler(
|
||||
enabled = !shownOnboardingFlow,
|
||||
onBack = {
|
||||
BackHandler(enabled = !shownOnboardingFlow) {
|
||||
// Prevent exiting if onboarding hasn't been completed
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
OnboardingScreen(
|
||||
onComplete = finishOnboarding,
|
||||
|
@@ -15,7 +15,6 @@ import eu.kanade.domain.manga.model.readingMode
|
||||
import eu.kanade.domain.source.interactor.GetIncognitoState
|
||||
import eu.kanade.domain.track.interactor.TrackChapter
|
||||
import eu.kanade.domain.track.service.TrackPreferences
|
||||
import eu.kanade.tachiyomi.data.database.models.isRecognizedNumber
|
||||
import eu.kanade.tachiyomi.data.database.models.toDomainChapter
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.download.DownloadProvider
|
||||
@@ -147,6 +146,11 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
|
||||
private var chapterToDownload: Download? = null
|
||||
|
||||
private val unfilteredChapterList by lazy {
|
||||
val manga = manga!!
|
||||
runBlocking { getChaptersByMangaId.await(manga.id, applyScanlatorFilter = false) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Chapter list for the active manga. It's retrieved lazily and should be accessed for the first
|
||||
* time in a background thread to avoid blocking the UI.
|
||||
@@ -533,7 +537,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
readerChapter.requestedPage = pageIndex
|
||||
chapterPageIndex = pageIndex
|
||||
|
||||
if (!incognitoMode && page.status != Page.State.ERROR) {
|
||||
if (!incognitoMode && page.status !is Page.State.Error) {
|
||||
readerChapter.chapter.last_page_read = pageIndex
|
||||
|
||||
if (readerChapter.pages?.lastIndex == pageIndex) {
|
||||
@@ -559,15 +563,14 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
.contains(LibraryPreferences.MARK_DUPLICATE_CHAPTER_READ_EXISTING)
|
||||
if (!markDuplicateAsRead) return
|
||||
|
||||
val duplicateUnreadChapters = chapterList
|
||||
.mapNotNull {
|
||||
val chapter = it.chapter
|
||||
val duplicateUnreadChapters = unfilteredChapterList
|
||||
.mapNotNull { chapter ->
|
||||
if (
|
||||
!chapter.read &&
|
||||
chapter.isRecognizedNumber &&
|
||||
chapter.chapter_number == readerChapter.chapter.chapter_number
|
||||
chapter.chapterNumber.toFloat() == readerChapter.chapter.chapter_number
|
||||
) {
|
||||
ChapterUpdate(id = chapter.id!!, read = true)
|
||||
ChapterUpdate(id = chapter.id, read = true)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -796,7 +799,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
*/
|
||||
fun saveImage() {
|
||||
val page = (state.value.dialog as? Dialog.PageActions)?.page
|
||||
if (page?.status != Page.State.READY) return
|
||||
if (page?.status != Page.State.Ready) return
|
||||
val manga = manga ?: return
|
||||
|
||||
val context = Injekt.get<Application>()
|
||||
@@ -844,7 +847,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
*/
|
||||
fun shareImage(copyToClipboard: Boolean) {
|
||||
val page = (state.value.dialog as? Dialog.PageActions)?.page
|
||||
if (page?.status != Page.State.READY) return
|
||||
if (page?.status != Page.State.Ready) return
|
||||
val manga = manga ?: return
|
||||
|
||||
val context = Injekt.get<Application>()
|
||||
@@ -874,7 +877,7 @@ class ReaderViewModel @JvmOverloads constructor(
|
||||
*/
|
||||
fun setAsCover() {
|
||||
val page = (state.value.dialog as? Dialog.PageActions)?.page
|
||||
if (page?.status != Page.State.READY) return
|
||||
if (page?.status != Page.State.Ready) return
|
||||
val manga = manga ?: return
|
||||
val stream = page.stream ?: return
|
||||
|
||||
|
@@ -19,7 +19,7 @@ internal class ArchivePageLoader(private val reader: ArchiveReader) : PageLoader
|
||||
.mapIndexed { i, entry ->
|
||||
ReaderPage(i).apply {
|
||||
stream = { reader.getInputStream(entry.name)!! }
|
||||
status = Page.State.READY
|
||||
status = Page.State.Ready
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
|
@@ -21,7 +21,7 @@ internal class DirectoryPageLoader(val file: UniFile) : PageLoader() {
|
||||
val streamFn = { file.openInputStream() }
|
||||
ReaderPage(i).apply {
|
||||
stream = streamFn
|
||||
status = Page.State.READY
|
||||
status = Page.State.Ready
|
||||
}
|
||||
}
|
||||
.orEmpty()
|
||||
|
@@ -57,7 +57,7 @@ internal class DownloadPageLoader(
|
||||
ReaderPage(page.index, page.url, page.imageUrl) {
|
||||
context.contentResolver.openInputStream(page.uri ?: Uri.EMPTY)!!
|
||||
}.apply {
|
||||
status = Page.State.READY
|
||||
status = Page.State.Ready
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -15,7 +15,7 @@ internal class EpubPageLoader(private val reader: EpubReader) : PageLoader() {
|
||||
return reader.getImagesFromPages().mapIndexed { i, path ->
|
||||
ReaderPage(i).apply {
|
||||
stream = { reader.getInputStream(path)!! }
|
||||
status = Page.State.READY
|
||||
status = Page.State.Ready
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -20,7 +20,9 @@ import tachiyomi.core.common.util.lang.withIOContext
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.concurrent.PriorityBlockingQueue
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.concurrent.atomics.AtomicInt
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
import kotlin.concurrent.atomics.incrementAndFetch
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
@@ -48,7 +50,7 @@ internal class HttpPageLoader(
|
||||
emit(runInterruptible { queue.take() }.page)
|
||||
}
|
||||
}
|
||||
.filter { it.status == Page.State.QUEUE }
|
||||
.filter { it.status == Page.State.Queue }
|
||||
.collect(::internalLoadPage)
|
||||
}
|
||||
}
|
||||
@@ -81,17 +83,17 @@ internal class HttpPageLoader(
|
||||
val imageUrl = page.imageUrl
|
||||
|
||||
// Check if the image has been deleted
|
||||
if (page.status == Page.State.READY && imageUrl != null && !chapterCache.isImageInCache(imageUrl)) {
|
||||
page.status = Page.State.QUEUE
|
||||
if (page.status == Page.State.Ready && imageUrl != null && !chapterCache.isImageInCache(imageUrl)) {
|
||||
page.status = Page.State.Queue
|
||||
}
|
||||
|
||||
// Automatically retry failed pages when subscribed to this page
|
||||
if (page.status == Page.State.ERROR) {
|
||||
page.status = Page.State.QUEUE
|
||||
if (page.status is Page.State.Error) {
|
||||
page.status = Page.State.Queue
|
||||
}
|
||||
|
||||
val queuedPages = mutableListOf<PriorityPage>()
|
||||
if (page.status == Page.State.QUEUE) {
|
||||
if (page.status == Page.State.Queue) {
|
||||
queuedPages += PriorityPage(page, 1).also { queue.offer(it) }
|
||||
}
|
||||
queuedPages += preloadNextPages(page, preloadSize)
|
||||
@@ -99,7 +101,7 @@ internal class HttpPageLoader(
|
||||
suspendCancellableCoroutine<Nothing> { continuation ->
|
||||
continuation.invokeOnCancellation {
|
||||
queuedPages.forEach {
|
||||
if (it.page.status == Page.State.QUEUE) {
|
||||
if (it.page.status == Page.State.Queue) {
|
||||
queue.remove(it)
|
||||
}
|
||||
}
|
||||
@@ -111,8 +113,8 @@ internal class HttpPageLoader(
|
||||
* Retries a page. This method is only called from user interaction on the viewer.
|
||||
*/
|
||||
override fun retryPage(page: ReaderPage) {
|
||||
if (page.status == Page.State.ERROR) {
|
||||
page.status = Page.State.QUEUE
|
||||
if (page.status is Page.State.Error) {
|
||||
page.status = Page.State.Queue
|
||||
}
|
||||
queue.offer(PriorityPage(page, 2))
|
||||
}
|
||||
@@ -151,7 +153,7 @@ internal class HttpPageLoader(
|
||||
return pages
|
||||
.subList(pageIndex + 1, min(pageIndex + 1 + amount, pages.size))
|
||||
.mapNotNull {
|
||||
if (it.status == Page.State.QUEUE) {
|
||||
if (it.status == Page.State.Queue) {
|
||||
PriorityPage(it, 0).apply { queue.offer(this) }
|
||||
} else {
|
||||
null
|
||||
@@ -168,21 +170,21 @@ internal class HttpPageLoader(
|
||||
private suspend fun internalLoadPage(page: ReaderPage) {
|
||||
try {
|
||||
if (page.imageUrl.isNullOrEmpty()) {
|
||||
page.status = Page.State.LOAD_PAGE
|
||||
page.status = Page.State.LoadPage
|
||||
page.imageUrl = source.getImageUrl(page)
|
||||
}
|
||||
val imageUrl = page.imageUrl!!
|
||||
|
||||
if (!chapterCache.isImageInCache(imageUrl)) {
|
||||
page.status = Page.State.DOWNLOAD_IMAGE
|
||||
page.status = Page.State.DownloadImage
|
||||
val imageResponse = source.getImage(page)
|
||||
chapterCache.putImageToCache(imageUrl, imageResponse)
|
||||
}
|
||||
|
||||
page.stream = { chapterCache.getImageFile(imageUrl).inputStream() }
|
||||
page.status = Page.State.READY
|
||||
page.status = Page.State.Ready
|
||||
} catch (e: Throwable) {
|
||||
page.status = Page.State.ERROR
|
||||
page.status = Page.State.Error(e)
|
||||
if (e is CancellationException) {
|
||||
throw e
|
||||
}
|
||||
@@ -193,15 +195,16 @@ internal class HttpPageLoader(
|
||||
/**
|
||||
* Data class used to keep ordering of pages in order to maintain priority.
|
||||
*/
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
private class PriorityPage(
|
||||
val page: ReaderPage,
|
||||
val priority: Int,
|
||||
) : Comparable<PriorityPage> {
|
||||
companion object {
|
||||
private val idGenerator = AtomicInteger()
|
||||
private val idGenerator = AtomicInt(0)
|
||||
}
|
||||
|
||||
private val identifier = idGenerator.incrementAndGet()
|
||||
private val identifier = idGenerator.incrementAndFetch()
|
||||
|
||||
override fun compareTo(other: PriorityPage): Int {
|
||||
val p = other.priority.compareTo(priority)
|
||||
|
@@ -5,7 +5,7 @@ class InsertPage(val parent: ReaderPage) : ReaderPage(parent.index, parent.url,
|
||||
override var chapter: ReaderChapter = parent.chapter
|
||||
|
||||
init {
|
||||
status = State.READY
|
||||
status = State.Ready
|
||||
stream = parent.stream
|
||||
}
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user