Compare commits

...

187 Commits

Author SHA1 Message Date
AntsyLich
e4de208cf7 Release v0.19.0 2025-08-04 09:31:59 +06:00
AntsyLich
f1193866f4 Fix same manga check logic in mass migration 2025-08-04 09:25:56 +06:00
AntsyLich
3782e1b414 Update CHANGELOG.md 2025-08-03 23:41:03 +06:00
AntsyLich
c4407eda0e Potentially fix library IndexOutOfBound crash (#2341) 2025-08-03 17:08:19 +00:00
MajorTanya
33e0121a2a [skip ci] Fix superfluous string concat (#2339) 2025-08-03 05:26:12 +06:00
AntsyLich
b93337cb3d Remove checksum from release notes and improve download tip 2025-08-03 02:23:15 +06:00
AntsyLich
358adb8cd1 Add donate link in more tab 2025-08-03 02:17:59 +06:00
AntsyLich
22f851173b Support mass migration in 'Browse -> Migrate' (#2338) 2025-08-02 19:45:10 +00:00
AntsyLich
982ebcf777 Support mass migration for selected library items (#2336) 2025-08-02 19:03:45 +00:00
AntsyLich
e62cd0e816 Optimize and cleanup library code (#2329) 2025-08-02 03:04:23 +00:00
Mend Renovate
1365b28106 Update dependency com.android.tools.build:gradle to v8.12.0 (#2331) 2025-08-01 03:34:40 +06:00
Mend Renovate
7ec28eb3bd Update dependency androidx.test.ext:junit-ktx to v1.3.0 (#2327)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-07-31 11:45:18 +06:00
Mend Renovate
ff9dfe45ed Update dependency androidx.test.espresso:espresso-core to v3.7.0 (#2326) 2025-07-31 11:44:35 +06:00
Mend Renovate
967750ba58 Update dependency androidx.benchmark:benchmark-macro-junit4 to v1.4.0 (#2325) 2025-07-31 04:40:29 +06:00
Mend Renovate
269af7fe2b Update dependency androidx.work:work-runtime to v2.10.3 (#2324) 2025-07-31 04:40:20 +06:00
AwkwardPeak7
62eec15fe6 Include Manga initialized status in backup (#2285) 2025-07-30 16:12:48 +06:00
Secozzi
2ef8ae11c9 Add option for rendering images in description (#2076) 2025-07-30 10:20:43 +06:00
Mend Renovate
4f1faf49f3 Update dependency androidx.compose:compose-bom to v2025.07.00 (#2284) 2025-07-30 10:13:12 +06:00
Mend Renovate
6d717ea88b Update okhttp monorepo to v5.1.0 (#2257) 2025-07-30 09:58:46 +06:00
Mend Renovate
fbb5e6b92f Update kotlin monorepo to v2.2.0 (#2235) 2025-07-30 09:58:04 +06:00
Mend Renovate
8f5f29e737 Update dependency org.jsoup:jsoup to v1.21.1 (#2233) 2025-07-30 09:57:50 +06:00
Mend Renovate
a4b9c704b6 Update dependency com.squareup.okio:okio to v3.16.0 (#2320) 2025-07-30 09:57:34 +06:00
Mend Renovate
9352201b03 Update plugin firebase-crashlytics to v3.0.5 (#2307) 2025-07-29 12:24:53 +00:00
Mend Renovate
c715e981bf Update dependency com.google.firebase:firebase-bom to v34 (#2310) 2025-07-29 18:21:12 +06:00
AntsyLich
7f56555d63 Make local source default chapter sorting match file explorer behavior
Closes #2225
2025-07-29 18:18:46 +06:00
Mend Renovate
8636b7a685 Update dependency com.squareup.logcat:logcat to v0.4 (#2319) 2025-07-29 12:14:31 +00:00
Mend Renovate
a49670bf0d Update xml.serialization.version to v0.91.2 (#2317) 2025-07-29 18:13:12 +06:00
Mend Renovate
61cee5c5e0 Update dependency com.squareup.logcat:logcat to v0.3 (#2309) 2025-07-26 03:12:20 +06:00
Mend Renovate
d805f0cd2a Update dependency com.pinterest.ktlint:ktlint-cli to v1.7.1 (#2281) 2025-07-25 00:29:27 +00:00
Mend Renovate
ce07259e8e Update dependency io.coil-kt.coil3:coil-bom to v3.3.0 (#2308) 2025-07-25 00:28:54 +00:00
Mend Renovate
2f10e7beaa Update lifecycle.version to v2.9.2 (#2283)
Update dependency androidx.lifecycle:lifecycle-process to v2.9.2
2025-07-25 06:18:24 +06:00
Mend Renovate
4ef8fb9588 Update dependency io.mockk:mockk to v1.14.5 (#2282) 2025-07-25 06:18:00 +06:00
Mend Renovate
084e626669 Update dependency com.diffplug.spotless:spotless-plugin-gradle to v7.2.1 (#2293) 2025-07-25 06:17:31 +06:00
Mend Renovate
f93ccaaaa4 Update dependency org.junit.jupiter:junit-jupiter to v5.13.4 (#2296) 2025-07-25 06:17:15 +06:00
Mend Renovate
5585388e2d Update dependency com.android.tools.build:gradle to v8.11.1 (#2277) 2025-07-15 07:20:29 +06:00
Matthias Ahouansou
d60241690b Use median to determine smart update interval (#2251)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-07-10 19:36:31 +06:00
Mend Renovate
84aa07b7f0 Update dependency com.squareup.okio:okio to v3.15.0 (#2256) 2025-07-09 20:31:01 +00:00
Mend Renovate
0cc1224094 Update dependency com.diffplug.spotless:spotless-plugin-gradle to v7.1.0 (#2274) 2025-07-10 02:19:55 +06:00
Mend Renovate
a5a0d83302 Update serialization.version to v1.9.0 (#2252) 2025-07-10 02:01:24 +06:00
Mend Renovate
1fde0275e3 Update dependency org.junit.jupiter:junit-jupiter to v5.13.3 (#2263) 2025-07-10 02:00:29 +06:00
Mend Renovate
a992f2d467 Update dependency gradle to v8.14.3 (#2264) 2025-07-10 02:00:17 +06:00
Mend Renovate
d8dd170d1b Update aboutlib.version to v12.2.4 (#2261) 2025-07-10 01:59:54 +06:00
Mend Renovate
6953090dab Update moko to v0.25.0 (#2258) 2025-07-10 01:59:32 +06:00
Mend Renovate
ab452a9945 Update plugin google-services to v4.4.3 (#2250) 2025-06-28 00:15:28 +06:00
Mend Renovate
7dd595f16e Update dependency com.google.firebase:firebase-bom to v33.16.0 (#2248) 2025-06-27 02:17:47 +06:00
Mend Renovate
6eb2a022f1 Update dependency com.android.tools.build:gradle to v8.11.0 (#2241) 2025-06-27 02:17:33 +06:00
Mend Renovate
d61c66c286 Update dependency org.junit.jupiter:junit-jupiter to v5.13.2 (#2240) 2025-06-27 02:17:23 +06:00
Mend Renovate
1e4ee14608 Update dependency io.mockk:mockk to v1.14.4 (#2232) 2025-06-27 02:17:01 +06:00
AntsyLich
63943debc2 Fix background crash in mass migration screen 2025-06-20 17:27:17 +06:00
Danny Wu
d2c1ff6adf Add option to hide missing chapter count (#2108)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-06-20 15:00:10 +06:00
AntsyLich
b9e02e92be Update manga without chapters even if restricted by source (#2224) 2025-06-20 08:51:49 +00:00
Mend Renovate
103218681a Update dependency com.mohamedrejeb.richeditor:richeditor-compose to v1.0.0-rc13 (#2213) 2025-06-20 14:33:45 +06:00
Mend Renovate
07136d3969 Update dependency androidx.compose:compose-bom to v2025.06.01 (#2220) 2025-06-20 14:33:13 +06:00
Mend Renovate
4962deeb0c Update dependency androidx.work:work-runtime to v2.10.2 (#2221) 2025-06-20 14:31:13 +06:00
Mend Renovate
cecf4596f9 Update dependency com.squareup.okio:okio to v3.13.0 (#2201) 2025-06-19 11:56:24 +06:00
Mend Renovate
0c77afbe03 Update aboutlib.version to v12.2.3 (#2205) 2025-06-19 11:56:08 +06:00
Mend Renovate
d126b84f95 Update sqlite to v2.5.2 (#2210) 2025-06-19 11:55:12 +06:00
AwkwardPeak7
2df3382148 Ensure app waits for Cloudflare challenge to complete before continuing (#2200)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-06-13 23:24:13 +06:00
jobobby04
ee19050cc0 Mass migration implementation (#2110)
There is no way to trigger mass migration at the moment. The functionality will be added in a follow up PR.

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-06-13 11:15:29 +00:00
Mend Renovate
8de1fa854d Update gradle/actions action to v4.4.1 (#2194) 2025-06-13 16:52:18 +06:00
AntsyLich
019fc08da2 Further tweak migration config screen sheet 2025-06-13 15:22:01 +06:00
AntsyLich
288f577a45 Add more migration config options and remove skipping option (#2193) 2025-06-12 04:18:13 +06:00
Mend Renovate
0290a2d815 Update softprops/action-gh-release action to v2.3.2 (#2184) 2025-06-11 12:10:15 +06:00
Mend Renovate
a47d4ebbdd Update aboutlib.version to v12.2.2 (#2190) 2025-06-11 11:58:59 +06:00
Mend Renovate
89954e68e3 Update dependency sh.calvin.reorderable:reorderable to v2.5.1 (#2183) 2025-06-10 05:38:58 +06:00
Mend Renovate
c3b590cd3d Update dependency sh.calvin.reorderable:reorderable to v2.5.0 (#2178) 2025-06-08 01:35:48 +06:00
Mend Renovate
cb3c5e9c9c Update dependency com.google.firebase:firebase-bom to v33.15.0 (#2177) 2025-06-08 01:35:36 +06:00
Mend Renovate
7fa2834009 Update plugin firebase-crashlytics to v3.0.4 (#2174) 2025-06-08 01:35:10 +06:00
Mend Renovate
95e3c22429 Update dependency gradle to v8.14.2 (#2168) 2025-06-08 01:34:45 +06:00
Mend Renovate
8bd70342fc Update okhttp monorepo to v5.0.0-alpha.16 (#2149) 2025-06-08 01:34:33 +06:00
Mend Renovate
92ec6b17a3 Update sqldelight to v2.1.0 (#2119)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-06-08 01:34:19 +06:00
Mend Renovate
591e9c1356 Update dependency org.junit.jupiter:junit-jupiter to v5.13.1 (#1754)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-06-08 01:34:02 +06:00
Mend Renovate
7fed9c2ccf Update dependency androidx.compose:compose-bom to v2025.06.00 (#2175) 2025-06-07 23:49:56 +06:00
Mend Renovate
ccb554c877 Update dependency com.squareup.logcat:logcat to v0.2.3 (#2126) 2025-06-07 23:45:49 +06:00
Mend Renovate
5235713d83 Update dependency androidx.appcompat:appcompat to v1.7.1 (#2167) 2025-06-07 23:42:07 +06:00
Mend Renovate
4692010400 Update lifecycle.version to v2.9.1 (#2173) 2025-06-07 23:39:19 +06:00
Mend Renovate
be528ba12b Update aboutlib.version to v12.2.1 (#2170) 2025-06-07 23:38:34 +06:00
Mend Renovate
405e536cbf Update dependency me.zhanghai.android.libarchive:library to v1.1.6 (#2171) 2025-06-07 23:38:15 +06:00
AntsyLich
8714653a2f Add option to skip migration config 2025-06-02 11:10:39 +06:00
AntsyLich
2b126f1ff5 Cleanup migrate manga dialog and related code (#2156) 2025-05-31 15:03:39 +00:00
AntsyLich
5919f34fc9 Fix no sources while migrating alongside UI and code cleanup (#2155) 2025-05-31 16:11:49 +06:00
Mend Renovate
a4df33caf9 Update dependency com.github.requery:sqlite-android to v3.49.0 (#2150) 2025-05-31 15:06:19 +06:00
Mend Renovate
3580d2da6c Update aboutlib.version to v12.2.0 (#2152) 2025-05-31 15:06:05 +06:00
claymorwan
77eb558742 Add Catppuccin theme (#2117)
Mocha for dark and Latte for light, mauve accent
2025-05-28 19:39:42 +00:00
Mend Renovate
e1f6d14393 Update dependency com.android.tools.build:gradle to v8.10.1 (#2148) 2025-05-29 01:32:59 +06:00
AntsyLich
2e180005a0 Add migration config screen to select and prioritize target sources (#2144) 2025-05-28 15:04:44 +00:00
Mend Renovate
0f59fc1dd4 Update markdown to v0.35.0 (#2143) 2025-05-28 19:41:12 +06:00
Mend Renovate
d1055475e2 Update dependency com.diffplug.spotless:spotless-plugin-gradle to v7.0.4 (#2146) 2025-05-28 19:36:25 +06:00
Mend Renovate
32470657dd Update dependency com.squareup.okio:okio to v3.12.0 (#2147) 2025-05-28 19:35:52 +06:00
Mend Renovate
158896cfa9 Update dependency me.zhanghai.android.libarchive:library to v1.1.5 (#2142) 2025-05-25 22:48:13 +06:00
Mend Renovate
92b376d9af Update dependency androidx.compose:compose-bom to v2025.05.01 (#2133) 2025-05-25 02:05:36 +06:00
Mend Renovate
1a2f09a622 Update dependency gradle to v8.14.1 (#2138) 2025-05-25 02:04:26 +06:00
AntsyLich
209e982fe4 Fix content cut off in home screen
Closes #2141
2025-05-25 02:03:56 +06:00
Mend Renovate
0109102901 Update plugin org.gradle.toolchains.foojay-resolver-convention to v1 (#2130) 2025-05-20 11:59:05 +06:00
Mend Renovate
4117a51674 Update dependency com.pinterest.ktlint:ktlint-cli to v1.6.0 (#2129) 2025-05-20 11:58:15 +06:00
Mend Renovate
4090a61d08 Update xml.serialization.version to v0.91.1 (#2112) 2025-05-17 15:02:29 +06:00
Mend Renovate
c406513557 Update gradle/actions action to v4.4.0 (#2118) 2025-05-17 15:02:00 +06:00
Mend Renovate
625c85cbd6 Update kotlin monorepo to v2.1.21 (#2102) 2025-05-14 22:03:26 +06:00
Mend Renovate
737ceeea57 Update dependency org.jetbrains.kotlinx:kotlinx-collections-immutable to v0.4.0 (#2104) 2025-05-14 22:03:06 +06:00
Mend Renovate
213b673b13 Update actions/dependency-review-action action to v4.7.1 (#2103) 2025-05-14 22:01:55 +06:00
Mend Renovate
7933c9eeb7 Update dependency io.coil-kt.coil3:coil-bom to v3.2.0 (#2101) 2025-05-14 22:01:23 +06:00
AntsyLich
f0de8f973b Disable reader's 'Keep screen on' setting by default (#2095) 2025-05-11 12:59:21 +00:00
AntsyLich
e8c6e3364d Update CHANGELOG.md [skip ci] 2025-05-10 19:41:18 +06:00
AntsyLich
c12bdbae8e Add full predictive back support (#2085)
Co-authored-by: p
2025-05-10 00:07:05 +00:00
Mend Renovate
33d407ee9c Update markdown to v0.34.0 (#2086) 2025-05-09 23:06:42 +00:00
AntsyLich
ef8c3ca119 Update voyager to v1.1.0-beta03 (#2087) 2025-05-10 04:49:02 +06:00
Mend Renovate
0cb3a4aeb6 Update actions/dependency-review-action action to v4.7.0 (#2082) 2025-05-09 04:10:04 +06:00
FlaminSarge
8b45ef0e5d Add advanced option to always update manga title from source (#1182)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-05-09 04:09:32 +06:00
AwkwardPeak7
86ebf55815 Fix pressing Enter while searching also triggering navigation back on physical keyboards (#2077)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-05-08 16:24:13 +06:00
Mend Renovate
ddf282b103 Update lifecycle.version to v2.9.0 (#2080) 2025-05-08 04:57:55 +06:00
Mend Renovate
744b809d45 Update sqlite to v2.5.1 (#2078) 2025-05-07 23:42:58 +06:00
Mend Renovate
cd2ce44efa Update dependency androidx.compose:compose-bom to v2025.05.00 (#2079) 2025-05-07 17:39:34 +00:00
Mend Renovate
cae7c3dc58 Update dependency com.android.tools.build:gradle to v8.10.0 (#2072) 2025-05-07 23:35:26 +06:00
Mend Renovate
c0074402e7 Update aboutlib.version to v12.1.2 (#2073) 2025-05-07 23:30:17 +06:00
AntsyLich
7deeabe844 Add autofill support to tracker login dialog and update processing text (#2069) 2025-05-04 19:36:37 +00:00
AntsyLich
536393a6d9 Fix downloader stopping after failing to create download directory of a manga (#2068) 2025-05-04 19:02:08 +00:00
AntsyLich
f8cb506137 Fix Pill not following the local text style
Closes #2009
2025-05-04 23:54:02 +06:00
AntsyLich
98230ed30f Cleanup MarkdownRender
Co-authored-by: p
2025-05-04 23:46:16 +06:00
Mend Renovate
d721a4321b Update dependency androidx.compose:compose-bom to v2025.04.01 (#2040)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-05-04 16:28:12 +00:00
Mend Renovate
1ac4b72cfe Update aboutlib.version to v12.1.0 (#2052) 2025-05-04 22:20:17 +06:00
Mend Renovate
9331f2b93f Update dependency io.mockk:mockk to v1.14.2 (#2057) 2025-05-04 02:15:46 +06:00
Mend Renovate
99c2a99973 Update dependency org.jsoup:jsoup to v1.20.1 (#2058) 2025-05-04 02:15:14 +06:00
Mend Renovate
001716e34b Update aboutlib.version (#2046) 2025-04-27 03:37:03 +06:00
NarwhalHorns
37e19edf8a Fix empty layout not appearing in browse source screen in some cases (#2043)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-04-27 03:36:50 +06:00
AntsyLich
8b7f355988 Switch default user agent to Android Chrome (#2048) 2025-04-26 19:29:33 +06:00
Mend Renovate
0b77733673 Update dependency com.google.firebase:firebase-bom to v33.13.0 (#2047) 2025-04-26 19:29:16 +06:00
Mend Renovate
9be558d6c0 Update dependency androidx.work:work-runtime to v2.10.1 (#2041) 2025-04-26 19:22:55 +06:00
Mend Renovate
0c8c5dbba6 Update dependency gradle to v8.14 (#2049) 2025-04-26 19:20:32 +06:00
AntsyLich
1c982c2a01 Fix crash when trying use source sort filter without a pre-selection (#2036) 2025-04-22 19:02:52 +06:00
Mend Renovate
eeab61fc94 Update dependency com.android.tools.build:gradle to v8.9.2 (#2033) 2025-04-22 19:02:34 +06:00
Mend Renovate
a036407c75 Update aboutlib.version to v12 (major) (#2016)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-04-20 20:40:51 +06:00
AntsyLich
615d93f780 Update dependency com.mohamedrejeb.richeditor:richeditor-compose to v1.0.0-rc11 2025-04-20 20:40:30 +06:00
AntsyLich
9750c1e4bd Fix content under source browse screen top appbar is interactable (#2026) 2025-04-20 19:12:41 +06:00
Secozzi
e2915a1f69 Update markdown to 0.33.0 and tweak visuals (#2024)
- Update markdown to 0.33.0
- Use github flavour for github changelog
- Fix bullet list alignment
2025-04-19 18:13:05 +00:00
AntsyLich
f6617a7a22 Fix labels not applying on issues and rearrange them [skip ci] 2025-04-19 12:07:29 +06:00
Mend Renovate
ef37a4c80b Update softprops/action-gh-release action to v2.2.2 (#2019) 2025-04-19 11:05:20 +06:00
AwkwardPeak7
df2b4c754b Remove Okhttp networking from WebView Screen (#2020) 2025-04-19 11:04:52 +06:00
KokaKiwi
6632a12228 Fix reader not updating progress (#2007)
The condition for updating progress is wrong since fefa8f8498
2025-04-18 09:50:42 +06:00
Joseph Madamba
0cb1925cf1 Update Facebook and Reddit icon (#1994) 2025-04-13 18:41:48 +00:00
ArthurKun
a31b3b7bbf Replace Modifier.composed with Composable Modifier (#1959)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-04-13 18:31:04 +00:00
AwkwardPeak7
fea85241af Include source headers when opening failed images from reader (#2004) 2025-04-13 23:05:42 +06:00
Secozzi
e273a26c9b Use simpler markdown flavour in manga description (#2000) 2025-04-13 16:30:04 +00:00
AwkwardPeak7
818e6931c6 Fix duplicate requests in WebView due to empty reasonPhrase (#2003) 2025-04-13 22:28:44 +06:00
AwkwardPeak7
ecc6ede081 Add option to keep read manga when clearing database (#1979) 2025-04-13 15:24:31 +00:00
AwkwardPeak7
fefa8f8498 Surface image loading error in Reader (#1981) 2025-04-13 20:46:12 +06:00
AwkwardPeak7
f1e2efcb37 Change Page.State to sealed interface (#1988) 2025-04-13 16:32:20 +06:00
Mend Renovate
180318f57d Update dependency androidx.core:core-ktx to v1.16.0 (#1990) 2025-04-13 16:31:31 +06:00
Mend Renovate
ed749de806 Update plugin org.gradle.toolchains.foojay-resolver-convention to v0.10.0 (#1992) 2025-04-13 16:30:47 +06:00
Mend Renovate
bb33b0029e Update markdown to v0.33.0-rc01 (#1999) 2025-04-13 16:29:02 +06:00
Mend Renovate
3a19e449b1 Update dependency androidx.compose:compose-bom to v2025.04.00 (#1989) 2025-04-10 11:08:47 +06:00
Mend Renovate
818edf2776 Update dependency com.squareup.okio:okio to v3.11.0 (#1991) 2025-04-10 11:05:44 +06:00
Mend Renovate
47d2646751 Update dependency io.mockk:mockk to v1.14.0 (#1987) 2025-04-09 23:19:11 +06:00
Mend Renovate
a1a7d67afb Update dependency androidx.sqlite:sqlite-framework to v2.5.0 (#1986) 2025-04-09 23:17:01 +06:00
Mend Renovate
2090a380e0 Update dependency com.diffplug.spotless:spotless-plugin-gradle to v7.0.3 (#1977) 2025-04-09 23:11:11 +06:00
Mend Renovate
8e5cfe9d0a Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-bom to v1.10.2 (#1978) 2025-04-09 23:10:56 +06:00
Mend Renovate
3249228c49 Update actions/setup-java action to v4.7.1 (#1983) 2025-04-09 23:04:51 +06:00
Cuong-Tran
d9c4b56336 Fix navigation issue after migrating a duplicated entry from History tab 2025-04-09 23:04:26 +06:00
AntsyLich
5e029b1fe6 Only enable telemetry in Mihon production apps (#1976) 2025-04-08 02:07:15 +06:00
NarwhalHorns
12abd9938b Display total chapters on duplicates list items (#1963) 2025-04-07 17:33:49 +00:00
Mend Renovate
c1225a5ef9 Update dependency androidx.compose:compose-bom to v2025.03.01 (#1927) 2025-04-07 15:22:20 +06:00
AntsyLich
a594ad392d Update non-library manga data when browsing (#1967) 2025-04-07 06:10:12 +00:00
AntsyLich
2259164fde Fix unintended app permissions due to Firebase misconfiguration (#1960) 2025-04-03 18:45:16 +06:00
Mend Renovate
80de032819 Update xml.serialization.version to v0.91.0 (#1956)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-04-03 18:32:02 +06:00
NarwhalHorns
0d35b6fdaf Display all similarly named duplicates in duplicate manga dialogue (#1861)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-04-03 01:54:46 +06:00
AntsyLich
f81da3dcce Deduplicate entries when browsing (#1957) 2025-04-03 01:48:54 +06:00
Mend Renovate
2ce9fa0271 Update serialization.version to v1.8.1 (#1953) 2025-04-02 15:53:48 +06:00
AntsyLich
300bee865f Switch readme download link to website (#1954) 2025-04-02 15:39:48 +06:00
Mend Renovate
542d30c14a Update actions/dependency-review-action action to v4.6.0 (#1955) 2025-04-02 14:24:27 +06:00
AntsyLich
5d2110f3fb Remove feature flag from Nord theme (#1951) 2025-04-01 12:53:32 +06:00
Secozzi
4e68339783 Add markdown support for manga descriptions (#1948) 2025-04-01 12:52:15 +06:00
AntsyLich
c8ffabc84a Significantly improve browsing speed (near instantaneous) (#1946) 2025-03-31 13:17:22 +06:00
Bartu Özen
77e79233ab Fix app bar action tooltips blocking clicks (#1928) 2025-03-31 07:02:40 +00:00
AntsyLich
8a21148578 Fix mark existing duplicate read chapters as read option not working in some cases (#1944) 2025-03-31 11:09:35 +06:00
AntsyLich
e91db86fae Fix user notes not restoring when manga doesn't exist in DB (#1945) 2025-03-31 11:09:10 +06:00
Mend Renovate
556290f2d3 Update kotlin monorepo to v2.1.20 (#1883)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-03-31 04:05:38 +00:00
Mend Renovate
8b947919ac Update dependency com.android.tools.build:gradle to v8.9.1 (#1913) 2025-03-31 09:59:59 +06:00
Mend Renovate
b62a9b40eb Update dependency androidx.benchmark:benchmark-macro-junit4 to v1.3.4 (#1926) 2025-03-31 09:47:07 +06:00
AntsyLich
a6b532ee57 Update editor config for 'sq' and 'sqm' file [skip ci] 2025-03-30 20:37:29 +06:00
kunet
8fbe630308 Add user manga notes (#428)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
2025-03-29 21:18:38 +00:00
perokhe
132d77aa99 Fix page number not appearing when opening chapter (#1936) 2025-03-29 15:52:11 +06:00
Cuong-Tran
b00bbe91be Fix benchmark build (#1938) 2025-03-29 15:51:18 +06:00
Jayman Rana
3e5d3d099f Fix backup sharing from notifications not working when app is in background (#1929) 2025-03-27 17:41:30 +00:00
perokhe
941dde341e Fix next chapter button occasionally jumping to the last page of the current chapter (#1920) 2025-03-27 14:25:25 +00:00
Mend Renovate
365d167eac Update gradle/actions action to v4.3.1 (#1921) 2025-03-27 16:25:50 +06:00
Ian Hunter
d4aaf6521e Add more Kaomoji for empty/error screens (#1909) 2025-03-26 03:19:02 +06:00
Mend Renovate
f7046a503b Update dependency com.google.firebase:firebase-bom to v33.11.0 (#1890) 2025-03-21 23:06:12 +06:00
MajorTanya
953c4e7bc0 Fix Bangumi search including novels (#1885) 2025-03-20 22:02:55 +06:00
188 changed files with 6400 additions and 1889 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@
Discover and read manga, webtoons, comics, and more easier than ever on your Android device.
[![Discord server](https://img.shields.io/discord/1195734228319617024.svg?label=&labelColor=6A7EC2&color=7389D8&logo=discord&logoColor=FFFFFF)](https://discord.gg/mihon)
[![GitHub downloads](https://img.shields.io/github/downloads/mihonapp/mihon/total?label=downloads&labelColor=27303D&color=0D1117&logo=github&logoColor=FFFFFF&style=flat)](https://github.com/mihonapp/mihon/releases)
[![GitHub downloads](https://img.shields.io/github/downloads/mihonapp/mihon/total?label=downloads&labelColor=27303D&color=0D1117&logo=github&logoColor=FFFFFF&style=flat)](https://mihon.app/download)
[![CI](https://img.shields.io/github/actions/workflow/status/mihonapp/mihon/build_push.yml?labelColor=27303D)](https://github.com/mihonapp/mihon/actions/workflows/build_push.yml)
[![License: Apache-2.0](https://img.shields.io/github/license/mihonapp/mihon?labelColor=27303D&color=0877d2)](/LICENSE)
@@ -18,8 +18,8 @@ Discover and read manga, webtoons, comics, and more easier than ever on your
## Download
[![Mihon Stable](https://img.shields.io/github/release/mihonapp/mihon.svg?maxAge=3600&label=Stable&labelColor=06599d&color=043b69)](https://github.com/mihonapp/mihon/releases)
[![Mihon Beta](https://img.shields.io/github/v/release/mihonapp/mihon-preview.svg?maxAge=3600&label=Beta&labelColor=2c2c47&color=1c1c39)](https://github.com/mihonapp/mihon-preview/releases)
[![Mihon Stable](https://img.shields.io/github/release/mihonapp/mihon.svg?maxAge=3600&label=Stable&labelColor=06599d&color=043b69)](https://mihon.app/download)
[![Mihon Beta](https://img.shields.io/github/v/release/mihonapp/mihon-preview.svg?maxAge=3600&label=Beta&labelColor=2c2c47&color=1c1c39)](https://mihon.app/download)
*Requires Android 8.0 or higher.*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,7 @@ fun GlobalSearchScreen(
navigateUp = navigateUp,
onChangeSearchQuery = onChangeSearchQuery,
onSearch = onSearch,
hideSourceFilter = false,
sourceFilter = state.sourceFilter,
onChangeSearchFilter = onChangeSearchFilter,
onlyShowHasResults = state.onlyShowHasResults,

View File

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

View File

@@ -32,6 +32,7 @@ fun MigrateSearchScreen(
navigateUp = navigateUp,
onChangeSearchQuery = onChangeSearchQuery,
onSearch = onSearch,
hideSourceFilter = true,
sourceFilter = state.sourceFilter,
onChangeSearchFilter = onChangeSearchFilter,
onlyShowHasResults = state.onlyShowHasResults,

View File

@@ -40,6 +40,7 @@ fun GlobalSearchToolbar(
navigateUp: () -> Unit,
onChangeSearchQuery: (String?) -> Unit,
onSearch: (String) -> Unit,
hideSourceFilter: Boolean,
sourceFilter: SourceFilter,
onChangeSearchFilter: (SourceFilter) -> Unit,
onlyShowHasResults: Boolean,
@@ -73,38 +74,40 @@ fun GlobalSearchToolbar(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
) {
// TODO: make this UX better; it only applies when triggering a new search
FilterChip(
selected = sourceFilter == SourceFilter.PinnedOnly,
onClick = { onChangeSearchFilter(SourceFilter.PinnedOnly) },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.PushPin,
contentDescription = null,
modifier = Modifier
.size(FilterChipDefaults.IconSize),
)
},
label = {
Text(text = stringResource(MR.strings.pinned_sources))
},
)
FilterChip(
selected = sourceFilter == SourceFilter.All,
onClick = { onChangeSearchFilter(SourceFilter.All) },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.DoneAll,
contentDescription = null,
modifier = Modifier
.size(FilterChipDefaults.IconSize),
)
},
label = {
Text(text = stringResource(MR.strings.all))
},
)
if (!hideSourceFilter) {
FilterChip(
selected = sourceFilter == SourceFilter.PinnedOnly,
onClick = { onChangeSearchFilter(SourceFilter.PinnedOnly) },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.PushPin,
contentDescription = null,
modifier = Modifier
.size(FilterChipDefaults.IconSize),
)
},
label = {
Text(text = stringResource(MR.strings.pinned_sources))
},
)
FilterChip(
selected = sourceFilter == SourceFilter.All,
onClick = { onChangeSearchFilter(SourceFilter.All) },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.DoneAll,
contentDescription = null,
modifier = Modifier
.size(FilterChipDefaults.IconSize),
)
},
label = {
Text(text = stringResource(MR.strings.all))
},
)
VerticalDivider()
VerticalDivider()
}
FilterChip(
selected = onlyShowHasResults,

View File

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

View File

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

View File

@@ -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,19 +61,13 @@ 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) },
onClick = {
onDownloadClicked(downloadAction)
onDismissRequest()
},
)
}
options.map { (downloadAction, string) ->
DropdownMenuItem(
text = { Text(text = string) },
onClick = {
onDownloadClicked(downloadAction)
onDismissRequest()
},
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,81 +97,310 @@ 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()
},
)
HorizontalDivider()
TextPreferenceWidget(
title = stringResource(MR.strings.action_add_anyway),
icon = Icons.Outlined.Add,
onPreferenceClick = {
onDismissRequest()
onConfirm()
},
)
Row(
modifier = Modifier
.sizeIn(minHeight = minHeight)
.clickable { onDismissRequest.invoke() }
.padding(ButtonPadding)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
LazyRow(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
modifier = Modifier.height(getMaximumMangaCardHeight(duplicates)),
contentPadding = horizontalPadding,
) {
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,
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(
title = stringResource(MR.strings.action_add_anyway),
icon = Icons.Outlined.Add,
onPreferenceClick = {
onDismissRequest()
onConfirm()
},
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

View File

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

View File

@@ -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) {
onAllChapterSelected(false)
} else {
navigateUp()
}
})
BackHandler(enabled = isAnySelected) {
onAllChapterSelected(false)
}
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) {
onAllChapterSelected(false)
} else {
navigateUp()
}
})
BackHandler(enabled = isAnySelected) {
onAllChapterSelected(false)
}
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,
)
}
},

View File

@@ -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,24 +201,28 @@ private fun RowScope.Button(
onLongClick = onLongClick,
onClick = onClick,
),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = title,
)
AnimatedVisibility(
visible = toConfirm,
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = title,
overflow = TextOverflow.Visible,
maxLines = 1,
style = MaterialTheme.typography.labelSmall,
Icon(
imageVector = icon,
contentDescription = title,
)
AnimatedVisibility(
visible = toConfirm,
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
) {
Text(
text = title,
overflow = TextOverflow.Visible,
maxLines = 1,
style = MaterialTheme.typography.labelSmall,
)
}
}
content?.invoke()
}
@@ -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,
)
}
}
Button(
title = stringResource(MR.strings.action_delete),
icon = Icons.Outlined.Delete,
toConfirm = confirm[4],
onLongClick = { onLongClickItem(4) },
onClick = onDeleteClicked,
)
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[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)

View File

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

View File

@@ -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,25 +623,48 @@ 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,
)
},
{
SelectionContainer {
Text(
text = if (expanded) expandedDescription else shrunkDescription,
maxLines = Int.MAX_VALUE,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.secondaryItemAlpha(),
Column {
MangaNotesSection(
content = notes,
expanded = true,
onEditNotes = onEditNotesClicked,
)
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,
)
}
}
},
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -145,6 +145,10 @@ object SettingsAppearanceScreen : SearchableSettings {
formattedNow,
),
),
Preference.PreferenceItem.SwitchPreference(
preference = uiPreferences.imagesInDescription(),
title = stringResource(MR.strings.pref_display_images_description),
),
),
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -99,4 +99,6 @@ private fun Manga.toBackupManga() =
lastModifiedAt = this.lastModifiedAt,
favoriteModifiedAt = this.favoriteModifiedAt,
version = this.version,
notes = this.notes,
initialized = this.initialized,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -104,6 +104,7 @@ class BangumiApi(
.awaitSuccess()
.parseAs<BGMSearchResult>()
.data
.filter { it.platform == null || it.platform == "漫画" }
.map { it.toTrackSearch(trackId) }
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
title = state.source!!.name,
state = state,
onClickItem = { navigator.push(MigrateSearchScreen(it.id)) },
onClickCover = { navigator.push(MangaScreen(it.id)) },
)
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 = 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) },
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 },
) {
val ascending = if (index == filter.state?.index) {
!filter.state!!.ascending
} else {
filter.state!!.ascending
}
filter.state = Filter.Sort.Selection(
index = index,
ascending = ascending,
)
onUpdate()
}
sortDescending = if (sortAscending != null) !sortAscending else null,
onClick = {
val ascending = if (index == filter.state?.index) {
!filter.state!!.ascending
} else {
filter.state?.ascending ?: true
}
filter.state = Filter.Sort.Selection(
index = index,
ascending = ascending,
)
onUpdate()
},
)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,18 +157,18 @@ class LibraryScreenModel(
combine(
getLibraryItemPreferencesFlow(),
getTrackingFilterFlow(),
) { prefs, trackFilter ->
(
listOf(
prefs.filterDownloaded,
prefs.filterUnread,
prefs.filterStarted,
prefs.filterBookmarked,
prefs.filterCompleted,
prefs.filterIntervalCustom,
) + trackFilter.values
).any { it != TriState.DISABLED }
getTrackingFiltersFlow(),
) { prefs, trackFilters ->
listOf(
prefs.filterDownloaded,
prefs.filterUnread,
prefs.filterStarted,
prefs.filterBookmarked,
prefs.filterCompleted,
prefs.filterIntervalCustom,
*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
LibraryItem(
libraryManga,
downloadCount = if (prefs.downloadBadge) {
downloadManager.getDownloadCount(libraryManga.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
} 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
) { libraryManga, preferences, _ ->
libraryManga.map { manga ->
LibraryItem(
libraryManga = manga,
downloadCount = if (preferences.downloadBadge) {
downloadManager.getDownloadCount(manga.manga).toLong()
} else {
0
},
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 {
""
},
)
}
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()
}
combine(prefFlows) {
loggedInTrackers
.mapIndexed { index, tracker -> tracker.id to it[index] }
.toMap()
if (loggedInTrackers.isEmpty()) {
flowOf(emptyMap())
} else {
val filterFlows = loggedInTrackers.map { tracker ->
libraryPreferences.filterTracking(tracker.id.toInt()).changes().map { tracker.id to it }
}
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 }
}
list.addAll(newSelections)
selectionRange.mapNotNull { items[it] }.let(list::addAll)
}
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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,12 +33,9 @@ class OnboardingScreen : Screen() {
val restoreSettingKey = stringResource(SettingsDataScreen.restorePreferenceKeyString)
BackHandler(
enabled = !shownOnboardingFlow,
onBack = {
// Prevent exiting if onboarding hasn't been completed
},
)
BackHandler(enabled = !shownOnboardingFlow) {
// Prevent exiting if onboarding hasn't been completed
}
OnboardingScreen(
onComplete = finishOnboarding,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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