Compare commits

..
272 Commits
Author SHA1 Message Date
FooIbar 6d1d737f9f Prepare for release 1.11.6-RC1
Releases / build (push) Has been cancelled
2024-06-02 23:38:02 +08:00
FooIbar bc75156232 Simplify ReadableTime.getFilenamableTime 2024-06-02 22:51:58 +08:00
FooIbar 2101ea7a36 Update mime types based on Android's mime.types 2024-06-02 22:22:42 +08:00
FooIbar 26921c8ae1 Fix data export/import on Android V
The mime type mapping for sqlite files was added in
https://r.android.com/2988915.
2024-06-02 22:22:42 +08:00
FooIbar be1eecf3b5 Use official coroutines API instead of manual cast 2024-06-02 19:49:21 +08:00
FooIbar 2d4f154c9b Prevent async failure from cancelling parent scope 2024-06-02 19:49:21 +08:00
renovate[bot] d82cbe51ba Update dependency io.github.fornewid:material-motion-compose-core to v1.2.1 2024-06-02 03:13:01 +00:00
FooIbar d13ffa93ba Workaround for navigation bars padding on API < 28 2024-06-01 22:34:30 +08:00
FooIbar 2cce55953f Fix quick search and download label reordering
This is a regression introduced by 6277d7d.
2024-06-01 22:15:44 +08:00
FooIbar 07d100fcc0 Show navigation drawer only on top-level screens (#1266)
* Show navigation drawer only on top-level screens

* Remove redundant `rememberUpdatedState`
2024-06-01 18:59:16 +08:00
FooIbar cc04371181 Fix navigation drawer overlapping side sheet 2024-06-01 13:08:49 +08:00
github-actions[bot] ee50c88765 Update Chrome version for default User-Agent 2024-06-01 09:02:43 +08:00
renovate[bot] efc5b55ea7 Update dependency gradle to v8.8 2024-05-31 23:42:12 +00:00
FooIbar 94af66156a Replace invalid/consecutive whitespaces in query 2024-06-01 01:52:03 +08:00
FooIbar 4861359f9e Make secondary FAB offset density-aware 2024-06-01 01:51:40 +08:00
renovate[bot] 5af167dab4 Update compose.destinations to v2.1.0-beta09 2024-05-31 14:19:32 +00:00
renovate[bot] db1f0bc6d7 Update dependency com.android.application to v8.6.0-alpha04 2024-05-30 23:33:55 +00:00
renovate[bot] b94a843ec5 Update aboutlibraries to v11.2.1 2024-05-30 08:27:09 +00:00
FooIbar b27d214f24 Use Non-H as default category for guest mode
Don't modify the URL builder.
2024-05-30 10:57:26 +08:00
FooIbar a81eb9d17b Fix default value 2024-05-30 10:57:26 +08:00
FooIbar ec9b85f30f Refactor to not get sign-in state from cookies 2024-05-30 10:57:26 +08:00
FooIbar 8ac2f33949 Update liblzma to v5.6.2 2024-05-30 09:32:18 +08:00
FooIbar d84198ab28 Move Compose out of snapshot 2024-05-30 09:22:20 +08:00
renovate[bot] 34d339d9f1 Update compose.destinations to v2.1.0-beta08 2024-05-30 00:26:53 +00:00
renovate[bot] bcfefc7c76 Update dependency dev.chrisbanes.compose:compose-bom to v2024.05.00-alpha03 2024-05-29 21:16:26 +00:00
renovate[bot] 62539dc48e Update dependency androidx.appcompat:appcompat to v1.7.0 2024-05-29 21:15:22 +00:00
renovate[bot] 453ab11395 Update androidx.lifecycle to v2.8.1 2024-05-29 18:36:58 +00:00
renovate[bot] b2246b980a Update androidx.room to v2.7.0-alpha03 2024-05-29 18:36:07 +00:00
FooIbar 822a461acd Use All as default recent download label 2024-05-28 22:46:18 +08:00
renovate[bot] 25e5c0ab8e Lock file maintenance 2024-05-27 01:53:10 +00:00
FooIbar 2a55db95f4 Workaround for ReaderPageSheet clipped on API < 28 2024-05-26 17:17:50 +08:00
FooIbar a144866182 Use commit time instead for reproducible builds 2024-05-25 16:56:17 +08:00
FooIbar be7a04a4cb Move kotlin options to kotlin block 2024-05-24 22:18:18 +08:00
renovate[bot] fd1b861dab Update dependency com.android.application to v8.6.0-alpha03 2024-05-24 01:09:17 +00:00
FooIbar 957ebd35aa Update Compose snapshot
https://issuetracker.google.com/333584635
2024-05-23 16:31:14 +08:00
FooIbar 29d8b11771 Fix quota exceeded exceptions being swallowed 2024-05-22 18:37:28 +08:00
renovate[bot] c87059e9d7 Update Kotlin & KSP 2024-05-22 09:51:29 +08:00
renovate[bot] 4babe2eec4 Update aboutlibraries to v11.2.0 2024-05-21 23:45:18 +00:00
FooIbar 48fdaaca0c Fix race condition of ComicInfo.xml modification 2024-05-21 19:54:41 +08:00
FooIbar f0ee88b4d6 [no ci] Fix renovate config 2024-05-21 17:32:37 +08:00
FooIbar 9210780b6b Fix renovate false positive 2024-05-21 17:26:30 +08:00
FooIbar b479e28543 Initial support for importing downloaded archives 2024-05-21 14:20:57 +08:00
FooIbar f93b98fc5e Fix user input not being used when rating gallery 2024-05-20 11:51:29 +08:00
renovate[bot] 4721d1011b Lock file maintenance 2024-05-20 00:43:28 +00:00
FooIbar 4da4443e67 [no ci] Remove unused res 2024-05-20 00:25:13 +08:00
FooIbar e5343f4360 Don't package 32-bit x86 libs into universal apk 2024-05-20 00:24:05 +08:00
FooIbar 86323220dc Prepare for next iteration 2024-05-19 23:06:35 +08:00
FooIbar ada8f3af75 Prepare for release 1.11.5
Releases / build (push) Has been cancelled
2024-05-19 23:06:00 +08:00
FooIbar fb940d3982 Group downloads by artist if no labels are defined 2024-05-19 23:02:29 +08:00
FooIbar 8e8729c25f Introduce AppConfig.isSnapshot 2024-05-19 23:01:25 +08:00
FooIbar ffdd063ca1 Revert "Update Compose snapshot"
This reverts commit e6bdaf1172.

Reason for revert: https://issuetracker.google.com/341320533
2024-05-19 20:44:51 +08:00
FooIbar dfd5c6c70e Remove unnecessary synchronization
We use thread-safe methods now.
2024-05-19 20:32:08 +08:00
FooIbar 3068aab681 Use CI update channel by default for snapshots (#1223)
* Use CI update channel by default for snapshots

And use build time as initial update time.

* Fix build time
2024-05-19 20:25:59 +08:00
renovate[bot] e402587f8f Update plugin composeCompilerReportGenerator to v1.3.1 2024-05-19 10:52:18 +00:00
FooIbar 00b1be879d Fix app language out of sync with system settings (#1221)
* Fix app language out of sync with system settings

* Manually update current language

Recomposition may not happen when current language is the same as system
 default.
2024-05-19 10:43:24 +00:00
ɴᴇᴋᴏ e63bd49a4c Update TW Translation (#1081)
* Add new TW Translation

* Fix translation

* change translate

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* change to '圖庫'

* 更新 strings.xml

* space

* fix

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml
2024-05-19 15:41:10 +08:00
renovate[bot] 8131170eb1 Update compose.destinations to v2.1.0-beta07 2024-05-18 20:13:22 +00:00
FooIbar dc1abd3806 Migrate to Compose Material3 Adaptive (#1219)
* Migrate to Compose Material3 Adaptive

* Fix R8
2024-05-18 20:56:03 +08:00
FooIbar 468fbd50da Fix tap zone overlay not following inversion 2024-05-18 18:53:26 +08:00
FooIbar f2011f9b6f Fix reader tap zone inversion not working 2024-05-18 18:53:26 +08:00
FooIbar 1e53a9b389 Make sure favorites don't have the same timestamp 2024-05-18 10:46:46 +08:00
renovate[bot] dc2504b67f Update dependency com.android.application to v8.6.0-alpha02 2024-05-17 23:06:40 +00:00
FooIbar 2ae3e3b64c Reland "Use autogenerated localeConfig"
Reland as https://issuetracker.google.com/294109810 has been fixed.
2024-05-17 23:50:48 +08:00
FooIbar e6bdaf1172 Update Compose snapshot
https://issuetracker.google.com/339188523
2024-05-17 14:34:53 +08:00
FooIbar 36a66d9199 Revert "Workaround for renovate false positive"
This reverts commit fb6fbb8df9.
2024-05-17 14:23:21 +08:00
FooIbar fb6fbb8df9 Workaround for renovate false positive 2024-05-17 12:17:12 +08:00
renovate[bot] ef8449dc02 Update compose.destinations to v2.1.0-beta06 2024-05-16 16:06:28 +00:00
FooIbar b59f8e9ade Use Non-H category as homepage in guest mode 2024-05-16 01:46:14 +08:00
FooIbar a49506f4a0 Update Compose snapshot to fix item clipping
https://issuetracker.google.com/340201510
2024-05-15 21:26:07 +08:00
FooIbar 4ce3d25eed Remove unused code 2024-05-15 17:28:42 +08:00
FooIbar 81b166bee1 Unpin dependencies 2024-05-15 10:58:53 +08:00
renovate[bot] 37a587859a Update dependency dev.chrisbanes.compose:compose-bom to v2024.05.00-alpha02 2024-05-15 10:47:04 +08:00
renovate[bot] b7544630bb Update dependency com.android.application to v8.6.0-alpha01 2024-05-15 02:43:51 +00:00
renovate[bot] 05c7a0fa39 Update dependency androidx.paging:paging-compose to v3.3.0 2024-05-15 02:43:16 +00:00
renovate[bot] eee7655f80 Update dependency org.jetbrains.kotlinx:kotlinx-datetime to v0.6.0 2024-05-15 02:42:54 +00:00
renovate[bot] 9390813c48 Update dependency androidx.lifecycle:lifecycle-runtime-compose to v2.8.0 2024-05-15 02:42:37 +00:00
renovate[bot] d032fbdd67 Update dependency androidx.annotation:annotation to v1.8.0 2024-05-14 23:15:41 +00:00
renovate[bot] adcd73a590 Update dependency androidx.appcompat:appcompat to v1.7.0-rc01 2024-05-14 23:15:00 +00:00
renovate[bot] 87b405bf1f Update androidx.room to v2.7.0-alpha02 2024-05-14 17:41:39 +00:00
renovate[bot] d8306e2c80 Update androidx.lifecycle to v2.8.0 2024-05-14 17:41:20 +00:00
FooIbar c03fe9b51e Address java.util.regex.Pattern usage 2024-05-15 01:31:54 +08:00
FooIbar 9099014d28 Remove reference of "Sign in via cookies" 2024-05-15 01:31:54 +08:00
FooIbar d7b3872486 Support autofill in SignInScreen 2024-05-15 01:31:54 +08:00
FooIbar 4ae2af0bef Adjust SignInScreen layout 2024-05-15 01:31:54 +08:00
FooIbar c9bcd46b77 Revert "Drop basic login as captcha is mandatory now"
This reverts commit e07aab4434.
2024-05-15 01:31:54 +08:00
FooIbar e531653634 Workaround for archive list crash when scrolling
It has fixed size and we don't actually expect its items to be reused.
2024-05-14 13:59:24 +08:00
renovate[bot]andFooIbar a29e4c72b1 Update Kotlin & KSP (#1171)
* Update Kotlin & KSP

* Fix build

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-05-14 12:14:54 +08:00
FooIbar 9e1f7ca051 Fix crash in favorites caused by duplicate items 2024-05-13 22:42:39 +08:00
renovate[bot] fbbc347411 Update dependency sh.calvin.reorderable:reorderable to v2.1.1 2024-05-13 04:11:24 +00:00
renovate[bot] ea364536a4 Lock file maintenance 2024-05-13 01:32:13 +00:00
github-actions[bot] 575fc9e94c Update Chrome version for default User-Agent 2024-05-12 14:53:50 +08:00
FooIbar 703c63125a [no ci] Don't push to main branch directly 2024-05-12 14:42:53 +08:00
FooIbar b205b5e120 Keep default User-Agent updated 2024-05-12 11:44:28 +08:00
FooIbar e27f36d2c8 Don't swallow exceptions when importing database 2024-05-12 11:39:29 +08:00
FooIbar d582eddc03 Update Corrosion to v0.5 2024-05-12 02:08:29 +08:00
FooIbar 3b83d8744c Revert "Update dependency com.android.application to v8.5.0-beta01"
This reverts commit 7c39b95025.
2024-05-11 13:18:20 +08:00
renovate[bot] 68a5217b1c Lock file maintenance 2024-05-11 04:39:15 +00:00
FooIbar c01082b822 Revert pointless bumps and check in Cargo.lock 2024-05-11 12:26:25 +08:00
FooIbar bb95c86320 Update Compose snapshot 2024-05-11 11:56:41 +08:00
FooIbar d398926aeb Simplify rating widget implementation 2024-05-11 03:23:40 +08:00
FooIbar 7b23efb0ce Fix rememberMutableStateInDataStore persistence 2024-05-11 03:22:22 +08:00
FooIbar 7818ff197c Fix R8 2024-05-11 03:21:37 +08:00
FooIbar 95bfc4f8c5 Introduce AnchoredHorizontalDraggable to prevent unintentional drags 2024-05-11 03:21:37 +08:00
FooIbar c750016034 Revert "Revert "Drop experimental touch slop factor setting" (#1162)"
This reverts commit a4619547f1.
2024-05-11 03:21:37 +08:00
FooIbar 6298e501cd Save minimum rating search option in preference (#1163) 2024-05-11 03:18:01 +08:00
renovate[bot] 7c39b95025 Update dependency com.android.application to v8.5.0-beta01 2024-05-10 18:46:00 +00:00
renovate[bot] 02c1e630d1 Update dependency org.jetbrains.kotlinx:kotlinx-coroutines-android to v1.8.1 2024-05-10 11:36:12 +00:00
GG Bond 8f3b0cc536 Refine naming 2024-05-10 19:27:45 +08:00
GG Bond d0c5ef8e8a Refine naming 2024-05-10 19:27:45 +08:00
GG Bond d628024637 Refine 2024-05-10 19:27:45 +08:00
GG Bond fe1249630b Convert FavDialogTheta to use rememberInDataStore 2024-05-10 19:27:45 +08:00
GG Bond cdbe3cf728 Drop unused avatar pref 2024-05-10 19:27:45 +08:00
GG Bond f5e36bc0cf Initialize dataStateFlow on app start 2024-05-10 19:27:45 +08:00
GG Bond 4f9bfe13cb Move SearchCategory to use rememberInDataStore 2024-05-10 19:27:45 +08:00
GG Bond 959da21b9d Introduce rememberInDataStore 2024-05-10 19:27:45 +08:00
renovate[bot]andGG Bond fae2b1f416 Cleanup rememberInVM family (#1166)
* Update DataStore to 1.1.1 for future `rememberInDataStore` impl

* Cleanup `rememberInVM` family

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-05-10 06:15:18 +00:00
GG Bond a054633d0a Update DataStore to 1.1.1 for future rememberInDataStore impl 2024-05-10 13:49:36 +08:00
FooIbar a4619547f1 Revert "Drop experimental touch slop factor setting" (#1162)
* Revert "Drop experimental touch slop factor setting"

This reverts commit d5d8a828ef.

Reason for revert: After some more testing I think sadly we still need it.

* Read touch slop factor as state
2024-05-10 13:08:32 +08:00
FooIbar 5d53febb15 Show gallery pages in thumbnail mode (#1164) 2024-05-10 04:41:42 +00:00
FooIbar 3da1587924 Use HapticFeedback in ChapterNavigator as well 2024-05-10 12:22:09 +08:00
FooIbar 971d0307fe Inline DragHandle as it's redundant now 2024-05-10 12:22:09 +08:00
FooIbar 705d3afb78 Refactor haptic feedback on reordering 2024-05-10 12:22:09 +08:00
renovate[bot] bc34b4b9c1 Update dependency sh.calvin.reorderable:reorderable to v2.1.0 2024-05-09 09:59:57 +00:00
FooIbar a2ac01fcdc Revert "Update dependency sh.calvin.reorderable:reorderable to v2.0.4"
This reverts commit 4cea8d0090.

Reason for revert: The item being dragged becomes invisible.
2024-05-09 14:44:10 +08:00
FooIbar 645ebddd4f Fix crash when saving unloaded images 2024-05-09 14:23:11 +08:00
FooIbar d5d8a828ef Drop experimental touch slop factor setting 2024-05-09 12:18:59 +08:00
FooIbar 96d4193d2d Fix system bars insets in fullscreen on API < 30 2024-05-09 12:14:59 +08:00
FooIbar 18e4afdff9 Update Compose snapshot 2024-05-09 12:13:28 +08:00
FooIbar 19a702ffc4 Migrate to main artifact of AndroidX WorkManager
`work-runtime-ktx` is empty since 2.9.0
2024-05-08 17:55:39 +08:00
renovate[bot] cfd44c256f Update compose.destinations to v2.1.0-beta05 2024-05-07 23:13:30 +00:00
renovate[bot] 4cea8d0090 Update dependency sh.calvin.reorderable:reorderable to v2.0.4 2024-05-07 19:49:57 +00:00
FooIbar 30e166b29c Fix DownloadService state not cleared 2024-05-07 16:34:23 +08:00
FooIbar c50f3d8c5b Fix tap control area shifting after zooming out 2024-05-07 15:37:38 +08:00
FooIbar d9249eeedb Fix tap control area shifting in multi-window mode 2024-05-07 15:37:38 +08:00
FooIbar 51699cafe6 Constrain minimum width of reader bottom bar menus 2024-05-07 15:37:17 +08:00
FooIbar ce97976bdd Refactor bottom bar icon dropdown menus in reader
Avoid hard-coded width and offset.
2024-05-07 15:37:17 +08:00
renovate[bot]andFooIbar 7b8a12e3eb Update dependency sh.calvin.reorderable:reorderable to v2.0.3 (#1143)
* Update dependency sh.calvin.reorderable:reorderable to v2.0.3

* Address deprecation

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-05-07 03:49:33 +00:00
FooIbar 7276c37c54 Don't constrain menu's width 2024-05-07 01:17:55 +08:00
FooIbar 4ec7de611c Introduce DropdownFilterChip for SearchFilter 2024-05-07 01:17:55 +08:00
FooIbar 0295c40b02 Implement fullscreen mode in new reader 2024-05-06 19:53:32 +08:00
FooIbar 035fa44523 Correctly handle display cutout (#1139) 2024-05-06 14:34:22 +08:00
renovate[bot] 60c6618db1 Update compose.destinations to v2.1.0-beta04 2024-05-05 23:53:49 +00:00
FooIbar f35643b2b6 Correctly handle window insets in reader
Now it's actual edge-to-edge.
2024-05-06 07:46:15 +08:00
FooIbar b25661c436 Revert "Clarify message for no current torrents"
This reverts commit 49d4ae07

Reason for revert: We list outdated torrents as well after torrent
dialog overhaul.
2024-05-05 18:05:40 +08:00
FooIbar cea06f0c7e Disable abortOnError for lint in debug build
[no ci]
2024-05-05 17:16:16 +08:00
FooIbar 7de6a821d9 Drop 32-bit x86 support 2024-05-05 11:17:26 +08:00
FooIbar 13be4e4eb1 Remove Navigation Compose dependency
It's transitively provided by Compose Destinations, we should not
directly depend on it in case they are incompatible.
2024-05-05 07:58:46 +08:00
renovate[bot] 691e7c3ba7 Update compose.destinations to v2.1.0-beta03 2024-05-04 18:08:26 +00:00
FooIbar 31aea1cdbf Fix duplicate key in search suggestions 2024-05-05 01:59:49 +08:00
FooIbar 97d5642427 Run download item restoration in parallel 2024-05-04 18:52:05 +08:00
FooIbar c68272c1a4 Fix download item restoration not bailing on error 2024-05-04 17:59:34 +08:00
renovate[bot] a69dc994b3 Update dependency sh.calvin.reorderable:reorderable to v2.0.1 2024-05-04 09:27:19 +00:00
renovate[bot] d79a71c764 Update dependency dev.chrisbanes.compose:compose-bom to v2024.05.00-alpha01 2024-05-04 08:31:58 +00:00
renovate[bot] 35183de477 Update dependency androidx.navigation:navigation-compose to v2.8.0-alpha08 2024-05-04 16:31:41 +08:00
FooIbar 21b9fe3cb2 Update to snapshot Compose and remove workaround (#1050)
* Update to snapshot Compose

* Fix dep

* Revert "More workaround"

This reverts commit 9013778f58.

* Revert "Workaround for lazy layout lagging (#1042)"

This reverts commit b83e0fd0

* Revert "Workaround for comment list lagging at the bottom"

This reverts commit 45be957df5.

* Fix minOffset for blur radius calculation

Also use `onSizeChanged` instead of `onGloballyPositioned` as
measurement happens before placement.
2024-05-04 16:22:01 +08:00
FooIbar 5762b0bfc3 Disallow screen capture on identity cookies dialog 2024-05-04 11:30:34 +08:00
renovate[bot] 3473e91747 Update dependency com.android.application to v8.5.0-alpha08 2024-05-03 23:07:00 +00:00
renovate[bot]andFooIbar 6277d7d41d Update dependency sh.calvin.reorderable:reorderable to v2 (#1120)
* Update dependency sh.calvin.reorderable:reorderable to v2

* Fix build

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-05-03 09:36:56 +00:00
renovate[bot] f22da2b771 Revert androidx navigation update 2024-05-03 17:07:55 +08:00
FooIbar 3466bbd64a Disable grouping for AndroidX dependencies 2024-05-03 16:33:25 +08:00
FooIbar 49fc87621c Revert "Limit workflow concurrency"
This reverts commit e81092d42c.
2024-05-03 16:33:25 +08:00
GG Bond 7e558f4478 DialogState.bgWork doesn't show dialog if work doesn't suspend 2024-05-03 15:29:13 +08:00
FooIbar 307624ca9a Copy invalid filename handling logic from AOSP 2024-05-03 15:29:00 +08:00
FooIbar 40e66b919a Move ArchiveList and TorrentList to separate files (#1113)
* Move ArchiveList and TorrentList to separate files

GalleryDetailScreen is too long

* Refactor with deferred archive and torrent results
2024-05-03 07:27:57 +08:00
FooIbar cf2e0aadd5 [no ci] Fix renovate config 2024-05-02 13:56:25 +08:00
FooIbar 1b523f6a78 [no ci] Tweak renovate config 2024-05-02 13:48:50 +08:00
renovate[bot] 6a9f6bb645 Drop obsolete flag
Room generate kotlin by default now.
2024-05-02 07:17:16 +08:00
renovate[bot] 45602b05c6 Update aboutlibraries to v11.1.4 2024-05-01 19:09:42 +00:00
renovate[bot] 8dc842e125 Update AndroidX & Jetpack Compose 2024-05-01 19:09:18 +00:00
renovate[bot] 9dfc3a49fe Update Rust crate tl to 0.7.8 2024-05-01 17:10:59 +00:00
renovate[bot] 97d4a61140 Update Rust crate serde_cbor to 0.11.2 2024-05-01 17:10:41 +00:00
renovate[bot] 8b596e2274 Update Rust crate sha1 to 0.10.6 2024-05-01 17:10:15 +00:00
renovate[bot] 123b86830b Update Rust crate serde to 1.0.200 2024-05-01 16:40:54 +00:00
renovate[bot] e11f5615a1 Update Rust crate regex-lite to 0.1.5 2024-05-01 14:16:12 +00:00
FooIbar 31fb2ed2b1 Address renovate warning
[no ci]
2024-05-01 22:15:59 +08:00
renovate[bot] 9c12d11c85 Update Rust crate serde to 1.0.199 2024-05-01 14:14:59 +00:00
renovate[bot] aea0e885c3 Update Rust crate log to 0.4.21 2024-05-01 12:43:51 +00:00
renovate[bot] f34ff71145 Update Rust crate jni to 0.21.1 2024-05-01 12:43:29 +00:00
FooIbar 820c16e86b Tweak image limits dialog 2024-05-01 19:39:24 +08:00
renovate[bot] a335348b1a Update Rust crate android_logger to 0.13.3 2024-05-01 10:41:05 +00:00
renovate[bot] 7f134e1995 Update Rust crate anyhow to 1.0.82 2024-05-01 10:40:43 +00:00
FooIbar 9e13c1c3db Revert "Update Kotlin & KSP (#1084)"
This reverts commit 9ff74e789c.

Reason for revert: crash
2024-05-01 01:29:14 +08:00
FooIbar 61161cef0f Torrent dialog overhaul 2024-05-01 01:06:57 +08:00
renovate[bot]andFooIbar 9ff74e789c Update Kotlin & KSP (#1084)
* Update Kotlin & KSP

* Fix build

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-04-30 12:48:06 +00:00
FooIbar 45183df640 Avoid packing/unpacking and use pre-allocated list 2024-04-30 17:13:59 +08:00
FooIbar 22f11994f5 Remove obsolete workaround 2024-04-30 12:46:46 +08:00
renovate[bot] bc7aa832b7 Update dependency com.android.application to v8.5.0-alpha07 2024-04-29 21:37:45 +00:00
FooIbar b32f78ea9f Archive dialog overhaul 2024-04-29 23:49:40 +08:00
FooIbar 78002dac3a Use proper placeholder instead of 0 in image limit 2024-04-29 16:29:46 +08:00
renovate[bot]andGG Bond aca70354e7 Refactor FabBuilder interface & refine suspend dialog naming (#1082)
* `FabLayoutValue` is `Stable`

* FabBuilder accepts a autoClose

* Refactor control flow to pattern matching expression and avoid exception

* Close fab on DatePicker selected

* Refine naming

* Introduce `SnapshotStateMap<K, V>.takeAndClear()`

* PutAll action use `autoClose = false`

* Handle exceptions

* Cleanup

* Handle potential e

* Refine to use autoClose

* FabLayout should use parent Screen CoroutineScope

* Use implicit context

* Introduce `runSwallowingWithUI`

* log exception as well

* Refine naming

* Cleanup with `runSwallowingWithUI`

* Refine naming again

* Fixup

* Remove redundant logcat

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-04-29 04:20:37 +00:00
FooIbar d7a3471b9b Fix side drawer anchor updates during initial open 2024-04-28 23:43:55 +08:00
FooIbar e81092d42c Limit workflow concurrency
[no ci]
2024-04-28 18:51:11 +08:00
renovate[bot] d06df3c819 Update dependency me.saket.telephoto:zoomable to v0.11.2 2024-04-28 10:17:26 +00:00
FooIbar 050bfea8f2 Parse favorite categories from user config page 2024-04-28 18:09:17 +08:00
renovate[bot] 7b84c74abf Update dependency me.saket.telephoto:zoomable to v0.11.1 2024-04-28 06:46:40 +00:00
FooIbar 009c49cd89 Don't fail silently if can't install update 2024-04-28 14:37:31 +08:00
renovate[bot] ca023f73f4 Update dependency me.saket.telephoto:zoomable to v0.11.0 2024-04-28 05:53:51 +00:00
renovate[bot]andFooIbar 456dfd2aac Update compose.destinations to v2.1.0-beta02 (#1073)
* Update compose.destinations to v2.1.0-beta02

* Fix build

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-04-28 05:45:03 +00:00
renovate[bot] 0c0291127c Revert "Drop Xjvm-default=all"
This reverts commit df1efa7604.
2024-04-27 16:59:41 +08:00
FooIbar fe0c74de23 MimeTypeMap is case-sensitive prior to Android O
https://android-review.googlesource.com/251120
2024-04-27 16:54:06 +08:00
renovate[bot] df1efa7604 Drop Xjvm-default=all
We are not a library intended for Javaers nor do we have any Java code to interop so this is meaningless and safely to drop
2024-04-27 03:41:54 +08:00
FooIbar 73122fc517 Workaround for SearchBar reopen in non-touch mode 2024-04-27 03:34:27 +08:00
renovate[bot] b506ba277c Drop -Xlambdas=indy
https://kotlinlang.org/docs/whatsnew-eap.html#generate-lambda-functions-using-invokedynamic
2024-04-26 23:00:41 +08:00
dasoopsandFooIbar ea3aed8fae Reload metadata (#1058)
* Reload metadata

* Remove pages check and Refactor

* Check metadata stable

* Run in parallel

* Refactor postedTime

* Remove concurrency limit

* Update translation

* Update translate

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>

* `parseDate` return timestamps

* Remove unused import

* Show Snackbar after the update

* Revert EndLine

* use localContext

* Extend the range of `runSuspendCatching`

---------

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-04-26 21:05:52 +08:00
FooIbar 481bb8e7d4 Update libarchive to v3.7.4 2024-04-26 19:01:03 +08:00
FooIbar 0ed8c40dbf Improve a11y by limiting grid item's aspect ratio 2024-04-26 18:08:32 +08:00
FooIbar d267d7c3b0 [no ci] Change links in README to use https 2024-04-26 17:48:27 +08:00
FooIbar a04206f600 Refine UniFile and evict lookup cache on delete
Also add comments on functions that rely on implementation details.
2024-04-26 15:46:47 +08:00
FooIbar 8d64a6f1b2 Update ndk and support 16 KB page sizes (#1057)
* Update ndk and support 16 KB page sizes

* Fix CI

* Update graphics-path and pass linker flag to rustc
2024-04-25 14:37:01 +00:00
dasoops b3f614e150 Sort artists by gallery count (#1059)
* Sort artists by number of galleries

* Sorted in DB query
2024-04-25 11:23:11 +00:00
renovate[bot]andGG Bond 988fae56fd Add fake implicit syntax for migration (#1056)
* Add fake `implicit` syntax

* Refine

* Refine

* Refine

* Refine

---------

Co-authored-by: GG Bond <whatever@tagkong.com>
2024-04-25 00:17:33 +08:00
FooIbar fa439fc62d Switch back to GitHub xz repo
It should be faster.
2024-04-24 22:05:35 +08:00
GG Bond c58b2d6156 Cleanup realPadding calculation 2024-04-24 19:40:30 +08:00
GG Bond 62c56b7918 Add missing lazy item key 2024-04-24 19:40:30 +08:00
GG Bond 73882ab55d Cleanup DownloadsScreen to use implicit context 2024-04-24 19:17:34 +08:00
490ea2b521 Add Option to Select Downloads Grouping Mode (#998)
* Add Option to Select Downloads Grouping Mode

* hide Drag and Edit Button when Mode is Artist

* Revert "hide Drag and Edit Button when Mode is Artist"

some changes are not filtered when pushed

This reverts commit 319812e156f61c32250d908ed5ed22720ea0c6b7.

* hide Drag and Edit Button when Mode is Artist

* declared is typo

* use ComicInfo.penciller

* remove BaseGalleryInfo.artist

* Correct invalidate key

* reImport kotlin.math.roundToInt, it was incorrectly deleted

* use movableContentOf

* optimize imports

* use remember not rememberInVM

* Get DownloadInfo.artists from Database or `ComicInfo.xml`

* refactor

* unknown artist group

* redundant parameter

* reformat code

* SearchBar title and hint

* Translation

* Fix when initially loaded, the count of DownloadLabel is zero.

* lazy invalid

* use remember not rememberInVM

* refactor

* Fix LazyColumn not updated

* refactor

* Hide some Button in Artist Mode

* Revert

* Fix update labelList not updated UI

* Revert reFormat

* use trailing lambda

* Revert rememberUpdatedState

* Translate

* use buildMap

* Move ARTIST to DOWNLOAD_ARTIST

* Revert multi-line for chained Modifier

* Generate DownloadInfo.artistInfoList after writeComicInfo and restoreDownload

* Fixed crash after download gallery

* Use a composite key for DOWNLOAD_ARTISTS

* Make artists non-null

* Refactor

* Only update artist info when writing comic info

* Separate list for pending artist info

* Remove unused

* Put download artist when import database

* Use a composite key of GID and ARTIST

* Refactor

* Cache labelList

* named lambda parameters

* Refactor to avoid `mutex`

* follow up on `Make artists non-null`

* ArtistList order by ARTIST

* Lift EhTagDatabase check

* Translate artist in download title

* Refactor

* Refactor readMetadataFromLocal

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>

* Do not update the DB when the `artists` is empty

* Remove unused

---------

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
Co-authored-by: GG Bond <whatever@tagkong.com>
2024-04-24 11:01:53 +00:00
GG Bond 7116871652 Avoid implement a CancellableContinuation
Let compiler do CPS transform
2024-04-24 18:43:21 +08:00
FooIbar cca374c774 Refactor UniFile.fromUri for tree uri
This reverts b39e244 ("Fix `UniFile.fromUri` for children of tree uri")
and 3978abe ("Fix UniFile.fromUri() for 2 path segments tree uri").
2024-04-24 17:48:41 +08:00
FooIbar 3e0dbf92d0 Fix search bar offset not being reset 2024-04-22 12:49:03 +08:00
renovate[bot] 2747ae674c Update dependency sh.calvin.reorderable:reorderable to v1.5.2 2024-04-21 20:20:31 +00:00
FooIbar e85ec5aaba Code cleanup 2024-04-21 23:42:55 +08:00
FooIbar d4894e066b Prepare for next iteration 2024-04-21 22:47:04 +08:00
FooIbar 71a4e84d7c Prepare for release 1.11.4
Releases / build (push) Has been cancelled
2024-04-21 22:45:54 +08:00
FooIbar d6c36146ba Fix losing reading progress on reading mode change 2024-04-21 22:35:22 +08:00
FooIbar 9013778f58 More workaround 2024-04-21 18:05:25 +08:00
renovate[bot] 1af03dd145 Update dependency sh.calvin.reorderable:reorderable to v1.5.1 2024-04-20 16:46:53 +00:00
FooIbar b83e0fd092 Workaround for lazy layout lagging (#1042) 2024-04-20 16:37:41 +00:00
GG Bond 1f8d786d05 Simplify to use Modifier.offset 2024-04-20 18:31:22 +08:00
GG Bond 5d87a4ada0 Never have a clipped shadow 2024-04-20 18:31:22 +08:00
GG Bond 4d4fb1e398 Eliminate unnecessary Column 2024-04-20 17:10:33 +08:00
GG Bond 05eea54f06 Fix primaryFab appear anim 2024-04-20 17:10:33 +08:00
GG Bond a00d9ef765 Mitigate shadow being clipped 2024-04-20 17:10:33 +08:00
GG Bond aa6ddac9c5 Avoid unnecessary zIndex 2024-04-20 17:10:33 +08:00
GG Bond 9a6f17c745 Image Search Card doesn't need to be scrollable now 2024-04-20 17:10:33 +08:00
GG Bond 980977aaf3 Ensure Image Search Card's shadow not being clipped 2024-04-20 17:10:33 +08:00
GG Bond 3ed0afaf91 Restore an alpha usage can be safely applied 2024-04-20 17:10:33 +08:00
GG Bond 250adb4b33 Fix shadow being clipped 2024-04-20 17:10:33 +08:00
GG Bond 0baf09cb53 Codestyle fix for DownloadCard 2024-04-20 17:10:33 +08:00
FooIbar d04a914b9b Tweak gallery card alignment and ensure min height 2024-04-19 19:08:21 +08:00
renovate[bot] 59775cb27c Update dependency com.android.application to v8.5.0-alpha06 2024-04-18 22:06:24 +00:00
GG Bond fb76cd7b1b Move compose out of snapshot 2024-04-18 11:25:52 +08:00
renovate[bot] 52fb5306b6 Update dependency dev.chrisbanes.compose:compose-bom to v2024.04.00-alpha02 2024-04-18 01:03:11 +00:00
renovate[bot] 74f7d95718 Update AndroidX & Jetpack Compose 2024-04-17 18:12:27 +00:00
renovate[bot] bcc1b78c7c Update compose snapshot 2024-04-18 00:00:56 +08:00
FooIbar 3978abea87 Fix UniFile.fromUri() for 2 path segments tree uri 2024-04-17 17:38:51 +08:00
FooIbar 556b726349 Update to follow the latest material slider specs
Tick size is 4 dp now.
2024-04-16 10:49:10 +08:00
FooIbar e0f16f31e6 Automatically backup data before updating 2024-04-16 00:08:30 +08:00
FooIbar b39e24467c Fix UniFile.fromUri for children of tree uri 2024-04-15 23:50:54 +08:00
FooIbar 65613f07ba Optimize DownloadCard interaction in select mode 2024-04-15 21:53:37 +08:00
FooIbar 666a7e4d77 Allow disable list item animations on API 29+ (#1024)
* Allow disable list item animations on API 29+

* Update list item animations setting description
2024-04-15 14:54:13 +08:00
renovate[bot] cf08bfdd9b Update dependency me.saket.telephoto:zoomable to v0.10.0 2024-04-14 21:58:40 +00:00
renovate[bot]andFooIbar 1f47d56c55 Update Kotlin, KSP and Compose Compiler (#1009)
* Update Kotlin, KSP and Compose Compiler

* Migrate to new compose compiler gradle plugin

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
2024-04-14 14:38:22 +00:00
FooIbar 1a7dde848f Don't treat warnings as errors on debug build 2024-04-14 17:56:55 +08:00
GG Bond c8207308a8 Update predictive back animations to use interpolation curve of (0.1, 0.1, 0, 1) 2024-04-14 17:38:03 +08:00
renovate[bot] f23020cc6f Update dependency sh.calvin.reorderable:reorderable to v1.5.0 2024-04-12 18:46:12 +00:00
renovate[bot] 555e487f1e Update dependency com.android.application to v8.5.0-alpha05 2024-04-11 18:20:21 +00:00
renovate[bot] 107bb0b15d Update aboutlibraries to v11.1.3 2024-04-09 19:14:59 +00:00
FooIbar 87a2de393e Tweak select item dialog style 2024-04-10 03:04:49 +08:00
renovate[bot] a7e4412454 Update dependency com.mikepenz.aboutlibraries.plugin to v11.1.2 2024-04-09 14:42:54 +00:00
FooIbar 7dce4cfa8e Update Compose snapshot 2024-04-09 22:12:49 +08:00
FooIbar 2b30aca47c Update R8 and remove workaround 2024-04-09 21:44:57 +08:00
FooIbar 0073d2862b Fixed crash on deleting a label after renaming it 2024-04-09 18:39:25 +08:00
FooIbar b182c97185 Fix titleless select item dialog top padding 2024-04-09 10:46:44 +08:00
renovate[bot] a32119bada Update dependency com.android.application to v8.5.0-alpha04 2024-04-08 19:37:35 +00:00
FooIbar a928cfd066 Update libarchive to v3.7.3 2024-04-09 00:08:01 +08:00
FooIbar 8c7a371336 Highlight selected item in select item dialog 2024-04-07 23:32:47 +08:00
GG Bond 6f61eb4946 Update outdated comments 2024-04-07 21:21:37 +08:00
GG Bond 1096f05344 Move navigation out of snapshot 2024-04-07 21:21:37 +08:00
GG Bond 26f96d3e38 Move to telephoto 0.10.0-SNAPSHOT 2024-04-07 21:21:37 +08:00
FooIbar 4a02189a59 Rearrange code to move plurals resources together 2024-04-07 17:13:35 +08:00
FooIbar 7a5bab41f6 Add option to disable list item animations (#997)
It may crash on some devices.
2024-04-07 13:57:27 +08:00
FooIbar 84c32fb28d Prepare for next iteration 2024-04-06 17:10:28 +08:00
178 changed files with 5795 additions and 2547 deletions

No files matched your search

+12 -15
View File
@@ -21,7 +21,7 @@ jobs:
java-version: 21
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android && rustup component add rustfmt clippy
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -42,6 +42,9 @@ jobs:
with:
cache-read-only: true
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11718014"
- name: Spotless Check
run: ./gradlew spotlessCheck
@@ -57,8 +60,11 @@ jobs:
distribution: temurin
java-version: 21
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11718014"
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android && rustup component add rustfmt clippy
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -101,12 +107,6 @@ jobs:
name: default-armeabi-v7a-${{ github.sha }}
path: app/build/outputs/apk/default/release/app-default-armeabi-v7a-release.apk
- name: Upload x86
uses: actions/upload-artifact@v4
with:
name: default-x86-${{ github.sha }}
path: app/build/outputs/apk/default/release/app-default-x86-release.apk
- name: Upload mapping
uses: actions/upload-artifact@v4
with:
@@ -131,8 +131,11 @@ jobs:
distribution: temurin
java-version: 21
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11718014"
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android && rustup component add rustfmt clippy
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -175,12 +178,6 @@ jobs:
name: marshmallow-armeabi-v7a-${{ github.sha }}
path: app/build/outputs/apk/marshmallow/release/app-marshmallow-armeabi-v7a-release.apk
- name: Upload x86
uses: actions/upload-artifact@v4
with:
name: marshmallow-x86-${{ github.sha }}
path: app/build/outputs/apk/marshmallow/release/app-marshmallow-x86-release.apk
- name: Upload mapping
uses: actions/upload-artifact@v4
with:
+4 -5
View File
@@ -18,8 +18,11 @@ jobs:
distribution: temurin
java-version: 21
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11718014"
- name: Install Rust Toolchain
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -43,7 +46,6 @@ jobs:
mv app/build/outputs/apk/default/release/app-default-universal-release.apk EhViewer-${{ github.ref_name }}-default-universal.apk
mv app/build/outputs/apk/default/release/app-default-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-default-arm64-v8a.apk
mv app/build/outputs/apk/default/release/app-default-x86_64-release.apk EhViewer-${{ github.ref_name }}-default-x86_64.apk
mv app/build/outputs/apk/default/release/app-default-x86-release.apk EhViewer-${{ github.ref_name }}-default-x86.apk
mv app/build/outputs/apk/default/release/app-default-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-default-armeabi-v7a.apk
mv app/build/outputs/mapping/defaultRelease/mapping.txt EhViewer-${{ github.ref_name }}-default-mapping.txt
mv app/build/outputs/native-debug-symbols/defaultRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-default-native-debug-symbols.zip
@@ -51,7 +53,6 @@ jobs:
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-universal-release.apk EhViewer-${{ github.ref_name }}-marshmallow-universal.apk
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-arm64-v8a.apk
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-x86_64-release.apk EhViewer-${{ github.ref_name }}-marshmallow-x86_64.apk
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-x86-release.apk EhViewer-${{ github.ref_name }}-marshmallow-x86.apk
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-armeabi-v7a.apk
mv app/build/outputs/mapping/marshmallowRelease/mapping.txt EhViewer-${{ github.ref_name }}-marshmallow-mapping.txt
mv app/build/outputs/native-debug-symbols/marshmallowRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-marshmallow-native-debug-symbols.zip
@@ -64,7 +65,6 @@ jobs:
EhViewer-${{ github.ref_name }}-default-universal.apk
EhViewer-${{ github.ref_name }}-default-arm64-v8a.apk
EhViewer-${{ github.ref_name }}-default-x86_64.apk
EhViewer-${{ github.ref_name }}-default-x86.apk
EhViewer-${{ github.ref_name }}-default-armeabi-v7a.apk
EhViewer-${{ github.ref_name }}-default-mapping.txt
EhViewer-${{ github.ref_name }}-default-native-debug-symbols.zip
@@ -72,7 +72,6 @@ jobs:
EhViewer-${{ github.ref_name }}-marshmallow-universal.apk
EhViewer-${{ github.ref_name }}-marshmallow-arm64-v8a.apk
EhViewer-${{ github.ref_name }}-marshmallow-x86_64.apk
EhViewer-${{ github.ref_name }}-marshmallow-x86.apk
EhViewer-${{ github.ref_name }}-marshmallow-armeabi-v7a.apk
EhViewer-${{ github.ref_name }}-marshmallow-mapping.txt
EhViewer-${{ github.ref_name }}-marshmallow-native-debug-symbols.zip
@@ -0,0 +1,29 @@
name: 'Update Chrome Version'
on:
schedule:
- cron: '0 0 1 * *'
workflow_dispatch:
jobs:
action:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Update Chrome Version
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd chrome-for-testing
curl -OLf https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_STABLE
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config push.autoSetupRemote true
git switch -c chore/chrome
if git commit -am "Update Chrome version for default User-Agent"; then
git push
gh pr create -f
gh pr merge chore/chrome --auto -dr
fi
+78
View File
@@ -1,5 +1,83 @@
# 变更日志
## [1.11.6-RC1] - 2024-06-02
### 改进
* 下载列表默认显示全部下载项目
* 替换搜索文本中的换行符为空格 [#1258](https://github.com/FooIbar/EhViewer/issues/1258)
### Bug 修复
* 评价画廊时未使用输入的评分
* 搜索时意外选择 Non-H 类别 [#1246](https://github.com/FooIbar/EhViewer/issues/1246)
* 侧边栏与导航抽屉重叠 [#1262](https://github.com/FooIbar/EhViewer/issues/1262)
* 快速搜索/下载标签排序失效 [#1267](https://github.com/FooIbar/EhViewer/issues/1267)
* 加载压缩包/种子列表出错时 UI 冻结
* 低版本 Android 上某些 UI 被遮挡
## [1.11.5] - 2024-05-19
### 重大变更
* 不再支持 32 位 x86
### 新功能
* 支持按作者对下载分组
* 由于 E 站不再强制要求人机验证,重新支持账号密码登录
### 改进
* 在登录时加载收藏名称 [#1077](https://github.com/FooIbar/EhViewer/issues/1077)
* 优化归档和种子界面 UI
* 禁止在 cookie 界面截图以防止泄露
* 在缩略图模式下显示画廊页数
* 记住最低评分和页数搜索参数
* 更新 User-Agent 以缓解 IP 封禁 [#1182](https://github.com/FooIbar/EhViewer/issues/1182)
* 账号密码登录支持自动填充
* 更新中文(台灣)翻译
### Bug 修复
* 非触摸模式下无法退出搜索界面 [#1060](https://github.com/FooIbar/EhViewer/issues/1060)
* 某些设备上无法安装更新 [#1067](https://github.com/FooIbar/EhViewer/issues/1067)
* 某些设备上崩溃/卡顿 [#996](https://github.com/FooIbar/EhViewer/issues/996) [#1023](https://github.com/FooIbar/EhViewer/issues/1023)
* 搜索记录中存在重复条目时崩溃 [#1130](https://github.com/FooIbar/EhViewer/issues/1130)
* 条漫模式下缩小后点按区域偏移 [#127](https://github.com/FooIbar/EhViewer/issues/127)
* 多窗口模式下点按区域偏移
* 点击下载通知时未清除状态
* 保存未完成加载的图片时崩溃 [#1154](https://github.com/FooIbar/EhViewer/issues/1154)
* 历史记录界面无法在项目上滑动打开抽屉 [#464](https://github.com/FooIbar/EhViewer/issues/464)
* 导入数据失败时显示导入成功 [#1174](https://github.com/FooIbar/EhViewer/issues/1174)
* 某些条件下收藏界面崩溃 [#1190](https://github.com/FooIbar/EhViewer/issues/1190)
* 反转点按区域功能失效 [#1217](https://github.com/FooIbar/EhViewer/issues/1217)
### 已知问题
* 某些设备上从后台返回时某些 UI 消失,可通过关闭列表项目动画部分缓解 [#1184](https://github.com/FooIbar/EhViewer/issues/1184)
## [1.11.4] - 2024-04-21
### 新功能
* 更新前自动备份数据
### 改进
* 在 设置-高级 中加入了禁用列表项目动画的选项,该功能在部分设备上导致崩溃/卡顿 [#996](https://github.com/FooIbar/EhViewer/issues/996) [#1023](https://github.com/FooIbar/EhViewer/issues/1023)
* 下载列表多选模式下避免误触 [#1016](https://github.com/FooIbar/EhViewer/issues/1016)
* 杂项 UI 优化
### Bug 修复
* 重命名下载标签后删除该标签时崩溃 [#1008](https://github.com/FooIbar/EhViewer/issues/1008)
* 搜索栏中某些输入法长按退格无法连续删除 [#606](https://github.com/FooIbar/EhViewer/issues/606)
* 评论/屏蔽/搜索建议/下载标签/快速搜索列表卡顿 [#1041](https://github.com/FooIbar/EhViewer/issues/1041)
## [1.11.3] - 2024-04-06
### 改进
+2 -2
View File
@@ -85,13 +85,13 @@ and [Dynamic Color](https://m3.material.io/styles/color/dynamic-color/overview)
Here is the libraries
- [Arrow](https://arrow-kt.io/)
- [AOSP & AndroidX](http://source.android.com/)
- [AOSP & AndroidX](https://source.android.com/)
- [Kotlin & KotlinX](https://kotlinlang.org/)
- [Material Icons](https://github.com/google/material-design-icons)
- [Ktor](https://ktor.io/)
- [Coil](https://coil-kt.github.io/coil/)
- [Compose Destinations](https://composedestinations.rafaelcosta.xyz/)
- [libarchive](http://www.libarchive.org/)
- [libarchive](https://www.libarchive.org/)
- [jsoup](https://jsoup.org/)
Specially thanks [Tachiyomi](https://tachiyomi.org/) for its reader implementation!
+59 -49
View File
@@ -1,8 +1,5 @@
import com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE
import com.mikepenz.aboutlibraries.plugin.DuplicateRule.GROUP
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
val isRelease: Boolean
get() = gradle.startParameter.taskNames.any { it.contains("Release") }
@@ -10,6 +7,7 @@ val isRelease: Boolean
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
@@ -18,16 +16,19 @@ plugins {
alias(libs.plugins.composeCompilerReportGenerator)
}
val supportedAbis = arrayOf("arm64-v8a", "x86_64", "armeabi-v7a")
android {
compileSdk = 34
ndkVersion = "26.2.11394342"
ndkVersion = "27.0.11718014-beta1"
androidResources.generateLocaleConfig = true
splits {
abi {
isEnable = true
reset()
if (isRelease) {
include("arm64-v8a", "x86_64", "armeabi-v7a", "x86")
include(*supportedAbis)
isUniversalApk = true
} else {
include("arm64-v8a", "x86_64")
@@ -48,22 +49,25 @@ android {
commandLine = "git rev-parse --short=7 HEAD".split(' ')
}.standardOutput.asText.get().trim()
val buildTime by lazy {
val formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm").withZone(ZoneOffset.UTC)
formatter.format(Instant.now())
}
val commitTime = providers.exec {
commandLine = "git log -1 --format=%ct".split(' ')
}.standardOutput.asText.get().trim()
val repoName = providers.exec {
commandLine = "git remote get-url origin".split(' ')
}.standardOutput.asText.get().trim().removePrefix("https://github.com/").removePrefix("git@github.com:")
.removeSuffix(".git")
val chromeVersion = rootProject.layout.projectDirectory.file("chrome-for-testing/LATEST_RELEASE_STABLE").asFile
.readText().substringBefore('.')
defaultConfig {
applicationId = "moe.tarsin.ehviewer"
minSdk = 26
targetSdk = 34
versionCode = 180054
versionName = "1.11.3"
versionCode = 180056
versionName = "1.11.6"
versionNameSuffix = "-RC1"
resourceConfigurations.addAll(
listOf(
"zh",
@@ -82,10 +86,20 @@ android {
)
buildConfigField("String", "RAW_VERSION_NAME", "\"$versionName${versionNameSuffix.orEmpty()}\"")
buildConfigField("String", "COMMIT_SHA", "\"$commitSha\"")
buildConfigField("long", "COMMIT_TIME", commitTime)
buildConfigField("String", "REPO_NAME", "\"$repoName\"")
buildConfigField("String", "CHROME_VERSION", "\"$chromeVersion\"")
ndk {
if (isRelease) {
abiFilters.addAll(supportedAbis)
}
debugSymbolLevel = "FULL"
}
externalNativeBuild {
cmake {
arguments += "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
}
}
}
flavorDimensions += "api"
@@ -109,34 +123,6 @@ android {
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
freeCompilerArgs = listOf(
// https://kotlinlang.org/docs/compiler-reference.html#progressive
"-progressive",
"-Xjvm-default=all",
"-Xlambdas=indy",
"-Xcontext-receivers",
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
"-opt-in=androidx.paging.ExperimentalPagingApi",
"-opt-in=kotlin.contracts.ExperimentalContracts",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
"-opt-in=splitties.experimental.ExperimentalSplittiesApi",
"-opt-in=splitties.preferences.DataStorePreferencesPreview",
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true",
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:nonSkippingGroupOptimization=true",
)
}
lint {
disable += setOf("MissingTranslation", "MissingQuantity")
fatal += setOf("NewApi", "InlinedApi")
@@ -156,11 +142,12 @@ android {
isShrinkResources = true
proguardFiles("proguard-rules.pro")
signingConfig = signConfig
buildConfigField("String", "BUILD_TIME", "\"$buildTime\"")
}
debug {
applicationIdSuffix = ".debug"
buildConfigField("String", "BUILD_TIME", "\"\"")
lint {
abortOnError = false
}
}
}
@@ -170,13 +157,14 @@ android {
viewBinding = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}
namespace = "com.hippo.ehviewer"
}
composeCompiler {
enableNonSkippingGroupOptimization = true
enableStrongSkippingMode = true
}
androidComponents {
onVariants(selector().withBuildType("release")) {
it.packaging.resources.excludes.addAll(
@@ -206,13 +194,13 @@ dependencies {
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.datastore)
implementation(libs.androidx.graphics.path)
// https://developer.android.com/jetpack/androidx/releases/lifecycle
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.compose)
// https://developer.android.com/jetpack/androidx/releases/navigation
implementation(libs.androidx.navigation.compose)
// https://developer.android.com/jetpack/androidx/releases/paging
implementation(libs.androidx.paging.compose)
@@ -270,11 +258,33 @@ dependencies {
kotlin {
jvmToolchain(21)
compilerOptions {
freeCompilerArgs = listOf(
// https://kotlinlang.org/docs/compiler-reference.html#progressive
"-progressive",
"-Xjvm-default=all",
"-Xcontext-receivers",
"-opt-in=coil3.annotation.ExperimentalCoilApi",
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
"-opt-in=androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi",
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
"-opt-in=androidx.paging.ExperimentalPagingApi",
"-opt-in=kotlin.contracts.ExperimentalContracts",
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
"-opt-in=splitties.experimental.ExperimentalSplittiesApi",
"-opt-in=splitties.preferences.DataStorePreferencesPreview",
)
}
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
arg("room.generateKotlin", "true")
arg("compose-destinations.codeGenPackageName", "com.hippo.ehviewer.ui")
}
+4 -5
View File
@@ -11,16 +11,15 @@
public static final ** CREATOR;
}
# Workaround for https://issuetracker.google.com/331556916
-keepclassmembers class androidx.biometric.BiometricManager {
public int canAuthenticate(int);
}
# Ktor logger
-dontwarn org.slf4j.impl.StaticLoggerBinder
-dontwarn org.conscrypt.Conscrypt
# https://issuetracker.google.com/222232895
-dontwarn androidx.window.extensions.**
-dontwarn androidx.window.sidecar.Sidecar*
-keepattributes LineNumberTable
-allowaccessmodification
@@ -0,0 +1,564 @@
{
"formatVersion": 1,
"database": {
"version": 23,
"identityHash": "7d1cfc4e9a9d506a73eb0520ae414d25",
"entities": [
{
"tableName": "GALLERIES",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TOKEN` TEXT NOT NULL, `TITLE` TEXT, `TITLE_JPN` TEXT, `THUMB` TEXT, `CATEGORY` INTEGER NOT NULL, `POSTED` TEXT, `UPLOADER` TEXT, `RATING` REAL NOT NULL, `SIMPLE_TAGS` TEXT, `PAGES` INTEGER NOT NULL DEFAULT 0, `SIMPLE_LANGUAGE` TEXT, `FAVORITE_SLOT` INTEGER NOT NULL, PRIMARY KEY(`GID`))",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "token",
"columnName": "TOKEN",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "TITLE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "titleJpn",
"columnName": "TITLE_JPN",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbKey",
"columnName": "THUMB",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "category",
"columnName": "CATEGORY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "posted",
"columnName": "POSTED",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "UPLOADER",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "rating",
"columnName": "RATING",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "simpleTags",
"columnName": "SIMPLE_TAGS",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pages",
"columnName": "PAGES",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "simpleLanguage",
"columnName": "SIMPLE_LANGUAGE",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "favoriteSlot",
"columnName": "FAVORITE_SLOT",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "DOWNLOAD_LABELS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`LABEL` TEXT NOT NULL, `POSITION` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "label",
"columnName": "LABEL",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [
{
"name": "index_DOWNLOAD_LABELS_LABEL",
"unique": true,
"columnNames": [
"LABEL"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DOWNLOAD_LABELS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
}
],
"foreignKeys": []
},
{
"tableName": "DOWNLOADS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `STATE` INTEGER NOT NULL, `LEGACY` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, `LABEL` TEXT, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`LABEL`) REFERENCES `DOWNLOAD_LABELS`(`LABEL`) ON UPDATE CASCADE ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "state",
"columnName": "STATE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "legacy",
"columnName": "LEGACY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "LABEL",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [
{
"name": "index_DOWNLOADS_LABEL",
"unique": false,
"columnNames": [
"LABEL"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_DOWNLOADS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
}
],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
},
{
"table": "DOWNLOAD_LABELS",
"onDelete": "SET NULL",
"onUpdate": "CASCADE",
"columns": [
"LABEL"
],
"referencedColumns": [
"LABEL"
]
}
]
},
{
"tableName": "DOWNLOAD_DIRNAME",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `DIRNAME` TEXT NOT NULL, PRIMARY KEY(`GID`))",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dirname",
"columnName": "DIRNAME",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "DOWNLOAD_ARTISTS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `ARTIST` TEXT NOT NULL, PRIMARY KEY(`GID`, `ARTIST`), FOREIGN KEY(`GID`) REFERENCES `DOWNLOADS`(`GID`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "artist",
"columnName": "ARTIST",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID",
"ARTIST"
]
},
"indices": [],
"foreignKeys": [
{
"table": "DOWNLOADS",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "FILTER",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`MODE` INTEGER NOT NULL, `TEXT` TEXT NOT NULL, `ENABLE` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "mode",
"columnName": "MODE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "TEXT",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "enable",
"columnName": "ENABLE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "HISTORY",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "LOCAL_FAVORITES",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "TIME",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "PROGRESS",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `PAGE` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "gid",
"columnName": "GID",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "page",
"columnName": "PAGE",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"GID"
]
},
"indices": [],
"foreignKeys": [
{
"table": "GALLERIES",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"GID"
],
"referencedColumns": [
"GID"
]
}
]
},
{
"tableName": "QUICK_SEARCH",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER, `NAME` TEXT NOT NULL, `MODE` INTEGER NOT NULL, `CATEGORY` INTEGER NOT NULL, `KEYWORD` TEXT, `ADVANCE_SEARCH` INTEGER NOT NULL, `MIN_RATING` INTEGER NOT NULL, `PAGE_FROM` INTEGER NOT NULL, `PAGE_TO` INTEGER NOT NULL, `POSITION` INTEGER NOT NULL, PRIMARY KEY(`_id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "NAME",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mode",
"columnName": "MODE",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "CATEGORY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "keyword",
"columnName": "KEYWORD",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "advanceSearch",
"columnName": "ADVANCE_SEARCH",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minRating",
"columnName": "MIN_RATING",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageFrom",
"columnName": "PAGE_FROM",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pageTo",
"columnName": "PAGE_TO",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "POSITION",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
},
{
"ftsVersion": "FTS4",
"ftsOptions": {
"tokenizer": "simple",
"tokenizerArgs": [],
"contentTable": "GALLERIES",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [],
"prefixSizes": [],
"preferredOrder": "ASC"
},
"contentSyncTriggers": [
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_BEFORE_UPDATE BEFORE UPDATE ON `GALLERIES` BEGIN DELETE FROM `GALLERIES_FTS` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_BEFORE_DELETE BEFORE DELETE ON `GALLERIES` BEGIN DELETE FROM `GALLERIES_FTS` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_AFTER_UPDATE AFTER UPDATE ON `GALLERIES` BEGIN INSERT INTO `GALLERIES_FTS`(`docid`, `TITLE`, `TITLE_JPN`, `SIMPLE_TAGS`) VALUES (NEW.`rowid`, NEW.`TITLE`, NEW.`TITLE_JPN`, NEW.`SIMPLE_TAGS`); END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_AFTER_INSERT AFTER INSERT ON `GALLERIES` BEGIN INSERT INTO `GALLERIES_FTS`(`docid`, `TITLE`, `TITLE_JPN`, `SIMPLE_TAGS`) VALUES (NEW.`rowid`, NEW.`TITLE`, NEW.`TITLE_JPN`, NEW.`SIMPLE_TAGS`); END"
],
"tableName": "GALLERIES_FTS",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`TITLE` TEXT NOT NULL, `TITLE_JPN` TEXT NOT NULL, `SIMPLE_TAGS` TEXT, content=`GALLERIES`)",
"fields": [
{
"fieldPath": "title",
"columnName": "TITLE",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "titleJpn",
"columnName": "TITLE_JPN",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "simpleTags",
"columnName": "SIMPLE_TAGS",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": []
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7d1cfc4e9a9d506a73eb0520ae414d25')"
]
}
}
+9 -9
View File
@@ -29,7 +29,6 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="@bool/large_heap"
android:localeConfig="@xml/locale_config"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
@@ -101,23 +100,24 @@
<data android:scheme="content" />
<data android:host="*" />
<data android:mimeType="application/octet-stream" />
<data android:mimeType="application/7z" />
<data android:mimeType="application/rar" />
<data android:mimeType="application/zip" />
<data android:mimeType="application/x-7z-compressed" />
<data android:mimeType="application/x-rar-compressed" />
<data android:mimeType="application/x-zip-compressed" />
<data android:mimeType="application/x-tar" />
<data android:mimeType="application/x-xz" />
<data android:mimeType="application/gzip" />
<data android:mimeType="application/vnd.rar" />
<data android:mimeType="application/vnd.comicbook+zip" />
<data android:mimeType="application/vnd.comicbook-rar" />
<!-- unofficial -->
<data android:mimeType="application/7z" />
<data android:mimeType="application/rar" />
<data android:mimeType="application/x-cbz" />
<data android:mimeType="application/x-cbr" />
<data android:mimeType="application/x-cbt" />
<data android:mimeType="application/x-cb7" />
<!-- untested -->
<data android:mimeType="application/vnd.comicbook+zip" />
<data android:mimeType="application/vnd.comicbook-rar" />
<data android:mimeType="application/x-zip-compressed" />
<data android:mimeType="application/x-rar-compressed" />
<data android:mimeType="application/x-compressed-tar" />
<data android:mimeType="application/x-bzip-compressed-tar" />
<data android:mimeType="application/x-lzma-compressed-tar" />
+29
View File
@@ -0,0 +1,29 @@
From 2fa97f75b64c05da506968fa9a1711716f2d45a0 Mon Sep 17 00:00:00 2001
From: FooIbar <118464521+FooIbar@users.noreply.github.com>
Date: Tue, 26 Sep 2023 00:08:03 +0800
Subject: [PATCH 1/2] Hide symbols
---
CMakeLists.txt | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index ec97e4c7..4d859341 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -2129,10 +2129,10 @@ SET(OLD_CMAKE_REQUIRED_FLAGS "${CMAKE_REQUIRED_FLAGS}")
SET(CMAKE_REQUIRED_FLAGS "${CMAKE_REQUIRED_FLAGS} -fvisibility=hidden -Werror")
CHECK_C_SOURCE_COMPILES("void __attribute__((visibility(\"default\"))) foo(void);
int main() { return 0; }" HAVE_VISIBILITY_ATTR)
-IF (HAVE_VISIBILITY_ATTR)
+IF (0)
SET(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fvisibility=hidden")
ADD_DEFINITIONS(-D__LIBARCHIVE_ENABLE_VISIBILITY)
-ENDIF(HAVE_VISIBILITY_ATTR)
+ENDIF(0)
SET(CMAKE_REQUIRED_FLAGS "${OLD_CMAKE_REQUIRED_FLAGS}")
# Generate "config.h" from "build/cmake/config.h.in"
--
2.34.1
@@ -0,0 +1,25 @@
From 2dcc8be937499022c37678eb6bf4bd026b6fcbb0 Mon Sep 17 00:00:00 2001
From: FooIbar <118464521+FooIbar@users.noreply.github.com>
Date: Tue, 26 Sep 2023 00:09:25 +0800
Subject: [PATCH 2/2] Insert link libs
---
CMakeLists.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 4d859341..1cd1cd47 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -438,7 +438,7 @@ IF(DEFINED __GNUWIN32PATH AND EXISTS "${__GNUWIN32PATH}")
# # endif
ENDIF(DEFINED __GNUWIN32PATH AND EXISTS "${__GNUWIN32PATH}")
-SET(ADDITIONAL_LIBS "")
+SET(ADDITIONAL_LIBS ${LIBARCHIVE_CUSTOM_LIBS})
#
# Find ZLIB
#
--
2.34.1
+7 -5
View File
@@ -20,8 +20,8 @@ option(BUILD_TESTING OFF)
FetchContent_Declare(
liblzma
GIT_REPOSITORY https://git.tukaani.org/xz.git
GIT_TAG v5.6.1
GIT_REPOSITORY https://github.com/tukaani-project/xz.git
GIT_TAG v5.6.2
PATCH_COMMAND git apply ${CMAKE_CURRENT_LIST_DIR}/0001-Don-t-build-other-binaries.patch || cd .
)
@@ -69,8 +69,9 @@ SET(LIBARCHIVE_CUSTOM_LIBS "nettle" "liblzma")
FetchContent_Declare(
libarchive
GIT_REPOSITORY https://github.com/FooIbar/libarchive.git
GIT_TAG v3.7.2
GIT_REPOSITORY https://github.com/libarchive/libarchive.git
GIT_TAG v3.7.4
PATCH_COMMAND git apply ${CMAKE_CURRENT_LIST_DIR}/0001-Hide-symbols.patch ${CMAKE_CURRENT_LIST_DIR}/0002-Insert-link-libs.patch || cd .
)
FetchContent_MakeAvailable(libarchive)
@@ -79,11 +80,12 @@ include_directories(${libarchive_SOURCE_DIR}/libarchive)
FetchContent_Declare(
Corrosion
GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git
GIT_TAG 0a3bdf452995803c334d79dc9a2affbfad51b720
GIT_TAG v0.5
)
FetchContent_MakeAvailable(Corrosion)
corrosion_import_crate(MANIFEST_PATH ../rust/Cargo.toml)
corrosion_add_target_local_rustflags(ehviewer_rust "-Clink-args=-Wl,-z,max-page-size=16384")
# Build and link our app's native lib
add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c natsort/strnatcmp.c)
+3 -1
View File
@@ -54,8 +54,9 @@ static archive_ctx **ctx_pool;
static void *mempool = NULL;
static size_t *mempoolofs = NULL;
static int page_size = 0;
#define PAGE_ALIGN(x) ((x + ~PAGE_MASK) & PAGE_MASK)
#define PAGE_ALIGN(x) ((x + page_size - 1) & ~(page_size - 1))
#define MEMPOOL_ADDR_BY_SORTED_IDX(x) (mempool + (index ? mempoolofs[index - 1] : 0))
#define MEMPOOL_SIZE (mempoolofs[entryCount - 1])
@@ -282,6 +283,7 @@ JNIEXPORT jint JNICALL
Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint fd, jlong size, jboolean sort_entries) {
EH_UNUSED(env);
EH_UNUSED(thiz);
page_size = getpagesize();
archive_ctx *ctx = NULL;
archiveAddr = mmap(0, size, PROT_READ, MAP_PRIVATE, fd, 0);
if (archiveAddr == MAP_FAILED) {
@@ -0,0 +1,278 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.foundation.gestures
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragDelta
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.requireLayoutDirection
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Velocity
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
@ExperimentalFoundationApi
fun <T> Modifier.anchoredHorizontalDraggable(
state: AnchoredDraggableState<T>,
enableDragFromStartToEnd: Boolean = true,
enableDragFromEndToStart: Boolean = true,
enabled: Boolean = true,
startDragImmediately: Boolean = state.isAnimationRunning,
): Modifier = this then AnchoredHorizontalDraggableElement(
state = state,
canDrag = { (x, y) ->
if (x > 0) {
enableDragFromStartToEnd
} else {
enableDragFromEndToStart
} && x.absoluteValue > y.absoluteValue * 2f
},
enabled = enabled,
startDragImmediately = startDragImmediately,
)
@OptIn(ExperimentalFoundationApi::class)
private class AnchoredHorizontalDraggableElement<T>(
private val state: AnchoredDraggableState<T>,
private val canDrag: (Offset) -> Boolean,
private val enabled: Boolean,
private val startDragImmediately: Boolean,
) : ModifierNodeElement<AnchoredHorizontalDraggableNode<T>>() {
override fun create() = AnchoredHorizontalDraggableNode(
state,
canDrag,
enabled,
startDragImmediately,
)
override fun update(node: AnchoredHorizontalDraggableNode<T>) {
node.update(
state,
canDrag,
enabled,
startDragImmediately,
)
}
override fun hashCode(): Int {
var result = state.hashCode()
result = 31 * result + canDrag.hashCode()
result = 31 * result + enabled.hashCode()
result = 31 * result + startDragImmediately.hashCode()
return result
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is AnchoredHorizontalDraggableElement<*>) return false
if (state != other.state) return false
if (canDrag != other.canDrag) return false
if (enabled != other.enabled) return false
if (startDragImmediately != other.startDragImmediately) return false
return true
}
override fun InspectorInfo.inspectableProperties() {
name = "anchoredDraggable"
properties["state"] = state
properties["canDrag"] = canDrag
properties["enabled"] = enabled
properties["startDragImmediately"] = startDragImmediately
}
}
@OptIn(ExperimentalFoundationApi::class)
private class AnchoredHorizontalDraggableNode<T>(
private var state: AnchoredDraggableState<T>,
private var canDrag: (Offset) -> Boolean,
enabled: Boolean,
private var startDragImmediately: Boolean,
) : HorizontalDragGestureNode(
enabled = enabled,
) {
private val isReverseDirection: Boolean
get() = requireLayoutDirection() == LayoutDirection.Rtl
override fun canDrag(change: PointerInputChange): Boolean {
return canDrag(change.positionChange().reverseIfNeeded())
}
override suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit) {
state.anchoredDrag {
forEachDelta { dragDelta ->
dragTo(state.newOffsetForDelta(dragDelta.delta.reverseIfNeeded().x))
}
}
}
override fun onDragStarted(startedPosition: Offset) { }
override fun onDragStopped(velocity: Velocity) {
if (!isAttached) return
coroutineScope.launch {
state.settle(velocity.reverseIfNeeded().x)
}
}
override fun startDragImmediately(): Boolean = startDragImmediately
fun update(
state: AnchoredDraggableState<T>,
canDrag: (Offset) -> Boolean,
enabled: Boolean,
startDragImmediately: Boolean,
) {
var resetPointerInputHandling = false
if (this.state != state) {
this.state = state
resetPointerInputHandling = true
}
this.canDrag = canDrag
this.startDragImmediately = startDragImmediately
update(
enabled = enabled,
shouldResetPointerInputHandling = resetPointerInputHandling,
)
}
private fun Velocity.reverseIfNeeded() = if (isReverseDirection) this * -1f else this * 1f
private fun Offset.reverseIfNeeded() = if (isReverseDirection) this * -1f else this * 1f
}
internal fun <T> AnchoredDraggableState<T>.newOffsetForDelta(delta: Float) =
((if (offset.isNaN()) 0f else offset) + delta)
.coerceIn(anchors.minAnchor(), anchors.maxAnchor())
/**
* This Modifier allows configuring an [AnchoredDraggableState]'s anchors based on this layout
* node's size and offsetting it.
* It considers lookahead and reports the appropriate size and measurement for the appropriate
* phase.
*
* @param state The state the anchors should be attached to
* @param orientation The orientation the component should be offset in
* @param anchors Lambda to calculate the anchors based on this layout's size and the incoming
* constraints. These can be useful to avoid subcomposition.
*/
internal fun <T> Modifier.draggableAnchors(
state: AnchoredDraggableState<T>,
orientation: Orientation,
anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
) = this then DraggableAnchorsElement(state, anchors, orientation)
private class DraggableAnchorsElement<T>(
private val state: AnchoredDraggableState<T>,
private val anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
private val orientation: Orientation,
) : ModifierNodeElement<DraggableAnchorsNode<T>>() {
override fun create() = DraggableAnchorsNode(state, anchors, orientation)
override fun update(node: DraggableAnchorsNode<T>) {
node.state = state
node.anchors = anchors
node.orientation = orientation
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is DraggableAnchorsElement<*>) return false
if (state != other.state) return false
if (anchors !== other.anchors) return false
if (orientation != other.orientation) return false
return true
}
override fun hashCode(): Int {
var result = state.hashCode()
result = 31 * result + anchors.hashCode()
result = 31 * result + orientation.hashCode()
return result
}
override fun InspectorInfo.inspectableProperties() {
debugInspectorInfo {
properties["state"] = state
properties["anchors"] = anchors
properties["orientation"] = orientation
}
}
}
private class DraggableAnchorsNode<T>(
var state: AnchoredDraggableState<T>,
var anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
var orientation: Orientation,
) : Modifier.Node(), LayoutModifierNode {
private var didLookahead: Boolean = false
override fun onDetach() {
didLookahead = false
}
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints,
): MeasureResult {
val placeable = measurable.measure(constraints)
// If we are in a lookahead pass, we only want to update the anchors here and not in
// post-lookahead. If there is no lookahead happening (!isLookingAhead && !didLookahead),
// update the anchors in the main pass.
if (!isLookingAhead || !didLookahead) {
val size = IntSize(placeable.width, placeable.height)
val newAnchorResult = anchors(size, constraints)
state.updateAnchors(newAnchorResult.first, newAnchorResult.second)
}
didLookahead = isLookingAhead || didLookahead
return layout(placeable.width, placeable.height) {
// In a lookahead pass, we use the position of the current target as this is where any
// ongoing animations would move. If the component is in a settled state, lookahead
// and post-lookahead will converge.
val offset = if (isLookingAhead) {
state.anchors.positionOf(state.targetValue)
} else {
state.requireOffset()
}
val xOffset = if (orientation == Orientation.Horizontal) offset else 0f
val yOffset = if (orientation == Orientation.Vertical) offset else 0f
placeable.placeRelative(xOffset.roundToInt(), yOffset.roundToInt())
}
}
}
@@ -0,0 +1,113 @@
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.foundation.gestures
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.platform.ViewConfiguration
import kotlin.coroutines.cancellation.CancellationException
/**
* A Gesture detector that waits for pointer down and touch slop in the horizontal direction and
* then calls [onDrag] for each drag event.
* It follows the touch slop detection of [awaitHorizontalTouchSlopOrCancellation] but will consume
* the position change automatically once the touch slop has been crossed, the amount of drag over
* the touch slop is reported as the first drag event [onDrag] after the slop is crossed.
* If [shouldAwaitTouchSlop] returns true the touch slop recognition phase will be ignored
* and the drag gesture will be recognized immediately.The first [onDrag] in this case will report
* an [Offset.Zero].
*
* [onDragStart] is called when the touch slop has been passed and includes an [Offset] representing
* the last known pointer position relative to the containing element as well as the initial
* down event that triggered this gesture detection cycle. The [Offset] can be outside
* the actual bounds of the element itself meaning the numbers can be negative or larger than the
* element bounds if the touch target is smaller than the
* [ViewConfiguration.minimumTouchTargetSize].
*
* [onDragEnd] is called after all pointers are up with the event change of the up event
* and [onDragCancel] is called if another gesture has consumed pointer input,
* canceling this gesture.
*
* @param onDragStart A lambda to be called when the drag gesture starts, it contains information
* about the last known [PointerInputChange] relative to the containing element and the post slop
* delta.
* @param onDragEnd A lambda to be called when the gesture ends. It contains information about the
* up [PointerInputChange] that finished the gesture.
* @param onDragCancel A lambda to be called when the gesture is cancelled either by an error or
* when it was consumed.
* @param shouldAwaitTouchSlop Indicates if touch slop detection should be skipped.
* @param onDrag A lambda to be called for each delta event in the gesture. It contains information
* about the [PointerInputChange] and the movement offset.
*/
internal suspend fun PointerInputScope.detectHorizontalDragGestures(
canDrag: (PointerInputChange) -> Boolean,
onDragStart: (change: PointerInputChange, initialDelta: Offset) -> Unit,
onDragEnd: () -> Unit,
onDragCancel: () -> Unit,
shouldAwaitTouchSlop: () -> Boolean,
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit,
) {
awaitEachGesture {
val initialDown =
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
val awaitTouchSlop = shouldAwaitTouchSlop()
if (!awaitTouchSlop) {
initialDown.consume()
}
val down = awaitFirstDown(requireUnconsumed = false)
var drag: PointerInputChange?
var overSlop = Offset.Zero
if (awaitTouchSlop) {
do {
drag = awaitHorizontalTouchSlopOrCancellation(
down.id,
) { change, over ->
if (canDrag.invoke(change)) {
change.consume()
overSlop = Offset(over, 0f)
} else {
throw CancellationException()
}
}
} while (drag != null && !drag.isConsumed)
} else {
drag = initialDown
}
if (drag != null) {
onDragStart.invoke(drag, overSlop)
onDrag(drag, overSlop)
val dragEnd = horizontalDrag(
pointerId = drag.id,
onDrag = {
onDrag(it, it.positionChange())
it.consume()
},
)
if (dragEnd) {
onDragEnd()
} else {
onDragCancel()
}
}
}
}
@@ -0,0 +1,228 @@
/*
* Copyright 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.foundation.gestures
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragCancelled
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragDelta
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragStarted
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragStopped
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
import androidx.compose.ui.node.DelegatingNode
import androidx.compose.ui.node.PointerInputModifierNode
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.Velocity
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.sign
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* A node that performs drag gesture recognition and event propagation.
*/
internal abstract class HorizontalDragGestureNode(
enabled: Boolean,
) : DelegatingNode(), PointerInputModifierNode, CompositionLocalConsumerModifierNode {
protected var enabled = enabled
private set
private var channel: Channel<HorizontalDragEvent>? = null
private var isListeningForEvents = false
abstract fun canDrag(change: PointerInputChange): Boolean
/**
* Responsible for the dragging behavior between the start and the end of the drag. It
* continually invokes `forEachDelta` to process incoming events. In return, `forEachDelta`
* calls `dragBy` method to process each individual delta.
*/
abstract suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit)
/**
* Passes the action needed when a drag starts. This gives the ability to pass the desired
* behavior from other nodes implementing AbstractDraggableNode
*/
abstract fun onDragStarted(startedPosition: Offset)
/**
* Passes the action needed when a drag stops. This gives the ability to pass the desired
* behavior from other nodes implementing AbstractDraggableNode
*/
abstract fun onDragStopped(velocity: Velocity)
/**
* If touch slop recognition should be skipped. If this is true, this node will start
* recognizing drag events immediately without waiting for touch slop.
*/
abstract fun startDragImmediately(): Boolean
private fun startListeningForEvents() {
isListeningForEvents = true
/**
* To preserve the original behavior we had (before the Modifier.Node migration) we need to
* scope the DragStopped and DragCancel methods to the node's coroutine scope instead of using
* the one provided by the pointer input modifier, this is to ensure that even when the pointer
* input scope is reset we will continue any coroutine scope scope that we started from these
* methods while the pointer input scope was active.
*/
coroutineScope.launch {
while (isActive) {
var event = channel?.receive()
if (event !is DragStarted) continue
onDragStarted(event.startPoint)
try {
drag { processDelta ->
while (event !is DragStopped && event !is DragCancelled) {
(event as? DragDelta)?.let(processDelta)
event = channel?.receive()
}
}
if (event is DragStopped) {
onDragStopped((event as DragStopped).velocity)
} else if (event is DragCancelled) {
onDragStopped(Velocity.Zero)
}
} catch (c: CancellationException) {
onDragStopped(Velocity.Zero)
}
}
}
}
private var pointerInputNode: SuspendingPointerInputModifierNode? = null
override fun onDetach() {
isListeningForEvents = false
}
override fun onPointerEvent(
pointerEvent: PointerEvent,
pass: PointerEventPass,
bounds: IntSize,
) {
if (enabled && pointerInputNode == null) {
pointerInputNode = delegate(initializePointerInputNode())
}
pointerInputNode?.onPointerEvent(pointerEvent, pass, bounds)
}
private fun initializePointerInputNode(): SuspendingPointerInputModifierNode {
return SuspendingPointerInputModifierNode {
// re-create tracker when pointer input block restarts. This lazily creates the tracker
// only when it is need.
val velocityTracker = VelocityTracker()
val onDragStart: (change: PointerInputChange, initialDelta: Offset) -> Unit =
{ startEvent, initialDelta ->
if (!isListeningForEvents) {
if (channel == null) {
channel = Channel(capacity = Channel.UNLIMITED)
}
startListeningForEvents()
}
val xSign = sign(startEvent.position.x)
val ySign = sign(startEvent.position.y)
val adjustedStart = startEvent.position -
Offset(initialDelta.x * xSign, initialDelta.y * ySign)
channel?.trySend(DragStarted(adjustedStart))
}
val onDragEnd: () -> Unit = {
val maximumVelocity = currentValueOf(LocalViewConfiguration)
.maximumFlingVelocity
val velocity = velocityTracker.calculateVelocity(
Velocity(maximumVelocity, maximumVelocity),
)
velocityTracker.resetTracking()
channel?.trySend(DragStopped(velocity))
}
val onDragCancel: () -> Unit = {
channel?.trySend(DragCancelled)
}
val shouldAwaitTouchSlop: () -> Boolean = {
!startDragImmediately()
}
val onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit =
{ change, delta ->
velocityTracker.addPointerInputChange(change)
channel?.trySend(DragDelta(delta))
}
coroutineScope {
try {
detectHorizontalDragGestures(
canDrag = ::canDrag,
onDragStart = onDragStart,
onDragEnd = onDragEnd,
onDragCancel = onDragCancel,
shouldAwaitTouchSlop = shouldAwaitTouchSlop,
onDrag = onDrag,
)
} catch (cancellation: CancellationException) {
channel?.trySend(DragCancelled)
if (!isActive) throw cancellation
}
}
}
}
override fun onCancelPointerInput() {
pointerInputNode?.onCancelPointerInput()
}
fun update(
enabled: Boolean = this.enabled,
shouldResetPointerInputHandling: Boolean = false,
) {
var resetPointerInputHandling = shouldResetPointerInputHandling
if (this.enabled != enabled) {
this.enabled = enabled
if (!enabled) {
pointerInputNode?.let { undelegate(it) }
pointerInputNode = null
}
resetPointerInputHandling = true
}
if (resetPointerInputHandling) {
pointerInputNode?.resetPointerInputHandler()
}
}
}
internal sealed class HorizontalDragEvent {
class DragStarted(val startPoint: Offset) : HorizontalDragEvent()
class DragStopped(val velocity: Velocity) : HorizontalDragEvent()
object DragCancelled : HorizontalDragEvent()
class DragDelta(val delta: Offset) : HorizontalDragEvent()
}
@@ -10,11 +10,8 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation
import androidx.compose.foundation.gestures.anchoredHorizontalDraggable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.horizontalDrag
import androidx.compose.foundation.gestures.snapTo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -42,17 +39,12 @@ import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import com.hippo.ehviewer.ui.tools.animateFloatMergeOneWayPredictiveBackAsState
@@ -63,7 +55,6 @@ import kotlinx.coroutines.launch
private val AnimationSpec = TweenSpec<Float>(durationMillis = 256)
private const val DrawerPositionalThreshold = 0.5f
private val DrawerVelocityThreshold = 400.dp
private val ContainerWidth = 360.0.dp
@Suppress("NotCloseable")
@Stable
@@ -256,60 +247,30 @@ fun ModalSideDrawer(
) {
val scope = rememberCoroutineScope()
val density = LocalDensity.current
var minValue by remember { mutableFloatStateOf(with(density) { ContainerWidth.toPx() }) }
val maxValue = 0f
var maxValue by remember { mutableFloatStateOf(with(density) { DrawerDefaults.MaximumDrawerWidth.toPx() }) }
val minValue = 0f
SideEffect {
drawerState.density = density
drawerState.anchoredDraggableState.updateAnchors(
DraggableAnchors {
DrawerValue.Closed at minValue
DrawerValue.Open at maxValue
DrawerValue.Closed at maxValue
DrawerValue.Open at minValue
},
)
}
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val velocityTracker = remember { VelocityTracker() }
val dragModifier = if (gesturesEnabled) {
Modifier.pointerInput(isRtl) {
val multiplier = if (isRtl) -1 else 1
awaitEachGesture {
fun canConsume(slop: Float) = (slop > 0 && drawerState.isOpen) || (slop < 0 && drawerState.isClosed)
val down = awaitFirstDown(requireUnconsumed = false)
var ignore = false
val drag = awaitHorizontalTouchSlopOrCancellation(down.id) { change, over ->
val overSlop = over * multiplier
val canConsume = canConsume(overSlop)
if (canConsume && !ignore) {
velocityTracker.addPointerInputChange(change)
change.consume()
drawerState.anchoredDraggableState.dispatchRawDelta(overSlop)
} else {
ignore = true
}
}
if (drag != null) {
horizontalDrag(drag.id) {
velocityTracker.addPointerInputChange(it)
drag.consume()
drawerState.anchoredDraggableState.dispatchRawDelta(it.positionChange().x * multiplier)
}
val velocity = velocityTracker.calculateVelocity()
velocityTracker.resetTracking()
scope.launch {
drawerState.anchoredDraggableState.settle(velocity.x * multiplier)
}
}
}
}
} else {
Modifier
}
Box(modifier.fillMaxSize().then(dragModifier)) {
Box(
modifier.fillMaxSize().anchoredHorizontalDraggable(
state = drawerState.anchoredDraggableState,
enableDragFromStartToEnd = drawerState.isOpen,
enableDragFromEndToStart = drawerState.isClosed,
enabled = gesturesEnabled,
),
) {
val radius by remember {
snapshotFlow {
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
val step = calculateFraction(maxValue, minValue, drawerState.currentOffset)
lerp(0, 10, step).dp
}
}.collectAsState(0.dp)
@@ -331,7 +292,7 @@ fun ModalSideDrawer(
}
},
fraction = {
calculateFraction(minValue, maxValue, drawerState.requireOffset())
calculateFraction(maxValue, minValue, drawerState.requireOffset())
},
color = scrimColor,
)
@@ -349,7 +310,7 @@ fun ModalSideDrawer(
val scaledHeight = (multiplierY * placeable.height).roundToInt()
val reMeasured = measurable.measure(Constraints.fixed(scaledWidth, scaledHeight))
layout(scaledWidth, scaledHeight) {
reMeasured.placeRelative(-(scaledWidth - placeable.width), 0)
reMeasured.placeRelative(placeable.width - scaledWidth, 0)
}
}.background(
color = MaterialTheme.colorScheme.surfaceColorAtElevation(absoluteElevation),
@@ -361,14 +322,20 @@ fun ModalSideDrawer(
scaleX = scale
scaleY = scale
}.offset {
IntOffset(lerp(0f, minValue * 0.05f, predictiveState).roundToInt(), 0)
IntOffset(lerp(0f, maxValue * 0.05f, predictiveState).roundToInt(), 0)
}
}
} else {
Modifier.onGloballyPositioned {
val w = it.size.width
if (w != 0) {
minValue = it.size.width.toFloat()
Modifier.onSizeChanged {
val width = it.width
if (width != 0) {
maxValue = width.toFloat()
drawerState.anchoredDraggableState.updateAnchors(
DraggableAnchors {
DrawerValue.Closed at maxValue
DrawerValue.Open at minValue
},
)
}
}
}
@@ -0,0 +1,255 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.material3.fork
import androidx.annotation.FloatRange
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredHorizontalDraggable
import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.gestures.draggableAnchors
import androidx.compose.foundation.gestures.snapTo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.SwipeToDismissBoxDefaults
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
private val SnapAnimationSpec = SpringSpec<Float>()
// TODO: Update to M3 spec once it's available
private val DecayAnimationSpec = exponentialDecay<Float>()
/**
* State of the [SwipeToDismissBox] composable.
*
* @param initialValue The initial value of the state.
* @param density The density that this state can use to convert values to and from dp.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* @param positionalThreshold The positional threshold to be used when calculating the target state
* while a swipe is in progress and when settling after the swipe ends. This is the distance from
* the start of a transition. It will be, depending on the direction of the interaction, added or
* subtracted from/to the origin offset. It should always be a positive value.
*/
class SwipeToDismissBoxState(
initialValue: SwipeToDismissBoxValue,
internal val density: Density,
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true },
positionalThreshold: (totalDistance: Float) -> Float,
) {
internal val anchoredDraggableState =
AnchoredDraggableState(
initialValue = initialValue,
snapAnimationSpec = SnapAnimationSpec,
decayAnimationSpec = DecayAnimationSpec,
confirmValueChange = confirmValueChange,
positionalThreshold = positionalThreshold,
velocityThreshold = { with(density) { DismissVelocityThreshold.toPx() } },
)
internal val offset: Float get() = anchoredDraggableState.offset
/**
* Require the current offset.
*
* @throws IllegalStateException If the offset has not been initialized yet
*/
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
/**
* The current state value of the [SwipeToDismissBoxState].
*/
val currentValue: SwipeToDismissBoxValue get() = anchoredDraggableState.currentValue
/**
* The target state. This is the closest state to the current offset (taking into account
* positional thresholds). If no interactions like animations or drags are in progress, this
* will be the current state.
*/
val targetValue: SwipeToDismissBoxValue get() = anchoredDraggableState.targetValue
/**
* The fraction of the progress going from currentValue to targetValue, within [0f..1f] bounds.
*/
@get:FloatRange(from = 0.0, to = 1.0)
val progress: Float get() = anchoredDraggableState.progress(anchoredDraggableState.settledValue, anchoredDraggableState.targetValue)
/**
* The direction (if any) in which the composable has been or is being dismissed.
*
* Use this to change the background of the [SwipeToDismissBox] if you want different actions
* on each side.
*/
val dismissDirection: SwipeToDismissBoxValue
get() = when {
offset == 0f || offset.isNaN() -> SwipeToDismissBoxValue.Settled
offset > 0f -> SwipeToDismissBoxValue.StartToEnd
else -> SwipeToDismissBoxValue.EndToStart
}
/**
* Set the state without any animation and suspend until it's set
*
* @param targetValue The new target value
*/
suspend fun snapTo(targetValue: SwipeToDismissBoxValue) {
anchoredDraggableState.snapTo(targetValue)
}
/**
* Reset the component to the default position with animation and suspend until it if fully
* reset or animation has been cancelled. This method will throw [CancellationException] if
* the animation is interrupted
*
* @return the reason the reset animation ended
*/
suspend fun reset() = anchoredDraggableState.animateTo(
targetValue = SwipeToDismissBoxValue.Settled,
)
/**
* Dismiss the component in the given [direction], with an animation and suspend. This method
* will throw [CancellationException] if the animation is interrupted
*
* @param direction The dismiss direction.
*/
suspend fun dismiss(direction: SwipeToDismissBoxValue) {
anchoredDraggableState.animateTo(targetValue = direction)
}
companion object {
/**
* The default [Saver] implementation for [SwipeToDismissBoxState].
*/
fun Saver(
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean,
positionalThreshold: (totalDistance: Float) -> Float,
density: Density,
) = Saver<SwipeToDismissBoxState, SwipeToDismissBoxValue>(
save = { it.currentValue },
restore = {
SwipeToDismissBoxState(
it,
density,
confirmValueChange,
positionalThreshold,
)
},
)
}
}
/**
* Create and [remember] a [SwipeToDismissBoxState].
*
* @param initialValue The initial value of the state.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* @param positionalThreshold The positional threshold to be used when calculating the target state
* while a swipe is in progress and when settling after the swipe ends. This is the distance from
* the start of a transition. It will be, depending on the direction of the interaction, added or
* subtracted from/to the origin offset. It should always be a positive value.
*/
@Composable
fun rememberSwipeToDismissBoxState(
initialValue: SwipeToDismissBoxValue = SwipeToDismissBoxValue.Settled,
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true },
positionalThreshold: (totalDistance: Float) -> Float =
SwipeToDismissBoxDefaults.positionalThreshold,
): SwipeToDismissBoxState {
val density = LocalDensity.current
return rememberSaveable(
saver = SwipeToDismissBoxState.Saver(
confirmValueChange = confirmValueChange,
density = density,
positionalThreshold = positionalThreshold,
),
) {
SwipeToDismissBoxState(initialValue, density, confirmValueChange, positionalThreshold)
}
}
/**
* A composable that can be dismissed by swiping left or right.
*
* @param state The state of this component.
* @param backgroundContent A composable that is stacked behind the [content] and is exposed when
* the content is swiped. You can/should use the [state] to have different backgrounds on each side.
* @param modifier Optional [Modifier] for this component.
* @param enableDismissFromStartToEnd Whether SwipeToDismissBox can be dismissed from start to end.
* @param enableDismissFromEndToStart Whether SwipeToDismissBox can be dismissed from end to start.
* @param gesturesEnabled Whether swipe-to-dismiss can be interacted by gestures.
* @param content The content that can be dismissed.
*/
@Composable
fun SwipeToDismissBox(
state: SwipeToDismissBoxState,
backgroundContent: @Composable RowScope.() -> Unit,
modifier: Modifier = Modifier,
enableDismissFromStartToEnd: Boolean = true,
enableDismissFromEndToStart: Boolean = true,
gesturesEnabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) {
Box(
modifier
.anchoredHorizontalDraggable(
state = state.anchoredDraggableState,
enableDragFromStartToEnd = enableDismissFromStartToEnd,
enableDragFromEndToStart = enableDismissFromEndToStart,
enabled = gesturesEnabled && state.currentValue == SwipeToDismissBoxValue.Settled,
),
propagateMinConstraints = true,
) {
Row(
content = backgroundContent,
modifier = Modifier.matchParentSize(),
)
Row(
content = content,
modifier = Modifier.draggableAnchors(
state.anchoredDraggableState,
Orientation.Horizontal,
) { size, _ ->
val width = size.width.toFloat()
return@draggableAnchors DraggableAnchors {
SwipeToDismissBoxValue.Settled at 0f
if (enableDismissFromStartToEnd) {
SwipeToDismissBoxValue.StartToEnd at width
}
if (enableDismissFromEndToStart) {
SwipeToDismissBoxValue.EndToStart at -width
}
} to state.targetValue
},
)
}
}
private val DismissVelocityThreshold = 125.dp
@@ -56,7 +56,8 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
// Current quickscale state
private val quickScaleThreshold: Float
private val quickScaleThreshold =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 20F, context.resources.displayMetrics)
// Long click handler
private var longClickJob: Job? = null
@@ -85,9 +86,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
@ScaleType
private var minimumScaleType = SCALE_TYPE_CENTER_INSIDE
// Whether to crop borders.
private var cropBorders = false
// Gesture detection settings
private var panEnabled = true
@@ -186,15 +184,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
var isReady = false
private set
/**
* Call to find whether the main image (base layer tiles where relevant) have been loaded. Before
* this event the view is blank unless a preview was provided.
*
* @return true if the main image (not the preview) has been loaded and is ready to display.
*/
var isImageLoaded = false
private set
// Event listener
private var onImageEventListener: OnImageEventListener? = null
@@ -218,11 +207,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
private val mtrx = Matrix()
init {
setMinimumDpi(160)
setDoubleTapZoomDpi(160)
setGestureDetector(context)
quickScaleThreshold =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 20F, context.resources.displayMetrics)
}
/**
@@ -270,7 +255,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
sHeight = 0
srcRect.setEmpty()
isReady = false
isImageLoaded = false
bitmap = null
bitmapIsCached = false
}
@@ -768,19 +752,13 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
}
}
/**
* Checks whether the base layer of tiles or full size bitmap is ready.
*/
private val isBaseLayerReady: Boolean
get() = bitmap != null
/**
* Check whether view and image dimensions are known and either a preview, full size image or
* base layer tiles are loaded. First time, send ready event to listener. The next draw will
* display an image.
*/
private fun checkReady(): Boolean {
val ready = width > 0 && height > 0 && sWidth > 0 && sHeight > 0 && (bitmap != null || isBaseLayerReady)
val ready = width > 0 && height > 0 && sWidth > 0 && sHeight > 0 && bitmap != null
if (!isReady && ready) {
preDraw()
isReady = true
@@ -789,19 +767,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
return ready
}
/**
* Check whether either the full size bitmap or base layer tiles are loaded. First time, send image
* loaded event to listener.
*/
private fun checkImageLoaded(): Boolean {
val imageLoaded = isBaseLayerReady
if (!isImageLoaded && imageLoaded) {
preDraw()
isImageLoaded = true
}
return imageLoaded
}
/**
* Sets scale and translate ready for the next draw.
*/
@@ -832,13 +797,14 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* is set so one dimension fills the view and the image is centered on the other dimension. Used to calculate what the target of an
* animation should be.
*
* @param _center Whether the image should be centered in the dimension it's too small to fill. While animating this can be false to avoid changes in direction as bounds are reached.
* @param maybeCenter Whether the image should be centered in the dimension it's too small to fill. While animating this can be false to avoid changes in direction as bounds are reached.
* @param sat The scale we want and the translation we're aiming for. The values are adjusted to be valid.
*/
private fun fitToBounds(_center: Boolean, sat: ScaleAndTranslate) {
var center = _center
if (panLimit == PAN_LIMIT_OUTSIDE && isReady) {
center = false
private fun fitToBounds(maybeCenter: Boolean, sat: ScaleAndTranslate) {
val center = if (panLimit == PAN_LIMIT_OUTSIDE && isReady) {
false
} else {
maybeCenter
}
val vTranslate = sat.vTranslate
val scale = limitedScale(sat.scale)
@@ -920,21 +886,9 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
this.bitmap = bitmap
sWidth = srcRect.width()
sHeight = srcRect.height()
val ready = checkReady()
val imageLoaded = checkImageLoaded()
if (ready || imageLoaded) {
invalidate()
requestLayout()
}
}
/**
* Set border crop of non-filled (white or black) content.
*
* @param cropBorders Whether to crop image borders.
*/
fun setCropBorders(cropBorders: Boolean) {
this.cropBorders = cropBorders
checkReady()
invalidate()
requestLayout()
}
/**
@@ -987,7 +941,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* @param sTarget target object for result. The same instance is also returned.
* @return a coordinate representing the corresponding source coordinate. This is the same instance passed to the sTarget param.
*/
fun viewToSourceCoord(vx: Float, vy: Float, sTarget: PointF = PointF()): PointF? {
private fun viewToSourceCoord(vx: Float, vy: Float, sTarget: PointF = PointF()): PointF? {
if (vTranslate == null) {
return null
}
@@ -1027,7 +981,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* @param vTarget target object for result. The same instance is also returned.
* @return view coordinates. This is the same instance passed to the vTarget param.
*/
fun sourceToViewCoord(sx: Float, sy: Float, vTarget: PointF = PointF()): PointF? {
private fun sourceToViewCoord(sx: Float, sy: Float, vTarget: PointF = PointF()): PointF? {
if (vTranslate == null) {
return null
}
@@ -1704,7 +1658,5 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
* State change originated from a double tap zoom anim.
*/
const val ORIGIN_DOUBLE_TAP_ZOOM = 4
private val TAG = SubsamplingScaleImageView::class.java.simpleName
}
}
@@ -0,0 +1,152 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.accompanist.systemuicontroller
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.view.View
import android.view.Window
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
/**
* A class which provides easy-to-use utilities for updating the System UI bar
* colors within Jetpack Compose.
*/
@Stable
interface SystemUiController {
/**
* Control for the behavior of the system bars. This value should be one of the
* [WindowInsetsControllerCompat] behavior constants:
* [WindowInsetsControllerCompat.BEHAVIOR_SHOW_BARS_BY_TOUCH] (Deprecated),
* [WindowInsetsControllerCompat.BEHAVIOR_DEFAULT] and
* [WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE].
*/
var systemBarsBehavior: Int
/**
* Property which holds the status bar visibility. If set to true, show the status bar,
* otherwise hide the status bar.
*/
var isStatusBarVisible: Boolean
/**
* Property which holds the navigation bar visibility. If set to true, show the navigation bar,
* otherwise hide the navigation bar.
*/
var isNavigationBarVisible: Boolean
/**
* Property which holds the status & navigation bar visibility. If set to true, show both bars,
* otherwise hide both bars.
*/
var isSystemBarsVisible: Boolean
get() = isNavigationBarVisible && isStatusBarVisible
set(value) {
isStatusBarVisible = value
isNavigationBarVisible = value
}
}
/**
* Remembers a [SystemUiController] for the given [window].
*
* If no [window] is provided, an attempt to find the correct [Window] is made.
*
* First, if the [LocalView]'s parent is a [DialogWindowProvider], then that dialog's [Window] will
* be used.
*
* Second, we attempt to find [Window] for the [Activity] containing the [LocalView].
*
* If none of these are found (such as may happen in a preview), then the functionality of the
* returned [SystemUiController] will be degraded, but won't throw an exception.
*/
@Composable
fun rememberSystemUiController(
window: Window? = findWindow(),
): SystemUiController {
val view = LocalView.current
return remember(view, window) { AndroidSystemUiController(view, window) }
}
@Composable
private fun findWindow(): Window? =
(LocalView.current.parent as? DialogWindowProvider)?.window
?: LocalView.current.context.findWindow()
private tailrec fun Context.findWindow(): Window? =
when (this) {
is Activity -> window
is ContextWrapper -> baseContext.findWindow()
else -> null
}
/**
* A helper class for setting the navigation and status bar colors for a [View], gracefully
* degrading behavior based upon API level.
*
* Typically you would use [rememberSystemUiController] to remember an instance of this.
*/
internal class AndroidSystemUiController(
private val view: View,
window: Window?,
) : SystemUiController {
private val windowInsetsController = window?.let {
WindowCompat.getInsetsController(it, view)
}
override var systemBarsBehavior: Int
get() = windowInsetsController?.systemBarsBehavior ?: 0
set(value) {
windowInsetsController?.systemBarsBehavior = value
}
override var isStatusBarVisible: Boolean
get() {
return ViewCompat.getRootWindowInsets(view)
?.isVisible(WindowInsetsCompat.Type.statusBars()) == true
}
set(value) {
if (value) {
windowInsetsController?.show(WindowInsetsCompat.Type.statusBars())
} else {
windowInsetsController?.hide(WindowInsetsCompat.Type.statusBars())
}
}
override var isNavigationBarVisible: Boolean
get() {
return ViewCompat.getRootWindowInsets(view)
?.isVisible(WindowInsetsCompat.Type.navigationBars()) == true
}
set(value) {
if (value) {
windowInsetsController?.show(WindowInsetsCompat.Type.navigationBars())
} else {
windowInsetsController?.hide(WindowInsetsCompat.Type.navigationBars())
}
}
}
@@ -46,12 +46,14 @@ import com.hippo.ehviewer.coil.MergeInterceptor
import com.hippo.ehviewer.dailycheck.checkDawn
import com.hippo.ehviewer.dao.SearchDatabase
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.DownloadsFilterMode
import com.hippo.ehviewer.ktbuilder.diskCache
import com.hippo.ehviewer.ktbuilder.imageLoader
import com.hippo.ehviewer.ktor.CronetEngine
import com.hippo.ehviewer.legacy.cleanObsoleteCache
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
import com.hippo.ehviewer.ui.lockObserver
import com.hippo.ehviewer.ui.tools.dataStateFlow
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.Crash
import com.hippo.ehviewer.util.FavouriteStatusRouter
@@ -103,13 +105,13 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
super.onCreate()
System.loadLibrary("ehviewer")
lifecycleScope.launchIO {
launch { EhTagDatabase }
launch { EhDB }
dataStateFlow.value
launch {
EhTagDatabase
}
launch {
EhDB
}
launch {
if (DownloadManager.labelList.isNotEmpty() && Settings.downloadFilterMode.key !in Settings.prefs) {
Settings.downloadFilterMode.value = DownloadsFilterMode.CUSTOM.flag
}
DownloadManager.readMetadataFromLocal()
}
launch {
+48 -45
View File
@@ -20,6 +20,7 @@ import android.net.Uri
import arrow.fx.coroutines.release
import arrow.fx.coroutines.resource
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.dao.DownloadArtist
import com.hippo.ehviewer.dao.DownloadDirname
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.dao.DownloadLabel
@@ -32,6 +33,7 @@ import com.hippo.ehviewer.dao.QuickSearch
import com.hippo.ehviewer.dao.Schema17to18
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.UniFile
import com.hippo.unifile.asUniFile
import kotlinx.coroutines.flow.Flow
import splitties.arch.room.roomDb
@@ -112,8 +114,11 @@ object EhDB {
dao.insertOrIgnore(downloadDirnameList)
}
val downloadsCount
get() = db.downloadsDao().count()
val downloadsCountByLabel
get() = db.downloadsDao().countByLabel()
val downloadsCountByArtist
get() = db.downloadsDao().countByArtist()
suspend fun getAllDownloadLabelList() = db.downloadLabelDao().list()
@@ -141,6 +146,14 @@ object EhDB {
dao.fill(raw.position)
}
suspend fun putDownloadArtist(gid: Long, artists: List<DownloadArtist>) {
if (artists.isNotEmpty()) {
val dao = db.downloadArtistDao()
dao.deleteByGid(gid)
dao.insert(artists)
}
}
suspend fun removeLocalFavorites(galleryInfo: BaseGalleryInfo) {
db.localFavoritesDao().deleteByKey(galleryInfo.gid)
deleteGalleryInfo(galleryInfo)
@@ -260,10 +273,10 @@ object EhDB {
db.filterDao().update(filter)
}
fun exportDB(context: Context, uri: Uri) {
fun exportDB(context: Context, file: UniFile) {
db.query("PRAGMA wal_checkpoint(FULL)", null).use { it.moveToNext() }
val dbFile = context.getDatabasePath(DB_NAME)
dbFile.asUniFile() sendTo uri.asUniFile()
dbFile.asUniFile() sendTo file
}
suspend fun importDB(context: Context, uri: Uri) {
@@ -278,49 +291,39 @@ object EhDB {
it.close()
context.deleteDatabase(tempDBName)
} use { oldDB ->
runCatching {
db.galleryDao().insertOrIgnore(oldDB.galleryDao().list())
db.galleryDao().insertOrIgnore(oldDB.galleryDao().list())
db.progressDao().insertOrIgnore(oldDB.progressDao().list())
val downloadLabelList = oldDB.downloadLabelDao().list()
DownloadManager.addDownloadLabel(downloadLabelList)
oldDB.downloadDirnameDao().list().let {
importDownloadDirname(it)
}
runCatching {
db.progressDao().insertOrIgnore(oldDB.progressDao().list())
val downloadInfoList = oldDB.downloadsDao().joinList().asReversed()
DownloadManager.addDownload(downloadInfoList)
val historyInfoList = oldDB.historyDao().list()
importHistoryInfo(historyInfoList)
val quickSearchList = oldDB.quickSearchDao().list()
val currentQuickSearchList = db.quickSearchDao().list()
val offset = currentQuickSearchList.size
val importList = quickSearchList.filter { newQS ->
currentQuickSearchList.none { it.name == newQS.name }
}.onEachIndexed { index, q -> q.position = index + offset }
importQuickSearch(importList)
oldDB.localFavoritesDao().list().let {
importLocalFavorites(it)
}
runCatching {
val downloadLabelList = oldDB.downloadLabelDao().list()
DownloadManager.addDownloadLabel(downloadLabelList)
}
runCatching {
oldDB.downloadDirnameDao().list().let {
importDownloadDirname(it)
}
}
runCatching {
val downloadInfoList = oldDB.downloadsDao().joinList().asReversed()
DownloadManager.addDownload(downloadInfoList)
}
runCatching {
val historyInfoList = oldDB.historyDao().list()
importHistoryInfo(historyInfoList)
}
runCatching {
val quickSearchList = oldDB.quickSearchDao().list()
val currentQuickSearchList = db.quickSearchDao().list()
val offset = currentQuickSearchList.size
val importList = quickSearchList.filter { newQS ->
currentQuickSearchList.none { it.name == newQS.name }
}.onEachIndexed { index, q -> q.position = index + offset }
importQuickSearch(importList)
}
runCatching {
oldDB.localFavoritesDao().list().let {
importLocalFavorites(it)
}
}
runCatching {
val filterList = oldDB.filterDao().list()
val currentFilterList = db.filterDao().list()
filterList.forEach {
if (it !in currentFilterList) addFilter(it)
}
val filterList = oldDB.filterDao().list()
val currentFilterList = db.filterDao().list()
filterList.forEach {
if (it !in currentFilterList) addFilter(it)
}
}
}
@@ -10,9 +10,11 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import com.hippo.ehviewer.client.CHROME_USER_AGENT
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.data.FavListUrlBuilder
import com.hippo.ehviewer.download.DownloadsFilterMode
import com.hippo.ehviewer.download.SortMode
import com.hippo.ehviewer.util.AppConfig
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import java.util.Locale
@@ -110,13 +112,17 @@ object Settings : DataStorePreferences(null) {
val listThumbSize = intPref("list_tile_size", 40)
val languageFilter = intPref("language_filter", -1)
val downloadSortMode = intPref("download_sort_mode", SortMode.Default.flag)
val downloadFilterMode = intPref("download_filter_mode", DownloadsFilterMode.Default.flag)
val hasSignedIn = boolPref("has_signed_in", EhCookieStore.hasSignedIn())
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
val blackDarkTheme = boolPref("black_dark_theme", false)
val gridView = boolPref("grid_view", false)
val showGalleryPages = boolPref("show_gallery_pages", true)
val qSSaveProgress = boolPref("qs_save_progress", true)
val security = boolPref("require_unlock", false)
val recentDownloadLabel = stringOrNullPref("recent_download_label", null)
val animateItems = boolPref("animate_items", true)
val displayName = stringOrNullPref("display_name", null)
val recentDownloadLabel = stringOrNullPref("recent_download_label", "")
var downloadScheme by stringOrNullPref("image_scheme", null)
var downloadAuthority by stringOrNullPref("image_authority", null)
@@ -142,7 +148,8 @@ object Settings : DataStorePreferences(null) {
var showTagTranslations by boolPref(KEY_SHOW_TAG_TRANSLATIONS, false).observed { updateWhenTagTranslationChanges() }
var appLinkVerifyTip by boolPref("app_link_verify_tip", false)
var enabledSecurity by boolPref("enable_secure", false)
var useCIUpdateChannel by boolPref("ci_update_channel", false)
var backupBeforeUpdate by boolPref("backup_before_update", true)
var useCIUpdateChannel by boolPref("ci_update_channel", AppConfig.isSnapshot)
var mediaScan by boolPref("media_scan", false).observed { updateWhenKeepMediaStatusChanges() }
var hasDefaultDownloadLabel by boolPref("has_default_download_label", false)
var saveParseErrorBody by boolPref("save_parse_error_body", true)
@@ -158,21 +165,13 @@ object Settings : DataStorePreferences(null) {
var defaultFavSlot by intPref("default_favorite_slot", -2)
var securityDelay by intPref("require_unlock_delay", 0)
var clipboardTextHashCode by intPref("clipboard_text_hash_code", 0)
var searchCategory by intPref("search_pref", EhUtils.ALL_CATEGORY)
var requestNewsTime by intPref("request_news_time", 0).observed { updateWhenRequestNewsChanges() }
var updateIntervalDays by intPref("update_interval_days", 7)
var lastDawnDays by intPref("last_dawn_days", 0)
var recentToplist by stringPref("recent_toplist", "11")
var userAgent by stringPref("user_agent", CHROME_USER_AGENT)
var defaultDownloadLabel by stringOrNullPref("default_download_label", null)
var displayName by stringOrNullPref("display_name", null)
var avatar by stringOrNullPref("avatar", null)
var language by stringPref("app_language", "system").observed { updateWhenLocaleChanges() }
var lastUpdateTime by longPref("last_update_time", 0)
var favDialogTheta by floatPref("fav_select_dialog_delta", 0F)
// TODO: Remove this after swipe gestures are correctly handled in compose
var touchSlopFactor by intPref("touch_slop", 3)
var lastUpdateTime by longPref("last_update_time", BuildConfig.COMMIT_TIME)
// Tachiyomi Reader
var newReader by boolPref("new_compose_reader", false)
@@ -201,7 +200,8 @@ object Settings : DataStorePreferences(null) {
val invertedColors = boolPref("pref_inverted_colors", false)
val readerWebtoonNav = intPref("reader_navigation_mode_webtoon", 0)
val readerPagerNav = intPref("reader_navigation_mode_pager", 0)
val readerWebtoonNavInverted = intPref("reader_tapping_inverted_webtoon", 0)
val readerPagerNavInverted = intPref("reader_tapping_inverted_2", 0)
val readerWebtoonNavInverted = intPref("reader_tapping_inverted_webtoon_2", 0)
val webtoonSidePadding = intPref("webtoon_side_padding", 0)
val navigateToPan = boolPref("navigate_pan", true)
val imageScaleType = intPref("pref_image_scale_type_key", 1)
@@ -2,8 +2,6 @@ package com.hippo.ehviewer
import android.app.UiModeManager
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.dailycheck.updateDailyCheckWork
@@ -27,15 +25,6 @@ fun <T, R : Settings.Delegate<T>> R.observed(func: (Unit) -> Unit) = apply { col
fun <T, R : Settings.Delegate<T>> R.emitTo(flow: MutableSharedFlow<Unit>) = apply { collectScope.launch { flowGetter().collect { flow.emit(Unit) } } }
fun <T, R : PrefDelegate<T>> R.emitTo(flow: MutableSharedFlow<Unit>) = apply { collectScope.launch { changesFlow().collect { flow.emit(Unit) } } }
fun updateWhenLocaleChanges() {
val newValue = Settings.language
if ("system" == newValue) {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList())
} else {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(newValue))
}
}
fun updateWhenKeepMediaStatusChanges() {
collectScope.launchIO {
runCatching {
@@ -64,7 +53,7 @@ fun updateWhenRequestNewsChanges() {
}
fun updateWhenGallerySiteChanges() {
if (!Settings.needSignIn && EhCookieStore.hasSignedIn()) {
if (Settings.hasSignedIn.value) {
collectScope.launchIO {
runCatching {
EhEngine.getUConfig()
@@ -12,7 +12,7 @@ import io.ktor.http.renderSetCookieHeader
object EhCookieStore : CookiesStorage {
private val manager = CookieManager.getInstance()
fun signOut() = manager.removeAllCookies(null)
fun removeAllCookies() = manager.removeAllCookies(null)
fun contains(url: String, name: String) = load(Url(url)).fastAny { it.name == name }
fun hasSignedIn(): Boolean {
@@ -17,6 +17,7 @@ package com.hippo.ehviewer.client
import arrow.core.Either
import arrow.core.left
import arrow.core.partially2
import arrow.core.right
import arrow.fx.coroutines.parMap
import arrow.fx.coroutines.parZip
@@ -44,8 +45,10 @@ import com.hippo.ehviewer.client.parser.GalleryTokenApiParser
import com.hippo.ehviewer.client.parser.HomeParser
import com.hippo.ehviewer.client.parser.ProfileParser
import com.hippo.ehviewer.client.parser.RateGalleryResult
import com.hippo.ehviewer.client.parser.SignInParser
import com.hippo.ehviewer.client.parser.TorrentParser
import com.hippo.ehviewer.client.parser.TorrentResult
import com.hippo.ehviewer.client.parser.UserConfigParser
import com.hippo.ehviewer.client.parser.VoteCommentResult
import com.hippo.ehviewer.client.parser.VoteTagParser
import com.hippo.ehviewer.dailycheck.showEventNotification
@@ -62,6 +65,7 @@ import io.ktor.utils.io.pool.useInstance
import java.io.File
import java.nio.ByteBuffer
import kotlin.math.ceil
import kotlin.system.measureTimeMillis
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.SerializationException
@@ -80,11 +84,9 @@ private const val MAX_REQUEST_SIZE = 25
private const val MAX_SEQUENTIAL_REQUESTS = 5
private const val REQUEST_INTERVAL = 5000L
private const val U_CONFIG_TEXT = "Selected Profile"
fun Either<String, ByteBuffer>.saveParseError(e: Throwable) {
val dir = AppConfig.externalParseErrorDir ?: return
val file = File(dir, ReadableTime.getFilenamableTime(System.currentTimeMillis()) + ".txt")
val file = File(dir, ReadableTime.getFilenamableTime() + ".txt")
file.sink().buffer().use { sink ->
with(sink) {
writeUtf8(e.message + "\n")
@@ -177,16 +179,20 @@ object EhEngine {
return ehRequest(url, referer).fetchUsingAsByteBuffer(TorrentParser::parse)
}
suspend fun getArchiveList(url: String, gid: Long, token: String) = ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
.fetchUsingAsText(ArchiveParser::parse)
.apply { funds = funds ?: ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds) }
suspend fun getArchiveList(url: String, gid: Long, token: String): ArchiveParser.Result {
val funds = if (EhUtils.isExHentai) getFunds() else null
return ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
.fetchUsingAsText(ArchiveParser::parse.partially2(funds))
}
suspend fun getImageLimits() = parZip(
{ ehRequest(EhUrl.URL_HOME).fetchUsingAsByteBuffer(HomeParser::parse) },
{ ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds) },
{ getFunds() },
{ limits, funds -> HomeParser.Result(limits, funds) },
)
private suspend fun getFunds() = ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds)
suspend fun getNews(parse: Boolean) = ehRequest(EhUrl.URL_NEWS, EhUrl.REFERER_E)
.fetchUsingAsText { if (parse) EventPaneParser.parse(this) else null }
@@ -197,12 +203,12 @@ object EhEngine {
suspend fun getUConfig(url: String = EhUrl.uConfigUrl) {
runSuspendCatching {
ehRequest(url).fetchUsingAsText { check(U_CONFIG_TEXT in this) { "Unable to load config from $url!" } }
ehRequest(url).fetchUsingAsByteBuffer(UserConfigParser::parse)
}.onFailure { throwable ->
// It may get redirected when accessing ex for the first time
if (url == EhUrl.URL_UCONFIG_EX) {
logcat(throwable)
ehRequest(url).fetchUsingAsText { check(U_CONFIG_TEXT in this) { "Unable to load config from $url!" } }
ehRequest(url).fetchUsingAsByteBuffer(UserConfigParser::parse)
} else {
throw throwable
}
@@ -234,6 +240,22 @@ object EhEngine {
suspend fun getFavorites(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(FavoritesParser::parse)
.apply { galleryInfoList.fillInfo(url) }
suspend fun signIn(username: String, password: String): String {
val referer = "https://forums.e-hentai.org/index.php?act=Login&CODE=00"
val url = EhUrl.API_SIGN_IN
val origin = "https://forums.e-hentai.org"
return ehRequest(url, referer, origin) {
formBody {
append("referer", referer)
append("b", "")
append("bt", "")
append("UserName", username)
append("PassWord", password)
append("CookieDate", "1")
}
}.fetchUsingAsText(SignInParser::parse)
}
suspend fun commentGallery(url: String, comment: String, id: Long = -1) = ehRequest(url, url, EhUrl.origin) {
formBody {
if (id == -1L) {
@@ -431,13 +453,13 @@ object EhEngine {
}
suspend fun addFavorites(galleryList: List<Pair<Long, String>>, dstCat: Int) {
galleryList.chunked(MAX_SEQUENTIAL_REQUESTS).forEachIndexed { index, chunk ->
if (index != 0) {
delay(REQUEST_INTERVAL)
}
chunk.parMap { (gid, token) ->
galleryList.forEach { (gid, token) ->
// https://github.com/FooIbar/EhViewer/issues/1190
// Workaround for duplicate items when sorting by favorited time
val timeTaken = measureTimeMillis {
modifyFavorites(gid, token, dstCat)
}
delay(1000 - timeTaken)
}
}
}
@@ -15,6 +15,7 @@
*/
package com.hippo.ehviewer.client
import com.hippo.ehviewer.BuildConfig
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
import com.hippo.ehviewer.EhApplication.Companion.noRedirectKtorClient
import com.hippo.ehviewer.Settings
@@ -50,7 +51,7 @@ inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
val json = Json { ignoreUnknownKeys = true }
const val CHROME_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Mobile Safari/537.36"
const val CHROME_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Mobile Safari/537.36"
const val CHROME_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.5"
@@ -36,6 +36,7 @@ object EhUrl {
const val API_E = "https://api.e-hentai.org/api.php"
const val URL_UCONFIG_E = HOST_E + "uconfig.php"
const val URL_MY_TAGS_E = HOST_E + "mytags"
const val API_SIGN_IN = "https://forums.e-hentai.org/index.php?act=Login&CODE=01"
const val URL_POPULAR_E = "https://e-hentai.org/popular"
const val URL_POPULAR_EX = "https://exhentai.org/popular"
const val URL_IMAGE_SEARCH_E = "https://upload.e-hentai.org/image_lookup.php"
@@ -152,9 +152,9 @@ object EhUtils {
val categoryTextColor = Color(0xffe6e0e9)
fun signOut() {
EhCookieStore.signOut()
Settings.avatar = null
Settings.displayName = null
EhCookieStore.removeAllCookies()
Settings.displayName.value = null
Settings.hasSignedIn.value = false
Settings.gallerySite = EhUrl.SITE_E
Settings.needSignIn = true
}
@@ -262,6 +262,7 @@ data class ListUrlBuilder(
fun build(): String {
return when (mode) {
MODE_NORMAL, MODE_SUBSCRIPTION -> ehUrl(EhUrl.WATCHED_PATH.takeIf { mode == MODE_SUBSCRIPTION }) {
val category = if (!Settings.hasSignedIn.value && category <= 0) EhUtils.NON_H else category
if (category > 0) {
addQueryParameter("f_cats", (category.inv() and EhUtils.ALL_CATEGORY).toString())
}
@@ -34,7 +34,7 @@ object ArchiveParser {
private const val ERROR_INSUFFICIENT_FUNDS =
"You do not have enough funds to download this archive."
fun parse(body: String): Result {
fun parse(body: String, maybeFunds: Funds?): Result {
val m = PATTERN_HATH_FORM.find(body)!!
val paramOr = m.groupValues[1]
val archiveList = ArrayList<Archive>()
@@ -59,14 +59,12 @@ object ArchiveParser {
val item = Archive(res, name, size, cost, true)
archiveList.add(item)
}
val result = Result(paramOr, archiveList, null)
PATTERN_CURRENT_FUNDS.find(body)?.groupValues?.run {
val funds = maybeFunds ?: PATTERN_CURRENT_FUNDS.find(body)!!.groupValues.run {
val fundsGP = ParserUtils.parseInt(get(1), 0)
val fundsC = ParserUtils.parseInt(get(2), 0)
val funds = HomeParser.Funds(fundsGP, fundsC)
result.funds = funds
Funds("%,d".format(fundsGP), "%,d".format(fundsC))
}
return result
return Result(paramOr, archiveList, funds)
}
fun parseArchiveUrl(body: String): String? {
@@ -80,13 +78,13 @@ object ArchiveParser {
// TODO: Check more errors
}
class Archive(
val res: String,
val name: String,
val size: String,
val cost: String,
val isHAtH: Boolean,
)
class Result(val paramOr: String, val archiveList: List<Archive>, var funds: HomeParser.Funds?)
data class Result(val paramOr: String, val archiveList: List<Archive>, val funds: Funds)
}
class Archive(
val res: String,
val name: String,
val size: String,
val cost: String,
val isHAtH: Boolean,
)
@@ -25,18 +25,18 @@ object HomeParser {
PATTERN_FUNDS.find(body)?.groupValues?.run {
val fundsC = ParserUtils.parseInt(get(1), 0)
val fundsGP = ParserUtils.parseInt(get(2), 0) * 1000
return Funds(fundsGP, fundsC)
return Funds("%,d+".format(fundsGP), "%,d".format(fundsC))
}
throw ParseException("Parse funds error")
}
@Parcelize
data class Funds(val fundsGP: Int, val fundsC: Int) : Parcelable
@Parcelize
data class Result(val limits: Limits, val funds: Funds) : Parcelable
}
@Parcelize
data class Funds(val gp: String, val credit: String) : Parcelable
@Parcelize
@Serializable
data class Limits(val current: Int, val maximum: Int, val resetCost: Int) : Parcelable
@@ -15,6 +15,7 @@
*/
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.util.toEpochMillis
import com.hippo.ehviewer.util.toIntOrDefault
import com.hippo.ehviewer.util.toLocalDateTime
import com.hippo.ehviewer.util.toLongOrDefault
@@ -36,11 +37,14 @@ object ParserUtils {
minute()
}
@Synchronized
fun formatDate(time: Long): String {
return formatter.format(time.toLocalDateTime())
}
fun parseDate(str: String): Long {
return formatter.parse(str).toEpochMillis()
}
fun trim(str: String?): String {
return str?.unescapeXml()?.trim() ?: ""
}
@@ -0,0 +1,35 @@
/*
* Copyright 2016 Hippo Seven
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.exception.ParseException
object SignInParser {
private val NAME_PATTERN = Regex("<p>You are now logged in as: (.+?)<")
private val ERROR_PATTERN = Regex(
"<h4>The error returned was:</h4>\\s*<p>(.+?)</p>" +
"|<span class=\"postcolor\">(.+?)</span>",
)
fun parse(body: String): String {
return NAME_PATTERN.find(body)?.let {
it.groupValues[1]
} ?: ERROR_PATTERN.find(body)?.let {
throw EhException(it.groupValues[1].ifEmpty { it.groupValues[2] })
} ?: throw ParseException("Can't parse sign in")
}
}
@@ -14,16 +14,18 @@ object TorrentParser {
@Serializable
data class Torrent(
val outdated: Boolean,
val posted: String,
val size: String,
val seeds: Int,
val peers: Int,
val downloads: Int,
val uploader: String,
val url: String,
val name: String,
)
fun Torrent.format() = "[$posted] $name [$size] [↑$seeds ↓$peers ✓$downloads]"
fun Torrent.format() = "↑$seeds ↓$peers ✓$downloads"
typealias TorrentResult = List<Torrent>
@@ -0,0 +1,16 @@
package com.hippo.ehviewer.client.parser
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.exception.ParseException
import java.nio.ByteBuffer
object UserConfigParser {
private external fun parseFavCat(body: ByteBuffer, size: Int = body.limit()): Int
fun parse(body: ByteBuffer) = runCatching {
val favCat = unmarshalParsingAs<Array<String>>(body, ::parseFavCat)
Settings.favCat = favCat
}.onFailure {
throw ParseException("Can't parse user config", it)
}
}
@@ -21,7 +21,6 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.system.logcat
@@ -83,7 +82,7 @@ val today
get() = (Clock.System.now().epochSeconds / 86400).toInt()
suspend fun checkDawn() = runCatching {
if (EhCookieStore.hasSignedIn() && Settings.lastDawnDays != today) {
if (Settings.hasSignedIn.value && Settings.lastDawnDays != today) {
EhEngine.getNews(true)?.let {
Settings.lastDawnDays = today
showEventNotification(it)
@@ -8,11 +8,11 @@ import com.hippo.ehviewer.client.data.BaseGalleryInfo
@Database(
entities = [
BaseGalleryInfo::class, DownloadLabel::class, DownloadEntity::class, DownloadDirname::class,
BaseGalleryInfo::class, DownloadLabel::class, DownloadEntity::class, DownloadDirname::class, DownloadArtist::class,
Filter::class, HistoryInfo::class, LocalFavoriteInfo::class, ProgressInfo::class, QuickSearch::class,
GalleryEntityFts::class,
],
version = 22,
version = 23,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 4, to = 5, spec = Schema4to5::class),
@@ -32,11 +32,13 @@ import com.hippo.ehviewer.client.data.BaseGalleryInfo
AutoMigration(from = 19, to = 20),
AutoMigration(from = 20, to = 21, spec = Schema20to21::class),
AutoMigration(from = 21, to = 22, spec = Schema21to22::class),
AutoMigration(from = 22, to = 23),
],
)
@TypeConverters(FilterModeConverter::class)
abstract class EhDatabase : RoomDatabase() {
abstract fun galleryDao(): GalleryDao
abstract fun downloadArtistDao(): DownloadArtistDao
abstract fun downloadDirnameDao(): DownloadDirnameDao
abstract fun downloadLabelDao(): DownloadLabelDao
abstract fun downloadsDao(): DownloadsDao
@@ -0,0 +1,24 @@
package com.hippo.ehviewer.dao
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@Entity(
tableName = "DOWNLOAD_ARTISTS",
primaryKeys = ["GID", "ARTIST"],
foreignKeys = [
ForeignKey(DownloadEntity::class, ["GID"], ["GID"], onDelete = ForeignKey.CASCADE),
],
)
data class DownloadArtist(
@ColumnInfo(name = "GID")
val gid: Long,
@ColumnInfo(name = "ARTIST")
val artist: String,
) {
companion object {
fun from(gid: Long, artists: Iterable<String>) = artists.map { DownloadArtist(gid, it) }
}
}
@@ -0,0 +1,14 @@
package com.hippo.ehviewer.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
@Dao
interface DownloadArtistDao {
@Insert
suspend fun insert(downloadArtists: List<DownloadArtist>)
@Query("DELETE FROM DOWNLOAD_ARTISTS WHERE GID = :gid")
suspend fun deleteByGid(gid: Long)
}
@@ -67,9 +67,13 @@ data class DownloadInfo(
@ColumnInfo(name = "DIRNAME")
val dirname: String?,
@Relation(parentColumn = "GID", entityColumn = "GID")
var artistInfoList: List<DownloadArtist> = emptyList(),
@Embedded
val downloadInfo: DownloadEntity = DownloadEntity(galleryInfo.gid),
) : GalleryInfo by galleryInfo, AbstractDownloadInfo by downloadInfo {
companion object {
const val STATE_INVALID = -1
const val STATE_NONE = 0
@@ -79,3 +83,6 @@ data class DownloadInfo(
const val STATE_FAILED = 4
}
}
val DownloadInfo.artists: List<String>
get() = artistInfoList.map { it.artist }
@@ -14,7 +14,11 @@ import kotlinx.coroutines.flow.Flow
interface DownloadsDao {
@Suppress("ktlint:standard:annotation")
@Query("SELECT LABEL, COUNT(*) AS COUNT FROM DOWNLOADS LEFT JOIN DOWNLOAD_LABELS USING(LABEL) GROUP BY LABEL")
fun count(): Flow<Map<@MapColumn("LABEL") String?, @MapColumn("COUNT") Int>>
fun countByLabel(): Flow<Map<@MapColumn("LABEL") String?, @MapColumn("COUNT") Int>>
@Suppress("ktlint:standard:annotation")
@Query("SELECT ARTIST, COUNT(*) AS COUNT FROM DOWNLOADS LEFT JOIN DOWNLOAD_ARTISTS USING(GID) GROUP BY ARTIST ORDER BY COUNT DESC, ARTIST")
fun countByArtist(): Flow<Map<@MapColumn("ARTIST") String?, @MapColumn("COUNT") Int>>
@Query("SELECT * FROM DOWNLOADS ORDER BY TIME")
suspend fun list(): List<DownloadEntity>
@@ -12,11 +12,11 @@ interface SearchDao {
@Query("DELETE FROM suggestions WHERE `query` = :query")
suspend fun deleteQuery(query: String)
@Query("SELECT * FROM suggestions WHERE `query` LIKE :prefix || '%' ORDER BY date DESC LIMIT :limit")
suspend fun rawSuggestions(prefix: String, limit: Int): Array<Search>
@Query("SELECT DISTINCT `query` FROM suggestions WHERE `query` LIKE :prefix || '%' ORDER BY date DESC LIMIT :limit")
suspend fun rawSuggestions(prefix: String, limit: Int): List<String>
@Query("SELECT * FROM suggestions ORDER BY date DESC LIMIT :limit")
suspend fun list(limit: Int): Array<Search>
@Query("SELECT DISTINCT `query` FROM suggestions ORDER BY date DESC LIMIT :limit")
suspend fun list(limit: Int): List<String>
@Insert
suspend fun insert(search: Search)
@@ -30,6 +30,7 @@ import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.dao.DownloadArtist
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.dao.DownloadLabel
import com.hippo.ehviewer.image.Image
@@ -174,7 +175,6 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
info = DownloadInfo(galleryInfo, galleryInfo.putToDownloadDir())
info.label = label
info.state = DownloadInfo.STATE_WAIT
// Add to all download list and map
allInfoList.insertWith(info, comparator())
mAllInfoMap[galleryInfo.gid] = info
@@ -266,6 +266,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
// Save to
EhDB.putDownloadInfo(info)
EhDB.putDownloadArtist(info.gid, info.artistInfoList)
}
}
@@ -530,26 +531,63 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
}
suspend fun readMetadataFromLocal() {
val list = allInfoList.filter { it.pages == 0 || it.simpleTags == null }.parMapNotNull(concurrency = 5) { info ->
val list = allInfoList.mapNotNull {
val updateGallery = it.pages == 0 || it.simpleTags == null
val updateArtist = it.artistInfoList.isEmpty()
if (updateGallery || updateArtist) {
Triple(it, updateGallery, updateArtist)
} else {
null
}
}.parMapNotNull(concurrency = 5) { (info, updateGallery, updateArtist) ->
info.downloadDir?.run {
val comicInfo = findFile(COMIC_INFO_FILE)?.let { readComicInfo(it) }
if (comicInfo != null) {
info.pages = comicInfo.pageCount
info.simpleTags = comicInfo.toSimpleTags()
info.galleryInfo
val galleryInfo = if (updateGallery) {
info.pages = comicInfo.pageCount
info.simpleTags = comicInfo.toSimpleTags()
info.galleryInfo
} else {
null
}
val artistList = if (updateArtist && comicInfo.penciller != null) {
info.artistInfoList = DownloadArtist.from(info.gid, comicInfo.penciller)
info.gid to info.artistInfoList
} else {
null
}
if (galleryInfo != null || artistList != null) {
galleryInfo to artistList
} else {
null
}
} else if (info.pages == 0) {
findFile(SPIDER_INFO_FILENAME)?.let {
val galleryInfo = findFile(SPIDER_INFO_FILENAME)?.let {
readCompatFromUniFile(it)?.run {
info.pages = pages
info.galleryInfo
}
}
galleryInfo?.let { it to null }
} else {
null
}
}
}
EhDB.updateGalleryInfo(list)
val galleryInfoList = mutableListOf<BaseGalleryInfo>()
list.forEach { (info, artists) ->
info?.let {
galleryInfoList.add(it)
}
artists?.let { (gid, updateList) ->
EhDB.putDownloadArtist(gid, updateList)
}
}
if (galleryInfoList.isNotEmpty()) EhDB.updateGalleryInfo(galleryInfoList)
}
val isIdle: Boolean
@@ -797,5 +835,5 @@ var downloadLocation: UniFile
}
val DownloadInfo.downloadDir get() = dirname?.let { downloadLocation / it }
val DownloadInfo.archiveFile get() = downloadDir?.findFile("$gid.cbz")
val DownloadInfo.archiveFile get() = downloadDir?.run { findFile("$gid.cbz") ?: findFile("$gid.zip") }
val GalleryInfo.tempDownloadDir get() = AppConfig.getTempDir("$gid")
@@ -34,7 +34,6 @@ import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.ui.MainActivity
import com.hippo.ehviewer.ui.screen.DownloadsFragment
import com.hippo.ehviewer.util.FileUtils
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.SimpleHandler
@@ -187,7 +186,7 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
clearIntent.action = ACTION_CLEAR
val piClear = PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_IMMUTABLE)
val bundle = Bundle()
bundle.putString(DownloadsFragment.KEY_ACTION, DownloadsFragment.ACTION_CLEAR_DOWNLOAD_SERVICE)
bundle.putString(KEY_ACTION, ACTION_CLEAR_DOWNLOAD_SERVICE)
val activityIntent = Intent(this, MainActivity::class.java)
activityIntent.action = ACTION_START_DOWNLOADSCENE
activityIntent.putExtra(ACTION_START_DOWNLOADSCENE_ARGS, bundle)
@@ -243,7 +242,7 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
}
ensureDownloadingBuilder()
val bundle = Bundle()
bundle.putLong(DownloadsFragment.KEY_GID, info.gid)
bundle.putLong(KEY_GID, info.gid)
val activityIntent = Intent(this, MainActivity::class.java)
activityIntent.action = ACTION_START_DOWNLOADSCENE
activityIntent.putExtra(ACTION_START_DOWNLOADSCENE_ARGS, bundle)
@@ -540,6 +539,8 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
const val KEY_LABEL = "label"
const val KEY_GID = "gid"
const val KEY_GID_LIST = "gid_list"
const val KEY_ACTION = "action"
const val ACTION_CLEAR_DOWNLOAD_SERVICE = "clear_download_service"
private const val ID_DOWNLOADING = 1
private const val ID_DOWNLOADED = 2
private const val ID_509 = 3
@@ -0,0 +1,26 @@
package com.hippo.ehviewer.download
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.dao.artists
enum class DownloadsFilterMode(
val flag: Int,
val take: (info: DownloadInfo, label: String?) -> Boolean,
) {
CUSTOM(flag = 0, take = { info, label ->
label == "" || info.label == label
}),
ARTIST(flag = 1, take = { info, label ->
if (null == label) {
info.artistInfoList.isEmpty()
} else {
label == "" || info.artists.contains(label)
}
}),
;
companion object {
val Default = ARTIST
fun from(flag: Int) = DownloadsFilterMode.entries[flag]
}
}
@@ -55,7 +55,7 @@ class ArchivePageLoader(
private val hostJob = launch(start = CoroutineStart.LAZY) {
logcat(DEBUG_TAG) { "Open archive ${file.uri.displayPath}" }
pfd = file.openFileDescriptor("r")
size = openArchive(pfd.fd, pfd.statSize, gid == 0L)
size = openArchive(pfd.fd, pfd.statSize, gid == 0L || file.name!!.endsWith(".zip"))
if (size == 0) {
return@launch
}
@@ -22,7 +22,6 @@ import com.hippo.ehviewer.spider.SpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.Companion.obtainSpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.Companion.releaseSpiderQueen
import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener
import com.hippo.ehviewer.util.FileUtils
import com.hippo.unifile.UniFile
class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
@@ -43,11 +42,11 @@ class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
}
override val title by lazy {
FileUtils.sanitizeFilename(EhUtils.getSuitableTitle(mGalleryInfo))
EhUtils.getSuitableTitle(mGalleryInfo)
}
override fun getImageExtension(index: Int): String {
return mSpiderQueen.getExtension(index)!!
override fun getImageExtension(index: Int): String? {
return mSpiderQueen.getExtension(index)
}
override fun save(index: Int, file: UniFile): Boolean {
@@ -17,6 +17,7 @@ package com.hippo.ehviewer.gallery
import androidx.annotation.CallSuper
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.util.FileUtils
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
import kotlinx.coroutines.CoroutineScope
@@ -59,16 +60,15 @@ abstract class PageLoader2(private val gid: Long, var startPage: Int) : PageLoad
protected abstract val title: String
protected abstract fun getImageExtension(index: Int): String
protected abstract fun getImageExtension(index: Int): String?
fun getImageFilename(index: Int): String {
return "$title - ${index + 1}.${getImageExtension(index)}"
fun getImageFilename(index: Int): String? = getImageExtension(index)?.let {
FileUtils.sanitizeFilename("$title - ${index + 1}.${it.lowercase()}")
}
abstract fun save(index: Int, file: UniFile): Boolean
fun saveToDir(index: Int, dir: UniFile): UniFile? {
val filename = getImageFilename(index)
return (dir / filename).takeIf { save(index, it) }
fun saveToDir(index: Int, dir: UniFile): UniFile? = getImageFilename(index)?.let { filename ->
(dir / filename).takeIf { save(index, it) }
}
}
@@ -76,7 +76,7 @@ fun GalleryInfo.getComicInfo(): ComicInfo {
}
return ComicInfo(
series = title,
alternateSeries = titleJpn.takeUnless { it.isNullOrBlank() },
alternateSeries = titleJpn?.ifBlank { null },
writer = groups.ifEmpty { null },
penciller = artists.ifEmpty { null },
genre = otherTags.ifEmpty { null },
@@ -32,6 +32,8 @@ import com.hippo.ehviewer.client.executeSafely
import com.hippo.ehviewer.client.getImageKey
import com.hippo.ehviewer.coil.read
import com.hippo.ehviewer.coil.suspendEdit
import com.hippo.ehviewer.dao.DownloadArtist
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.download.tempDownloadDir
import com.hippo.ehviewer.image.UniFileSource
@@ -41,7 +43,6 @@ import com.hippo.ehviewer.util.sendTo
import com.hippo.unifile.UniFile
import com.hippo.unifile.asUniFile
import com.hippo.unifile.openOutputStream
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.system.logcat
import io.ktor.client.plugins.onDownload
import io.ktor.client.statement.HttpResponse
@@ -321,11 +322,15 @@ class SpiderDen(val info: GalleryInfo) {
createFile(COMIC_INFO_FILE)?.also {
runCatching {
if (info !is GalleryDetail && fetchMetadata) {
withNonCancellableContext {
EhEngine.fillGalleryListByApi(listOf(info))
EhEngine.fillGalleryListByApi(listOf(info))
}
info.getComicInfo().apply {
write(it)
DownloadManager.getDownloadInfo(gid)?.let { downloadInfo ->
downloadInfo.artistInfoList = DownloadArtist.from(gid, penciller.orEmpty())
EhDB.putDownloadArtist(gid, downloadInfo.artistInfoList)
}
}
info.getComicInfo().write(it)
}.onFailure {
logcat(it)
}
@@ -244,12 +244,15 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
if (archiveJob.isActive) {
archiveJob.join()
}
runCatching {
if (!mSpiderDen.postArchive()) {
if (!mSpiderDen.postArchive()) {
if (mWorkerScope.isDownloadMode) {
mSpiderDen.writeComicInfo()
}
runCatching {
writeSpiderInfoToLocal()
}.onFailure {
logcat(it)
}
}.onFailure {
logcat(it)
}
queenScope.cancel()
}
@@ -483,13 +486,7 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
val gid = galleryInfo.gid
return (sQueenMap[gid] ?: SpiderQueen(galleryInfo).also { sQueenMap[gid] = it }).apply {
setMode(mode)
launch {
// Will create download dir if not exists
updateMode()
if (mode == MODE_DOWNLOAD) {
mSpiderDen.writeComicInfo()
}
}
launch { updateMode() }
}
}
@@ -516,7 +513,8 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
private val downloadTimeout = Settings.downloadTimeout.seconds
private val delayLock = Mutex()
private var lastRequestTime = TimeSource.Monotonic.markNow()
private var isDownloadMode = false
var isDownloadMode = false
private set
fun cancelDecode(index: Int) {
decoder.cancel(index)
@@ -648,15 +646,15 @@ class SpiderQueen private constructor(val galleryInfo: GalleryInfo) : CoroutineS
pToken,
localShowKey,
previousPToken,
).let {
check509(it.imageUrl)
imageUrl = it.imageUrl
skipHathKey = it.skipHathKey
originImageUrl = it.originImageUrl
}
}.onFailure {
)
}.getOrElse {
forceHtml = true
return@repeat
}.let {
check509(it.imageUrl)
imageUrl = it.imageUrl
skipHathKey = it.skipHathKey
originImageUrl = it.originImageUrl
}
}
@@ -40,7 +40,6 @@ import androidx.core.content.ContextCompat
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.BaseGalleryInfo
@@ -161,7 +160,7 @@ suspend fun DialogState.startDownload(
add(it.label)
}
}
val (selected, checked) = showSelectItemWithCheckBox(
val (selected, checked) = awaitSelectItemWithCheckBox(
items,
title = R.string.download,
checkBoxText = R.string.remember_download_label,
@@ -190,7 +189,7 @@ suspend fun DialogState.startDownload(
suspend fun DialogState.modifyFavorites(galleryInfo: BaseGalleryInfo): Boolean {
val localFavorited = EhDB.containLocalFavorites(galleryInfo.gid)
if (EhCookieStore.hasSignedIn()) {
if (Settings.hasSignedIn.value) {
val isFavorited = galleryInfo.favoriteSlot != NOT_FAVORITED
val defaultFavSlot = Settings.defaultFavSlot
if (defaultFavSlot == -2) {
@@ -206,7 +205,7 @@ suspend fun DialogState.modifyFavorites(galleryInfo: BaseGalleryInfo): Boolean {
add(localFav)
addAll(cloudFav)
}
val (slot, note) = showSelectItemWithIconAndTextField(
val (slot, note) = awaitSelectItemWithIconAndTextField(
items,
title = R.string.add_favorites_dialog_title,
hint = R.string.favorite_note,
@@ -311,7 +310,7 @@ suspend fun doGalleryInfoAction(info: BaseGalleryInfo) {
add(Icons.AutoMirrored.Default.DriveFileMove to R.string.download_move_dialog_title)
}
}
val selected = showSelectItemWithIcon(items, EhUtils.getSuitableTitle(info))
val selected = awaitSelectItemWithIcon(items, EhUtils.getSuitableTitle(info))
with(findActivity<MainActivity>()) {
when (selected) {
0 -> {
@@ -410,7 +409,7 @@ suspend fun DialogState.showMoveDownloadLabel(info: GalleryInfo) {
add(it.label)
}
}
val selected = showSelectItem(labels, R.string.download_move_dialog_title)
val selected = awaitSelectItem(labels, R.string.download_move_dialog_title)
val downloadInfo = DownloadManager.getDownloadInfo(info.gid) ?: return
val label = if (selected == 0) null else labels[selected]
DownloadManager.changeLabel(listOf(downloadInfo), label)
@@ -424,19 +423,19 @@ suspend fun DialogState.showMoveDownloadLabelList(list: Collection<DownloadInfo>
add(it.label)
}
}
val selected = showSelectItem(labels, R.string.download_move_dialog_title)
val selected = awaitSelectItem(labels, R.string.download_move_dialog_title)
val label = if (selected == 0) null else labels[selected]
DownloadManager.changeLabel(list, label)
return label
}
suspend fun DialogState.showDatePicker(): String? {
suspend fun DialogState.awaitSelectDate(): String? {
val initial = LocalDate(2007, 3, 21)
val yesterday = Clock.System.todayIn(TimeZone.UTC).minus(1, DateTimeUnit.DAY)
val initialMillis = initial.toEpochMillis()
val yesterdayMillis = yesterday.toEpochMillis()
val dateRange = initialMillis..yesterdayMillis
val dateMillis = showDatePicker(
val dateMillis = awaitSelectDate(
title = R.string.go_to,
yearRange = initial.year..yesterday.year,
selectableDates = object : SelectableDates {
@@ -16,16 +16,26 @@
package com.hippo.ehviewer.ui
import android.os.Bundle
import android.view.View
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.ActivityNavigator
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.util.isAtLeastR
import eu.kanade.tachiyomi.util.view.setSecureScreen
abstract class EhActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// https://issuetracker.google.com/204791558
// Fix system bars insets still exist in fullscreen mode on API < 30
@Suppress("DEPRECATION")
if (!isAtLeastR) {
with(window.decorView) {
systemUiVisibility = systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_STABLE.inv()
}
}
}
override fun onResume() {
@@ -49,6 +49,7 @@ import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Whatshot
import androidx.compose.material3.DrawerDefaults
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerState2
import androidx.compose.material3.DrawerValue
@@ -64,9 +65,9 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.rememberDrawerState2
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
@@ -76,6 +77,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.currentCompositeKeyHash
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -92,9 +94,9 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
@@ -127,12 +129,10 @@ import com.hippo.ehviewer.ui.destinations.WhatshotScreenDestination
import com.hippo.ehviewer.ui.screen.asDst
import com.hippo.ehviewer.ui.screen.asDstWith
import com.hippo.ehviewer.ui.screen.navWithUrl
import com.hippo.ehviewer.ui.screen.navigateTo
import com.hippo.ehviewer.ui.settings.showNewVersion
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalTouchSlopProvider
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.updater.AppUpdater
import com.hippo.ehviewer.util.addTextToClipboard
@@ -147,6 +147,7 @@ import com.hippo.unifile.sha1
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
import com.ramcosta.composedestinations.utils.currentDestinationAsState
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
import eu.kanade.tachiyomi.util.lang.withIOContext
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
@@ -218,6 +219,7 @@ class MainActivity : EhActivity() {
val snackbarState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val navController = rememberNavController()
val navigator = navController.rememberDestinationsNavigator()
fun closeDrawer(callback: () -> Unit = {}) = scope.launch {
navDrawerState.close()
callback()
@@ -236,7 +238,7 @@ class MainActivity : EhActivity() {
style = MaterialTheme.typography.titleMedium,
)
}
navController.navigateTo(DownloadScreenDestination)
navigator.navigate(DownloadScreenDestination)
}
}
@@ -260,7 +262,7 @@ class MainActivity : EhActivity() {
when (intent.action) {
Intent.ACTION_VIEW -> {
val url = intent.data?.toString()
if (url != null && !navController.navWithUrl(url)) {
if (url != null && !navigator.navWithUrl(url)) {
val new = dialogState.awaitInputText(initial = url, title = cannotParse)
addTextToClipboard(new)
}
@@ -269,14 +271,14 @@ class MainActivity : EhActivity() {
val type = intent.type
if ("text/plain" == type) {
val keyword = intent.getStringExtra(Intent.EXTRA_TEXT)
if (keyword != null && !navController.navWithUrl(keyword)) {
navController.navigateTo(ListUrlBuilder(mKeyword = keyword).asDst())
if (keyword != null && !navigator.navWithUrl(keyword)) {
navigator.navigate(ListUrlBuilder(mKeyword = keyword).asDst())
}
} else if (type != null && type.startsWith("image/")) {
val uri = intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM)
if (null != uri) {
val hash = withIOContext { uri.asUniFile().sha1() }
navController.navigateTo(
navigator.navigate(
ListUrlBuilder(
mode = ListUrlBuilder.MODE_IMAGE_SEARCH,
hash = hash,
@@ -286,8 +288,11 @@ class MainActivity : EhActivity() {
}
}
DownloadService.ACTION_START_DOWNLOADSCENE -> {
val args = intent.getBundleExtra(DownloadService.ACTION_START_DOWNLOADSCENE_ARGS)
navController.navigateTo(DownloadsScreenDestination)
val args = intent.getBundleExtra(DownloadService.ACTION_START_DOWNLOADSCENE_ARGS)!!
if (args.getString(DownloadService.KEY_ACTION) == DownloadService.ACTION_CLEAR_DOWNLOAD_SERVICE) {
DownloadService.clear()
}
navigator.navigate(DownloadsScreenDestination)
}
}
}
@@ -327,11 +332,11 @@ class MainActivity : EhActivity() {
val result1 = GalleryDetailUrlParser.parse(text, false)
var launch: (() -> Unit)? = null
if (result1 != null) {
launch = { navController.navigateTo(result1.gid asDstWith result1.token) }
launch = { navigator.navigate(result1.gid asDstWith result1.token) }
}
val result2 = GalleryPageUrlParser.parse(text, false)
if (result2 != null) {
launch = { navController.navigateTo(ProgressScreenDestination(result2.gid, result2.pToken, result2.page)) }
launch = { navigator.navigate(ProgressScreenDestination(result2.gid, result2.pToken, result2.page)) }
}
launch?.let {
val ret = snackbarState.showSnackbar(snackMessage, snackAction, true)
@@ -343,17 +348,18 @@ class MainActivity : EhActivity() {
onPauseOrDispose { job.cancel() }
}
val currentDestination by navController.currentDestinationAsState()
val lockDrawerHandle = remember { mutableStateListOf<Int>() }
val drawerHandle = remember { mutableStateListOf<Int>() }
var snackbarFabPadding by remember { mutableStateOf(0.dp) }
val drawerLocked = lockDrawerHandle.isNotEmpty()
val viewConfiguration = LocalViewConfiguration.current
val drawerEnabled = drawerHandle.isNotEmpty()
val density = LocalDensity.current
val adaptiveInfo = currentWindowAdaptiveInfo()
CompositionLocalProvider(
LocalNavDrawerState provides navDrawerState,
LocalSideSheetState provides sideSheetState,
LocalDrawerLockHandle provides lockDrawerHandle,
LocalDrawerHandle provides drawerHandle,
LocalSnackBarHostState provides snackbarState,
LocalSnackBarFabPadding provides animateDpAsState(snackbarFabPadding, label = "SnackbarFabPadding"),
LocalWindowSizeClass provides adaptiveInfo.windowSizeClass,
) {
Scaffold(
snackbarHost = {
@@ -367,92 +373,85 @@ class MainActivity : EhActivity() {
)
},
) {
LocalTouchSlopProvider(Settings.touchSlopFactor.toFloat()) {
val minOffset = -with(density) { 360.dp.toPx() }
ModalNavigationDrawer(
drawerContent = {
ModalDrawerSheet(
drawerState = navDrawerState,
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 56).dp),
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Start),
var minOffset by remember {
mutableFloatStateOf(-with(density) { DrawerDefaults.MaximumDrawerWidth.toPx() })
}
ModalNavigationDrawer(
drawerContent = {
ModalDrawerSheet(
drawerState = navDrawerState,
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 56).dp)
.onSizeChanged { minOffset = -it.width.toFloat() },
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Start),
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier.verticalScroll(scrollState)
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)),
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier.verticalScroll(scrollState)
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)),
) {
Image(
painter = painterResource(id = R.drawable.sadpanda_low_poly),
contentDescription = null,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
contentScale = ContentScale.FillWidth,
Image(
painter = painterResource(id = R.drawable.sadpanda_low_poly),
contentDescription = null,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
contentScale = ContentScale.FillWidth,
)
navItems.forEach { (direction, stringId, icon) ->
NavigationDrawerItem(
label = {
Text(text = stringResource(id = stringId))
},
selected = currentDestination === direction,
onClick = {
navigator.navigate(direction)
closeDrawer()
},
modifier = Modifier.padding(horizontal = 12.dp),
icon = {
Icon(imageVector = icon, contentDescription = null)
},
)
navItems.forEach { (direction, stringId, icon) ->
NavigationDrawerItem(
label = {
Text(text = stringResource(id = stringId))
},
selected = currentDestination === direction,
onClick = {
navController.navigate(direction.route)
closeDrawer()
},
modifier = Modifier.padding(horizontal = 12.dp),
icon = {
Icon(imageVector = icon, contentDescription = null)
},
)
}
}
}
}
},
drawerState = navDrawerState,
gesturesEnabled = drawerEnabled && sideSheetState.isClosed || navDrawerState.isOpen,
) {
val sheet = sideSheet.firstOrNull()
val radius by remember {
snapshotFlow {
val step = calculateFraction(minOffset, 0f, navDrawerState.currentOffset)
with(density) { lerp(0, 10, step).dp.toPx() }
}
}.collectAsState(0f)
ModalSideDrawer(
drawerContent = {
if (sheet != null) {
ModalDrawerSheet(
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 112).dp),
drawerShape = ShapeDefaults.Large.copy(topEnd = CornerSize(0), bottomEnd = CornerSize(0)),
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.End),
) {
sheet(sideSheetState)
}
}
},
drawerState = navDrawerState,
gesturesEnabled = !drawerLocked || navDrawerState.isOpen,
modifier = Modifier.graphicsLayer {
if (radius != 0f) {
renderEffect = BlurEffect(radius, radius, TileMode.Clamp)
shape = RectangleShape
clip = true
}
},
drawerState = sideSheetState,
gesturesEnabled = sheet != null && drawerEnabled,
) {
val sheet = sideSheet.firstOrNull()
val radius by remember {
snapshotFlow {
val step = calculateFraction(minOffset, 0f, navDrawerState.currentOffset)
with(density) { lerp(0, 10, step).dp.toPx() }
}
}.collectAsState(0f)
ModalSideDrawer(
drawerContent = {
if (sheet != null) {
ModalDrawerSheet(
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 112).dp),
drawerShape = ShapeDefaults.Large.copy(topEnd = CornerSize(0), bottomEnd = CornerSize(0)),
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.End),
) {
CompositionLocalProvider(LocalViewConfiguration provides viewConfiguration) {
sheet(sideSheetState)
}
}
}
},
modifier = Modifier.graphicsLayer {
if (radius != 0f) {
renderEffect = BlurEffect(radius, radius, TileMode.Clamp)
shape = RectangleShape
clip = true
}
},
drawerState = sideSheetState,
gesturesEnabled = sheet != null && !drawerLocked,
) {
val windowSizeClass = calculateWindowSizeClass(this)
CompositionLocalProvider(
LocalViewConfiguration provides viewConfiguration,
LocalWindowSizeClass provides windowSizeClass,
) {
DestinationsNavHost(
navGraph = NavGraphs.root,
startRoute = if (Settings.needSignIn) SignInScreenDestination else StartDestination,
defaultTransitions = rememberEhNavAnim(),
navController = navController,
)
}
}
DestinationsNavHost(
navGraph = NavGraphs.root,
startRoute = if (Settings.needSignIn) SignInScreenDestination else StartDestination,
defaultTransitions = rememberEhNavAnim(),
navController = navController,
)
}
}
}
@@ -531,16 +530,15 @@ class MainActivity : EhActivity() {
val LocalNavDrawerState = compositionLocalOf<DrawerState> { error("CompositionLocal LocalNavDrawerState not present!") }
val LocalSideSheetState = compositionLocalOf<DrawerState2> { error("CompositionLocal LocalSideSheetState not present!") }
val LocalDrawerLockHandle = compositionLocalOf<SnapshotStateList<Int>> { error("CompositionLocal LocalDrawerLockHandle not present!") }
val LocalDrawerHandle = compositionLocalOf<SnapshotStateList<Int>> { error("CompositionLocal LocalDrawerHandle not present!") }
val LocalSnackBarHostState = compositionLocalOf<SnackbarHostState> { error("CompositionLocal LocalSnackBarHostState not present!") }
val LocalSnackBarFabPadding = compositionLocalOf<State<Dp>> { error("CompositionLocal LocalSnackBarFabPadding not present!") }
@Composable
fun LockDrawer(value: Boolean) {
val updated by rememberUpdatedState(value)
if (updated) {
fun DrawerHandle(enabled: Boolean) {
if (enabled) {
val current = currentCompositeKeyHash
val handle = LocalDrawerLockHandle.current
val handle = LocalDrawerHandle.current
DisposableEffect(current) {
handle.add(current)
onDispose {
@@ -12,8 +12,7 @@ suspend fun postLogin() = coroutineScope {
launch {
runCatching {
EhEngine.getProfile().run {
Settings.displayName = displayName
Settings.avatar = avatar
Settings.displayName.value = displayName
}
}.onFailure {
logcat(it)
@@ -41,5 +40,6 @@ suspend fun postLogin() = coroutineScope {
}.onFailure {
Settings.gallerySite = EhUrl.SITE_E
}
Settings.hasSignedIn.value = true
Settings.needSignIn = false
}
@@ -14,48 +14,176 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowWidthSizeClass
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.ui.LockDrawer
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.ui.StartDestination
import com.hippo.ehviewer.ui.destinations.WebViewSignInScreenDestination
import com.hippo.ehviewer.ui.openBrowser
import com.hippo.ehviewer.ui.screen.popNavigate
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.ui.tools.autofill
import com.hippo.ehviewer.util.displayString
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import kotlinx.coroutines.Job
@Destination<RootGraph>(start = true)
@Composable
fun SignInScreen(navigator: DestinationsNavigator) {
LockDrawer(true)
val windowSizeClass = LocalWindowSizeClass.current
val coroutineScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
var isProgressIndicatorVisible by rememberSaveable { mutableStateOf(false) }
var showUsernameError by rememberSaveable { mutableStateOf(false) }
var showPasswordError by rememberSaveable { mutableStateOf(false) }
var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var passwordHidden by rememberSaveable { mutableStateOf(true) }
val context = LocalContext.current
var signInJob by remember { mutableStateOf<Job?>(null) }
val dialogState = LocalDialogState.current
fun signIn() {
if (signInJob?.isActive == true) return
if (username.isEmpty()) {
showUsernameError = true
return
} else {
showUsernameError = false
}
if (password.isEmpty()) {
showPasswordError = true
return
} else {
showPasswordError = false
}
focusManager.clearFocus()
isProgressIndicatorVisible = true
EhUtils.signOut()
signInJob = coroutineScope.launchIO {
runCatching {
EhEngine.signIn(username, password)
}.onFailure {
withUIContext {
focusManager.clearFocus()
dialogState.awaitPermissionOrCancel(
confirmText = R.string.get_it,
title = R.string.sign_in_failed,
showCancelButton = false,
text = {
Text(
"""
${it.displayString()}
${stringResource(R.string.sign_in_failed_tip, stringResource(R.string.sign_in_via_webview))}
""".trimIndent(),
)
},
)
isProgressIndicatorVisible = false
}
}.onSuccess {
withNonCancellableContext { postLogin() }
withUIContext { navigator.popNavigate(StartDestination) }
}
}
}
@Composable
fun UsernameAndPasswordTextField() {
OutlinedTextField(
value = username,
onValueChange = { username = it },
modifier = Modifier.width(dimensionResource(id = R.dimen.single_max_width)).autofill(
autofillTypes = listOf(AutofillType.Username),
onFill = { username = it },
),
label = { Text(stringResource(R.string.username)) },
supportingText = { if (showUsernameError) Text(stringResource(R.string.error_username_cannot_empty)) },
trailingIcon = { if (showUsernameError) Icon(imageVector = Icons.Filled.Info, contentDescription = null) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
singleLine = true,
isError = showUsernameError,
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
modifier = Modifier.width(dimensionResource(id = R.dimen.single_max_width)).autofill(
autofillTypes = listOf(AutofillType.Password),
onFill = { password = it },
),
label = { Text(stringResource(R.string.password)) },
visualTransformation = if (passwordHidden) PasswordVisualTransformation() else VisualTransformation.None,
supportingText = { if (showPasswordError) Text(stringResource(R.string.error_password_cannot_empty)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
keyboardActions = KeyboardActions(onDone = { signIn() }),
trailingIcon = {
if (showPasswordError) {
Icon(imageVector = Icons.Filled.Info, contentDescription = null)
} else {
IconButton(onClick = { passwordHidden = !passwordHidden }) {
val visibilityIcon = if (passwordHidden) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
Icon(imageVector = visibilityIcon, contentDescription = null)
}
}
},
singleLine = true,
isError = showPasswordError,
)
}
Box(contentAlignment = Alignment.Center) {
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact, WindowWidthSizeClass.Medium -> {
when (windowSizeClass.windowWidthSizeClass) {
WindowWidthSizeClass.COMPACT, WindowWidthSizeClass.MEDIUM -> {
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).systemBarsPadding().padding(dimensionResource(id = R.dimen.keyline_margin)),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -65,6 +193,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
contentDescription = null,
modifier = Modifier.padding(dimensionResource(id = R.dimen.keyline_margin)),
)
UsernameAndPasswordTextField()
Text(
text = stringResource(id = R.string.app_waring),
modifier = Modifier.widthIn(max = dimensionResource(id = R.dimen.single_max_width)).padding(top = 24.dp),
@@ -73,7 +202,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
Text(
text = stringResource(id = R.string.app_waring_2),
modifier = Modifier.widthIn(max = dimensionResource(id = R.dimen.single_max_width)).padding(top = 12.dp),
style = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.titleLarge,
)
Spacer(modifier = Modifier.height(dimensionResource(id = R.dimen.keyline_margin)))
Row(modifier = Modifier.padding(top = dimensionResource(R.dimen.keyline_margin))) {
@@ -84,32 +213,49 @@ fun SignInScreen(navigator: DestinationsNavigator) {
Text(text = stringResource(id = R.string.register))
}
Button(
onClick = { navigator.navigate(WebViewSignInScreenDestination) },
onClick = ::signIn,
modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
) {
Text(text = stringResource(id = R.string.sign_in))
}
}
TextButton(
onClick = {
Settings.needSignIn = false
Settings.gallerySite = EhUrl.SITE_E
navigator.popNavigate(StartDestination)
},
) {
Text(
text = buildAnnotatedString {
withStyle(
style = SpanStyle(textDecoration = TextDecoration.Underline),
) {
append(stringResource(id = R.string.guest_mode))
}
Row(modifier = Modifier.padding(horizontal = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = { navigator.navigate(WebViewSignInScreenDestination) },
modifier = Modifier.weight(1f),
) {
Text(
text = buildAnnotatedString {
withStyle(
style = SpanStyle(textDecoration = TextDecoration.Underline),
) {
append(stringResource(id = R.string.sign_in_via_webview))
}
},
)
}
TextButton(
onClick = {
Settings.needSignIn = false
Settings.gallerySite = EhUrl.SITE_E
navigator.popNavigate(StartDestination)
},
)
modifier = Modifier.weight(1f),
) {
Text(
text = buildAnnotatedString {
withStyle(
style = SpanStyle(textDecoration = TextDecoration.Underline),
) {
append(stringResource(id = R.string.guest_mode))
}
},
)
}
}
}
}
WindowWidthSizeClass.Expanded -> {
WindowWidthSizeClass.EXPANDED -> {
Row(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).systemBarsPadding().padding(dimensionResource(id = R.dimen.keyline_margin)),
verticalAlignment = Alignment.CenterVertically,
@@ -132,17 +278,18 @@ fun SignInScreen(navigator: DestinationsNavigator) {
Text(
text = stringResource(id = R.string.app_waring_2),
modifier = Modifier.widthIn(max = 360.dp),
style = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.titleLarge,
)
}
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(48.dp))
UsernameAndPasswordTextField()
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.Center) {
Button(
onClick = { navigator.navigate(WebViewSignInScreenDestination) },
onClick = ::signIn,
modifier = Modifier.padding(horizontal = 4.dp).width(128.dp),
) {
Text(text = stringResource(id = R.string.sign_in))
@@ -155,27 +302,46 @@ fun SignInScreen(navigator: DestinationsNavigator) {
}
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = {
Settings.needSignIn = false
Settings.gallerySite = EhUrl.SITE_E
navigator.popNavigate(StartDestination)
},
modifier = Modifier.padding(horizontal = 4.dp),
) {
Text(
text = buildAnnotatedString {
withStyle(
style = SpanStyle(textDecoration = TextDecoration.Underline),
) {
append(stringResource(id = R.string.guest_mode))
}
Row(horizontalArrangement = Arrangement.Center) {
TextButton(
onClick = { navigator.navigate(WebViewSignInScreenDestination) },
modifier = Modifier.padding(horizontal = 4.dp).width(128.dp),
) {
Text(
text = buildAnnotatedString {
withStyle(
style = SpanStyle(textDecoration = TextDecoration.Underline),
) {
append(stringResource(id = R.string.sign_in_via_webview))
}
},
)
}
TextButton(
onClick = {
Settings.needSignIn = false
Settings.gallerySite = EhUrl.SITE_E
navigator.popNavigate(StartDestination)
},
)
modifier = Modifier.padding(horizontal = 4.dp).width(128.dp),
) {
Text(
text = buildAnnotatedString {
withStyle(
style = SpanStyle(textDecoration = TextDecoration.Underline),
) {
append(stringResource(id = R.string.guest_mode))
}
},
)
}
}
}
}
}
}
if (isProgressIndicatorVisible) {
CircularProgressIndicator()
}
}
}
@@ -13,7 +13,6 @@ import com.google.accompanist.web.rememberWebViewState
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.ui.LockDrawer
import com.hippo.ehviewer.ui.StartDestination
import com.hippo.ehviewer.ui.screen.popNavigate
import com.hippo.ehviewer.util.setDefaultSettings
@@ -28,7 +27,6 @@ import io.ktor.http.Url
@Destination<RootGraph>
@Composable
fun WebViewSignInScreen(navigator: DestinationsNavigator) {
LockDrawer(true)
val coroutineScope = rememberCoroutineScope()
val state = rememberWebViewState(url = EhUrl.URL_SIGN_IN)
val client = remember {
@@ -2,6 +2,8 @@ package com.hippo.ehviewer.ui.main
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
object AdvanceTable {
const val SH = 0x1
@@ -12,7 +14,9 @@ object AdvanceTable {
}
@Parcelize
@Serializable
data class AdvancedSearchOption(
@Transient
val advanceSearch: Int = 0,
val minRating: Int = 0,
val fromPage: Int = 0,
@@ -0,0 +1,112 @@
package com.hippo.ehviewer.ui.main
import androidx.compose.foundation.layout.Arrangement
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.parser.Archive
import com.hippo.ehviewer.client.parser.Funds
@Composable
fun ArchiveList(
funds: Funds,
items: List<Archive>,
onItemClick: (Archive) -> Unit,
modifier: Modifier = Modifier,
) {
val currentFunds = stringResource(R.string.current_funds)
val archiveFree = stringResource(R.string.archive_free)
val archiveOriginal = stringResource(R.string.archive_original)
val archiveResample = stringResource(R.string.archive_resample)
val fundsStyle = MaterialTheme.typography.labelLarge
val (hAtH, nonHAtH) = remember(items) { items.partition { it.isHAtH } }
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Text(text = currentFunds, textAlign = TextAlign.Center)
}
item {
FundsItem(
type = "GP",
amount = funds.gp,
modifier = Modifier.fillMaxWidth(),
textStyle = fundsStyle,
)
}
item {
FundsItem(
type = "C",
amount = funds.credit,
modifier = Modifier.fillMaxWidth(),
textStyle = fundsStyle,
)
}
items(nonHAtH) {
ArchiveItem(
name = if (it.res == "org") archiveOriginal else archiveResample,
cost = if (it.cost == "Free!") archiveFree else it.cost,
size = it.size,
onClick = { onItemClick(it) },
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(text = "H@H")
}
}
items(hAtH) {
ArchiveItem(
name = if (it.res == "org") archiveOriginal else it.name,
cost = if (it.cost == "Free") archiveFree else it.cost,
size = it.size,
onClick = { onItemClick(it) },
)
}
}
}
@Composable
private fun ArchiveItem(
name: String,
cost: String,
size: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val labelStyle = MaterialTheme.typography.labelMedium
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = cost,
style = labelStyle,
)
Button(onClick = onClick) {
Text(text = name)
}
Text(
text = size,
style = labelStyle,
)
}
}
@@ -2,10 +2,10 @@ package com.hippo.ehviewer.ui.main
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
@@ -18,19 +18,24 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.ConstraintSet
import androidx.constraintlayout.compose.Dimension
import arrow.core.Tuple9
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhUtils
@@ -40,6 +45,51 @@ import com.hippo.ehviewer.ui.tools.CrystalCard
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
import com.hippo.ehviewer.util.FileUtils
private val ids = Tuple9(1, 2, 3, 4, 5, 6, 7, 8, 9)
private val constraintSet = ConstraintSet {
val (
titleRef, uploaderRef, ratingRef, categoryRef, actionsRef, stateTextRef,
progressBarRef, progressTextRef, speedRef,
) = createRefsFor(1, 2, 3, 4, 5, 6, 7, 8, 9)
constrain(titleRef) {
top.linkTo(parent.top)
width = Dimension.matchParent
}
constrain(uploaderRef) {
start.linkTo(parent.start)
bottom.linkTo(actionsRef.top)
}
constrain(ratingRef) {
start.linkTo(parent.start)
top.linkTo(actionsRef.top, 1.dp)
}
constrain(categoryRef) {
start.linkTo(parent.start)
bottom.linkTo(parent.bottom)
}
constrain(progressBarRef) {
bottom.linkTo(actionsRef.top)
width = Dimension.matchParent
}
constrain(progressTextRef) {
bottom.linkTo(progressBarRef.top)
start.linkTo(progressBarRef.start)
}
constrain(speedRef) {
bottom.linkTo(progressBarRef.top)
end.linkTo(progressBarRef.end)
}
constrain(actionsRef) {
end.linkTo(parent.end, (-4).dp)
bottom.linkTo(parent.bottom, (-4).dp)
}
constrain(stateTextRef) {
end.linkTo(parent.end)
bottom.linkTo(actionsRef.top)
}
}
@Composable
fun DownloadCard(
onClick: () -> Unit,
@@ -48,143 +98,122 @@ fun DownloadCard(
onStart: () -> Unit,
onStop: () -> Unit,
info: DownloadInfo,
selectMode: Boolean,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
CrystalCard(
modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
) {
Row {
Card(onClick = onThumbClick) {
) = CrystalCard(modifier = modifier, onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource) {
Row {
val thumb = remember {
movableContentOf<DownloadInfo> {
EhAsyncCropThumb(
key = info,
modifier = Modifier.aspectRatio(DEFAULT_ASPECT).fillMaxSize(),
key = it,
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
)
}
val stateFailed = stringResource(R.string.download_state_failed)
val stateFailed2 = stringResource(R.string.download_state_failed_2, info.legacy)
val downloadState by DownloadManager.collectDownloadState(info.gid)
val stateText = when (downloadState) {
DownloadInfo.STATE_NONE -> stringResource(R.string.download_state_none)
DownloadInfo.STATE_WAIT -> stringResource(R.string.download_state_wait)
DownloadInfo.STATE_DOWNLOAD -> null
DownloadInfo.STATE_FAILED -> if (info.legacy <= 0) stateFailed else stateFailed2
DownloadInfo.STATE_FINISH -> stringResource(R.string.download_state_finish)
else -> null // Chill, will be removed soon
}
if (selectMode) {
Card {
thumb(info)
}
ConstraintLayout(modifier = Modifier.padding(8.dp, 4.dp).fillMaxSize()) {
val (
titleRef, uploaderRef, ratingRef, categoryRef, actionsRef, stateTextRef,
progressBarRef, progressTextRef, speedRef,
) = createRefs()
Text(
text = EhUtils.getSuitableTitle(info),
maxLines = 2,
modifier = Modifier.constrainAs(titleRef) {
top.linkTo(parent.top)
start.linkTo(parent.start)
}.fillMaxWidth(),
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
)
if (downloadState != DownloadInfo.STATE_DOWNLOAD) {
ProvideTextStyle(MaterialTheme.typography.labelLarge) {
info.uploader?.let {
Text(
text = it,
modifier = Modifier.constrainAs(uploaderRef) {
start.linkTo(parent.start)
bottom.linkTo(ratingRef.top)
}.alpha(if (info.disowned) 0.5f else 1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
GalleryListCardRating(
rating = info.rating,
modifier = Modifier.constrainAs(ratingRef) {
start.linkTo(parent.start)
bottom.linkTo(categoryRef.top)
},
)
val categoryColor = EhUtils.getCategoryColor(info.category)
val categoryText = EhUtils.getCategory(info.category).uppercase()
} else {
Card(onClick = onThumbClick) {
thumb(info)
}
}
val stateFailed = stringResource(R.string.download_state_failed)
val stateFailed2 = stringResource(R.string.download_state_failed_2, info.legacy)
val downloadState by DownloadManager.collectDownloadState(info.gid)
val stateText = when (downloadState) {
DownloadInfo.STATE_NONE -> stringResource(R.string.download_state_none)
DownloadInfo.STATE_WAIT -> stringResource(R.string.download_state_wait)
DownloadInfo.STATE_DOWNLOAD -> null
DownloadInfo.STATE_FAILED -> if (info.legacy <= 0) stateFailed else stateFailed2
DownloadInfo.STATE_FINISH -> stringResource(R.string.download_state_finish)
else -> null // Chill, will be removed soon
}
ConstraintLayout(
modifier = Modifier.padding(start = 8.dp, top = 2.dp, end = 4.dp, bottom = 4.dp).fillMaxSize(),
constraintSet = constraintSet,
) {
val (
titleRef, uploaderRef, ratingRef, categoryRef, actionsRef, stateTextRef,
progressBarRef, progressTextRef, speedRef,
) = ids
Text(
text = EhUtils.getSuitableTitle(info),
maxLines = 2,
modifier = Modifier.layoutId(titleRef),
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
)
if (downloadState != DownloadInfo.STATE_DOWNLOAD) {
ProvideTextStyle(MaterialTheme.typography.labelLarge) {
info.uploader?.let {
Text(
text = categoryText,
modifier = Modifier.constrainAs(categoryRef) {
start.linkTo(parent.start)
bottom.linkTo(parent.bottom)
}.clip(ShapeDefaults.Small).background(categoryColor).padding(vertical = 2.dp, horizontal = 8.dp),
color = if (Settings.harmonizeCategoryColor) Color.Unspecified else EhUtils.categoryTextColor,
textAlign = TextAlign.Center,
text = it,
modifier = Modifier.layoutId(uploaderRef).alpha(if (info.disowned) 0.5f else 1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} else {
val (total, finished, speed) = DownloadManager.updatedDownloadInfo(info) {
Triple(total, finished, speed)
}
ProvideTextStyle(MaterialTheme.typography.labelMedium) {
if (total <= 0 || finished < 0) {
LinearProgressIndicator(
modifier = Modifier.constrainAs(progressBarRef) {
bottom.linkTo(actionsRef.top)
}.fillMaxWidth(),
)
} else {
LinearProgressIndicator(
progress = { finished.toFloat() / total.toFloat() },
modifier = Modifier.constrainAs(progressBarRef) {
bottom.linkTo(actionsRef.top)
}.fillMaxWidth(),
)
Text(
text = "$finished/$total",
modifier = Modifier.constrainAs(progressTextRef) {
bottom.linkTo(progressBarRef.top)
start.linkTo(progressBarRef.start)
},
)
}
Text(
text = FileUtils.humanReadableByteCount(speed.coerceAtLeast(0), false) + "/S",
modifier = Modifier.constrainAs(speedRef) {
bottom.linkTo(progressBarRef.top)
end.linkTo(progressBarRef.end)
},
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.constrainAs(actionsRef) {
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
},
) {
if (downloadState == DownloadInfo.STATE_WAIT || downloadState == DownloadInfo.STATE_DOWNLOAD) {
IconButton(onClick = onStop) {
Icon(imageVector = Icons.Default.Pause, contentDescription = null)
}
} else {
IconButton(onClick = onStart) {
Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null)
}
}
}
if (stateText != null) {
GalleryListCardRating(
rating = info.rating,
modifier = Modifier.layoutId(ratingRef),
)
val categoryColor = EhUtils.getCategoryColor(info.category)
val categoryText = EhUtils.getCategory(info.category).uppercase()
Text(
text = stateText,
modifier = Modifier.constrainAs(stateTextRef) {
end.linkTo(parent.end, 16.dp)
bottom.linkTo(actionsRef.top)
},
style = MaterialTheme.typography.titleSmall,
text = categoryText,
modifier = Modifier.layoutId(categoryRef).clip(ShapeDefaults.Small)
.background(categoryColor).padding(vertical = 2.dp, horizontal = 8.dp),
color = if (Settings.harmonizeCategoryColor) Color.Unspecified else EhUtils.categoryTextColor,
textAlign = TextAlign.Center,
)
}
} else {
val (total, finished, speed) = DownloadManager.updatedDownloadInfo(info) {
Triple(total, finished, speed)
}
ProvideTextStyle(MaterialTheme.typography.labelMedium) {
if (total <= 0 || finished < 0) {
LinearProgressIndicator(modifier = Modifier.layoutId(progressBarRef))
} else {
LinearProgressIndicator(
progress = { finished.toFloat() / total.toFloat() },
modifier = Modifier.layoutId(progressBarRef),
)
Text(
text = "$finished/$total",
modifier = Modifier.layoutId(progressTextRef),
)
}
Text(
text = FileUtils.humanReadableByteCount(speed.coerceAtLeast(0), false) + "/S",
modifier = Modifier.layoutId(speedRef),
)
}
}
val running = downloadState == DownloadInfo.STATE_WAIT || downloadState == DownloadInfo.STATE_DOWNLOAD
val icon = remember {
movableContentOf<Boolean> {
Icon(imageVector = if (it) Icons.Default.Pause else Icons.Default.PlayArrow, contentDescription = null)
}
}
if (selectMode) {
Box(modifier = Modifier.layoutId(actionsRef).minimumInteractiveComponentSize()) {
icon(running)
}
} else {
IconButton(onClick = if (running) onStop else onStart, modifier = Modifier.layoutId(actionsRef)) {
icon(running)
}
}
if (stateText != null) {
Text(
text = stateText,
modifier = Modifier.layoutId(stateTextRef),
style = MaterialTheme.typography.labelLarge,
)
}
}
}
@@ -7,11 +7,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil3.Image
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.request.ImageRequest
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.ktbuilder.imageRequest
@@ -29,13 +28,15 @@ fun requestOf(model: GalleryInfo): ImageRequest {
fun EhAsyncThumb(
model: GalleryInfo,
modifier: Modifier = Modifier,
placeholder: Painter? = null,
onSuccess: ((Image) -> Unit)? = null,
contentScale: ContentScale = ContentScale.Fit,
) = AsyncImage(
model = requestOf(model),
contentDescription = null,
modifier = modifier,
placeholder = placeholder,
onSuccess = onSuccess?.let { callback ->
{ callback(it.result.image) }
},
contentScale = contentScale,
)
@@ -49,11 +50,9 @@ fun EhAsyncCropThumb(
model = requestOf(key),
contentDescription = null,
modifier = modifier,
onState = {
if (it is AsyncImagePainter.State.Success) {
if (it.result.image.shouldCrop) {
contentScale = ContentScale.Crop
}
onSuccess = {
if (it.result.image.shouldCrop) {
contentScale = ContentScale.Crop
}
},
contentScale = contentScale,
@@ -3,7 +3,6 @@ package com.hippo.ehviewer.ui.main
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.EaseOut
import androidx.compose.animation.core.tween
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.MutatorMutex
@@ -12,6 +11,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
@@ -23,7 +23,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
@@ -31,27 +30,27 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import com.hippo.ehviewer.ui.tools.PredictiveBackEasing
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
import com.hippo.ehviewer.ui.tools.snackBarPadding
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.onEachLatest
import moe.tarsin.coroutines.runSuspendCatching
enum class FabLayoutValue {
Hidden,
Primary,
Expand,
}
@Stable
enum class FabLayoutValue { Hidden, Primary, Expand }
@Stable
@Composable
fun rememberFabLayoutState(initialValue: FabLayoutValue = FabLayoutValue.Primary): FabLayoutState {
return remember { FabLayoutState(initialValue) }
fun rememberFabLayoutState(initialValue: FabLayoutValue = FabLayoutValue.Primary) = remember {
FabLayoutState(initialValue)
}
@Stable
@@ -97,9 +96,11 @@ class FabLayoutState(
}
fun interface FabBuilder {
fun onClick(icon: ImageVector, that: suspend () -> Unit)
fun onClick(icon: ImageVector, autoClose: Boolean, that: suspend () -> Unit)
fun onClick(icon: ImageVector, that: suspend () -> Unit) = onClick(icon, true, that)
}
context(CoroutineScope)
@Composable
fun FabLayout(
hidden: Boolean,
@@ -132,7 +133,11 @@ fun FabLayout(
record { buildFab(builder) }
transform { onEachLatest { state.waitCollapse() } }
}
val coroutineScope = rememberCoroutineScope()
val density = LocalDensity.current
val interval = remember(density) { with(density) { FabInterval.roundToPx() } }
val padding = remember(density) { with(density) { FabPadding.roundToPx() } }
if (updatedExpanded && autoCancel) {
Spacer(
modifier = Modifier.fillMaxSize().pointerInput(Unit) {
@@ -140,77 +145,76 @@ fun FabLayout(
},
)
}
val appearState by state.appearProgress.asState()
val animatedProgress by state.expandProgress.asState()
PredictiveBackHandler(updatedExpanded) { flow ->
try {
state.mutatorMutex.mutate(MutatePriority.UserInput) {
flow.collect {
val eased = PredictiveBackEasing.transform(it.progress)
state.expandProgress.snapTo(1 - eased)
}
}
onExpandChanged(false)
} catch (e: CancellationException) {
launch { state.expand() }
}
}
Box(
modifier = Modifier.fillMaxSize().navigationBarsPadding().padding(16.dp).snackBarPadding(),
modifier = Modifier.fillMaxSize().navigationBarsPadding().snackBarPadding(),
contentAlignment = Alignment.BottomEnd,
) {
Box(
modifier = Modifier.graphicsLayer {
if (!state.appearProgress.isRunning && !updatedHidden) {
Box(
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = animatedProgress },
contentAlignment = Alignment.BottomEnd,
) {
with(secondaryFab) {
forEachIndexed { index, (imageVector, autoClose, onClick) ->
SmallFloatingActionButton(
onClick = {
launch(Dispatchers.Default) {
onClick()
if (autoClose) onExpandChanged(false)
}
},
modifier = Modifier.padding(20.dp).offset {
val distance = lerp(0, interval * (size - index) + padding, animatedProgress)
IntOffset(0, -distance)
},
) {
Icon(imageVector = imageVector, contentDescription = null)
}
}
}
}
}
val appearState by state.appearProgress.asState()
FloatingActionButton(
onClick = { onExpandChanged(!updatedExpanded) },
modifier = Modifier.padding(16.dp).graphicsLayer {
rotationZ = lerp(-90f, 0f, appearState)
scaleX = appearState
scaleY = appearState
},
contentAlignment = Alignment.Center,
) {
val animatedProgress by state.expandProgress.asState()
PredictiveBackHandler(updatedExpanded) { flow ->
try {
state.mutatorMutex.mutate(MutatePriority.UserInput) {
flow.collect {
val eased = EaseOut.transform(it.progress)
state.expandProgress.snapTo(1 - eased)
}
}
onExpandChanged(false)
} catch (e: CancellationException) {
coroutineScope.launch {
state.expand()
}
}
}
FloatingActionButton(onClick = { onExpandChanged(!updatedExpanded) }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
modifier = Modifier.graphicsLayer {
rotationZ = lerp(-135f, 0f, animatedProgress)
},
)
}
secondaryFab.run {
forEachIndexed { index, (imageVector, onClick) ->
SmallFloatingActionButton(
onClick = {
coroutineScope.launchIO {
runSuspendCatching {
onClick()
}
onExpandChanged(false)
}
},
modifier = Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
val distance = lerp(0, 150 * (size - index) + 50, animatedProgress)
placeable.placeRelative(0, -distance, -(size - index).toFloat())
}
}.graphicsLayer {
alpha = animatedProgress
},
) {
Icon(imageVector = imageVector, contentDescription = null)
}
}
}
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
modifier = Modifier.graphicsLayer {
rotationZ = lerp(-135f, 0f, animatedProgress)
},
)
}
}
}
private fun buildFab(builder: FabBuilder.() -> Unit) = buildList {
builder { icon, action ->
add(icon to action)
builder { icon, autoClose, action ->
add(Triple(icon, autoClose, action))
}
}
private val FabInterval = 56.dp // Small FAB height + 16 dp
private val FabPadding = 8.dp // (FAB height - small FAB height) / 2
const val FAB_ANIMATE_TIME = 300
@@ -0,0 +1,32 @@
package com.hippo.ehviewer.ui.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Badge
import androidx.compose.material3.LocalTextStyle
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.text.TextStyle
import androidx.compose.ui.unit.dp
@Composable
fun FundsItem(
type: String,
amount: String,
modifier: Modifier = Modifier,
textStyle: TextStyle = LocalTextStyle.current,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
Badge(containerColor = MaterialTheme.colorScheme.primaryContainer) {
Text(text = type)
}
Text(text = amount, style = textStyle)
}
}
@@ -5,12 +5,13 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
@@ -25,16 +26,14 @@ import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.BrushPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
@@ -42,6 +41,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.ConstraintSet
import androidx.constraintlayout.compose.Dimension
import arrow.core.Tuple7
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhUtils
@@ -60,15 +60,15 @@ private val constraintSet = ConstraintSet {
createRefsFor(1, 2, 3, 4, 5, 6, 7)
constrain(titleRef) {
top.linkTo(parent.top)
start.linkTo(parent.start)
width = Dimension.matchParent
}
constrain(uploaderRef) {
start.linkTo(parent.start)
bottom.linkTo(ratingRef.top)
bottom.linkTo(iconsRef.top)
}
constrain(ratingRef) {
start.linkTo(parent.start)
bottom.linkTo(categoryRef.top)
top.linkTo(iconsRef.top, 1.dp)
}
constrain(categoryRef) {
start.linkTo(parent.start)
@@ -76,7 +76,7 @@ private val constraintSet = ConstraintSet {
}
constrain(iconsRef) {
end.linkTo(parent.end)
bottom.linkTo(postedRef.top)
bottom.linkTo(categoryRef.top)
}
constrain(favRef) {
end.linkTo(parent.end)
@@ -84,7 +84,7 @@ private val constraintSet = ConstraintSet {
}
constrain(postedRef) {
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
linkTo(categoryRef.top, parent.bottom)
}
}
@@ -107,15 +107,18 @@ fun GalleryInfoListItem(
Card {
EhAsyncCropThumb(
key = info,
modifier = Modifier.aspectRatio(DEFAULT_ASPECT).fillMaxSize(),
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
)
}
ConstraintLayout(modifier = Modifier.padding(8.dp, 4.dp).fillMaxSize(), constraintSet = constraintSet) {
ConstraintLayout(
modifier = Modifier.padding(start = 8.dp, top = 2.dp, end = 4.dp, bottom = 4.dp).fillMaxSize(),
constraintSet = constraintSet,
) {
val (titleRef, uploaderRef, ratingRef, categoryRef, postedRef, favRef, iconsRef) = ids
Text(
text = EhUtils.getSuitableTitle(info),
maxLines = 2,
modifier = Modifier.layoutId(titleRef).fillMaxWidth(),
modifier = Modifier.layoutId(titleRef),
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
)
@@ -145,6 +148,8 @@ fun GalleryInfoListItem(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.layoutId(iconsRef),
) {
// Placeholder to reserve minimum height
Text(text = "")
val download by DownloadManager.collectContainDownloadInfo(info.gid)
if (download) {
Icon(
@@ -153,7 +158,9 @@ fun GalleryInfoListItem(
modifier = Modifier.size(16.dp),
)
}
Text(text = info.simpleLanguage.orEmpty())
info.simpleLanguage?.let {
Text(text = it)
}
if (info.pages != 0 && showPages) {
Text(text = "${info.pages}P")
}
@@ -163,11 +170,12 @@ fun GalleryInfoListItem(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.layoutId(favRef),
) {
// Placeholder to reserve minimum height
Text(text = "")
if (isInFavScene) {
Text(
text = info.favoriteNote.orEmpty(),
fontStyle = FontStyle.Italic,
)
info.favoriteNote?.let {
Text(text = it, fontStyle = FontStyle.Italic)
}
} else {
val showFav by FavouriteStatusRouter.collectAsState(info) { it != NOT_FAVORITED }
if (showFav) {
@@ -176,7 +184,9 @@ fun GalleryInfoListItem(
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Text(text = info.favoriteName.orEmpty())
info.favoriteName?.let {
Text(text = it)
}
}
}
}
@@ -195,7 +205,8 @@ fun GalleryInfoGridItem(
onLongClick: () -> Unit,
info: GalleryInfo,
modifier: Modifier = Modifier,
badgeText: String? = info.simpleLanguage,
showLanguage: Boolean = true,
showPages: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) = ElevatedCard(
modifier = modifier,
@@ -204,19 +215,20 @@ fun GalleryInfoGridItem(
interactionSource = interactionSource,
) {
Box {
val placeholder = remember {
val aspect = if (info.thumbHeight != 0) {
(info.thumbWidth.toFloat() / info.thumbHeight).coerceIn(MIN_ASPECT, MAX_ASPECT)
var ratio by remember(info) {
val ratio = if (info.thumbHeight != 0) {
(info.thumbWidth.toFloat() / info.thumbHeight).coerceIn(MIN_RATIO, MAX_RATIO)
} else {
DEFAULT_ASPECT
DEFAULT_RATIO
}
BrushPainter(Brush.linearGradient(PlaceholderColors, end = Offset(aspect, 1f)))
mutableFloatStateOf(ratio)
}
EhAsyncThumb(
model = info,
modifier = Modifier.fillMaxWidth(),
placeholder = placeholder,
contentScale = ContentScale.Crop,
modifier = Modifier.aspectRatio(ratio),
onSuccess = {
ratio = (it.width.toFloat() / it.height).coerceIn(MIN_RATIO, MAX_RATIO)
},
)
val categoryColor = EhUtils.getCategoryColor(info.category)
Badge(
@@ -224,15 +236,20 @@ fun GalleryInfoGridItem(
containerColor = categoryColor,
contentColor = if (Settings.harmonizeCategoryColor) contentColorFor(categoryColor) else EhUtils.categoryTextColor,
) {
if (badgeText != null) {
Text(text = badgeText)
val shouldShowLanguage = showLanguage && info.simpleLanguage != null
if (showPages && info.pages > 0) {
Text(text = "${info.pages}")
if (shouldShowLanguage) {
Spacer(modifier = Modifier.width(4.dp))
}
}
if (shouldShowLanguage) {
Text(text = "${info.simpleLanguage}")
}
}
}
}
private val PlaceholderColors = listOf(Color.Transparent, Color.Transparent)
private const val MIN_ASPECT = 0.33F
private const val MAX_ASPECT = 1.5F
const val DEFAULT_ASPECT = 0.67F
private const val MIN_RATIO = 0.5F
private const val MAX_RATIO = 1.5F
const val DEFAULT_RATIO = 0.67F
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
@@ -25,19 +26,24 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
@@ -54,7 +60,10 @@ import com.hippo.ehviewer.ui.screen.collectDetailSizeAsState
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalGrid
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalStaggeredGrid
import com.hippo.ehviewer.util.displayString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@Stable
operator fun PaddingValues.plus(r: PaddingValues) = object : PaddingValues {
@@ -65,6 +74,7 @@ operator fun PaddingValues.plus(r: PaddingValues) = object : PaddingValues {
override fun calculateTopPadding() = l.calculateTopPadding() + r.calculateTopPadding()
}
context(CoroutineScope)
@Composable
fun GalleryList(
modifier: Modifier = Modifier,
@@ -76,20 +86,41 @@ fun GalleryList(
detailItemContent: @Composable (LazyGridItemScope.(BaseGalleryInfo) -> Unit),
thumbListState: LazyStaggeredGridState = rememberLazyStaggeredGridState(),
thumbItemContent: @Composable (LazyStaggeredGridItemScope.(BaseGalleryInfo) -> Unit),
refreshState: PullToRefreshState,
searchBarOffsetY: () -> Int,
scrollToTopOnRefresh: Boolean = true,
onRefresh: () -> Unit,
onLoading: () -> Unit,
) {
val marginH = dimensionResource(id = R.dimen.gallery_list_margin_h)
val marginV = dimensionResource(id = R.dimen.gallery_list_margin_v)
val combinedModifier = contentModifier.nestedScroll(refreshState.nestedScrollConnection)
Box(modifier = modifier.fillMaxSize()) {
var isRefreshing by remember { mutableStateOf(false) }
val refreshState = rememberPullToRefreshState()
Box(
modifier = modifier.fillMaxSize().pullToRefresh(
isRefreshing = isRefreshing,
state = refreshState,
enabled = data.loadState.refresh is LoadState.NotLoading,
) {
isRefreshing = true
launch {
if (data.loadState.prepend.endOfPaginationReached) {
onRefresh()
} else {
data.retry()
}
snapshotFlow { data.loadState }.drop(1).first { state ->
state.prepend !is LoadState.Loading && state.refresh !is LoadState.Loading
}
isRefreshing = false
}
},
) {
if (listMode == 0) {
val columnWidth by collectDetailSizeAsState()
FastScrollLazyVerticalGrid(
columns = GridCells.Adaptive(columnWidth),
modifier = combinedModifier,
modifier = contentModifier,
state = detailListState,
contentPadding = contentPadding + PaddingValues(marginH, marginV),
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.gallery_list_interval)),
@@ -118,7 +149,7 @@ fun GalleryList(
val thumbColumns by Settings.thumbColumns.collectAsState()
FastScrollLazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(thumbColumns),
modifier = combinedModifier,
modifier = contentModifier,
state = thumbListState,
verticalItemSpacing = gridInterval,
horizontalArrangement = Arrangement.spacedBy(gridInterval),
@@ -144,23 +175,8 @@ fun GalleryList(
}
}
if (refreshState.isRefreshing) {
LaunchedEffect(Unit) {
if (data.loadState.prepend.endOfPaginationReached) {
onRefresh()
} else {
data.retry()
}
snapshotFlow { data.loadState }.drop(1).collect { state ->
if (state.prepend !is LoadState.Loading && state.refresh !is LoadState.Loading) {
refreshState.endRefresh()
}
}
}
}
when (val state = data.loadState.refresh) {
is LoadState.Loading -> if (!refreshState.isRefreshing && scrollToTopOnRefresh) {
is LoadState.Loading -> if (!isRefreshing && scrollToTopOnRefresh) {
LaunchedEffect(Unit) {
onLoading()
}
@@ -195,6 +211,13 @@ fun GalleryList(
ErrorTip(modifier = Modifier.widthIn(max = 228.dp), text = stringResource(id = R.string.gallery_list_empty_hit))
}
}
PullToRefreshDefaults.Indicator(
state = refreshState,
isRefreshing = isRefreshing,
modifier = Modifier.align(Alignment.TopCenter).padding(top = contentPadding.calculateTopPadding())
.offset { IntOffset(0, searchBarOffsetY()) },
)
}
}
@@ -90,7 +90,7 @@ fun EhPreviewItem(
Box(contentAlignment = Alignment.Center) {
CrystalCard(
onClick = onClick,
modifier = Modifier.aspectRatio(DEFAULT_ASPECT),
modifier = Modifier.aspectRatio(DEFAULT_RATIO),
) {
if (galleryPreview != null) {
EhAsyncPreview(
@@ -12,9 +12,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
@@ -39,8 +37,11 @@ import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.asMutableState
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.S_LANG_TAGS
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.ui.tools.DropdownFilterChip
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.thenIf
import com.hippo.ehviewer.util.toIntOrDefault
import kotlinx.coroutines.launch
@@ -68,10 +69,11 @@ fun SearchFilter(
val scope = rememberCoroutineScope()
val context = LocalContext.current
val dialogState = LocalDialogState.current
val animateItems by Settings.animateItems.collectAsState()
fun isCategoryChecked(bit: Int) = category and bit != 0
val categories = remember(category) { categoryTable.sortedBy { !isCategoryChecked(it.first) } }
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Workaround for the first item's animation
@@ -82,44 +84,48 @@ fun SearchFilter(
selected = isCategoryChecked(it.first),
onClick = { onCategoryChanged(category xor it.first) },
label = { Text(text = stringResource(id = it.second)) },
modifier = Modifier.animateItem(),
modifier = Modifier.thenIf(animateItems) { animateItem() },
)
}
}
var languageFilter by Settings.languageFilter.asMutableState()
val languages = remember {
GalleryInfo.S_LANG_TAGS.map { tag ->
tag.substringAfter(':').let {
if (EhTagDatabase.initialized && EhTagDatabase.isTranslatable(context)) {
EhTagDatabase.getTranslation("l", it) ?: it
} else {
it
}
}
}
}
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val any = stringResource(id = R.string.any)
val languageStr = stringResource(id = R.string.key_language)
FilterChip(
selected = languageFilter != -1,
onClick = {
scope.launch {
val items = listOf(any, *languages.toTypedArray())
languageFilter = dialogState.showSingleChoice(items, languageFilter + 1) - 1
val languages = remember {
val translatable = EhTagDatabase.initialized && EhTagDatabase.isTranslatable(context)
List(S_LANG_TAGS.size + 1) { i ->
if (i == 0) {
any
} else {
val tag = S_LANG_TAGS[i - 1].substringAfter(':')
if (translatable) {
EhTagDatabase.getTranslation("l", tag) ?: tag
} else {
tag
}
}
}
}
var languageFilter by Settings.languageFilter.asMutableState()
DropdownFilterChip(
label = languageStr,
menuItems = languages,
selectedItemIndex = languageFilter + 1,
onSelectedItemIndexChange = {
languageFilter = it - 1
},
label = {
Text(text = if (languageFilter == -1) languageStr else languages[languageFilter])
},
trailingIcon = {
Icon(
imageVector = if (languageFilter == -1) Icons.Outlined.FilterAlt else Icons.Default.FilterAlt,
contentDescription = null,
)
)
val minRatingItems = stringArrayResource(id = R.array.search_min_rating)
val minRatingStr = stringResource(id = R.string.search_sr)
DropdownFilterChip(
label = minRatingStr,
menuItems = minRatingItems.asList(),
selectedItemIndex = (advancedOption.minRating - 1).coerceAtLeast(0),
onSelectedItemIndexChange = {
onAdvancedOptionChanged(advancedOption.copy(minRating = if (it == 0) 0 else it + 1))
},
)
val pageErr1 = stringResource(R.string.search_sp_err1)
@@ -201,19 +207,6 @@ fun SearchFilter(
},
label = { Text(text = pagesText) },
)
val minRatingItems = stringArrayResource(id = R.array.search_min_rating)
val minRatingStr = stringResource(id = R.string.search_sr)
val minRatingIndex = (advancedOption.minRating - 1).coerceAtLeast(0)
FilterChip(
selected = advancedOption.minRating != 0,
onClick = {
scope.launch {
val selected = dialogState.showSingleChoice(minRatingItems.toList(), minRatingIndex)
onAdvancedOptionChanged(advancedOption.copy(minRating = if (selected == 0) 0 else selected + 1))
}
},
label = { Text(text = minRatingStr + minRatingItems[minRatingIndex]) },
)
fun checked(bit: Int) = advancedOption.advanceSearch and bit != 0
fun AdvancedSearchOption.inv(bit: Int) = onAdvancedOptionChanged(copy(advanceSearch = advanceSearch xor bit))
FilterChip(
@@ -0,0 +1,93 @@
package com.hippo.ehviewer.ui.main
import androidx.compose.foundation.MarqueeSpacing
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
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.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.parser.Torrent
import com.hippo.ehviewer.client.parser.format
@Composable
fun TorrentList(
items: List<Torrent>,
onItemClick: (Torrent) -> Unit,
modifier: Modifier = Modifier,
) {
val title = stringResource(R.string.torrents)
Column(
modifier = modifier.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = title, style = MaterialTheme.typography.headlineSmall)
val labelStyle = MaterialTheme.typography.labelLarge
LazyColumn(
contentPadding = PaddingValues(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items) {
Column(
modifier = Modifier.clickable { onItemClick(it) }.minimumInteractiveComponentSize()
.padding(horizontal = 8.dp),
) {
Text(
text = it.name,
modifier = Modifier.basicMarquee(
spacing = MarqueeSpacing(16.dp),
velocity = 60.dp,
),
color = MaterialTheme.colorScheme.primary,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = it.posted,
modifier = Modifier.weight(2.5f),
color = if (it.outdated) MaterialTheme.colorScheme.error else Color.Unspecified,
style = labelStyle,
)
Text(
text = it.uploader,
modifier = Modifier.weight(2.5f),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = labelStyle,
)
Text(
text = it.size,
modifier = Modifier.weight(2f),
style = labelStyle,
textAlign = TextAlign.End,
)
Text(
text = it.format(),
modifier = Modifier.weight(3f),
style = labelStyle,
textAlign = TextAlign.End,
)
}
}
}
}
}
}
@@ -3,6 +3,7 @@ package com.hippo.ehviewer.ui.reader
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
@@ -39,6 +40,7 @@ fun GalleryPager(
onSelectPage: (ReaderPage) -> Unit,
onMenuRegionClick: () -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
) {
val items = pageLoader.pages
when (type) {
@@ -46,6 +48,7 @@ fun GalleryPager(
HorizontalPager(
state = pagerState,
modifier = modifier,
contentPadding = contentPadding,
reverseLayout = type == RIGHT_TO_LEFT,
key = { it },
) { index ->
@@ -82,6 +85,7 @@ fun GalleryPager(
VerticalPager(
state = pagerState,
modifier = modifier,
contentPadding = contentPadding,
key = { it },
) { index ->
val page = items[index]
@@ -132,6 +136,7 @@ fun GalleryPager(
},
),
state = lazyListState,
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(if (type != WEBTOON) 15.dp else 0.dp),
) {
items(items, key = { it.index }) { page ->
@@ -86,6 +86,10 @@ suspend fun save(page: ReaderPage) {
val cannotSave = getString(R.string.error_cant_save_image)
if (granted) {
val filename = getImageFilename(page.index)
if (filename == null) {
showSnackbar(cannotSave)
return
}
val extension = FileUtils.getExtensionFromFilename(filename)
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "image/jpeg"
val values = ContentValues()
@@ -133,6 +137,10 @@ suspend fun save(page: ReaderPage) {
context(SnackbarHostState, Context, PageLoader2)
suspend fun saveTo(page: ReaderPage) {
val filename = getImageFilename(page.index)
if (filename == null) {
showSnackbar(getString(R.string.error_cant_save_image))
return
}
val extension = FileUtils.getExtensionFromFilename(filename)
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "image/jpeg"
page.runSuspendCatching {
@@ -7,12 +7,15 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.asMutableState
import com.hippo.ehviewer.util.isAtLeastP
@Composable
fun ReaderGeneralSetting() = Column(modifier = Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding()) {
@@ -39,11 +42,15 @@ fun ReaderGeneralSetting() = Column(modifier = Modifier.verticalScroll(rememberS
title = stringResource(id = R.string.pref_fullscreen),
field = fullscreen,
)
AnimatedVisibility(visible = fullscreen.value) {
SwitchChoice(
title = stringResource(id = R.string.pref_cutout_short),
field = Settings.cutoutShort.asMutableState(),
)
val view = LocalView.current
val hasDisplayCutout = remember(view) { isAtLeastP && view.rootWindowInsets.displayCutout != null }
if (hasDisplayCutout) {
AnimatedVisibility(visible = fullscreen.value) {
SwitchChoice(
title = stringResource(id = R.string.pref_cutout_short),
field = Settings.cutoutShort.asMutableState(),
)
}
}
SwitchChoice(
title = stringResource(id = R.string.pref_keep_screen_on),
@@ -70,7 +70,7 @@ private fun PagerSetting() = Column {
title = stringResource(id = R.string.pref_read_with_tapping_inverted),
entries = stringArrayResource(id = R.array.invert_tapping_mode),
values = arrayOf("0", "1", "2", "3"),
field = Settings.readerWebtoonNavInverted.asMutableState(),
field = Settings.readerPagerNavInverted.asMutableState(),
)
SwitchChoice(
title = stringResource(id = R.string.pref_navigate_pan),
@@ -4,10 +4,17 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsIgnoringVisibility
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.BottomSheetDefaults
@@ -24,12 +31,15 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.util.VelocityTrackerAddPointsFix
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowInsetsControllerCompat
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.BaseGalleryInfo
@@ -38,7 +48,6 @@ import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.archiveFile
import com.hippo.ehviewer.gallery.ArchivePageLoader
import com.hippo.ehviewer.gallery.EhPageLoader
import com.hippo.ehviewer.ui.LockDrawer
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.tools.Deferred
import com.ramcosta.composedestinations.annotation.Destination
@@ -57,16 +66,6 @@ import moe.tarsin.kt.unreachable
@Destination<RootGraph>
@Composable
fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsNavigator) = composing(navigator) {
// Workaround for incorrect fling velocity on zoom gesture end
// https://github.com/saket/telephoto/issues/71
DisposableEffect(Unit) {
VelocityTrackerAddPointsFix = false
onDispose {
VelocityTrackerAddPointsFix = true
}
}
LockDrawer(true)
ConfigureKeepScreenOn()
val pageLoader = remember {
val archive = DownloadManager.getDownloadInfo(info.gid)?.archiveFile
@@ -86,6 +85,16 @@ fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsN
val showSeekbar by Settings.showReaderSeekbar.collectAsState()
val readingMode by Settings.readingMode.collectAsState { ReadingModeType.fromPreference(it) }
val volumeKeysEnabled by Settings.readWithVolumeKeys.collectAsState()
val fullscreen by Settings.fullscreen.collectAsState()
val cutoutShort by Settings.cutoutShort.collectAsState()
val uiController = rememberSystemUiController()
DisposableEffect(uiController) {
uiController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
onDispose {
uiController.isSystemBarsVisible = true
uiController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
}
}
Deferred({ pageLoader.awaitReady() }) {
val lazyListState = rememberLazyListState()
val pagerState = rememberPagerState { pageLoader.size }
@@ -94,6 +103,13 @@ fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsN
Box {
var appbarVisible by remember { mutableStateOf(false) }
val bgColor by collectBackgroundColorAsState()
if (fullscreen) {
LaunchedEffect(Unit) {
snapshotFlow { appbarVisible }.collect {
uiController.isSystemBarsVisible = it
}
}
}
VolumeKeysHandler(
enabled = { volumeKeysEnabled && !appbarVisible },
movePrevious = { syncState.sliderScrollTo(syncState.sliderValue - 1) },
@@ -112,8 +128,9 @@ fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsN
val state = rememberModalBottomSheetState()
ModalBottomSheet(
onDismissRequest = { dispose() },
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
sheetState = state,
windowInsets = WindowInsets.statusBarsIgnoringVisibility,
contentWindowInsets = { WindowInsets(0) },
) {
ReaderPageSheetMeta(
retry = { pageLoader.retryPage(page.index) },
@@ -131,6 +148,15 @@ fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsN
},
onMenuRegionClick = { appbarVisible = !appbarVisible },
modifier = Modifier.background(bgColor),
contentPadding = if (fullscreen) {
if (cutoutShort) {
PaddingValues(0.dp)
} else {
WindowInsets.displayCutout.asPaddingValues()
}
} else {
WindowInsets.systemBars.asPaddingValues()
},
)
val brightness by Settings.customBrightness.collectAsState()
val brightnessValue by Settings.customBrightnessValue.collectAsState()
@@ -153,7 +179,7 @@ fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsN
colorBlendMode = colorOverlayMode,
)
val showPageNumber by Settings.showPageNumber.collectAsState()
if (showPageNumber) {
if (showPageNumber && !appbarVisible) {
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) {
PageIndicatorText(
currentPage = syncState.sliderValue,
@@ -180,10 +206,11 @@ fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsN
)
ModalBottomSheet(
onDismissRequest = { dispose() },
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
// Yeah, I know color state should not be read here, but we have to do it...
scrimColor = scrim,
dragHandle = null,
windowInsets = WindowInsets.statusBarsIgnoringVisibility,
contentWindowInsets = { WindowInsets(0) },
) {
SettingsPager(modifier = Modifier.fillMaxSize()) { page ->
isColorFilter = page == 2
@@ -8,7 +8,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
@@ -16,7 +15,7 @@ import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.gallery.PageLoader2
import kotlin.math.absoluteValue
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.filterNotNull
@Stable
class SliderPagerDoubleSync(
@@ -24,8 +23,10 @@ class SliderPagerDoubleSync(
private val pagerState: PagerState,
private val pageLoader: PageLoader2,
) {
private var sliderFollowPager by mutableStateOf(true)
var sliderValue by mutableIntStateOf(1)
private var requestScroll = true
private var sliderFollowPager = true
var sliderValue by mutableIntStateOf(pageLoader.startPage + 1)
private set
fun sliderScrollTo(index: Int) {
sliderFollowPager = false
@@ -37,52 +38,55 @@ class SliderPagerDoubleSync(
val fling by lazyListState.interactionSource.collectIsDraggedAsState()
val pagerFling by pagerState.interactionSource.collectIsDraggedAsState()
if (fling || pagerFling) sliderFollowPager = true
val startPage = remember { pageLoader.startPage }
val currentIndexFlow = remember(webtoon) {
requestScroll = true
if (webtoon) {
snapshotFlow { lazyListState.layoutInfo }.mapNotNull { info ->
info.visibleItemsInfo.lastOrNull {
it.offset <= maxOf(info.viewportStartOffset, info.viewportEndOffset - it.size)
}?.index
}
snapshotFlow {
with(lazyListState.layoutInfo) {
visibleItemsInfo.lastOrNull {
it.offset <= maxOf(viewportStartOffset, viewportEndOffset - it.size)
}?.index
}
}.filterNotNull()
} else {
snapshotFlow { pagerState.currentPage }
}
}
if (sliderFollowPager) {
LaunchedEffect(currentIndexFlow) {
currentIndexFlow.collect { index ->
LaunchedEffect(currentIndexFlow) {
currentIndexFlow.collect { index ->
if (sliderFollowPager && !requestScroll) {
sliderValue = index + 1
pageLoader.startPage = index
}
}
} else {
LaunchedEffect(webtoon) {
snapshotFlow { sliderValue - 1 }.collectLatest { index ->
if (webtoon) {
val noAnim = (index - lazyListState.firstVisibleItemIndex).absoluteValue > SMOOTH_SCROLL_THRESHOLD || !Settings.pageTransitions.value
if (noAnim) {
lazyListState.scrollToItem(index)
} else {
lazyListState.animateScrollToItem(index)
}
}
LaunchedEffect(webtoon) {
snapshotFlow { sliderValue - 1 }.collectLatest { index ->
if (sliderFollowPager && !requestScroll) return@collectLatest
if (webtoon) {
if (requestScroll) {
lazyListState.requestScrollToItem(index)
requestScroll = false
} else {
val noAnim = (index - pagerState.currentPage).absoluteValue > SMOOTH_SCROLL_THRESHOLD || !Settings.pageTransitions.value
if (noAnim) {
pagerState.scrollToPage(index)
if (smoothScroll((index - lazyListState.firstVisibleItemIndex))) {
lazyListState.animateScrollToItem(index)
} else {
lazyListState.scrollToItem(index)
}
}
} else {
if (requestScroll) {
pagerState.requestScrollToPage(index)
requestScroll = false
} else {
if (smoothScroll((index - pagerState.currentPage))) {
pagerState.animateScrollToPage(index)
} else {
pagerState.scrollToPage(index)
}
}
pageLoader.startPage = index
}
}
}
LaunchedEffect(Unit) {
if (webtoon) {
lazyListState.scrollToItem(startPage)
} else {
pagerState.scrollToPage(startPage)
pageLoader.startPage = index
}
}
}
@@ -100,4 +104,7 @@ fun rememberSliderPagerDoubleSyncState(
}
}
private fun smoothScroll(distance: Int) =
distance.absoluteValue < SMOOTH_SCROLL_THRESHOLD && Settings.pageTransitions.value
private const val SMOOTH_SCROLL_THRESHOLD = 50
@@ -29,6 +29,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
@Composable
fun SpinnerChoice(title: String, entries: Array<String>, values: Array<String>, field: MutableState<Int>) {
@@ -97,9 +98,7 @@ fun SliderChoice(
) {
startSlot()
val configuration = LocalConfiguration.current
// https://m3.material.io/components/sliders/specs
// Tick width is 2 dp
val maxTickCount = configuration.screenWidthDp / 6
val maxTickCount = configuration.screenWidthDp / (SliderDefaults.TickSize.value * 2.5f).roundToInt()
val steps = range.last - range.first - 1
val showTicks = steps < maxTickCount
Slider(
@@ -0,0 +1,5 @@
package com.hippo.ehviewer.ui.screen
// https://github.com/Kotlin/KEEP/blob/context-parameters/proposals/context-parameters.md#standard-library-support
context(T)
inline fun <reified T> implicit(): T = this@T
@@ -2,18 +2,20 @@ package com.hippo.ehviewer.ui.screen
import android.os.Parcelable
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.download.DownloadsFilterMode
import com.hippo.ehviewer.util.containsIgnoreCase
import kotlinx.parcelize.Parcelize
@Parcelize
data class DownloadsFilterState(
val mode: DownloadsFilterMode,
val label: String?,
val state: Int = -1,
val keyword: String = "",
) : Parcelable
fun DownloadsFilterState.take(info: DownloadInfo) =
(label == "" || info.label == label) && (state == -1 || info.state == state) &&
mode.take(info, label) && (state == -1 || info.state == state) &&
with(info) {
title.containsIgnoreCase(keyword) || titleJpn.containsIgnoreCase(keyword) ||
simpleTags?.any { it.containsIgnoreCase(keyword) } ?: false
@@ -1,5 +1,7 @@
package com.hippo.ehviewer.ui.screen
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.view.ViewConfiguration
import androidx.compose.animation.Crossfade
@@ -12,8 +14,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
@@ -31,6 +31,7 @@ import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.automirrored.filled.ViewList
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.GridView
@@ -38,6 +39,7 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.NewLabel
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Reorder
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material3.DropdownMenu
@@ -47,13 +49,15 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBoxDefaults
import androidx.compose.material3.SwipeToDismissBoxState
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.fork.SwipeToDismissBox
import androidx.compose.material3.fork.SwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
@@ -70,10 +74,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
@@ -84,35 +85,39 @@ import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.asMutableState
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.DownloadManager.labelList
import com.hippo.ehviewer.download.DownloadManager.downloadInfoList
import com.hippo.ehviewer.download.DownloadService
import com.hippo.ehviewer.download.DownloadsFilterMode
import com.hippo.ehviewer.download.SortMode
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.big.Download
import com.hippo.ehviewer.ui.DrawerHandle
import com.hippo.ehviewer.ui.LocalSideSheetState
import com.hippo.ehviewer.ui.LockDrawer
import com.hippo.ehviewer.ui.MainActivity
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.confirmRemoveDownloadRange
import com.hippo.ehviewer.ui.main.DownloadCard
import com.hippo.ehviewer.ui.main.FAB_ANIMATE_TIME
import com.hippo.ehviewer.ui.main.FabLayout
import com.hippo.ehviewer.ui.main.GalleryInfoGridItem
import com.hippo.ehviewer.ui.main.plus
import com.hippo.ehviewer.ui.navToReader
import com.hippo.ehviewer.ui.showMoveDownloadLabelList
import com.hippo.ehviewer.ui.tools.Deferred
import com.hippo.ehviewer.ui.tools.DragHandle
import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalStaggeredGrid
import com.hippo.ehviewer.ui.tools.SwipeToDismissBox2
import com.hippo.ehviewer.ui.tools.HapticFeedbackType
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
import com.hippo.ehviewer.ui.tools.draggingHapticFeedback
import com.hippo.ehviewer.ui.tools.rememberHapticFeedback
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.util.findActivity
import com.hippo.ehviewer.ui.tools.thenIf
import com.hippo.ehviewer.util.mapToLongArray
import com.hippo.ehviewer.util.takeAndClear
import com.jamal.composeprefs3.ui.ifTrueThen
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@@ -120,44 +125,57 @@ import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import kotlin.math.roundToInt
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.onEachLatest
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyColumnState
import sh.calvin.reorderable.rememberReorderableLazyListState
@Destination<RootGraph>
@Composable
fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
var gridView by Settings.gridView.asMutableState()
var sortMode by Settings.downloadSortMode.asMutableState()
var filterState by rememberSaveable { mutableStateOf(DownloadsFilterState(Settings.recentDownloadLabel.value)) }
val filterMode by Settings.downloadFilterMode.collectAsState { DownloadsFilterMode.from(it) }
var filterState by rememberSaveable { mutableStateOf(DownloadsFilterState(filterMode, Settings.recentDownloadLabel.value)) }
var invalidateKey by rememberSaveable { mutableStateOf(false) }
var searchBarOffsetY by remember(filterState.label) { mutableIntStateOf(0) }
var searchBarExpanded by rememberSaveable { mutableStateOf(false) }
var searchBarOffsetY by remember { mutableIntStateOf(0) }
val animateItems by Settings.animateItems.collectAsState()
var fabExpanded by remember { mutableStateOf(false) }
var fabHidden by remember { mutableStateOf(false) }
val checkedInfoMap = remember { mutableStateMapOf<Long, DownloadInfo>() }
val selectMode by rememberUpdatedState(checkedInfoMap.isNotEmpty())
LockDrawer(selectMode)
DrawerHandle(!selectMode && !searchBarExpanded)
val context = LocalContext.current
val activity = remember { findActivity<MainActivity>() }
val density = LocalDensity.current
val view = LocalView.current
val canTranslate = Settings.showTagTranslations && EhTagDatabase.isTranslatable(implicit<Context>()) && EhTagDatabase.initialized
val ehTags = EhTagDatabase.takeIf { canTranslate }
fun String.translateArtist() = ehTags?.getTranslation(TagNamespace.Artist.toPrefix(), this) ?: this
val positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold
val allName = stringResource(R.string.download_all)
val defaultName = stringResource(R.string.default_download_label_name)
val unknownName = stringResource(R.string.unknown_artists)
val emptyLabelName = when (filterMode) {
DownloadsFilterMode.ARTIST -> unknownName
DownloadsFilterMode.CUSTOM -> defaultName
}
val title = stringResource(
R.string.scene_download_title,
with(filterState) { if (label == "") allName else label ?: defaultName },
with(filterState) {
when (label) {
"" -> allName
null -> emptyLabelName
else -> if (mode == DownloadsFilterMode.ARTIST) label.translateArtist() else label
}
},
)
val hint = stringResource(R.string.search_bar_hint, title)
val sortModes = stringArrayResource(id = R.array.download_sort_modes)
val downloadStates = stringArrayResource(id = R.array.download_state)
val list = remember(filterState, invalidateKey) {
DownloadManager.downloadInfoList.filterTo(mutableStateListOf()) { info ->
downloadInfoList.filterTo(mutableStateListOf()) { info ->
filterState.take(info)
}
}
@@ -168,21 +186,42 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
val labelEmpty = stringResource(R.string.label_text_is_empty)
val defaultInvalid = stringResource(R.string.label_text_is_invalid)
val labelExists = stringResource(R.string.label_text_exist)
val downloadsCount by rememberInVM { EhDB.downloadsCount }.collectAsState(emptyMap())
val totalCount = remember(downloadsCount) { downloadsCount.values.sum() }
val downloadsCountGroupByArtist by rememberInVM { EhDB.downloadsCountByArtist }.collectAsState(emptyMap())
val downloadsCountGroupByLabel by rememberInVM { EhDB.downloadsCountByLabel }.collectAsState(emptyMap())
val downloadsCount = when (filterMode) {
DownloadsFilterMode.CUSTOM -> downloadsCountGroupByLabel
DownloadsFilterMode.ARTIST -> downloadsCountGroupByArtist
}
val artistList = remember(downloadsCountGroupByArtist) {
downloadsCountGroupByArtist.keys.mapNotNull { artist -> artist?.let { it to it } }
}
val labelList by remember {
derivedStateOf {
DownloadManager.labelList.map { it.id!! to it.label }
}
}
val groupList = when (filterMode) {
DownloadsFilterMode.CUSTOM -> labelList
DownloadsFilterMode.ARTIST -> artistList
}
val totalCount = remember(downloadsCountGroupByLabel) { downloadsCountGroupByLabel.values.sum() }
fun switchLabel(label: String?) {
Settings.recentDownloadLabel.value = label
filterState = filterState.copy(label = label)
}
with(activity) {
ProvideSideSheetContent { drawerState ->
fun closeSheet() = launch { drawerState.close() }
TopAppBar(
title = { Text(text = labelsStr) },
windowInsets = WindowInsets(0, 0, 0, 0),
actions = {
LaunchedEffect(filterState) {
searchBarOffsetY = 0
}
ProvideSideSheetContent { drawerState ->
fun closeSheet() = launch { drawerState.close() }
TopAppBar(
title = { Text(text = labelsStr) },
windowInsets = WindowInsets(0, 0, 0, 0),
actions = {
if (DownloadsFilterMode.CUSTOM == filterMode) {
IconButton(
onClick = {
launch {
@@ -204,7 +243,12 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
IconButton(
onClick = {
launch {
showSelectActions(R.string.default_download_label) {
val selected = if (!Settings.hasDefaultDownloadLabel) {
0
} else {
DownloadManager.labelList.indexOfFirst { it.label == Settings.defaultDownloadLabel } + 2
}
awaitSelectAction(R.string.default_download_label, selected) {
onSelect(letMeSelect) {
Settings.hasDefaultDownloadLabel = false
}
@@ -212,147 +256,181 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
Settings.hasDefaultDownloadLabel = true
Settings.defaultDownloadLabel = null
}
labelList.forEach { (label) ->
DownloadManager.labelList.forEach { (label) ->
onSelect(label) {
Settings.hasDefaultDownloadLabel = true
Settings.defaultDownloadLabel = label
}
}
}
}()
}
},
) {
Icon(imageVector = Icons.Default.Settings, contentDescription = null)
Icon(imageVector = Icons.Default.Download, contentDescription = null)
}
},
)
val labelsListState = rememberLazyListState()
val reorderableLabelState = rememberReorderableLazyColumnState(labelsListState) { from, to ->
val fromPosition = from.index - 2
val toPosition = to.index - 2
labelList.apply { add(toPosition, removeAt(fromPosition)) }
view.performHapticFeedback(draggingHapticFeedback)
}
var fromIndex by remember { mutableIntStateOf(-1) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = labelsListState,
contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(),
) {
stickyHeader {
ListItem(
modifier = Modifier.clickable {
switchLabel("")
closeSheet()
},
tonalElevation = 1.dp,
shadowElevation = 1.dp,
headlineContent = {
Text("$allName [$totalCount]")
},
)
}
stickyHeader {
ListItem(
modifier = Modifier.clickable {
switchLabel(null)
closeSheet()
},
tonalElevation = 1.dp,
shadowElevation = 1.dp,
headlineContent = {
Text("$defaultName [${downloadsCount.getOrDefault(null, 0)}]")
},
)
}
itemsIndexed(labelList, key = { _, item -> item.id!! }) { index, (item, _, id) ->
// Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it
val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density, positionalThreshold = positionalThreshold) }
LaunchedEffect(dismissState) {
snapshotFlow { dismissState.currentValue }.collect {
if (it == SwipeToDismissBoxValue.EndToStart) {
runCatching {
awaitPermissionOrCancel(confirmText = R.string.delete) {
Text(text = stringResource(R.string.delete_label, item))
}
}.onSuccess {
DownloadManager.deleteLabel(item)
when (filterState.label) {
item -> switchLabel("")
null -> invalidateKey = !invalidateKey
}
}.onFailure {
dismissState.reset()
val custom = stringResource(R.string.select_grouping_mode_custom)
val artist = stringResource(R.string.select_grouping_mode_artist)
IconButton(
onClick = {
launch {
awaitSelectAction(R.string.select_grouping_mode) {
val select = { mode: DownloadsFilterMode ->
filterState = filterState.copy(mode = mode, label = "")
Settings.downloadFilterMode.value = mode.flag
Settings.recentDownloadLabel.value = ""
}
onSelect(custom) { select(DownloadsFilterMode.CUSTOM) }
onSelect(artist) { select(DownloadsFilterMode.ARTIST) }
}()
}
},
) {
Icon(imageVector = Icons.Default.Settings, contentDescription = null)
}
},
)
val labelsListState = rememberLazyListState()
val editEnable = DownloadsFilterMode.CUSTOM == filterMode
val hapticFeedback = rememberHapticFeedback()
val reorderableLabelState = rememberReorderableLazyListState(labelsListState) { from, to ->
val fromPosition = from.index - 2
val toPosition = to.index - 2
DownloadManager.labelList.apply { add(toPosition, removeAt(fromPosition)) }
hapticFeedback.performHapticFeedback(HapticFeedbackType.MOVE)
}
var fromIndex by remember { mutableIntStateOf(-1) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = labelsListState,
contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(),
) {
item {
ListItem(
modifier = Modifier.clickable {
switchLabel("")
closeSheet()
},
tonalElevation = 1.dp,
shadowElevation = 1.dp,
headlineContent = {
Text("$allName [$totalCount]")
},
)
}
item {
ListItem(
modifier = Modifier.clickable {
switchLabel(null)
closeSheet()
},
tonalElevation = 1.dp,
shadowElevation = 1.dp,
headlineContent = {
Text("$emptyLabelName [${downloadsCount.getOrDefault(null, 0)}]")
},
)
}
itemsIndexed(groupList, key = { _, (id) -> id }) { itemIndex, (id, label) ->
val index by rememberUpdatedState(itemIndex)
val item by rememberUpdatedState(label)
// Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it
val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density, positionalThreshold = positionalThreshold) }
LaunchedEffect(dismissState) {
snapshotFlow { dismissState.currentValue }.collect {
if (it == SwipeToDismissBoxValue.EndToStart) {
runCatching {
awaitPermissionOrCancel(confirmText = R.string.delete) {
Text(text = stringResource(R.string.delete_label, item))
}
}.onSuccess {
DownloadManager.deleteLabel(item)
when (filterState.label) {
item -> switchLabel("")
null -> invalidateKey = !invalidateKey
}
}.onFailure {
dismissState.reset()
}
}
}
ReorderableItem(reorderableLabelState, key = id!!) { isDragging ->
SwipeToDismissBox2(
state = dismissState,
backgroundContent = {},
) {
val elevation by animateDpAsState(
if (isDragging) {
8.dp // md.sys.elevation.level4
} else {
1.dp // md.sys.elevation.level1
},
label = "elevation",
)
ListItem(
modifier = Modifier.clickable {
switchLabel(item)
closeSheet()
},
tonalElevation = 1.dp,
shadowElevation = elevation,
headlineContent = {
Text("$item [${downloadsCount.getOrDefault(item, 0)}]")
},
trailingContent = {
Row {
IconButton(
onClick = {
launch {
val new = awaitInputText(initial = item, title = renameLabel, hint = labelsStr) { text ->
when {
text.isBlank() -> labelEmpty
text == defaultName -> defaultInvalid
DownloadManager.containLabel(text) -> labelExists
else -> null
}
}
DownloadManager.renameLabel(item, new)
if (filterState.label == item) {
switchLabel(new)
}
ReorderableItem(reorderableLabelState, enabled = editEnable, key = id) { isDragging ->
SwipeToDismissBox(
state = dismissState,
backgroundContent = {},
enableDismissFromStartToEnd = false,
gesturesEnabled = editEnable,
) {
val elevation by animateDpAsState(
if (isDragging) {
8.dp // md.sys.elevation.level4
} else {
1.dp // md.sys.elevation.level1
},
label = "elevation",
)
ListItem(
modifier = Modifier.clickable {
switchLabel(item)
closeSheet()
},
tonalElevation = 1.dp,
shadowElevation = elevation,
headlineContent = {
val name = if (filterMode == DownloadsFilterMode.ARTIST) label.translateArtist() else label
Text("$name [${downloadsCount.getOrDefault(item, 0)}]")
},
trailingContent = editEnable.ifTrueThen {
Row {
IconButton(
onClick = {
launch {
val new = awaitInputText(initial = item, title = renameLabel, hint = labelsStr) { text ->
when {
text.isBlank() -> labelEmpty
text == defaultName -> defaultInvalid
DownloadManager.containLabel(text) -> labelExists
else -> null
}
}
},
) {
Icon(imageVector = Icons.Default.Edit, contentDescription = null)
}
DragHandle(
DownloadManager.renameLabel(item, new)
if (filterState.label == item) {
switchLabel(new)
}
}
},
) {
Icon(imageVector = Icons.Default.Edit, contentDescription = null)
}
IconButton(
onClick = {},
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.START)
fromIndex = index
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.END)
if (fromIndex != -1) {
if (fromIndex != index) {
val range = if (fromIndex < index) fromIndex..index else index..fromIndex
val toUpdate = labelList.slice(range)
val toUpdate = DownloadManager.labelList.slice(range)
toUpdate.zip(range).forEach { it.first.position = it.second }
launchIO { EhDB.updateDownloadLabel(toUpdate) }
}
fromIndex = -1
}
},
)
),
) {
Icon(imageVector = Icons.Default.Reorder, contentDescription = null)
}
},
)
}
}
},
)
}
}
}
@@ -360,14 +438,15 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
}
SearchBarScreen(
onApplySearch = { filterState = filterState.copy(keyword = it) },
expanded = searchBarExpanded,
onExpandedChange = {
searchBarExpanded = it
fabHidden = it
if (it) checkedInfoMap.clear()
},
title = title,
searchFieldHint = hint,
onApplySearch = { filterState = filterState.copy(keyword = it) },
onSearchExpanded = {
checkedInfoMap.clear()
fabHidden = true
},
onSearchHidden = { fabHidden = false },
searchBarOffsetY = { searchBarOffsetY },
trailingIcon = {
var expanded by remember { mutableStateOf(false) }
@@ -391,9 +470,9 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
text = { Text(text = stringResource(id = R.string.download_start_all)) },
onClick = {
expanded = false
val intent = Intent(activity, DownloadService::class.java)
val intent = Intent(implicit<Activity>(), DownloadService::class.java)
intent.action = DownloadService.ACTION_START_ALL
ContextCompat.startForegroundService(activity, intent)
ContextCompat.startForegroundService(implicit<Activity>(), intent)
},
)
DropdownMenuItem(
@@ -425,27 +504,19 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
onClick = {
expanded = false
val gidList = list.filter { it.state != DownloadInfo.STATE_FINISH }.asReversed().mapToLongArray(DownloadInfo::gid)
val intent = Intent(activity, DownloadService::class.java)
val intent = Intent(implicit<Activity>(), DownloadService::class.java)
intent.action = DownloadService.ACTION_START_RANGE
intent.putExtra(DownloadService.KEY_GID_LIST, gidList)
ContextCompat.startForegroundService(activity, intent)
ContextCompat.startForegroundService(implicit<Activity>(), intent)
},
)
}
},
) { contentPadding ->
val height by collectListThumbSizeAsState()
val layoutDirection = LocalLayoutDirection.current
val marginH = dimensionResource(id = R.dimen.gallery_list_margin_h)
val marginV = dimensionResource(id = R.dimen.gallery_list_margin_v)
val realPadding = PaddingValues(
top = contentPadding.calculateTopPadding() + marginV,
bottom = contentPadding.calculateBottomPadding() + marginV,
start = contentPadding.calculateStartPadding(layoutDirection) + marginH,
end = contentPadding.calculateEndPadding(layoutDirection) + marginH,
)
val realPadding = contentPadding + PaddingValues(dimensionResource(id = R.dimen.gallery_list_margin_h), dimensionResource(id = R.dimen.gallery_list_margin_v))
val searchBarConnection = remember {
val slop = ViewConfiguration.get(context).scaledTouchSlop
val slop = ViewConfiguration.get(implicit<Context>()).scaledTouchSlop
val topPaddingPx = with(density) { contentPadding.calculateTopPadding().roundToPx() }
object : NestedScrollConnection {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
@@ -465,6 +536,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
launchIO { EhDB.putHistoryInfo(info.galleryInfo) }
navToReader(info.galleryInfo)
}
Crossfade(targetState = gridView, label = "Downloads") { showGridView ->
if (showGridView) {
val gridInterval = dimensionResource(R.dimen.gallery_grid_interval)
@@ -476,13 +548,13 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
horizontalArrangement = Arrangement.spacedBy(gridInterval),
contentPadding = realPadding,
) {
items(list) { info ->
items(list, key = { it.gid }) { info ->
GalleryInfoGridItem(
onClick = ::onItemClick.partially1(info),
onLongClick = { navigator.navigate(info.galleryInfo.asDst()) },
onLongClick = { navigate(info.galleryInfo.asDst()) },
info = info,
modifier = Modifier.animateItemPlacement(),
badgeText = info.pages.takeIf { it > 0 }?.toString(),
modifier = Modifier.thenIf(animateItems) { animateItem() },
showLanguage = false,
)
}
}
@@ -494,7 +566,10 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
) {
items(list, key = { it.gid }) { info ->
val checked = info.gid in checkedInfoMap
CheckableItem(checked = checked, modifier = Modifier.animateItem()) { interactionSource ->
CheckableItem(
checked = checked,
modifier = Modifier.thenIf(animateItems) { animateItem() },
) { interactionSource ->
DownloadCard(
onClick = {
if (selectMode) {
@@ -508,19 +583,20 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
}
},
onThumbClick = {
navigator.navigate(info.galleryInfo.asDst())
navigate(info.galleryInfo.asDst())
},
onLongClick = {
checkedInfoMap[info.gid] = info
},
onStart = {
val intent = Intent(activity, DownloadService::class.java)
val intent = Intent(implicit<Activity>(), DownloadService::class.java)
intent.action = DownloadService.ACTION_START
intent.putExtra(DownloadService.KEY_GALLERY_INFO, info.galleryInfo)
ContextCompat.startForegroundService(activity, intent)
ContextCompat.startForegroundService(implicit<Activity>(), intent)
},
onStop = { launchIO { DownloadManager.stopDownload(info.gid) } },
info = info,
selectMode = selectMode,
modifier = Modifier.height(height),
interactionSource = interactionSource,
)
@@ -577,7 +653,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
}
onClick(Icons.AutoMirrored.Default.Sort) {
val oldMode = SortMode.from(sortMode)
val (selected, checked) = showSelectItemWithCheckBox(
val (selected, checked) = awaitSelectItemWithCheckBox(
sortModes.toList(),
R.string.sort_by,
R.string.group_by_download_label,
@@ -590,7 +666,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
invalidateKey = !invalidateKey
}
onClick(Icons.Default.FilterList) {
val state = showSingleChoice(
val state = awaitSingleChoice(
downloadStates.toList(),
filterState.state + 1,
R.string.download_filter,
@@ -598,31 +674,28 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
filterState = filterState.copy(state = state)
}
} else {
onClick(Icons.Default.DoneAll) {
onClick(Icons.Default.DoneAll, autoClose = false) {
val info = list.associateBy { it.gid }
checkedInfoMap.putAll(info)
throw CancellationException()
}
onClick(Icons.Default.PlayArrow) {
val gidList = checkedInfoMap.run { toMap().values.also { clear() } }
.mapToLongArray(DownloadInfo::gid)
val intent = Intent(activity, DownloadService::class.java)
val gidList = checkedInfoMap.takeAndClear().mapToLongArray(DownloadInfo::gid)
val intent = Intent(implicit<Activity>(), DownloadService::class.java)
intent.action = DownloadService.ACTION_START_RANGE
intent.putExtra(DownloadService.KEY_GID_LIST, gidList)
ContextCompat.startForegroundService(context, intent)
ContextCompat.startForegroundService(implicit<Context>(), intent)
}
onClick(Icons.Default.Pause) {
val gidList = checkedInfoMap.run { toMap().values.also { clear() } }
.mapToLongArray(DownloadInfo::gid)
val gidList = checkedInfoMap.takeAndClear().mapToLongArray(DownloadInfo::gid)
DownloadManager.stopRangeDownload(gidList)
}
onClick(Icons.Default.Delete) {
val infoList = checkedInfoMap.run { toMap().values.also { clear() } }
val infoList = checkedInfoMap.takeAndClear()
confirmRemoveDownloadRange(infoList)
list.removeAll(infoList)
}
onClick(Icons.AutoMirrored.Default.DriveFileMove) {
val infoList = checkedInfoMap.run { toMap().values.also { clear() } }
val infoList = checkedInfoMap.takeAndClear()
val toLabel = showMoveDownloadLabelList(infoList)
with(filterState) {
if (label != "" && label != toLabel) {
@@ -633,9 +706,3 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
}
}
}
object DownloadsFragment {
const val KEY_GID = "gid"
const val KEY_ACTION = "action"
const val ACTION_CLEAR_DOWNLOAD_SERVICE = "clear_download_service"
}
@@ -1,6 +1,8 @@
package com.hippo.ehviewer.ui.screen
import android.content.Context
import android.view.ViewConfiguration
import androidx.collection.MutableLongSet
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
@@ -26,7 +28,6 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -43,53 +44,53 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewModelScope
import androidx.paging.LoadState
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.PagingState
import androidx.paging.cachedIn
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.filter
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.FavListUrlBuilder
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.filled.GoTo
import com.hippo.ehviewer.ui.DrawerHandle
import com.hippo.ehviewer.ui.LocalSideSheetState
import com.hippo.ehviewer.ui.LockDrawer
import com.hippo.ehviewer.ui.awaitSelectDate
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.main.FAB_ANIMATE_TIME
import com.hippo.ehviewer.ui.main.FabLayout
import com.hippo.ehviewer.ui.main.GalleryInfoGridItem
import com.hippo.ehviewer.ui.main.GalleryInfoListItem
import com.hippo.ehviewer.ui.main.GalleryList
import com.hippo.ehviewer.ui.showDatePicker
import com.hippo.ehviewer.ui.startDownload
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
import com.hippo.ehviewer.ui.tools.foldToLoadResult
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.util.displayString
import com.hippo.ehviewer.ui.tools.thenIf
import com.hippo.ehviewer.util.mapToLongArray
import com.hippo.ehviewer.util.takeAndClear
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import kotlin.math.roundToInt
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import moe.tarsin.coroutines.onEachLatest
import moe.tarsin.coroutines.runSuspendCatching
import moe.tarsin.coroutines.runSwallowingWithUI
@Destination<RootGraph>
@Composable
@@ -97,9 +98,12 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
// Immutables
val localFavName = stringResource(R.string.local_favorites)
val cloudFavName = stringResource(R.string.cloud_favorites)
val animateItems by Settings.animateItems.collectAsState()
val hasSignedIn by Settings.hasSignedIn.collectAsState()
// Meta State
var urlBuilder by rememberSaveable { mutableStateOf(FavListUrlBuilder(favCat = Settings.recentFavCat)) }
var searchBarExpanded by rememberSaveable { mutableStateOf(false) }
var searchBarOffsetY by remember { mutableIntStateOf(0) }
// Derived State
@@ -108,7 +112,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
val favCatName = remember(urlBuilder) {
when (val favCat = urlBuilder.favCat) {
in 0..9 -> Settings.favCat[favCat]
FavListUrlBuilder.FAV_CAT_LOCAL -> localFavName
FavListUrlBuilder.FAV_CAT_LOCAL -> localFavName.also { searchBarOffsetY = 0 }
else -> cloudFavName
}
}
@@ -117,7 +121,6 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
} else {
stringResource(R.string.favorites_title_2, favCatName, keyword)
}
val context = LocalContext.current
val density = LocalDensity.current
val localFavCountFlow = rememberInVM { EhDB.localFavCount }
val searchBarHint = stringResource(R.string.search_bar_hint, favCatName)
@@ -162,7 +165,14 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
}
}
}
}.flow.cachedIn(viewModelScope)
}.flow.map { pagingData ->
// https://github.com/FooIbar/EhViewer/issues/1190
// Workaround for duplicate items when sorting by favorited time
val gidSet = MutableLongSet(50)
pagingData.filter {
gidSet.add(it.gid)
}
}.cachedIn(viewModelScope)
}.collectAsLazyPagingItems()
fun refresh(newUrlBuilder: FavListUrlBuilder = urlBuilder.copy(jumpTo = null, prev = null, next = null)) {
@@ -183,7 +193,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
}
}
val localFav = stringResource(id = R.string.local_favorites) to localFavCount
val faves = if (EhCookieStore.hasSignedIn()) {
val faves = if (hasSignedIn) {
arrayOf(
localFav,
stringResource(id = R.string.cloud_favorites) to Settings.favCloudCount,
@@ -211,26 +221,22 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
}
}
val refreshState = rememberPullToRefreshState {
data.loadState.refresh is LoadState.NotLoading
}
var fabExpanded by remember { mutableStateOf(false) }
var fabHidden by remember { mutableStateOf(false) }
val checkedInfoMap = remember { mutableStateMapOf<Long, BaseGalleryInfo>() }
val selectMode = checkedInfoMap.isNotEmpty()
LockDrawer(selectMode)
DrawerHandle(!selectMode && !searchBarExpanded)
SearchBarScreen(
onApplySearch = { refresh(FavListUrlBuilder(urlBuilder.favCat, it)) },
expanded = searchBarExpanded,
onExpandedChange = {
searchBarExpanded = it
fabHidden = it
if (it) checkedInfoMap.clear()
},
title = title,
searchFieldHint = searchBarHint,
onApplySearch = { refresh(FavListUrlBuilder(urlBuilder.favCat, it)) },
onSearchExpanded = {
checkedInfoMap.clear()
fabHidden = true
},
onSearchHidden = { fabHidden = false },
refreshState = refreshState,
tagNamespace = !isLocalFav,
searchBarOffsetY = { searchBarOffsetY },
trailingIcon = {
@@ -244,7 +250,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
val height by collectListThumbSizeAsState()
val showPages by Settings.showGalleryPages.collectAsState()
val searchBarConnection = remember {
val slop = ViewConfiguration.get(context).scaledTouchSlop
val slop = ViewConfiguration.get(implicit<Context>()).scaledTouchSlop
val topPaddingPx = with(density) { contentPadding.calculateTopPadding().roundToPx() }
object : NestedScrollConnection {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
@@ -266,7 +272,10 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
listMode = listMode,
detailItemContent = { info ->
val checked = info.gid in checkedInfoMap
CheckableItem(checked = checked, modifier = Modifier.animateItemPlacement()) { interactionSource ->
CheckableItem(
checked = checked,
modifier = Modifier.thenIf(animateItems) { animateItem() },
) { interactionSource ->
GalleryInfoListItem(
onClick = {
if (selectMode) {
@@ -276,7 +285,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
checkedInfoMap[info.gid] = info
}
} else {
navigator.navigate(info.asDst())
navigate(info.asDst())
}
},
onLongClick = {
@@ -292,7 +301,10 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
},
thumbItemContent = { info ->
val checked = info.gid in checkedInfoMap
CheckableItem(checked = checked, modifier = Modifier.animateItemPlacement()) { interactionSource ->
CheckableItem(
checked = checked,
modifier = Modifier.thenIf(animateItems) { animateItem() },
) { interactionSource ->
GalleryInfoGridItem(
onClick = {
if (selectMode) {
@@ -302,18 +314,19 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
checkedInfoMap[info.gid] = info
}
} else {
navigator.navigate(info.asDst())
navigate(info.asDst())
}
},
onLongClick = {
checkedInfoMap[info.gid] = info
},
info = info,
showPages = showPages,
interactionSource = interactionSource,
)
}
},
refreshState = refreshState,
searchBarOffsetY = { searchBarOffsetY },
scrollToTopOnRefresh = urlBuilder.favCat != FavListUrlBuilder.FAV_CAT_LOCAL,
onRefresh = { refresh() },
onLoading = { searchBarOffsetY = 0 },
@@ -343,16 +356,12 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
if (isLocalFav) {
onClick(Icons.Default.Shuffle) {
val random = EhDB.randomLocalFav()
withUIContext {
navigator.navigate(random.asDst())
}
withUIContext { navigate(random.asDst()) }
}
}
onClick(EhIcons.Default.GoTo) {
launch {
val date = showDatePicker()
refresh(urlBuilder.copy(jumpTo = date))
}
val date = awaitSelectDate()
refresh(urlBuilder.copy(jumpTo = date))
}
onClick(Icons.Default.Refresh) {
refresh()
@@ -361,43 +370,47 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
refresh(urlBuilder.copy(jumpTo = null, prev = "1-0", next = null))
}
} else {
onClick(Icons.Default.DoneAll) {
onClick(Icons.Default.DoneAll, autoClose = false) {
val info = data.itemSnapshotList.items.associateBy { it.gid }
checkedInfoMap.putAll(info)
throw CancellationException()
}
onClick(Icons.Default.Download) {
val info = checkedInfoMap.run { toMap().values.also { clear() } }
startDownload(context, false, *info.toTypedArray())
val info = checkedInfoMap.takeAndClear()
runSwallowingWithUI {
startDownload(implicit<Context>(), false, *info.toTypedArray())
}
}
onClick(Icons.Default.Delete) {
val info = checkedInfoMap.run { toMap().values.also { clear() } }
val info = checkedInfoMap.takeAndClear()
awaitPermissionOrCancel(title = R.string.delete_favorites_dialog_title) {
Text(text = stringResource(R.string.delete_favorites_dialog_message, info.size))
}
val srcCat = urlBuilder.favCat
if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) { // Delete local fav
EhDB.removeLocalFavorites(info)
} else {
val delList = info.mapToLongArray(BaseGalleryInfo::gid)
EhEngine.modifyFavorites(delList, srcCat, -1)
runSwallowingWithUI {
if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) { // Delete local fav
EhDB.removeLocalFavorites(info)
} else {
val delList = info.mapToLongArray(BaseGalleryInfo::gid)
EhEngine.modifyFavorites(delList, srcCat, -1)
}
}
// We refresh anyway as cloud data maybe partially modified
data.refresh()
}
onClick(Icons.AutoMirrored.Default.DriveFileMove) {
// First is local favorite, the other 10 is cloud favorite
val items = buildList {
add(localFavName)
if (EhCookieStore.hasSignedIn()) {
if (hasSignedIn) {
addAll(Settings.favCat)
}
}
val index = showSelectItem(items, R.string.move_favorites_dialog_title)
val index = awaitSelectItem(items, R.string.move_favorites_dialog_title)
val srcCat = urlBuilder.favCat
val dstCat = if (index == 0) FavListUrlBuilder.FAV_CAT_LOCAL else index - 1
val info = checkedInfoMap.run { toMap().values.also { clear() } }
val info = checkedInfoMap.takeAndClear()
if (srcCat != dstCat) {
runSuspendCatching {
runSwallowingWithUI {
if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
// Move from local to cloud
val galleryList = info.map { it.gid to it.token }
@@ -411,11 +424,9 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
val gidArray = info.mapToLongArray(BaseGalleryInfo::gid)
EhEngine.modifyFavorites(gidArray, srcCat, dstCat)
}
}.onSuccess {
data.refresh()
}.onFailure {
showSnackbar(it.displayString())
}
// We refresh anyway as cloud data maybe partially modified
data.refresh()
}
}
}
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -41,8 +40,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -64,8 +62,9 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.parseAsHtml
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
@@ -74,24 +73,26 @@ import androidx.core.text.inSpans
import androidx.core.text.parseAsHtml
import com.hippo.ehviewer.EhApplication
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhFilter.remember
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.data.GalleryComment
import com.hippo.ehviewer.client.data.ListUrlBuilder
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.dao.Filter
import com.hippo.ehviewer.dao.FilterMode
import com.hippo.ehviewer.ui.LockDrawer
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.jumpToReaderByPage
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
import com.hippo.ehviewer.ui.main.GalleryCommentCard
import com.hippo.ehviewer.ui.main.plus
import com.hippo.ehviewer.ui.openBrowser
import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState
import com.hippo.ehviewer.ui.tools.normalizeSpan
import com.hippo.ehviewer.ui.tools.rememberBBCodeTextToolbar
import com.hippo.ehviewer.ui.tools.snackBarPadding
import com.hippo.ehviewer.ui.tools.thenIf
import com.hippo.ehviewer.ui.tools.toBBCode
import com.hippo.ehviewer.ui.tools.updateSpan
import com.hippo.ehviewer.util.ReadableTime
@@ -102,7 +103,6 @@ import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import kotlin.math.roundToInt
@@ -148,10 +148,10 @@ private val MinimumContentPaddingEditText = 88.dp
@Destination<RootGraph>
@Composable
fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composing(navigator) {
LockDrawer(true)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
var commenting by rememberSaveable { mutableStateOf(false) }
val animationProgress by animateFloatMergePredictiveBackAsState(enable = commenting) { commenting = false }
val animateItems by Settings.animateItems.collectAsState()
val galleryDetail = remember { EhApplication.galleryDetailCache[gid]!! }
val userCommentBackField = remember { mutableStateOf(TextFieldValue()) }
@@ -241,7 +241,7 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
}
}
}
val hasSignedIn = remember { EhCookieStore.hasSignedIn() }
val hasSignedIn by Settings.hasSignedIn.collectAsState()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
@@ -274,20 +274,28 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
) { paddingValues ->
val keylineMargin = dimensionResource(id = R.dimen.keyline_margin)
var editTextMeasured by remember { mutableStateOf(MinimumContentPaddingEditText) }
val refreshState = rememberPullToRefreshState()
if (refreshState.isRefreshing) {
LaunchedEffect(Unit) {
runSuspendCatching {
withIOContext { refreshComment(true) }
var isRefreshing by remember { mutableStateOf(false) }
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
launchIO {
runSuspendCatching {
refreshComment(true)
}
isRefreshing = false
}
refreshState.endRefresh()
}
}
Box(modifier = Modifier.fillMaxSize().imePadding().nestedScroll(refreshState.nestedScrollConnection)) {
},
modifier = Modifier.fillMaxSize().imePadding(),
) {
val additionalPadding = if (commenting) {
editTextMeasured
} else {
0.dp
if (!comments.hasMore) {
MinimumContentPaddingEditText
} else {
0.dp
}
}
val voteUpSucceed = stringResource(R.string.vote_up_successfully)
val cancelVoteUpSucceed = stringResource(R.string.cancel_vote_up_successfully)
@@ -297,10 +305,7 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
LazyColumn(
modifier = Modifier.padding(horizontal = keylineMargin),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding() + additionalPadding,
),
contentPadding = paddingValues + PaddingValues(bottom = additionalPadding),
) {
items(
items = comments.comments,
@@ -324,7 +329,7 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
}
}
suspend fun doCommentAction(comment: GalleryComment) = showSelectActions {
suspend fun doCommentAction(comment: GalleryComment) = awaitSelectAction {
onSelect(copyComment) {
addTextToClipboard(comment.comment.parseAsHtml())
}
@@ -333,7 +338,7 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
}
if (comment.editable) {
onSelect(editComment) {
userComment = TextFieldValue(comment.comment.parseAsHtml())
userComment = TextFieldValue(AnnotatedString.fromHtml(comment.comment))
commentId = comment.id
commenting = true
}
@@ -353,10 +358,10 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
showCommentVoteStatus(comment)
}
}
}
}()
GalleryCommentCard(
modifier = Modifier.animateItem(),
modifier = Modifier.thenIf(animateItems) { animateItem() },
comment = item,
onUserClick = {
navigate(
@@ -398,18 +403,8 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
}
}
}
} else {
// Workaround for comment list lagging when reaching the bottom
// https://github.com/FooIbar/EhViewer/issues/994
item {
Spacer(modifier = Modifier.height(MinimumContentPaddingEditText))
}
}
}
PullToRefreshContainer(
state = refreshState,
modifier = Modifier.align(Alignment.TopCenter).padding(top = paddingValues.calculateTopPadding()),
)
Surface(
modifier = Modifier.align(Alignment.BottomCenter).layout { measurable, constraints ->
val origin = measurable.measure(constraints)
@@ -56,13 +56,10 @@ import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -78,8 +75,9 @@ import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.core.text.parseAsHtml
import arrow.core.left
import androidx.window.core.layout.WindowWidthSizeClass
import arrow.core.partially1
import coil3.imageLoader
import com.hippo.ehviewer.EhApplication.Companion.galleryDetailCache
@@ -105,10 +103,6 @@ import com.hippo.ehviewer.client.data.findBaseInfo
import com.hippo.ehviewer.client.exception.EhException
import com.hippo.ehviewer.client.exception.NoHAtHClientException
import com.hippo.ehviewer.client.getImageKey
import com.hippo.ehviewer.client.parser.ArchiveParser
import com.hippo.ehviewer.client.parser.HomeParser
import com.hippo.ehviewer.client.parser.TorrentResult
import com.hippo.ehviewer.client.parser.format
import com.hippo.ehviewer.coil.justDownload
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.dao.DownloadInfo
@@ -118,7 +112,6 @@ import com.hippo.ehviewer.download.DownloadManager as EhDownloadManager
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.spider.SpiderDen
import com.hippo.ehviewer.ui.GalleryInfoBottomSheet
import com.hippo.ehviewer.ui.LockDrawer
import com.hippo.ehviewer.ui.MainActivity
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.confirmRemoveDownload
@@ -127,11 +120,13 @@ import com.hippo.ehviewer.ui.destinations.GalleryPreviewScreenDestination
import com.hippo.ehviewer.ui.getFavoriteIcon
import com.hippo.ehviewer.ui.jumpToReaderByPage
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
import com.hippo.ehviewer.ui.main.ArchiveList
import com.hippo.ehviewer.ui.main.EhPreviewItem
import com.hippo.ehviewer.ui.main.GalleryCommentCard
import com.hippo.ehviewer.ui.main.GalleryDetailErrorTip
import com.hippo.ehviewer.ui.main.GalleryDetailHeaderCard
import com.hippo.ehviewer.ui.main.GalleryTags
import com.hippo.ehviewer.ui.main.TorrentList
import com.hippo.ehviewer.ui.modifyFavorites
import com.hippo.ehviewer.ui.navToReader
import com.hippo.ehviewer.ui.openBrowser
@@ -141,6 +136,7 @@ import com.hippo.ehviewer.ui.tools.FilledTertiaryIconButton
import com.hippo.ehviewer.ui.tools.FilledTertiaryIconToggleButton
import com.hippo.ehviewer.ui.tools.GalleryDetailRating
import com.hippo.ehviewer.ui.tools.GalleryRatingBar
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.ui.tools.rememberLambda
import com.hippo.ehviewer.util.AppConfig
@@ -164,6 +160,10 @@ import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import moe.tarsin.coroutines.runSuspendCatching
@@ -214,7 +214,6 @@ private fun List<GalleryTagGroup>.getArtistTag(): String? {
@Destination<RootGraph>
@Composable
fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNavigator) = composing(navigator) {
LockDrawer(true)
var galleryInfo by remember {
val casted = args as? GalleryInfoArgs
mutableStateOf<GalleryInfo?>(casted?.galleryInfo)
@@ -293,13 +292,13 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
}
}
var mArchiveFormParamOr by remember { mutableStateOf<String?>(null) }
var mArchiveList by remember { mutableStateOf<List<ArchiveParser.Archive>?>(null) }
var mCurrentFunds by remember { mutableStateOf<HomeParser.Funds?>(null) }
val archiveResult = remember(galleryInfo) {
async(Dispatchers.IO + Job(), CoroutineStart.LAZY) {
val detail = galleryInfo as GalleryDetail
EhEngine.getArchiveList(detail.archiveUrl!!, gid, token)
}
}
val archiveFree = stringResource(R.string.archive_free)
val archiveOriginal = stringResource(R.string.archive_original)
val archiveResample = stringResource(R.string.archive_resample)
val failureNoHath = stringResource(R.string.download_archive_failure_no_hath)
val noArchive = stringResource(R.string.no_archives)
val downloadStarted = stringResource(R.string.download_archive_started)
@@ -311,42 +310,19 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
if (galleryDetail.apiUid < 0) {
showSnackbar(signInFirst)
} else {
Unit.runSuspendCatching {
if (mArchiveList == null) {
val result = bgWork {
withIOContext {
EhEngine.getArchiveList(galleryDetail.archiveUrl!!, gid, token)
}
}
mArchiveFormParamOr = result.paramOr
mArchiveList = result.archiveList
mCurrentFunds = result.funds
}
if (mArchiveList!!.isEmpty()) {
runSuspendCatching {
val (paramOr, archiveList, funds) = bgWork { archiveResult.await() }
if (archiveList.isEmpty()) {
showSnackbar(noArchive)
} else {
val items = mArchiveList!!.map {
it.run {
if (isHAtH) {
val costStr = if (cost == "Free") archiveFree else cost
"[H@H] $name [$size] [$costStr]"
} else {
val nameStr = if (res == "org") archiveOriginal else archiveResample
val costStr = if (cost == "Free!") archiveFree else cost
"$nameStr [$size] [$costStr]"
}
}
val selected = showNoButton {
ArchiveList(
funds = funds,
items = archiveList,
onItemClick = { dismissWith(it) },
)
}
var fundsGP = mCurrentFunds!!.fundsGP.toString()
// Ex GP numbers are rounded down to the nearest thousand
if (EhUtils.isExHentai) {
fundsGP += "+"
}
val title = getString(R.string.current_funds, fundsGP, mCurrentFunds!!.fundsC)
val selected = showSelectItem(items, title.left())
val res = mArchiveList!![selected].res
val isHAtH = mArchiveList!![selected].isHAtH
EhEngine.downloadArchive(gid, token, mArchiveFormParamOr!!, res, isHAtH)?.let {
EhEngine.downloadArchive(gid, token, paramOr, selected.res, selected.isHAtH)?.let {
val uri = Uri.parse(it)
val intent = Intent().apply {
action = Intent.ACTION_VIEW
@@ -477,7 +453,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
val items = galleryDetail.newerVersions.map {
getString(R.string.newer_version_title, it.title, it.posted)
}
val selected = showSelectItem(items)
val selected = awaitSelectItem(items)
val info = galleryDetail.newerVersions[selected]
withUIContext {
// Can't use GalleryInfoArgs as thumbKey is null
@@ -583,29 +559,30 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
)
},
)
val torrentText = stringResource(R.string.torrents)
val torrentText = stringResource(R.string.torrent_count, galleryDetail.torrentCount)
val permissionDenied = stringResource(R.string.permission_denied)
val downloadTorrentFailed = stringResource(R.string.download_torrent_failure)
val downloadTorrentStarted = stringResource(R.string.download_torrent_started)
val noTorrents = stringResource(R.string.no_torrents)
val noCurrentTorrents = stringResource(R.string.no_current_torrents)
var mTorrentList by remember { mutableStateOf<TorrentResult?>(null) }
suspend fun showTorrentDialog() {
if (mTorrentList == null) {
mTorrentList = bgWork {
withIOContext {
EhEngine.getTorrentList(galleryDetail.torrentUrl!!, gid, token)
}
}
val torrentResult = remember(galleryDetail) {
async(Dispatchers.IO + Job(), CoroutineStart.LAZY) {
EhEngine.getTorrentList(galleryDetail.torrentUrl!!, gid, token)
}
if (mTorrentList!!.isEmpty()) {
showSnackbar(noCurrentTorrents)
}
suspend fun showTorrentDialog() {
val torrentList = bgWork { torrentResult.await() }
if (torrentList.isEmpty()) {
showSnackbar(noTorrents)
} else {
val items = mTorrentList!!.map { it.format() }
val selected = showSelectItem(items, R.string.torrents, false)
val url = mTorrentList!![selected].url
val name = "${mTorrentList!![selected].name}.torrent"
val r = DownloadManager.Request(Uri.parse(url))
val selected = showNoButton(false) {
TorrentList(
items = torrentList,
onItemClick = { dismissWith(it) },
)
}
val url = selected.url
val name = "${selected.name}.torrent"
val r = DownloadManager.Request(url.toUri())
r.setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS,
AppConfig.APP_DIRNAME + "/" + FileUtils.sanitizeFilename(name),
@@ -660,23 +637,22 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
showSnackbar(signInFirst)
return@launchIO
}
awaitPermissionOrCancel(title = R.string.rate) {
val pendingRating = awaitResult(galleryDetail.rating.coerceAtLeast(.5f), title = R.string.rate) {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
var rating by remember { mutableFloatStateOf(galleryDetail.rating.coerceAtLeast(.5f)) }
var text by remember { mutableIntStateOf(getRatingText(rating)) }
var text by remember { mutableIntStateOf(getRatingText(expectedValue)) }
Text(text = stringResource(id = text), style = MaterialTheme.typography.bodyLarge)
Spacer(modifier = Modifier.size(keylineMargin))
GalleryRatingBar(
rating = rating,
rating = expectedValue,
onRatingChange = {
rating = it.coerceAtLeast(.5f)
text = getRatingText(rating)
expectedValue = it.coerceAtLeast(.5f)
text = getRatingText(expectedValue)
},
)
}
}
galleryDetail.runSuspendCatching {
EhEngine.rateGallery(apiUid, apiKey, gid, token, rating)
EhEngine.rateGallery(apiUid, apiKey, gid, token, pendingRating)
}.onSuccess { result ->
galleryInfo = galleryDetail.apply {
rating = result.rating
@@ -733,7 +709,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
tag
}
launchIO {
showSelectActions {
awaitSelectAction {
with(activity) {
onSelect(copy) {
addTextToClipboard(tag)
@@ -756,7 +732,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
onSelect(downTag) { galleryDetail.voteTag(tag, -1) }
}
}
}
}()
}
},
)
@@ -775,7 +751,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
modifier: Modifier,
) {
val galleryDetail = galleryInfo.asGalleryDetail()
val windowSizeClass = calculateWindowSizeClass(activity)
val windowSizeClass = LocalWindowSizeClass.current
val thumbColumns by Settings.thumbColumns.collectAsState()
val readText = stringResource(R.string.read)
val startPage by rememberInVM {
@@ -822,7 +798,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
if (showBottomSheet && galleryDetail != null) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
windowInsets = WindowInsets(0, 0, 0, 0),
contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
) {
GalleryInfoBottomSheet(galleryDetail, navigator)
}
@@ -856,8 +832,8 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
}
}
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Medium, WindowWidthSizeClass.Compact -> LazyVerticalGrid(
when (windowSizeClass.windowWidthSizeClass) {
WindowWidthSizeClass.MEDIUM, WindowWidthSizeClass.COMPACT -> LazyVerticalGrid(
columns = GridCells.Fixed(thumbColumns),
contentPadding = contentPadding,
modifier = modifier.padding(horizontal = keylineMargin),
@@ -908,7 +884,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
}
}
WindowWidthSizeClass.Expanded -> LazyVerticalGrid(
WindowWidthSizeClass.EXPANDED -> LazyVerticalGrid(
columns = GridCells.Fixed(thumbColumns),
contentPadding = contentPadding,
modifier = modifier.padding(horizontal = keylineMargin),
@@ -1098,7 +1074,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
} else {
val info = galleryInfo!!
val uri = awaitActivityResult(
CreateDocument("application/x-cbz"),
CreateDocument("application/vnd.comicbook+zip"),
EhUtils.getSuitableTitle(info) + ".cbz",
)
val dirname = downloadInfo?.dirname
@@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState
@@ -63,7 +64,7 @@ fun collectListThumbSizeAsState(): State<Dp> {
val density = LocalDensity.current
return Settings.listThumbSize.collectAsState {
with(density) {
it.toDp() * 9
(it.toDp() * 9).coerceAtLeast(120.dp)
}
}
}
@@ -1,5 +1,6 @@
package com.hippo.ehviewer.ui.screen
import android.content.Context
import android.net.Uri
import android.view.ViewConfiguration
import androidx.activity.result.contract.ActivityResultContracts
@@ -17,25 +18,22 @@ import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.automirrored.filled.LastPage
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Reorder
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.outlined.Bookmarks
@@ -47,11 +45,11 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBoxDefaults
import androidx.compose.material3.SwipeToDismissBoxState
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.fork.SwipeToDismissBox
import androidx.compose.material3.fork.SwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
@@ -74,7 +72,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
@@ -84,7 +81,6 @@ import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.lerp
import androidx.lifecycle.viewModelScope
import androidx.paging.LoadState
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingSource
@@ -113,7 +109,9 @@ import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.dao.QuickSearch
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.filled.GoTo
import com.hippo.ehviewer.ui.DrawerHandle
import com.hippo.ehviewer.ui.LocalSideSheetState
import com.hippo.ehviewer.ui.awaitSelectDate
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.destinations.ProgressScreenDestination
import com.hippo.ehviewer.ui.doGalleryInfoAction
@@ -125,15 +123,14 @@ import com.hippo.ehviewer.ui.main.GalleryInfoListItem
import com.hippo.ehviewer.ui.main.GalleryList
import com.hippo.ehviewer.ui.main.ImageSearch
import com.hippo.ehviewer.ui.main.SearchFilter
import com.hippo.ehviewer.ui.showDatePicker
import com.hippo.ehviewer.ui.tools.Deferred
import com.hippo.ehviewer.ui.tools.DragHandle
import com.hippo.ehviewer.ui.tools.SwipeToDismissBox2
import com.hippo.ehviewer.ui.tools.HapticFeedbackType
import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
import com.hippo.ehviewer.ui.tools.draggingHapticFeedback
import com.hippo.ehviewer.ui.tools.foldToLoadResult
import com.hippo.ehviewer.ui.tools.rememberHapticFeedback
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.ui.tools.rememberMutableStateInDataStore
import com.hippo.ehviewer.ui.tools.snackBarPadding
import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.hippo.ehviewer.util.pickVisualMedia
@@ -153,7 +150,7 @@ import kotlinx.coroutines.launch
import moe.tarsin.coroutines.onEachLatest
import moe.tarsin.coroutines.runSuspendCatching
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyColumnState
import sh.calvin.reorderable.rememberReorderableLazyListState
@Destination<RootGraph>
@Composable
@@ -176,16 +173,17 @@ fun ToplistScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlB
fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = composing(navigator) {
val searchFieldState = rememberTextFieldState()
var urlBuilder by rememberSaveable(lub) { mutableStateOf(lub) }
var searchBarExpanded by rememberSaveable { mutableStateOf(false) }
var searchBarOffsetY by remember { mutableIntStateOf(0) }
var showSearchLayout by rememberSaveable { mutableStateOf(false) }
var category by rememberSaveable { mutableIntStateOf(Settings.searchCategory) }
var searchMethod by rememberSaveable { mutableIntStateOf(1) }
var advancedSearchOption by rememberSaveable { mutableStateOf(AdvancedSearchOption()) }
var category by rememberMutableStateInDataStore("SearchCategory") { EhUtils.ALL_CATEGORY }
var advancedSearchOption by rememberMutableStateInDataStore("AdvancedSearchOption") { AdvancedSearchOption() }
var imageUri by rememberSaveable { mutableStateOf<Uri?>(null) }
DrawerHandle(!searchBarExpanded)
LaunchedEffect(urlBuilder) {
if (urlBuilder.mode == MODE_SUBSCRIPTION) searchMethod = 2
if (urlBuilder.category != EhUtils.NONE) category = urlBuilder.category
if (urlBuilder.mode != MODE_TOPLIST) {
var keyword = urlBuilder.keyword.orEmpty()
@@ -368,14 +366,14 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
},
windowInsets = WindowInsets(0),
)
val view = LocalView.current
Box(modifier = Modifier.fillMaxSize()) {
val quickSearchListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyColumnState(quickSearchListState) { from, to ->
val hapticFeedback = rememberHapticFeedback()
val reorderableLazyListState = rememberReorderableLazyListState(quickSearchListState) { from, to ->
val fromIndex = from.index - 1
val toIndex = to.index - 1
quickSearchList.apply { add(toIndex, removeAt(fromIndex)) }
view.performHapticFeedback(draggingHapticFeedback)
hapticFeedback.performHapticFeedback(HapticFeedbackType.MOVE)
}
var fromIndex by remember { mutableIntStateOf(-1) }
LazyColumn(
@@ -387,7 +385,8 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
stickyHeader {
HorizontalDivider()
}
itemsIndexed(quickSearchList, key = { _, item -> item.id!! }) { index, item ->
itemsIndexed(quickSearchList, key = { _, item -> item.id!! }) { itemIndex, item ->
val index by rememberUpdatedState(itemIndex)
ReorderableItem(reorderableLazyListState, key = item.id!!) { isDragging ->
// Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it
val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density, positionalThreshold = positionalThreshold) }
@@ -401,11 +400,10 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
}.onSuccess {
EhDB.deleteQuickSearch(item)
with(quickSearchList) {
val removeIndex = indexOf(item)
subList(removeIndex + 1, size).forEach {
subList(index + 1, size).forEach {
it.position--
}
removeAt(removeIndex)
removeAt(index)
}
}.onFailure {
dismissState.reset()
@@ -413,9 +411,10 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
}
}
}
SwipeToDismissBox2(
SwipeToDismissBox(
state = dismissState,
backgroundContent = {},
enableDismissFromStartToEnd = false,
) {
val elevation by animateDpAsState(
if (isDragging) {
@@ -443,22 +442,29 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
Text(text = item.name)
},
trailingContent = {
DragHandle(
onDragStarted = {
fromIndex = index
},
onDragStopped = {
if (fromIndex != -1) {
if (fromIndex != index) {
val range = if (fromIndex < index) fromIndex..index else index..fromIndex
val toUpdate = quickSearchList.slice(range)
toUpdate.zip(range).forEach { it.first.position = it.second }
launchIO { EhDB.updateQuickSearch(toUpdate) }
IconButton(
onClick = {},
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.START)
fromIndex = index
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.END)
if (fromIndex != -1) {
if (fromIndex != index) {
val range = if (fromIndex < index) fromIndex..index else index..fromIndex
val toUpdate = quickSearchList.slice(range)
toUpdate.zip(range).forEach { it.first.position = it.second }
launchIO { EhDB.updateQuickSearch(toUpdate) }
}
fromIndex = -1
}
fromIndex = -1
}
},
)
},
),
) {
Icon(imageVector = Icons.Default.Reorder, contentDescription = null)
}
},
)
}
@@ -477,10 +483,6 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
}
}
val refreshState = rememberPullToRefreshState {
data.loadState.refresh is LoadState.NotLoading
}
var fabExpanded by remember { mutableStateOf(false) }
var fabHidden by remember { mutableStateOf(false) }
@@ -540,13 +542,15 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
}
SearchBarScreen(
onApplySearch = ::onApplySearch,
expanded = searchBarExpanded,
onExpandedChange = {
searchBarExpanded = it
fabHidden = it
},
title = suitableTitle,
searchFieldState = searchFieldState,
searchFieldHint = searchBarHint,
onApplySearch = ::onApplySearch,
onSearchExpanded = { fabHidden = true },
onSearchHidden = { fabHidden = false },
refreshState = refreshState,
suggestionProvider = {
GalleryDetailUrlParser.parse(it, false)?.run {
GalleryDetailUrlSuggestion(gid, token)
@@ -574,12 +578,11 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
filter = {
SearchFilter(
category = category,
onCategoryChanged = {
Settings.searchCategory = it
category = it
},
onCategoryChanged = { category = it },
advancedOption = advancedSearchOption,
onAdvancedOptionChanged = { advancedSearchOption = it },
onAdvancedOptionChanged = {
advancedSearchOption = it
},
)
},
floatingActionButton = {
@@ -606,50 +609,44 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
val layoutDirection = LocalLayoutDirection.current
val marginH = dimensionResource(id = R.dimen.gallery_list_margin_h)
val marginV = dimensionResource(id = R.dimen.gallery_list_margin_v)
Column(
modifier = Modifier.imePadding().verticalScroll(rememberScrollState())
.padding(
top = contentPadding.calculateTopPadding() + marginV,
start = contentPadding.calculateStartPadding(layoutDirection) + marginH,
end = contentPadding.calculateEndPadding(layoutDirection) + marginH,
bottom = 8.dp,
)
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom))
.graphicsLayer {
scaleX = 1 - animatedSearchLayout
scaleY = 1 - animatedSearchLayout
alpha = 1 - animatedSearchLayout
},
ElevatedCard(
modifier = Modifier.graphicsLayer {
scaleX = 1 - animatedSearchLayout
scaleY = 1 - animatedSearchLayout
alpha = 1 - animatedSearchLayout
}.padding(
top = contentPadding.calculateTopPadding() + marginV,
start = contentPadding.calculateStartPadding(layoutDirection) + marginH,
end = contentPadding.calculateEndPadding(layoutDirection) + marginH,
bottom = 8.dp,
).padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v)),
) {
ElevatedCard(modifier = Modifier.padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v))) {
Column(
modifier = Modifier.padding(
horizontal = dimensionResource(id = R.dimen.search_category_padding_h),
vertical = dimensionResource(id = R.dimen.search_category_padding_v),
).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(id = R.string.search_image),
modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)),
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
)
ImageSearch(
image = imageUri,
onSelectImage = {
launch { imageUri = pickVisualMedia(ActivityResultContracts.PickVisualMedia.ImageOnly) }
},
)
}
Column(
modifier = Modifier.padding(
horizontal = dimensionResource(id = R.dimen.search_category_padding_h),
vertical = dimensionResource(id = R.dimen.search_category_padding_v),
).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(id = R.string.search_image),
modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)),
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
)
ImageSearch(
image = imageUri,
onSelectImage = {
launch { imageUri = pickVisualMedia(ActivityResultContracts.PickVisualMedia.ImageOnly) }
},
)
}
}
val height by collectListThumbSizeAsState()
val showPages by Settings.showGalleryPages.collectAsState()
val context = LocalContext.current
val searchBarConnection = remember {
val slop = ViewConfiguration.get(context).scaledTouchSlop
val slop = ViewConfiguration.get(implicit<Context>()).scaledTouchSlop
val topPaddingPx = with(density) { contentPadding.calculateTopPadding().roundToPx() }
object : NestedScrollConnection {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
@@ -690,9 +687,10 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
onClick = { navigate(info.asDst()) },
onLongClick = { launch { doGalleryInfoAction(info) } },
info = info,
showPages = showPages,
)
},
refreshState = refreshState,
searchBarOffsetY = { searchBarOffsetY },
onRefresh = {
urlBuilder.setRange(0)
data.refresh()
@@ -737,32 +735,26 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
val page = urlBuilder.mJumpTo?.toIntOrNull() ?: 0
val hint = getString(R.string.go_to_hint, page + 1, TOPLIST_PAGES)
val text = awaitInputText(title = gotoTitle, hint = hint, isNumber = true) { oriText ->
val text = oriText.trim()
val goTo = runCatching {
text.toInt() - 1
}.onFailure {
return@awaitInputText invalidNum
}.getOrThrow()
if (goTo !in 0..<TOPLIST_PAGES) outOfRange else null
when (oriText.trim().toIntOrNull()?.let { it - 1 }) {
null -> invalidNum
!in 0..<TOPLIST_PAGES -> outOfRange
else -> null
}
}.trim().toInt() - 1
urlBuilder.setJumpTo(text)
data.refresh()
} else {
launch {
val date = showDatePicker()
urlBuilder.mJumpTo = date
data.refresh()
}
val date = awaitSelectDate()
urlBuilder.mJumpTo = date
}
data.refresh()
}
onClick(Icons.AutoMirrored.Default.LastPage) {
if (isTopList) {
urlBuilder.setJumpTo(TOPLIST_PAGES - 1)
data.refresh()
} else {
urlBuilder.setIndex("1", false)
data.refresh()
}
data.refresh()
}
}
}
@@ -39,7 +39,6 @@ import com.hippo.ehviewer.coil.justDownload
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.ktbuilder.imageRequest
import com.hippo.ehviewer.ktbuilder.launchIn
import com.hippo.ehviewer.ui.LockDrawer
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.main.EhPreviewItem
import com.hippo.ehviewer.ui.navToReader
@@ -59,7 +58,6 @@ import moe.tarsin.coroutines.runSuspendCatching
@Destination<RootGraph>
@Composable
fun GalleryPreviewScreen(detail: GalleryDetail, toNextPage: Boolean, navigator: DestinationsNavigator) = composing(navigator) {
LockDrawer(true)
val scrollBehaviour = TopAppBarDefaults.pinnedScrollBehavior()
val pgSize = detail.previewList.size
val state = rememberLazyGridState(initialFirstVisibleItemIndex = if (toNextPage) pgSize else 0)
@@ -15,7 +15,8 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.material3.fork.SwipeToDismissBox
import androidx.compose.material3.fork.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -46,14 +47,15 @@ import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.big.History
import com.hippo.ehviewer.ui.DrawerHandle
import com.hippo.ehviewer.ui.composing
import com.hippo.ehviewer.ui.doGalleryInfoAction
import com.hippo.ehviewer.ui.main.GalleryInfoListItem
import com.hippo.ehviewer.ui.main.plus
import com.hippo.ehviewer.ui.tools.Deferred
import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
import com.hippo.ehviewer.ui.tools.SwipeToDismissBox2
import com.hippo.ehviewer.ui.tools.rememberInVM
import com.hippo.ehviewer.ui.tools.thenIf
import com.hippo.ehviewer.util.FavouriteStatusRouter
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
@@ -67,10 +69,14 @@ import kotlinx.coroutines.launch
fun HistoryScreen(navigator: DestinationsNavigator) = composing(navigator) {
val title = stringResource(id = R.string.history)
val hint = stringResource(R.string.search_bar_hint, title)
val animateItems by Settings.animateItems.collectAsState()
var searchBarExpanded by rememberSaveable { mutableStateOf(false) }
var searchBarOffsetY by remember { mutableIntStateOf(0) }
var keyword by rememberSaveable { mutableStateOf("") }
DrawerHandle(!searchBarExpanded)
val density = LocalDensity.current
val historyData = rememberInVM {
Pager(config = PagingConfig(pageSize = 20, jumpThreshold = 40)) {
@@ -83,14 +89,14 @@ fun HistoryScreen(navigator: DestinationsNavigator) = composing(navigator) {
}.collectAsLazyPagingItems()
FavouriteStatusRouter.Observe(historyData)
SearchBarScreen(
title = title,
searchFieldHint = hint,
onApplySearch = {
keyword = it
historyData.refresh()
},
onSearchExpanded = {},
onSearchHidden = {},
expanded = searchBarExpanded,
onExpandedChange = { searchBarExpanded = it },
title = title,
searchFieldHint = hint,
searchBarOffsetY = { searchBarOffsetY },
trailingIcon = {
IconButton(onClick = {
@@ -139,10 +145,11 @@ fun HistoryScreen(navigator: DestinationsNavigator) = composing(navigator) {
true
},
)
SwipeToDismissBox2(
SwipeToDismissBox(
state = dismissState,
backgroundContent = {},
modifier = Modifier.animateItem(),
modifier = Modifier.thenIf(animateItems) { animateItem() },
enableDismissFromStartToEnd = false,
) {
GalleryInfoListItem(
onClick = { navigate(info.asDst()) },
@@ -1,7 +1,6 @@
package com.hippo.ehviewer.ui.screen
import androidx.annotation.MainThread
import androidx.navigation.NavController
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.ListUrlBuilder
import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser
@@ -34,19 +33,9 @@ fun DestinationsNavigator.navWithUrl(url: String): Boolean {
@MainThread
fun DestinationsNavigator.popNavigate(direction: Direction) = navigate(direction) {
popUpTo(NavGraphs.root.route)
popUpTo(NavGraphs.root)
}
@MainThread
fun NavController.navWithUrl(url: String): Boolean {
val dest = urlToDestination(url) ?: return false
navigateTo(dest)
return true
}
@MainThread
fun NavController.navigateTo(direction: Direction) = navigate(direction.route)
fun BaseGalleryInfo.asDst() = GalleryDetailScreenDestination(GalleryInfoArgs(this))
infix fun Long.asDstWith(token: String) = GalleryDetailScreenDestination(TokenArgs(this, token))
@@ -24,7 +24,6 @@ import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.icons.EhIcons
import com.hippo.ehviewer.icons.big.SadAndroid
import com.hippo.ehviewer.ui.LockDrawer
import com.hippo.ehviewer.util.displayString
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
@@ -35,7 +34,6 @@ import moe.tarsin.coroutines.runSuspendCatching
@Destination<RootGraph>
@Composable
fun ProgressScreen(gid: Long, token: String, page: Int, navigator: DestinationsNavigator) {
LockDrawer(true)
val wrong = stringResource(id = R.string.error_something_wrong_happened)
var error by rememberSaveable { mutableStateOf("") }
LaunchedEffect(error) {
@@ -1,6 +1,8 @@
package com.hippo.ehviewer.ui.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -8,17 +10,14 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
@@ -42,15 +41,12 @@ import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.SearchBarHorizontalPadding
import androidx.compose.material3.SearchBarInputField
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
@@ -66,12 +62,13 @@ import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.client.data.TagNamespace
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.dao.Search
import com.hippo.ehviewer.dao.SearchDao
import com.hippo.ehviewer.ui.LocalNavDrawerState
import com.hippo.ehviewer.ui.LockDrawer
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.rememberCompositionActiveState
import com.hippo.ehviewer.ui.tools.thenIf
import com.jamal.composeprefs3.ui.ifNotNullThen
import com.jamal.composeprefs3.ui.ifTrueThen
import eu.kanade.tachiyomi.util.lang.launchIO
@@ -96,31 +93,30 @@ abstract class Suggestion {
}
suspend fun SearchDao.suggestions(prefix: String, limit: Int) =
(if (prefix.isBlank()) list(limit) else rawSuggestions(prefix, limit)).map { it.query }
(if (prefix.isBlank()) list(limit) else rawSuggestions(prefix, limit))
@Composable
fun SearchBarScreen(
onApplySearch: (String) -> Unit,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
title: String? = null,
searchFieldState: TextFieldState = rememberTextFieldState(),
searchFieldHint: String? = null,
onApplySearch: (String) -> Unit,
onSearchExpanded: () -> Unit,
onSearchHidden: () -> Unit,
refreshState: PullToRefreshState? = null,
suggestionProvider: SuggestionProvider? = null,
tagNamespace: Boolean = false,
searchBarOffsetY: () -> Int,
trailingIcon: @Composable () -> Unit,
searchBarOffsetY: () -> Int = { 0 },
trailingIcon: @Composable () -> Unit = {},
filter: @Composable (() -> Unit)? = null,
floatingActionButton: @Composable () -> Unit = {},
content: @Composable (PaddingValues) -> Unit,
) {
var mSuggestionList by remember { mutableStateOf(emptyList<Suggestion>()) }
val mSearchDatabase = searchDatabase.searchDao()
var expanded by rememberSaveable { mutableStateOf(false) }
val scope = rememberCoroutineScope { Dispatchers.IO }
val context = LocalContext.current
val dialogState = LocalDialogState.current
val animateItems by Settings.animateItems.collectAsState()
class TagSuggestion(
override val hint: String?,
@@ -167,19 +163,6 @@ fun SearchBarScreen(
mSuggestionList = mergedSuggestionFlow().toList()
}
var shouldLockDrawer by remember { mutableStateOf(false) }
LockDrawer(shouldLockDrawer)
fun onSearchViewExpanded() {
onSearchExpanded()
shouldLockDrawer = true
}
fun onSearchViewHidden() {
shouldLockDrawer = false
onSearchHidden()
}
if (expanded) {
LaunchedEffect(Unit) {
snapshotFlow { searchFieldState.text }.collectLatest {
@@ -189,12 +172,12 @@ fun SearchBarScreen(
}
fun hideSearchView() {
onSearchViewHidden()
expanded = false
onExpandedChange(false)
}
fun onApplySearch() {
val query = searchFieldState.text.trim().toString()
// May have invalid whitespaces if pasted from clipboard, replace them with spaces
val query = searchFieldState.text.trim().replace(WhitespaceRegex, " ")
if (query.isNotEmpty()) {
scope.launchIO {
mSearchDatabase.deleteQuery(query)
@@ -217,42 +200,21 @@ fun SearchBarScreen(
}
}
val scrollAwayModifier = if (!expanded) {
Modifier.offset { IntOffset(0, searchBarOffsetY()) }
} else {
Modifier
}
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
Scaffold(
topBar = {
// Placeholder, fill immutable SearchBar padding
Spacer(
modifier = Modifier.statusBarsPadding().displayCutoutPadding()
.height(SearchBarDefaults.InputFieldHeight + 16.dp),
)
Spacer(modifier = Modifier.statusBarsPadding().height(SearchBarDefaults.InputFieldHeight + 16.dp))
},
floatingActionButton = floatingActionButton,
content = content,
)
if (refreshState != null) {
PullToRefreshContainer(
state = refreshState,
modifier = Modifier.align(Alignment.TopCenter).safeDrawingPadding()
.padding(top = 48.dp) then scrollAwayModifier,
)
}
val onExpandedChange = { v: Boolean ->
if (v) {
onSearchViewExpanded()
} else {
onSearchViewHidden()
}
expanded = v
}
// https://issuetracker.google.com/337191298
// Workaround for can't exit SearchBar due to refocus in non-touch mode
Box(Modifier.size(1.dp).focusable())
val activeState = rememberCompositionActiveState()
SearchBar(
modifier = Modifier.align(Alignment.TopCenter) then scrollAwayModifier
modifier = Modifier.align(Alignment.TopCenter).thenIf(!expanded) { offset { IntOffset(0, searchBarOffsetY()) } }
.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)),
inputField = {
SearchBarInputField(
@@ -305,8 +267,7 @@ fun SearchBarScreen(
filter?.invoke()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = WindowInsets.navigationBars.union(WindowInsets.ime)
.only(WindowInsetsSides.Bottom).asPaddingValues(),
contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom).asPaddingValues(),
) {
// Workaround for prepending before the first item
item {}
@@ -329,7 +290,7 @@ fun SearchBarScreen(
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable { it.onClick() }.animateItem(),
modifier = Modifier.clickable { it.onClick() }.thenIf(animateItems) { animateItem() },
)
}
}
@@ -355,3 +316,5 @@ fun wrapTagKeyword(keyword: String, translate: Boolean = false): String {
}
}
}
private val WhitespaceRegex = Regex("\\s+")
@@ -26,11 +26,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.parseAsHtml
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.BuildConfig
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.ui.destinations.LicenseScreenDestination
import com.hippo.ehviewer.ui.tools.DialogState
import com.hippo.ehviewer.ui.tools.LocalDialogState
@@ -38,6 +41,7 @@ import com.hippo.ehviewer.ui.tools.observed
import com.hippo.ehviewer.updater.AppUpdater
import com.hippo.ehviewer.updater.Release
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.displayString
import com.hippo.ehviewer.util.installPackage
import com.ramcosta.composedestinations.annotation.Destination
@@ -54,11 +58,11 @@ private const val RELEASE_URL = "$REPO_URL/releases"
@Composable
@Stable
private fun versionCode() = "${BuildConfig.VERSION_NAME} (${BuildConfig.COMMIT_SHA})\n" + stringResource(R.string.settings_about_build_time, BuildConfig.BUILD_TIME)
private fun versionCode() = "${BuildConfig.VERSION_NAME} (${BuildConfig.COMMIT_SHA})\n" + stringResource(R.string.settings_about_commit_time, AppConfig.commitTime)
@Composable
@Stable
private fun author() = stringResource(R.string.settings_about_author_summary).replace('$', '@').parseAsHtml()
private fun author() = AnnotatedString.fromHtml(stringResource(R.string.settings_about_author_summary).replace('$', '@'))
@Destination<RootGraph>
@Composable
@@ -107,6 +111,10 @@ fun AboutScreen(navigator: DestinationsNavigator) {
title = stringResource(id = R.string.settings_about_version),
summary = versionCode(),
)
SwitchPreference(
title = stringResource(id = R.string.backup_before_update),
value = Settings::backupBeforeUpdate,
)
SwitchPreference(
title = stringResource(id = R.string.use_ci_update_channel),
value = Settings::useCIUpdateChannel,
@@ -144,6 +152,12 @@ suspend fun DialogState.showNewVersion(context: Context, release: Release) {
Text(text = release.changelog)
}
}
if (Settings.backupBeforeUpdate) {
val time = ReadableTime.getFilenamableTime()
downloadLocation.createFile("$time.db")?.let {
EhDB.exportDB(context, it)
}
}
// TODO: Download in the background and show progress in notification
val file = File(AppConfig.tempDir, "update.apk").apply { delete() }
AppUpdater.downloadUpdate(release.downloadLink, file)
@@ -22,6 +22,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
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
@@ -33,15 +34,23 @@ import com.hippo.ehviewer.BuildConfig
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.asMutableState
import com.hippo.ehviewer.client.CHROME_USER_AGENT
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.data.FavListUrlBuilder
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.observed
import com.hippo.ehviewer.util.AppConfig
import com.hippo.ehviewer.util.Crash
import com.hippo.ehviewer.util.ReadableTime
import com.hippo.ehviewer.util.getAppLanguage
import com.hippo.ehviewer.util.getLanguages
import com.hippo.ehviewer.util.isAtLeastV
import com.hippo.ehviewer.util.setAppLanguage
import com.hippo.unifile.asUniFile
import com.jamal.composeprefs3.ui.prefs.DropDownPref
import com.jamal.composeprefs3.ui.prefs.SwitchPref
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@@ -78,10 +87,12 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
snackbarHost = { SnackbarHost(snackbarHostState) },
) { paddingValues ->
Column(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection).verticalScroll(rememberScrollState()).padding(paddingValues)) {
// SwitchPreference(
// title = "New Compose Reader [WIP!!!]",
// value = Settings::newReader,
// )
if (AppConfig.isSnapshot) {
SwitchPreference(
title = "New Compose Reader [WIP!!!]",
value = Settings::newReader,
)
}
SwitchPreference(
title = stringResource(id = R.string.settings_advanced_save_parse_error_body),
summary = stringResource(id = R.string.settings_advanced_save_parse_error_body_summary),
@@ -97,7 +108,7 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
title = stringResource(id = R.string.settings_advanced_dump_logcat),
summary = stringResource(id = R.string.settings_advanced_dump_logcat_summary),
contract = ActivityResultContracts.CreateDocument("application/zip"),
key = "log-" + ReadableTime.getFilenamableTime(System.currentTimeMillis()) + ".zip",
key = "log-" + ReadableTime.getFilenamableTime() + ".zip",
) { uri ->
uri?.run {
context.runCatching {
@@ -113,7 +124,7 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
zipOs.putNextEntry(entry)
file.inputStream().use { it.copyTo(zipOs) }
}
val logcatEntry = ZipEntry("logcat-" + ReadableTime.getFilenamableTime(System.currentTimeMillis()) + ".txt")
val logcatEntry = ZipEntry("logcat-" + ReadableTime.getFilenamableTime() + ".txt")
zipOs.putNextEntry(logcatEntry)
Crash.collectInfo(zipOs.writer())
Runtime.getRuntime().exec("logcat -d").inputStream.use { it.copyTo(zipOs) }
@@ -132,22 +143,28 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
entryValueRes = R.array.read_cache_size_entry_values,
value = Settings::readCacheSize.observed,
)
SimpleMenuPreference(
var currentLanguage by remember { mutableStateOf(getAppLanguage()) }
val languages = remember { context.getLanguages() }
DropDownPref(
title = stringResource(id = R.string.settings_advanced_app_language_title),
entry = R.array.app_language_entries,
entryValueRes = R.array.app_language_entry_values,
value = Settings::language,
defaultValue = currentLanguage,
onValueChange = {
setAppLanguage(it)
currentLanguage = it
},
useSelectedAsSummary = true,
entries = languages,
)
SwitchPreference(
title = stringResource(id = R.string.preload_thumb_aggressively),
value = Settings::preloadThumbAggressively,
)
IntSliderPreference(
maxValue = 5,
minValue = 1,
title = stringResource(id = R.string.settings_advanced_touch_slop),
summary = stringResource(id = R.string.settings_advanced_touch_slop_summary),
value = Settings::touchSlopFactor,
var animateItems by Settings.animateItems.asMutableState()
SwitchPref(
checked = animateItems,
onMutate = { animateItems = !animateItems },
title = stringResource(id = R.string.animate_items),
summary = stringResource(id = R.string.animate_items_summary),
)
var userAgent by Settings::userAgent.observed
val userAgentTitle = stringResource(id = R.string.user_agent)
@@ -159,20 +176,21 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
userAgent = dialogState.awaitInputText(
initial = userAgent,
title = userAgentTitle,
).trim().takeUnless { it.isBlank() } ?: CHROME_USER_AGENT
).trim().ifBlank { null } ?: CHROME_USER_AGENT
}
}
val exportFailed = stringResource(id = R.string.settings_advanced_export_data_failed)
val now = ReadableTime.getFilenamableTime()
LauncherPreference(
title = stringResource(id = R.string.settings_advanced_export_data),
summary = stringResource(id = R.string.settings_advanced_export_data_summary),
contract = ActivityResultContracts.CreateDocument("application/vnd.sqlite3"),
key = ReadableTime.getFilenamableTime(System.currentTimeMillis()) + ".db",
key = if (isAtLeastV) now else "$now.db",
) { uri ->
uri?.let {
context.runCatching {
grantUriPermission(BuildConfig.APPLICATION_ID, uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
EhDB.exportDB(context, uri)
EhDB.exportDB(context, uri.asUniFile())
launchSnackBar(getString(R.string.settings_advanced_export_data_to, uri.toString()))
}.onFailure {
logcat(it)
@@ -186,7 +204,7 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
title = stringResource(id = R.string.settings_advanced_import_data),
summary = stringResource(id = R.string.settings_advanced_import_data_summary),
contract = ActivityResultContracts.GetContent(),
key = "application/octet-stream",
key = if (isAtLeastV) "application/vnd.sqlite3" else "application/octet-stream",
) { uri ->
uri?.let {
context.runCatching {
@@ -199,7 +217,8 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
}
}
}
if (EhCookieStore.hasSignedIn()) {
val hasSignedIn by Settings.hasSignedIn.collectAsState()
if (hasSignedIn) {
val backupNothing = stringResource(id = R.string.settings_advanced_backup_favorite_nothing)
val backupFailed = stringResource(id = R.string.settings_advanced_backup_favorite_failed)
val backupSucceed = stringResource(id = R.string.settings_advanced_backup_favorite_success)
@@ -28,6 +28,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import arrow.fx.coroutines.parMap
import arrow.fx.coroutines.parMapNotNull
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
@@ -36,7 +37,10 @@ import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.client.data.BaseGalleryInfo
import com.hippo.ehviewer.client.data.GalleryInfo
import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser
import com.hippo.ehviewer.client.parser.ParserUtils
import com.hippo.ehviewer.dao.DownloadInfo
import com.hippo.ehviewer.download.DownloadManager
import com.hippo.ehviewer.download.downloadDir
import com.hippo.ehviewer.download.downloadLocation
import com.hippo.ehviewer.spider.COMIC_INFO_FILE
import com.hippo.ehviewer.spider.SpiderDen
@@ -46,6 +50,7 @@ import com.hippo.ehviewer.spider.readCompatFromUniFile
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
import com.hippo.ehviewer.ui.tools.observed
import com.hippo.ehviewer.ui.tools.rememberedAccessor
import com.hippo.ehviewer.util.displayString
import com.hippo.unifile.UniFile
import com.hippo.unifile.asUniFile
import com.hippo.unifile.displayPath
@@ -94,6 +99,7 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
keepNoMediaFileStatus(downloadLocationState)
}
}.onFailure {
logcat(it)
launchSnackBar(cannotGetDownloadLocation)
}
}
@@ -156,6 +162,34 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
summary = stringResource(id = R.string.settings_download_archive_metadata_summary),
value = Settings::archiveMetadata,
)
WorkPreference(
title = stringResource(id = R.string.settings_download_reload_metadata),
summary = stringResource(id = R.string.settings_download_reload_metadata_summary),
) {
fun DownloadInfo.isStable(): Boolean {
val downloadTime = downloadDir?.findFile(COMIC_INFO_FILE)?.lastModified() ?: return false
val postedTime = posted?.let { ParserUtils.parseDate(it) } ?: return false
// stable 30 days after posted
val stableTime = postedTime + 30L * 24L * 60L * 60L * 1000L
return downloadTime > stableTime
}
runSuspendCatching {
DownloadManager.downloadInfoList.parMapNotNull {
if (it.state == DownloadInfo.STATE_FINISH && !it.isStable()) it else null
}.apply {
fillGalleryListByApi(this, EhUrl.referer)
val toUpdate = parMap { di ->
di.galleryInfo.also { SpiderDen(it, di.dirname!!).writeComicInfo(false) }
}
EhDB.updateGalleryInfo(toUpdate)
launchSnackBar(context.getString(R.string.settings_download_reload_metadata_successfully, toUpdate.size))
}
}.onFailure {
launchSnackBar(context.getString(R.string.settings_download_reload_metadata_failed, it.displayString()))
}
}
val restoreFailed = stringResource(id = R.string.settings_download_restore_failed)
WorkPreference(
title = stringResource(id = R.string.settings_download_restore_download_items),
summary = stringResource(id = R.string.settings_download_restore_download_items_summary),
@@ -163,7 +197,7 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
var restoreDirCount = 0
suspend fun getRestoreItem(file: UniFile): RestoreItem? {
if (!file.isDirectory) return null
return runCatching {
return runSuspendCatching {
val (gid, token) = file.findFile(SPIDER_INFO_FILENAME)?.let {
readCompatFromUniFile(it)?.run {
GalleryDetailUrlParser.Result(gid, token)
@@ -189,12 +223,8 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
}.getOrNull()
}
runCatching {
val result = downloadLocation.listFiles().mapNotNull { getRestoreItem(it) }.apply {
runSuspendCatching {
fillGalleryListByApi(this, EhUrl.referer)
}.onFailure {
logcat(it)
}
val result = downloadLocation.listFiles().parMapNotNull { getRestoreItem(it) }.also {
fillGalleryListByApi(it, EhUrl.referer)
}
if (result.isEmpty()) {
launchSnackBar(RESTORE_COUNT_MSG(restoreDirCount))
@@ -210,6 +240,7 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
}
}.onFailure {
logcat(it)
launchSnackBar(restoreFailed)
}
}
WorkPreference(
@@ -1,9 +1,12 @@
package com.hippo.ehviewer.ui.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
@@ -14,6 +17,7 @@ import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
@@ -37,7 +41,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.parseAsHtml
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.asMutableState
@@ -46,10 +52,12 @@ import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.client.EhUtils
import com.hippo.ehviewer.client.parser.HomeParser
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.ui.destinations.FilterScreenDestination
import com.hippo.ehviewer.ui.destinations.MyTagsScreenDestination
import com.hippo.ehviewer.ui.destinations.SignInScreenDestination
import com.hippo.ehviewer.ui.destinations.UConfigScreenDestination
import com.hippo.ehviewer.ui.main.FundsItem
import com.hippo.ehviewer.ui.screen.popNavigate
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.observed
@@ -76,7 +84,7 @@ fun EhScreen(navigator: DestinationsNavigator) {
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
val context = LocalContext.current
fun launchSnackBar(content: String) = coroutineScope.launch { snackbarHostState.showSnackbar(content) }
val signin = EhCookieStore.hasSignedIn()
val hasSignedIn by Settings.hasSignedIn.collectAsState()
val dialogState = LocalDialogState.current
Scaffold(
topBar = {
@@ -100,9 +108,10 @@ fun EhScreen(navigator: DestinationsNavigator) {
.verticalScroll(rememberScrollState())
.padding(paddingValues),
) {
val displayName by Settings.displayName.collectAsState()
Preference(
title = stringResource(id = R.string.account_name),
summary = Settings.displayName ?: guestMode,
summary = displayName ?: guestMode,
) {
coroutineScope.launch {
val cookies = EhCookieStore.getIdentityCookies()
@@ -111,12 +120,13 @@ fun EhScreen(navigator: DestinationsNavigator) {
dismissText = R.string.settings_eh_clear_igneous,
showCancelButton = cookies.last().second != null,
onCancelButtonClick = { EhCookieStore.clearIgneous() },
secure = hasSignedIn,
) {
if (signin) {
if (hasSignedIn) {
Column {
val warning = stringResource(id = R.string.settings_eh_identity_cookies_signed)
val str = cookies.joinToString("\n") { (k, v) -> "$k: $v" }
Text(text = warning.parseAsHtml())
Text(text = AnnotatedString.fromHtml(warning))
Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.keyline_margin)))
OutlinedTextField(
value = str,
@@ -143,17 +153,15 @@ fun EhScreen(navigator: DestinationsNavigator) {
}
}
}
if (signin) {
if (hasSignedIn) {
val placeholder = stringResource(id = R.string.please_wait)
val resetImageLimitSucceed = stringResource(id = R.string.reset_limits_succeed)
var result by rememberSaveable { mutableStateOf<HomeParser.Result?>(null) }
var error by rememberSaveable { mutableStateOf<String?>(null) }
val summary by rememberUpdatedState(
stringResource(
id = R.string.image_limits_summary,
result?.run { limits.current } ?: 0,
result?.run { limits.maximum } ?: 0,
),
result?.run {
stringResource(id = R.string.image_limits_summary, limits.current, limits.maximum)
} ?: placeholder,
)
suspend fun getImageLimits() {
result = EhEngine.getImageLimits()
@@ -181,10 +189,28 @@ fun EhScreen(navigator: DestinationsNavigator) {
error?.let {
Text(text = it)
} ?: result?.let { (limits, funds) ->
Text(
text = stringResource(id = R.string.current_limits, summary, limits.resetCost) +
"\n" + stringResource(id = R.string.current_funds, "${funds.fundsGP}+", funds.fundsC),
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
LinearProgressIndicator(
progress = { limits.current.toFloat() / limits.maximum },
modifier = Modifier.height(12.dp).fillMaxWidth(),
)
Text(text = summary)
Text(text = stringResource(id = R.string.reset_cost, limits.resetCost))
Text(text = stringResource(id = R.string.current_funds))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround,
) {
FundsItem(
type = "GP",
amount = funds.gp,
)
FundsItem(
type = "C",
amount = funds.credit,
)
}
}
} ?: run {
Column(
modifier = Modifier.fillMaxWidth(),
@@ -237,11 +263,15 @@ fun EhScreen(navigator: DestinationsNavigator) {
val items = buildList {
add(disabled)
add(localFav)
if (signin) {
if (hasSignedIn) {
addAll(Settings.favCat)
}
}
defaultFavSlot = dialogState.showSelectItem(items, R.string.default_favorites_collection) - 2
defaultFavSlot = dialogState.awaitSelectItem(
items = items,
title = R.string.default_favorites_collection,
selected = defaultFavSlot + 2,
) - 2
}
}
SimpleMenuPreferenceInt(
@@ -350,7 +380,7 @@ fun EhScreen(navigator: DestinationsNavigator) {
title = stringResource(id = R.string.settings_eh_metered_network_warning),
value = Settings.meteredNetworkWarning::value,
)
if (signin) {
if (hasSignedIn) {
SwitchPreference(
title = stringResource(id = R.string.settings_eh_show_jpn_title),
summary = stringResource(id = R.string.settings_eh_show_jpn_title_summary),
@@ -366,7 +396,7 @@ fun EhScreen(navigator: DestinationsNavigator) {
Preference(title = pickerTitle) {
coroutineScope.launch {
val time = LocalTime.fromSecondOfDay(Settings.requestNewsTime)
val (hour, minute) = dialogState.showTimePicker(pickerTitle, time.hour, time.minute)
val (hour, minute) = dialogState.awaitSelectTime(pickerTitle, time.hour, time.minute)
Settings.requestNewsTime = LocalTime(hour, minute).toSecondOfDay()
}
}
@@ -28,6 +28,7 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -49,14 +50,17 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.hippo.ehviewer.R
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.EhFilter
import com.hippo.ehviewer.client.EhFilter.forget
import com.hippo.ehviewer.client.EhFilter.remember
import com.hippo.ehviewer.client.EhFilter.trigger
import com.hippo.ehviewer.collectAsState
import com.hippo.ehviewer.dao.Filter
import com.hippo.ehviewer.dao.FilterMode
import com.hippo.ehviewer.ui.tools.Deferred
import com.hippo.ehviewer.ui.tools.LocalDialogState
import com.hippo.ehviewer.ui.tools.thenIf
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@@ -74,6 +78,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
val dialogState = LocalDialogState.current
val textIsEmpty = stringResource(R.string.text_is_empty)
val labelExist = stringResource(R.string.label_text_exist)
val animateItems by Settings.animateItems.collectAsState()
fun addFilter() {
scope.launch {
@@ -122,7 +127,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
onExpandedChange = { expanded = !expanded },
) {
OutlinedTextField(
modifier = Modifier.menuAnchor(),
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable),
readOnly = true,
value = type,
onValueChange = {},
@@ -224,10 +229,10 @@ fun FilterScreen(navigator: DestinationsNavigator) {
FilterMode.COMMENT -> R.string.filter_comment
}
if (filters.isNotEmpty()) {
stickyHeader(key = filterMode) {
item(key = filterMode) {
Text(
text = stringResource(id = title),
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp).animateItem(),
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp).thenIf(animateItems) { animateItem() },
color = MaterialTheme.colorScheme.tertiary,
style = MaterialTheme.typography.titleMedium,
)
@@ -235,7 +240,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
items(filters, key = { requireNotNull(it.id) }) { filter ->
val filterCheckBoxRecomposeScope = currentRecomposeScope
Row(
modifier = Modifier.fillMaxWidth().animateItem().clickable { filter.trigger { filterCheckBoxRecomposeScope.invalidate() } },
modifier = Modifier.fillMaxWidth().thenIf(animateItems) { animateItem() }.clickable { filter.trigger { filterCheckBoxRecomposeScope.invalidate() } },
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
@@ -16,7 +16,6 @@ import com.google.accompanist.web.WebView
import com.google.accompanist.web.rememberWebViewState
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.ui.LockDrawer
import com.hippo.ehviewer.util.setDefaultSettings
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
@@ -26,7 +25,6 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
@Composable
fun MyTagsScreen(navigator: DestinationsNavigator) {
val url = EhUrl.myTagsUrl
LockDrawer(true)
Scaffold(
topBar = {
TopAppBar(
@@ -25,7 +25,6 @@ import com.google.accompanist.web.rememberWebViewState
import com.hippo.ehviewer.R
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.EhUrl
import com.hippo.ehviewer.ui.LockDrawer
import com.hippo.ehviewer.util.setDefaultSettings
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
@@ -36,7 +35,6 @@ private const val APPLY_JS = "javascript:(function(){var apply = document.getEle
@Destination<RootGraph>
@Composable
fun UConfigScreen(navigator: DestinationsNavigator) {
LockDrawer(true)
val url = EhUrl.uConfigUrl
val webview = remember { Atomic<WebView?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
@@ -0,0 +1,33 @@
package com.hippo.ehviewer.ui.tools
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree
@Composable
fun Modifier.autofill(
autofillTypes: List<AutofillType>,
onFill: ((String) -> Unit),
): Modifier {
val autofill = LocalAutofill.current
val autofillNode = AutofillNode(onFill = onFill, autofillTypes = autofillTypes)
LocalAutofillTree.current += autofillNode
return onGloballyPositioned {
autofillNode.boundingBox = it.boundsInWindow()
}.onFocusChanged { focusState ->
autofill?.run {
if (focusState.isFocused) {
requestAutofillForNode(autofillNode)
} else {
cancelAutofillForNode(autofillNode)
}
}
}
}
@@ -6,14 +6,11 @@ import androidx.compose.foundation.gestures.draggable2D
import androidx.compose.foundation.gestures.rememberDraggable2DState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.Layout
import com.hippo.ehviewer.Settings
import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.cos
@@ -38,7 +35,7 @@ fun CircularLayout(
content: @Composable () -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
var delta by remember { mutableFloatStateOf(Settings.favDialogTheta) }
var delta by rememberMutableStateInDataStore("FavDialogTheta") { 0F }
val state = rememberDraggable2DState {
val prevOfs = currentOfs
val prevTheta = currentTheta
@@ -72,12 +69,10 @@ fun CircularLayout(
animateDecay(delta, omega, rotateDecay) { d, _ ->
delta = d
}
Settings.favDialogTheta = delta
}
}
currentOfs = null
currentTheta = null
Settings.favDialogTheta = delta
},
),
content = content,
@@ -1,6 +1,6 @@
package com.hippo.ehviewer.ui.tools
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.compositionLocalOf
import androidx.window.core.layout.WindowSizeClass
val LocalWindowSizeClass = compositionLocalOf<WindowSizeClass> { error("CompositionLocal LocalWindowSizeClass not present!") }
@@ -35,7 +35,7 @@ import androidx.compose.material3.DisplayMode
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
@@ -50,7 +50,6 @@ import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -69,6 +68,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import arrow.core.Either
import arrow.core.right
import com.jamal.composeprefs3.ui.ifNotNullThen
@@ -100,15 +100,10 @@ class DialogState {
content = null
}
suspend inline fun <R> dialog(crossinline block: @Composable (CancellableContinuation<R>) -> Unit) = suspendCancellableCoroutine { cont ->
cont.invokeOnCancellation { dismiss() }
val realContinuation = object : CancellableContinuation<R> by cont {
override fun resumeWith(result: Result<R>) {
dismiss()
cont.resumeWith(result)
}
}
content = { block(realContinuation) }
suspend inline fun <R> dialog(crossinline block: @Composable (CancellableContinuation<R>) -> Unit) = try {
suspendCancellableCoroutine { cont -> content = { block(cont) } }
} finally {
dismiss()
}
suspend fun <R> awaitResult(
@@ -280,6 +275,7 @@ class DialogState {
confirmButtonEnabled: Boolean = true,
showCancelButton: Boolean = true,
onCancelButtonClick: () -> Unit = {},
secure: Boolean = false,
text: @Composable (() -> Unit)? = null,
) {
return dialog { cont ->
@@ -300,11 +296,16 @@ class DialogState {
},
title = title.ifNotNullThen { Text(text = stringResource(id = title!!)) },
text = text,
properties = if (secure) {
DialogProperties(securePolicy = SecureFlagPolicy.SecureOn)
} else {
DialogProperties()
},
)
}
}
suspend fun showDatePicker(
suspend fun awaitSelectDate(
@StringRes title: Int,
initialSelectedDateMillis: Long? = null,
initialDisplayedMonthMillis: Long? = initialSelectedDateMillis,
@@ -371,7 +372,7 @@ class DialogState {
}
}
suspend fun showTimePicker(
suspend fun awaitSelectTime(
title: String,
initialHour: Int,
initialMinute: Int,
@@ -413,7 +414,7 @@ class DialogState {
}
}
suspend fun showSingleChoice(
suspend fun awaitSingleChoice(
items: List<String>,
selected: Int,
@StringRes title: Int? = null,
@@ -439,15 +440,17 @@ class DialogState {
}
}
suspend fun showSelectItem(
suspend fun awaitSelectItem(
items: List<String>,
@StringRes title: Int? = null,
selected: Int = -1,
respectDefaultWidth: Boolean = true,
) = showSelectItem(items, title?.right(), respectDefaultWidth)
) = awaitSelectItem(items, title?.right(), selected, respectDefaultWidth)
suspend fun showSelectItem(
suspend fun awaitSelectItem(
items: List<String>,
title: Either<String, Int>?,
selected: Int = -1,
respectDefaultWidth: Boolean = true,
): Int = showNoButton(respectDefaultWidth) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
@@ -460,29 +463,27 @@ class DialogState {
}
LazyColumn {
itemsIndexed(items) { index, text ->
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.tertiary) {
Text(
text = text,
modifier = Modifier.clickable { dismissWith(index) }.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
style = MaterialTheme.typography.titleMedium,
)
}
CheckableItem(
text = text,
checked = index == selected,
modifier = Modifier.fillMaxWidth().clickable { dismissWith(index) }.padding(horizontal = 8.dp),
)
}
}
}
}
suspend inline fun showSelectActions(
suspend inline fun awaitSelectAction(
@StringRes title: Int? = null,
selected: Int = -1,
builder: ActionScope.() -> Unit,
) {
): suspend () -> Unit {
val (items, actions) = buildList { builder(ActionScope { action, that -> add(action to that) }) }.unzip()
val selected = showSelectItem(items, title)
actions[selected].invoke()
val index = awaitSelectItem(items, title, selected)
return actions[index]
}
suspend fun showSelectItemWithCheckBox(
suspend fun awaitSelectItemWithCheckBox(
items: List<String>,
@StringRes title: Int,
@StringRes checkBoxText: Int,
@@ -490,8 +491,6 @@ class DialogState {
initialChecked: Boolean = false,
): Pair<Int, Boolean> = showNoButton {
var checked by remember { mutableStateOf(initialChecked) }
val textStyle = MaterialTheme.typography.titleMedium
val selectedColor = MaterialTheme.colorScheme.tertiary
Column(modifier = Modifier.padding(vertical = 8.dp)) {
Text(
text = stringResource(id = title),
@@ -500,19 +499,10 @@ class DialogState {
)
LazyColumn {
itemsIndexed(items) { index, text ->
ListItem(
headlineContent = {
Text(
text = text,
style = if (index == selected) textStyle.copy(color = selectedColor) else textStyle,
)
},
modifier = Modifier.clickable { dismissWith(index to checked) }.fillMaxWidth(),
trailingContent = {
if (index == selected) {
Icon(imageVector = Icons.Default.Check, contentDescription = null, tint = selectedColor)
}
},
CheckableItem(
text = text,
checked = index == selected,
modifier = Modifier.fillMaxWidth().clickable { dismissWith(index to checked) }.padding(horizontal = 8.dp),
)
}
}
@@ -525,7 +515,7 @@ class DialogState {
}
}
suspend fun showSelectItemWithIcon(
suspend fun awaitSelectItemWithIcon(
items: List<Pair<ImageVector, Int>>,
title: String,
): Int = showNoButton {
@@ -538,16 +528,17 @@ class DialogState {
headlineContent = {
Text(text = stringResource(id = text), style = MaterialTheme.typography.titleMedium)
},
modifier = Modifier.clickable { dismissWith(index) },
modifier = Modifier.clickable { dismissWith(index) }.padding(horizontal = 8.dp),
leadingContent = {
Icon(imageVector = icon, contentDescription = null, tint = AlertDialogDefaults.iconContentColor)
},
colors = ListItemDefaults.colors(containerColor = Color.Unspecified),
)
}
}
}
suspend fun showSelectItemWithIconAndTextField(
suspend fun awaitSelectItemWithIconAndTextField(
items: List<Pair<ImageVector, String>>,
@StringRes title: Int,
@StringRes hint: Int,
@@ -606,6 +597,25 @@ class DialogState {
}
}
@Composable
private fun CheckableItem(text: String, checked: Boolean, modifier: Modifier = Modifier) {
val textStyle = MaterialTheme.typography.titleMedium
val checkedColor = MaterialTheme.colorScheme.primary
ListItem(
headlineContent = {
Text(
text = text,
style = if (checked) textStyle.copy(color = checkedColor) else textStyle,
)
},
modifier = modifier,
trailingContent = checked.ifTrueThen {
Icon(imageVector = Icons.Default.Check, contentDescription = null, tint = checkedColor)
},
colors = ListItemDefaults.colors(containerColor = Color.Unspecified),
)
}
private val IconWithTextCorner = RoundedCornerShape(8.dp)
private val DatePickerTitlePadding = PaddingValues(start = 24.dp, end = 12.dp, top = 16.dp)
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff. Show more