From 2d7e8e7f372ccfcbc915caf07e5631ad1d521d4e Mon Sep 17 00:00:00 2001 From: Carlos <2092019+CarlosEsco@users.noreply.github.com> Date: Thu, 10 Jun 2021 20:03:51 -0400 Subject: [PATCH] Merge in J2K newest changes into Neko (#511) --- app/build.gradle.kts | 47 +- app/src/main/AndroidManifest.xml | 19 +- app/src/main/java/eu/kanade/tachiyomi/App.kt | 7 +- .../java/eu/kanade/tachiyomi/AppModule.kt | 8 +- .../java/eu/kanade/tachiyomi/Migrations.kt | 25 +- .../data/backup/AbstractBackupManager.kt | 95 ++ .../backup/AbstractBackupRestoreValidator.kt | 16 + .../tachiyomi/data/backup/BackupConst.kt | 9 +- .../data/backup/BackupCreateService.kt | 15 +- .../tachiyomi/data/backup/BackupCreatorJob.kt | 11 +- .../data/backup/BackupRestoreService.kt | 5 +- .../tachiyomi/data/backup/RestoreHelper.kt | 17 +- .../backup/full/FullBackupRestoreValidator.kt | 47 + .../tachiyomi/data/backup/full/FullRestore.kt | 6 +- .../data/backup/full/models/BackupChapter.kt | 7 +- .../data/backup/full/models/BackupTracking.kt | 4 + .../data/backup/legacy/LegacyBackupManager.kt | 32 +- .../legacy/LegacyBackupRestoreValidator.kt | 66 + .../data/backup/legacy/LegacyRestore.kt | 9 +- .../data/backup/{ => legacy}/models/Backup.kt | 5 +- .../backup/{ => legacy}/models/DHistory.kt | 2 +- .../serializer/CategoryTypeAdapter.kt | 2 +- .../serializer/ChapterTypeAdapter.kt | 2 +- .../serializer/HistoryTypeAdapter.kt | 4 +- .../serializer/MangaTypeAdapter.kt | 7 +- .../serializer/TrackTypeAdapter.kt | 2 +- .../tachiyomi/data/cache/ChapterCache.kt | 73 +- .../kanade/tachiyomi/data/cache/CoverCache.kt | 63 +- .../tachiyomi/data/database/DatabaseHelper.kt | 2 + .../tachiyomi/data/database/DbOpenCallback.kt | 6 +- .../database/mappers/CategoryTypeMapping.kt | 4 +- .../data/database/mappers/MangaTypeMapping.kt | 1 - .../data/database/mappers/TrackTypeMapping.kt | 6 + .../data/database/models/CachedManga.kt | 2 +- .../data/database/models/Category.kt | 84 +- .../tachiyomi/data/database/models/Chapter.kt | 1 - .../data/database/models/ChapterImpl.kt | 2 +- .../tachiyomi/data/database/models/Manga.kt | 47 +- .../data/database/models/MangaImpl.kt | 9 +- .../database/queries/CachedMangaQueries.kt | 14 +- .../data/database/queries/ChapterQueries.kt | 30 +- .../data/database/queries/HistoryQueries.kt | 64 +- .../data/database/queries/MangaQueries.kt | 33 +- .../data/database/queries/RawQueries.kt | 189 ++- .../resolvers/HistoryLastReadPutResolver.kt | 21 +- .../MangaChapterHistoryGetResolver.kt | 8 +- .../resolvers/MangaInfoPutResolver.kt | 14 +- .../data/database/tables/CachedMangaTable.kt | 3 +- .../data/database/tables/MangaTable.kt | 1 - .../data/database/tables/SimilarTable.kt | 1 - .../data/database/tables/TrackTable.kt | 12 + .../tachiyomi/data/download/DownloadCache.kt | 10 +- .../data/download/DownloadManager.kt | 14 +- .../data/download/DownloadNotifier.kt | 37 +- .../data/download/DownloadProvider.kt | 10 +- .../data/download/DownloadService.kt | 5 +- .../tachiyomi/data/download/Downloader.kt | 175 +-- .../tachiyomi/data/download/model/Download.kt | 28 +- .../data/download/model/DownloadQueue.kt | 58 +- .../data/image/coil/CoverViewTarget.kt | 9 +- .../image/coil/LibraryMangaImageTarget.kt | 28 +- .../tachiyomi/data/image/coil/MangaFetcher.kt | 87 +- .../data/library/CustomMangaManager.kt | 21 +- .../data/library/LibraryUpdateJob.kt | 11 +- .../data/library/LibraryUpdateNotifier.kt | 66 +- .../data/library/LibraryUpdateRanker.kt | 4 +- .../data/library/LibraryUpdateService.kt | 41 +- .../data/notification/NotificationReceiver.kt | 200 ++- .../data/notification/Notifications.kt | 23 +- .../DelayedLibrarySuggestionsJob.kt | 42 + .../data/preference/PreferenceKeys.kt | 89 +- .../data/preference/PreferencesHelper.kt | 170 ++- .../data/similar/MangaCacheUpdateJob.kt | 3 +- .../data/similar/MangaCacheUpdateService.kt | 15 +- .../tachiyomi/data/track/TrackService.kt | 47 +- .../tachiyomi/data/track/anilist/Anilist.kt | 25 +- .../data/track/anilist/AnilistApi.kt | 77 +- .../tachiyomi/data/track/kitsu/Kitsu.kt | 10 +- .../tachiyomi/data/track/kitsu/KitsuApi.kt | 29 +- .../tachiyomi/data/track/kitsu/KitsuModels.kt | 2 +- .../tachiyomi/data/track/mdlist/MdList.kt | 6 +- .../data/track/myanimelist/MyAnimeList.kt | 10 +- .../data/track/myanimelist/MyAnimeListApi.kt | 19 +- .../kanade/tachiyomi/data/updater/Release.kt | 2 + .../tachiyomi/data/updater/UpdaterJob.kt | 45 +- .../tachiyomi/data/updater/UpdaterNotifier.kt | 95 +- .../tachiyomi/data/updater/UpdaterService.kt | 57 +- .../data/updater/github/GithubRelease.kt | 1 + .../tachiyomi/network/AndroidCookieJar.kt | 4 +- .../network/CloudflareInterceptor.kt | 36 +- .../kanade/tachiyomi/network/DohProviders.kt | 40 + .../kanade/tachiyomi/network/NetworkHelper.kt | 24 +- .../tachiyomi/network/OkHttpExtensions.kt | 54 +- .../eu/kanade/tachiyomi/network/Requests.kt | 3 - .../tachiyomi/network/TokenAuthenticator.kt | 2 +- .../eu/kanade/tachiyomi/source/model/Page.kt | 20 +- .../kanade/tachiyomi/source/model/SChapter.kt | 33 + .../tachiyomi/source/model/SChapterImpl.kt | 2 +- .../kanade/tachiyomi/source/model/SManga.kt | 80 +- .../tachiyomi/source/online/MangaDex.kt | 17 +- .../tachiyomi/source/online/MangaDexCache.kt | 24 +- .../source/online/MangaDexLoginHelper.kt | 4 +- .../tachiyomi/source/online/MergeSource.kt | 1 - .../source/online/handlers/ApiMangaParser.kt | 14 +- .../source/online/handlers/FilterHandler.kt | 4 +- .../source/online/handlers/FollowsHandler.kt | 3 - .../source/online/handlers/MangaHandler.kt | 3 - .../source/online/handlers/PopularHandler.kt | 1 - .../source/online/handlers/SearchHandler.kt | 2 - .../online/handlers/serializers/Auth.kt | 2 +- .../serializers/CacheApiMangaSerializer.kt | 59 +- .../handlers/serializers/ChapterSerializer.kt | 2 +- .../handlers/serializers/MangaSerializer.kt | 23 +- .../handlers/serializers/SimilarSerializer.kt | 20 +- .../tachiyomi/source/online/utils/MdUtil.kt | 9 - .../kanade/tachiyomi/ui/base/BaseToolbar.kt | 76 ++ .../tachiyomi/ui/base/CenteredToolbar.kt | 58 +- .../tachiyomi/ui/base/FloatingToolbar.kt | 81 ++ .../tachiyomi/ui/base/MaterialFastScroll.kt | 19 +- .../tachiyomi/ui/base/MaterialMenuSheet.kt | 182 ++- .../ui/base/MaterialMenuSheetItem.kt | 50 + .../tachiyomi/ui/base/MaxHeightScrollView.kt | 5 +- .../tachiyomi/ui/base/MiniSearchView.kt | 51 + .../ui/base/activity/BaseActivity.kt | 14 +- .../ui/base/activity/BaseThemedActivity.kt | 7 +- .../ui/base/controller/BaseController.kt | 149 ++- .../controller/BaseCoroutineController.kt | 21 + .../ui/base/controller/DialogController.kt | 4 +- .../ui/base/controller/NucleusController.kt | 5 +- .../controller/OneWayFadeChangeHandler.kt | 50 + .../ui/base/controller/RxController.kt | 11 +- .../ui/base/holder/BaseFlexibleViewHolder.kt | 7 +- .../ui/base/holder/BaseViewHolder.kt | 7 +- .../base/presenter/BaseCoroutinePresenter.kt | 18 + .../ui/category/CategoryController.kt | 43 +- .../tachiyomi/ui/category/CategoryHolder.kt | 80 +- .../ui/category/CategoryPresenter.kt | 5 +- .../ui/category/ManageCategoryDialog.kt | 162 ++- .../category/addtolibrary/AddCategoryItem.kt | 37 + .../addtolibrary/SetCategoriesSheet.kt | 207 ++++ .../tachiyomi/ui/download/DownloadAdapter.kt | 3 +- .../ui/download/DownloadBottomPresenter.kt | 4 + .../ui/download/DownloadBottomSheet.kt | 86 +- .../tachiyomi/ui/download/DownloadButton.kt | 161 ++- .../tachiyomi/ui/download/DownloadHolder.kt | 50 +- .../ui/extension/RecyclerViewPagerAdapter.kt | 34 + .../tachiyomi/ui/follows/FollowsController.kt | 15 +- .../library/AddToLibraryCategoriesDialog.kt | 67 - .../ui/library/ChangeMangaCategoriesDialog.kt | 53 - .../ui/library/DisplayBottomSheet.kt | 156 --- .../tachiyomi/ui/library/LibraryBadge.kt | 103 +- .../ui/library/LibraryCategoryAdapter.kt | 93 +- .../tachiyomi/ui/library/LibraryController.kt | 992 +++++++++------ .../ui/library/LibraryGestureDetector.kt | 118 +- .../tachiyomi/ui/library/LibraryGridHolder.kt | 72 +- .../ui/library/LibraryHeaderHolder.kt | 246 ++-- .../tachiyomi/ui/library/LibraryHeaderItem.kt | 2 +- .../tachiyomi/ui/library/LibraryHolder.kt | 26 +- .../tachiyomi/ui/library/LibraryItem.kt | 73 +- .../tachiyomi/ui/library/LibraryListHolder.kt | 89 +- .../tachiyomi/ui/library/LibraryPresenter.kt | 422 +++++-- .../tachiyomi/ui/library/LibrarySort.kt | 68 +- .../library/category/CategoryRecyclerView.kt | 4 +- .../ui/library/display/LibraryBadgesView.kt | 22 + .../ui/library/display/LibraryCategoryView.kt | 50 + .../ui/library/display/LibraryDisplayView.kt | 148 +++ .../display/TabbedLibraryDisplaySheet.kt | 60 + .../ui/library/filter/FilterBottomSheet.kt | 304 ++--- .../ui/library/filter/FilterTagGroup.kt | 81 +- .../ui/library/filter/ManageFilterItem.kt | 27 +- .../ui/main/ChangelogDialogController.kt | 32 - .../kanade/tachiyomi/ui/main/MainActivity.kt | 665 +++++++--- .../tachiyomi/ui/main/OverflowDialog.kt | 96 ++ .../tachiyomi/ui/main/SearchActivity.kt | 76 +- .../tachiyomi/ui/manga/MangaDetailsAdapter.kt | 10 +- .../ui/manga/MangaDetailsController.kt | 872 +++++++------ .../ui/manga/MangaDetailsPresenter.kt | 250 ++-- .../tachiyomi/ui/manga/MangaHeaderHolder.kt | 367 +++--- .../tachiyomi/ui/manga/MangaHeaderItem.kt | 11 +- .../ui/manga/chapter/BaseChapterHolder.kt | 23 +- .../ui/manga/chapter/BaseChapterItem.kt | 6 +- .../ui/manga/chapter/ChapterFilterLayout.kt | 59 +- .../ui/manga/chapter/ChapterHolder.kt | 89 +- .../manga/chapter/ChaptersSortBottomSheet.kt | 121 +- .../ui/manga/external/ExternalBottomSheet.kt | 53 +- .../ui/manga/merge/MergeSearchAdapter.kt | 11 +- .../ui/manga/merge/MergeSearchDialog.kt | 50 +- .../manga/track/SetTrackReadingDatesDialog.kt | 2 +- .../ui/manga/track/SetTrackStatusDialog.kt | 3 +- .../tachiyomi/ui/manga/track/TrackHolder.kt | 87 +- .../ui/manga/track/TrackRemoveDialog.kt | 9 +- .../ui/manga/track/TrackSearchAdapter.kt | 40 +- .../ui/manga/track/TrackSearchDialog.kt | 39 +- .../ui/manga/track/TrackingBottomSheet.kt | 133 +- .../ui/reader/PageIndicatorTextView.kt | 3 +- .../tachiyomi/ui/reader/ReaderActivity.kt | 1082 ++++++++++++----- .../ui/reader/ReaderColorFilterSheet.kt | 314 ----- .../ui/reader/ReaderNavGestureDetector.kt | 69 ++ .../ui/reader/ReaderNavigationOverlayView.kt | 130 ++ .../tachiyomi/ui/reader/ReaderPresenter.kt | 327 +++-- .../ui/reader/ReaderSettingsSheet.kt | 190 --- .../tachiyomi/ui/reader/SaveImageNotifier.kt | 4 +- .../ui/reader/chapter/ReaderChapterItem.kt | 61 +- .../ui/reader/chapter/ReaderChapterSheet.kt | 162 ++- .../ui/reader/loader/ChapterLoader.kt | 7 +- .../ui/reader/loader/DownloadPageLoader.kt | 4 +- .../ui/reader/loader/HttpPageLoader.kt | 62 +- .../ui/reader/model/ChapterTransition.kt | 5 +- .../tachiyomi/ui/reader/model/ReaderPage.kt | 13 +- .../ui/reader/settings/OrientationType.kt | 20 + .../ui/reader/settings/PageLayout.kt | 18 + .../ui/reader/settings/ReaderBottomButton.kt | 27 + .../ui/reader/settings/ReaderFilterView.kt | 294 +++++ .../ui/reader/settings/ReaderGeneralView.kt | 53 + .../ui/reader/settings/ReaderPagedView.kt | 100 ++ .../ui/reader/settings/ReadingModeType.kt | 30 + .../settings/TabbedReaderSettingsSheet.kt | 137 +++ .../tachiyomi/ui/reader/viewer/BaseViewer.kt | 12 +- .../ui/reader/viewer/ReaderProgressBar.kt | 29 +- .../ui/reader/viewer/ViewerConfig.kt | 22 +- .../ui/reader/viewer/ViewerNavigation.kt | 71 ++ .../viewer/navigation/EdgeNavigation.kt | 32 + .../viewer/navigation/KindlishNavigation.kt | 28 + .../reader/viewer/navigation/LNavigation.kt | 36 + .../navigation/RightAndLeftNavigation.kt | 28 + .../ui/reader/viewer/pager/PagerConfig.kt | 123 +- .../ui/reader/viewer/pager/PagerPageHolder.kt | 370 +++++- .../viewer/pager/PagerTransitionHolder.kt | 6 +- .../ui/reader/viewer/pager/PagerViewer.kt | 164 ++- .../reader/viewer/pager/PagerViewerAdapter.kt | 209 +++- .../reader/viewer/webtoon/WebtoonAdapter.kt | 4 +- .../viewer/webtoon/WebtoonBaseHolder.kt | 4 +- .../ui/reader/viewer/webtoon/WebtoonConfig.kt | 69 +- .../viewer/webtoon/WebtoonLayoutManager.kt | 5 +- .../viewer/webtoon/WebtoonPageHolder.kt | 87 +- .../viewer/webtoon/WebtoonRecyclerView.kt | 24 +- .../viewer/webtoon/WebtoonTransitionHolder.kt | 10 +- .../ui/reader/viewer/webtoon/WebtoonViewer.kt | 119 +- .../ui/recent_updates/RecentChapterHolder.kt | 110 -- .../ui/recent_updates/RecentChapterItem.kt | 36 - .../recent_updates/RecentChaptersAdapter.kt | 42 - .../RecentChaptersController.kt | 275 ----- .../recent_updates/RecentChaptersPresenter.kt | 184 --- .../ui/recently_read/RecentlyReadAdapter.kt | 55 - .../recently_read/RecentlyReadController.kt | 253 ---- .../ui/recently_read/RecentlyReadHolder.kt | 66 - .../ui/recently_read/RecentlyReadItem.kt | 41 - .../ui/recently_read/RecentlyReadPresenter.kt | 136 --- .../{recent_updates => recents}/DateItem.kt | 16 +- .../ui/recents/RecentMangaAdapter.kt | 53 +- .../ui/recents/RecentMangaFooterHolder.kt | 29 + .../ui/recents/RecentMangaHeaderItem.kt | 21 +- .../tachiyomi/ui/recents/RecentMangaHolder.kt | 160 ++- .../tachiyomi/ui/recents/RecentMangaItem.kt | 16 +- .../tachiyomi/ui/recents/RecentsController.kt | 625 +++++++--- .../tachiyomi/ui/recents/RecentsPresenter.kt | 458 ++++--- .../RemoveHistoryDialog.kt | 8 +- .../ui/recents/options/RecentsGeneralView.kt | 24 + .../ui/recents/options/RecentsHistoryView.kt | 16 + .../ui/recents/options/RecentsUpdatesView.kt | 17 + .../options/TabbedRecentsOptionsSheet.kt | 43 + .../ui/security/BiometricActivity.kt | 6 +- .../tachiyomi/ui/setting/AboutController.kt | 119 +- .../tachiyomi/ui/setting/PreferenceDSL.kt | 49 +- .../ui/setting/SettingsAdvancedController.kt | 103 +- .../ui/setting/SettingsBackupController.kt | 237 ++-- .../ui/setting/SettingsController.kt | 47 +- .../ui/setting/SettingsDownloadController.kt | 37 +- .../ui/setting/SettingsGeneralController.kt | 186 +-- .../ui/setting/SettingsLibraryController.kt | 65 +- .../ui/setting/SettingsMainController.kt | 61 +- .../ui/setting/SettingsReaderController.kt | 249 +++- .../ui/setting/SettingsSecurityController.kt | 62 + .../ui/setting/SettingsSiteController.kt | 20 +- .../ui/setting/SettingsTrackingController.kt | 4 +- .../tachiyomi/ui/setting/ThemePreference.kt | 179 +++ .../setting/search/SettingsSearchAdapter.kt | 84 ++ .../search/SettingsSearchController.kt | 172 +++ .../ui/setting/search/SettingsSearchHelper.kt | 139 +++ .../ui/setting/search/SettingsSearchHolder.kt | 45 + .../ui/setting/search/SettingsSearchItem.kt | 58 + .../setting/search/SettingsSearchPresenter.kt | 32 + .../tachiyomi/ui/similar/SimilarController.kt | 46 +- .../tachiyomi/ui/similar/SimilarPresenter.kt | 11 +- .../source/browse/BrowseSourceController.kt | 398 ++---- .../source/browse/BrowseSourceGridHolder.kt | 28 +- .../ui/source/browse/BrowseSourceItem.kt | 42 +- .../source/browse/BrowseSourceListHolder.kt | 22 +- .../ui/source/browse/BrowseSourcePager.kt | 5 +- .../ui/source/browse/BrowseSourcePresenter.kt | 160 +-- .../ui/source/browse/SourceFilterSheet.kt | 167 +++ .../ui/source/browse/SourceSearchSheet.kt | 147 --- .../tachiyomi/ui/source/filter/GroupItem.kt | 13 +- .../tachiyomi/ui/source/filter/SelectItem.kt | 26 +- .../tachiyomi/ui/source/filter/SortGroup.kt | 13 +- .../tachiyomi/ui/source/filter/TextItem.kt | 10 +- .../ui/source/filter/TriStateItem.kt | 5 +- .../ui/webview/BaseWebViewActivity.kt | 135 +- .../tachiyomi/ui/webview/WebViewActivity.kt | 42 +- .../eu/kanade/tachiyomi/util/CrashLogUtil.kt | 56 + .../kanade/tachiyomi/util/MangaExtensions.kt | 162 +++ .../tachiyomi/util/PreferenceExtensions.kt | 84 ++ .../util/chapter/ChapterRecognition.kt | 27 +- .../util/chapter/ChapterSourceSync.kt | 11 +- .../tachiyomi/util/chapter/ChapterUtil.kt | 72 +- .../util/chapter/NoChaptersException.kt | 3 + .../tachiyomi/util/lang/EnumExtensions.kt | 7 + .../tachiyomi/util/lang/RxCoroutineBridge.kt | 2 +- .../tachiyomi/util/lang/StringExtensions.kt | 110 +- .../util/manga/MangaShortcutManager.kt | 134 ++ .../kanade/tachiyomi/util/storage/DiskUtil.kt | 15 +- .../kanade/tachiyomi/util/storage/EpubFile.kt | 141 ++- .../tachiyomi/util/storage/FileExtensions.kt | 4 +- .../util/system/BooleanExtensions.kt | 3 + .../util/system/ContextExtensions.kt | 10 +- .../util/system/CoroutinesExtensions.kt | 8 + .../tachiyomi/util/system/CrashLogUtil.kt | 2 +- .../tachiyomi/util/system/DateExtensions.kt | 11 + .../kanade/tachiyomi/util/system/ImageUtil.kt | 134 +- .../util/system/NotificationExtensions.kt | 1 - .../util/system/RxCoroutineBridge.kt | 83 ++ .../eu/kanade/tachiyomi/util/system/RxUtil.kt | 3 +- .../kanade/tachiyomi/util/system/ThemeUtil.kt | 67 +- .../eu/kanade/tachiyomi/util/system/Themes.kt | 233 ++++ .../util/system/WebViewClientCompat.kt | 11 +- .../tachiyomi/util/system/WebViewUtil.kt | 62 +- .../util/system/WindowInsetsExtensions.kt | 4 + .../util/view/ControllerExtensions.kt | 552 +++++++-- .../util/view/ImageViewExtensions.kt | 11 + .../util/view/MaterialDialogExtensions.kt | 10 +- .../tachiyomi/util/view/ViewExtensions.kt | 284 +++-- .../eu/kanade/tachiyomi/v5/db/V5DbQueries.kt | 1 - .../kanade/tachiyomi/v5/job/V5MigrationJob.kt | 3 +- .../tachiyomi/v5/job/V5MigrationNotifier.kt | 64 +- .../tachiyomi/v5/job/V5MigrationService.kt | 10 +- .../tachiyomi/widget/AutofitRecyclerView.kt | 39 +- .../tachiyomi/widget/BaseTabbedScrollView.kt | 48 + .../tachiyomi/widget/CustomLayoutPicker.kt | 8 +- .../tachiyomi/widget/DialogCheckboxView.kt | 29 - .../tachiyomi/widget/E2EBottomSheetDialog.kt | 71 ++ .../eu/kanade/tachiyomi/widget/EmptyView.kt | 83 +- .../kanade/tachiyomi/widget/GifViewTarget.kt | 11 +- .../tachiyomi/widget/MaterialSpinnerView.kt | 261 ++++ .../tachiyomi/widget/MenuSheetItemView.kt | 133 ++ .../tachiyomi/widget/NegativeSeekBar.kt | 26 +- .../tachiyomi/widget/RevealAnimationView.kt | 24 +- .../tachiyomi/widget/SimpleNavigationView.kt | 21 +- .../tachiyomi/widget/TabbedBottomSheet.kt | 120 ++ .../widget/preference/IntListMatPreference.kt | 21 +- .../widget/preference/ListMatPreference.kt | 8 +- .../preference/LoginDialogPreference.kt | 22 +- .../widget/preference/LoginPreference.kt | 16 +- .../widget/preference/MangadexLoginDialog.kt | 35 +- .../widget/preference/MangadexLogoutDialog.kt | 7 +- .../widget/preference/MatPreference.kt | 45 +- .../preference/MultiListMatPreference.kt | 77 +- .../widget/preference/SiteLoginPreference.kt | 25 +- .../preference/SwitchPreferenceCategory.kt | 5 +- .../widget/preference/TrackLoginDialog.kt | 20 +- app/src/main/res/anim/enter_from_bottom.xml | 8 - app/src/main/res/anim/exit_to_bottom.xml | 8 - .../main/res/anim/fade_in_grow_from_top.xml | 11 + app/src/main/res/anim/fade_out_short.xml | 6 + app/src/main/res/color/accent_alpha.xml | 4 + .../res/color/bottom_nav_item_selector.xml | 10 + .../main/res/color/mtrl_btn_bg_selector.xml | 3 +- .../primary_button_text_color_selector.xml | 2 +- app/src/main/res/color/secondary_alpha.xml | 4 + app/src/main/res/color/tabs_selector.xml | 11 + app/src/main/res/color/tabs_selector_alt.xml | 11 + .../res/color/tabs_selector_background.xml | 10 + .../main/res/color/tint_color_secondary.xml | 4 + .../anim_browse_filled_to_outline.xml | 55 + .../anim_browse_outline_to_filled.xml | 67 + .../main/res/drawable/anim_crop_to_free.xml | 45 + .../res/drawable/anim_dl_to_check_to_dl.xml | 125 ++ .../res/drawable/anim_expand_less_to_more.xml | 28 + .../res/drawable/anim_expand_more_to_less.xml | 28 + .../main/res/drawable/anim_free_to_crop.xml | 43 + .../main/res/drawable/anim_incog_to_read.xml | 46 + .../drawable/anim_lib_filled_to_outline.xml | 50 + .../drawable/anim_lib_outline_to_filled.xml | 50 + .../res/drawable/anim_outline_to_filled.xml | 29 + .../main/res/drawable/anim_read_to_incog.xml | 47 + .../anim_recents_filled_to_outline.xml | 92 ++ .../anim_recents_outline_to_filled.xml | 139 +++ .../res/drawable/bottom_nav_item_selector.xml | 10 - app/src/main/res/drawable/button_bg_error.xml | 26 - .../main/res/drawable/button_text_state.xml | 6 +- app/src/main/res/drawable/chapter_nav.xml | 6 +- app/src/main/res/drawable/filter_mock.webp | Bin 77344 -> 0 bytes app/src/main/res/drawable/ic_add_24dp.xml | 9 + .../main/res/drawable/ic_arrow_back_24dp.xml | 5 + app/src/main/res/drawable/ic_backup_24dp.xml | 9 + app/src/main/res/drawable/ic_blank_28dp.xml | 10 + app/src/main/res/drawable/ic_book_24dp.xml | 13 +- .../drawable/ic_book_open_variant_24dp.xml | 9 + .../main/res/drawable/ic_bookmark_24dp.xml | 10 +- .../res/drawable/ic_broken_image_24dp.xml | 10 + app/src/main/res/drawable/ic_browse_24dp.xml | 9 +- .../main/res/drawable/ic_browse_off_24dp.xml | 8 + .../res/drawable/ic_browse_selector_24dp.xml | 19 +- .../ic_calendar_text_outline_24dp.xml | 8 + .../res/drawable/ic_close_circle_24dp.xml | 8 + .../main/res/drawable/ic_eye_down_24dp.xml | 9 + .../res/drawable/ic_eye_off_down_24dp.xml | 9 + .../res/drawable/ic_eye_off_range_24dp.xml | 9 + .../main/res/drawable/ic_eye_off_up_24dp.xml | 15 + .../main/res/drawable/ic_eye_range_24dp.xml | 9 + .../drawable/ic_eye_remove_outline_24dp.xml | 8 + app/src/main/res/drawable/ic_eye_up_24dp.xml | 9 + app/src/main/res/drawable/ic_glasses_24dp.xml | 8 + .../main/res/drawable/ic_heart_off_24dp.xml | 8 + .../res/drawable/ic_help_outline_24dp.xml | 11 + .../main/res/drawable/ic_history_off_24dp.xml | 10 + .../main/res/drawable/ic_incognito_24dp.xml | 8 + .../res/drawable/ic_incognito_circle_24dp.xml | 8 + .../main/res/drawable/ic_language_24dp.xml | 10 + .../res/drawable/ic_libary_filled_24dp.xml | 9 + app/src/main/res/drawable/ic_library_24dp.xml | 5 - .../res/drawable/ic_library_selector_24dp.xml | 19 +- app/src/main/res/drawable/ic_lock_24dp.xml | 5 + .../main/res/drawable/ic_more_vert_24dp.xml | 3 +- .../main/res/drawable/ic_no_settings_24dp.xml | 9 - .../res/drawable/ic_outline_photo_24dp.xml | 10 + .../res/drawable/ic_outline_save_24dp.xml | 10 + .../res/drawable/ic_outline_settings_24dp.xml | 10 + .../res/drawable/ic_outline_share_24dp.xml | 10 + .../drawable/ic_page_next_outline_24dp.xml | 9 + .../ic_page_previous_outline_24dp.xml | 9 + app/src/main/res/drawable/ic_pin_24dp.xml | 1 + .../{ic_star_24dp.xml => ic_plus_24dp.xml} | 4 +- .../ic_reader_continuous_vertical_24dp.xml | 15 + .../res/drawable/ic_reader_default_24dp.xml | 12 + .../main/res/drawable/ic_reader_ltr_24dp.xml | 21 + .../main/res/drawable/ic_reader_rtl_24dp.xml | 21 + .../res/drawable/ic_reader_vertical_24dp.xml | 12 + .../res/drawable/ic_reader_webtoon_24dp.xml | 12 + .../drawable/ic_recent_read_selector_24dp.xml | 5 - .../res/drawable/ic_recents_filled_24dp.xml | 9 + .../res/drawable/ic_recents_outline_24dp.xml | 9 + .../res/drawable/ic_recents_selector_24dp.xml | 16 + .../res/drawable/ic_rounded_tooltip_24dp.xml | 9 + .../res/drawable/ic_save_all_outline_24dp.xml | 9 + .../ic_screen_lock_landscape_24dp.xml | 9 + .../drawable/ic_screen_lock_portrait_24dp.xml | 9 + .../res/drawable/ic_screen_rotation_24dp.xml | 9 + .../main/res/drawable/ic_search_off_24dp.xml | 13 + .../main/res/drawable/ic_security_24dp.xml | 9 + .../main/res/drawable/ic_select_all_24dp.xml | 11 + .../main/res/drawable/ic_select_none_24dp.xml | 11 + .../drawable/ic_share_all_outline_24dp.xml | 9 + .../main/res/drawable/ic_single_page_24dp.xml | 11 + app/src/main/res/drawable/ic_skip_next_24.xml | 10 + .../main/res/drawable/ic_skip_previous_24.xml | 10 + .../ic_stay_current_landscape_24dp.xml | 9 + .../ic_stay_current_portrait_24dp.xml | 9 + app/src/main/res/drawable/ic_update_24dp.xml | 10 - .../res/drawable/ic_view_comments_24p.xml | 8 + app/src/main/res/drawable/oval.xml | 6 + app/src/main/res/drawable/oval_ripple.xml | 6 +- .../res/drawable/reader_toolbar_ripple.xml | 5 + app/src/main/res/drawable/rect_ripple.xml | 9 + .../drawable/round_textview_background.xml | 9 - .../res/drawable/round_textview_border.xml | 13 +- .../res/drawable/rounded_preview_rect.xml | 6 + app/src/main/res/drawable/sc_glasses_48dp.xml | 19 - app/src/main/res/drawable/sc_update_48dp.xml | 19 - .../drawable/shape_gradient_start_shadow.xml | 7 + .../res/drawable/tab_highlight_indicator.xml | 12 + app/src/main/res/drawable/tab_indicator.xml | 8 + .../res/drawable/theme_selected_border.xml | 10 + app/src/main/res/drawable/thumb_drawable.xml | 2 +- .../main/res/drawable/unread_angled_badge.xml | 4 +- .../layout-land/reader_color_filter_sheet.xml | 49 - .../main/res/layout-sw600dp/main_activity.xml | 220 ++++ app/src/main/res/layout/add_category_item.xml | 12 + app/src/main/res/layout/bottom_menu_sheet.xml | 28 +- .../res/layout/browse_source_controller.xml | 15 +- app/src/main/res/layout/categories_item.xml | 33 +- .../res/layout/changelog_header_layout.xml | 25 - .../main/res/layout/changelog_row_layout.xml | 35 - .../main/res/layout/chapter_filter_layout.xml | 4 +- .../main/res/layout/chapter_header_item.xml | 59 + .../res/layout/chapter_sort_bottom_sheet.xml | 7 +- app/src/main/res/layout/chapters_item.xml | 9 +- .../layout/common_dialog_with_checkbox.xml | 21 - app/src/main/res/layout/common_view_empty.xml | 35 +- .../main/res/layout/display_bottom_sheet.xml | 238 ---- .../main/res/layout/download_bottom_sheet.xml | 12 +- app/src/main/res/layout/download_button.xml | 3 +- app/src/main/res/layout/download_item.xml | 14 +- .../main/res/layout/filter_bottom_sheet.xml | 30 +- ...ilter_buttons.xml => filter_tag_group.xml} | 0 app/src/main/res/layout/in_library_badge.xml | 12 + .../main/res/layout/library_badges_layout.xml | 56 + .../layout/library_category_header_item.xml | 4 +- .../res/layout/library_category_layout.xml | 66 + ..._controller.xml => library_controller.xml} | 65 +- .../res/layout/library_display_layout.xml | 123 ++ app/src/main/res/layout/main_activity.xml | 158 ++- .../main/res/layout/manga_category_dialog.xml | 25 +- .../res/layout/manga_details_controller.xml | 52 +- app/src/main/res/layout/manga_grid_item.xml | 3 +- app/src/main/res/layout/manga_header_item.xml | 6 +- app/src/main/res/layout/manga_list_item.xml | 23 +- .../main/res/layout/material_spinner_view.xml | 61 + .../main/res/layout/material_text_button.xml | 1 + app/src/main/res/layout/menu_sheet_item.xml | 25 +- .../main/res/layout/navigation_view_group.xml | 25 +- .../res/layout/navigation_view_spinner.xml | 22 +- .../main/res/layout/pref_account_login.xml | 37 +- app/src/main/res/layout/pref_item_source.xml | 61 - .../main/res/layout/pref_widget_imageview.xml | 10 +- app/src/main/res/layout/reader_activity.xml | 43 +- .../main/res/layout/reader_chapter_item.xml | 25 +- .../main/res/layout/reader_chapters_sheet.xml | 166 ++- .../main/res/layout/reader_color_filter.xml | 464 +++---- .../res/layout/reader_color_filter_sheet.xml | 41 - .../main/res/layout/reader_general_layout.xml | 98 ++ app/src/main/res/layout/reader_nav.xml | 64 +- .../main/res/layout/reader_paged_layout.xml | 150 +++ .../main/res/layout/reader_settings_sheet.xml | 328 ----- .../res/layout/recent_chapters_controller.xml | 35 - .../main/res/layout/recent_chapters_item.xml | 86 -- app/src/main/res/layout/recent_manga_item.xml | 70 +- .../res/layout/recently_read_controller.xml | 27 - .../main/res/layout/recently_read_item.xml | 75 -- .../main/res/layout/recents_controller.xml | 28 +- .../main/res/layout/recents_footer_item.xml | 2 +- .../main/res/layout/recents_general_view.xml | 51 + .../main/res/layout/recents_header_item.xml | 27 - .../main/res/layout/recents_history_view.xml | 24 + .../main/res/layout/recents_updates_view.xml | 34 + .../res/layout/rounded_category_hopper.xml | 14 +- .../main/res/layout/set_categories_sheet.xml | 131 ++ .../res/layout/settings_search_controller.xml | 36 + .../settings_search_controller_card.xml | 34 + .../main/res/layout/source_filter_sheet.xml | 148 ++- .../main/res/layout/tabbed_bottom_sheet.xml | 53 + .../main/res/layout/tachi_overflow_layout.xml | 106 ++ app/src/main/res/layout/theme_item.xml | 218 ++++ app/src/main/res/layout/themes_preference.xml | 35 + app/src/main/res/layout/tooltip_text_view.xml | 13 + .../main/res/layout/track_search_dialog.xml | 4 +- .../main/res/layout/tracking_bottom_sheet.xml | 4 +- .../main/res/layout/unread_download_badge.xml | 10 +- app/src/main/res/layout/webview_activity.xml | 39 +- app/src/main/res/menu/bottom_navigation.xml | 2 +- app/src/main/res/menu/browse_source.xml | 24 +- app/src/main/res/menu/chapter_single.xml | 2 + app/src/main/res/menu/download_queue.xml | 12 - app/src/main/res/menu/download_single.xml | 3 + app/src/main/res/menu/library.xml | 15 +- app/src/main/res/menu/library_selection.xml | 4 +- app/src/main/res/menu/manga_details.xml | 3 +- app/src/main/res/menu/manga_details_cover.xml | 18 + app/src/main/res/menu/reader.xml | 10 + app/src/main/res/menu/recently_read.xml | 10 - app/src/main/res/menu/recents.xml | 36 +- app/src/main/res/menu/settings_main.xml | 15 +- app/src/main/res/raw/changelog_release.xml | 794 ------------ app/src/main/res/values-night/colors.xml | 14 + app/src/main/res/values-night/themes.xml | 39 +- .../values-sw360dp-v13/values-preference.xml | 5 - .../dimens.xml => values-sw600dp/attrs.xml} | 2 +- app/src/main/res/values-sw600dp/dimens.xml | 4 - app/src/main/res/values-v27/styles.xml | 25 - app/src/main/res/values-w820dp/dimens.xml | 7 - app/src/main/res/values/arrays.xml | 70 +- app/src/main/res/values/attrs.xml | 39 +- app/src/main/res/values/colors.xml | 48 +- app/src/main/res/values/dimens.xml | 16 +- app/src/main/res/values/strings.xml | 495 ++++++-- app/src/main/res/values/strings_neko.xml | 166 --- app/src/main/res/values/styles.xml | 78 +- app/src/main/res/values/styles_neko.xml | 4 + app/src/main/res/values/themes.xml | 61 +- app/src/main/res/values/types_neko.xml | 4 + .../main/res/xml/network_security_config.xml | 8 + app/src/main/res/xml/searchable.xml | 2 +- .../test/java/eu/kanade/tachiyomi/TestApp.kt | 4 +- build.gradle.kts | 24 +- buildSrc/src/main/kotlin/Configs.kt | 12 +- buildSrc/src/main/kotlin/Dependencies.kt | 240 ++-- gradle/wrapper/gradle-wrapper.properties | 2 +- ktlintCodeStyle.xml | 131 ++ 587 files changed, 21906 insertions(+), 12016 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupManager.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupRestoreValidator.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestoreValidator.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestoreValidator.kt rename app/src/main/java/eu/kanade/tachiyomi/data/backup/{ => legacy}/models/Backup.kt (80%) rename app/src/main/java/eu/kanade/tachiyomi/data/backup/{ => legacy}/models/DHistory.kt (51%) rename app/src/main/java/eu/kanade/tachiyomi/data/backup/{ => legacy}/serializer/CategoryTypeAdapter.kt (92%) rename app/src/main/java/eu/kanade/tachiyomi/data/backup/{ => legacy}/serializer/ChapterTypeAdapter.kt (96%) rename app/src/main/java/eu/kanade/tachiyomi/data/backup/{ => legacy}/serializer/HistoryTypeAdapter.kt (85%) rename app/src/main/java/eu/kanade/tachiyomi/data/backup/{ => legacy}/serializer/MangaTypeAdapter.kt (85%) rename app/src/main/java/eu/kanade/tachiyomi/data/backup/{ => legacy}/serializer/TrackTypeAdapter.kt (96%) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/preference/DelayedLibrarySuggestionsJob.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/network/DohProviders.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/base/BaseToolbar.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/base/FloatingToolbar.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/base/MaterialMenuSheetItem.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/base/MiniSearchView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/BaseCoroutineController.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/OneWayFadeChangeHandler.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BaseCoroutinePresenter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/category/addtolibrary/AddCategoryItem.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/category/addtolibrary/SetCategoriesSheet.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/extension/RecyclerViewPagerAdapter.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/library/AddToLibraryCategoriesDialog.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCategoriesDialog.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/library/DisplayBottomSheet.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/library/display/LibraryBadgesView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/library/display/LibraryCategoryView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/library/display/LibraryDisplayView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/library/display/TabbedLibraryDisplaySheet.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogController.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/main/OverflowDialog.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderColorFilterSheet.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderNavGestureDetector.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderNavigationOverlayView.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsSheet.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/OrientationType.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/PageLayout.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderBottomButton.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderFilterView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderGeneralView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderPagedView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReadingModeType.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/TabbedReaderSettingsSheet.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ViewerNavigation.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/navigation/EdgeNavigation.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/navigation/KindlishNavigation.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/navigation/LNavigation.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/navigation/RightAndLeftNavigation.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterHolder.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterItem.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersAdapter.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersController.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadAdapter.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadController.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadHolder.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadItem.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadPresenter.kt rename app/src/main/java/eu/kanade/tachiyomi/ui/{recent_updates => recents}/DateItem.kt (81%) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaFooterHolder.kt rename app/src/main/java/eu/kanade/tachiyomi/ui/{recently_read => recents}/RemoveHistoryDialog.kt (91%) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/recents/options/RecentsGeneralView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/recents/options/RecentsHistoryView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/recents/options/RecentsUpdatesView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/recents/options/TabbedRecentsOptionsSheet.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSecurityController.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/setting/ThemePreference.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchAdapter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchController.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHelper.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHolder.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchItem.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchPresenter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/SourceFilterSheet.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/SourceSearchSheet.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/util/chapter/NoChaptersException.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/util/lang/EnumExtensions.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/util/manga/MangaShortcutManager.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/util/system/BooleanExtensions.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/util/system/RxCoroutineBridge.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/util/system/Themes.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/widget/BaseTabbedScrollView.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/widget/DialogCheckboxView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/widget/E2EBottomSheetDialog.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/widget/MaterialSpinnerView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/widget/MenuSheetItemView.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/widget/TabbedBottomSheet.kt delete mode 100644 app/src/main/res/anim/enter_from_bottom.xml delete mode 100644 app/src/main/res/anim/exit_to_bottom.xml create mode 100644 app/src/main/res/anim/fade_in_grow_from_top.xml create mode 100644 app/src/main/res/anim/fade_out_short.xml create mode 100644 app/src/main/res/color/accent_alpha.xml create mode 100644 app/src/main/res/color/bottom_nav_item_selector.xml create mode 100644 app/src/main/res/color/secondary_alpha.xml create mode 100644 app/src/main/res/color/tabs_selector.xml create mode 100644 app/src/main/res/color/tabs_selector_alt.xml create mode 100644 app/src/main/res/color/tabs_selector_background.xml create mode 100644 app/src/main/res/color/tint_color_secondary.xml create mode 100644 app/src/main/res/drawable/anim_browse_filled_to_outline.xml create mode 100644 app/src/main/res/drawable/anim_browse_outline_to_filled.xml create mode 100644 app/src/main/res/drawable/anim_crop_to_free.xml create mode 100644 app/src/main/res/drawable/anim_dl_to_check_to_dl.xml create mode 100644 app/src/main/res/drawable/anim_expand_less_to_more.xml create mode 100644 app/src/main/res/drawable/anim_expand_more_to_less.xml create mode 100644 app/src/main/res/drawable/anim_free_to_crop.xml create mode 100644 app/src/main/res/drawable/anim_incog_to_read.xml create mode 100644 app/src/main/res/drawable/anim_lib_filled_to_outline.xml create mode 100644 app/src/main/res/drawable/anim_lib_outline_to_filled.xml create mode 100644 app/src/main/res/drawable/anim_outline_to_filled.xml create mode 100644 app/src/main/res/drawable/anim_read_to_incog.xml create mode 100644 app/src/main/res/drawable/anim_recents_filled_to_outline.xml create mode 100644 app/src/main/res/drawable/anim_recents_outline_to_filled.xml delete mode 100644 app/src/main/res/drawable/bottom_nav_item_selector.xml delete mode 100644 app/src/main/res/drawable/button_bg_error.xml delete mode 100644 app/src/main/res/drawable/filter_mock.webp create mode 100644 app/src/main/res/drawable/ic_add_24dp.xml create mode 100644 app/src/main/res/drawable/ic_arrow_back_24dp.xml create mode 100644 app/src/main/res/drawable/ic_backup_24dp.xml create mode 100644 app/src/main/res/drawable/ic_blank_28dp.xml create mode 100644 app/src/main/res/drawable/ic_book_open_variant_24dp.xml create mode 100644 app/src/main/res/drawable/ic_broken_image_24dp.xml create mode 100644 app/src/main/res/drawable/ic_browse_off_24dp.xml create mode 100644 app/src/main/res/drawable/ic_calendar_text_outline_24dp.xml create mode 100644 app/src/main/res/drawable/ic_close_circle_24dp.xml create mode 100644 app/src/main/res/drawable/ic_eye_down_24dp.xml create mode 100644 app/src/main/res/drawable/ic_eye_off_down_24dp.xml create mode 100644 app/src/main/res/drawable/ic_eye_off_range_24dp.xml create mode 100644 app/src/main/res/drawable/ic_eye_off_up_24dp.xml create mode 100644 app/src/main/res/drawable/ic_eye_range_24dp.xml create mode 100644 app/src/main/res/drawable/ic_eye_remove_outline_24dp.xml create mode 100644 app/src/main/res/drawable/ic_eye_up_24dp.xml create mode 100644 app/src/main/res/drawable/ic_glasses_24dp.xml create mode 100644 app/src/main/res/drawable/ic_heart_off_24dp.xml create mode 100644 app/src/main/res/drawable/ic_help_outline_24dp.xml create mode 100644 app/src/main/res/drawable/ic_history_off_24dp.xml create mode 100644 app/src/main/res/drawable/ic_incognito_24dp.xml create mode 100644 app/src/main/res/drawable/ic_incognito_circle_24dp.xml create mode 100644 app/src/main/res/drawable/ic_language_24dp.xml create mode 100644 app/src/main/res/drawable/ic_libary_filled_24dp.xml delete mode 100644 app/src/main/res/drawable/ic_library_24dp.xml create mode 100644 app/src/main/res/drawable/ic_lock_24dp.xml delete mode 100644 app/src/main/res/drawable/ic_no_settings_24dp.xml create mode 100644 app/src/main/res/drawable/ic_outline_photo_24dp.xml create mode 100644 app/src/main/res/drawable/ic_outline_save_24dp.xml create mode 100644 app/src/main/res/drawable/ic_outline_settings_24dp.xml create mode 100644 app/src/main/res/drawable/ic_outline_share_24dp.xml create mode 100644 app/src/main/res/drawable/ic_page_next_outline_24dp.xml create mode 100644 app/src/main/res/drawable/ic_page_previous_outline_24dp.xml rename app/src/main/res/drawable/{ic_star_24dp.xml => ic_plus_24dp.xml} (50%) create mode 100644 app/src/main/res/drawable/ic_reader_continuous_vertical_24dp.xml create mode 100644 app/src/main/res/drawable/ic_reader_default_24dp.xml create mode 100644 app/src/main/res/drawable/ic_reader_ltr_24dp.xml create mode 100644 app/src/main/res/drawable/ic_reader_rtl_24dp.xml create mode 100644 app/src/main/res/drawable/ic_reader_vertical_24dp.xml create mode 100644 app/src/main/res/drawable/ic_reader_webtoon_24dp.xml delete mode 100644 app/src/main/res/drawable/ic_recent_read_selector_24dp.xml create mode 100644 app/src/main/res/drawable/ic_recents_filled_24dp.xml create mode 100644 app/src/main/res/drawable/ic_recents_outline_24dp.xml create mode 100644 app/src/main/res/drawable/ic_recents_selector_24dp.xml create mode 100644 app/src/main/res/drawable/ic_rounded_tooltip_24dp.xml create mode 100644 app/src/main/res/drawable/ic_save_all_outline_24dp.xml create mode 100644 app/src/main/res/drawable/ic_screen_lock_landscape_24dp.xml create mode 100644 app/src/main/res/drawable/ic_screen_lock_portrait_24dp.xml create mode 100644 app/src/main/res/drawable/ic_screen_rotation_24dp.xml create mode 100644 app/src/main/res/drawable/ic_search_off_24dp.xml create mode 100644 app/src/main/res/drawable/ic_security_24dp.xml create mode 100644 app/src/main/res/drawable/ic_select_all_24dp.xml create mode 100644 app/src/main/res/drawable/ic_select_none_24dp.xml create mode 100644 app/src/main/res/drawable/ic_share_all_outline_24dp.xml create mode 100644 app/src/main/res/drawable/ic_single_page_24dp.xml create mode 100644 app/src/main/res/drawable/ic_skip_next_24.xml create mode 100644 app/src/main/res/drawable/ic_skip_previous_24.xml create mode 100644 app/src/main/res/drawable/ic_stay_current_landscape_24dp.xml create mode 100644 app/src/main/res/drawable/ic_stay_current_portrait_24dp.xml delete mode 100644 app/src/main/res/drawable/ic_update_24dp.xml create mode 100644 app/src/main/res/drawable/ic_view_comments_24p.xml create mode 100644 app/src/main/res/drawable/oval.xml create mode 100644 app/src/main/res/drawable/reader_toolbar_ripple.xml create mode 100644 app/src/main/res/drawable/rect_ripple.xml delete mode 100644 app/src/main/res/drawable/round_textview_background.xml create mode 100644 app/src/main/res/drawable/rounded_preview_rect.xml delete mode 100644 app/src/main/res/drawable/sc_glasses_48dp.xml delete mode 100644 app/src/main/res/drawable/sc_update_48dp.xml create mode 100644 app/src/main/res/drawable/shape_gradient_start_shadow.xml create mode 100644 app/src/main/res/drawable/tab_highlight_indicator.xml create mode 100644 app/src/main/res/drawable/tab_indicator.xml create mode 100644 app/src/main/res/drawable/theme_selected_border.xml delete mode 100644 app/src/main/res/layout-land/reader_color_filter_sheet.xml create mode 100644 app/src/main/res/layout-sw600dp/main_activity.xml create mode 100644 app/src/main/res/layout/add_category_item.xml delete mode 100644 app/src/main/res/layout/changelog_header_layout.xml delete mode 100644 app/src/main/res/layout/changelog_row_layout.xml create mode 100644 app/src/main/res/layout/chapter_header_item.xml delete mode 100644 app/src/main/res/layout/common_dialog_with_checkbox.xml delete mode 100644 app/src/main/res/layout/display_bottom_sheet.xml rename app/src/main/res/layout/{filter_buttons.xml => filter_tag_group.xml} (100%) create mode 100644 app/src/main/res/layout/in_library_badge.xml create mode 100644 app/src/main/res/layout/library_badges_layout.xml create mode 100644 app/src/main/res/layout/library_category_layout.xml rename app/src/main/res/layout/{library_list_controller.xml => library_controller.xml} (71%) create mode 100644 app/src/main/res/layout/library_display_layout.xml create mode 100644 app/src/main/res/layout/material_spinner_view.xml delete mode 100644 app/src/main/res/layout/pref_item_source.xml delete mode 100644 app/src/main/res/layout/reader_color_filter_sheet.xml create mode 100644 app/src/main/res/layout/reader_general_layout.xml create mode 100644 app/src/main/res/layout/reader_paged_layout.xml delete mode 100644 app/src/main/res/layout/reader_settings_sheet.xml delete mode 100644 app/src/main/res/layout/recent_chapters_controller.xml delete mode 100644 app/src/main/res/layout/recent_chapters_item.xml delete mode 100644 app/src/main/res/layout/recently_read_controller.xml delete mode 100644 app/src/main/res/layout/recently_read_item.xml create mode 100644 app/src/main/res/layout/recents_general_view.xml create mode 100644 app/src/main/res/layout/recents_history_view.xml create mode 100644 app/src/main/res/layout/recents_updates_view.xml create mode 100644 app/src/main/res/layout/set_categories_sheet.xml create mode 100644 app/src/main/res/layout/settings_search_controller.xml create mode 100644 app/src/main/res/layout/settings_search_controller_card.xml create mode 100644 app/src/main/res/layout/tabbed_bottom_sheet.xml create mode 100644 app/src/main/res/layout/tachi_overflow_layout.xml create mode 100644 app/src/main/res/layout/theme_item.xml create mode 100644 app/src/main/res/layout/themes_preference.xml create mode 100644 app/src/main/res/layout/tooltip_text_view.xml create mode 100644 app/src/main/res/menu/manga_details_cover.xml create mode 100644 app/src/main/res/menu/reader.xml delete mode 100644 app/src/main/res/menu/recently_read.xml delete mode 100644 app/src/main/res/raw/changelog_release.xml delete mode 100644 app/src/main/res/values-sw360dp-v13/values-preference.xml rename app/src/main/res/{values-land/dimens.xml => values-sw600dp/attrs.xml} (62%) delete mode 100644 app/src/main/res/values-sw600dp/dimens.xml delete mode 100644 app/src/main/res/values-v27/styles.xml delete mode 100644 app/src/main/res/values-w820dp/dimens.xml delete mode 100644 app/src/main/res/values/strings_neko.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 ktlintCodeStyle.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 472b92a33a..8fd83b86b9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,8 +6,8 @@ import java.time.format.DateTimeFormatter plugins { id(Plugins.androidApplication) kotlin(Plugins.kotlinAndroid) - kotlin(Plugins.kotlinExtensions) kotlin(Plugins.kapt) + id(Plugins.kotlinParcelize) id(Plugins.kotlinSerialization) id(Plugins.aboutLibraries) id(Plugins.firebaseCrashlytics) @@ -29,7 +29,6 @@ fun runCommand(command: String): String { android { compileSdkVersion(Configs.compileSdkVersion) - buildToolsVersion(Configs.buildToolsVersion) defaultConfig { minSdkVersion(Configs.minSdkVersion) @@ -56,6 +55,10 @@ android { } } + buildFeatures { + viewBinding = true + } + flavorDimensions("default") productFlavors { @@ -68,6 +71,7 @@ android { } lintOptions { + disable("MissingTranslation") isAbortOnError = false isCheckReleaseBuilds = false } @@ -80,13 +84,12 @@ android { jvmTarget = JavaVersion.VERSION_1_8.toString() } } -androidExtensions { - isExperimental = true -} dependencies { // Modified dependencies implementation(Libs.UI.subsamplingScaleImageView) + // Source models and interfaces from Tachiyomi 1.x + implementation("tachiyomi.sourceapi:source-api:1.1") // Android support library implementation(Libs.Android.appCompat) implementation(Libs.Android.cardView) @@ -227,9 +230,6 @@ dependencies { // Conductor implementation(Libs.Navigation.conductor) - implementation(Libs.Navigation.conductorSupport) { - exclude("group", "com.android.support") - } implementation(Libs.Navigation.conductorSupportPreferences) // RxBindings @@ -248,6 +248,7 @@ dependencies { testImplementation(Libs.Test.roboElectricShadowPlayServices) implementation(Libs.Kotlin.stdLib) + implementation(Libs.Kotlin.reflection) implementation(Libs.Kotlin.coroutines) // Text distance @@ -264,21 +265,23 @@ dependencies { implementation(Libs.Util.aboutLibraries) } -// See https://kotlinlang.org/docs/reference/experimental.html#experimental-status-of-experimental-api-markers -tasks.withType().all { - kotlinOptions.freeCompilerArgs += listOf( - "-Xopt-in=kotlin.Experimental", - "-Xopt-in=kotlin.RequiresOptIn", - "-Xuse-experimental=kotlin.ExperimentalStdlibApi", - "-Xuse-experimental=kotlinx.coroutines.FlowPreview", - "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-Xuse-experimental=kotlinx.coroutines.InternalCoroutinesApi", - "-Xuse-experimental=kotlinx.serialization.ExperimentalSerializationApi" - ) -} +tasks { + // See https://kotlinlang.org/docs/reference/experimental.html#experimental-status-of-experimental-api(-markers) + withType { + kotlinOptions.freeCompilerArgs += listOf( + "-Xopt-in=kotlin.Experimental", + "-Xopt-in=kotlin.RequiresOptIn", + "-Xuse-experimental=kotlin.ExperimentalStdlibApi", + "-Xuse-experimental=kotlinx.coroutines.FlowPreview", + "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-Xuse-experimental=kotlinx.coroutines.InternalCoroutinesApi", + "-Xuse-experimental=kotlinx.serialization.ExperimentalSerializationApi" + ) + } -tasks.preBuild { - dependsOn(tasks.ktlintFormat) + preBuild { + // dependsOn(formatKotlin) + } } if (gradle.startParameter.taskRequests.toString().contains("Standard")) { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 93d32e1a1d..90736ab0bf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ @@ -14,24 +15,28 @@ + + android:networkSecurityConfig="@xml/network_security_config"> + android:windowSoftInputMode="adjustNothing" + android:theme="@style/Theme.Splash"> @@ -81,7 +86,7 @@ = 0) { SecureActivityDelegate.locked = true } diff --git a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt index 1314cd2381..55146e088c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt @@ -23,6 +23,7 @@ import eu.kanade.tachiyomi.source.online.handlers.SearchHandler import eu.kanade.tachiyomi.source.online.handlers.SimilarHandler import eu.kanade.tachiyomi.util.chapter.ChapterFilter import eu.kanade.tachiyomi.v5.db.V5DbHelper +import eu.kanade.tachiyomi.util.manga.MangaShortcutManager import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.serialization.json.Json @@ -35,7 +36,6 @@ import uy.kohesive.injekt.api.get class AppModule(val app: Application) : InjektModule { override fun InjektRegistrar.registerInjectables() { - addSingleton(app) addSingletonFactory { PreferencesHelper(app) } @@ -84,6 +84,12 @@ class AppModule(val app: Application) : InjektModule { addSingleton(MangaPlusHandler()) + addSingletonFactory { Json { ignoreUnknownKeys = true } } + + addSingletonFactory { ChapterFilter() } + + addSingletonFactory { MangaShortcutManager() } + // Asynchronously init expensive components for a faster cold start GlobalScope.launch { get() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt index 4abd3e49d1..2b907c6568 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt @@ -1,19 +1,17 @@ package eu.kanade.tachiyomi -import com.elvishew.xlog.XLog +import androidx.core.content.edit +import androidx.preference.PreferenceManager import eu.kanade.tachiyomi.data.backup.BackupCreatorJob -import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.library.LibraryUpdateJob +import eu.kanade.tachiyomi.data.preference.PreferenceKeys import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.updater.UpdaterJob -import eu.kanade.tachiyomi.source.online.utils.MdUtil +import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE import eu.kanade.tachiyomi.util.system.toast -import eu.kanade.tachiyomi.v5.db.V5DbHelper -import eu.kanade.tachiyomi.v5.db.V5DbQueries import eu.kanade.tachiyomi.v5.job.V5MigrationJob -import eu.kanade.tachiyomi.v5.job.V5MigrationService import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -61,10 +59,23 @@ object Migrations { context.toast(R.string.myanimelist_relogin) } } - if(oldVersion < 114 && oldVersion != 0) { + if (oldVersion < 114 && oldVersion != 0) { // Force migrate all manga to the new V5 ids V5MigrationJob.doWorkNow() } + if (oldVersion < 115) { + // Migrate DNS over HTTPS setting + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val wasDohEnabled = prefs.getBoolean("enable_doh", false) + if (wasDohEnabled) { + prefs.edit { + putInt(PreferenceKeys.dohProvider, PREF_DOH_CLOUDFLARE) + remove("enable_doh") + } + } + // Reset rotation to Free after replacing Lock + preferences.rotation().set(1) + } return true } return false diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupManager.kt new file mode 100644 index 0000000000..89a2fd4334 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupManager.kt @@ -0,0 +1,95 @@ +package eu.kanade.tachiyomi.data.backup + +import android.content.Context +import android.net.Uri +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.library.CustomMangaManager +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource +import uy.kohesive.injekt.injectLazy + +abstract class AbstractBackupManager(protected val context: Context) { + + internal val databaseHelper: DatabaseHelper by injectLazy() + internal val sourceManager: SourceManager by injectLazy() + internal val trackManager: TrackManager by injectLazy() + protected val preferences: PreferencesHelper by injectLazy() + protected val customMangaManager: CustomMangaManager by injectLazy() + + abstract fun createBackup(uri: Uri, flags: Int, isJob: Boolean): String? + + /** + * Returns manga + * + * @return [Manga], null if not found + */ + internal fun getMangaFromDatabase(manga: Manga): Manga? = + databaseHelper.getManga(manga.url, manga.source).executeAsBlocking() + + /** + * Fetches chapter information. + * + * @param source source of manga + * @param manga manga that needs updating + * @param chapters list of chapters in the backup + * @return Updated manga chapters. + */ + internal suspend fun restoreChapters(source: Source, manga: Manga, chapters: List): Pair, List> { + val fetchedChapters = source.fetchChapterList(manga) + val syncedChapters = syncChaptersWithSource(databaseHelper, fetchedChapters, manga) + if (syncedChapters.first.isNotEmpty()) { + chapters.forEach { it.manga_id = manga.id } + updateChapters(chapters) + } + return syncedChapters + } + + /** + * Returns list containing manga from library + * + * @return [Manga] from library + */ + protected fun getFavoriteManga(): List = + databaseHelper.getFavoriteMangas().executeAsBlocking() + + /** + * Inserts manga and returns id + * + * @return id of [Manga], null if not found + */ + internal fun insertManga(manga: Manga): Long? = + databaseHelper.insertManga(manga).executeAsBlocking().insertedId() + + /** + * Inserts list of chapters + */ + protected fun insertChapters(chapters: List) { + databaseHelper.insertChapters(chapters).executeAsBlocking() + } + + /** + * Updates a list of chapters + */ + protected fun updateChapters(chapters: List) { + databaseHelper.updateChaptersBackup(chapters).executeAsBlocking() + } + + /** + * Updates a list of chapters with known database ids + */ + protected fun updateKnownChapters(chapters: List) { + databaseHelper.updateKnownChaptersBackup(chapters).executeAsBlocking() + } + + /** + * Return number of backups. + * + * @return number of backups selected by user + */ + protected fun numberOfBackups(): Int = preferences.numberOfBackups().get() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupRestoreValidator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupRestoreValidator.kt new file mode 100644 index 0000000000..2dc959691c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupRestoreValidator.kt @@ -0,0 +1,16 @@ +package eu.kanade.tachiyomi.data.backup + +import android.content.Context +import android.net.Uri +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.source.SourceManager +import uy.kohesive.injekt.injectLazy + +abstract class AbstractBackupRestoreValidator { + protected val sourceManager: SourceManager by injectLazy() + protected val trackManager: TrackManager by injectLazy() + + abstract fun validate(context: Context, uri: Uri): Results + + data class Results(val missingSources: List, val missingTrackers: List) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt index 576fafb98f..1b396bc11b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt @@ -5,15 +5,10 @@ import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID object BackupConst { private const val NAME = "BackupRestoreServices" + const val EXTRA_URI = "$ID.$NAME.EXTRA_URI" const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS" - const val EXTRA_TYPE = "$ID.$NAME.EXTRA_TYPE" - const val INTENT_FILTER = "SettingsBackupFragment" - const val ACTION_BACKUP_COMPLETED_DIALOG = "$ID.$INTENT_FILTER.ACTION_BACKUP_COMPLETED_DIALOG" - const val ACTION_ERROR_BACKUP_DIALOG = "$ID.$INTENT_FILTER.ACTION_ERROR_BACKUP_DIALOG" - const val ACTION = "$ID.$INTENT_FILTER.ACTION" - const val EXTRA_ERROR_MESSAGE = "$ID.$INTENT_FILTER.EXTRA_ERROR_MESSAGE" - const val EXTRA_URI = "$ID.$INTENT_FILTER.EXTRA_URI" const val EXTRA_MODE = "$ID.$NAME.EXTRA_MODE" + const val EXTRA_TYPE = "$ID.$NAME.EXTRA_TYPE" const val BACKUP_TYPE_LEGACY = 0 const val BACKUP_TYPE_FULL = 1 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt index d5ac6c302f..6d51ac15cd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt @@ -4,9 +4,9 @@ import android.app.Service import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Build import android.os.IBinder import android.os.PowerManager +import androidx.core.content.ContextCompat import androidx.core.net.toUri import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.data.backup.full.FullBackupManager @@ -54,11 +54,7 @@ class BackupCreateService : Service() { putExtra(BackupConst.EXTRA_FLAGS, flags) putExtra(BackupConst.EXTRA_TYPE, type) } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - context.startService(intent) - } else { - context.startForegroundService(intent) - } + ContextCompat.startForegroundService(context, intent) } } } @@ -78,7 +74,8 @@ class BackupCreateService : Service() { startForeground(Notifications.ID_BACKUP_PROGRESS, notifier.showBackupProgress().build()) wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, "${javaClass.name}:WakeLock" + PowerManager.PARTIAL_WAKE_LOCK, + "${javaClass.name}:WakeLock" ) wakeLock.acquire() } @@ -113,8 +110,8 @@ class BackupCreateService : Service() { val backupType = intent.getIntExtra(BackupConst.EXTRA_TYPE, BackupConst.BACKUP_TYPE_LEGACY) val backupFileUri = when (backupType) { - BackupConst.BACKUP_TYPE_FULL -> FullBackupManager(this).createBackup(uri, backupFlags, false)?.toUri() - else -> LegacyBackupManager(this).createBackup(uri, backupFlags, false)?.toUri() + BackupConst.BACKUP_TYPE_FULL -> FullBackupManager(this).createBackup(uri!!, backupFlags, false).toUri() + else -> LegacyBackupManager(this).createBackup(uri!!, backupFlags, false)?.toUri() } val unifile = UniFile.fromUri(this, backupFileUri) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt index d983ffb57d..8d690c0e4c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt @@ -10,7 +10,6 @@ import androidx.work.WorkerParameters import eu.kanade.tachiyomi.data.backup.full.FullBackupManager import eu.kanade.tachiyomi.data.backup.legacy.LegacyBackupManager import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.util.concurrent.TimeUnit @@ -20,7 +19,7 @@ class BackupCreatorJob(private val context: Context, workerParams: WorkerParamet override fun doWork(): Result { val preferences = Injekt.get() - val uri = preferences.backupsDirectory().getOrDefault().toUri() + val uri = preferences.backupsDirectory().get().toUri() val flags = BackupCreateService.BACKUP_ALL return try { FullBackupManager(context).createBackup(uri, flags, true) @@ -38,11 +37,13 @@ class BackupCreatorJob(private val context: Context, workerParams: WorkerParamet fun setupTask(prefInterval: Int? = null) { val preferences = Injekt.get() - val interval = prefInterval ?: preferences.backupInterval().getOrDefault() + val interval = prefInterval ?: preferences.backupInterval().get() if (interval > 0) { val request = PeriodicWorkRequestBuilder( - interval.toLong(), TimeUnit.HOURS, - 10, TimeUnit.MINUTES + interval.toLong(), + TimeUnit.HOURS, + 10, + TimeUnit.MINUTES ) .addTag(TAG) .build() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt index d6bb214d4e..e4aa2ae04b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt @@ -19,7 +19,7 @@ import kotlinx.coroutines.launch import java.util.concurrent.TimeUnit /** - * Restores backup from json file + * Restores backup. */ class BackupRestoreService : Service() { @@ -42,7 +42,8 @@ class BackupRestoreService : Service() { super.onCreate() startForeground(Notifications.ID_RESTORE_PROGRESS, restoreHelper.progressNotification.build()) wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, "BackupRestoreService:WakeLock" + PowerManager.PARTIAL_WAKE_LOCK, + "BackupRestoreService:WakeLock" ) wakeLock.acquire(TimeUnit.HOURS.toMillis(3)) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/RestoreHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/RestoreHelper.kt index b7719b0104..3e091fd6ec 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/RestoreHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/RestoreHelper.kt @@ -21,7 +21,7 @@ class RestoreHelper(val context: Context) { * Pending intent of action that cancels the library update */ val cancelIntent by lazy { - NotificationReceiver.cancelRestorePendingBroadcast(context) + NotificationReceiver.cancelRestorePendingBroadcast(context, Notifications.ID_RESTORE_PROGRESS) } /** @@ -29,7 +29,7 @@ class RestoreHelper(val context: Context) { */ val progressNotification by lazy { NotificationCompat.Builder(context, Notifications.CHANNEL_BACKUP_RESTORE_PROGRESS) - .setContentTitle(context.getString(R.string.neko_app_name)) + .setContentTitle(context.getString(R.string.app_name)) .setSmallIcon(R.drawable.ic_neko_notification) .setOngoing(true) .setOnlyAlertOnce(true) @@ -61,7 +61,8 @@ class RestoreHelper(val context: Context) { .setContentTitle(title.chop(30)) .setContentText( context.getString( - R.string.restoring_progress, current, + R.string.restoring_progress, + current, total ) ) @@ -120,7 +121,8 @@ class RestoreHelper(val context: Context) { content.add( context.getString( - R.string.restore_completed_errors, errors.size.toString() + R.string.restore_completed_errors, + errors.size.toString() ) ) @@ -139,8 +141,9 @@ class RestoreHelper(val context: Context) { val trackingErrorsString = trackingErrors.distinct().joinToString("\n") content.add(trackingErrorsString) } - if (cancelled > 0) + if (cancelled > 0) { content.add(context.getString(R.string.restore_content_skipped, cancelled)) + } val restoreString = content.joinToString("\n") @@ -151,7 +154,7 @@ class RestoreHelper(val context: Context) { .setSmallIcon(R.drawable.ic_neko_notification) .setColor(ContextCompat.getColor(context, R.color.neko_green_darker)) .setPriority(NotificationCompat.PRIORITY_HIGH) - if (errors.size > 0 && !path.isNullOrEmpty() && !file.isNullOrEmpty()) { + if (errors.isNotEmpty() && !path.isNullOrEmpty() && !file.isNullOrEmpty()) { resultNotification.addAction( R.drawable.ic_close_24dp, context.getString( @@ -194,4 +197,4 @@ class RestoreHelper(val context: Context) { } return File("") } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestoreValidator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestoreValidator.kt new file mode 100644 index 0000000000..bb932c52c2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestoreValidator.kt @@ -0,0 +1,47 @@ +package eu.kanade.tachiyomi.data.backup.full + +import android.content.Context +import android.net.Uri +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.backup.AbstractBackupRestoreValidator +import eu.kanade.tachiyomi.data.backup.full.models.BackupSerializer +import okio.buffer +import okio.gzip +import okio.source + +class FullBackupRestoreValidator : AbstractBackupRestoreValidator() { + /** + * Checks for critical backup file data. + * + * @throws Exception if manga cannot be found. + * @return List of missing sources or missing trackers. + */ + override fun validate(context: Context, uri: Uri): Results { + val backupManager = FullBackupManager(context) + + val backupString = context.contentResolver.openInputStream(uri)!!.source().gzip().buffer().use { it.readByteArray() } + val backup = backupManager.parser.decodeFromByteArray(BackupSerializer, backupString) + + if (backup.backupManga.isEmpty()) { + throw Exception(context.getString(R.string.backup_has_no_manga)) + } + + val sources = backup.backupSources.map { it.sourceId to it.name }.toMap() + val missingSources = sources + .filter { sourceManager.get(it.key) == null } + .values + .sorted() + + val trackers = backup.backupManga + .flatMap { it.tracking } + .map { it.syncId } + .distinct() + val missingTrackers = trackers + .mapNotNull { trackManager.getService(it) } + .filter { !it.isLogged } + .map { context.getString(it.nameRes()) } + .sorted() + + return Results(missingSources, missingTrackers) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullRestore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullRestore.kt index b0fd6053c7..6344cdc3c3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullRestore.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullRestore.kt @@ -52,7 +52,6 @@ class FullRestore(val context: Context, val job: Job?) { internal val trackManager: TrackManager by injectLazy() suspend fun restoreBackup(uri: Uri) { - backupManager = FullBackupManager(context) val backupString = context.contentResolver.openInputStream(uri)!!.source().gzip().buffer().use { it.readByteArray() } @@ -64,6 +63,7 @@ class FullRestore(val context: Context, val job: Job?) { skippedTitles = partitionedList.second.map { it.title } totalAmount = backup.backupManga.size skippedAmount = totalAmount - dexManga.size + restoreAmount = dexManga.size trackingErrors.clear() errors.clear() cancelled = 0 @@ -119,7 +119,7 @@ class FullRestore(val context: Context, val job: Job?) { if (isNumericId) { val newMangaId = V5DbQueries.getNewMangaId(dbV5.idDb, oldMangaId) if (newMangaId.isNotBlank()) { - manga.url = "/title/${newMangaId}" + manga.url = "/title/$newMangaId" } } @@ -139,4 +139,4 @@ class FullRestore(val context: Context, val job: Job?) { errors.add("${backupManga.title} - ${e.message}") } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupChapter.kt index 05111123b9..6a37362525 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupChapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupChapter.kt @@ -21,6 +21,9 @@ data class BackupChapter( // chapterNumber is called number is 1.x @ProtoNumber(9) var chapterNumber: Float = 0F, @ProtoNumber(10) var sourceOrder: Int = 0, + + // J2K specific values + @ProtoNumber(800) var pagesLeft: Int = 0, ) { fun toChapterImpl(): ChapterImpl { return ChapterImpl().apply { @@ -34,6 +37,7 @@ data class BackupChapter( date_fetch = this@BackupChapter.dateFetch date_upload = this@BackupChapter.dateUpload source_order = this@BackupChapter.sourceOrder + pages_left = this@BackupChapter.pagesLeft } } @@ -49,7 +53,8 @@ data class BackupChapter( lastPageRead = chapter.last_page_read, dateFetch = chapter.date_fetch, dateUpload = chapter.date_upload, - sourceOrder = chapter.source_order + sourceOrder = chapter.source_order, + pagesLeft = chapter.pages_left, ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt index a9a4d2344b..270adf2bfa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt @@ -37,6 +37,8 @@ data class BackupTracking( total_chapters = this@BackupTracking.totalChapters score = this@BackupTracking.score status = this@BackupTracking.status + started_reading_date = this@BackupTracking.startedReadingDate + finished_reading_date = this@BackupTracking.finishedReadingDate tracking_url = this@BackupTracking.trackingUrl } } @@ -54,6 +56,8 @@ data class BackupTracking( totalChapters = track.total_chapters, score = track.score, status = track.status, + startedReadingDate = track.started_reading_date, + finishedReadingDate = track.finished_reading_date, trackingUrl = track.tracking_url ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt index 7376975801..1b604b71c0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt @@ -21,19 +21,19 @@ import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HIST import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY_MASK import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK_MASK -import eu.kanade.tachiyomi.data.backup.models.Backup -import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES -import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS -import eu.kanade.tachiyomi.data.backup.models.Backup.CURRENT_VERSION -import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY -import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA -import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK -import eu.kanade.tachiyomi.data.backup.models.DHistory -import eu.kanade.tachiyomi.data.backup.serializer.CategoryTypeAdapter -import eu.kanade.tachiyomi.data.backup.serializer.ChapterTypeAdapter -import eu.kanade.tachiyomi.data.backup.serializer.HistoryTypeAdapter -import eu.kanade.tachiyomi.data.backup.serializer.MangaTypeAdapter -import eu.kanade.tachiyomi.data.backup.serializer.TrackTypeAdapter +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.CATEGORIES +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.CHAPTERS +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.CURRENT_VERSION +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.HISTORY +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.MANGA +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.TRACK +import eu.kanade.tachiyomi.data.backup.legacy.models.DHistory +import eu.kanade.tachiyomi.data.backup.legacy.serializer.CategoryTypeAdapter +import eu.kanade.tachiyomi.data.backup.legacy.serializer.ChapterTypeAdapter +import eu.kanade.tachiyomi.data.backup.legacy.serializer.HistoryTypeAdapter +import eu.kanade.tachiyomi.data.backup.legacy.serializer.MangaTypeAdapter +import eu.kanade.tachiyomi.data.backup.legacy.serializer.TrackTypeAdapter import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.CategoryImpl import eu.kanade.tachiyomi.data.database.models.Chapter @@ -45,7 +45,6 @@ import eu.kanade.tachiyomi.data.database.models.MangaImpl import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.TrackImpl import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager @@ -447,8 +446,9 @@ class LegacyBackupManager(val context: Context, version: Int = CURRENT_VERSION) val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking() // Return if fetch is needed - if (dbChapters.isEmpty() || dbChapters.size < chapters.size) + if (dbChapters.isEmpty() || dbChapters.size < chapters.size) { return false + } for (chapter in chapters) { val pos = dbChapters.indexOf(chapter) @@ -495,5 +495,5 @@ class LegacyBackupManager(val context: Context, version: Int = CURRENT_VERSION) * * @return number of backups selected by user */ - fun numberOfBackups(): Int = preferences.numberOfBackups().getOrDefault() + fun numberOfBackups(): Int = preferences.numberOfBackups().get() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestoreValidator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestoreValidator.kt new file mode 100644 index 0000000000..0398cc4d4b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestoreValidator.kt @@ -0,0 +1,66 @@ +package eu.kanade.tachiyomi.data.backup.legacy + +import android.content.Context +import android.net.Uri +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.google.gson.stream.JsonReader +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.backup.AbstractBackupRestoreValidator +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup + +class LegacyBackupRestoreValidator : AbstractBackupRestoreValidator() { + /** + * Checks for critical backup file data. + * + * @throws Exception if version or manga cannot be found. + * @return List of missing sources or missing trackers. + */ + override fun validate(context: Context, uri: Uri): Results { + val reader = JsonReader(context.contentResolver.openInputStream(uri)!!.bufferedReader()) + val json = JsonParser.parseReader(reader).asJsonObject + + val version = json.get(Backup.VERSION) + val mangasJson = json.get(Backup.MANGAS) + if (version == null || mangasJson == null) { + throw Exception(context.getString(R.string.file_is_missing_data)) + } + + val mangas = mangasJson.asJsonArray + if (mangas.size() == 0) { + throw Exception(context.getString(R.string.backup_has_no_manga)) + } + + val sources = getSourceMapping(json) + val missingSources = sources + .filter { sourceManager.get(it.key) == null } + .values + .sorted() + + val trackers = mangas + .filter { it.asJsonObject.has("track") } + .flatMap { it.asJsonObject["track"].asJsonArray } + .map { it.asJsonObject["s"].asInt } + .distinct() + val missingTrackers = trackers + .mapNotNull { trackManager.getService(it) } + .filter { !it.isLogged } + .map { context.getString(it.nameRes()) } + .sorted() + + return Results(missingSources, missingTrackers) + } + + companion object { + fun getSourceMapping(json: JsonObject): Map { + val extensionsMapping = json.get(Backup.EXTENSIONS) ?: return emptyMap() + + return extensionsMapping.asJsonArray + .map { + val items = it.asString.split(":") + items[0].toLong() to items[1] + } + .toMap() + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyRestore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyRestore.kt index df41ca73f6..d8c30bac91 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyRestore.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyRestore.kt @@ -11,8 +11,8 @@ import com.google.gson.JsonParser import com.google.gson.stream.JsonReader import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.backup.RestoreHelper -import eu.kanade.tachiyomi.data.backup.models.Backup -import eu.kanade.tachiyomi.data.backup.models.DHistory +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup +import eu.kanade.tachiyomi.data.backup.legacy.models.DHistory import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.ChapterImpl import eu.kanade.tachiyomi.data.database.models.Manga @@ -98,7 +98,6 @@ class LegacyRestore(val context: Context, val job: Job?) { val mangasJson = json.get(Backup.MANGAS).asJsonArray val mangdexManga = mangasJson.filter { - val manga = backupManager.parser.fromJson(it.asJsonObject.get(Backup.MANGA)) val isMangaDex = backupManager.sourceManager.isMangadex(manga.source) if (!isMangaDex) { @@ -173,7 +172,7 @@ class LegacyRestore(val context: Context, val job: Job?) { if (isNumericId) { val newMangaId = V5DbQueries.getNewMangaId(dbV5.idDb, oldMangaId) if (newMangaId != "") { - manga.url = "/title/${newMangaId}" + manga.url = "/title/$newMangaId" } } @@ -236,4 +235,4 @@ class LegacyRestore(val context: Context, val job: Job?) { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/Backup.kt similarity index 80% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/Backup.kt index d45adaa0ad..32dfa9245c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/Backup.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.data.backup.models +package eu.kanade.tachiyomi.data.backup.legacy.models import java.text.SimpleDateFormat import java.util.Date @@ -14,11 +14,12 @@ object Backup { const val TRACK = "track" const val CHAPTERS = "chapters" const val CATEGORIES = "categories" + const val EXTENSIONS = "extensions" const val HISTORY = "history" const val VERSION = "version" fun getDefaultFilename(): String { val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date()) - return "neko_$date.json" + return "tachiyomi_$date.json" } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/DHistory.kt similarity index 51% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/DHistory.kt index a5e1c1a0f3..9a0ea06609 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/DHistory.kt @@ -1,3 +1,3 @@ -package eu.kanade.tachiyomi.data.backup.models +package eu.kanade.tachiyomi.data.backup.legacy.models data class DHistory(val url: String, val lastRead: Long) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/CategoryTypeAdapter.kt similarity index 92% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/CategoryTypeAdapter.kt index 1beb5d9798..d346af19cf 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/CategoryTypeAdapter.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.data.backup.serializer +package eu.kanade.tachiyomi.data.backup.legacy.serializer import com.github.salomonbrys.kotson.typeAdapter import com.google.gson.TypeAdapter diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/ChapterTypeAdapter.kt similarity index 96% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/ChapterTypeAdapter.kt index 9bd6e8e1e6..cacc8cb25b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/ChapterTypeAdapter.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.data.backup.serializer +package eu.kanade.tachiyomi.data.backup.legacy.serializer import com.github.salomonbrys.kotson.typeAdapter import com.google.gson.TypeAdapter diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/HistoryTypeAdapter.kt similarity index 85% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/HistoryTypeAdapter.kt index 863a1a1f30..4f7d5d9ff1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/HistoryTypeAdapter.kt @@ -1,8 +1,8 @@ -package eu.kanade.tachiyomi.data.backup.serializer +package eu.kanade.tachiyomi.data.backup.legacy.serializer import com.github.salomonbrys.kotson.typeAdapter import com.google.gson.TypeAdapter -import eu.kanade.tachiyomi.data.backup.models.DHistory +import eu.kanade.tachiyomi.data.backup.legacy.models.DHistory /** * JSON Serializer used to write / read [DHistory] to / from json diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/MangaTypeAdapter.kt similarity index 85% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/MangaTypeAdapter.kt index e10adc2fab..b902cbb5b7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/MangaTypeAdapter.kt @@ -1,9 +1,8 @@ -package eu.kanade.tachiyomi.data.backup.serializer +package eu.kanade.tachiyomi.data.backup.legacy.serializer import com.github.salomonbrys.kotson.typeAdapter import com.google.gson.TypeAdapter import eu.kanade.tachiyomi.data.database.models.MangaImpl -import kotlin.math.max /** * JSON Serializer used to write / read [MangaImpl] to / from json @@ -15,9 +14,9 @@ object MangaTypeAdapter { write { beginArray() value(it.url) - value(it.originalTitle) + value(it.title) value(it.source) - value(max(0, it.viewer)) + value(it.viewer) value(it.chapter_flags) endArray() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/TrackTypeAdapter.kt similarity index 96% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/TrackTypeAdapter.kt index de78b8c115..84c0cd829d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/TrackTypeAdapter.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.data.backup.serializer +package eu.kanade.tachiyomi.data.backup.legacy.serializer import com.github.salomonbrys.kotson.typeAdapter import com.google.gson.TypeAdapter diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt index 709f6d9d42..c09028b7c7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt @@ -2,20 +2,29 @@ package eu.kanade.tachiyomi.data.cache import android.content.Context import android.text.format.Formatter -import com.github.salomonbrys.kotson.fromJson -import com.google.gson.Gson import com.jakewharton.disklrucache.DiskLruCache import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.saveTo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import okhttp3.Response import okio.buffer import okio.sink -import rx.Observable import uy.kohesive.injekt.injectLazy import java.io.File import java.io.IOException +import kotlin.math.pow +import kotlin.math.roundToLong /** * Class used to create chapter cache @@ -39,19 +48,18 @@ class ChapterCache(private val context: Context) { const val PARAMETER_VALUE_COUNT = 1 /** The maximum number of bytes this cache should use to store. */ - const val PARAMETER_CACHE_SIZE = 75L * 1024 * 1024 + const val PARAMETER_CACHE_SIZE = 50L * 1024 * 1024 } /** Google Json class used for parsing JSON files. */ - private val gson: Gson by injectLazy() + private val json: Json by injectLazy() + + private val preferences: PreferencesHelper by injectLazy() + + private val scope = CoroutineScope(Job() + Dispatchers.IO) /** Cache class used for cache management. */ - private val diskCache = DiskLruCache.open( - File(context.cacheDir, PARAMETER_CACHE_DIRECTORY), - PARAMETER_APP_VERSION, - PARAMETER_VALUE_COUNT, - PARAMETER_CACHE_SIZE - ) + private var diskCache = setupDiskCache(preferences.preloadSize().get()) /** * Returns directory of cache. @@ -71,6 +79,28 @@ class ChapterCache(private val context: Context) { val readableSize: String get() = Formatter.formatFileSize(context, realSize) + init { + preferences.preloadSize().asFlow() + .drop(1) + .onEach { + // Save old cache for destruction later + val oldCache = diskCache + diskCache = setupDiskCache(it) + oldCache.close() + } + .launchIn(scope) + } + + private fun setupDiskCache(cacheSize: Int): DiskLruCache { + return DiskLruCache.open( + File(context.cacheDir, PARAMETER_CACHE_DIRECTORY), + PARAMETER_APP_VERSION, + PARAMETER_VALUE_COUNT, + // 4 pages = 115MB, 6 = ~150MB, 10 = ~200MB, 20 = ~300MB + (PARAMETER_CACHE_SIZE * cacheSize.toFloat().pow(0.6f)).roundToLong() + ) + } + /** * Remove file from cache. * @@ -79,8 +109,9 @@ class ChapterCache(private val context: Context) { */ fun removeFileFromCache(file: String): Boolean { // Make sure we don't delete the journal file (keeps track of cache). - if (file == "journal" || file.startsWith("journal.")) + if (file == "journal" || file.startsWith("journal.")) { return false + } try { // Remove the extension from the file to get the key of the cache @@ -96,17 +127,15 @@ class ChapterCache(private val context: Context) { * Get page list from cache. * * @param chapter the chapter. - * @return an observable of the list of pages. + * @return the list of pages. */ - fun getPageListFromCache(chapter: Chapter): Observable> { - return Observable.fromCallable { - // Get the key for the chapter. - val key = DiskUtil.hashKeyForDisk(getKey(chapter)) + fun getPageListFromCache(chapter: Chapter): List { + // Get the key for the chapter. + val key = DiskUtil.hashKeyForDisk(getKey(chapter)) - // Convert JSON string to list of objects. Throws an exception if snapshot is null - diskCache.get(key).use { - gson.fromJson>(it.getString(0)) - } + // Convert JSON string to list of objects. Throws an exception if snapshot is null + return diskCache.get(key).use { + json.decodeFromString(it.getString(0)) } } @@ -118,7 +147,7 @@ class ChapterCache(private val context: Context) { */ fun putPageListToCache(chapter: Chapter, pages: List) { // Convert list of pages to json string. - val cachedValue = gson.toJson(pages) + val cachedValue = json.encodeToString(pages) // Initialize the editor (edits the values for an entry). var editor: DiskLruCache.Editor? = null diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt index 6228cd089f..63153ca5ab 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt @@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.data.cache import android.content.Context import android.text.format.Formatter -import coil.Coil +import coil.imageLoader import coil.memory.MemoryCache import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.DatabaseHelper @@ -15,6 +15,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import timber.log.Timber import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.File @@ -70,7 +71,7 @@ class CoverCache(val context: Context) { val db = Injekt.get() var deletedSize = 0L val urls = db.getLibraryMangas().executeOnIO().mapNotNull { - it.thumbnail_url?.let { url -> return@mapNotNull it.key() } + it.thumbnail_url?.let { url -> return@mapNotNull DiskUtil.hashKeyForDisk(url) } null } val files = cacheDir.listFiles()?.iterator() ?: return@launch @@ -84,7 +85,8 @@ class CoverCache(val context: Context) { withContext(Dispatchers.Main) { context.toast( context.getString( - R.string.deleted_, Formatter.formatFileSize(context, deletedSize) + R.string.deleted_, + Formatter.formatFileSize(context, deletedSize) ) ) } @@ -98,7 +100,9 @@ class CoverCache(val context: Context) { GlobalScope.launch(Dispatchers.IO) { val directory = onlineCoverDirectory val size = DiskUtil.getDirectorySize(directory) - + if (size <= maxOnlineCacheSize) { + return@launch + } var deletedSize = 0L val files = directory.listFiles()?.sortedBy { it.lastModified() }?.iterator() ?: return@launch @@ -110,7 +114,8 @@ class CoverCache(val context: Context) { withContext(Dispatchers.Main) { context.toast( context.getString( - R.string.deleted_, Formatter.formatFileSize(context, deletedSize) + R.string.deleted_, + Formatter.formatFileSize(context, deletedSize) ) ) } @@ -124,21 +129,25 @@ class CoverCache(val context: Context) { fun deleteCachedCovers() { if (lastClean + renewInterval < System.currentTimeMillis()) { GlobalScope.launch(Dispatchers.IO) { - val directory = onlineCoverDirectory - val size = DiskUtil.getDirectorySize(directory) - if (size <= maxOnlineCacheSize) { - return@launch - } - var deletedSize = 0L - val files = directory.listFiles()?.sortedWith(compareBy({ it.lastModified() }, { it.name }))?.iterator() - ?: return@launch - while (files.hasNext()) { - val file = files.next() - deletedSize += file.length() - file.delete() - if (size - deletedSize <= maxOnlineCacheSize) { - break + try { + val directory = onlineCoverDirectory + val size = DiskUtil.getDirectorySize(directory) + if (size <= maxOnlineCacheSize) { + return@launch } + var deletedSize = 0L + val files = directory.listFiles()?.sortedBy { it.lastModified() }?.iterator() + ?: return@launch + while (files.hasNext()) { + val file = files.next() + deletedSize += file.length() + file.delete() + if (size - deletedSize <= maxOnlineCacheSize) { + break + } + } + } catch (e: Exception) { + Timber.e(e) } } lastClean = System.currentTimeMillis() @@ -166,7 +175,7 @@ class CoverCache(val context: Context) { fun setCustomCoverToCache(manga: Manga, inputStream: InputStream) { getCustomCoverFile(manga).outputStream().use { inputStream.copyTo(it) - Coil.imageLoader(context).memoryCache.remove(MemoryCache.Key(manga.key())) + context.imageLoader.memoryCache.remove(MemoryCache.Key(manga.key())) } } @@ -180,7 +189,7 @@ class CoverCache(val context: Context) { val result = getCustomCoverFile(manga).let { it.exists() && it.delete() } - Coil.imageLoader(context).memoryCache.remove(MemoryCache.Key(manga.key())) + context.imageLoader.memoryCache.remove(MemoryCache.Key(manga.key())) return result } @@ -191,22 +200,18 @@ class CoverCache(val context: Context) { * @return cover image. */ fun getCoverFile(manga: Manga): File { + val hashKey = DiskUtil.hashKeyForDisk((manga.thumbnail_url.orEmpty())) return if (manga.favorite) { - File(cacheDir, manga.key()) + File(cacheDir, hashKey) } else { - getOnlineCoverFile(manga) + File(onlineCoverDirectory, hashKey) } } - fun getOnlineCoverFile(manga: Manga): File { - return File(onlineCoverDirectory, manga.key()) - } - fun deleteFromCache(name: String?) { if (name.isNullOrEmpty()) return val file = getCoverFile(MangaImpl().apply { thumbnail_url = name }) - Coil.imageLoader(context).memoryCache.remove(MemoryCache.Key(file.name)) - + context.imageLoader.memoryCache.remove(MemoryCache.Key(file.name)) if (file.exists()) file.delete() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt index 20bdffa8e2..979be52ff4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt @@ -66,5 +66,7 @@ open class DatabaseHelper(context: Context) : inline fun inTransaction(block: () -> Unit) = db.inTransaction(block) + inline fun inTransactionReturn(block: () -> T): T = db.inTransactionReturn(block) + fun lowLevel() = db.lowLevel() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt index 333c18267c..46bad1b663 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenCallback.kt @@ -22,7 +22,7 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) { /** * Version of the database. */ - const val DATABASE_VERSION = 26 + const val DATABASE_VERSION = 27 } override fun onCreate(db: SupportSQLiteDatabase) = with(db) { @@ -108,6 +108,10 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) { db.execSQL(CachedMangaTable.dropVirtualTableQuery) db.execSQL(CachedMangaTable.createVirtualTableQuery) } + if (oldVersion < 27) { + db.execSQL(TrackTable.addStartDate) + db.execSQL(TrackTable.addFinishDate) + } } override fun onConfigure(db: SupportSQLiteDatabase) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/CategoryTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/CategoryTypeMapping.kt index c05f95c220..7ff44191ce 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/CategoryTypeMapping.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/CategoryTypeMapping.kt @@ -41,9 +41,9 @@ class CategoryPutResolver : DefaultPutResolver() { put(COL_NAME, obj.name) put(COL_ORDER, obj.order) put(COL_FLAGS, obj.flags) - if (obj.mangaSort != null) + if (obj.mangaSort != null) { put(COL_MANGA_ORDER, obj.mangaSort.toString()) - else { + } else { val orderString = obj.mangaOrder.joinToString("/") put(COL_MANGA_ORDER, orderString) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaTypeMapping.kt index 7192b65656..fa79c80f5a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaTypeMapping.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaTypeMapping.kt @@ -82,7 +82,6 @@ class MangaPutResolver : DefaultPutResolver() { put(COL_VIEWER, obj.viewer) put(COL_CHAPTER_FLAGS, obj.chapter_flags) put(COL_DATE_ADDED, obj.date_added) - put(COL_DATE_ADDED, obj.date_added) put(COL_LANG_FLAG, obj.lang_flag) put(COL_FOLLOW_STATUS, obj.follow_status?.int) put(COL_ANILIST_ID, obj.anilist_id) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt index 875059530e..94de567ad5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt @@ -11,12 +11,14 @@ import com.pushtorefresh.storio.sqlite.queries.InsertQuery import com.pushtorefresh.storio.sqlite.queries.UpdateQuery import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.TrackImpl +import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_FINISH_DATE import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_ID import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_LAST_CHAPTER_READ import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_LIBRARY_ID import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MANGA_ID import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MEDIA_ID import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SCORE +import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_START_DATE import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_STATUS import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SYNC_ID import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TITLE @@ -54,6 +56,8 @@ class TrackPutResolver : DefaultPutResolver() { put(COL_STATUS, obj.status) put(COL_TRACKING_URL, obj.tracking_url) put(COL_SCORE, obj.score) + put(COL_START_DATE, obj.started_reading_date) + put(COL_FINISH_DATE, obj.finished_reading_date) } } @@ -71,6 +75,8 @@ class TrackGetResolver : DefaultGetResolver() { status = cursor.getInt(cursor.getColumnIndex(COL_STATUS)) score = cursor.getFloat(cursor.getColumnIndex(COL_SCORE)) tracking_url = cursor.getString(cursor.getColumnIndex(COL_TRACKING_URL)) + started_reading_date = cursor.getLong(cursor.getColumnIndex(COL_START_DATE)) + finished_reading_date = cursor.getLong(cursor.getColumnIndex(COL_FINISH_DATE)) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/CachedManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/CachedManga.kt index 791db8f401..c526e54ba4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/CachedManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/CachedManga.kt @@ -5,4 +5,4 @@ data class CachedManga( val title: String, val uuid: String, val rating: String, -) \ No newline at end of file +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Category.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Category.kt index dffbc6f286..f6c1b7460b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Category.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Category.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.data.database.models import android.content.Context +import androidx.annotation.StringRes import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.library.LibrarySort import java.io.Serializable @@ -34,70 +35,24 @@ interface Category : Serializable { return ((mangaSort?.minus('a') ?: 0) % 2) != 1 } - fun sortingMode(): Int? = when (mangaSort) { - ALPHA_ASC, ALPHA_DSC -> LibrarySort.ALPHA - UPDATED_ASC, UPDATED_DSC -> LibrarySort.LATEST_CHAPTER - UNREAD_ASC, UNREAD_DSC -> LibrarySort.UNREAD - LAST_READ_ASC, LAST_READ_DSC -> LibrarySort.LAST_READ - TOTAL_ASC, TOTAL_DSC -> LibrarySort.TOTAL - DRAG_AND_DROP -> LibrarySort.DRAG_AND_DROP - DATE_ADDED_ASC, DATE_ADDED_DSC -> LibrarySort.DATE_ADDED - RATING_ASC, RATING_DSC -> LibrarySort.RATING - else -> null - } + fun sortingMode(nullAsDND: Boolean = false): LibrarySort? = LibrarySort.valueOf(mangaSort) + ?: if (nullAsDND && !isDynamic) LibrarySort.DragAndDrop else null - fun sortRes(): Int = when (mangaSort) { - ALPHA_ASC, ALPHA_DSC -> R.string.title - UPDATED_ASC, UPDATED_DSC -> R.string.latest_chapter - UNREAD_ASC, UNREAD_DSC -> R.string.unread - LAST_READ_ASC, LAST_READ_DSC -> R.string.last_read - TOTAL_ASC, TOTAL_DSC -> R.string.total_chapters - DATE_ADDED_ASC, DATE_ADDED_DSC -> R.string.date_added - RATING_ASC, RATING_DSC -> R.string.rating - else -> if (isDynamic) R.string.category else R.string.drag_and_drop - } + val isDragAndDrop + get() = ( + mangaSort == null || + mangaSort == LibrarySort.DragAndDrop.categoryValue + ) && !isDynamic - fun catSortingMode(): Int? = when (mangaSort) { - ALPHA_ASC, ALPHA_DSC -> 0 - UPDATED_ASC, UPDATED_DSC -> 1 - UNREAD_ASC, UNREAD_DSC -> 2 - LAST_READ_ASC, LAST_READ_DSC -> 3 - TOTAL_ASC, TOTAL_DSC -> 4 - DATE_ADDED_ASC, DATE_ADDED_DSC -> 5 - RATING_ASC, RATING_DSC -> 6 - else -> null - } + @StringRes + fun sortRes(): Int = + (LibrarySort.valueOf(mangaSort) ?: LibrarySort.DragAndDrop).stringRes(isDynamic) fun changeSortTo(sort: Int) { - mangaSort = when (sort) { - LibrarySort.ALPHA -> ALPHA_ASC - LibrarySort.LATEST_CHAPTER -> UPDATED_ASC - LibrarySort.UNREAD -> UNREAD_ASC - LibrarySort.LAST_READ -> LAST_READ_ASC - LibrarySort.TOTAL -> ALPHA_ASC - LibrarySort.DATE_ADDED -> DATE_ADDED_ASC - LibrarySort.RATING -> RATING_ASC - else -> ALPHA_ASC - } + mangaSort = (LibrarySort.valueOf(sort) ?: LibrarySort.Title).categoryValue } companion object { - private const val DRAG_AND_DROP = 'D' - private const val ALPHA_ASC = 'a' - private const val ALPHA_DSC = 'b' - private const val UPDATED_ASC = 'c' - private const val UPDATED_DSC = 'd' - private const val UNREAD_ASC = 'e' - private const val UNREAD_DSC = 'f' - private const val LAST_READ_ASC = 'g' - private const val LAST_READ_DSC = 'h' - private const val TOTAL_ASC = 'i' - private const val TOTAL_DSC = 'j' - private const val DATE_ADDED_ASC = 'k' - private const val DATE_ADDED_DSC = 'l' - private const val RATING_ASC = 'm' - private const val RATING_DSC = 'n' - fun create(name: String): Category = CategoryImpl().apply { this.name = name } @@ -109,18 +64,9 @@ interface Category : Serializable { fun createCustom(name: String, libSort: Int, ascending: Boolean): Category = create(name).apply { - mangaSort = when (libSort) { - LibrarySort.ALPHA -> ALPHA_ASC - LibrarySort.LATEST_CHAPTER -> UPDATED_ASC - LibrarySort.UNREAD -> UNREAD_ASC - LibrarySort.LAST_READ -> LAST_READ_ASC - LibrarySort.TOTAL -> TOTAL_ASC - LibrarySort.DATE_ADDED -> DATE_ADDED_ASC - LibrarySort.DRAG_AND_DROP -> DRAG_AND_DROP - LibrarySort.RATING -> RATING_ASC - else -> DRAG_AND_DROP - } - if (mangaSort != DRAG_AND_DROP && !ascending) { + val librarySort = LibrarySort.valueOf(libSort) ?: LibrarySort.DragAndDrop + changeSortTo(librarySort.mainValue) + if (mangaSort != LibrarySort.DragAndDrop.categoryValue && !ascending) { mangaSort = mangaSort?.plus(1) } isDynamic = true diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt index 5bd9c6861e..0b3a5c5f7b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Chapter.kt @@ -52,4 +52,3 @@ fun List.filterIfUsingCache(downloadManager: DownloadManager, manga: Ma } } } - diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt index 43340202ff..89f00f46fb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/ChapterImpl.kt @@ -39,7 +39,7 @@ class ChapterImpl : Chapter { override var mangadex_chapter_id: String = "" override var old_mangadex_id: String? = null - + override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || javaClass != other.javaClass) return false diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt index 81599bf919..1d507a795f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt @@ -10,7 +10,7 @@ import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.utils.MdUtil import eu.kanade.tachiyomi.ui.reader.ReaderActivity -import eu.kanade.tachiyomi.util.storage.DiskUtil +import tachiyomi.source.model.MangaInfo import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.util.Locale @@ -37,9 +37,7 @@ interface Manga : SManga { fun isBlank() = id == Long.MIN_VALUE - fun getGenres(): List? { - return genre?.split(", ")?.map { it.trim() } - } + fun isHidden() = status == -1 fun setChapterOrder(order: Int) { setFlags(order, SORT_MASK) @@ -63,9 +61,9 @@ interface Manga : SManga { fun showChapterTitle(defaultShow: Boolean): Boolean = chapter_flags and DISPLAY_MASK == DISPLAY_NUMBER - fun mangaType(context: Context): String { + fun seriesType(context: Context): String { return context.getString( - when (mangaType()) { + when (seriesType()) { TYPE_WEBTOON -> R.string.webtoon TYPE_MANHWA -> R.string.manhwa TYPE_MANHUA -> R.string.manhua @@ -75,14 +73,20 @@ interface Manga : SManga { ).toLowerCase(Locale.getDefault()) } + fun getGenres(): List? { + return genre?.split(",") + ?.mapNotNull { tag -> tag.trim().takeUnless { it.isBlank() } } + } + /** * The type of comic the manga is (ie. manga, manhwa, manhua) */ - fun mangaType(): Int { + fun seriesType(): Int { // lump everything as manga if not manhua or manhwa return when (lang_flag) { - "kr" -> TYPE_MANHWA - "cn" -> TYPE_MANHUA + "ko" -> TYPE_MANHWA + "zh" -> TYPE_MANHUA + "zh-hk" -> TYPE_MANHUA else -> TYPE_MANGA } } @@ -98,20 +102,20 @@ interface Manga : SManga { { tag -> tag == "long strip" || tag == "manhwa" || tag.contains("webtoon") } == true - ) + ) { ReaderActivity.WEBTOON - else if (currentTags?.any + } else if (currentTags?.any { tag -> tag == "chinese" || tag == "manhua" || tag.startsWith("english") || tag == "comic" } == true - ) + ) { ReaderActivity.LEFT_TO_RIGHT - else 0 + } else 0 } fun key(): String { - return DiskUtil.hashKeyForDisk(thumbnail_url.orEmpty()) + return "manga-id-$id" } fun getExternalLinks(): List { @@ -200,7 +204,7 @@ interface Manga : SManga { } } -fun Manga.isWebtoon() = this.genre?.contains("long strip", true) ?: false +fun Manga.isLongStrip() = this.genre?.contains("long strip", true) ?: false fun Manga.potentialAltThumbnail(): String? { // ignore null and already small thumbs @@ -209,3 +213,16 @@ fun Manga.potentialAltThumbnail(): String? { } return thumbnail_url!!.replace(".jpg", ".thumb.jpg") } + +fun Manga.toMangaInfo(): MangaInfo { + return MangaInfo( + artist = this.artist ?: "", + author = this.author ?: "", + cover = this.thumbnail_url ?: "", + description = this.description ?: "", + genres = this.getGenres() ?: emptyList(), + key = this.url, + status = this.status, + title = this.title + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaImpl.kt index 29b2ce62e5..3056b8a38d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaImpl.kt @@ -52,7 +52,11 @@ open class MangaImpl : Manga { ogGenre = value } - override var status: Int = 0 + override var status: Int + get() = if (favorite) customMangaManager.getManga(this)?.status ?: ogStatus else ogStatus + set(value) { + ogStatus = value + } override var thumbnail_url: String? = null @@ -81,6 +85,9 @@ open class MangaImpl : Manga { var ogGenre: String? = null private set + var ogStatus: Int = 0 + private set + override var follow_status: FollowStatus? = null override var lang_flag: String? = null diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/CachedMangaQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/CachedMangaQueries.kt index 320ebbeb99..ab73b55fb4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/CachedMangaQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/CachedMangaQueries.kt @@ -13,9 +13,11 @@ interface CachedMangaQueries : DbProvider { fun insertCachedManga2(cachedManga: List) = db.inTransaction { val query = RawQuery.builder() - .query("INSERT INTO ${CachedMangaTable.TABLE_FTS} " + + .query( + "INSERT INTO ${CachedMangaTable.TABLE_FTS} " + "(${CachedMangaTable.COL_MANGA_TITLE}, ${CachedMangaTable.COL_MANGA_UUID}," + - " ${CachedMangaTable.COL_MANGA_RATING}) VALUES (?, ?, ?);") + " ${CachedMangaTable.COL_MANGA_RATING}) VALUES (?, ?, ?);" + ) cachedManga.forEach { db.lowLevel().executeSQL( @@ -27,9 +29,11 @@ interface CachedMangaQueries : DbProvider { fun insertCachedManga2Single(cachedManga: CachedManga) = db.inTransaction { val query = RawQuery.builder() - .query("INSERT INTO ${CachedMangaTable.TABLE_FTS} " + + .query( + "INSERT INTO ${CachedMangaTable.TABLE_FTS} " + "(${CachedMangaTable.COL_MANGA_TITLE}, ${CachedMangaTable.COL_MANGA_UUID}," + - " ${CachedMangaTable.COL_MANGA_RATING}) VALUES (?, ?, ?);") + " ${CachedMangaTable.COL_MANGA_RATING}) VALUES (?, ?, ?);" + ) db.lowLevel().executeSQL( query.args(cachedManga.title, cachedManga.uuid, cachedManga.rating) .build() @@ -46,7 +50,7 @@ interface CachedMangaQueries : DbProvider { .listOfObjects(CachedManga::class.java) .withQuery( RawQuery.builder() - .query("SELECT * FROM ${CachedMangaTable.TABLE_FTS} LIMIT ${limit+1} OFFSET ${page*limit}") + .query("SELECT * FROM ${CachedMangaTable.TABLE_FTS} LIMIT ${limit + 1} OFFSET ${page * limit}") .build() ) .prepare() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt index 409868009e..5ff15fd543 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt @@ -6,26 +6,25 @@ import eu.kanade.tachiyomi.data.database.DbProvider import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.MangaChapter -import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory import eu.kanade.tachiyomi.data.database.resolvers.ChapterBackupPutResolver import eu.kanade.tachiyomi.data.database.resolvers.ChapterKnownBackupPutResolver import eu.kanade.tachiyomi.data.database.resolvers.ChapterProgressPutResolver import eu.kanade.tachiyomi.data.database.resolvers.ChapterSourceOrderPutResolver import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterGetResolver -import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterHistoryGetResolver import eu.kanade.tachiyomi.data.database.tables.ChapterTable import eu.kanade.tachiyomi.util.lang.sqLite -import java.util.Date interface ChapterQueries : DbProvider { - fun getChapters(manga: Manga) = db.get() + fun getChapters(manga: Manga) = getChapters(manga.id) + + fun getChapters(mangaId: Long?) = db.get() .listOfObjects(Chapter::class.java) .withQuery( Query.builder() .table(ChapterTable.TABLE) .where("${ChapterTable.COL_MANGA_ID} = ?") - .whereArgs(manga.id) + .whereArgs(mangaId) .build() ) .prepare() @@ -41,28 +40,33 @@ interface ChapterQueries : DbProvider { ) .prepare() - fun getRecentChapters(date: Date) = db.get() + fun getRecentChapters(search: String = "", offset: Int, isResuming: Boolean) = db.get() .listOfObjects(MangaChapter::class.java) .withQuery( RawQuery.builder() - .query(getRecentsQuery()) - .args(date.time) + .query(getRecentsQuery(search.sqLite, offset, isResuming)) +// .args(date.time, startDate.time) .observesTables(ChapterTable.TABLE) .build() ) .withGetResolver(MangaChapterGetResolver.INSTANCE) .prepare() - fun getUpdatedManga(date: Date, search: String = "", endless: Boolean) = db.get() - .listOfObjects(MangaChapterHistory::class.java) + /** + * Returns history of recent manga containing last read chapter in 25s + * @param date recent date range + * @offset offset the db by + */ + fun getUpdatedChaptersDistinct(search: String = "", offset: Int, isResuming: Boolean) = db.get() + .listOfObjects(MangaChapter::class.java) .withQuery( RawQuery.builder() - .query(getRecentsQueryDistinct(search.sqLite, endless)) - .args(date.time) + .query(getRecentsQueryDistinct(search.sqLite, offset, isResuming)) +// .args(date.time, startDate.time) .observesTables(ChapterTable.TABLE) .build() ) - .withGetResolver(MangaChapterHistoryGetResolver.INSTANCE) + .withGetResolver(MangaChapterGetResolver.INSTANCE) .prepare() fun getChapter(id: Long) = db.get() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt index 58d6f28659..faa7f59576 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt @@ -8,9 +8,7 @@ import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory import eu.kanade.tachiyomi.data.database.resolvers.HistoryLastReadPutResolver import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterHistoryGetResolver import eu.kanade.tachiyomi.data.database.tables.HistoryTable -import eu.kanade.tachiyomi.data.database.tables.MangaTable import eu.kanade.tachiyomi.util.lang.sqLite -import java.util.Date interface HistoryQueries : DbProvider { @@ -18,37 +16,37 @@ interface HistoryQueries : DbProvider { * Insert history into database * @param history object containing history information */ - fun insertHistory(history: History) = db.put().`object`(history).prepare() +// fun insertHistory(history: History) = db.put().`object`(history).prepare() - /** - * Returns history of recent manga containing last read chapter in 25s - * @param date recent date range - * @offset offset the db by - */ - fun getRecentManga(date: Date, offset: Int = 0, search: String = "") = db.get() - .listOfObjects(MangaChapterHistory::class.java) - .withQuery( - RawQuery.builder() - .query(getRecentMangasQuery(offset, search.sqLite)) - .args(date.time) - .observesTables(HistoryTable.TABLE) - .build() - ) - .withGetResolver(MangaChapterHistoryGetResolver.INSTANCE) - .prepare() +// /** +// * Returns history of recent manga containing last read chapter in 25s +// * @param date recent date range +// * @offset offset the db by +// */ +// fun getRecentManga(date: Date, offset: Int = 0, search: String = "") = db.get() +// .listOfObjects(MangaChapterHistory::class.java) +// .withQuery( +// RawQuery.builder() +// .query(getRecentMangasQuery(offset, search.sqLite)) +// .args(date.time) +// .observesTables(HistoryTable.TABLE) +// .build() +// ) +// .withGetResolver(MangaChapterHistoryGetResolver.INSTANCE) +// .prepare() /** * Returns history of recent manga containing last read chapter in 25s * @param date recent date range * @offset offset the db by */ - fun getRecentlyAdded(date: Date, search: String = "", endless: Boolean) = db.get() + fun getRecentMangaLimit(search: String = "", offset: Int, isResuming: Boolean) = db.get() .listOfObjects(MangaChapterHistory::class.java) .withQuery( RawQuery.builder() - .query(getRecentAdditionsQuery(search.sqLite, endless)) - .args(date.time) - .observesTables(MangaTable.TABLE) + .query(getRecentMangasLimitQuery(search.sqLite, offset, isResuming)) +// .args(date.time, startDate.time) + .observesTables(HistoryTable.TABLE) .build() ) .withGetResolver(MangaChapterHistoryGetResolver.INSTANCE) @@ -59,12 +57,12 @@ interface HistoryQueries : DbProvider { * @param date recent date range * @offset offset the db by */ - fun getRecentMangaLimit(date: Date, limit: Int = 0, search: String = "") = db.get() + fun getHistoryUngrouped(search: String = "", offset: Int, isResuming: Boolean) = db.get() .listOfObjects(MangaChapterHistory::class.java) .withQuery( RawQuery.builder() - .query(getRecentMangasLimitQuery(limit, search.sqLite)) - .args(date.time) + .query(getRecentHistoryUngrouped(search.sqLite, offset, isResuming)) +// .args(date.time, startDate.time) .observesTables(HistoryTable.TABLE) .build() ) @@ -76,12 +74,20 @@ interface HistoryQueries : DbProvider { * @param date recent date range * @offset offset the db by */ - fun getRecentsWithUnread(date: Date, search: String = "", endless: Boolean) = db.get() + fun getAllRecentsTypes(search: String = "", includeRead: Boolean, endless: Boolean, offset: Int, isResuming: Boolean) = db.get() .listOfObjects(MangaChapterHistory::class.java) .withQuery( RawQuery.builder() - .query(getRecentReadWithUnreadChapters(search.sqLite, endless)) - .args(date.time) + .query( + getAllRecentsType( + search.sqLite, + includeRead, + endless, + offset, + isResuming + ) + ) +// .args(date.time, startDate.time) .observesTables(HistoryTable.TABLE) .build() ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaQueries.kt index 516241cb6c..e0379b9e81 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaQueries.kt @@ -6,7 +6,15 @@ import com.pushtorefresh.storio.sqlite.queries.RawQuery import eu.kanade.tachiyomi.data.database.DbProvider import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.resolvers.* +import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver +import eu.kanade.tachiyomi.data.database.resolvers.MangaDateAddedPutResolver +import eu.kanade.tachiyomi.data.database.resolvers.MangaFavoritePutResolver +import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver +import eu.kanade.tachiyomi.data.database.resolvers.MangaLastUpdatedPutResolver +import eu.kanade.tachiyomi.data.database.resolvers.MangaNextUpdatedPutResolver +import eu.kanade.tachiyomi.data.database.resolvers.MangaScanlatorFilterFlagsPutResolver +import eu.kanade.tachiyomi.data.database.resolvers.MangaTitlePutResolver +import eu.kanade.tachiyomi.data.database.resolvers.MangaViewerPutResolver import eu.kanade.tachiyomi.data.database.tables.CategoryTable import eu.kanade.tachiyomi.data.database.tables.ChapterTable import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable @@ -94,9 +102,9 @@ interface MangaQueries : DbProvider { .prepare() fun updateNextUpdated(manga: Manga) = db.put() - .`object`(manga) - .withPutResolver(MangaNextUpdatedPutResolver()) - .prepare() + .`object`(manga) + .withPutResolver(MangaNextUpdatedPutResolver()) + .prepare() fun updateLastUpdated(manga: Manga) = db.put() .`object`(manga) @@ -125,12 +133,7 @@ interface MangaQueries : DbProvider { fun updateMangaInfo(manga: Manga) = db.put() .`object`(manga) - .withPutResolver(MangaInfoPutResolver()) - .prepare() - - fun resetMangaInfo(manga: Manga) = db.put() - .`object`(manga) - .withPutResolver(MangaInfoPutResolver(true)) + .withPutResolver(MangaTitlePutResolver()) .prepare() fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare() @@ -165,6 +168,16 @@ interface MangaQueries : DbProvider { ) .prepare() + fun getLastFetchedManga() = db.get() + .listOfObjects(Manga::class.java) + .withQuery( + RawQuery.builder() + .query(getLastFetchedMangaQuery()) + .observesTables(MangaTable.TABLE) + .build() + ) + .prepare() + fun getTotalChapterManga() = db.get().listOfObjects(Manga::class.java) .withQuery(RawQuery.builder().query(getTotalChapterMangaQuery()).observesTables(MangaTable.TABLE).build()).prepare() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt index 6781a1ecc6..eff8a896e3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/RawQueries.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.data.database.queries import eu.kanade.tachiyomi.data.database.tables.CachedMangaTable +import eu.kanade.tachiyomi.ui.recents.RecentsPresenter import eu.kanade.tachiyomi.data.database.tables.CategoryTable as Category import eu.kanade.tachiyomi.data.database.tables.ChapterTable as Chapter import eu.kanade.tachiyomi.data.database.tables.HistoryTable as History @@ -42,52 +43,58 @@ val libraryQuery = /** * Query to get the recent chapters of manga from the library up to a date. */ -fun getRecentsQuery() = +fun getRecentsQuery(search: String, offset: Int, isResuming: Boolean) = """ SELECT ${Manga.TABLE}.${Manga.COL_URL} as mangaUrl, * FROM ${Manga.TABLE} JOIN ${Chapter.TABLE} ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} - WHERE ${Manga.COL_FAVORITE} = 1 - AND ${Chapter.COL_DATE_UPLOAD} > ? + WHERE ${Manga.COL_FAVORITE} = 1 AND ${Chapter.COL_DATE_FETCH} > ${Manga.COL_DATE_ADDED} - ORDER BY ${Chapter.COL_DATE_UPLOAD} DESC + AND lower(${Manga.COL_TITLE}) LIKE '%$search%' + ORDER BY ${Chapter.COL_DATE_FETCH} DESC + ${limitAndOffset(true, isResuming, offset)} """ /** * Query to get the recently added manga */ -fun getRecentAdditionsQuery(search: String, endless: Boolean) = +fun getRecentAdditionsQuery(search: String, endless: Boolean, offset: Int, isResuming: Boolean) = """ SELECT ${Manga.TABLE}.${Manga.COL_URL} as mangaUrl, * FROM ${Manga.TABLE} WHERE ${Manga.COL_FAVORITE} = 1 - AND ${Manga.COL_DATE_ADDED} > ? AND lower(${Manga.COL_TITLE}) LIKE '%$search%' ORDER BY ${Manga.COL_DATE_ADDED} DESC - ${if (endless) "" else "LIMIT 8"} + ${limitAndOffset(endless, isResuming, offset)} """ +fun limitAndOffset(endless: Boolean, isResuming: Boolean, offset: Int): String { + return when { + isResuming && endless && offset > 0 -> "LIMIT $offset" + endless -> "LIMIT ${RecentsPresenter.ENDLESS_LIMIT}\nOFFSET $offset" + else -> "LIMIT ${RecentsPresenter.SHORT_LIMIT}" + } +} + /** * Query to get the manga with recently uploaded chapters */ -fun getRecentsQueryDistinct(search: String, endless: Boolean) = +fun getRecentsQueryDistinct(search: String, offset: Int = 0, isResuming: Boolean) = """ SELECT ${Manga.TABLE}.${Manga.COL_URL} as mangaUrl, ${Manga.TABLE}.*, ${Chapter.TABLE}.* FROM ${Manga.TABLE} JOIN ${Chapter.TABLE} ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} JOIN ( - SELECT ${Chapter.TABLE}.${Chapter.COL_MANGA_ID},${Chapter.TABLE}.${Chapter.COL_ID} as ${History.COL_CHAPTER_ID},MAX(${Chapter.TABLE}.${Chapter.COL_DATE_UPLOAD}) - FROM ${Chapter.TABLE} JOIN ${Manga.TABLE} - ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} - WHERE ${Chapter.COL_DATE_UPLOAD} > ? - AND ${Chapter.COL_READ} = 0 - GROUP BY ${Chapter.TABLE}.${Chapter.COL_MANGA_ID}) AS newest_chapter + SELECT ${Chapter.TABLE}.${Chapter.COL_MANGA_ID},${Chapter.TABLE}.${Chapter.COL_ID},MAX(${Chapter.TABLE}.${Chapter.COL_DATE_FETCH}) + FROM ${Chapter.TABLE} JOIN ${Manga.TABLE} + ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} + GROUP BY ${Chapter.TABLE}.${Chapter.COL_MANGA_ID}) AS newest_chapter ON ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} = newest_chapter.${Chapter.COL_MANGA_ID} WHERE ${Manga.COL_FAVORITE} = 1 - AND newest_chapter.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID} + AND newest_chapter.${Chapter.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_ID} AND ${Chapter.COL_DATE_FETCH} > ${Manga.COL_DATE_ADDED} AND lower(${Manga.COL_TITLE}) LIKE '%$search%' - ORDER BY ${Chapter.COL_DATE_UPLOAD} DESC - ${if (endless) "" else "LIMIT 8"} + ORDER BY ${Chapter.COL_DATE_FETCH} DESC + ${limitAndOffset(true, isResuming, offset)} """ /** @@ -95,9 +102,12 @@ fun getRecentsQueryDistinct(search: String, endless: Boolean) = * The max_last_read table contains the most recent chapters grouped by manga * The select statement returns all information of chapters that have the same id as the chapter in max_last_read * and are read after the given time period - * @return return limit is 25 */ -fun getRecentMangasQuery(offset: Int = 0, search: String = "") = +fun getRecentMangasLimitQuery( + search: String = "", + offset: Int = 0, + isResuming: Boolean +) = """ SELECT ${Manga.TABLE}.${Manga.COL_URL} as mangaUrl, ${Manga.TABLE}.*, ${Chapter.TABLE}.*, ${History.TABLE}.* FROM ${Manga.TABLE} @@ -111,11 +121,11 @@ fun getRecentMangasQuery(offset: Int = 0, search: String = "") = ON ${Chapter.TABLE}.${Chapter.COL_ID} = ${History.TABLE}.${History.COL_CHAPTER_ID} GROUP BY ${Chapter.TABLE}.${Chapter.COL_MANGA_ID}) AS max_last_read ON ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} = max_last_read.${Chapter.COL_MANGA_ID} - WHERE ${History.TABLE}.${History.COL_LAST_READ} > ? AND max_last_read.${History.COL_CHAPTER_ID} = ${History.TABLE}.${History.COL_CHAPTER_ID} + AND max_last_read.${History.COL_LAST_READ} > 0 AND lower(${Manga.TABLE}.${Manga.COL_TITLE}) LIKE '%$search%' ORDER BY max_last_read.${History.COL_LAST_READ} DESC - LIMIT 25 OFFSET $offset + ${limitAndOffset(true, isResuming, offset)} """ /** @@ -124,7 +134,11 @@ fun getRecentMangasQuery(offset: Int = 0, search: String = "") = * The select statement returns all information of chapters that have the same id as the chapter in max_last_read * and are read after the given time period */ -fun getRecentMangasLimitQuery(limit: Int = 25, search: String = "") = +fun getRecentHistoryUngrouped( + search: String = "", + offset: Int = 0, + isResuming: Boolean +) = """ SELECT ${Manga.TABLE}.${Manga.COL_URL} as mangaUrl, ${Manga.TABLE}.*, ${Chapter.TABLE}.*, ${History.TABLE}.* FROM ${Manga.TABLE} @@ -132,17 +146,10 @@ fun getRecentMangasLimitQuery(limit: Int = 25, search: String = "") = ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} JOIN ${History.TABLE} ON ${Chapter.TABLE}.${Chapter.COL_ID} = ${History.TABLE}.${History.COL_CHAPTER_ID} - JOIN ( - SELECT ${Chapter.TABLE}.${Chapter.COL_MANGA_ID},${Chapter.TABLE}.${Chapter.COL_ID} as ${History.COL_CHAPTER_ID}, MAX(${History.TABLE}.${History.COL_LAST_READ}) as ${History.COL_LAST_READ} - FROM ${Chapter.TABLE} JOIN ${History.TABLE} - ON ${Chapter.TABLE}.${Chapter.COL_ID} = ${History.TABLE}.${History.COL_CHAPTER_ID} - GROUP BY ${Chapter.TABLE}.${Chapter.COL_MANGA_ID}) AS max_last_read - ON ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} = max_last_read.${Chapter.COL_MANGA_ID} - WHERE ${History.TABLE}.${History.COL_LAST_READ} > ? - AND max_last_read.${History.COL_CHAPTER_ID} = ${History.TABLE}.${History.COL_CHAPTER_ID} + AND ${History.TABLE}.${History.COL_LAST_READ} > 0 AND lower(${Manga.TABLE}.${Manga.COL_TITLE}) LIKE '%$search%' - ORDER BY max_last_read.${History.COL_LAST_READ} DESC - LIMIT $limit + ORDER BY ${History.TABLE}.${History.COL_LAST_READ} DESC + ${limitAndOffset(true, isResuming, offset)} """ /** @@ -151,39 +158,98 @@ fun getRecentMangasLimitQuery(limit: Int = 25, search: String = "") = * The max_last_read table contains the most recent chapters grouped by manga * The select statement returns all information of chapters that have the same id as the chapter in max_last_read * and are read after the given time period + * The Second Union/Select gets recents chapters + * Final Union gets newly added manga */ -fun getRecentReadWithUnreadChapters(search: String = "", endless: Boolean) = - """ - SELECT ${Manga.TABLE}.${Manga.COL_URL} as mangaUrl, ${Manga.TABLE}.*, ${Chapter.TABLE}.*, ${History.TABLE}.* +fun getAllRecentsType( + search: String = "", + includeRead: Boolean, + endless: Boolean, + offset: Int = 0, + isResuming: Boolean +) = """ + SELECT * FROM + (SELECT mangas.url as mangaUrl, mangas.*, chapters.*, history.* FROM ( - SELECT ${Manga.TABLE}.* - FROM ${Manga.TABLE} + SELECT mangas.* + FROM mangas LEFT JOIN ( - SELECT ${Chapter.COL_MANGA_ID}, COUNT(*) AS unread - FROM ${Chapter.TABLE} - WHERE ${Chapter.COL_READ} = 0 - GROUP BY ${Chapter.COL_MANGA_ID} + SELECT manga_id, COUNT(*) AS unread + FROM chapters + WHERE read = 0 + GROUP BY manga_id ) AS C - ON ${Manga.COL_ID} = C.${Chapter.COL_MANGA_ID} - WHERE C.unread > 0 - GROUP BY ${Manga.COL_ID} - ORDER BY ${Manga.COL_TITLE} - ) AS ${Manga.TABLE} - JOIN ${Chapter.TABLE} - ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} - JOIN ${History.TABLE} - ON ${Chapter.TABLE}.${Chapter.COL_ID} = ${History.TABLE}.${History.COL_CHAPTER_ID} - JOIN ( + ON _id = C.manga_id + ${if (includeRead) "" else "WHERE C.unread > 0"} + GROUP BY _id + ORDER BY title + ) AS mangas + JOIN chapters + ON mangas._id = chapters.manga_id + JOIN history + ON chapters._id = history.history_chapter_id + JOIN ( SELECT ${Chapter.TABLE}.${Chapter.COL_MANGA_ID},${Chapter.TABLE}.${Chapter.COL_ID} as ${History.COL_CHAPTER_ID}, MAX(${History.TABLE}.${History.COL_LAST_READ}) as ${History.COL_LAST_READ} FROM ${Chapter.TABLE} JOIN ${History.TABLE} ON ${Chapter.TABLE}.${Chapter.COL_ID} = ${History.TABLE}.${History.COL_CHAPTER_ID} GROUP BY ${Chapter.TABLE}.${Chapter.COL_MANGA_ID}) AS max_last_read ON ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} = max_last_read.${Chapter.COL_MANGA_ID} - WHERE ${History.TABLE}.${History.COL_LAST_READ} > ? AND max_last_read.${History.COL_CHAPTER_ID} = ${History.TABLE}.${History.COL_CHAPTER_ID} - AND lower(${Manga.TABLE}.${Manga.COL_TITLE}) LIKE '%$search%' - ORDER BY max_last_read.${History.COL_LAST_READ} DESC - ${if (endless) "" else "LIMIT 8"} + AND max_last_read.${History.COL_LAST_READ} > 0 + AND lower(${Manga.COL_TITLE}) LIKE '%$search%') + UNION + SELECT * FROM + (SELECT ${Manga.TABLE}.${Manga.COL_URL} as mangaUrl, ${Manga.TABLE}.*, ${Chapter.TABLE}.*, + Null as history_id, + Null as history_chapter_id, + chapters.date_fetch as history_last_read, + Null as history_time_read + FROM ${Manga.TABLE} + JOIN ${Chapter.TABLE} + ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} + JOIN ( + SELECT ${Chapter.TABLE}.${Chapter.COL_MANGA_ID},${Chapter.TABLE}.${Chapter.COL_ID} as ${History.COL_CHAPTER_ID},MAX(${Chapter.TABLE}.${Chapter.COL_DATE_UPLOAD}) + FROM ${Chapter.TABLE} JOIN ${Manga.TABLE} + ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} + WHERE ${Chapter.COL_READ} = 0 + GROUP BY ${Chapter.TABLE}.${Chapter.COL_MANGA_ID}) AS newest_chapter + ON ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} = newest_chapter.${Chapter.COL_MANGA_ID} + WHERE ${Manga.COL_FAVORITE} = 1 + AND newest_chapter.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID} + AND ${Chapter.COL_DATE_FETCH} > ${Manga.COL_DATE_ADDED} + AND lower(${Manga.COL_TITLE}) LIKE '%$search%') + UNION + SELECT * FROM + (SELECT mangas.url as mangaUrl, + mangas.*, + Null as _id, + Null as manga_id, + Null as url, + Null as name, + Null as read, + Null as scanlator, + Null as bookmark, + Null as date_fetch, + Null as date_upload, + Null as last_page_read, + Null as pages_left, + Null as chapter_number, + Null as source_order, + Null as vol, + Null as chapter_txt, + Null as chapter_title, + Null as mangadex_chapter_id, + Null as old_mangadex_chapter_id, + Null as language, + Null as history_id, + Null as history_chapter_id, + ${Manga.TABLE}.${Manga.COL_DATE_ADDED} as history_last_read, + Null as history_time_read + FROM mangas + WHERE ${Manga.COL_FAVORITE} = 1 + AND lower(${Manga.COL_TITLE}) LIKE '%$search%') + ORDER BY history_last_read DESC + ${limitAndOffset(endless, isResuming, offset)} """ fun getHistoryByMangaId() = @@ -217,6 +283,17 @@ fun getLastReadMangaQuery() = ORDER BY max DESC """ +fun getLastFetchedMangaQuery() = + """ + SELECT ${Manga.TABLE}.*, MAX(${Chapter.TABLE}.${Chapter.COL_DATE_FETCH}) AS max + FROM ${Manga.TABLE} + JOIN ${Chapter.TABLE} + ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} + WHERE ${Manga.TABLE}.${Manga.COL_FAVORITE} = 1 + GROUP BY ${Manga.TABLE}.${Manga.COL_ID} + ORDER BY max DESC +""" + fun getTotalChapterMangaQuery() = """ SELECT ${Manga.TABLE}.* @@ -244,6 +321,6 @@ fun searchCachedMangaQuery(query: String, page: Int, limit: Int): String { return """ SELECT * FROM ${CachedMangaTable.TABLE_FTS} WHERE ${CachedMangaTable.COL_MANGA_TITLE} MATCH '$queryCleaned' - LIMIT ${limit+1} OFFSET ${page*limit} + LIMIT ${limit + 1} OFFSET ${page * limit} """ } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/HistoryLastReadPutResolver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/HistoryLastReadPutResolver.kt index d33dbc3359..24771204e9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/HistoryLastReadPutResolver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/HistoryLastReadPutResolver.kt @@ -1,7 +1,7 @@ package eu.kanade.tachiyomi.data.database.resolvers -import android.content.ContentValues import androidx.annotation.NonNull +import androidx.core.content.contentValuesOf import com.pushtorefresh.storio.sqlite.StorIOSQLite import com.pushtorefresh.storio.sqlite.operations.put.PutResult import com.pushtorefresh.storio.sqlite.queries.Query @@ -27,19 +27,15 @@ class HistoryLastReadPutResolver : HistoryPutResolver() { .build() ) - val putResult: PutResult - - try { - if (cursor.count == 0) { + val putResult = cursor.use { putCursor -> + if (putCursor.count == 0) { val insertQuery = mapToInsertQuery(history) val insertedId = db.lowLevel().insert(insertQuery, mapToContentValues(history)) - putResult = PutResult.newInsertResult(insertedId, insertQuery.table()) + PutResult.newInsertResult(insertedId, insertQuery.table()) } else { val numberOfRowsUpdated = db.lowLevel().update(updateQuery, mapToUpdateContentValues(history)) - putResult = PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table()) + PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table()) } - } finally { - cursor.close() } putResult @@ -59,7 +55,8 @@ class HistoryLastReadPutResolver : HistoryPutResolver() { * Create content query * @param history object */ - fun mapToUpdateContentValues(history: History) = ContentValues(1).apply { - put(HistoryTable.COL_LAST_READ, history.last_read) - } + private fun mapToUpdateContentValues(history: History) = + contentValuesOf( + HistoryTable.COL_LAST_READ to history.last_read + ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaChapterHistoryGetResolver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaChapterHistoryGetResolver.kt index 63d33d1332..2058a72fab 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaChapterHistoryGetResolver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaChapterHistoryGetResolver.kt @@ -49,7 +49,13 @@ class MangaChapterHistoryGetResolver : DefaultGetResolver() val history = if (!cursor.isNull(cursor.getColumnIndex(HistoryTable.COL_ID))) historyGetResolver.mapFromCursor( cursor - ) else HistoryImpl() + ) else HistoryImpl().apply { + last_read = try { + cursor.getLong(cursor.getColumnIndex(HistoryTable.COL_LAST_READ)) + } catch (e: Exception) { + 0L + } + } // Make certain column conflicts are dealt with if (chapter.id != null) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaInfoPutResolver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaInfoPutResolver.kt index b1a2c826c4..82719f95fb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaInfoPutResolver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/MangaInfoPutResolver.kt @@ -9,11 +9,11 @@ import eu.kanade.tachiyomi.data.database.inTransactionReturn import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.tables.MangaTable -class MangaInfoPutResolver(val reset: Boolean = false) : PutResolver() { +class MangaInfoPutResolver() : PutResolver() { override fun performPut(db: StorIOSQLite, manga: Manga) = db.inTransactionReturn { val updateQuery = mapToUpdateQuery(manga) - val contentValues = if (reset) resetToContentValues(manga) else mapToContentValues(manga) + val contentValues = mapToContentValues(manga) val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues) PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table()) @@ -31,14 +31,6 @@ class MangaInfoPutResolver(val reset: Boolean = false) : PutResolver() { put(MangaTable.COL_AUTHOR, manga.originalAuthor) put(MangaTable.COL_ARTIST, manga.originalArtist) put(MangaTable.COL_DESCRIPTION, manga.originalDescription) - } - - fun resetToContentValues(manga: Manga) = ContentValues(1).apply { - val splitter = "▒ ▒∩▒" - put(MangaTable.COL_TITLE, manga.title.split(splitter).last()) - put(MangaTable.COL_GENRE, manga.genre?.split(splitter)?.lastOrNull()) - put(MangaTable.COL_AUTHOR, manga.author?.split(splitter)?.lastOrNull()) - put(MangaTable.COL_ARTIST, manga.artist?.split(splitter)?.lastOrNull()) - put(MangaTable.COL_DESCRIPTION, manga.description?.split(splitter)?.lastOrNull()) + put(MangaTable.COL_STATUS, manga.originalStatus) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/CachedMangaTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/CachedMangaTable.kt index 4fb117d2db..69f5e7d7ae 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/CachedMangaTable.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/CachedMangaTable.kt @@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.data.database.tables object CachedMangaTable { const val TABLE_FTS = "cached_manga_fts" - + const val COL_MANGA_TITLE = "manga_title" const val COL_MANGA_UUID = "manga_uuid" @@ -18,5 +18,4 @@ object CachedMangaTable { get() = """CREATE VIRTUAL TABLE $TABLE_FTS USING fts5($COL_MANGA_TITLE, $COL_MANGA_UUID UNINDEXED, $COL_MANGA_RATING UNINDEXED)""" - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt index 687737079a..5b4564a11e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaTable.kt @@ -162,5 +162,4 @@ object MangaTable { val addMergeMangaImageCol: String get() = "ALTER TABLE ${MangaTable.TABLE} ADD COLUMN ${MangaTable.COL_MERGE_MANGA_IMAGE_URL} TEXT DEFAULT NULL" - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/SimilarTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/SimilarTable.kt index e698f4202b..4e7ffdb5a1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/SimilarTable.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/SimilarTable.kt @@ -24,5 +24,4 @@ object SimilarTable { val createMangaIdIndexQuery: String get() = "CREATE INDEX ${SimilarTable.TABLE}_${SimilarTable.COL_MANGA_ID}_index ON ${SimilarTable.TABLE}(${SimilarTable.COL_MANGA_ID})" - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt index 0a3ccf3442..c8dff441a4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt @@ -26,6 +26,10 @@ object TrackTable { const val COL_TRACKING_URL = "remote_url" + const val COL_START_DATE = "start_date" + + const val COL_FINISH_DATE = "finish_date" + val createTableQuery: String get() = """CREATE TABLE $TABLE( @@ -40,6 +44,8 @@ object TrackTable { $COL_STATUS INTEGER NOT NULL, $COL_SCORE FLOAT NOT NULL, $COL_TRACKING_URL TEXT NOT NULL, + $COL_START_DATE LONG NOT NULL, + $COL_FINISH_DATE LONG NOT NULL, UNIQUE ($COL_MANGA_ID, $COL_SYNC_ID) ON CONFLICT REPLACE, FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID}) ON DELETE CASCADE @@ -50,4 +56,10 @@ object TrackTable { val addLibraryId: String get() = "ALTER TABLE $TABLE ADD COLUMN $COL_LIBRARY_ID INTEGER NULL" + + val addStartDate: String + get() = "ALTER TABLE $TABLE ADD COLUMN $COL_START_DATE LONG NOT NULL DEFAULT 0" + + val addFinishDate: String + get() = "ALTER TABLE $TABLE ADD COLUMN $COL_FINISH_DATE LONG NOT NULL DEFAULT 0" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt index e4aeb72e4d..981eb8ed88 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt @@ -102,7 +102,6 @@ class DownloadCache( checkRenew() if (forceCheckFolder) { - val mangaDir = provider.findMangaDir(manga, sourceManager.getMangadex()) if (mangaDir != null) { @@ -202,7 +201,6 @@ class DownloadCache( */ @Synchronized fun addChapter(chapterDirName: String, mangaUniFile: UniFile, manga: Manga) { - val id = manga.id ?: return val files = mangaFiles[id] val mangadexId = chapterDirName.substringAfterLast("- ") @@ -271,6 +269,14 @@ class DownloadCache( mangaFiles.remove(manga.id) } + /** + * Class to store the files under the root downloads directory. + */ + private class RootDirectory( + val dir: UniFile, + var files: Map = hashMapOf() + ) + /** * Class to store the files under a source directory. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt index 5b1fa14de6..1efdc5fc62 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt @@ -106,10 +106,11 @@ class DownloadManager(val context: Context) { queue.add(0, download) reorderQueue(queue) if (isPaused()) { - if (DownloadService.isRunning(context)) + if (DownloadService.isRunning(context)) { downloader.start() - else + } else { DownloadService.start(context) + } } } @@ -248,7 +249,9 @@ class DownloadManager(val context: Context) { queue.remove(chapters) val chapterDirs = provider.findChapterDirs(chapters, manga, source) + provider.findTempChapterDirs( - chapters, manga, source + chapters, + manga, + source ) chapterDirs.forEach { it.delete() } cache.removeChapters(chapters, manga) @@ -324,6 +327,7 @@ class DownloadManager(val context: Context) { queue.remove(manga) provider.findMangaDir(manga, sourceManager.getMangadex())?.delete() cache.removeManga(manga) + queue.updateListeners() } /** @@ -370,8 +374,8 @@ class DownloadManager(val context: Context) { fun addListener(listener: DownloadQueue.DownloadListener) = queue.addListener(listener) fun removeListener(listener: DownloadQueue.DownloadListener) = queue.removeListener(listener) - - //forceRefresh the cache + + // forceRefresh the cache fun refreshCache() { cache.forceRenewCache() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt index 3b9a783623..0094c30bec 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt @@ -1,6 +1,8 @@ package eu.kanade.tachiyomi.data.download +import android.app.PendingIntent import android.content.Context +import android.content.Intent import android.graphics.BitmapFactory import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat @@ -78,7 +80,7 @@ internal class DownloadNotifier(private val context: Context) { * Dismiss the downloader's notification. Downloader error notifications use a different id, so * those can only be dismissed by the user. */ - fun dismissProgress() { + fun dismiss() { context.notificationManager.cancel(Notifications.ID_DOWNLOAD_CHAPTER_PROGRESS) } @@ -117,6 +119,7 @@ internal class DownloadNotifier(private val context: Context) { setAutoCancel(false) clearActions() // Open download manager when clicked + color = ContextCompat.getColor(context, R.color.colorAccent) setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) isDownloading = true // Pause action @@ -129,7 +132,10 @@ internal class DownloadNotifier(private val context: Context) { val title = download.manga.title.chop(15) val quotedTitle = Pattern.quote(title) - val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "") + val chapter = download.chapter.name.replaceFirst( + "$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), + "" + ) setContentTitle("$title - $chapter".chop(30)) setContentText( context.getString(R.string.downloading_progress) @@ -151,6 +157,7 @@ internal class DownloadNotifier(private val context: Context) { setSmallIcon(R.drawable.ic_pause_24dp) setAutoCancel(false) setProgress(0, 0, false) + color = ContextCompat.getColor(context, R.color.colorAccent) clearActions() // Open download manager when clicked setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) @@ -182,6 +189,7 @@ internal class DownloadNotifier(private val context: Context) { with(errorNotificationBuilder) { setContentTitle(context.getString(R.string.downloads)) setContentText(reason) + color = ContextCompat.getColor(context, R.color.colorAccent) setSmallIcon(android.R.drawable.stat_sys_warning) setAutoCancel(true) clearActions() @@ -201,17 +209,36 @@ internal class DownloadNotifier(private val context: Context) { * @param error string containing error information. * @param chapter string containing chapter title. */ - fun onError(error: String? = null, chapter: String? = null) { + fun onError( + error: String? = null, + chapter: String? = null, + customIntent: Intent? = null + ) { // Create notification with(errorNotificationBuilder) { setContentTitle(chapter ?: context.getString(R.string.download_error)) setContentText(error ?: context.getString(R.string.could_not_download_unexpected_error)) - setStyle(NotificationCompat.BigTextStyle().bigText(error ?: context.getString(R.string.could_not_download_unexpected_error))) + setStyle( + NotificationCompat.BigTextStyle().bigText( + error ?: context.getString(R.string.could_not_download_unexpected_error) + ) + ) setSmallIcon(android.R.drawable.stat_sys_warning) setCategory(NotificationCompat.CATEGORY_ERROR) clearActions() setAutoCancel(true) - setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) + if (customIntent != null) { + setContentIntent( + PendingIntent.getActivity( + context, + 0, + customIntent, + PendingIntent.FLAG_UPDATE_CURRENT + ) + ) + } else { + setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) + } color = ContextCompat.getColor(context, R.color.colorAccent) setProgress(0, 0, false) show(Notifications.ID_DOWNLOAD_CHAPTER_ERROR) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt index bcd1445039..d96f2a541a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt @@ -50,7 +50,6 @@ class DownloadProvider(private val context: Context) { * @param manga the manga to query. * @param source the source of the manga. */ - @Synchronized internal fun getMangaDir(manga: Manga, source: Source): UniFile { try { return downloadsDir.createDirectory(getSourceDirName(source)) @@ -67,7 +66,7 @@ class DownloadProvider(private val context: Context) { * @param source the source to query. */ fun findSourceDir(source: Source): UniFile? { - return downloadsDir.findFile(getSourceDirName(source)) + return downloadsDir.findFile(getSourceDirName(source), true) } /** @@ -78,7 +77,7 @@ class DownloadProvider(private val context: Context) { */ fun findMangaDir(manga: Manga, source: Source): UniFile? { val sourceDir = findSourceDir(source) - return sourceDir?.findFile(getMangaDirName(manga)) + return sourceDir?.findFile(getMangaDirName(manga), true) } /** @@ -119,7 +118,7 @@ class DownloadProvider(private val context: Context) { return mangaDir.listFiles()!!.asList().filter { file -> file.name?.let { fileName -> val mangadexId = fileName.substringAfterLast(" - ", "") - //legacy dex id + // legacy dex id if (mangadexId.isNotEmpty() && mangadexId.isUUID()) { return@filter idHashSet.contains(mangadexId) } else if (mangadexId.isNotEmpty() && mangadexId.isDigitsOnly()) { @@ -218,7 +217,6 @@ class DownloadProvider(private val context: Context) { * @param chapter the chapter to query. */ fun getChapterDirName(chapter: Chapter, useNewId: Boolean = true): String { - if (chapter.isMergedChapter()) { return getJ2kChapterName(chapter) } else { @@ -247,7 +245,7 @@ class DownloadProvider(private val context: Context) { getChapterDirName(chapter, true), // chater names from j2k getJ2kChapterName(chapter), - //legacy manga id + // legacy manga id getChapterDirName(chapter, false), // Legacy chapter directory name used in v0.8.4 and before DiskUtil.buildValidFilename(chapter.name) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt index cfff137c2b..b407edf02d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt @@ -201,10 +201,11 @@ class DownloadService : Service() { */ private fun listenDownloaderState() { subscriptions += downloadManager.runningRelay.subscribe { running -> - if (running) + if (running) { wakeLock.acquireIfNeeded() - else + } else { wakeLock.releaseIfNeeded() + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt index 2b26383df9..60d6d3c5fa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt @@ -1,11 +1,17 @@ package eu.kanade.tachiyomi.data.download import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Environment +import android.provider.Settings import android.webkit.MimeTypeMap +import androidx.core.net.toUri import com.elvishew.xlog.XLog import com.hippo.unifile.UniFile import com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.PublishRelay +import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga @@ -22,14 +28,15 @@ import eu.kanade.tachiyomi.util.lang.plusAssign import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.saveTo import eu.kanade.tachiyomi.util.system.ImageUtil +import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.util.system.launchNow -import eu.kanade.tachiyomi.util.system.launchUI import kotlinx.coroutines.async import okhttp3.Response import rx.Observable import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers import rx.subscriptions.CompositeSubscription +import uy.kohesive.injekt.injectLazy import java.io.File /** @@ -53,6 +60,8 @@ class Downloader( private val sourceManager: SourceManager ) { + private val chapterCache: ChapterCache by injectLazy() + /** * Store for persisting downloads across restarts. */ @@ -108,8 +117,8 @@ class Downloader( notifier.paused = false if (!subscriptions.hasSubscriptions()) initializeSubscriptions() - val pending = queue.filter { it.status != Download.DOWNLOADED } - pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE } + val pending = queue.filter { it.status != Download.State.DOWNLOADED } + pending.forEach { if (it.status != Download.State.QUEUE) it.status = Download.State.QUEUE } downloadsRelay.call(pending) return pending.isNotEmpty() @@ -120,22 +129,22 @@ class Downloader( */ fun stop(reason: String? = null) { destroySubscriptions() - queue.filter { it.status == Download.DOWNLOADING }.forEach { it.status = Download.ERROR } + queue + .filter { it.status == Download.State.DOWNLOADING } + .forEach { it.status = Download.State.ERROR } if (reason != null) { - if (reason != "**") { - notifier.onWarning(reason) - } + notifier.onWarning(reason) } else { if (notifier.paused) { if (queue.isEmpty()) { - notifier.dismissProgress() + notifier.dismiss() } else { notifier.paused = false notifier.onDownloadPaused() } } else { - notifier.downloadFinished() + notifier.dismiss() } } } @@ -145,7 +154,9 @@ class Downloader( */ fun pause() { destroySubscriptions() - queue.filter { it.status == Download.DOWNLOADING }.forEach { it.status = Download.QUEUE } + queue + .filter { it.status == Download.State.DOWNLOADING } + .forEach { it.status = Download.State.QUEUE } notifier.paused = true } @@ -164,11 +175,12 @@ class Downloader( // Needed to update the chapter view if (isNotification) { - queue.filter { it.status == Download.QUEUE } - .forEach { it.status = Download.NOT_DOWNLOADED } + queue + .filter { it.status == Download.State.QUEUE } + .forEach { it.status = Download.State.NOT_DOWNLOADED } } queue.clear() - notifier.dismissProgress() + notifier.dismiss() } /** @@ -179,15 +191,15 @@ class Downloader( fun clearQueue(manga: Manga, isNotification: Boolean = false) { // Needed to update the chapter view if (isNotification) { - queue.filter { it.status == Download.QUEUE && it.manga.id == manga.id } - .forEach { it.status = Download.NOT_DOWNLOADED } + queue.filter { it.status == Download.State.QUEUE && it.manga.id == manga.id } + .forEach { it.status = Download.State.NOT_DOWNLOADED } } queue.remove(manga) if (queue.isEmpty()) { if (DownloadService.isRunning(context)) DownloadService.stop(context) - stop("**") + stop() } - notifier.dismissProgress() + notifier.dismiss() } /** @@ -233,9 +245,10 @@ class Downloader( * @param chapters the list of chapters to download. * @param autoStart whether to start the downloader after enqueing the chapters. */ - fun queueChapters(manga: Manga, chapters: List, autoStart: Boolean) = launchUI { + fun queueChapters(manga: Manga, chapters: List, autoStart: Boolean) = launchIO { val mangadexSource = sourceManager.getMangadex() val mergedSource = sourceManager.getMergeSource() + val wasEmpty = queue.isEmpty() // Called in background thread, the operation can be slow with SAF. val chaptersWithoutDir = async { @@ -279,18 +292,38 @@ class Downloader( * @param download the chapter to be downloaded. */ private fun downloadChapter(download: Download): Observable = Observable.defer { - val chapterDirname = provider.getChapterDirName(download.chapter) - val mangaDir = provider.getMangaDir(download.manga, sourceManager.getMangadex()) - val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX) + val availSpace = DiskUtil.getAvailableStorageSpace(mangaDir) + if (availSpace != -1L && availSpace < MIN_DISK_SPACE) { + download.status = Download.State.ERROR + notifier.onError(context.getString(R.string.couldnt_download_low_space), download.chapter.name) + return@defer Observable.just(download) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + !Environment.isExternalStorageManager() + ) { + val intent = Intent( + Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + "package:${context.packageName}".toUri() + ) + + notifier.onError( + context.getString(R.string.external_storage_download_notice), + download.chapter.name, + intent + ) + return@defer Observable.just(download) + } + val chapterDirname = provider.getChapterDirName(download.chapter) + val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX) val pagesToDownload = if (download.source is MergeSource) 3 else 10 val pageListObservable = if (download.pages == null) { // Pull page list from network and add them to download object download.source.fetchPageList(download.chapter).doOnNext { pages -> if (pages.isEmpty()) { - throw Exception("Page list is empty") + throw Exception(context.getString(R.string.no_pages_found)) } download.pages = pages } @@ -299,25 +332,31 @@ class Downloader( Observable.just(download.pages!!) } - pageListObservable.doOnNext { _ -> - // Delete all temporary (unfinished) files - tmpDir.listFiles()?.filter { it.name!!.endsWith(".tmp") }?.forEach { it.delete() } + pageListObservable + .doOnNext { _ -> + // Delete all temporary (unfinished) files + tmpDir.listFiles() + ?.filter { it.name!!.endsWith(".tmp") } + ?.forEach { it.delete() } - download.downloadedImages = 0 - download.status = Download.DOWNLOADING - } + download.downloadedImages = 0 + download.status = Download.State.DOWNLOADING + } // Get all the URLs to the source images, fetch pages if necessary .flatMap { Observable.from(it) } // Start downloading images, consider we can have downloaded images already + // Concurrently do 5 pages at a time .flatMap({ page -> getOrDownloadImage(page, download, tmpDir) }, pagesToDownload) // Do when page is downloaded. - .doOnNext { notifier.onProgressChange(download) }.toList().map { _ -> download } + .doOnNext { notifier.onProgressChange(download) } + .toList() + .map { download } // Do after download completes .doOnNext { ensureSuccessfulDownload(download, mangaDir, tmpDir, chapterDirname) } // If the page list threw, it will resume here .onErrorReturn { error -> XLog.e(error) - download.status = Download.ERROR + download.status = Download.State.ERROR notifier.onError(error.message, download.chapter.name) download } @@ -337,7 +376,9 @@ class Downloader( tmpDir: UniFile ): Observable { // If the image URL is empty, do nothing - if (page.imageUrl == null) return Observable.just(page) + if (page.imageUrl == null) { + return Observable.just(page) + } val filename = String.format("%03d", page.number) val tmpFile = tmpDir.findFile("$filename.tmp") @@ -347,16 +388,11 @@ class Downloader( // Try to find the image file. val imageFile = tmpDir.listFiles()!!.find { it.name!!.startsWith("$filename.") } - val cache = ChapterCache(context) + // If the image is already downloaded, do nothing. Otherwise download from network val pageObservable = when { imageFile != null -> Observable.just(imageFile) - cache.isImageInCache(page.imageUrl!!) -> moveFromCache( - page, - cache.getImageFile(page.imageUrl!!), - tmpDir, - filename - ) + chapterCache.isImageInCache(page.imageUrl!!) -> moveImageFromCache(chapterCache.getImageFile(page.imageUrl!!), tmpDir, filename) else -> downloadImage(page, download.source, tmpDir, filename) } @@ -367,7 +403,8 @@ class Downloader( page.progress = 100 download.downloadedImages++ page.status = Page.READY - }.map { page } + } + .map { page } // Mark this page as error and allow to download the remaining .onErrorReturn { page.progress = 0 @@ -377,30 +414,27 @@ class Downloader( } /** - * Returns the observable which takes from the downloaded image from cache + * Return the observable which copies the image from cache. * - * @param page the page to download. - * @param file the file from cache + * @param cacheFile the file from cache. * @param tmpDir the temporary directory of the download. * @param filename the filename of the image. */ - private fun moveFromCache( - page: Page, - file: File, + private fun moveImageFromCache( + cacheFile: File, tmpDir: UniFile, filename: String ): Observable { - return Observable.just(file).map { + return Observable.just(cacheFile).map { val tmpFile = tmpDir.createFile("$filename.tmp") - val inputStream = file.inputStream() - inputStream.use { input -> + cacheFile.inputStream().use { input -> tmpFile.openOutputStream().use { output -> input.copyTo(output) } } - val extension = ImageUtil.findImageType(file.inputStream()) ?: return@map tmpFile + val extension = ImageUtil.findImageType(cacheFile.inputStream()) ?: return@map tmpFile tmpFile.renameTo("$filename.${extension.extension}") - file.delete() + cacheFile.delete() tmpFile } } @@ -421,19 +455,20 @@ class Downloader( ): Observable { page.status = Page.DOWNLOAD_IMAGE page.progress = 0 - return source.fetchImage(page).map { response -> - val file = tmpDir.createFile("$filename.tmp") - try { - response.body!!.source().saveTo(file.openOutputStream()) - val extension = getImageExtension(response, file) - file.renameTo("$filename.$extension") - } catch (e: Exception) { - response.close() - file.delete() - throw e + return source.fetchImage(page) + .map { response -> + val file = tmpDir.createFile("$filename.tmp") + try { + response.body!!.source().saveTo(file.openOutputStream()) + val extension = getImageExtension(response, file) + file.renameTo("$filename.$extension") + } catch (e: Exception) { + response.close() + file.delete() + throw e + } + file } - file - } // Retry 3 times, waiting 2, 4 and 8 seconds between attempts. .retryWhen(RetryWithDelay(3, { (2 shl it - 1) * 1000 }, Schedulers.trampoline())) } @@ -448,7 +483,7 @@ class Downloader( private fun getImageExtension(response: Response, file: UniFile): String { // Read content type if available. val mime = response.body?.contentType()?.let { ct -> "${ct.type}/${ct.subtype}" } - // Else guess from the uri. + // Else guess from the uri. ?: context.contentResolver.getType(file.uri) // Else read magic numbers. ?: ImageUtil.findImageType { file.openInputStream() }?.mime @@ -470,18 +505,17 @@ class Downloader( tmpDir: UniFile, dirname: String ) { - // Ensure that the chapter folder has all the images. val downloadedImages = tmpDir.listFiles().orEmpty().filterNot { it.name!!.endsWith(".tmp") } download.status = if (downloadedImages.size == download.pages!!.size) { - Download.DOWNLOADED + Download.State.DOWNLOADED } else { - Download.ERROR + Download.State.ERROR } // Only rename the directory if it's downloaded. - if (download.status == Download.DOWNLOADED) { + if (download.status == Download.State.DOWNLOADED) { tmpDir.renameTo(dirname) cache.addChapter(dirname, mangaDir, download.manga) @@ -494,7 +528,7 @@ class Downloader( */ private fun completeDownload(download: Download) { // Delete successful downloads from queue - if (download.status == Download.DOWNLOADED) { + if (download.status == Download.State.DOWNLOADED) { // remove downloaded chapter from queue queue.remove(download) } @@ -507,10 +541,13 @@ class Downloader( * Returns true if all the queued downloads are in DOWNLOADED or ERROR state. */ private fun areAllDownloadsFinished(): Boolean { - return queue.none { it.status <= Download.DOWNLOADING } + return queue.none { it.status <= Download.State.DOWNLOADING } } companion object { const val TMP_DIR_SUFFIX = "_tmp" + + // Arbitrary minimum required space to start a download: 50 MB + const val MIN_DISK_SPACE = 50 * 1024 * 1024 } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt index 6701d43a09..a720b411af 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt @@ -11,11 +11,14 @@ class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) { var pages: List? = null - @Volatile @Transient var totalProgress: Int = 0 + @Volatile @Transient + var totalProgress: Int = 0 - @Volatile @Transient var downloadedImages: Int = 0 + @Volatile @Transient + var downloadedImages: Int = 0 - @Volatile @Transient var status: Int = 0 + @Volatile @Transient + var status: State = State.default set(status) { field = status statusSubject?.onNext(this) @@ -46,12 +49,17 @@ class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) { statusCallback = f } - companion object { - const val CHECKED = -1 - const val NOT_DOWNLOADED = 0 - const val QUEUE = 1 - const val DOWNLOADING = 2 - const val DOWNLOADED = 3 - const val ERROR = 4 + enum class State { + CHECKED, + NOT_DOWNLOADED, + QUEUE, + DOWNLOADING, + DOWNLOADED, + ERROR + ; + + companion object { + val default = NOT_DOWNLOADED + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt index 585206c711..d1a2228525 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/DownloadQueue.kt @@ -5,7 +5,6 @@ import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.DownloadStore import eu.kanade.tachiyomi.source.model.Page -import rx.Observable import rx.subjects.PublishSubject import java.util.concurrent.CopyOnWriteArrayList @@ -25,7 +24,7 @@ class DownloadQueue( downloads.forEach { download -> download.setStatusSubject(statusSubject) download.setStatusCallback(::setPagesFor) - download.status = Download.QUEUE + download.status = Download.State.QUEUE } queue.addAll(downloads) store.addAll(downloads) @@ -37,8 +36,9 @@ class DownloadQueue( store.remove(download) download.setStatusSubject(null) download.setStatusCallback(null) - if (download.status == Download.DOWNLOADING || download.status == Download.QUEUE) - download.status = Download.NOT_DOWNLOADED + if (download.status == Download.State.DOWNLOADING || download.status == Download.State.QUEUE) { + download.status = Download.State.NOT_DOWNLOADED + } downloadListeners.forEach { it.updateDownload(download) } if (removed) { updatedRelay.call(Unit) @@ -65,8 +65,9 @@ class DownloadQueue( queue.forEach { download -> download.setStatusSubject(null) download.setStatusCallback(null) - if (download.status == Download.DOWNLOADING || download.status == Download.QUEUE) - download.status = Download.NOT_DOWNLOADED + if (download.status == Download.State.DOWNLOADING || download.status == Download.State.QUEUE) { + download.status = Download.State.NOT_DOWNLOADED + } downloadListeners.forEach { it.updateDownload(download) } } queue.clear() @@ -74,28 +75,22 @@ class DownloadQueue( updatedRelay.call(Unit) } - fun getActiveDownloads(): Observable = - Observable.from(this).filter { download -> download.status == Download.DOWNLOADING } - - fun getStatusObservable(): Observable = statusSubject.onBackpressureBuffer() - - fun getUpdatedObservable(): Observable> = updatedRelay.onBackpressureBuffer() - .startWith(Unit) - .map { this } - private fun setPagesFor(download: Download) { - if (download.status == Download.DOWNLOADING) { - if (download.pages != null) + if (download.status == Download.State.DOWNLOADING) { + if (download.pages != null) { for (page in download.pages!!) page.setStatusCallback { callListeners(download) } - downloadListeners.forEach { it.updateDownload(download) } - } else if (download.status == Download.DOWNLOADED || download.status == Download.ERROR) { + } + callListeners(download) + } else if (download.status == Download.State.DOWNLOADED || download.status == Download.State.ERROR) { setPagesSubject(download.pages, null) - downloadListeners.forEach { it.updateDownload(download) } + if (download.status == Download.State.ERROR) { + callListeners(download) + } } else { - downloadListeners.forEach { it.updateDownload(download) } + callListeners(download) } } @@ -103,27 +98,6 @@ class DownloadQueue( downloadListeners.forEach { it.updateDownload(download) } } - fun getProgressObservable(): Observable { - return statusSubject.onBackpressureBuffer() - .startWith(getActiveDownloads()) - .flatMap { download -> - if (download.status == Download.DOWNLOADING) { - val pageStatusSubject = PublishSubject.create() - setPagesSubject(download.pages, pageStatusSubject) - downloadListeners.forEach { it.updateDownload(download) } - return@flatMap pageStatusSubject - .onBackpressureBuffer() - .filter { it == Page.READY } - .map { download } - } else if (download.status == Download.DOWNLOADED || download.status == Download.ERROR) { - setPagesSubject(download.pages, null) - downloadListeners.forEach { it.updateDownload(download) } - } - Observable.just(download) - } - .filter { it.status == Download.DOWNLOADING } - } - private fun setPagesSubject(pages: List?, subject: PublishSubject?) { if (pages != null) { for (page in pages) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/image/coil/CoverViewTarget.kt b/app/src/main/java/eu/kanade/tachiyomi/data/image/coil/CoverViewTarget.kt index 1cf03cb99e..417cc57505 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/image/coil/CoverViewTarget.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/image/coil/CoverViewTarget.kt @@ -3,14 +3,13 @@ package eu.kanade.tachiyomi.data.image.coil import android.graphics.drawable.Drawable import android.view.View import android.widget.ImageView +import androidx.core.view.isVisible import coil.Coil import coil.request.CachePolicy import coil.request.ImageRequest import coil.target.ImageViewTarget import com.mikepenz.iconics.typeface.library.materialdesigndx.MaterialDesignDx import eu.kanade.tachiyomi.util.system.iconicsDrawableLarge -import eu.kanade.tachiyomi.util.view.gone -import eu.kanade.tachiyomi.util.view.visible class CoverViewTarget( view: ImageView, @@ -20,7 +19,7 @@ class CoverViewTarget( ) : ImageViewTarget(view) { override fun onError(error: Drawable?) { - progress?.gone() + progress?.isVisible = false if (errorUrl == null) { view.scaleType = ImageView.ScaleType.CENTER view.setImageDrawable(view.context.iconicsDrawableLarge(MaterialDesignDx.Icon.gmf_broken_image, color = android.R.attr.textColorSecondary)) @@ -32,13 +31,13 @@ class CoverViewTarget( } override fun onStart(placeholder: Drawable?) { - progress?.visible() + progress?.isVisible = true view.scaleType = scaleType super.onStart(placeholder) } override fun onSuccess(result: Drawable) { - progress?.gone() + progress?.isVisible = false view.scaleType = scaleType super.onSuccess(result) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/image/coil/LibraryMangaImageTarget.kt b/app/src/main/java/eu/kanade/tachiyomi/data/image/coil/LibraryMangaImageTarget.kt index 9f81fcdbd3..42761a863c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/image/coil/LibraryMangaImageTarget.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/image/coil/LibraryMangaImageTarget.kt @@ -3,8 +3,8 @@ package eu.kanade.tachiyomi.data.image.coil import android.graphics.BitmapFactory import android.graphics.drawable.Drawable import android.widget.ImageView -import coil.Coil import coil.ImageLoader +import coil.imageLoader import coil.memory.MemoryCache import coil.request.Disposable import coil.request.ImageRequest @@ -22,31 +22,33 @@ class LibraryMangaImageTarget( override fun onError(error: Drawable?) { super.onError(error) - val file = coverCache.getCoverFile(manga) - // if the file exists and the there was still an error then the file is corrupted - if (file.exists()) { - val options = BitmapFactory.Options() - options.inJustDecodeBounds = true - BitmapFactory.decodeFile(file.path, options) - if (options.outWidth == -1 || options.outHeight == -1) { - file.delete() - - Coil.imageLoader(view.context).memoryCache.remove(MemoryCache.Key(manga.key())) + if (manga.favorite) { + val file = coverCache.getCoverFile(manga) + // if the file exists and the there was still an error then the file is corrupted + if (file.exists()) { + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + BitmapFactory.decodeFile(file.path, options) + if (options.outWidth == -1 || options.outHeight == -1) { + file.delete() + view.context.imageLoader.memoryCache.remove(MemoryCache.Key(manga.key())) + } } } } } @JvmSynthetic -inline fun ImageView.loadLibraryManga( +inline fun ImageView.loadManga( manga: Manga, - imageLoader: ImageLoader = Coil.imageLoader(context), + imageLoader: ImageLoader = context.imageLoader, builder: ImageRequest.Builder.() -> Unit = {} ): Disposable { val request = ImageRequest.Builder(context) .data(manga) .target(LibraryMangaImageTarget(this, manga)) .apply(builder) + .memoryCacheKey(manga.key()) .build() return imageLoader.enqueue(request) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/image/coil/MangaFetcher.kt b/app/src/main/java/eu/kanade/tachiyomi/data/image/coil/MangaFetcher.kt index a2f1a11a08..a1ade28011 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/image/coil/MangaFetcher.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/image/coil/MangaFetcher.kt @@ -33,6 +33,8 @@ class MangaFetcher : Fetcher { companion object { const val realCover = "real_cover" + const val onlyCache = "only_cache" + const val onlyFetchRemotely = "only_fetch_remotely" } private val coverCache: CoverCache by injectLazy() @@ -58,47 +60,80 @@ class MangaFetcher : Fetcher { } private suspend fun httpLoader(manga: Manga, options: Options): FetchResult { - val customCoverFile = coverCache.getCustomCoverFile(manga) - if (customCoverFile.exists() && options.parameters.value(realCover) != true) { - return fileLoader(customCoverFile) + val onlyCache = options.parameters.value(onlyCache) == true + val shouldFetchRemotely = options.parameters.value(onlyFetchRemotely) == true && !onlyCache + if (!shouldFetchRemotely) { + val customCoverFile = coverCache.getCustomCoverFile(manga) + if (customCoverFile.exists() && options.parameters.value(realCover) != true) { + return fileLoader(customCoverFile) + } } val coverFile = coverCache.getCoverFile(manga) - if (coverFile.exists() && options.diskCachePolicy.readEnabled) { + if (!shouldFetchRemotely && coverFile.exists() && options.diskCachePolicy.readEnabled) { if (!manga.favorite) { coverFile.setLastModified(Date().time) } return fileLoader(coverFile) } - val (_, body) = awaitGetCall( + val (response, body) = awaitGetCall( manga, if (manga.favorite) { - !options.networkCachePolicy.readEnabled + onlyCache } else { false - } + }, + shouldFetchRemotely ) - val tmpFile = File(coverFile.absolutePath + "_tmp") - body.source().use { input -> - tmpFile.sink().buffer().use { output -> - output.writeAll(input) + if (options.diskCachePolicy.writeEnabled) { + val tmpFile = File(coverFile.absolutePath + "_tmp") + body.source().use { input -> + tmpFile.sink().buffer().use { output -> + output.writeAll(input) + } } - } - tmpFile.renameTo(coverFile) - if (manga.favorite) { - coverCache.deleteCachedCovers() + if (response.isSuccessful || !coverFile.exists()) { + if (coverFile.exists()) { + coverFile.delete() + } + + tmpFile.renameTo(coverFile) + } + if (manga.favorite) { + coverCache.deleteCachedCovers() + } } return fileLoader(coverFile) } - private suspend fun awaitGetCall(manga: Manga, onlyCache: Boolean = false): Pair { - val call = getCall(manga, onlyCache) + val call = getCall(manga, onlyCache, forceNetwork) val response = call.await() return response to checkNotNull(response.body) { "Null response source" } } + private fun getCall(manga: Manga, onlyCache: Boolean, forceNetwork: Boolean): Call { + val source = sourceManager.get(manga.source) as? HttpSource + val client = source?.client ?: defaultClient + + val newClient = client.newBuilder().build() + + val request = Request.Builder().url(manga.thumbnail_url!!).also { + if (source != null) { + it.headers(source.headers) + } + if (forceNetwork) { + it.cacheControl(CacheControl.FORCE_NETWORK) + } else if (onlyCache) { + it.cacheControl(CacheControl.FORCE_CACHE) + } + }.build() + + return newClient.newCall(request) + } + /** * "text/plain" is often used as a default/fallback MIME type. * Attempt to guess a better MIME type from the file extension. @@ -139,24 +174,6 @@ class MangaFetcher : Fetcher { ) } - private fun getCall(manga: Manga, onlyCache: Boolean): Call { - val source = sourceManager.get(manga.source) as? HttpSource - val client = source?.client ?: defaultClient - - val newClient = client.newBuilder().build() - - val request = Request.Builder().url(manga.thumbnail_url!!).also { - if (source != null) { - it.headers(source.headers) - } - if (onlyCache) { - it.cacheControl(CacheControl.FORCE_CACHE) - } - }.build() - - return newClient.newCall(request) - } - private fun getResourceType(cover: String?): Type? { return when { cover.isNullOrEmpty() -> null diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/CustomMangaManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/CustomMangaManager.kt index 6ce0ce311f..53824c4d1c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/CustomMangaManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/CustomMangaManager.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.data.library import android.content.Context +import com.github.salomonbrys.kotson.nullInt import com.github.salomonbrys.kotson.nullLong import com.github.salomonbrys.kotson.nullString import com.github.salomonbrys.kotson.set @@ -29,7 +30,8 @@ class CustomMangaManager(val context: Context) { val json = try { Gson().fromJson( - Scanner(editJson).useDelimiter("\\Z").next(), JsonObject::class.java + Scanner(editJson).useDelimiter("\\Z").next(), + JsonObject::class.java ) } catch (e: Exception) { null @@ -47,13 +49,14 @@ class CustomMangaManager(val context: Context) { description = mangaObject["description"]?.nullString genre = mangaObject["genre"]?.asJsonArray?.mapNotNull { it.nullString } ?.joinToString(", ") + status = mangaObject["status"]?.nullInt ?: 0 } id to manga }.toMap().toMutableMap() } fun saveMangaInfo(manga: MangaJson) { - if (manga.title == null && manga.author == null && manga.artist == null && manga.description == null && manga.genre == null) { + if (manga.title == null && manga.author == null && manga.artist == null && manga.description == null && manga.genre == null && manga.status == null) { customMangaMap.remove(manga.id) } else { customMangaMap[manga.id] = MangaImpl().apply { @@ -63,6 +66,7 @@ class CustomMangaManager(val context: Context) { artist = manga.artist description = manga.description genre = manga.genre?.joinToString(", ") + status = manga.status ?: -1 } } saveCustomInfo() @@ -83,17 +87,24 @@ class CustomMangaManager(val context: Context) { fun Manga.toJson(): MangaJson { return MangaJson( - id!!, title, author, artist, description, genre?.split(", ")?.toTypedArray() + id!!, + title, + author, + artist, + description, + genre?.split(", ")?.toTypedArray(), + status.takeUnless { it == -1 } ) } data class MangaJson( - val id: Long, + var id: Long, val title: String? = null, val author: String? = null, val artist: String? = null, val description: String? = null, - val genre: Array? = null + val genre: Array? = null, + val status: Int? = null ) { override fun equals(other: Any?): Boolean { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt index 1f7ae0a7a0..3587ddc189 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt @@ -31,10 +31,11 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet if (interval > 0) { val restrictions = preferences.libraryUpdateRestriction()!! val acRestriction = "ac" in restrictions - val wifiRestriction = if ("wifi" in restrictions) + val wifiRestriction = if ("wifi" in restrictions) { NetworkType.UNMETERED - else + } else { NetworkType.CONNECTED + } val constraints = Constraints.Builder() .setRequiredNetworkType(wifiRestriction) @@ -42,8 +43,10 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet .build() val request = PeriodicWorkRequestBuilder( - interval.toLong(), TimeUnit.HOURS, - 10, TimeUnit.MINUTES + interval.toLong(), + TimeUnit.HOURS, + 10, + TimeUnit.MINUTES ) .addTag(TAG) .setConstraints(constraints) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt index 62ccd6d409..d1a6cb7eb4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt @@ -13,10 +13,12 @@ import androidx.core.content.ContextCompat import coil.Coil import coil.request.CachePolicy import coil.request.ImageRequest +import coil.request.Parameters import coil.transform.CircleCropTransformation import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.LibraryManga +import eu.kanade.tachiyomi.data.image.coil.MangaFetcher import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferencesHelper @@ -116,7 +118,11 @@ class LibraryUpdateNotifier(private val context: Context) { ) ) setSmallIcon(R.drawable.ic_neko_notification) - + addAction( + R.drawable.nnf_ic_file_folder, + context.getString(R.string.view_all_errors), + NotificationReceiver.openErrorLogPendingActivity(context, uri) + ) } .build() ) @@ -142,27 +148,26 @@ class LibraryUpdateNotifier(private val context: Context) { setSmallIcon(R.drawable.ic_neko_notification) try { val request = ImageRequest.Builder(context).data(manga) - .networkCachePolicy(CachePolicy.DISABLED) - .transformations(CircleCropTransformation()).size(width = ICON_SIZE, height = ICON_SIZE) - .build() - Coil.imageLoader(context) - .execute(request).drawable?.let { drawable -> - setLargeIcon((drawable as BitmapDrawable).bitmap) - } + .parameters(Parameters.Builder().set(MangaFetcher.onlyCache, true).build()) + .networkCachePolicy(CachePolicy.READ_ONLY) + .transformations(CircleCropTransformation()) + .size(width = ICON_SIZE, height = ICON_SIZE).build() + + Coil.imageLoader(context).execute(request).drawable?.let { drawable -> + setLargeIcon((drawable as BitmapDrawable).bitmap) + } } catch (e: Exception) { } setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) setContentTitle(manga.title) color = ContextCompat.getColor(context, R.color.colorAccent) val chaptersNames = if (chapterNames.size > MAX_CHAPTERS) { - "${ - chapterNames.take(MAX_CHAPTERS - 1) - .joinToString(", ") - }, " + context.resources.getQuantityString( - R.plurals.notification_and_n_more, - (chapterNames.size - (MAX_CHAPTERS - 1)), - (chapterNames.size - (MAX_CHAPTERS - 1)) - ) + "${chapterNames.take(MAX_CHAPTERS - 1).joinToString(", ")}, " + + context.resources.getQuantityString( + R.plurals.notification_and_n_more, + (chapterNames.size - (MAX_CHAPTERS - 1)), + (chapterNames.size - (MAX_CHAPTERS - 1)) + ) } else chapterNames.joinToString(", ") setContentText(chaptersNames) setStyle(NotificationCompat.BigTextStyle().bigText(chaptersNames)) @@ -170,21 +175,28 @@ class LibraryUpdateNotifier(private val context: Context) { setGroup(Notifications.GROUP_NEW_CHAPTERS) setContentIntent( NotificationReceiver.openChapterPendingActivity( - context, manga, chapters.first() + context, + manga, + chapters.first() ) ) addAction( - R.drawable.ic_eye_24dp, context.getString(R.string.mark_as_read), + R.drawable.ic_eye_24dp, + context.getString(R.string.mark_as_read), NotificationReceiver.markAsReadPendingBroadcast( context, - manga, chapters, Notifications.ID_NEW_CHAPTERS + manga, + chapters, + Notifications.ID_NEW_CHAPTERS ) ) addAction( - R.drawable.ic_book_24dp, context.getString(R.string.view_chapters), + R.drawable.ic_book_24dp, + context.getString(R.string.view_chapters), NotificationReceiver.openChapterPendingActivity( context, - manga, Notifications.ID_NEW_CHAPTERS + manga, + Notifications.ID_NEW_CHAPTERS ) ) setAutoCancel(true) @@ -195,7 +207,6 @@ class LibraryUpdateNotifier(private val context: Context) { } NotificationManagerCompat.from(context).apply { - notify( Notifications.ID_NEW_CHAPTERS, context.notification(Notifications.CHANNEL_NEW_CHAPTERS) { @@ -206,9 +217,9 @@ class LibraryUpdateNotifier(private val context: Context) { if (updates.size > 1) { setContentText( context.resources.getQuantityString( - R.plurals - .for_n_titles, - updates.size, updates.size + R.plurals.for_n_titles, + updates.size, + updates.size ) ) setStyle( @@ -220,10 +231,7 @@ class LibraryUpdateNotifier(private val context: Context) { ) ) } else { - val firstOrNull = updates.keys.firstOrNull() - firstOrNull?.apply { - setContentText(this.title.chop(45)) - } + setContentText(updates.keys.first().title.chop(45)) } priority = NotificationCompat.PRIORITY_HIGH setGroup(Notifications.GROUP_NEW_CHAPTERS) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateRanker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateRanker.kt index eeffed365c..b6e85cf71a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateRanker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateRanker.kt @@ -23,8 +23,8 @@ object LibraryUpdateRanker { fun nextFirstRanking(): Comparator { val time = System.currentTimeMillis() return Comparator { mangaFirst: Manga, - mangaSecond: Manga -> - compareValues(abs(mangaSecond.next_update-time), abs(mangaFirst.next_update-time)) + mangaSecond: Manga -> + compareValues(abs(mangaSecond.next_update - time), abs(mangaFirst.next_update - time)) }.reversed() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt index e85096925b..340444aea4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt @@ -101,10 +101,10 @@ class LibraryUpdateService( // List containing categories that get included in downloads. private val categoriesToDownload = - preferences.downloadNewCategories().getOrDefault().map(String::toInt) + preferences.downloadNewCategories().get().map(String::toInt) // Boolean to determine if user wants to automatically download new chapters. - private val downloadNew: Boolean = preferences.downloadNew().getOrDefault() + private val downloadNew: Boolean = preferences.downloadNew().get() // Boolean to determine if DownloadManager has downloads private var hasDownloads = false @@ -132,7 +132,9 @@ class LibraryUpdateService( val hasDLs = try { requestSemaphore.withPermit { updateMangaInSource( - it.key, downloadNew, categoriesToDownload + it.key, + downloadNew, + categoriesToDownload ) } } catch (e: Exception) { @@ -179,7 +181,7 @@ class LibraryUpdateService( db.getLibraryMangas().executeAsBlocking().filter { it.category == categoryId } } else { val categoriesToUpdate = - preferences.libraryUpdateCategories().getOrDefault().map(String::toInt) + preferences.libraryUpdateCategories().get().map(String::toInt) if (categoriesToUpdate.isNotEmpty()) { categoryIds.addAll(categoriesToUpdate) db.getLibraryMangas().executeAsBlocking() @@ -209,7 +211,8 @@ class LibraryUpdateService( super.onCreate() notifier = LibraryUpdateNotifier(this) wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, "LibraryUpdateService:WakeLock" + PowerManager.PARTIAL_WAKE_LOCK, + "LibraryUpdateService:WakeLock" ) wakeLock.acquire(TimeUnit.MINUTES.toMillis(30)) startForeground(Notifications.ID_LIBRARY_PROGRESS, notifier.progressNotificationBuilder.build()) @@ -220,8 +223,9 @@ class LibraryUpdateService( */ override fun onDestroy() { job?.cancel() - if (instance == this) + if (instance == this) { instance = null + } if (wakeLock.isHeld) { wakeLock.release() } @@ -299,7 +303,6 @@ class LibraryUpdateService( mangaToUpdateMap.putAll(mangaToAdd.groupBy { it.source }) coroutineScope { - val isDexUp = sourceManager.getMangadex().checkIfUp() jobCount.andIncrement @@ -365,7 +368,9 @@ class LibraryUpdateService( ) ) if (updateMangaChapters( - mangaToUpdateMap[source]!![count], this.count.andIncrement, shouldDownload + mangaToUpdateMap[source]!![count], + this.count.andIncrement, + shouldDownload ) ) { hasDownloads = true @@ -411,7 +416,7 @@ class LibraryUpdateService( val thumbnailUrl = manga.thumbnail_url manga.copyFrom(details.first) manga.initialized = true - //dont refresh covers while using cached source + // dont refresh covers while using cached source if (manga.thumbnail_url != null && preferences.refreshCoversToo().getOrDefault() && preferences.useCacheSource().not()) { coverCache.deleteFromCache(thumbnailUrl) // load new covers in background @@ -444,7 +449,6 @@ class LibraryUpdateService( if (shouldDownload) { var chaptersToDl = newChapters.first.sortedBy { it.chapter_number } if (manga.scanlator_filter != null) { - val originalScanlators = originalChapters.flatMap { it.scanlatorList() }.distinct() val newScanlators = newChapters.first.flatMap { it.scanlatorList() }.distinct() @@ -480,7 +484,7 @@ class LibraryUpdateService( } else { updateMissingChapterCount(manga) } - //no reason to do this when using cache + // no reason to do this when using cache /*if (preferences.markChaptersReadFromMDList() && preferences.useCacheSource().not()) { tracks.firstOrNull { it.sync_id == trackManager.mdList.id }?.let { if (FollowStatus.fromInt(it.status) == FollowStatus.READING && it.last_chapter_read > 0) { @@ -574,7 +578,6 @@ class LibraryUpdateService( val addToDefaultCategory = defaultCategory != null || defaultCategoryId == 0*/ - listManga.filter { it -> it.follow_status == FollowStatus.RE_READING || it.follow_status == FollowStatus.READING || (plannedToReadEnabled && it.follow_status == FollowStatus.PLAN_TO_READ) } @@ -634,7 +637,7 @@ class LibraryUpdateService( TrackItem(dbTracks.find { it.sync_id == service.id }, service) } - //find the mdlist entry if its unfollowed the follow it + // find the mdlist entry if its unfollowed the follow it trackList.firstOrNull { it.service.isMdList() }?.let { tracker -> if (tracker.track?.status == FollowStatus.UNFOLLOWED.int) { @@ -644,7 +647,6 @@ class LibraryUpdateService( countNew.incrementAndGet() } } - } notifier.cancelProgressNotification() } @@ -695,7 +697,7 @@ class LibraryUpdateService( enum class Target { CHAPTERS, // Manga meta data and chapters SYNC_FOLLOWS, // Manga in reading, rereading - SYNC_FOLLOWS_PLUS, //Manga in reading/rereading and planned to read + SYNC_FOLLOWS_PLUS, // Manga in reading/rereading and planned to read PUSH_FAVORITES, // Manga in reading TRACKING // Tracking metadata } @@ -788,12 +790,15 @@ class LibraryUpdateService( } fun removeListener(listener: LibraryServiceListener) { - if (this.listener == listener) - this.listener = null + if (this.listener == listener) this.listener = null + } + + fun callListener(manga: Manga) { + listener?.onUpdateManga(manga) } } } interface LibraryServiceListener { - fun onUpdateManga(manga: LibraryManga) + fun onUpdateManga(manga: Manga? = null) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt index af62d4f4fa..0e3a15b29e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt @@ -20,10 +20,12 @@ import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.similar.MangaCacheUpdateService +import eu.kanade.tachiyomi.data.updater.UpdaterService import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.manga.MangaDetailsController import eu.kanade.tachiyomi.ui.reader.ReaderActivity +import eu.kanade.tachiyomi.ui.setting.AboutController import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.system.notificationManager @@ -60,44 +62,58 @@ class NotificationReceiver : BroadcastReceiver() { // Clear the download queue ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue(true) // Launch share activity and dismiss notification - ACTION_SHARE_IMAGE -> - shareImage( - context, intent.getStringExtra(EXTRA_FILE_LOCATION), - intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) - ) + ACTION_SHARE_IMAGE -> shareImage( + context, + intent.getStringExtra(EXTRA_FILE_LOCATION)!!, + intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) + ) // Delete image from path and dismiss notification - ACTION_DELETE_IMAGE -> - deleteImage( - context, intent.getStringExtra(EXTRA_FILE_LOCATION), - intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) - ) + ACTION_DELETE_IMAGE -> deleteImage( + context, + intent.getStringExtra(EXTRA_FILE_LOCATION)!!, + intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) + ) // Cancel library update and dismiss notification ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context) ACTION_CANCEL_CACHE_UPDATE -> cancelCacheUpdate(context) ACTION_CANCEL_V5_MIGRATION -> cancelV5Migration(context) + ACTION_CANCEL_UPDATE_DOWNLOAD -> cancelDownloadUpdate(context) ACTION_CANCEL_RESTORE -> cancelRestoreUpdate(context) // Share backup file ACTION_SHARE_BACKUP -> shareBackup( - context, intent.getParcelableExtra(EXTRA_URI), + context, + intent.getParcelableExtra(EXTRA_URI)!!, intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) ) // Open reader activity ACTION_OPEN_CHAPTER -> { openChapter( - context, intent.getLongExtra(EXTRA_MANGA_ID, -1), + context, + intent.getLongExtra(EXTRA_MANGA_ID, -1), intent.getLongExtra(EXTRA_CHAPTER_ID, -1) ) } ACTION_MARK_AS_READ -> { val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) if (notificationId > -1) dismissNotification( - context, notificationId, intent.getIntExtra(EXTRA_GROUP_ID, 0) + context, + notificationId, + intent.getIntExtra(EXTRA_GROUP_ID, 0) ) val urls = intent.getStringArrayExtra(EXTRA_CHAPTER_URL) ?: return val mangaId = intent.getLongExtra(EXTRA_MANGA_ID, -1) markAsRead(urls, mangaId) } + + // Share crash dump file + ACTION_SHARE_CRASH_LOG -> + shareFile( + context, + intent.getParcelableExtra(EXTRA_URI)!!, + "text/plain", + intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) + ) } } @@ -172,6 +188,26 @@ class NotificationReceiver : BroadcastReceiver() { } } + /** + * Called to start share intent to share backup file + * + * @param context context of application + * @param path path of file + * @param notificationId id of notification + */ + private fun shareFile(context: Context, uri: Uri, fileMimeType: String, notificationId: Int) { + val sendIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + clipData = ClipData.newRawUri(null, uri) + type = fileMimeType + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION + } + // Dismiss notification + dismissNotification(context, notificationId) + // Launch share activity + context.startActivity(sendIntent) + } + /** * Called to delete image * @@ -244,16 +280,19 @@ class NotificationReceiver : BroadcastReceiver() { } } - /* Method called when user wants to stop a restore + /** Method called when user wants to stop a restore * * @param context context of application - * @param notificationId id of notification */ private fun cancelRestoreUpdate(context: Context) { BackupRestoreService.stop(context) Handler().post { dismissNotification(context, Notifications.ID_RESTORE_PROGRESS) } } + private fun cancelDownloadUpdate(context: Context) { + UpdaterService.stop(context) + } + companion object { private const val NAME = "NotificationReceiver" @@ -266,6 +305,8 @@ class NotificationReceiver : BroadcastReceiver() { // Called to launch send intent. private const val ACTION_SHARE_BACKUP = "$ID.$NAME.SEND_BACKUP" + private const val ACTION_SHARE_CRASH_LOG = "$ID.$NAME.SEND_CRASH_LOG" + // Called to cancel library update. private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE" @@ -275,6 +316,8 @@ class NotificationReceiver : BroadcastReceiver() { // Called to cancel library v5 migration update. private const val ACTION_CANCEL_V5_MIGRATION = "$ID.$NAME.CANCEL_V5_MIGRATION" + private const val ACTION_CANCEL_UPDATE_DOWNLOAD = "$ID.$NAME.CANCEL_UPDATE_DOWNLOAD" + // Called to mark as read private const val ACTION_MARK_AS_READ = "$ID.$NAME.MARK_AS_READ" @@ -317,6 +360,8 @@ class NotificationReceiver : BroadcastReceiver() { // Value containing chapter url. private const val EXTRA_CHAPTER_URL = "$ID.$NAME.EXTRA_CHAPTER_URL" + private const val EXTRA_IS_LEGACY_BACKUP = "$ID.$NAME.EXTRA_IS_LEGACY_BACKUP" + /** * Returns a [PendingIntent] that resumes the download of a chapter * @@ -418,7 +463,9 @@ class NotificationReceiver : BroadcastReceiver() { type = "image/*" } return PendingIntent.getActivity( - context, 0, shareIntent, + context, + 0, + shareIntent, PendingIntent .FLAG_CANCEL_CURRENT ) @@ -456,12 +503,36 @@ class NotificationReceiver : BroadcastReceiver() { ): PendingIntent { val newIntent = ReaderActivity.newIntent(context, manga, chapter) return PendingIntent.getActivity( - context, manga.id.hashCode(), newIntent, + context, + manga.id.hashCode(), + newIntent, PendingIntent .FLAG_UPDATE_CURRENT ) } + /** + * Returns [PendingIntent] that opens release notes for the next update. + * + * @param context context of application + * @param notes notes of the release + * @param downloadLink download link to the apk + */ + internal fun openUpdatePendingActivity(context: Context, notes: String, downloadLink: String): + PendingIntent { + val newIntent = + Intent(context, MainActivity::class.java).setAction(MainActivity.SHORTCUT_UPDATE_NOTES) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + .putExtra(AboutController.NewUpdateDialogController.BODY_KEY, notes) + .putExtra(AboutController.NewUpdateDialogController.URL_KEY, downloadLink) + return PendingIntent.getActivity( + context, + downloadLink.hashCode(), + newIntent, + PendingIntent.FLAG_UPDATE_CURRENT + ) + } + /** * Returns [PendingIntent] that opens the manga details controller. * @@ -470,16 +541,19 @@ class NotificationReceiver : BroadcastReceiver() { */ internal fun openChapterPendingActivity(context: Context, manga: Manga, groupId: Int): PendingIntent { - val newIntent = - Intent(context, MainActivity::class.java).setAction(MainActivity.SHORTCUT_MANGA) - .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) - .putExtra(MangaDetailsController.MANGA_EXTRA, manga.id) - .putExtra("notificationId", manga.id.hashCode()) - .putExtra("groupId", groupId) - return PendingIntent.getActivity( - context, manga.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT - ) - } + val newIntent = + Intent(context, MainActivity::class.java).setAction(MainActivity.SHORTCUT_MANGA) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + .putExtra(MangaDetailsController.MANGA_EXTRA, manga.id) + .putExtra("notificationId", manga.id.hashCode()) + .putExtra("groupId", groupId) + return PendingIntent.getActivity( + context, + manga.id.hashCode(), + newIntent, + PendingIntent.FLAG_UPDATE_CURRENT + ) + } /** * Returns [PendingIntent] that opens the error log file in an external viewer @@ -488,7 +562,7 @@ class NotificationReceiver : BroadcastReceiver() { * @param uri uri of error log file * @return [PendingIntent] */ - internal fun openErrorLogPendingActivity(context: Context, uri: Uri): PendingIntent { + internal fun openErrorLogPendingActivity(context: Context, uri: Uri?): PendingIntent { val intent = Intent().apply { action = Intent.ACTION_VIEW setDataAndType(uri, "text/plain") @@ -523,15 +597,15 @@ class NotificationReceiver : BroadcastReceiver() { groupId: Int ): PendingIntent { - val newIntent = Intent(context, NotificationReceiver::class.java).apply { - action = ACTION_MARK_AS_READ - putExtra(EXTRA_CHAPTER_URL, chapters.map { it.url }.toTypedArray()) - putExtra(EXTRA_MANGA_ID, manga.id) - putExtra(EXTRA_NOTIFICATION_ID, manga.id.hashCode()) - putExtra(EXTRA_GROUP_ID, groupId) - } - return PendingIntent.getBroadcast(context, manga.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT) + val newIntent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_MARK_AS_READ + putExtra(EXTRA_CHAPTER_URL, chapters.map { it.url }.toTypedArray()) + putExtra(EXTRA_MANGA_ID, manga.id) + putExtra(EXTRA_NOTIFICATION_ID, manga.id.hashCode()) + putExtra(EXTRA_GROUP_ID, groupId) } + return PendingIntent.getBroadcast(context, manga.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT) + } /** * Returns [PendingIntent] that starts a service which stops the library update @@ -590,14 +664,64 @@ class NotificationReceiver : BroadcastReceiver() { } /** - * Returns [PendingIntent] that starts a service which stops the restore service + * Returns [PendingIntent] that cancels the download for a Tachiyomi update * * @param context context of application * @return [PendingIntent] */ - internal fun cancelRestorePendingBroadcast(context: Context): PendingIntent { + internal fun cancelUpdateDownloadPendingBroadcast(context: Context): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_CANCEL_UPDATE_DOWNLOAD + } + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + /** + * Returns [PendingIntent] that starts a share activity for a backup file. + * + * @param context context of application + * @param uri uri of backup file + * @param notificationId id of notification + * @return [PendingIntent] + */ + internal fun shareBackupPendingBroadcast(context: Context, uri: Uri, isLegacyFormat: Boolean, notificationId: Int): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_SHARE_BACKUP + putExtra(EXTRA_URI, uri) + putExtra(EXTRA_IS_LEGACY_BACKUP, isLegacyFormat) + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + } + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + /** + * Returns [PendingIntent] that starts a share activity for a crash log dump file. + * + * @param context context of application + * @param uri uri of file + * @param notificationId id of notification + * @return [PendingIntent] + */ + internal fun shareCrashLogPendingBroadcast(context: Context, uri: Uri, notificationId: Int): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_SHARE_CRASH_LOG + putExtra(EXTRA_URI, uri) + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + } + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + /** + * Returns [PendingIntent] that cancels a backup restore job. + * + * @param context context of application + * @param notificationId id of notification + * @return [PendingIntent] + */ + internal fun cancelRestorePendingBroadcast(context: Context, notificationId: Int): PendingIntent { val intent = Intent(context, NotificationReceiver::class.java).apply { action = ACTION_CANCEL_RESTORE + putExtra(EXTRA_NOTIFICATION_ID, notificationId) } return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt index 79f08af6f3..10f8d7f4d7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt @@ -104,21 +104,24 @@ object Notifications { setShowBadge(false) }, NotificationChannel( - CHANNEL_DOWNLOADER_PROGRESS, context.getString(R.string.downloads), + CHANNEL_DOWNLOADER_PROGRESS, + context.getString(R.string.downloads), NotificationManager.IMPORTANCE_LOW ).apply { group = GROUP_DOWNLOADER setShowBadge(false) }, NotificationChannel( - CHANNEL_DOWNLOADER_COMPLETE, context.getString(R.string.download_complete), + CHANNEL_DOWNLOADER_COMPLETE, + context.getString(R.string.download_complete), NotificationManager.IMPORTANCE_LOW ).apply { group = GROUP_DOWNLOADER setShowBadge(false) }, NotificationChannel( - CHANNEL_DOWNLOADER_ERROR, context.getString(R.string.download_error), + CHANNEL_DOWNLOADER_ERROR, + context.getString(R.string.download_error), NotificationManager.IMPORTANCE_LOW ).apply { group = GROUP_DOWNLOADER @@ -130,14 +133,16 @@ object Notifications { NotificationManager.IMPORTANCE_DEFAULT ), NotificationChannel( - CHANNEL_BACKUP_RESTORE_PROGRESS, context.getString(R.string.backup_restore_progress), + CHANNEL_BACKUP_RESTORE_PROGRESS, + context.getString(R.string.backup_restore_progress), NotificationManager.IMPORTANCE_LOW ).apply { group = GROUP_BACKUP_RESTORE setShowBadge(false) }, NotificationChannel( - CHANNEL_BACKUP_RESTORE_COMPLETE, context.getString(R.string.backup_restore_complete), + CHANNEL_BACKUP_RESTORE_COMPLETE, + context.getString(R.string.backup_restore_complete), NotificationManager.IMPORTANCE_HIGH ).apply { group = GROUP_BACKUP_RESTORE @@ -145,7 +150,8 @@ object Notifications { setSound(null, null) }, NotificationChannel( - CHANNEL_BACKUP_RESTORE_ERROR, context.getString(R.string.restore_error), + CHANNEL_BACKUP_RESTORE_ERROR, + context.getString(R.string.restore_error), NotificationManager.IMPORTANCE_HIGH ).apply { group = GROUP_BACKUP_RESTORE @@ -153,8 +159,9 @@ object Notifications { setSound(null, null) }, NotificationChannel( - CHANNEL_V5_MIGRATION, context.getString(R.string.v5_migration_service), - NotificationManager.IMPORTANCE_HIGH + CHANNEL_V5_MIGRATION, + context.getString(R.string.v5_migration_service), + NotificationManager.IMPORTANCE_HIGH ).apply { setShowBadge(false) setSound(null, null) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/DelayedLibrarySuggestionsJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/DelayedLibrarySuggestionsJob.kt new file mode 100644 index 0000000000..91c8f08b44 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/DelayedLibrarySuggestionsJob.kt @@ -0,0 +1,42 @@ +package eu.kanade.tachiyomi.data.preference + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import eu.kanade.tachiyomi.ui.library.LibraryPresenter +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.concurrent.TimeUnit + +class DelayedLibrarySuggestionsJob(context: Context, workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams) { + + override suspend fun doWork(): Result { + val preferences = Injekt.get() + if (preferences.showLibrarySearchSuggestions().isNotSet()) { + preferences.showLibrarySearchSuggestions().set(true) + LibraryPresenter.setSearchSuggestion(preferences, Injekt.get(), Injekt.get()) + } + return Result.success() + } + + companion object { + private const val TAG = "DelayedLibrarySuggestions" + + fun setupTask(enabled: Boolean) { + if (enabled) { + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.DAYS) + .addTag(TAG) + .build() + + WorkManager.getInstance().enqueueUniqueWork(TAG, ExistingWorkPolicy.KEEP, request) + } else { + WorkManager.getInstance().cancelAllWorkByTag(TAG) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt index 612ee21297..60ca417c81 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt @@ -7,10 +7,20 @@ object PreferenceKeys { const val theme = "pref_theme_key" + const val nightMode = "night_mode" + const val lightTheme = "light_theme" + const val darkTheme = "dark_theme" + + const val startingTab = "starting_tab" + + const val backToStart = "back_to_start" + const val rotation = "pref_rotation_type_key" const val enableTransitions = "pref_enable_transitions_key" + const val pagerCutoutBehavior = "pager_cutout_behavior" + const val doubleTapAnimationSpeed = "pref_double_tap_anim_speed" const val showPageNumber = "pref_show_page_number_key" @@ -51,6 +61,25 @@ object PreferenceKeys { const val readWithVolumeKeysInverted = "reader_volume_keys_inverted" + const val navigationModePager = "reader_navigation_mode_pager" + + const val navigationModeWebtoon = "reader_navigation_mode_webtoon" + + const val pagerNavInverted = "reader_tapping_inverted" + + const val webtoonNavInverted = "reader_tapping_inverted_webtoon" + + const val pageLayout = "page_layout" + + const val invertDoublePages = "invert_double_pages" + + const val readerBottomButtons = "reader_bottom_buttons" + + const val showNavigationOverlayNewUser = "reader_navigation_overlay_new_user" + const val showNavigationOverlayNewUserWebtoon = "reader_navigation_overlay_new_user_webtoon" + + const val preloadSize = "preload_size" + const val webtoonSidePadding = "webtoon_side_padding" const val webtoonEnableZoomOut = "webtoon_enable_zoom_out" @@ -77,6 +106,12 @@ object PreferenceKeys { const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key" + const val folderPerManga = "create_folder_per_manga" + + const val showLibrarySearchSuggestions = "show_library_search_suggestions" + + const val librarySearchSuggestion = "library_search_suggestion" + const val numberOfBackups = "backup_slots" const val backupInterval = "backup_interval" @@ -113,13 +148,19 @@ object PreferenceKeys { const val automaticUpdates = "automatic_updates" + const val autoHideHopper = "autohide_hopper" + + const val hopperLongPress = "hopper_long_press" + + const val onlySearchPinned = "only_search_pinned" + const val downloadNew = "download_new" const val downloadNewCategories = "download_new_categories" const val libraryLayout = "pref_display_library_layout" - const val gridSize = "grid_size" + const val gridSize = "grid_size_float" const val uniformGrid = "uniform_grid" @@ -153,29 +194,37 @@ object PreferenceKeys { const val updateOnRefresh = "update_on_refresh" + const val showDLsInRecents = "show_dls_in_recents" + const val showRemHistoryInRecents = "show_rem_history_in_recents" + const val showReadInAllRecents = "show_read_in_all_recents" + const val showTitleFirstInRecents = "show_title_first_in_recents" + + const val groupChaptersHistory = "group_chapters_history" + + const val showUpdatedTime = "show_updated_time" + const val groupChaptersUpdates = "group_chapters_updates" + const val showLibraryUpdateErrors = "show_library_update_errors" const val alwaysShowChapterTransition = "always_show_chapter_transition" - const val enableDoh = "enable_doh" + const val hideBottomNavOnScroll = "hide_bottom_nav_on_scroll" - const val contentRating = "content_rating_options" + const val showSideNavOnBottom = "show_side_nav_on_bottom" - const val lowQualityCovers = "low_quality_covers" + const val showMangaAppShortcuts = "show_manga_app_shortcuts" - const val dataSaver = "data_saver_bool" + const val createLegacyBackup = "create_legacy_backup" - const val forceLatestCovers = "latest_cover_bool" + const val dohProvider = "doh_provider" - const val logLevel = "log_level" - - const val showContentRatingFilter = "show_R18_filter" + const val incognitoMode = "incognito_mode" - const val enablePort443Only = "use_port_443_only_for_image_server" + fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" - const val addToLibraryAsPlannedToRead = "add_to_libray_as_planned_to_read" + fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" - const val createLegacyBackup = "create_legacy_backup" + fun trackToken(syncId: Int) = "track_token_$syncId" const val useCacheSource = "use_cache_source_new" @@ -189,9 +238,19 @@ object PreferenceKeys { fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId" - fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" + const val contentRating = "content_rating_options" - fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" + const val lowQualityCovers = "low_quality_covers" - fun trackToken(syncId: Int) = "track_token_$syncId" + const val dataSaver = "data_saver_bool" + + const val forceLatestCovers = "latest_cover_bool" + + const val logLevel = "log_level" + + const val showContentRatingFilter = "show_R18_filter" + + const val enablePort443Only = "use_port_443_only_for_image_server" + + const val addToLibraryAsPlannedToRead = "add_to_libray_as_planned_to_read" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index 4781b49b67..4bf021afbe 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.SharedPreferences import android.net.Uri import android.os.Environment +import androidx.appcompat.app.AppCompatDelegate import androidx.preference.PreferenceManager import com.f2prateek.rx.preferences.Preference import com.f2prateek.rx.preferences.RxSharedPreferences @@ -12,6 +13,17 @@ import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet +import eu.kanade.tachiyomi.ui.reader.settings.PageLayout +import eu.kanade.tachiyomi.ui.reader.settings.ReaderBottomButton +import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation +import eu.kanade.tachiyomi.ui.recents.RecentMangaAdapter +import eu.kanade.tachiyomi.util.system.Themes +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import java.io.File import java.text.DateFormat import java.text.SimpleDateFormat @@ -20,7 +32,20 @@ import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys fun Preference.getOrDefault(): T = get() ?: defaultValue()!! -fun Preference.invert(): Boolean = getOrDefault().let { set(!it); !it } +fun com.tfcporciuncula.flow.Preference.asImmediateFlow(block: (value: T) -> Unit): Flow { + block(get()) + return asFlow() + .onEach { block(it) } +} + +fun com.tfcporciuncula.flow.Preference.asImmediateFlowIn(scope: CoroutineScope, block: (value: T) -> Unit): Job { + block(get()) + return asFlow() + .onEach { block(it) } + .launchIn(scope) +} + +fun com.tfcporciuncula.flow.Preference.toggle() = set(!get()) private class DateFormatConverter : Preference.Adapter { override fun get(key: String, preferences: SharedPreferences): DateFormat { @@ -38,13 +63,29 @@ private class DateFormatConverter : Preference.Adapter { } } +operator fun com.tfcporciuncula.flow.Preference>.plusAssign(item: T) { + set(get() + item) +} + +operator fun com.tfcporciuncula.flow.Preference>.minusAssign(item: T) { + set(get() - item) +} + +operator fun com.tfcporciuncula.flow.Preference>.plusAssign(item: Collection) { + set(get() + item) +} + +operator fun com.tfcporciuncula.flow.Preference>.minusAssign(item: Collection) { + set(get() - item) +} + class PreferencesHelper(val context: Context) { private val prefs = PreferenceManager.getDefaultSharedPreferences(context) private val rxPrefs = RxSharedPreferences.create(prefs) private val flowPrefs = FlowSharedPreferences(prefs) - private val defaultFolder = context.getString(R.string.neko_app_name) + when (BuildConfig.DEBUG) { + private val defaultFolder = context.getString(R.string.app_name) + when (BuildConfig.DEBUG) { true -> "_DEBUG" false -> "" } @@ -69,14 +110,24 @@ class PreferencesHelper(val context: Context) { fun getStringPref(key: String, default: String?) = rxPrefs.getString(key, default) fun getStringSet(key: String, default: Set) = rxPrefs.getStringSet(key, default) + fun startingTab() = flowPrefs.getInt(Keys.startingTab, 0) + fun backReturnsToStart() = flowPrefs.getBoolean(Keys.backToStart, true) + fun clear() = prefs.edit().clear().apply() - fun theme() = prefs.getInt(Keys.theme, 5) + fun oldTheme() = prefs.getInt(Keys.theme, 5) + + fun nightMode() = flowPrefs.getInt(Keys.nightMode, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + + fun lightTheme() = flowPrefs.getEnum(Keys.lightTheme, Themes.PURE_WHITE) + fun darkTheme() = flowPrefs.getEnum(Keys.darkTheme, Themes.DARK) fun rotation() = flowPrefs.getInt(Keys.rotation, 1) fun pageTransitions() = flowPrefs.getBoolean(Keys.enableTransitions, true) + fun pagerCutoutBehavior() = flowPrefs.getInt(Keys.pagerCutoutBehavior, 0) + fun doubleTapAnimSpeed() = flowPrefs.getInt(Keys.doubleTapAnimationSpeed, 500) fun showPageNumber() = flowPrefs.getBoolean(Keys.showPageNumber, true) @@ -97,7 +148,7 @@ class PreferencesHelper(val context: Context) { fun colorFilterMode() = flowPrefs.getInt(Keys.colorFilterMode, 0) - fun defaultViewer() = prefs.getInt(Keys.defaultViewer, 1) + fun defaultViewer() = prefs.getInt(Keys.defaultViewer, 2) fun imageScaleType() = flowPrefs.getInt(Keys.imageScaleType, 1) @@ -121,21 +172,46 @@ class PreferencesHelper(val context: Context) { fun readWithVolumeKeysInverted() = flowPrefs.getBoolean(Keys.readWithVolumeKeysInverted, false) + fun navigationModePager() = flowPrefs.getInt(Keys.navigationModePager, 0) + + fun navigationModeWebtoon() = flowPrefs.getInt(Keys.navigationModeWebtoon, 0) + + fun pagerNavInverted() = flowPrefs.getEnum(Keys.pagerNavInverted, ViewerNavigation.TappingInvertMode.NONE) + + fun webtoonNavInverted() = flowPrefs.getEnum(Keys.webtoonNavInverted, ViewerNavigation.TappingInvertMode.NONE) + + fun pageLayout() = flowPrefs.getInt(Keys.pageLayout, PageLayout.AUTOMATIC.value) + + fun invertDoublePages() = flowPrefs.getBoolean(Keys.invertDoublePages, false) + + fun readerBottomButtons() = flowPrefs.getStringSet( + Keys.readerBottomButtons, + ReaderBottomButton.BUTTONS_DEFAULTS + ) + + fun showNavigationOverlayNewUser() = flowPrefs.getBoolean(Keys.showNavigationOverlayNewUser, true) + + fun showNavigationOverlayNewUserWebtoon() = flowPrefs.getBoolean(Keys.showNavigationOverlayNewUserWebtoon, true) + + fun preloadSize() = flowPrefs.getInt(Keys.preloadSize, 6) + fun updateOnlyNonCompleted() = prefs.getBoolean(Keys.updateOnlyNonCompleted, false) fun autoUpdateTrack() = prefs.getBoolean(Keys.autoUpdateTrack, true) - fun lastUsedCatalogueSource() = rxPrefs.getLong(Keys.lastUsedCatalogueSource, -1) + fun lastUsedCatalogueSource() = flowPrefs.getLong(Keys.lastUsedCatalogueSource, -1) fun lastUsedCategory() = rxPrefs.getInteger(Keys.lastUsedCategory, 0) + fun lastUsedSources() = flowPrefs.getStringSet("last_used_sources", emptySet()) + fun lastVersionCode() = rxPrefs.getInteger("last_version_code", 0) - fun browseAsList() = rxPrefs.getBoolean(Keys.catalogueAsList, false) + fun browseAsList() = flowPrefs.getBoolean(Keys.catalogueAsList, false) - fun browseShowLibrary() = rxPrefs.getBoolean(Keys.catalogueShowLibrary, true) + fun browseShowLibrary() = flowPrefs.getBoolean(Keys.catalogueShowLibrary, true) - fun enabledLanguages() = rxPrefs.getStringSet(Keys.enabledLanguages, setOf("en", Locale.getDefault().language)) + fun enabledLanguages() = flowPrefs.getStringSet(Keys.enabledLanguages, setOf("en", Locale.getDefault().language)) fun sourceSorting() = rxPrefs.getInteger(Keys.sourcesSort, 0) @@ -165,7 +241,7 @@ class PreferencesHelper(val context: Context) { fun anilistScoreType() = rxPrefs.getString("anilist_score_type", "POINT_10") - fun backupsDirectory() = rxPrefs.getString(Keys.backupDirectory, defaultBackupDir.toString()) + fun backupsDirectory() = flowPrefs.getString(Keys.backupDirectory, defaultBackupDir.toString()) fun dateFormat(format: String = flowPrefs.getString(Keys.dateFormat, "").get()): DateFormat = when (format) { "" -> DateFormat.getDateInstance(DateFormat.SHORT) @@ -176,9 +252,17 @@ class PreferencesHelper(val context: Context) { fun downloadOnlyOverWifi() = prefs.getBoolean(Keys.downloadOnlyOverWifi, true) - fun numberOfBackups() = rxPrefs.getInteger(Keys.numberOfBackups, 1) + fun folderPerManga() = prefs.getBoolean(Keys.folderPerManga, false) + + fun librarySearchSuggestion() = flowPrefs.getString(Keys.librarySearchSuggestion, "") + + fun showLibrarySearchSuggestions() = flowPrefs.getBoolean(Keys.showLibrarySearchSuggestions, false) - fun backupInterval() = rxPrefs.getInteger(Keys.backupInterval, 0) + fun lastLibrarySuggestion() = flowPrefs.getLong("last_library_suggestion", 0L) + + fun numberOfBackups() = flowPrefs.getInt(Keys.numberOfBackups, 1) + + fun backupInterval() = flowPrefs.getInt(Keys.backupInterval, 0) fun removeAfterReadSlots() = prefs.getInt(Keys.removeAfterReadSlots, -1) @@ -188,15 +272,15 @@ class PreferencesHelper(val context: Context) { fun libraryUpdateRestriction() = prefs.getStringSet(Keys.libraryUpdateRestriction, emptySet()) - fun libraryUpdateCategories() = rxPrefs.getStringSet(Keys.libraryUpdateCategories, emptySet()) + fun libraryUpdateCategories() = flowPrefs.getStringSet(Keys.libraryUpdateCategories, emptySet()) fun libraryUpdatePrioritization() = rxPrefs.getInteger(Keys.libraryUpdatePrioritization, 0) - fun libraryLayout() = rxPrefs.getInteger(Keys.libraryLayout, 1) + fun libraryLayout() = flowPrefs.getInt(Keys.libraryLayout, 2) - fun gridSize() = rxPrefs.getInteger(Keys.gridSize, 2) + fun gridSize() = flowPrefs.getFloat(Keys.gridSize, 1f) - fun uniformGrid() = rxPrefs.getBoolean(Keys.uniformGrid, true) + fun uniformGrid() = flowPrefs.getBoolean(Keys.uniformGrid, true) fun chaptersDescAsDefault() = rxPrefs.getBoolean("chapters_desc_as_default", true) @@ -224,13 +308,17 @@ class PreferencesHelper(val context: Context) { fun collapsedCategories() = rxPrefs.getStringSet("collapsed_categories", mutableSetOf()) - fun hiddenSources() = rxPrefs.getStringSet("hidden_catalogues", mutableSetOf()) + fun collapsedDynamicCategories() = flowPrefs.getStringSet("collapsed_dynamic_categories", mutableSetOf()) + + fun collapsedDynamicAtBottom() = flowPrefs.getBoolean("collapsed_dynamic_at_bottom", false) - fun pinnedCatalogues() = rxPrefs.getStringSet("pinned_catalogues", emptySet()) + fun hiddenSources() = flowPrefs.getStringSet("hidden_catalogues", mutableSetOf()) - fun downloadNew() = rxPrefs.getBoolean(Keys.downloadNew, false) + fun pinnedCatalogues() = flowPrefs.getStringSet("pinned_catalogues", mutableSetOf()) - fun downloadNewCategories() = rxPrefs.getStringSet(Keys.downloadNewCategories, emptySet()) + fun downloadNew() = flowPrefs.getBoolean(Keys.downloadNew, false) + + fun downloadNewCategories() = flowPrefs.getStringSet(Keys.downloadNewCategories, emptySet()) fun lang() = prefs.getString(Keys.lang, "") @@ -271,17 +359,29 @@ class PreferencesHelper(val context: Context) { fun extensionUpdatesCount() = rxPrefs.getInteger("ext_updates_count", 0) - fun recentsViewType() = rxPrefs.getInteger("recents_view_type", 0) + fun recentsViewType() = flowPrefs.getInt("recents_view_type", 0) + + fun showRecentsDownloads() = flowPrefs.getEnum(Keys.showDLsInRecents, RecentMangaAdapter.ShowRecentsDLs.All) + + fun showRecentsRemHistory() = flowPrefs.getBoolean(Keys.showRemHistoryInRecents, true) + + fun showReadInAllRecents() = flowPrefs.getBoolean(Keys.showReadInAllRecents, false) + + fun showUpdatedTime() = flowPrefs.getBoolean(Keys.showUpdatedTime, false) + + fun groupChaptersUpdates() = flowPrefs.getBoolean(Keys.groupChaptersUpdates, false) + + fun groupChaptersHistory() = flowPrefs.getBoolean(Keys.groupChaptersHistory, true) + + fun showTitleFirstInRecents() = flowPrefs.getBoolean(Keys.showTitleFirstInRecents, false) fun lastExtCheck() = rxPrefs.getLong("last_ext_check", 0) fun lastAppCheck() = flowPrefs.getLong("last_app_check", 0) - fun unreadBadgeType() = rxPrefs.getInteger("unread_badge_type", 2) + fun unreadBadgeType() = flowPrefs.getInt("unread_badge_type", 2) - fun hideStartReadingButton() = rxPrefs.getBoolean("hide_reading_button", false) - - fun hideFiltersAtStart() = rxPrefs.getBoolean("hide_filters_at_start", false) + fun hideStartReadingButton() = flowPrefs.getBoolean("hide_reading_button", false) fun alwaysShowChapterTransition() = flowPrefs.getBoolean(Keys.alwaysShowChapterTransition, true) @@ -291,14 +391,20 @@ class PreferencesHelper(val context: Context) { fun hopperGravity() = flowPrefs.getInt("hopper_gravity", 1) - fun filterOrder() = flowPrefs.getString("filter_order", "rudcmt") + fun filterOrder() = flowPrefs.getString("filter_order", FilterBottomSheet.Filters.DEFAULT_ORDER) + + fun hopperLongPressAction() = flowPrefs.getInt(Keys.hopperLongPress, 0) fun hideHopper() = flowPrefs.getBoolean("hide_hopper", false) + fun autohideHopper() = flowPrefs.getBoolean(Keys.autoHideHopper, true) + fun groupLibraryBy() = flowPrefs.getInt("group_library_by", 0) fun showCategoryInTitle() = flowPrefs.getBoolean("category_in_title", false) + fun onlySearchPinned() = flowPrefs.getBoolean(Keys.onlySearchPinned, false) + fun showLibraryUpdateErrors() = prefs.getBoolean(Keys.showLibraryUpdateErrors, false) // Tutorial preferences @@ -312,7 +418,15 @@ class PreferencesHelper(val context: Context) { fun shownHopperSwipeTutorial() = flowPrefs.getBoolean("shown_hopper_swipe", false) - fun enableDoh() = prefs.getBoolean(Keys.enableDoh, false) + fun hideBottomNavOnScroll() = flowPrefs.getBoolean(Keys.hideBottomNavOnScroll, true) + + fun showSideNavOnBottom() = flowPrefs.getBoolean(Keys.showSideNavOnBottom, false) + + fun createLegacyBackup() = flowPrefs.getBoolean(Keys.createLegacyBackup, true) + fun dohProvider() = prefs.getInt(Keys.dohProvider, -1) + fun appShortcuts() = prefs.getBoolean(Keys.showMangaAppShortcuts, true) + + fun incognitoMode() = flowPrefs.getBoolean(Keys.incognitoMode, false) fun shownSimilarTutorial() = flowPrefs.getBoolean("shown_similar_tutorial", false) @@ -325,13 +439,11 @@ class PreferencesHelper(val context: Context) { fun forceLatestCovers() = prefs.getBoolean(Keys.forceLatestCovers, false) fun logLevel() = prefs.getInt(Keys.logLevel, 0) - + fun showContentRatingFilter(): Boolean = prefs.getBoolean(Keys.showContentRatingFilter, true) fun addToLibraryAsPlannedToRead(): Boolean = prefs.getBoolean(Keys.addToLibraryAsPlannedToRead, false) - fun createLegacyBackup() = flowPrefs.getBoolean(Keys.createLegacyBackup, true) - fun useCacheSource(): Boolean = prefs.getBoolean(Keys.useCacheSource, false) fun contentRatingSelections(): MutableSet = prefs.getStringSet(Keys.contentRating, setOf("safe", "suggestive"))!! diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/similar/MangaCacheUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/similar/MangaCacheUpdateJob.kt index ed0210b82b..41a0f5b360 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/similar/MangaCacheUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/similar/MangaCacheUpdateJob.kt @@ -24,6 +24,5 @@ class MangaCacheUpdateJob(private val context: Context, workerParams: WorkerPara work.setInputData(data.build()) WorkManager.getInstance().enqueue(work.build()) } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/similar/MangaCacheUpdateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/similar/MangaCacheUpdateService.kt index f850d9e8ef..228be11c2b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/similar/MangaCacheUpdateService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/similar/MangaCacheUpdateService.kt @@ -28,7 +28,6 @@ import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.BufferedReader import java.io.InputStreamReader -import java.net.HttpURLConnection import java.net.URL import java.util.concurrent.TimeUnit @@ -74,7 +73,8 @@ class MangaCacheUpdateService( super.onCreate() startForeground(Notifications.ID_CACHE_PROGRESS, progressNotification.build()) wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, "SimilarUpdateService:WakeLock" + PowerManager.PARTIAL_WAKE_LOCK, + "SimilarUpdateService:WakeLock" ) wakeLock.acquire(TimeUnit.MINUTES.toMillis(30)) } @@ -124,7 +124,6 @@ class MangaCacheUpdateService( } private suspend fun updateCachedManga() = withContext(Dispatchers.IO) { - // Open the connection to the remove csv file // https://stackoverflow.com/a/38374532/7718197 XLog.i("CACHE: Starting download!") @@ -147,7 +146,7 @@ class MangaCacheUpdateService( // Insert into our database XLog.i("CACHE: Beginning cache manga insert") - //db.insertCachedManga2(cachedManga) + // db.insertCachedManga2(cachedManga) val totalManga = lines.size lines.mapIndexed { index, line -> @@ -159,12 +158,11 @@ class MangaCacheUpdateService( // Insert into the database showProgressNotification(index, totalManga) val strs = line.split(",").toTypedArray() - if(strs.size == 3) { + if (strs.size == 3) { val regex = Regex("[^A-Za-z0-9 ]") - val manga = CachedManga(regex.replace(strs[1],""), strs[0], strs[2]) + val manga = CachedManga(regex.replace(strs[1], ""), strs[0], strs[2]) db.insertCachedManga2Single(manga) } - } db.optimizeCachedManga() showProgressNotification(totalManga, totalManga) @@ -173,7 +171,6 @@ class MangaCacheUpdateService( // Done! XLog.i("CACHE: Done with cached manga") showResultNotification() - } /** @@ -264,4 +261,4 @@ class MangaCacheUpdateService( context.stopService(Intent(context, MangaCacheUpdateService::class.java)) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt index 82a390b829..f6b193bc9f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt @@ -3,11 +3,14 @@ package eu.kanade.tachiyomi.data.track import androidx.annotation.CallSuper import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.util.system.executeOnIO import okhttp3.OkHttpClient import uy.kohesive.injekt.injectLazy @@ -15,6 +18,7 @@ abstract class TrackService(val id: Int) { val preferences: PreferencesHelper by injectLazy() val networkService: NetworkHelper by injectLazy() + val db: DatabaseHelper by injectLazy() open fun canRemoveFromService() = false open val client: OkHttpClient get() = networkService.client @@ -35,6 +39,8 @@ abstract class TrackService(val id: Int) { abstract fun isCompletedStatus(index: Int): Boolean + abstract fun completedStatus(): Int + abstract fun getStatus(status: Int): String abstract fun getGlobalStatus(status: Int): String @@ -49,7 +55,7 @@ abstract class TrackService(val id: Int) { abstract suspend fun add(track: Track): Track - abstract suspend fun update(track: Track): Track + abstract suspend fun update(track: Track, setToReadStatus: Boolean = false): Track abstract suspend fun bind(track: Track): Track @@ -80,3 +86,42 @@ abstract class TrackService(val id: Int) { preferences.setTrackCredentials(this, username, password) } } + +suspend fun TrackService.updateNewTrackInfo(track: Track, planningStatus: Int) { + val allRead = db.getManga(track.manga_id).executeOnIO()?.status == SManga.COMPLETED && + db.getChapters(track.manga_id).executeOnIO().all { it.read } + if (supportsReadingDates) { + track.started_reading_date = getStartDate(track) + track.finished_reading_date = getCompletedDate(track, allRead) + } + track.last_chapter_read = getLastChapterRead(track) + if (track.last_chapter_read == 0) { + track.status = planningStatus + } + if (allRead) { + track.status = completedStatus() + } +} + +suspend fun TrackService.getStartDate(track: Track): Long { + if (db.getChapters(track.manga_id).executeOnIO().any { it.read }) { + val chapters = db.getHistoryByMangaId(track.manga_id).executeOnIO().filter { it.last_read > 0 } + val date = chapters.minOfOrNull { it.last_read } ?: return 0L + return if (date <= 0L) 0L else date + } + return 0L +} + +suspend fun TrackService.getCompletedDate(track: Track, allRead: Boolean): Long { + if (allRead) { + val chapters = db.getHistoryByMangaId(track.manga_id).executeOnIO() + val date = chapters.maxOfOrNull { it.last_read } ?: return 0L + return if (date <= 0L) 0L else date + } + return 0L +} + +suspend fun TrackService.getLastChapterRead(track: Track): Int { + val chapters = db.getChapters(track.manga_id).executeOnIO() + return chapters.filter { it.read }.minByOrNull { it.source_order }?.chapter_number?.toInt() ?: 0 +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt index 17581daabf..cdaa01dc28 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.track.anilist import android.content.Context import android.graphics.Color +import androidx.annotation.StringRes import com.elvishew.xlog.XLog import com.google.gson.Gson import eu.kanade.tachiyomi.R @@ -9,10 +10,12 @@ import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.track.updateNewTrackInfo import uy.kohesive.injekt.injectLazy class Anilist(private val context: Context, id: Int) : TrackService(id) { + @StringRes override fun nameRes() = R.string.anilist private val gson: Gson by injectLazy() @@ -21,6 +24,8 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) { private val api by lazy { AnilistApi(client, interceptor) } + override val supportsReadingDates: Boolean = true + private val scorePreference = preferences.anilistScoreType() init { @@ -41,6 +46,8 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) { override fun isCompletedStatus(index: Int) = getStatusList()[index] == COMPLETED + override fun completedStatus() = COMPLETED + override fun getStatus(status: Int): String = with(context) { when (status) { READING -> getString(R.string.reading) @@ -121,7 +128,17 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) { } } - override suspend fun update(track: Track): Track { + override suspend fun add(track: Track): Track { + track.score = DEFAULT_SCORE.toFloat() + track.status = DEFAULT_STATUS + updateNewTrackInfo(track, PLANNING) + return api.addLibManga(track) + } + + override suspend fun update(track: Track, setToReadStatus: Boolean): Track { + if (setToReadStatus && track.status == PLANNING && track.last_chapter_read != 0) { + track.status = READING + } if (track.status == READING && track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { track.status = COMPLETED } @@ -136,12 +153,6 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) { return api.updateLibraryManga(track) } - override suspend fun add(track: Track): Track { - track.score = DEFAULT_SCORE.toFloat() - track.status = DEFAULT_STATUS - return api.addLibManga(track) - } - override suspend fun bind(track: Track): Track { val remoteTrack = api.findLibManga(track, getUsername().toInt()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt index 0557671b83..79fdcedcd3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt @@ -17,10 +17,9 @@ import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.network.await -import eu.kanade.tachiyomi.network.jsonType +import eu.kanade.tachiyomi.network.jsonMime import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import okhttp3.MediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody @@ -34,17 +33,22 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { suspend fun addLibManga(track: Track): Track { return withContext(Dispatchers.IO) { - val variables = jsonObject( "mangaId" to track.media_id, "progress" to track.last_chapter_read, "status" to track.toAnilistStatus() ) + createDate(track.started_reading_date)?.let { + variables.add("startedAt", it) + } + createDate(track.finished_reading_date)?.let { + variables.add("completedAt", it) + } val payload = jsonObject( "query" to addToLibraryQuery(), "variables" to variables ) - val body = payload.toString().toRequestBody(MediaType.jsonType()) + val body = payload.toString().toRequestBody(jsonMime) val request = Request.Builder().url(apiUrl).post(body).build() val netResponse = authClient.newCall(request).await() @@ -66,26 +70,38 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { "listId" to track.library_id, "progress" to track.last_chapter_read, "status" to track.toAnilistStatus(), - "score" to track.score.toInt(), - "startedAt" to createDate(track.started_reading_date), - "completedAt" to createDate(track.finished_reading_date), - - ) + "score" to track.score.toInt() + ) + createDate(track.started_reading_date)?.let { + variables.add("startedAt", it) + } + createDate(track.finished_reading_date)?.let { + variables.add("completedAt", it) + } val payload = jsonObject( "query" to updateInLibraryQuery(), "variables" to variables ) - val body = payload.toString().toRequestBody(MediaType.jsonType()) + val body = payload.toString().toRequestBody(jsonMime) val request = Request.Builder().url(apiUrl).post(body).build() - val response = authClient.newCall(request).await() - + val netResponse = authClient.newCall(request).await() + val response = responseToJson(netResponse) + try { + val media = response["data"]["SaveMediaListEntry"].asJsonObject + if (track.started_reading_date == 0L) { + track.started_reading_date = parseDate(media, "startedAt") + } + if (track.finished_reading_date == 0L) { + track.finished_reading_date = parseDate(media, "completedAt") + } + } catch (e: Exception) { + } track } } suspend fun search(search: String, manga: Manga, wasPreviouslyTracked: Boolean): List { return withContext(Dispatchers.IO) { - val variables = jsonObject( "query" to if (manga.anilist_id != null && !wasPreviouslyTracked) manga.anilist_id else search ) @@ -93,8 +109,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { "query" to if (manga.anilist_id != null && !wasPreviouslyTracked) findQuery() else searchQuery(), "variables" to variables ) - - val body = payload.toString().toRequestBody(MediaType.jsonType()) + val body = payload.toString().toRequestBody(jsonMime) val request = Request.Builder().url(apiUrl).post(body).build() val netResponse = authClient.newCall(request).await() val response = responseToJson(netResponse) @@ -106,7 +121,6 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { } suspend fun findLibManga(track: Track, userid: Int): Track? { - return withContext(Dispatchers.IO) { val variables = jsonObject( "id" to userid, @@ -116,7 +130,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { "query" to findLibraryMangaQuery(), "variables" to variables ) - val body = payload.toString().toRequestBody(MediaType.jsonType()) + val body = payload.toString().toRequestBody(jsonMime) val request = Request.Builder().url(apiUrl).post(body).build() val result = authClient.newCall(request).await() @@ -142,7 +156,6 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { suspend fun remove(track: Track): Boolean { return withContext(Dispatchers.IO) { try { - val variables = jsonObject( "listId" to track.library_id ) @@ -151,7 +164,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { "variables" to variables ) - val body = payload.toString().toRequestBody(MediaType.jsonType()) + val body = payload.toString().toRequestBody(jsonMime) val request = Request.Builder().url(apiUrl).post(body).build() val result = authClient.newCall(request).await() return@withContext true @@ -176,7 +189,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { val payload = jsonObject( "query" to currentUserQuery() ) - val body = payload.toString().toRequestBody(MediaType.jsonType()) + val body = payload.toString().toRequestBody(jsonMime) val request = Request.Builder().url(apiUrl).post(body).build() val netResponse = authClient.newCall(request).await() @@ -223,12 +236,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { } } - private fun createDate(dateValue: Long): JsonObject { - if (dateValue == 0L) return jsonObject( - "year" to null, - "month" to null, - "day" to null, - ) + private fun createDate(dateValue: Long): JsonObject? { + if (dateValue == 0L) return null val calendar = Calendar.getInstance() calendar.timeInMillis = dateValue return jsonObject( @@ -260,15 +269,15 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { return baseMangaUrl + mediaId } - fun authUrl() = "${baseUrl}oauth/authorize".toUri().buildUpon() + fun authUrl() = "${baseUrl.toUri()}oauth/authorize".toUri().buildUpon() .appendQueryParameter("client_id", clientId) .appendQueryParameter("response_type", "token") .build()!! fun addToLibraryQuery() = """ - |mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) { - |SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) { + |mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}startedAt: FuzzyDateInput, ${'$'}completedAt: FuzzyDateInput) { + |SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status, startedAt: ${'$'}startedAt, completedAt: ${'$'}completedAt) { | id | status |} @@ -291,6 +300,16 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { |id |status |progress + |startedAt { + |year + |month + |day + |} + |completedAt { + |year + |month + |day + |} |} |} |""".trimMargin() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt index 77c0ebbe9f..a9091ceb1d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.track.kitsu import android.content.Context import android.graphics.Color +import androidx.annotation.StringRes import com.elvishew.xlog.XLog import com.google.gson.Gson import eu.kanade.tachiyomi.R @@ -9,6 +10,7 @@ import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeList import uy.kohesive.injekt.injectLazy import java.text.DecimalFormat @@ -25,6 +27,7 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) { const val DEFAULT_SCORE = 0f } + @StringRes override fun nameRes() = R.string.kitsu private val gson: Gson by injectLazy() @@ -47,6 +50,8 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) { override fun isCompletedStatus(index: Int) = getStatusList()[index] == COMPLETED + override fun completedStatus(): Int = MyAnimeList.COMPLETED + override fun getStatus(status: Int): String = with(context) { when (status) { READING -> getString(R.string.currently_reading) @@ -83,7 +88,10 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) { return df.format(track.score) } - override suspend fun update(track: Track): Track { + override suspend fun update(track: Track, setToReadStatus: Boolean): Track { + if (setToReadStatus && track.status == PLAN_TO_READ && track.last_chapter_read != 0) { + track.status = READING + } if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { track.status = COMPLETED } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt index 0ec8d3f0dc..5ae2a72e8b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt @@ -1,7 +1,11 @@ package eu.kanade.tachiyomi.data.track.kitsu -import com.elvishew.xlog.XLog -import com.github.salomonbrys.kotson.* +import com.github.salomonbrys.kotson.array +import com.github.salomonbrys.kotson.get +import com.github.salomonbrys.kotson.int +import com.github.salomonbrys.kotson.jsonObject +import com.github.salomonbrys.kotson.obj +import com.github.salomonbrys.kotson.string import com.google.gson.GsonBuilder import com.google.gson.JsonObject import com.google.gson.JsonParser @@ -11,12 +15,23 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.await +import com.github.salomonbrys.kotson.set import okhttp3.FormBody import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory -import retrofit2.http.* - +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query +import timber.log.Timber class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) { @@ -94,14 +109,14 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) rest.deleteLibManga(track.media_id) return true } catch (e: Exception) { - XLog.w(e) + Timber.w(e) } return false } suspend fun search(query: String, manga: Manga, wasPreviouslyTracked: Boolean): List { - if(manga.kitsu_id !== null && !wasPreviouslyTracked) { - val response = client.newCall(GET(apiMangaUrl(manga.kitsu_id!!))).await() + if (manga.kitsu_id !== null && !wasPreviouslyTracked) { + val response = client.newCall(eu.kanade.tachiyomi.network.GET(apiMangaUrl(manga.kitsu_id!!))).await() val jsonData = response.body!!.string() var json = JsonParser.parseString(jsonData).asJsonObject json["data"][0]["attributes"]["id"] = json["data"][0]["id"] diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt index b3663816b1..cbb013295e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt @@ -23,7 +23,7 @@ class KitsuSearchManga(obj: JsonObject, api: Boolean = false) { val original = obj.get("posterImage").nullObj?.get("original")?.asString private val synopsis by obj.byString private var startDate = obj.get("startDate").nullString?.let { - if(!api) { + if (!api) { val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) outputDf.format(Date(it.toLong() * 1000)) } else { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mdlist/MdList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mdlist/MdList.kt index 5e185a6e94..eb6eaea240 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mdlist/MdList.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mdlist/MdList.kt @@ -5,7 +5,6 @@ import android.content.Context import android.graphics.Color import com.elvishew.xlog.XLog import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.TrackManager @@ -22,7 +21,6 @@ import uy.kohesive.injekt.api.get class MdList(private val context: Context, id: Int) : TrackService(id) { private val mdex by lazy { Injekt.get().getMangadex() } - private val db: DatabaseHelper by lazy { Injekt.get() } override fun nameRes() = R.string.mdlist @@ -51,7 +49,7 @@ class MdList(private val context: Context, id: Int) : TrackService(id) { throw Exception("Not Used") } - override suspend fun update(track: Track): Track { + override suspend fun update(track: Track, setToReadStatus: Boolean): Track { return withContext(Dispatchers.IO) { try { val manga = db.getManga(track.tracking_url.substringAfter(".org"), mdex.id) @@ -99,6 +97,8 @@ class MdList(private val context: Context, id: Int) : TrackService(id) { override fun isCompletedStatus(index: Int) = getStatusList()[index] == FollowStatus.COMPLETED.int + override fun completedStatus() = FollowStatus.COMPLETED.int + override suspend fun bind(track: Track): Track { val remoteTrack = mdex.fetchTrackingInfo(track.tracking_url) track.copyPersonalFrom(remoteTrack) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt index 7a45e405c0..10b09becc4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt @@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.data.track.updateNewTrackInfo import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -57,6 +58,8 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) { override fun isCompletedStatus(index: Int) = getStatusList()[index] == COMPLETED + override fun completedStatus(): Int = COMPLETED + override fun getScoreList(): List { return IntRange(0, 10).map(Int::toString) } @@ -68,10 +71,14 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) { override suspend fun add(track: Track): Track { track.status = READING track.score = 0F + updateNewTrackInfo(track, PLAN_TO_READ) return api.updateItem(track) } - override suspend fun update(track: Track): Track { + override suspend fun update(track: Track, setToReadStatus: Boolean): Track { + if (setToReadStatus && track.status == PLAN_TO_READ && track.last_chapter_read != 0) { + track.status = READING + } return api.updateItem(track) } @@ -145,7 +152,6 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) { null } } - companion object { const val READING = 1 const val COMPLETED = 2 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt index e6c78aae7e..277bfcc798 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt @@ -65,7 +65,6 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI if (manga.my_anime_list_id !== null && !wasPreviouslyTracked) { listOf(getMangaDetails(manga.my_anime_list_id!!.toInt())) } else { - val url = "$baseApiUrl/manga".toUri().buildUpon() .appendQueryParameter("q", query) .appendQueryParameter("nsfw", "true") @@ -125,6 +124,12 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI .add("is_rereading", (track.status == MyAnimeList.REREADING).toString()) .add("score", track.score.toString()) .add("num_chapters_read", track.last_chapter_read.toString()) + convertToIsoDate(track.started_reading_date)?.let { + formBodyBuilder.add("start_date", it) + } + convertToIsoDate(track.finished_reading_date)?.let { + formBodyBuilder.add("finish_date", it) + } val request = Request.Builder() .url(mangaUrl(track.media_id).toString()) @@ -137,6 +142,11 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI } } + /* suspend fun updateLibManga(track: Track): Track { + authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track))).await() + return track + }*/ + suspend fun findListItem(track: Track): Track? { return withIOContext { val uri = "$baseApiUrl/manga".toUri().buildUpon() @@ -208,6 +218,12 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI status = if (isRereading) MyAnimeList.REREADING else getStatus(obj["status"]!!.jsonPrimitive.content) last_chapter_read = obj["num_chapters_read"]!!.jsonPrimitive.int score = obj["score"]!!.jsonPrimitive.int.toFloat() + obj["start_date"]?.let { + started_reading_date = parseDate(it.jsonPrimitive.content) + } + obj["finish_date"]?.let { + finished_reading_date = parseDate(it.jsonPrimitive.content) + } } } @@ -244,7 +260,6 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI } companion object { - private const val clientId = "4e30b4bb28f187666d334e640f197cb9" private const val baseOAuthUrl = "https://myanimelist.net/v1/oauth2" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/Release.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/Release.kt index 61f2bd7870..74166bf5d8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/Release.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/Release.kt @@ -9,4 +9,6 @@ interface Release { * @return download link of latest release. */ val downloadLink: String + + val releaseLink: String } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterJob.kt index 462980dabe..0d59bb1c8a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterJob.kt @@ -1,10 +1,7 @@ package eu.kanade.tachiyomi.data.updater -import android.app.PendingIntent import android.content.Context -import android.content.Intent import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.ExistingPeriodicWorkPolicy @@ -12,7 +9,6 @@ import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters -import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.util.system.notificationManager import kotlinx.coroutines.coroutineScope @@ -22,34 +18,19 @@ class UpdaterJob(private val context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { override suspend fun doWork(): Result = coroutineScope { - val result = try { - UpdateChecker.getUpdateChecker().checkForUpdate() - } catch (e: Exception) { - Result.failure() - } - if (result is UpdateResult.NewUpdate<*>) { - val url = result.release.downloadLink - - val intent = Intent(context, UpdaterService::class.java).apply { - putExtra(UpdaterService.EXTRA_DOWNLOAD_URL, url) - } - - NotificationCompat.Builder(context, Notifications.CHANNEL_COMMON).update { - setContentTitle(context.getString(R.string.neko_app_name)) - setContentText(context.getString(R.string.update_available)) - setSmallIcon(android.R.drawable.stat_sys_download_done) - color = ContextCompat.getColor(context, R.color.colorAccent) - // Download action - addAction( - android.R.drawable.stat_sys_download_done, - context.getString(R.string.download), - PendingIntent.getService( - context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT - ) + try { + val result = UpdateChecker.getUpdateChecker().checkForUpdate() + if (result is UpdateResult.NewUpdate<*>) { + UpdaterNotifier(context).promptUpdate( + result.release.info, + result.release.downloadLink, + result.release.releaseLink ) } + Result.success() + } catch (e: Exception) { + Result.failure() } - Result.success() } fun NotificationCompat.Builder.update(block: NotificationCompat.Builder.() -> Unit) { @@ -66,8 +47,10 @@ class UpdaterJob(private val context: Context, workerParams: WorkerParameters) : .build() val request = PeriodicWorkRequestBuilder( - 1, TimeUnit.DAYS, - 1, TimeUnit.HOURS + 2, + TimeUnit.DAYS, + 3, + TimeUnit.HOURS ) .addTag(TAG) .setConstraints(constraints) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterNotifier.kt index f13b5aab70..2956637e4c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterNotifier.kt @@ -1,13 +1,18 @@ package eu.kanade.tachiyomi.data.updater +import android.app.PendingIntent import android.content.Context +import android.content.Intent import android.net.Uri import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat +import androidx.core.net.toUri import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.notification.NotificationHandler import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.util.system.getResourceColor +import eu.kanade.tachiyomi.util.system.launchUI import eu.kanade.tachiyomi.util.system.notificationManager /** @@ -20,10 +25,14 @@ internal class UpdaterNotifier(private val context: Context) { /** * Builder to manage notifications. */ - private val notification by lazy { + private val notificationBuilder by lazy { NotificationCompat.Builder(context, Notifications.CHANNEL_COMMON) } + companion object { + var releasePageUrl: String? = null + } + /** * Call to show notification. * @@ -33,20 +42,74 @@ internal class UpdaterNotifier(private val context: Context) { context.notificationManager.notify(id, build()) } + fun promptUpdate(body: String, url: String, releaseUrl: String) { + val intent = Intent(context, UpdaterService::class.java).apply { + putExtra(UpdaterService.EXTRA_DOWNLOAD_URL, url) + } + + val pendingIntent = NotificationReceiver.openUpdatePendingActivity(context, body, url) + releasePageUrl = releaseUrl + with(notificationBuilder) { + setContentTitle(context.getString(R.string.app_name)) + setContentText(context.getString(R.string.new_version_available)) + setContentIntent(pendingIntent) + setAutoCancel(true) + setSmallIcon(android.R.drawable.stat_sys_download_done) + color = context.getResourceColor(R.attr.colorAccent) + clearActions() + // Download action + addAction( + android.R.drawable.stat_sys_download_done, + context.getString(R.string.download), + PendingIntent.getService( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT + ) + ) + addReleasePageAction() + } + notificationBuilder.show() + } + + private fun NotificationCompat.Builder.addReleasePageAction() { + releasePageUrl?.let { releaseUrl -> + val releaseIntent = Intent(Intent.ACTION_VIEW, releaseUrl.toUri()).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + addAction( + R.drawable.ic_new_releases_24dp, + context.getString(R.string.release_page), + PendingIntent.getActivity(context, releaseUrl.hashCode(), releaseIntent, PendingIntent.FLAG_UPDATE_CURRENT) + ) + } + } + /** * Call when apk download starts. * * @param title tile of notification. */ fun onDownloadStarted(title: String): NotificationCompat.Builder { - with(notification) { + with(notificationBuilder) { setContentTitle(title) setContentText(context.getString(R.string.downloading)) setSmallIcon(android.R.drawable.stat_sys_download) + setAutoCancel(false) setOngoing(true) + clearActions() + + // Cancel action + addAction( + R.drawable.ic_close_24dp, + context.getString(R.string.cancel), + NotificationReceiver.cancelUpdateDownloadPendingBroadcast(context) + ) + addReleasePageAction() } - notification.show() - return notification + notificationBuilder.show() + return notificationBuilder } /** @@ -55,11 +118,11 @@ internal class UpdaterNotifier(private val context: Context) { * @param progress progress of download (xx%/100). */ fun onProgressChange(progress: Int) { - with(notification) { + with(notificationBuilder) { setProgress(100, progress, false) setOnlyAlertOnce(true) } - notification.show() + notificationBuilder.show() } /** @@ -68,13 +131,15 @@ internal class UpdaterNotifier(private val context: Context) { * @param uri path location of apk. */ fun onDownloadFinished(uri: Uri) { - with(notification) { + with(notificationBuilder) { setContentText(context.getString(R.string.download_complete)) setSmallIcon(android.R.drawable.stat_sys_download_done) + setAutoCancel(false) setOnlyAlertOnce(false) setProgress(0, 0, false) // Install action setContentIntent(NotificationHandler.installApkPendingActivity(context, uri)) + clearActions() addAction( R.drawable.ic_system_update_24dp, context.getString(R.string.install), @@ -86,8 +151,11 @@ internal class UpdaterNotifier(private val context: Context) { context.getString(R.string.cancel), NotificationReceiver.dismissNotificationPendingBroadcast(context, Notifications.ID_UPDATER) ) + addReleasePageAction() + } + launchUI { + notificationBuilder.show() } - notification.show() } /** @@ -96,12 +164,14 @@ internal class UpdaterNotifier(private val context: Context) { * @param url web location of apk to download. */ fun onDownloadError(url: String) { - with(notification) { + with(notificationBuilder) { setContentText(context.getString(R.string.download_error)) setSmallIcon(android.R.drawable.stat_sys_warning) setOnlyAlertOnce(false) + setAutoCancel(false) setProgress(0, 0, false) color = ContextCompat.getColor(context, R.color.colorAccent) + clearActions() // Retry action addAction( R.drawable.ic_refresh_24dp, @@ -114,7 +184,12 @@ internal class UpdaterNotifier(private val context: Context) { context.getString(R.string.cancel), NotificationReceiver.dismissNotificationPendingBroadcast(context, Notifications.ID_UPDATER) ) + addReleasePageAction() } - notification.show(Notifications.ID_UPDATER) + notificationBuilder.show(Notifications.ID_UPDATER) + } + + fun cancel() { + NotificationReceiver.dismissNotification(context, Notifications.ID_UPDATER) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterService.kt index 192278405e..3c303a05a1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterService.kt @@ -7,6 +7,7 @@ import android.content.Intent import android.os.Build import android.os.IBinder import android.os.PowerManager +import com.elvishew.xlog.XLog import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.notification.Notifications @@ -17,11 +18,16 @@ import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.newCallWithProgress import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.storage.saveTo +import eu.kanade.tachiyomi.util.system.acquireWakeLock import eu.kanade.tachiyomi.util.system.isServiceRunning -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import com.elvishew.xlog.XLog +import okhttp3.Call +import okhttp3.internal.http2.ErrorCode +import okhttp3.internal.http2.StreamResetException import uy.kohesive.injekt.injectLazy import java.io.File @@ -36,16 +42,17 @@ class UpdaterService : Service() { private lateinit var notifier: UpdaterNotifier + private var runningJob: Job? = null + + private var runningCall: Call? = null + override fun onCreate() { super.onCreate() notifier = UpdaterNotifier(this) - startForeground(Notifications.ID_UPDATER, notifier.onDownloadStarted(getString(R.string.neko_app_name)).build()) + startForeground(Notifications.ID_UPDATER, notifier.onDownloadStarted(getString(R.string.app_name)).build()) - wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, "${javaClass.name}:WakeLock" - ) - wakeLock.acquire() + wakeLock = acquireWakeLock(javaClass.name) } /** @@ -56,14 +63,20 @@ class UpdaterService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent == null) return START_NOT_STICKY + val handler = CoroutineExceptionHandler { _, exception -> + XLog.e(exception) + stopSelf(startId) + } + val url = intent.getStringExtra(EXTRA_DOWNLOAD_URL) ?: return START_NOT_STICKY - val title = intent.getStringExtra(EXTRA_DOWNLOAD_TITLE) ?: getString(R.string.neko_app_name) + val title = intent.getStringExtra(EXTRA_DOWNLOAD_TITLE) ?: getString(R.string.app_name) - GlobalScope.launch(Dispatchers.IO) { + runningJob = GlobalScope.launch(handler) { downloadApk(title, url) } - stopSelf(startId) + runningJob?.invokeOnCompletion { stopSelf(startId) } + return START_NOT_STICKY } @@ -78,6 +91,8 @@ class UpdaterService : Service() { } private fun destroyJob() { + runningJob?.cancel() + runningCall?.cancel() if (wakeLock.isHeld) { wakeLock.release() } @@ -112,7 +127,8 @@ class UpdaterService : Service() { try { // Download the new update. - val response = network.client.newCallWithProgress(GET(url), progressListener).await() + val call = network.client.newCallWithProgress(GET(url), progressListener) + val response = call.await() // File where the apk will be saved. val apkFile = File(externalCacheDir, "update.apk") @@ -126,7 +142,13 @@ class UpdaterService : Service() { notifier.onDownloadFinished(apkFile.getUriCompat(this)) } catch (error: Exception) { XLog.e(error) - notifier.onDownloadError(url) + if (error is CancellationException || + (error is StreamResetException && error.errorCode == ErrorCode.CANCEL) + ) { + notifier.cancel() + } else { + notifier.onDownloadError(url) + } } } @@ -149,7 +171,7 @@ class UpdaterService : Service() { * @param context the application context. * @param url the url to the new update. */ - fun start(context: Context, url: String, title: String = context.getString(R.string.neko_app_name)) { + fun start(context: Context, url: String, title: String = context.getString(R.string.app_name)) { if (!isRunning(context)) { val intent = Intent(context, UpdaterService::class.java).apply { putExtra(EXTRA_DOWNLOAD_TITLE, title) @@ -163,6 +185,15 @@ class UpdaterService : Service() { } } + /** + * Stops the service. + * + * @param context the application context. + */ + fun stop(context: Context) { + context.stopService(Intent(context, UpdaterService::class.java)) + } + /** * Returns [PendingIntent] that starts a service which downloads the apk specified in url. * diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubRelease.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubRelease.kt index 09f1b37d01..69cafcd522 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubRelease.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/github/GithubRelease.kt @@ -14,6 +14,7 @@ import eu.kanade.tachiyomi.data.updater.Release class GithubRelease( @SerializedName("tag_name") val version: String, @SerializedName("body") override val info: String, + @SerializedName("html_url") override val releaseLink: String, @SerializedName("assets") private val assets: List ) : Release { diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/AndroidCookieJar.kt b/app/src/main/java/eu/kanade/tachiyomi/network/AndroidCookieJar.kt index c93b217ee0..c436b0dbd8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/AndroidCookieJar.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/AndroidCookieJar.kt @@ -12,9 +12,7 @@ class AndroidCookieJar : CookieJar { override fun saveFromResponse(url: HttpUrl, cookies: List) { val urlString = url.toString() - for (cookie in cookies) { - manager.setCookie(urlString, cookie.toString()) - } + cookies.forEach { manager.setCookie(urlString, it.toString()) } } override fun loadForRequest(url: HttpUrl): List { diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt index 10dc255ee9..0a6e2f0ca8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/CloudflareInterceptor.kt @@ -10,7 +10,10 @@ import android.widget.Toast import com.elvishew.xlog.XLog import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.util.system.WebViewClientCompat +import eu.kanade.tachiyomi.util.system.WebViewUtil import eu.kanade.tachiyomi.util.system.isOutdated +import eu.kanade.tachiyomi.util.system.launchUI +import eu.kanade.tachiyomi.util.system.setDefaultSettings import eu.kanade.tachiyomi.util.system.toast import okhttp3.Cookie import okhttp3.HttpUrl.Companion.toHttpUrl @@ -39,9 +42,17 @@ class CloudflareInterceptor(private val context: Context) : Interceptor { @Synchronized override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + if (!WebViewUtil.supportsWebView(context)) { + launchUI { + context.toast(R.string.webview_is_required, Toast.LENGTH_LONG) + } + return chain.proceed(originalRequest) + } + initWebView - val originalRequest = chain.request() val response = chain.proceed(originalRequest) // Check if Cloudflare anti-bot is on @@ -78,22 +89,20 @@ class CloudflareInterceptor(private val context: Context) : Interceptor { var isWebViewOutdated = false val origRequestUrl = request.url.toString() - val headers = request.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" } - XLog.d("headers") + val headers = request.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }.toMutableMap() + headers["X-Requested-With"] = WebViewUtil.REQUESTED_WITH + handler.post { val webview = WebView(context) webView = webview - webview.settings.javaScriptEnabled = true + webview.setDefaultSettings() - // Avoid set empty User-Agent, Chromium WebView will reset to default if empty - webview.settings.userAgentString = - request.header("User-Agent") ?: "Mozilla/5.0 (Windows NT 6.3; WOW64)" + // Avoid sending empty User-Agent, Chromium WebView will reset to default if empty + webview.settings.userAgentString = request.header("User-Agent") + ?: "Mozilla/5.0 (Windows NT 6.3; WOW64)" webview.webViewClient = object : WebViewClientCompat() { - override fun onPageFinished(view: WebView, url: String) { - XLog.d("Page Finished for $url") - fun isCloudFlareBypassed(): Boolean { return networkHelper.cookieManager.get(origRequestUrl.toHttpUrl()) .firstOrNull { it.name == "cf_clearance" } @@ -105,7 +114,6 @@ class CloudflareInterceptor(private val context: Context) : Interceptor { latch.countDown() } - // HTTP error codes are only received since M if (url == origRequestUrl && !challengeFound) { // The first request didn't return the challenge, abort. latch.countDown() @@ -121,7 +129,7 @@ class CloudflareInterceptor(private val context: Context) : Interceptor { ) { if (isMainFrame) { if (errorCode == 503) { - // Found the cloudflare challenge page. + // Found the Cloudflare challenge page. challengeFound = true } else { // Unlock thread, the challenge wasn't found. @@ -136,7 +144,7 @@ class CloudflareInterceptor(private val context: Context) : Interceptor { // Wait a reasonable amount of time to retrieve the solution. The minimum should be // around 4 seconds but it can take more due to slow networks or server issues. - latch.await(30, TimeUnit.SECONDS) + latch.await(12, TimeUnit.SECONDS) handler.post { if (!cloudflareBypassed) { @@ -160,6 +168,6 @@ class CloudflareInterceptor(private val context: Context) : Interceptor { companion object { private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare") - private val COOKIE_NAMES = listOf("__cfduid", "cf_clearance") + private val COOKIE_NAMES = listOf("cf_clearance") } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/DohProviders.kt b/app/src/main/java/eu/kanade/tachiyomi/network/DohProviders.kt new file mode 100644 index 0000000000..982910f529 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/network/DohProviders.kt @@ -0,0 +1,40 @@ +package eu.kanade.tachiyomi.network + +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.dnsoverhttps.DnsOverHttps +import java.net.InetAddress + +/** + * Based on https://github.com/square/okhttp/blob/ef5d0c83f7bbd3a0c0534e7ca23cbc4ee7550f3b/okhttp-dnsoverhttps/src/test/java/okhttp3/dnsoverhttps/DohProviders.java + */ + +const val PREF_DOH_CLOUDFLARE = 1 +const val PREF_DOH_GOOGLE = 2 + +fun OkHttpClient.Builder.dohCloudflare() = dns( + DnsOverHttps.Builder().client(build()) + .url("https://cloudflare-dns.com/dns-query".toHttpUrl()) + .bootstrapDnsHosts( + InetAddress.getByName("162.159.36.1"), + InetAddress.getByName("162.159.46.1"), + InetAddress.getByName("1.1.1.1"), + InetAddress.getByName("1.0.0.1"), + InetAddress.getByName("162.159.132.53"), + InetAddress.getByName("2606:4700:4700::1111"), + InetAddress.getByName("2606:4700:4700::1001"), + InetAddress.getByName("2606:4700:4700::0064"), + InetAddress.getByName("2606:4700:4700::6400") + ) + .build() +) + +fun OkHttpClient.Builder.dohGoogle() = dns( + DnsOverHttps.Builder().client(build()) + .url("https://dns.google/dns-query".toHttpUrl()) + .bootstrapDnsHosts( + InetAddress.getByName("8.8.4.4"), + InetAddress.getByName("8.8.8.8") + ) + .build() +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt index 4a0f562c19..b593f4783d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -11,15 +11,12 @@ import eu.kanade.tachiyomi.source.online.utils.MdUtil import eu.kanade.tachiyomi.util.log.XLogLevel import okhttp3.Cache import okhttp3.Headers -import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Interceptor import okhttp3.OkHttpClient -import okhttp3.dnsoverhttps.DnsOverHttps import okhttp3.logging.HttpLoggingInterceptor import org.isomorphism.util.TokenBuckets import uy.kohesive.injekt.injectLazy import java.io.File -import java.net.InetAddress import java.util.concurrent.TimeUnit class NetworkHelper(val context: Context) { @@ -52,24 +49,9 @@ class NetworkHelper(val context: Context) { if (BuildConfig.DEBUG) { addInterceptor(ChuckerInterceptor(context)) } - if (preferences.enableDoh()) { - dns( - DnsOverHttps.Builder().client(build()) - .url("https://cloudflare-dns.com/dns-query".toHttpUrl()) - .bootstrapDnsHosts( - listOf( - InetAddress.getByName("162.159.36.1"), - InetAddress.getByName("162.159.46.1"), - InetAddress.getByName("1.1.1.1"), - InetAddress.getByName("1.0.0.1"), - InetAddress.getByName("162.159.132.53"), - InetAddress.getByName("2606:4700:4700::1111"), - InetAddress.getByName("2606:4700:4700::1001"), - InetAddress.getByName("2606:4700:4700::0064"), - InetAddress.getByName("2606:4700:4700::6400") - ) - ).build() - ) + when (preferences.dohProvider()) { + PREF_DOH_CLOUDFLARE -> dohCloudflare() + PREF_DOH_GOOGLE -> dohGoogle() } if (XLogLevel.shouldLog(XLogLevel.EXTREME)) { val logger: HttpLoggingInterceptor.Logger = object : HttpLoggingInterceptor.Logger { diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt index 6f6f72d458..1f5c5ca56d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/OkHttpExtensions.kt @@ -5,7 +5,7 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import okhttp3.Call import okhttp3.Callback -import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -14,14 +14,13 @@ import rx.Producer import rx.Subscription import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.fullType -import java.io.BufferedReader import java.io.IOException -import java.io.InputStreamReader import java.util.concurrent.atomic.AtomicBoolean -import java.util.zip.GZIPInputStream import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException +val jsonMime = "application/json; charset=utf-8".toMediaType() + fun Call.asObservable(): Observable { return Observable.unsafeCreate { subscriber -> // Since Call is a one-shot type, clone it for each new subscriber. @@ -62,17 +61,24 @@ fun Call.asObservable(): Observable { // Based on https://github.com/gildor/kotlin-coroutines-okhttp suspend fun Call.await(): Response { return suspendCancellableCoroutine { continuation -> - enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - continuation.resume(response) - } + enqueue( + object : Callback { + override fun onResponse(call: Call, response: Response) { + if (!response.isSuccessful) { + continuation.resumeWithException(Exception("HTTP error ${response.code}")) + return + } + + continuation.resume(response) + } - override fun onFailure(call: Call, e: IOException) { - // Don't bother with resuming the continuation if it is already cancelled. - if (continuation.isCancelled) return - continuation.resumeWithException(e) + override fun onFailure(call: Call, e: IOException) { + // Don't bother with resuming the continuation if it is already cancelled. + if (continuation.isCancelled) return + continuation.resumeWithException(e) + } } - }) + ) continuation.invokeOnCancellation { try { @@ -122,25 +128,3 @@ inline fun Response.parseAs(): T { return json.decodeFromString(responseBody) } } - -fun MediaType.Companion.jsonType(): MediaType = "application/json; charset=utf-8".toMediaTypeOrNull()!! - -fun Response.consumeBody(): String? { - use { - if (it.code != 200) throw Exception("HTTP error ${it.code}") - return it.body?.string() - } -} - -fun Response.consumeXmlBody(): String? { - use { res -> - if (res.code != 200) throw Exception("Export list error") - BufferedReader(InputStreamReader(GZIPInputStream(res.body?.source()?.inputStream()))).use { reader -> - val sb = StringBuilder() - reader.forEachLine { line -> - sb.append(line) - } - return sb.toString() - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt b/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt index 741dfbf57a..72719e1bcc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt @@ -16,7 +16,6 @@ fun GET( headers: Headers = DEFAULT_HEADERS, cache: CacheControl = DEFAULT_CACHE_CONTROL ): Request { - return Request.Builder() .url(url) .headers(headers) @@ -30,7 +29,6 @@ fun POST( body: RequestBody = DEFAULT_BODY, cache: CacheControl = DEFAULT_CACHE_CONTROL ): Request { - return Request.Builder() .url(url) .post(body) @@ -47,7 +45,6 @@ fun POSTWithCookie( body: RequestBody = DEFAULT_BODY, cache: CacheControl = DEFAULT_CACHE_CONTROL ): Request { - return Request.Builder() .url(url) .post(body) diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/TokenAuthenticator.kt b/app/src/main/java/eu/kanade/tachiyomi/network/TokenAuthenticator.kt index bc1f5a0e82..6beeb459e2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/TokenAuthenticator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/TokenAuthenticator.kt @@ -48,4 +48,4 @@ class TokenAuthenticator(val loginHelper: MangaDexLoginHelper) : else -> "" } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt index 1ca0778b6f..b50507ff5f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.source.model import android.net.Uri import eu.kanade.tachiyomi.network.ProgressListener import rx.subjects.Subject +import tachiyomi.source.model.PageUrl open class Page( val index: Int, @@ -14,14 +15,16 @@ open class Page( val number: Int get() = index + 1 - @Transient @Volatile var status: Int = 0 + @Transient @Volatile + var status: Int = 0 set(value) { field = value statusSubject?.onNext(value) statusCallback?.invoke(this) } - @Transient @Volatile var progress: Int = 0 + @Transient @Volatile + var progress: Int = 0 set(value) { field = value statusCallback?.invoke(this) @@ -55,3 +58,16 @@ open class Page( const val ERROR = 4 } } + +fun Page.toPageUrl(): PageUrl { + return PageUrl( + url = this.imageUrl ?: this.url + ) +} + +fun PageUrl.toPage(index: Int): Page { + return Page( + index = index, + imageUrl = this.url + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt index 3074adadae..6b1959ad24 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt @@ -1,6 +1,8 @@ package eu.kanade.tachiyomi.source.model +import eu.kanade.tachiyomi.data.database.models.ChapterImpl import eu.kanade.tachiyomi.source.online.MergeSource +import tachiyomi.source.model.ChapterInfo import java.io.Serializable interface SChapter : Serializable { @@ -45,6 +47,16 @@ interface SChapter : Serializable { language = other.language } + fun toChapter(): ChapterImpl { + return ChapterImpl().apply { + name = this@SChapter.name + url = this@SChapter.url + date_upload = this@SChapter.date_upload + chapter_number = this@SChapter.chapter_number + scanlator = this@SChapter.scanlator + } + } + companion object { fun create(): SChapter { return SChapterImpl() @@ -53,3 +65,24 @@ interface SChapter : Serializable { } fun SChapter.isMergedChapter() = this.scanlator?.equals(MergeSource.name) ?: false + +fun SChapter.toChapterInfo(): ChapterInfo { + return ChapterInfo( + dateUpload = this.date_upload, + key = this.url, + name = this.name, + number = this.chapter_number, + scanlator = this.scanlator ?: "" + ) +} + +fun ChapterInfo.toSChapter(): SChapter { + val chapter = this + return SChapter.create().apply { + url = chapter.key + name = chapter.name + date_upload = chapter.dateUpload + chapter_number = chapter.number + scanlator = chapter.scanlator + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapterImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapterImpl.kt index 8c51173278..9f1901e99e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapterImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapterImpl.kt @@ -21,6 +21,6 @@ class SChapterImpl : SChapter { override var language: String? = null override var mangadex_chapter_id: String = "" - + override var old_mangadex_id: String? = null } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt index da471e9a85..174e45035c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.source.model import eu.kanade.tachiyomi.data.database.models.MangaImpl import eu.kanade.tachiyomi.source.online.utils.FollowStatus +import tachiyomi.source.model.MangaInfo import java.io.Serializable interface SManga : Serializable { @@ -39,6 +40,9 @@ interface SManga : Serializable { val originalGenre: String? get() = (this as? MangaImpl)?.ogGenre ?: genre + val originalStatus: Int + get() = (this as? MangaImpl)?.ogStatus ?: status + var follow_status: FollowStatus? var lang_flag: String? @@ -66,48 +70,60 @@ interface SManga : Serializable { var last_chapter_number: Int? fun copyFrom(other: SManga) { - - if (other.author != null) + if (other.author != null) { author = other.originalAuthor + } - if (other.artist != null) + if (other.artist != null) { artist = other.originalArtist + } - if (other.description != null) + if (other.description != null) { description = other.originalDescription + } - if (other.genre != null) + if (other.genre != null) { genre = other.originalGenre + } - if (other.thumbnail_url != null) + if (other.thumbnail_url != null) { thumbnail_url = other.thumbnail_url + } - if (other.lang_flag != null) + if (other.lang_flag != null) { lang_flag = other.lang_flag + } - if (other.follow_status != null) + if (other.follow_status != null) { follow_status = other.follow_status + } - if (other.anilist_id != null) + if (other.anilist_id != null) { anilist_id = other.anilist_id - - if (other.kitsu_id != null) + } + if (other.kitsu_id != null) { kitsu_id = other.kitsu_id + } - if (other.my_anime_list_id != null) + if (other.my_anime_list_id != null) { my_anime_list_id = other.my_anime_list_id + } - if (other.anime_planet_id != null) + if (other.anime_planet_id != null) { anime_planet_id = other.anime_planet_id + } - if (other.manga_updates_id != null) + if (other.manga_updates_id != null) { manga_updates_id = other.manga_updates_id + } - if (other.rating != null) + if (other.rating != null) { rating = other.rating + } - if (other.users != null) + if (other.users != null) { users = other.users + } if (other.last_chapter_number != null) { last_chapter_number = other.last_chapter_number @@ -117,8 +133,9 @@ interface SManga : Serializable { status = other.status - if (!initialized) + if (!initialized) { initialized = other.initialized + } } companion object { @@ -136,6 +153,33 @@ interface SManga : Serializable { } } +fun SManga.toMangaInfo(): MangaInfo { + return MangaInfo( + key = this.url, + title = this.title, + artist = this.artist ?: "", + author = this.author ?: "", + description = this.description ?: "", + genres = this.genre?.split(", ") ?: emptyList(), + status = this.status, + cover = this.thumbnail_url ?: "" + ) +} + +fun MangaInfo.toSManga(): SManga { + val mangaInfo = this + return SManga.create().apply { + url = mangaInfo.key + title = mangaInfo.title + artist = mangaInfo.artist + author = mangaInfo.author + description = mangaInfo.description + genre = mangaInfo.genres.joinToString(", ") + status = mangaInfo.status + thumbnail_url = mangaInfo.cover + } +} + fun SManga.isMerged(): Boolean { return merge_manga_url.isNullOrEmpty().not() -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDex.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDex.kt index ddc82a6b03..e4b21364dc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDex.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDex.kt @@ -131,7 +131,11 @@ open class MangaDex : HttpSource() { val duration = response.receivedResponseAtMillis - response.sentRequestAtMillis val cache = response.header("X-Cache", "") == "HIT" val result = ImageReportResult( - page.imageUrl!!, response.isSuccessful, byteSize, cache, duration + page.imageUrl!!, + response.isSuccessful, + byteSize, + cache, + duration ) val jsonString = MdUtil.jsonParser.encodeToString(result) @@ -158,7 +162,6 @@ open class MangaDex : HttpSource() { } open fun imageRequest(page: Page): Request { - val atHomeHeaders = if (isLogged()) { MdUtil.getAuthHeaders(headers, preferences) } else { @@ -173,9 +176,9 @@ open class MangaDex : HttpSource() { val tokenRequestUrl = data[1] val cacheControl = if (Date().time - ( - tokenTracker[tokenRequestUrl] - ?: 0 - ) > MdUtil.mdAtHomeTokenLifespan + tokenTracker[tokenRequestUrl] + ?: 0 + ) > MdUtil.mdAtHomeTokenLifespan ) { tokenTracker[tokenRequestUrl] = Date().time CacheControl.FORCE_NETWORK @@ -212,8 +215,8 @@ open class MangaDex : HttpSource() { } override fun isLogged(): Boolean { - return preferences.sourcePassword(this).isNullOrBlank().not() && preferences.sessionToken().isNullOrBlank().not() - && preferences.refreshToken().isNullOrBlank().not() + return preferences.sourceUsername(this).isNullOrBlank().not() && preferences.sourcePassword(this).isNullOrBlank().not() && preferences.sessionToken().isNullOrBlank().not() && + preferences.refreshToken().isNullOrBlank().not() } override suspend fun login( diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDexCache.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDexCache.kt index bc7085ee51..f32d12597e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDexCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDexCache.kt @@ -62,7 +62,6 @@ open class MangaDexCache() : MangaDex() { } override fun fetchPopularManga(page: Int): Observable { - // First check if we have manga to select val count = db.getCachedMangaCount().executeAsBlocking() if (count == 0) { @@ -99,7 +98,6 @@ open class MangaDexCache() : MangaDex() { query: String, filters: FilterList ): Observable { - // First check if we have manga to select val count = db.getCachedMangaCount().executeAsBlocking() XLog.i("Number of Cached entries: $count") @@ -158,7 +156,7 @@ open class MangaDexCache() : MangaDex() { } else { db.getChaptersByMangaId(dbManga.id!!).executeAsBlocking() } - //don't replace manga info if it already exists waste of network, and loses the original non cached info + // don't replace manga info if it already exists waste of network, and loses the original non cached info val mangaToReturn = if (manga.description.isNullOrBlank()) { fetchMangaDetails(manga) } else { @@ -234,7 +232,6 @@ open class MangaDexCache() : MangaDex() { } private fun parseMangaCacheApi(response: Response): SManga { - // Error check http response if (response.code == 404) { throw Exception("Manga has not been cached...") @@ -253,7 +250,7 @@ open class MangaDexCache() : MangaDex() { // Convert from the api format mangaReturn.title = MdUtil.cleanString(networkApiManga.data.attributes.title["en"]!!) mangaReturn.description = "NOTE: THIS IS A CACHED MANGA ENTRY\n" + MdUtil.cleanDescription(networkApiManga.data.attributes.description["en"]!!) - //mangaReturn.rating = networkApiManga.toString() + // mangaReturn.rating = networkApiManga.toString() mangaReturn.thumbnail_url = MdUtil.imageUrlCacheNotFound // Get the external tracking ids for this manga @@ -276,13 +273,14 @@ open class MangaDexCache() : MangaDex() { // List the labels for this manga val tags = filterHandler.getTags() val genres = ( - listOf(networkManga.publicationDemographic?.capitalize(Locale.US)) - + networkManga.tags?.map { it.id } - ?.map { dexTagId -> tags.firstOrNull { tag -> tag.id == dexTagId } } - ?.map { tag -> tag?.name } - + listOf( - "Content Rating - " + (networkManga.contentRating?.capitalize(Locale.US) ?: "Unknown") - )) + listOf(networkManga.publicationDemographic?.capitalize(Locale.US)) + + networkManga.tags?.map { it.id } + ?.map { dexTagId -> tags.firstOrNull { tag -> tag.id == dexTagId } } + ?.map { tag -> tag?.name } + + listOf( + "Content Rating - " + (networkManga.contentRating?.capitalize(Locale.US) ?: "Unknown") + ) + ) .filterNotNull() mangaReturn.genre = genres.joinToString(", ") @@ -292,4 +290,4 @@ open class MangaDexCache() : MangaDex() { throw e } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDexLoginHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDexLoginHelper.kt index 329d35bb51..3f16f29c50 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDexLoginHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/MangaDexLoginHelper.kt @@ -75,7 +75,6 @@ class MangaDexLoginHelper { password: String, ): Boolean { return withContext(Dispatchers.IO) { - val loginRequest = LoginRequest(username, password) val jsonString = MdUtil.jsonParser.encodeToString(LoginRequest.serializer(), loginRequest) @@ -88,7 +87,6 @@ class MangaDexLoginHelper { ).await() if (postResult.code == 200) { - val loginResponse = MdUtil.jsonParser.decodeFromString(postResult.body!!.string()) preferences.setRefreshToken(loginResponse.token.refresh) preferences.setSessionToken(loginResponse.token.session) @@ -109,4 +107,4 @@ class MangaDexLoginHelper { } return login(username, password) } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/MergeSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/MergeSource.kt index d33b2d2e7d..173b16ff18 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/MergeSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/MergeSource.kt @@ -90,7 +90,6 @@ class MergeSource : ReducedHttpSource() { return@withContext gson.fromJson(vmChapters).map { json -> val indexChapter = json["Chapter"].string SChapter.create().apply { - val type = json["Type"].string name = json["ChapterName"].nullString.let { if (it.isNullOrEmpty()) "$type ${chapterImage(indexChapter)}" else it } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/ApiMangaParser.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/ApiMangaParser.kt index 8687fd4ff7..f2b6c2f590 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/ApiMangaParser.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/ApiMangaParser.kt @@ -49,7 +49,6 @@ class ApiMangaParser { json.results.map { MdUtil.cleanString(it.data.attributes.name) } }.getOrNull() ?: emptyList() - manga.author = authors.joinToString() manga.artist = null manga.lang_flag = networkManga.originalLanguage @@ -80,7 +79,7 @@ class ApiMangaParser { manga.missing_chapters = null } else {*/ manga.status = tempStatus - //} + // } val tags = filterHandler.getTags() @@ -93,11 +92,11 @@ class ApiMangaParser { } val genres = ( - listOf(networkManga.publicationDemographic?.capitalize(Locale.US)) - + networkManga.tags.map { it.id } - .map { dexTagId -> tags.firstOrNull { tag -> tag.id == dexTagId } } - .map { tag -> tag?.name } - + listOf(contentRating) + listOf(networkManga.publicationDemographic?.capitalize(Locale.US)) + + networkManga.tags.map { it.id } + .map { dexTagId -> tags.firstOrNull { tag -> tag.id == dexTagId } } + .map { tag -> tag?.name } + + listOf(contentRating) ) .filterNotNull() @@ -250,7 +249,6 @@ class ApiMangaParser { val scanlatorName = networkChapter.relationships.filter { it.type == "scanlation_group" }.mapNotNull { groups[it.id] }.toSet() - chapter.scanlator = MdUtil.cleanString(MdUtil.getScanlatorString(scanlatorName)) chapter.mangadex_chapter_id = MdUtil.getChapterId(chapter.url) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/FilterHandler.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/FilterHandler.kt index 9af600eead..b801a48c16 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/FilterHandler.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/FilterHandler.kt @@ -13,7 +13,6 @@ class FilterHandler() { val preferencesHelper: PreferencesHelper by injectLazy() internal fun getMDFilterList(): FilterList { - val filters = mutableListOf( OriginalLanguageList(getOriginalLanguage()), DemographicList(getDemographics()), @@ -166,7 +165,7 @@ class FilterHandler() { Filter.Select("Excluded tags mode", arrayOf("And", "Or"), 1) val sortableList = listOf( - Pair("Default (Asc/Desc doesn't matter)", ""), + Pair("Number of follows", ""), Pair("Created at", "createdAt"), Pair("Updated at", "updatedAt"), ) @@ -269,4 +268,3 @@ class FilterHandler() { return url.toString() } } - diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/FollowsHandler.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/FollowsHandler.kt index 5044c3078b..9d7a6ff9bf 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/FollowsHandler.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/FollowsHandler.kt @@ -79,7 +79,6 @@ class FollowsHandler { * used when multiple follows */ private fun followsParseMangaPage(response: List, readingStatusMap: Map): MangasPage { - val comparator = compareBy { it.follow_status }.thenBy { it.title } val coverMap = MdUtil.getCoversFromMangaList(response, network.client) @@ -99,7 +98,6 @@ class FollowsHandler { */ private fun followStatusParse(response: Response, mangaId: String): Track { - val mangaResponse = MdUtil.jsonParser.decodeFromString(response.body!!.string()) val followStatus = FollowStatus.fromDex(mangaResponse.status) val track = Track.create(TrackManager.MDLIST) @@ -136,7 +134,6 @@ class FollowsHandler { */ suspend fun updateFollowStatus(mangaId: String, followStatus: FollowStatus): Boolean { return withContext(Dispatchers.IO) { - val status = when (followStatus == FollowStatus.UNFOLLOWED) { true -> null false -> followStatus.name.toLowerCase(Locale.US) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/MangaHandler.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/MangaHandler.kt index 621dd4eccc..2adab6f7ff 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/MangaHandler.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/MangaHandler.kt @@ -29,7 +29,6 @@ class MangaHandler() { suspend fun fetchMangaAndChapterDetails(manga: SManga): Pair> { return withContext(Dispatchers.IO) { - val response = network.client.newCall(mangaRequest(manga)).await() val jsonData = response.body!!.string() @@ -70,7 +69,6 @@ class MangaHandler() { } fun fetchMangaDetailsObservable(manga: SManga): Observable { - return network.client.newCall(mangaRequest(manga)) .asObservableSuccess() .map { response -> @@ -113,7 +111,6 @@ class MangaHandler() { }.flatten().toMap() }.getOrNull() ?: emptyMap() - apiMangaParser.chapterListParse(results, groupMap) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/PopularHandler.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/PopularHandler.kt index a14e1ecbe8..31bd0e6f42 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/PopularHandler.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/PopularHandler.kt @@ -31,7 +31,6 @@ class PopularHandler { } private fun popularMangaRequest(page: Int): Request { - val tempUrl = MdUtil.mangaUrl.toHttpUrlOrNull()!!.newBuilder() tempUrl.apply { diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/SearchHandler.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/SearchHandler.kt index f6781498f3..6d9fcf0216 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/SearchHandler.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/SearchHandler.kt @@ -25,7 +25,6 @@ class SearchHandler { private val v5DbHelper: V5DbHelper by injectLazy() fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { - return if (query.startsWith(PREFIX_ID_SEARCH)) { val realQuery = query.removePrefix(PREFIX_ID_SEARCH) network.client.newCall(searchMangaByIdRequest(realQuery)) @@ -65,7 +64,6 @@ class SearchHandler { } private fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - val tempUrl = MdUtil.mangaUrl.toHttpUrlOrNull()!!.newBuilder() tempUrl.apply { diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/Auth.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/Auth.kt index 9863b98539..db831cab57 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/Auth.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/Auth.kt @@ -36,4 +36,4 @@ data class CheckTokenResponse(val isAuthenticated: Boolean) * Request to refresh token */ @Serializable -data class RefreshTokenRequest(val token: String) \ No newline at end of file +data class RefreshTokenRequest(val token: String) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/CacheApiMangaSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/CacheApiMangaSerializer.kt index 1ca2285878..e4e163ccb1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/CacheApiMangaSerializer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/CacheApiMangaSerializer.kt @@ -4,50 +4,49 @@ import kotlinx.serialization.Serializable @Serializable data class CacheApiMangaSerializer( - val result : String, - val data : CacheApiData, - val relationships : List + val result: String, + val data: CacheApiData, + val relationships: List ) @Serializable -data class CacheApiRelationships ( - val id : String, - val type : String +data class CacheApiRelationships( + val id: String, + val type: String ) @Serializable -data class CacheApiData ( - val id : String, - val type : String, - val attributes : CacheApiDataAttributes +data class CacheApiData( + val id: String, + val type: String, + val attributes: CacheApiDataAttributes ) @Serializable data class CacheApiDataAttributes( - val title : Map, - val description : Map, - val links : Map? = null, - val originalLanguage : String? = null, - val lastChapter : String? = null, - val publicationDemographic : String? = null, - val status : String? = null, - val contentRating : String? = null, - val tags : List? = null, - val version : Int, - val createdAt : String, - val updatedAt : String + val title: Map, + val description: Map, + val links: Map? = null, + val originalLanguage: String? = null, + val lastChapter: String? = null, + val publicationDemographic: String? = null, + val status: String? = null, + val contentRating: String? = null, + val tags: List? = null, + val version: Int, + val createdAt: String, + val updatedAt: String ) @Serializable -data class CacheApiTags ( - val id : String, - val type : String, - val attributes : CacheApiTagAttributes +data class CacheApiTags( + val id: String, + val type: String, + val attributes: CacheApiTagAttributes ) @Serializable -data class CacheApiTagAttributes ( - val name : Map, - val version : Int, +data class CacheApiTagAttributes( + val name: Map, + val version: Int, ) - diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/ChapterSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/ChapterSerializer.kt index 38259dabe1..660dcac29b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/ChapterSerializer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/ChapterSerializer.kt @@ -64,4 +64,4 @@ data class GroupData( @Serializable data class GroupAttributes( val name: String, -) \ No newline at end of file +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/MangaSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/MangaSerializer.kt index b1ccc09ec0..b87ea461ec 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/MangaSerializer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/MangaSerializer.kt @@ -7,14 +7,14 @@ data class MangaListResponse( val limit: Int, val offset: Int, val total: Int, - val results: List + val results: List, ) @Serializable data class MangaResponse( val result: String, val data: NetworkManga, - val relationships: List + val relationships: List, ) @Serializable @@ -38,13 +38,20 @@ data class NetworkMangaAttributes( @Serializable data class TagsSerializer( - val id: String + val id: String, ) @Serializable data class Relationships( val id: String, val type: String, + val attributes: IncludesAttributes, +) + +@Serializable +data class IncludesAttributes( + val name: String = "", + val fileName: String = "", ) @Serializable @@ -71,17 +78,17 @@ data class AuthorAttributes( @Serializable data class GetReadingStatus( - val status: String? + val status: String?, ) @Serializable data class UpdateReadingStatus( - val status: String? + val status: String?, ) @Serializable data class MangaStatusListResponse( - val statuses: Map + val statuses: Map, ) @Serializable @@ -92,7 +99,7 @@ data class CoverListResponse( @Serializable data class CoverResponse( val data: Cover, - val relationships: List + val relationships: List, ) @Serializable @@ -103,4 +110,4 @@ data class Cover( @Serializable data class CoverAttributes( val fileName: String, -) \ No newline at end of file +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/SimilarSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/SimilarSerializer.kt index 425e45eee6..9de0c58852 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/SimilarSerializer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/handlers/serializers/SimilarSerializer.kt @@ -4,19 +4,17 @@ import kotlinx.serialization.Serializable @Serializable data class SimilarMangaResponse( - val id : String, - val title: Map, - val contentRating : String, - val matches : List, - val updatedAt : String + val id: String, + val title: Map, + val contentRating: String, + val matches: List, + val updatedAt: String ) @Serializable data class Matches( - val id : String, - val title : Map, - val contentRating : String, - val score : Double + val id: String, + val title: Map, + val contentRating: String, + val score: Double ) - - diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/utils/MdUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/utils/MdUtil.kt index 59c4431fe5..94a94056eb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/utils/MdUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/utils/MdUtil.kt @@ -244,14 +244,6 @@ class MdUtil { return cleanString(newDescription) } - fun getImageUrl(attr: String): String { - // Some images are hosted elsewhere - if (attr.startsWith("http")) { - return attr - } - return baseUrl + attr - } - fun getScanlators(scanlators: String): List { if (scanlators.isBlank()) return emptyList() return scanlators.split(scanlatorSeparator).distinct() @@ -317,7 +309,6 @@ class MdUtil { } fun getCoversFromMangaList(mangaResponseList: List, client: OkHttpClient): Map { - val idsAndCoverIds = mangaResponseList.mapNotNull { mangaResponse -> val mangaId = mangaResponse.data.id val coverId = mangaResponse.relationships.firstOrNull { relationship -> diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/BaseToolbar.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/BaseToolbar.kt new file mode 100644 index 0000000000..a0b5797940 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/BaseToolbar.kt @@ -0,0 +1,76 @@ +package eu.kanade.tachiyomi.ui.base + +import android.content.Context +import android.util.AttributeSet +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.appcompat.graphics.drawable.DrawerArrowDrawable +import androidx.core.view.isVisible +import com.google.android.material.appbar.MaterialToolbar +import eu.kanade.tachiyomi.R + +open class BaseToolbar @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + MaterialToolbar(context, attrs) { + + protected lateinit var toolbarTitle: TextView + private val defStyleRes = com.google.android.material.R.style.Widget_MaterialComponents_Toolbar + + protected val titleTextAppearance: Int + + var incognito = false + init { + val a = context.obtainStyledAttributes( + attrs, + R.styleable.Toolbar, + 0, + defStyleRes + ) + titleTextAppearance = a.getResourceId(R.styleable.Toolbar_titleTextAppearance, 0) + a.recycle() + } + + override fun setTitle(resId: Int) { + setCustomTitle(context.getString(resId)) + } + + override fun setTitle(title: CharSequence?) { + setCustomTitle(title) + } + + protected open fun setCustomTitle(title: CharSequence?) { + toolbarTitle.isVisible = true + toolbarTitle.text = title + super.setTitle(null) + setIncognitoMode(incognito) + } + + fun setIncognitoMode(enabled: Boolean) { + incognito = enabled + setIcons() + } + + open fun setIcons() { + toolbarTitle.setCompoundDrawablesRelativeWithIntrinsicBounds( + getIncogRes(), + 0, + getDropdownRes(), + 0 + ) + } + + @DrawableRes + private fun getIncogRes(): Int { + return when { + incognito -> R.drawable.ic_incognito_circle_24dp + else -> 0 + } + } + + @DrawableRes + private fun getDropdownRes(): Int { + return when { + incognito && navigationIcon !is DrawerArrowDrawable -> R.drawable.ic_blank_28dp + else -> 0 + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/CenteredToolbar.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/CenteredToolbar.kt index cf67558132..02b29163f2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/CenteredToolbar.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/CenteredToolbar.kt @@ -1,50 +1,36 @@ package eu.kanade.tachiyomi.ui.base +import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet +import android.view.Gravity import androidx.appcompat.graphics.drawable.DrawerArrowDrawable -import com.google.android.material.appbar.MaterialToolbar +import androidx.core.view.updateLayoutParams +import com.google.android.material.textview.MaterialTextView import eu.kanade.tachiyomi.R -import kotlinx.android.synthetic.main.main_activity.view.* +import eu.kanade.tachiyomi.util.system.contextCompatDrawable +import eu.kanade.tachiyomi.util.system.dpToPx +import eu.kanade.tachiyomi.util.system.getResourceColor -class CenteredToolbar @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - MaterialToolbar(context, attrs) { +@SuppressLint("CustomViewStyleable") +class CenteredToolbar@JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + BaseToolbar(context, attrs) { - override fun setTitle(resId: Int) { - if (navigationIcon is DrawerArrowDrawable) { - super.setTitle(resId) - toolbar_title.text = null - hideDropdown() - } else { - toolbar_title.text = context.getString(resId) - super.setTitle(null) + override fun onFinishInflate() { + super.onFinishInflate() + toolbarTitle = findViewById(R.id.toolbar_title) + toolbarTitle.setTextAppearance(titleTextAppearance) + toolbarTitle.setTextColor(context.getResourceColor(R.attr.actionBarTintColor)) + collapseIcon = context.contextCompatDrawable(R.drawable.ic_arrow_back_24dp)?.apply { + setTint(context.getResourceColor(R.attr.actionBarTintColor)) } } - override fun setTitle(title: CharSequence?) { - if (navigationIcon is DrawerArrowDrawable) { - super.setTitle(title) - toolbar_title.text = "" - hideDropdown() - } else { - toolbar_title.text = title - super.setTitle(null) + override fun setCustomTitle(title: CharSequence?) { + super.setCustomTitle(title) + toolbarTitle.updateLayoutParams { + gravity = if (navigationIcon is DrawerArrowDrawable) Gravity.START else Gravity.CENTER } - } - - fun showDropdown(down: Boolean = true) { - toolbar_title.setCompoundDrawablesRelativeWithIntrinsicBounds( - R.drawable.ic_blank_24dp, 0, - if (down) { - R.drawable.ic_arrow_drop_down_24dp - } else { - R.drawable.ic_arrow_drop_up_24dp - }, - 0 - ) - } - - fun hideDropdown() { - toolbar_title.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0) + toolbarTitle.compoundDrawablePadding = if (navigationIcon is DrawerArrowDrawable) 6.dpToPx else 0 } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/FloatingToolbar.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/FloatingToolbar.kt new file mode 100644 index 0000000000..37abdbf623 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/FloatingToolbar.kt @@ -0,0 +1,81 @@ +package eu.kanade.tachiyomi.ui.base + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.graphics.ColorUtils +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import com.google.android.material.textview.MaterialTextView +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.system.contextCompatDrawable +import eu.kanade.tachiyomi.util.system.getResourceColor + +class FloatingToolbar @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + BaseToolbar(context, attrs) { + + private val actionColorAlpha = ColorUtils.setAlphaComponent(context.getResourceColor(R.attr.actionBarTintColor), 200) + private val actionColorAlphaSecondary = ColorUtils.setAlphaComponent(context.getResourceColor(R.attr.actionBarTintColor), 150) + + private lateinit var toolbarsubTitle: TextView + private lateinit var cardIncogImage: ImageView + private val defStyleRes = com.google.android.material.R.style.Widget_MaterialComponents_Toolbar + private val subtitleTextAppeance: Int + + init { + val a = context.obtainStyledAttributes( + attrs, + R.styleable.Toolbar, + 0, + defStyleRes + ) + subtitleTextAppeance = a.getResourceId(R.styleable.Toolbar_subtitleTextAppearance, 0) + a.recycle() + } + override fun onFinishInflate() { + super.onFinishInflate() + toolbarTitle = findViewById(R.id.card_title) + toolbarTitle.setTextAppearance(titleTextAppearance) + toolbarTitle.setTextColor(actionColorAlpha) + + toolbarsubTitle = findViewById(R.id.card_subtitle) + toolbarsubTitle.setTextAppearance(subtitleTextAppeance) + toolbarsubTitle.setTextColor(actionColorAlphaSecondary) + toolbarsubTitle.isVisible = false + + cardIncogImage = findViewById(R.id.card_incog_image) + + setNavigationIconTint(actionColorAlpha) + collapseIcon = context.contextCompatDrawable(R.drawable.ic_arrow_back_24dp)?.apply { + setTint(actionColorAlpha) + } + } + + override fun setSubtitle(resId: Int) { + setCustomSubtitle(context.getString(resId)) + } + + override fun setSubtitle(subtitle: CharSequence?) { + setCustomSubtitle(subtitle) + } + + override fun setIcons() { + cardIncogImage.isVisible = incognito + } + + private fun setCustomSubtitle(title: CharSequence?) { + toolbarsubTitle.isVisible = !title.isNullOrBlank() + toolbarsubTitle.text = title + super.setSubtitle(null) + } + + override fun setCustomTitle(title: CharSequence?) { + super.setCustomTitle(title) + toolbarTitle.updateLayoutParams { + gravity = Gravity.START + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/MaterialFastScroll.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/MaterialFastScroll.kt index 69581b342e..e317cfdf75 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/MaterialFastScroll.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/MaterialFastScroll.kt @@ -9,6 +9,7 @@ import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.StaggeredGridLayoutManager import eu.davidea.fastscroller.FastScroller import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.dpToPxEnd import eu.kanade.tachiyomi.util.view.marginTop import kotlin.math.abs @@ -21,7 +22,9 @@ class MaterialFastScroll @JvmOverloads constructor(context: Context, attrs: Attr var scrollOffset = 0 init { setViewsToUse( - R.layout.material_fastscroll, R.id.fast_scroller_bubble, R.id.fast_scroller_handle + R.layout.material_fastscroll, + R.id.fast_scroller_bubble, + R.id.fast_scroller_handle ) autoHideEnabled = true ignoreTouchesOutsideHandle = false @@ -51,7 +54,7 @@ class MaterialFastScroll @JvmOverloads constructor(context: Context, attrs: Attr } MotionEvent.ACTION_MOVE -> { val y = event.y - if (!canScroll && abs(y - startY) > 10) { + if (!canScroll && abs(y - startY) > 15.dpToPx) { canScroll = true handle.isSelected = true notifyScrollStateChange(true) @@ -85,10 +88,14 @@ class MaterialFastScroll @JvmOverloads constructor(context: Context, attrs: Attr val targetPos = getTargetPos(y) if (layoutManager is StaggeredGridLayoutManager) { (layoutManager as StaggeredGridLayoutManager).scrollToPositionWithOffset( - targetPos, scrollOffset + targetPos, + scrollOffset ) } else { - (layoutManager as LinearLayoutManager).scrollToPositionWithOffset(targetPos, scrollOffset) + (layoutManager as LinearLayoutManager).scrollToPositionWithOffset( + targetPos, + scrollOffset + ) } updateBubbleText(targetPos) } @@ -111,4 +118,8 @@ class MaterialFastScroll @JvmOverloads constructor(context: Context, attrs: Attr } } } + + companion object { + const val noUpdate = "don't update scroll" + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/MaterialMenuSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/MaterialMenuSheet.kt index a614b57c22..3fbf2d7c67 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/MaterialMenuSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/MaterialMenuSheet.kt @@ -3,111 +3,96 @@ package eu.kanade.tachiyomi.ui.base import android.animation.ObjectAnimator import android.animation.ValueAnimator import android.app.Activity -import android.content.res.ColorStateList import android.os.Build +import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.widget.ImageView -import android.widget.TextView import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.textview.MaterialTextView -import eu.kanade.tachiyomi.R +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.mikepenz.fastadapter.FastAdapter +import com.mikepenz.fastadapter.adapters.ItemAdapter +import eu.kanade.tachiyomi.databinding.BottomMenuSheetBinding import eu.kanade.tachiyomi.util.system.dpToPx -import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.hasSideNavBar import eu.kanade.tachiyomi.util.system.isInNightMode +import eu.kanade.tachiyomi.util.view.RecyclerWindowInsetsListener +import eu.kanade.tachiyomi.util.view.checkHeightThen import eu.kanade.tachiyomi.util.view.expand -import eu.kanade.tachiyomi.util.view.invisible -import eu.kanade.tachiyomi.util.view.isVisible -import eu.kanade.tachiyomi.util.view.setBottomEdge -import eu.kanade.tachiyomi.util.view.setEdgeToEdge -import eu.kanade.tachiyomi.util.view.setTextColorRes import eu.kanade.tachiyomi.util.view.updateLayoutParams -import eu.kanade.tachiyomi.util.view.visible -import eu.kanade.tachiyomi.util.view.visibleIf -import kotlinx.android.synthetic.main.bottom_menu_sheet.* +import eu.kanade.tachiyomi.widget.E2EBottomSheetDialog +import kotlin.math.max +import kotlin.math.min class MaterialMenuSheet( activity: Activity, - items: List, + private val items: List, title: String? = null, selectedId: Int? = null, maxHeight: Int? = null, + showDivider: Boolean = false, onMenuItemClicked: (MaterialMenuSheet, Int) -> Boolean -) : - BottomSheetDialog - (activity, R.style.BottomSheetDialogTheme) { +) : E2EBottomSheetDialog(activity) { - private val primaryColor = activity.getResourceColor(android.R.attr.textColorPrimary) - private val view = activity.layoutInflater.inflate(R.layout.bottom_menu_sheet, null) + override fun createBinding(inflater: LayoutInflater) = BottomMenuSheetBinding.inflate(inflater) + private val fastAdapter: FastAdapter + private val itemAdapter = ItemAdapter() + + override var recyclerView: RecyclerView? = binding.menuSheetRecycler init { - setContentView(view) - setEdgeToEdge(activity, view) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.isInNightMode() && !activity.window.decorView.rootWindowInsets.hasSideNavBar()) { window?.decorView?.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR } - maxHeight?.let { - menu_scroll_view.maxHeight = it + activity.window.decorView.rootWindowInsets.systemWindowInsetBottom - menu_scroll_view.requestLayout() + binding.menuSheetLayout.checkHeightThen { + binding.menuSheetRecycler.updateLayoutParams { + val fullHeight = activity.window.decorView.height + val insets = activity.window.decorView.rootWindowInsets + matchConstraintMaxHeight = + min( + (maxHeight ?: fullHeight) + (insets?.systemWindowInsetBottom ?: 0), + fullHeight - (insets?.systemWindowInsetTop ?: 0) - + binding.titleLayout.height - 26.dpToPx + ) + } } - var currentIndex: Int? = null - items.forEachIndexed { index, item -> - val view = - activity.layoutInflater.inflate(R.layout.menu_sheet_item, null) as ViewGroup - val textView = view.getChildAt(0) as MaterialTextView - if (index == 0 && title == null) { - view.setBackgroundResource(R.drawable.rounded_item_background) - } - with(view) { - id = item.id - menu_layout.addView(this) - setOnClickListener { - val shouldDismiss = onMenuItemClicked(this@MaterialMenuSheet, id) - if (shouldDismiss) { - dismiss() - } - } - } - with(textView) { - if (item.text != null) { - text = item.text - } else { - setText(item.textRes) - } - setCompoundDrawablesRelativeWithIntrinsicBounds(item.drawable, 0, 0, 0) - if (item.drawable == 0) { - textSize = 14f - } - if (item.id == selectedId) { - currentIndex = index - setTextColorRes(R.color.colorAccent) - compoundDrawableTintList = - ColorStateList.valueOf(context.getColor(R.color.colorAccent)) - } - updateLayoutParams { - height = 48.dpToPx - width = MATCH_PARENT - } + binding.divider.isVisible = showDivider + + fastAdapter = FastAdapter.with(itemAdapter) + fastAdapter.setHasStableIds(true) + itemAdapter.set(items.map(::MaterialMenuSheetItem)) + + binding.menuSheetRecycler.layoutManager = LinearLayoutManager(context) + binding.menuSheetRecycler.adapter = fastAdapter + + fastAdapter.onClickListener = { _, _, item, _ -> + val shouldDismiss = onMenuItemClicked(this@MaterialMenuSheet, item.sheetItem.id) + if (shouldDismiss) { + dismiss() } + false } - BottomSheetBehavior.from(view.parent as ViewGroup).expand() - BottomSheetBehavior.from(view.parent as ViewGroup).skipCollapsed = true - - setBottomEdge(menu_layout, activity) + sheetBehavior.expand() + sheetBehavior.skipCollapsed = true - title_layout.visibleIf(title != null) - toolbar_title.text = title + binding.menuSheetRecycler.setOnApplyWindowInsetsListener(RecyclerWindowInsetsListener) + binding.titleLayout.isVisible = title != null + binding.toolbarTitle.text = title - currentIndex?.let { - view.post { - menu_scroll_view.scrollTo(0, it * 48.dpToPx - menu_scroll_view.height / 2) + if (selectedId != null) { + val pos = max(items.indexOfFirst { it.id == selectedId }, 0) + itemAdapter.getAdapterItem(pos).isSelected = true + binding.root.post { + binding.root.post { + binding.menuSheetRecycler.scrollBy( + 0, + pos * 48.dpToPx - binding.menuSheetRecycler.height / 2 + ) + } } } @@ -119,49 +104,46 @@ class MaterialMenuSheet( isElevated = elevate elevationAnimator?.cancel() elevationAnimator = ObjectAnimator.ofFloat( - title_layout, "elevation", title_layout.elevation, if (elevate) 10f else 0f + binding.titleLayout, + "elevation", + binding.titleLayout.elevation, + if (elevate) 5f else 0f ) elevationAnimator?.start() } - elevate(menu_scroll_view.canScrollVertically(-1)) - if (title_layout.isVisible()) { - menu_scroll_view.setOnScrollChangeListener { _: View?, _: Int, _: Int, _: Int, _: Int -> - val notAtTop = menu_scroll_view.canScrollVertically(-1) - if (notAtTop != isElevated) { - elevate(notAtTop) + elevate(binding.menuSheetRecycler.canScrollVertically(-1)) + if (binding.titleLayout.isVisible) { + binding.menuSheetRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + val notAtTop = binding.menuSheetRecycler.canScrollVertically(-1) + if (notAtTop != isElevated) { + elevate(notAtTop) + } } - } + }) } } private fun clearEndDrawables() { - (0 until menu_layout.childCount).forEach { - val textView = (menu_layout.getChildAt(it) as ViewGroup).getChildAt(0) as TextView - val imageView = (menu_layout.getChildAt(it) as ViewGroup).getChildAt(1) as ImageView - textView.setTextColor(primaryColor) - textView.compoundDrawableTintList = ColorStateList.valueOf(primaryColor) - imageView.invisible() - } + itemAdapter.adapterItems.forEach { it.isSelected = false } } fun setDrawable(id: Int, @DrawableRes drawableRes: Int, clearAll: Boolean = true) { if (clearAll) { clearEndDrawables() } - val layout = menu_layout.findViewById(id) ?: return - val textView = layout.getChildAt(0) as? TextView - val imageView = layout.getChildAt(1) as? ImageView - textView?.setTextColorRes(R.color.colorAccent) - textView?.compoundDrawableTintList = - ColorStateList.valueOf(context.getColor(R.color.colorAccent)) - imageView?.visible() - imageView?.setImageResource(drawableRes) + val pos = max(items.indexOfFirst { it.id == id }, 0) + val item = itemAdapter.getAdapterItem(pos) + item.sheetItem.endDrawableRes = drawableRes + item.isSelected = true + fastAdapter.notifyAdapterDataSetChanged() } data class MenuSheetItem( val id: Int, @DrawableRes val drawable: Int = 0, @StringRes val textRes: Int = 0, - val text: String? = null + val text: String? = null, + var endDrawableRes: Int = 0 ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/MaterialMenuSheetItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/MaterialMenuSheetItem.kt new file mode 100644 index 0000000000..72ad289e14 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/MaterialMenuSheetItem.kt @@ -0,0 +1,50 @@ +package eu.kanade.tachiyomi.ui.base + +import android.view.View +import com.mikepenz.fastadapter.FastAdapter +import com.mikepenz.fastadapter.items.AbstractItem +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.MenuSheetItemBinding + +class MaterialMenuSheetItem(val sheetItem: MaterialMenuSheet.MenuSheetItem) : AbstractItem() { + + /** defines the type defining this item. must be unique. preferably an id */ + override val type: Int = R.id.item_text_view + + /** + * Returns the layout resource for this item. + */ + override val layoutRes: Int = R.layout.menu_sheet_item + override var identifier = sheetItem.id.toLong() + + override fun getViewHolder(v: View): ViewHolder { + return ViewHolder(v) + } + + class ViewHolder(view: View) : FastAdapter.ViewHolder(view) { + + private val binding = MenuSheetItemBinding.bind(view) + override fun bindView(item: MaterialMenuSheetItem, payloads: List) { + val sheetItem = item.sheetItem + with(binding.root) { + if (sheetItem.text != null) { + text = sheetItem.text + } else { + setText(sheetItem.textRes) + } + setIcon(sheetItem.drawable) + if (sheetItem.drawable == 0) { + textSize = 14f + } + + isSelected = this.isSelected + if (isSelected) { + selectWithEndIcon(sheetItem.endDrawableRes) + } + } + } + + override fun unbindView(item: MaterialMenuSheetItem) { + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/MaxHeightScrollView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/MaxHeightScrollView.kt index bc8f5749d6..1d81ab6f67 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/MaxHeightScrollView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/MaxHeightScrollView.kt @@ -9,11 +9,14 @@ class MaxHeightScrollView @JvmOverloads constructor(context: Context, attrs: Att var maxHeight = -1 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val heightS = if (maxHeight > 0) { + var heightS = if (maxHeight > 0) { MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST) } else { heightMeasureSpec } + if (maxHeight < height + (rootWindowInsets?.systemWindowInsetBottom ?: 0)) { + heightS = MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST) + } super.onMeasure(widthMeasureSpec, heightS) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/MiniSearchView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/MiniSearchView.kt new file mode 100644 index 0000000000..d09edbd615 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/MiniSearchView.kt @@ -0,0 +1,51 @@ +package eu.kanade.tachiyomi.ui.base + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.util.AttributeSet +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.appcompat.widget.SearchView +import androidx.core.graphics.ColorUtils +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.system.getResourceColor + +class MiniSearchView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + SearchView(context, attrs) { + + init { + val searchTextView = + findViewById(androidx.appcompat.R.id.search_src_text) + searchTextView?.setTextAppearance(android.R.style.TextAppearance_Material_Body1) + val actionColorAlpha = + ColorUtils.setAlphaComponent(context.getResourceColor(R.attr.actionBarTintColor), 200) + searchTextView?.setTextColor(actionColorAlpha) + searchTextView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) + searchTextView?.setHintTextColor(actionColorAlpha) + + val clearButton = findViewById(androidx.appcompat.R.id.search_close_btn) + clearButton?.imageTintList = ColorStateList.valueOf(context.getResourceColor(R.attr.actionBarTintColor)) + + val searchPlateView = findViewById(androidx.appcompat.R.id.search_plate) + searchPlateView?.setBackgroundColor(Color.TRANSPARENT) + + setIconifiedByDefault(false) + + val searchMagIconImageView = findViewById(androidx.appcompat.R.id.search_mag_icon) + searchMagIconImageView?.layoutParams = LinearLayout.LayoutParams(0, 0) + } + + override fun onActionViewExpanded() { + super.onActionViewExpanded() + layoutParams?.let { + val params = it + params.width = ViewGroup.LayoutParams.MATCH_PARENT + layoutParams = params + } + requestLayout() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseActivity.kt index 7974658778..7fe19e71ec 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseActivity.kt @@ -2,28 +2,30 @@ package eu.kanade.tachiyomi.ui.base.activity import android.os.Bundle import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.app.AppCompatDelegate +import androidx.viewbinding.ViewBinding import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.ui.main.SearchActivity import eu.kanade.tachiyomi.ui.security.BiometricActivity import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate -import eu.kanade.tachiyomi.util.system.ThemeUtil +import eu.kanade.tachiyomi.util.system.setThemeAndNight import uy.kohesive.injekt.injectLazy -abstract class BaseActivity : AppCompatActivity() { +abstract class BaseActivity : AppCompatActivity() { val preferences: PreferencesHelper by injectLazy() + lateinit var binding: VB + val isBindingInitialized get() = this::binding.isInitialized override fun onCreate(savedInstanceState: Bundle?) { - AppCompatDelegate.setDefaultNightMode(ThemeUtil.nightMode(preferences.theme())) - setTheme(ThemeUtil.theme(preferences.theme())) + setThemeAndNight(preferences) super.onCreate(savedInstanceState) SecureActivityDelegate.setSecure(this) } override fun onResume() { super.onResume() - if (this !is BiometricActivity && this !is SearchActivity) + if (this !is BiometricActivity && this !is SearchActivity) { SecureActivityDelegate.promptLockIfNeeded(this) + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseThemedActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseThemedActivity.kt index fa9d257a07..6ee3e3436e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseThemedActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/activity/BaseThemedActivity.kt @@ -2,9 +2,8 @@ package eu.kanade.tachiyomi.ui.base.activity import android.os.Bundle import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.app.AppCompatDelegate import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.util.system.ThemeUtil +import eu.kanade.tachiyomi.util.system.setThemeAndNight import uy.kohesive.injekt.injectLazy abstract class BaseThemedActivity : AppCompatActivity() { @@ -12,9 +11,7 @@ abstract class BaseThemedActivity : AppCompatActivity() { val preferences: PreferencesHelper by injectLazy() override fun onCreate(savedInstanceState: Bundle?) { - AppCompatDelegate.setDefaultNightMode(ThemeUtil.nightMode(preferences.theme())) - setTheme(ThemeUtil.theme(preferences.theme())) - + setThemeAndNight(preferences) super.onCreate(savedInstanceState) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/BaseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/BaseController.kt index 0eb94f46cf..b6c3c03408 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/BaseController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/BaseController.kt @@ -1,91 +1,160 @@ package eu.kanade.tachiyomi.ui.base.controller +import android.app.Activity import android.os.Bundle import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.forEach +import androidx.viewbinding.ViewBinding import com.bluelinelabs.conductor.Controller import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeType -import com.bluelinelabs.conductor.RestoreViewOnCreateController -import kotlinx.android.extensions.LayoutContainer -import kotlinx.android.synthetic.* +import eu.kanade.tachiyomi.util.view.activityBinding +import eu.kanade.tachiyomi.util.view.removeQueryListener +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel import com.elvishew.xlog.XLog -abstract class BaseController(bundle: Bundle? = null) : - RestoreViewOnCreateController(bundle), - LayoutContainer { +abstract class BaseController(bundle: Bundle? = null) : + Controller(bundle) { - init { - addLifecycleListener(object : LifecycleListener() { - override fun postCreateView(controller: Controller, view: View) { - onViewCreated(view) - } - - override fun preCreateView(controller: Controller) { - XLog.d("Create view for ${controller.instance()}") - } - - override fun preAttach(controller: Controller, view: View) { - XLog.d("Attach view for ${controller.instance()}") - } - - override fun preDetach(controller: Controller, view: View) { - XLog.d("Detach view for ${controller.instance()}") - } + lateinit var binding: VB + lateinit var viewScope: CoroutineScope - override fun preDestroyView(controller: Controller, view: View) { - XLog.d("Destroy view for ${controller.instance()}") + val isBindingInitialized get() = this::binding.isInitialized + init { + addLifecycleListener( + object : LifecycleListener() { + override fun postCreateView(controller: Controller, view: View) { + onViewCreated(view) + } + + override fun preCreateView(controller: Controller) { + viewScope = MainScope() + XLog.d("Create view for ${controller.instance()}") + } + + override fun preAttach(controller: Controller, view: View) { + XLog.d("Attach view for ${controller.instance()}") + } + + override fun preDetach(controller: Controller, view: View) { + XLog.d("Detach view for ${controller.instance()}") + } + + override fun preDestroyView(controller: Controller, view: View) { + viewScope.cancel() + XLog.d("Destroy view for ${controller.instance()}") + } } - }) + ) } - override val containerView: View? - get() = view - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup, savedViewState: Bundle?): View { - return inflateView(inflater, container) + binding = createBinding(inflater) + return binding.root } - override fun onDestroyView(view: View) { - super.onDestroyView(view) - clearFindViewByIdCache() - } - - abstract fun inflateView(inflater: LayoutInflater, container: ViewGroup): View + abstract fun createBinding(inflater: LayoutInflater): VB open fun onViewCreated(view: View) {} override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { if (type.isEnter) { setTitle() + } else { + removeQueryListener() } setHasOptionsMenu(type.isEnter) super.onChangeStarted(handler, type) } val onRoot: Boolean - get() = router.backstack.lastOrNull()?.controller() == this + get() = router.backstack.lastOrNull()?.controller == this open fun getTitle(): String? { return null } + override fun onActivityPaused(activity: Activity) { + super.onActivityPaused(activity) + removeQueryListener() + } + fun setTitle() { var parentController = parentController while (parentController != null) { - if (parentController is BaseController && parentController.getTitle() != null) { + if (parentController is BaseController<*> && parentController.getTitle() != null) { return } parentController = parentController.parentController } - if (router.backstack.lastOrNull()?.controller() == this) - (activity as? AppCompatActivity)?.supportActionBar?.title = getTitle() + if (router.backstack.lastOrNull()?.controller == this) { + (activity as? AppCompatActivity)?.supportActionBar?.title = getTitle() + } } private fun Controller.instance(): String { return "${javaClass.simpleName}@${Integer.toHexString(hashCode())}" } + + /** + * Workaround for buggy menu item layout after expanding/collapsing an expandable item like a SearchView. + * This method should be removed when fixed upstream. + * Issue link: https://issuetracker.google.com/issues/37657375 + */ + var expandActionViewFromInteraction = false + fun MenuItem.fixExpand(onExpand: ((MenuItem) -> Boolean)? = null, onCollapse: ((MenuItem) -> Boolean)? = null) { + setOnActionExpandListener( + object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(item: MenuItem): Boolean { + hideItemsIfExpanded(item, activityBinding?.cardToolbar?.menu, true) + return onExpand?.invoke(item) ?: true + } + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + activity?.invalidateOptionsMenu() + + return onCollapse?.invoke(item) ?: true + } + } + ) + + if (expandActionViewFromInteraction) { + expandActionViewFromInteraction = false + expandActionView() + } + } + + fun hideItemsIfExpanded(searchItem: MenuItem?, menu: Menu?, isExpanded: Boolean = false) { + menu ?: return + searchItem ?: return + if (searchItem.isActionViewExpanded || isExpanded) { + menu.forEach { it.isVisible = false } + } + } + + fun MenuItem.fixExpandInvalidate() { + fixExpand { invalidateMenuOnExpand() } + } + + /** + * Workaround for menu items not disappearing when expanding an expandable item like a SearchView. + * [expandActionViewFromInteraction] should be set to true in [onOptionsItemSelected] when the expandable item is selected + * This method should be called as part of [MenuItem.OnActionExpandListener.onMenuItemActionExpand] + */ + fun invalidateMenuOnExpand(): Boolean { + return if (expandActionViewFromInteraction) { + activity?.invalidateOptionsMenu() + false + } else { + true + } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/BaseCoroutineController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/BaseCoroutineController.kt new file mode 100644 index 0000000000..d2cea3985c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/BaseCoroutineController.kt @@ -0,0 +1,21 @@ +package eu.kanade.tachiyomi.ui.base.controller + +import android.os.Bundle +import android.view.View +import androidx.viewbinding.ViewBinding +import eu.kanade.tachiyomi.ui.base.presenter.BaseCoroutinePresenter + +abstract class BaseCoroutineController(bundle: Bundle? = null) : + BaseController(bundle) { + + abstract val presenter: PS + override fun onViewCreated(view: View) { + super.onViewCreated(view) + presenter.onCreate() + } + + override fun onDestroyView(view: View) { + super.onDestroyView(view) + presenter.onDestroy() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/DialogController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/DialogController.kt index 9fecbecda4..2a9cb7a037 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/DialogController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/DialogController.kt @@ -5,7 +5,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import com.bluelinelabs.conductor.RestoreViewOnCreateController +import com.bluelinelabs.conductor.Controller import com.bluelinelabs.conductor.Router import com.bluelinelabs.conductor.RouterTransaction import com.bluelinelabs.conductor.changehandler.SimpleSwapChangeHandler @@ -17,7 +17,7 @@ import com.bluelinelabs.conductor.changehandler.SimpleSwapChangeHandler * * Implementations should override this class and implement [.onCreateDialog] to create a custom dialog, such as an [android.app.AlertDialog] */ -abstract class DialogController : RestoreViewOnCreateController { +abstract class DialogController : Controller { protected var dialog: Dialog? = null private set diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NucleusController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NucleusController.kt index b3c592dfa5..0786d5e137 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NucleusController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/NucleusController.kt @@ -1,14 +1,15 @@ package eu.kanade.tachiyomi.ui.base.controller import android.os.Bundle +import androidx.viewbinding.ViewBinding import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorDelegate import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorLifecycleListener import nucleus.factory.PresenterFactory import nucleus.presenter.Presenter @Suppress("LeakingThis") -abstract class NucleusController

>(val bundle: Bundle? = null) : - RxController(bundle), +abstract class NucleusController>(val bundle: Bundle? = null) : + RxController(bundle), PresenterFactory

{ private val delegate = NucleusConductorDelegate(this) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/OneWayFadeChangeHandler.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/OneWayFadeChangeHandler.kt new file mode 100644 index 0000000000..76e929df4f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/OneWayFadeChangeHandler.kt @@ -0,0 +1,50 @@ +package eu.kanade.tachiyomi.ui.base.controller + +import android.animation.Animator +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.view.View +import android.view.ViewGroup +import com.bluelinelabs.conductor.ControllerChangeHandler +import com.bluelinelabs.conductor.changehandler.FadeChangeHandler +import eu.kanade.tachiyomi.util.system.isTablet + +/** + * A variation of [FadeChangeHandler] that only fades in. + */ +class OneWayFadeChangeHandler : FadeChangeHandler { + constructor() + constructor(removesFromViewOnPush: Boolean) : super(removesFromViewOnPush) + constructor(duration: Long) : super(duration) + constructor(duration: Long, removesFromViewOnPush: Boolean) : super( + duration, + removesFromViewOnPush + ) + + override fun getAnimator( + container: ViewGroup, + from: View?, + to: View?, + isPush: Boolean, + toAddedToContainer: Boolean + ): Animator { + val animator = AnimatorSet() + if (to != null) { + val start: Float = if (toAddedToContainer) 0F else to.alpha + animator.play(ObjectAnimator.ofFloat(to, View.ALPHA, start, 1f)) + } + + if (from != null && (!isPush || removesFromViewOnPush())) { + if (!container.context.isTablet()) { + animator.play(ObjectAnimator.ofFloat(from, View.ALPHA, 0f)) + } else { + container.removeView(from) + } + } + return animator + } + + override fun copy(): ControllerChangeHandler { + return OneWayFadeChangeHandler(animationDuration, removesFromViewOnPush()) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/RxController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/RxController.kt index d227c1caf8..2c3d7e9b3c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/RxController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/RxController.kt @@ -3,11 +3,12 @@ package eu.kanade.tachiyomi.ui.base.controller import android.os.Bundle import android.view.View import androidx.annotation.CallSuper +import androidx.viewbinding.ViewBinding import rx.Observable import rx.Subscription import rx.subscriptions.CompositeSubscription -abstract class RxController(bundle: Bundle? = null) : BaseController(bundle) { +abstract class RxController(bundle: Bundle? = null) : BaseController(bundle) { var untilDetachSubscriptions = CompositeSubscription() private set @@ -43,12 +44,10 @@ abstract class RxController(bundle: Bundle? = null) : BaseController(bundle) { } fun Observable.subscribeUntilDetach(): Subscription { - return subscribe().also { untilDetachSubscriptions.add(it) } } fun Observable.subscribeUntilDetach(onNext: (T) -> Unit): Subscription { - return subscribe(onNext).also { untilDetachSubscriptions.add(it) } } @@ -56,7 +55,6 @@ abstract class RxController(bundle: Bundle? = null) : BaseController(bundle) { onNext: (T) -> Unit, onError: (Throwable) -> Unit ): Subscription { - return subscribe(onNext, onError).also { untilDetachSubscriptions.add(it) } } @@ -65,17 +63,14 @@ abstract class RxController(bundle: Bundle? = null) : BaseController(bundle) { onError: (Throwable) -> Unit, onCompleted: () -> Unit ): Subscription { - return subscribe(onNext, onError, onCompleted).also { untilDetachSubscriptions.add(it) } } fun Observable.subscribeUntilDestroy(): Subscription { - return subscribe().also { untilDestroySubscriptions.add(it) } } fun Observable.subscribeUntilDestroy(onNext: (T) -> Unit): Subscription { - return subscribe(onNext).also { untilDestroySubscriptions.add(it) } } @@ -83,7 +78,6 @@ abstract class RxController(bundle: Bundle? = null) : BaseController(bundle) { onNext: (T) -> Unit, onError: (Throwable) -> Unit ): Subscription { - return subscribe(onNext, onError).also { untilDestroySubscriptions.add(it) } } @@ -92,7 +86,6 @@ abstract class RxController(bundle: Bundle? = null) : BaseController(bundle) { onError: (Throwable) -> Unit, onCompleted: () -> Unit ): Subscription { - return subscribe(onNext, onError, onCompleted).also { untilDestroySubscriptions.add(it) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/BaseFlexibleViewHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/BaseFlexibleViewHolder.kt index 79f3455fe6..54285b1fff 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/BaseFlexibleViewHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/BaseFlexibleViewHolder.kt @@ -3,15 +3,10 @@ package eu.kanade.tachiyomi.ui.base.holder import android.view.View import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.viewholders.FlexibleViewHolder -import kotlinx.android.extensions.LayoutContainer abstract class BaseFlexibleViewHolder( view: View, adapter: FlexibleAdapter<*>, stickyHeader: Boolean = false ) : - FlexibleViewHolder(view, adapter, stickyHeader), LayoutContainer { - - override val containerView: View? - get() = itemView -} + FlexibleViewHolder(view, adapter, stickyHeader) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/BaseViewHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/BaseViewHolder.kt index 9b03064c47..61d6f6db74 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/BaseViewHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/holder/BaseViewHolder.kt @@ -1,10 +1,5 @@ package eu.kanade.tachiyomi.ui.base.holder import android.view.View -import kotlinx.android.extensions.LayoutContainer -abstract class BaseViewHolder(view: View) : androidx.recyclerview.widget.RecyclerView.ViewHolder(view), LayoutContainer { - - override val containerView: View? - get() = itemView -} +abstract class BaseViewHolder(view: View) : androidx.recyclerview.widget.RecyclerView.ViewHolder(view) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BaseCoroutinePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BaseCoroutinePresenter.kt new file mode 100644 index 0000000000..00e8ee1794 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BaseCoroutinePresenter.kt @@ -0,0 +1,18 @@ +package eu.kanade.tachiyomi.ui.base.presenter + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel + +open class BaseCoroutinePresenter { + lateinit var presenterScope: CoroutineScope + + open fun onCreate() { + presenterScope = CoroutineScope(Job() + Dispatchers.Default) + } + + open fun onDestroy() { + presenterScope.cancel() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryController.kt index 8368b653c7..8205bc457c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryController.kt @@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.ui.category import android.os.Bundle import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.afollestad.materialdialogs.MaterialDialog @@ -11,19 +10,19 @@ import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar import eu.davidea.flexibleadapter.FlexibleAdapter import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.CategoriesControllerBinding import eu.kanade.tachiyomi.ui.base.controller.BaseController import eu.kanade.tachiyomi.ui.category.CategoryPresenter.Companion.CREATE_CATEGORY_ORDER import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.view.liftAppbarWith import eu.kanade.tachiyomi.util.view.snack -import kotlinx.android.synthetic.main.categories_controller.* /** * Controller to manage the categories for the users' library. */ class CategoryController(bundle: Bundle? = null) : - BaseController(bundle), + BaseController(bundle), FlexibleAdapter.OnItemClickListener, FlexibleAdapter.OnItemMoveListener, CategoryAdapter.CategoryItemListener { @@ -50,15 +49,7 @@ class CategoryController(bundle: Bundle? = null) : return resources?.getString(R.string.edit_categories) } - /** - * Returns the view of this controller. - * - * @param inflater The layout inflater to create the view from XML. - * @param container The parent view for this one. - */ - override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.categories_controller, container, false) - } + override fun createBinding(inflater: LayoutInflater) = CategoriesControllerBinding.inflate(inflater) /** * Called after view inflation. Used to initialize the view. @@ -67,12 +58,12 @@ class CategoryController(bundle: Bundle? = null) : */ override fun onViewCreated(view: View) { super.onViewCreated(view) - liftAppbarWith(recycler) + liftAppbarWith(binding.recycler, true) adapter = CategoryAdapter(this@CategoryController) - recycler.layoutManager = LinearLayoutManager(view.context) - recycler.setHasFixedSize(true) - recycler.adapter = adapter + binding.recycler.layoutManager = LinearLayoutManager(view.context) + binding.recycler.setHasFixedSize(true) + binding.recycler.adapter = adapter adapter?.isHandleDragEnabled = true adapter?.isPermanentDelete = false @@ -122,8 +113,13 @@ class CategoryController(bundle: Bundle? = null) : override fun onCategoryRename(position: Int, newName: String): Boolean { val category = adapter?.getItem(position)?.category ?: return false - if (category.order == CREATE_CATEGORY_ORDER) + if (newName.isBlank()) { + activity?.toast(R.string.category_cannot_be_blank) + return false + } + if (category.order == CREATE_CATEGORY_ORDER) { return (presenter.createCategory(newName)) + } return (presenter.renameCategory(category, newName)) } @@ -148,12 +144,14 @@ class CategoryController(bundle: Bundle? = null) : adapter?.restoreDeletedItems() undoing = true } - addCallback(object : BaseTransientBottomBar.BaseCallback() { - override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { - super.onDismissed(transientBottomBar, event) - if (!undoing) confirmDelete() + addCallback( + object : BaseTransientBottomBar.BaseCallback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + super.onDismissed(transientBottomBar, event) + if (!undoing) confirmDelete() + } } - }) + ) } (activity as? MainActivity)?.setUndoSnackBar(snack) } @@ -183,6 +181,7 @@ class CategoryController(bundle: Bundle? = null) : override fun shouldMoveItem(fromPosition: Int, toPosition: Int): Boolean { return toPosition > 0 } + /** * Called from the presenter when a category with the given name already exists. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt index 4f8f3bb25b..1e1475f226 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryHolder.kt @@ -1,25 +1,24 @@ package eu.kanade.tachiyomi.ui.category +import android.annotation.SuppressLint import android.content.Context import android.graphics.drawable.Drawable -import android.text.InputType import android.view.View import android.view.WindowManager import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import androidx.core.content.ContextCompat +import androidx.core.view.isVisible import com.mikepenz.iconics.typeface.library.materialdesigndx.MaterialDesignDx + import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.databinding.CategoriesItemBinding import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder import eu.kanade.tachiyomi.ui.category.CategoryPresenter.Companion.CREATE_CATEGORY_ORDER -import eu.kanade.tachiyomi.util.system.contextCompatColor import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.iconicsDrawable import eu.kanade.tachiyomi.util.system.iconicsDrawableMedium -import eu.kanade.tachiyomi.util.view.gone -import eu.kanade.tachiyomi.util.view.visible -import kotlinx.android.synthetic.main.categories_item.* /** * Holder used to display category items. @@ -29,8 +28,10 @@ import kotlinx.android.synthetic.main.categories_item.* */ class CategoryHolder(view: View, val adapter: CategoryAdapter) : BaseFlexibleViewHolder(view, adapter) { + private val binding = CategoriesItemBinding.bind(view) + init { - edit_button.setOnClickListener { + binding.editButton.setOnClickListener { submitChanges() } } @@ -45,8 +46,8 @@ class CategoryHolder(view: View, val adapter: CategoryAdapter) : BaseFlexibleVie */ fun bind(category: Category) { // Set capitalized title. - title.text = category.name.capitalize() - edit_text.setOnEditorActionListener { _, actionId, _ -> + binding.title.text = category.name.capitalize() + binding.editText.setOnEditorActionListener { _, actionId, _ -> if (actionId == EditorInfo.IME_ACTION_DONE) { submitChanges() } @@ -54,67 +55,66 @@ class CategoryHolder(view: View, val adapter: CategoryAdapter) : BaseFlexibleVie } createCategory = category.order == CREATE_CATEGORY_ORDER if (createCategory) { - title.setTextColor(itemView.context.contextCompatColor(R.color.text_color_hint)) + binding.title.setTextColor(ContextCompat.getColor(itemView.context, R.color.text_color_hint)) regularDrawable = itemView.context.iconicsDrawable(MaterialDesignDx.Icon.gmf_add) - image.gone() - edit_button.setImageDrawable(null) - edit_text.setText("") - edit_text.hint = title.text + binding.image.isVisible = false + binding.editButton.setImageDrawable(null) + binding.editText.setText("") + binding.editText.hint = binding.title.text } else { - title.setTextColor(itemView.context.contextCompatColor(R.color.textColorPrimary)) + binding.title.setTextColor(itemView.context.getResourceColor(android.R.attr.textColorPrimary)) regularDrawable = itemView.context.iconicsDrawable(MaterialDesignDx.Icon.gmf_drag_handle) - image.visible() - edit_text.setText(title.text) + binding.image.isVisible = true + binding.editText.setText(binding.title.text) } } + @SuppressLint("ClickableViewAccessibility") fun isEditing(editing: Boolean) { itemView.isActivated = editing - title.visibility = if (editing) View.INVISIBLE else View.VISIBLE - edit_text.visibility = if (!editing) View.INVISIBLE else View.VISIBLE + binding.title.visibility = if (editing) View.INVISIBLE else View.VISIBLE + binding.editText.visibility = if (!editing) View.INVISIBLE else View.VISIBLE if (editing) { - edit_text.inputType = InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE - edit_text.requestFocus() - edit_text.selectAll() - edit_button.setImageDrawable(ContextCompat.getDrawable(itemView.context, R.drawable.ic_check_24dp)) - edit_button.drawable.mutate().setTint(itemView.context.getResourceColor(R.attr.colorAccent)) + binding.editText.requestFocus() + binding.editText.selectAll() + binding.editButton.setImageDrawable(ContextCompat.getDrawable(itemView.context, R.drawable.ic_check_24dp)) + binding.editButton.drawable.mutate().setTint(itemView.context.getResourceColor(R.attr.colorAccent)) showKeyboard() if (!createCategory) { - reorder.setImageDrawable(itemView.context.iconicsDrawable(MaterialDesignDx.Icon.gmf_delete)) - - reorder.setOnClickListener { - adapter.categoryItemListener.onItemDelete(adapterPosition) + binding.reorder.setImageDrawable(itemView.context.iconicsDrawable(MaterialDesignDx.Icon.gmf_delete)) + binding.reorder.setOnClickListener { + adapter.categoryItemListener.onItemDelete(flexibleAdapterPosition) } } } else { if (!createCategory) { - setDragHandleView(reorder) - edit_button.setImageDrawable(itemView.context.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_edit)) + setDragHandleView(binding.reorder) + binding.editButton.setImageDrawable(itemView.context.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_edit)) } else { - edit_button.setImageDrawable(null) - reorder.setOnTouchListener { _, _ -> true } + binding.editButton.setImageDrawable(null) + binding.reorder.setOnTouchListener { _, _ -> true } } - edit_text.clearFocus() - edit_button.drawable?.mutate()?.setTint( + binding.editText.clearFocus() + binding.editButton.drawable?.mutate()?.setTint( ContextCompat.getColor( itemView.context, R .color.gray_button ) ) - reorder.setImageDrawable(regularDrawable) + binding.reorder.setImageDrawable(regularDrawable) } } private fun submitChanges() { - if (edit_text.visibility == View.VISIBLE) { + if (binding.editText.visibility == View.VISIBLE) { if (adapter.categoryItemListener - .onCategoryRename(adapterPosition, edit_text.text.toString()) + .onCategoryRename(flexibleAdapterPosition, binding.editText.text.toString()) ) { isEditing(false) - edit_text.inputType = InputType.TYPE_NULL - if (!createCategory) - title.text = edit_text.text.toString() + if (!createCategory) { + binding.title.text = binding.editText.text.toString() + } } } else { itemView.performClick() @@ -125,7 +125,7 @@ class CategoryHolder(view: View, val adapter: CategoryAdapter) : BaseFlexibleVie val inputMethodManager: InputMethodManager = itemView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager inputMethodManager.showSoftInput( - edit_text, + binding.editText, WindowManager.LayoutParams .SOFT_INPUT_ADJUST_PAN ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryPresenter.kt index e470fc85fe..52564d8f5f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryPresenter.kt @@ -4,6 +4,7 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.library.LibrarySort import eu.kanade.tachiyomi.util.system.executeOnIO import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -72,10 +73,10 @@ class CategoryPresenter( val cat = Category.create(name) // Set the new item in the last position. - cat.order = categories.map { it.order + 1 }.max() ?: 0 + cat.order = (categories.maxOfOrNull { it.order } ?: 0) + 1 // Insert into database. - cat.mangaSort = 'a' + cat.mangaSort = LibrarySort.Title.categoryValue db.insertCategory(cat).executeAsBlocking() val cats = db.getCategories().executeAsBlocking() val newCat = cats.find { it.name == name } ?: return false diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/ManageCategoryDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/ManageCategoryDialog.kt index f42891c386..b98e750660 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/ManageCategoryDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/ManageCategoryDialog.kt @@ -1,79 +1,115 @@ package eu.kanade.tachiyomi.ui.category +import android.app.Activity import android.app.Dialog import android.os.Bundle -import android.view.View import android.widget.CompoundButton +import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.callbacks.onShow import com.afollestad.materialdialogs.customview.customView -import com.f2prateek.rx.preferences.Preference +import com.afollestad.materialdialogs.customview.getCustomView +import com.tfcporciuncula.flow.Preference import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.databinding.MangaCategoryDialogBinding import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.ui.library.LibraryController -import eu.kanade.tachiyomi.util.system.toast -import eu.kanade.tachiyomi.util.view.gone -import eu.kanade.tachiyomi.util.view.visible -import eu.kanade.tachiyomi.util.view.visibleIf +import eu.kanade.tachiyomi.ui.library.LibrarySort import eu.kanade.tachiyomi.util.view.withFadeTransaction -import kotlinx.android.synthetic.main.manga_category_dialog.view.* import uy.kohesive.injekt.injectLazy class ManageCategoryDialog(bundle: Bundle? = null) : DialogController(bundle) { - constructor(libraryController: LibraryController, category: Category) : this() { - this.libraryController = libraryController + constructor(category: Category?, updateLibrary: ((Int?) -> Unit)) : this() { + this.updateLibrary = updateLibrary this.category = category } - private lateinit var libraryController: LibraryController - private lateinit var category: Category - - private var dialogView: View? = null + private var updateLibrary: ((Int?) -> Unit)? = null + private var category: Category? = null private val preferences by injectLazy() private val db by injectLazy() + lateinit var binding: MangaCategoryDialogBinding override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val dialog = MaterialDialog(activity!!).apply { - title(R.string.manage_category) + val dialog = dialog(activity!!) + binding = MangaCategoryDialogBinding.bind(dialog.getCustomView()) + onViewCreated() + return dialog + } + + fun dialog(activity: Activity): MaterialDialog { + return MaterialDialog(activity).apply { + title(if (category == null) R.string.new_category else R.string.manage_category) customView(viewRes = R.layout.manga_category_dialog) - negativeButton(android.R.string.cancel) - positiveButton(R.string.save) { onPositiveButtonClick() } + negativeButton(android.R.string.cancel) { dismiss() } + positiveButton(R.string.save) { + if (onPositiveButtonClick()) { + dismiss() + } + } + noAutoDismiss() } - dialogView = dialog.view - onViewCreated(dialog.view) - return dialog } - private fun onPositiveButtonClick() { - val view = dialogView ?: return - if (category.id ?: 0 <= 0) return - val text = view.title.text.toString() + fun show(activity: Activity) { + val dialog = dialog(activity) + binding = MangaCategoryDialogBinding.bind(dialog.getCustomView()) + onViewCreated() + dialog.onShow { + binding.title.requestFocus() + } + dialog.show() + } + + private fun onPositiveButtonClick(): Boolean { + val text = binding.title.text.toString() val categoryExists = categoryExists(text) - if (text.isNotBlank() && !categoryExists && !text.equals(category.name, true)) { - category.name = text - db.insertCategory(category).executeAsBlocking() - libraryController.presenter.getLibrary() - } else if (categoryExists) { - activity?.toast(R.string.category_with_name_exists) + val category = this.category ?: Category.create(text) + if (category.id != 0) { + if (text.isNotBlank() && !categoryExists && + !text.equals(this.category?.name ?: "", true) + ) { + category.name = text + if (this.category == null) { + val categories = db.getCategories().executeAsBlocking() + category.order = (categories.maxOfOrNull { it.order } ?: 0) + 1 + category.mangaSort = LibrarySort.Title.categoryValue + val dbCategory = db.insertCategory(category).executeAsBlocking() + category.id = dbCategory.insertedId()?.toInt() + this.category = category + } else { + db.insertCategory(category).executeAsBlocking() + } + } else if (categoryExists) { + binding.categoryTextLayout.error = + binding.categoryTextLayout.context.getString(R.string.category_with_name_exists) + return false + } else if (text.isBlank()) { + binding.categoryTextLayout.error = + binding.categoryTextLayout.context.getString(R.string.category_cannot_be_blank) + return false + } } - if (!updatePref(preferences.downloadNewCategories(), view.download_new)) { - preferences.downloadNew().set(false) - } else { - preferences.downloadNew().set(true) + when (updatePref(preferences.downloadNewCategories(), binding.downloadNew)) { + true -> preferences.downloadNew().set(true) + false -> preferences.downloadNew().set(false) } if (preferences.libraryUpdateInterval().getOrDefault() > 0 && - !updatePref(preferences.libraryUpdateCategories(), view.include_global) + updatePref(preferences.libraryUpdateCategories(), binding.includeGlobal) == false ) { preferences.libraryUpdateInterval().set(0) LibraryUpdateJob.setupTask(0) } + updateLibrary?.invoke(category.id) + return true } /** @@ -81,46 +117,50 @@ class ManageCategoryDialog(bundle: Bundle? = null) : */ private fun categoryExists(name: String): Boolean { return db.getCategories().executeAsBlocking().any { - it.name.equals(name, true) && category.id != it.id + it.name.equals(name, true) && category?.id != it.id } } - fun onViewCreated(view: View) { - if (category.id ?: 0 <= 0) { - view.title.gone() - view.download_new.gone() - view.include_global.gone() - return + fun onViewCreated() { + if (category?.id ?: 0 <= 0 && category != null) { + binding.categoryTextLayout.isVisible = false } - view.edit_categories.setOnClickListener { + binding.editCategories.isVisible = category != null + binding.editCategories.setOnClickListener { router.popCurrentController() router.pushController(CategoryController().withFadeTransaction()) } - view.title.hint = category.name - view.title.append(category.name) - val downloadNew = preferences.downloadNew().getOrDefault() + binding.title.addTextChangedListener { + binding.categoryTextLayout.error = null + } + binding.title.hint = + category?.name ?: binding.editCategories.context.getString(R.string.category) + binding.title.append(category?.name ?: "") + val downloadNew = preferences.downloadNew().get() setCheckbox( - view.download_new, + binding.downloadNew, preferences.downloadNewCategories(), true ) - if (downloadNew && preferences.downloadNewCategories().getOrDefault().isEmpty()) - view.download_new.gone() - else if (!downloadNew) - view.download_new.visible() - view.download_new.isChecked = - preferences.downloadNew().getOrDefault() && view.download_new.isChecked + if (downloadNew && preferences.downloadNewCategories().get().isEmpty()) { + binding.downloadNew.isVisible = false + } else if (!downloadNew) { + binding.downloadNew.isVisible = true + } + binding.downloadNew.isChecked = + preferences.downloadNew().get() && binding.downloadNew.isChecked setCheckbox( - view.include_global, + binding.includeGlobal, preferences.libraryUpdateCategories(), preferences.libraryUpdateInterval().getOrDefault() > 0 ) } /** Update a pref based on checkbox, and return if the pref is not empty */ - private fun updatePref(categories: Preference>, box: CompoundButton): Boolean { - val categoryId = category.id ?: return true - val updateCategories = categories.getOrDefault().toMutableSet() + private fun updatePref(categories: Preference>, box: CompoundButton): Boolean? { + val categoryId = category?.id ?: return null + if (!box.isVisible) return null + val updateCategories = categories.get().toMutableSet() if (box.isChecked) { updateCategories.add(categoryId.toString()) } else { @@ -135,9 +175,9 @@ class ManageCategoryDialog(bundle: Bundle? = null) : categories: Preference>, shouldShow: Boolean ) { - val updateCategories = categories.getOrDefault() - box.visibleIf(updateCategories.isNotEmpty() && shouldShow) + val updateCategories = categories.get() + box.isVisible = updateCategories.isNotEmpty() && shouldShow if (updateCategories.isNotEmpty() && shouldShow) box.isChecked = - updateCategories.any { category.id == it.toIntOrNull() } + updateCategories.any { category?.id == it.toIntOrNull() } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/addtolibrary/AddCategoryItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/addtolibrary/AddCategoryItem.kt new file mode 100644 index 0000000000..6a9f4d7f8f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/addtolibrary/AddCategoryItem.kt @@ -0,0 +1,37 @@ +package eu.kanade.tachiyomi.ui.category.addtolibrary + +import android.view.View +import com.mikepenz.fastadapter.FastAdapter +import com.mikepenz.fastadapter.items.AbstractItem +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.databinding.AddCategoryItemBinding + +class AddCategoryItem(val category: Category) : AbstractItem>() { + + /** defines the type defining this item. must be unique. preferably an id */ + override val type: Int = R.id.category_checkbox + + /** defines the layout which will be used for this item in the list */ + override val layoutRes: Int = R.layout.add_category_item + + override var identifier = category.id?.toLong() ?: -1L + + override fun getViewHolder(v: View): FastAdapter.ViewHolder { + return ViewHolder(v) + } + + class ViewHolder(view: View) : FastAdapter.ViewHolder(view) { + + val binding = AddCategoryItemBinding.bind(view) + override fun bindView(item: AddCategoryItem, payloads: List) { + binding.categoryCheckbox.text = item.category.name + binding.categoryCheckbox.isChecked = item.isSelected + } + + override fun unbindView(item: AddCategoryItem) { + binding.categoryCheckbox.text = null + binding.categoryCheckbox.isChecked = false + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/addtolibrary/SetCategoriesSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/addtolibrary/SetCategoriesSheet.kt new file mode 100644 index 0000000000..13377d3114 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/addtolibrary/SetCategoriesSheet.kt @@ -0,0 +1,207 @@ +package eu.kanade.tachiyomi.ui.category.addtolibrary + +import android.app.Activity +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.mikepenz.fastadapter.FastAdapter +import com.mikepenz.fastadapter.ISelectionListener +import com.mikepenz.fastadapter.adapters.ItemAdapter +import com.mikepenz.fastadapter.select.SelectExtension +import com.mikepenz.fastadapter.select.getSelectExtension +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.MangaCategory +import eu.kanade.tachiyomi.databinding.SetCategoriesSheetBinding +import eu.kanade.tachiyomi.ui.category.ManageCategoryDialog +import eu.kanade.tachiyomi.util.system.dpToPx +import eu.kanade.tachiyomi.util.view.expand +import eu.kanade.tachiyomi.util.view.updateLayoutParams +import eu.kanade.tachiyomi.util.view.updatePaddingRelative +import eu.kanade.tachiyomi.widget.E2EBottomSheetDialog +import uy.kohesive.injekt.injectLazy +import java.util.ArrayList +import java.util.Date +import java.util.Locale +import kotlin.math.max + +class SetCategoriesSheet( + private val activity: Activity, + private val listManga: List, + var categories: MutableList, + var preselected: Array, + private val addingToLibrary: Boolean, + val onMangaAdded: (() -> Unit) = { } +) : E2EBottomSheetDialog(activity) { + + constructor(activity: Activity, manga: Manga, categories: MutableList, preselected: Array, addingToLibrary: Boolean, onMangaAdded: () -> Unit) : + this(activity, listOf(manga), categories, preselected, addingToLibrary, onMangaAdded) + + private val fastAdapter: FastAdapter + private val itemAdapter = ItemAdapter() + private val selectExtension: SelectExtension + private val db: DatabaseHelper by injectLazy() + override var recyclerView: RecyclerView? = binding.categoryRecyclerView + + override fun createBinding(inflater: LayoutInflater) = + SetCategoriesSheetBinding.inflate(inflater) + init { + binding.toolbarTitle.text = context.getString( + if (addingToLibrary) { + R.string.add_x_to + } else { + R.string.move_x_to + }, + if (listManga.size == 1) { + listManga.first().seriesType(context) + } else { + context.getString(R.string.selection).lowercase(Locale.ROOT) + } + ) + + setOnShowListener { + updateBottomButtons() + } + sheetBehavior.addBottomSheetCallback( + object : BottomSheetBehavior.BottomSheetCallback() { + + override fun onSlide(bottomSheet: View, slideOffset: Float) { + updateBottomButtons() + } + + override fun onStateChanged(bottomSheet: View, newState: Int) { + updateBottomButtons() + } + } + ) + + binding.titleLayout.viewTreeObserver.addOnGlobalLayoutListener { + binding.categoryRecyclerView.updateLayoutParams { + val fullHeight = activity.window.decorView.height + val insets = activity.window.decorView.rootWindowInsets + matchConstraintMaxHeight = + fullHeight - (insets?.systemWindowInsetTop ?: 0) - + binding.titleLayout.height - binding.buttonLayout.height - 75.dpToPx + } + } + + fastAdapter = FastAdapter.with(itemAdapter) + fastAdapter.setHasStableIds(true) + binding.categoryRecyclerView.layoutManager = LinearLayoutManager(context) + binding.categoryRecyclerView.adapter = fastAdapter + itemAdapter.set(categories.map(::AddCategoryItem)) + itemAdapter.adapterItems.forEach { item -> + item.isSelected = preselected.any { it == item.category.id } + } + + selectExtension = fastAdapter.getSelectExtension() + selectExtension.apply { + isSelectable = true + multiSelect = true + setCategoriesButtons() + selectionListener = object : ISelectionListener { + override fun onSelectionChanged(item: AddCategoryItem, selected: Boolean) { + setCategoriesButtons() + } + } + } + } + + fun setCategoriesButtons() { + binding.addToCategoriesButton.text = context.getString( + if (addingToLibrary) { + R.string.add_to_ + } else { + R.string.move_to_ + }, + when (selectExtension.selections.size) { + 0 -> context.getString(R.string.default_category).lowercase(Locale.ROOT) + 1 -> selectExtension.selectedItems.firstOrNull()?.category?.name ?: "" + else -> context.resources.getQuantityString( + R.plurals.category_plural, + selectExtension.selections.size, + selectExtension.selections.size + ) + } + ) + } + + override fun onStart() { + super.onStart() + sheetBehavior.expand() + sheetBehavior.skipCollapsed = true + updateBottomButtons() + binding.root.post { + binding.categoryRecyclerView.scrollToPosition( + max(0, itemAdapter.adapterItems.indexOf(selectExtension.selectedItems.firstOrNull())) + ) + } + } + + fun updateBottomButtons() { + val bottomSheet = binding.root.parent as View + val bottomSheetVisibleHeight = -bottomSheet.top + (activity.window.decorView.height - bottomSheet.height) + + binding.buttonLayout.translationY = bottomSheetVisibleHeight.toFloat() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val attrsArray = intArrayOf(android.R.attr.actionBarSize) + val array = context.obtainStyledAttributes(attrsArray) + val headerHeight = array.getDimensionPixelSize(0, 0) + binding.buttonLayout.updatePaddingRelative( + bottom = activity.window.decorView.rootWindowInsets.systemWindowInsetBottom + ) + + binding.buttonLayout.updateLayoutParams { + height = headerHeight + binding.buttonLayout.paddingBottom + } + array.recycle() + + binding.cancelButton.setOnClickListener { dismiss() } + binding.newCategoryButton.setOnClickListener { + ManageCategoryDialog(null) { + categories = db.getCategories().executeAsBlocking() + itemAdapter.set(categories.map(::AddCategoryItem)) + itemAdapter.adapterItems.forEach { item -> + item.isSelected = it == item.category.id + } + setCategoriesButtons() + }.show(activity) + } + + binding.addToCategoriesButton.setOnClickListener { + addMangaToCategories() + dismiss() + } + } + + private fun addMangaToCategories() { + if (listManga.size == 1 && !listManga.first().favorite) { + val manga = listManga.first() + manga.favorite = !manga.favorite + + manga.date_added = Date().time + + db.insertManga(manga).executeAsBlocking() + } + + val mc = ArrayList() + + val selectedCategories = selectExtension.selectedItems.map(AddCategoryItem::category) + for (manga in listManga) { + for (cat in selectedCategories) { + mc.add(MangaCategory.create(manga, cat)) + } + } + db.setMangaCategories(mc, listManga) + onMangaAdded() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadAdapter.kt index e471b37c21..e6d375d85c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadAdapter.kt @@ -9,7 +9,8 @@ import eu.davidea.flexibleadapter.FlexibleAdapter * @param context the context of the fragment containing this adapter. */ class DownloadAdapter(controller: DownloadItemListener) : FlexibleAdapter( - null, controller, + null, + controller, true ) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadBottomPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadBottomPresenter.kt index 638bab56ac..57721817eb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadBottomPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadBottomPresenter.kt @@ -68,4 +68,8 @@ class DownloadBottomPresenter(val sheet: DownloadBottomSheet) { fun cancelDownload(download: Download) { downloadManager.deletePendingDownloads(download) } + + fun cancelDownloads(downloads: List) { + downloadManager.deletePendingDownloads(*downloads.toTypedArray()) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadBottomSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadBottomSheet.kt index 504e874e77..04c8b813be 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadBottomSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadBottomSheet.kt @@ -6,13 +6,14 @@ import android.util.AttributeSet import android.view.Menu import android.view.MenuItem import android.widget.LinearLayout +import androidx.core.view.isInvisible import com.google.android.material.bottomsheet.BottomSheetBehavior import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.download.model.Download +import eu.kanade.tachiyomi.databinding.DownloadBottomSheetBinding import eu.kanade.tachiyomi.ui.recents.RecentsController -import eu.kanade.tachiyomi.util.view.RecyclerWindowInsetsListener import eu.kanade.tachiyomi.util.view.collapse import eu.kanade.tachiyomi.util.view.doOnApplyWindowInsets import eu.kanade.tachiyomi.util.view.expand @@ -20,8 +21,8 @@ import eu.kanade.tachiyomi.util.view.hide import eu.kanade.tachiyomi.util.view.isCollapsed import eu.kanade.tachiyomi.util.view.isExpanded import eu.kanade.tachiyomi.util.view.isHidden +import eu.kanade.tachiyomi.util.view.toolbarHeight import eu.kanade.tachiyomi.util.view.updateLayoutParams -import kotlinx.android.synthetic.main.download_bottom_sheet.view.* class DownloadBottomSheet @JvmOverloads constructor( context: Context, @@ -45,19 +46,24 @@ class DownloadBottomSheet @JvmOverloads constructor( private var isRunning: Boolean = false private var activity: Activity? = null + lateinit var binding: DownloadBottomSheetBinding + override fun onFinishInflate() { + super.onFinishInflate() + binding = DownloadBottomSheetBinding.bind(this) + } + fun onCreate(controller: RecentsController) { // Initialize adapter, scroll listener and recycler views adapter = DownloadAdapter(this) sheetBehavior = BottomSheetBehavior.from(this) activity = controller.activity // Create recycler and set adapter. - dl_recycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(context) - dl_recycler.adapter = adapter + binding.dlRecycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(context) + binding.dlRecycler.adapter = adapter adapter?.isHandleDragEnabled = true adapter?.isSwipeEnabled = true - adapter?.fastScroller = fast_scroller - dl_recycler.setHasFixedSize(true) - dl_recycler.setOnApplyWindowInsetsListener(RecyclerWindowInsetsListener) + adapter?.fastScroller = binding.fastScroller + binding.dlRecycler.setHasFixedSize(true) this.controller = controller updateDLTitle() @@ -65,18 +71,29 @@ class DownloadBottomSheet @JvmOverloads constructor( val array = context.obtainStyledAttributes(attrsArray) val headerHeight = array.getDimensionPixelSize(0, 0) array.recycle() - recycler_layout.doOnApplyWindowInsets { v, windowInsets, _ -> + binding.recyclerLayout.doOnApplyWindowInsets { v, windowInsets, _ -> v.updateLayoutParams { - topMargin = windowInsets.systemWindowInsetTop + headerHeight - sheet_layout.height + topMargin = windowInsets.systemWindowInsetTop + + (controller.toolbarHeight ?: headerHeight) - + binding.sheetLayout.height } } - sheet_layout.setOnClickListener { + binding.sheetLayout.setOnClickListener { if (!sheetBehavior.isExpanded()) { sheetBehavior?.expand() } else { sheetBehavior?.collapse() } } + binding.downloadFab.setOnClickListener { + if (controller.presenter.downloadManager.isPaused()) { + DownloadService.start(context) + } else { + DownloadService.stop(context) + presenter.pauseDownloads() + } + updateFab() + } update() setInformationView() if (!controller.hasQueue()) { @@ -88,12 +105,14 @@ class DownloadBottomSheet @JvmOverloads constructor( fun update() { presenter.getItems() onQueueStatusChange(!presenter.downloadManager.isPaused()) + binding.downloadFab.isInvisible = presenter.downloadQueue.isEmpty() } private fun updateDLTitle() { val extCount = presenter.downloadQueue.firstOrNull() - title_text.text = if (extCount != null) resources.getString( - R.string.downloading_, extCount.chapter.name + binding.titleText.text = if (extCount != null) resources.getString( + R.string.downloading_, + extCount.chapter.name ) else "" } @@ -106,6 +125,8 @@ class DownloadBottomSheet @JvmOverloads constructor( private fun onQueueStatusChange(running: Boolean) { val oldRunning = isRunning isRunning = running + binding.downloadFab.isInvisible = presenter.downloadQueue.isEmpty() + updateFab() if (oldRunning != running) { activity?.invalidateOptionsMenu() @@ -151,7 +172,7 @@ class DownloadBottomSheet @JvmOverloads constructor( * @return the holder of the download or null if it's not bound. */ private fun getHolder(download: Download): DownloadHolder? { - return dl_recycler?.findViewHolderForItemId(download.chapter.id!!) as? DownloadHolder + return binding.dlRecycler?.findViewHolderForItemId(download.chapter.id!!) as? DownloadHolder } /** @@ -161,22 +182,17 @@ class DownloadBottomSheet @JvmOverloads constructor( updateDLTitle() setBottomSheet() if (presenter.downloadQueue.isEmpty()) { - empty_view?.show( + binding.emptyView?.show( CommunityMaterial.Icon.cmd_download_off, R.string.nothing_is_downloading ) } else { - empty_view?.hide() + binding.emptyView.hide() } } fun prepareMenu(menu: Menu) { - // Set start button visibility. - menu.findItem(R.id.start_queue)?.isVisible = !isRunning && !presenter.downloadQueue.isEmpty() - - // Set pause button visibility. - menu.findItem(R.id.pause_queue)?.isVisible = isRunning && !presenter.downloadQueue.isEmpty() - + updateFab() // Set clear button visibility. menu.findItem(R.id.clear_queue)?.isVisible = !presenter.downloadQueue.isEmpty() @@ -184,14 +200,14 @@ class DownloadBottomSheet @JvmOverloads constructor( menu.findItem(R.id.reorder)?.isVisible = !presenter.downloadQueue.isEmpty() } + private fun updateFab() { + binding.downloadFab.text = context.getString(if (isRunning) R.string.pause else R.string.resume) + binding.downloadFab.setIconResource(if (isRunning) R.drawable.ic_pause_24dp else R.drawable.ic_play_arrow_24dp) + } + fun onOptionsItemSelected(item: MenuItem): Boolean { val context = activity ?: return false when (item.itemId) { - R.id.start_queue -> DownloadService.start(context) - R.id.pause_queue -> { - DownloadService.stop(context) - presenter.pauseDownloads() - } R.id.clear_queue -> { DownloadService.stop(context) presenter.clearQueue() @@ -200,8 +216,9 @@ class DownloadBottomSheet @JvmOverloads constructor( val adapter = adapter ?: return false val items = adapter.currentItems.sortedBy { it.download.chapter.date_upload } .toMutableList() - if (item.itemId == R.id.newest) + if (item.itemId == R.id.newest) { items.reverse() + } adapter.updateDataSet(items) val downloads = items.mapNotNull { it.download } presenter.reorder(downloads) @@ -250,6 +267,7 @@ class DownloadBottomSheet @JvmOverloads constructor( val adapter = adapter ?: return val downloads = (0 until adapter.itemCount).mapNotNull { adapter.getItem(it)?.download } presenter.reorder(downloads) + controller.updateChapterDownload(download, false) } /** @@ -264,14 +282,24 @@ class DownloadBottomSheet @JvmOverloads constructor( val items = adapter?.currentItems?.toMutableList() ?: return val item = items[position] items.remove(item) - if (menuItem.itemId == R.id.move_to_top) + if (menuItem.itemId == R.id.move_to_top) { items.add(0, item) - else + } else { items.add(item) + } adapter?.updateDataSet(items) val downloads = items.mapNotNull { it.download } presenter.reorder(downloads) } + R.id.cancel_series -> { + val download = adapter?.getItem(position)?.download ?: return + val allDownloadsForSeries = adapter?.currentItems + ?.filter { download.manga.id == it.download.manga.id } + ?.map(DownloadItem::download) + if (!allDownloadsForSeries.isNullOrEmpty()) { + presenter.cancelDownloads(allDownloadsForSeries) + } + } } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadButton.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadButton.kt index b7cb5a0954..2411ecb7f0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadButton.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadButton.kt @@ -1,22 +1,29 @@ package eu.kanade.tachiyomi.ui.download import android.animation.ObjectAnimator +import android.animation.ValueAnimator import android.content.Context import android.graphics.Color import android.util.AttributeSet import android.widget.FrameLayout import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils +import androidx.core.view.isVisible +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.model.Download +import eu.kanade.tachiyomi.databinding.DownloadButtonBinding import eu.kanade.tachiyomi.util.system.getResourceColor -import eu.kanade.tachiyomi.util.view.gone -import eu.kanade.tachiyomi.util.view.visible -import kotlinx.android.synthetic.main.download_button.view.* +import eu.kanade.tachiyomi.widget.EndAnimatorListener class DownloadButton @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : FrameLayout(context, attrs) { - private val activeColor = context.getResourceColor(R.attr.colorAccent) + private val activeColor = ColorUtils.blendARGB( + context.getResourceColor(R.attr.colorAccent), + context.getResourceColor(android.R.attr.textColorPrimaryInverse), + 0.05f + ) private val progressBGColor = ContextCompat.getColor( context, R.color.divider @@ -25,10 +32,12 @@ class DownloadButton @JvmOverloads constructor(context: Context, attrs: Attribut context, R.color.material_on_surface_disabled ) - private val downloadedColor = ContextCompat.getColor( - context, - R.color.download + private val downloadedColor = ColorUtils.blendARGB( + context.getResourceColor(R.attr.colorAccent), + context.getResourceColor(android.R.attr.textColorPrimary), + 0f ) + private val downloadedTextColor = context.getResourceColor(android.R.attr.textColorPrimaryInverse) private val errorColor = ContextCompat.getColor( context, R.color.material_red_500 @@ -49,55 +58,71 @@ class DownloadButton @JvmOverloads constructor(context: Context, attrs: Attribut context, R.drawable.ic_check_24dp )?.mutate() + private val filledAnim = AnimatedVectorDrawableCompat.create( + context, + R.drawable.anim_outline_to_filled + ) + private val checkAnim = AnimatedVectorDrawableCompat.create( + context, + R.drawable.anim_dl_to_check_to_dl + ) private var isAnimating = false private var iconAnimation: ObjectAnimator? = null - fun setDownloadStatus(state: Int, progress: Int = 0) { - if (state != Download.DOWNLOADING) { + lateinit var binding: DownloadButtonBinding + + override fun onFinishInflate() { + super.onFinishInflate() + binding = DownloadButtonBinding.bind(this) + } + + fun setDownloadStatus(state: Download.State, progress: Int = 0, animated: Boolean = false) { + if (state != Download.State.DOWNLOADING) { iconAnimation?.cancel() - download_icon.alpha = 1f + binding.downloadIcon.alpha = 1f isAnimating = false } - download_icon.setImageDrawable( - if (state == Download.CHECKED) - checkDrawable else downloadDrawable + binding.downloadIcon.setImageDrawable( + if (state == Download.State.CHECKED) { + checkDrawable + } else downloadDrawable ) when (state) { - Download.CHECKED -> { - download_progress.gone() - download_border.visible() - download_progress_indeterminate.gone() - download_border.setImageDrawable(filledCircle) - download_border.drawable.setTint(activeColor) - download_icon.drawable.setTint(Color.WHITE) + Download.State.CHECKED -> { + binding.downloadProgress.isVisible = false + binding.downloadBorder.isVisible = true + binding.downloadProgressIndeterminate.isVisible = false + binding.downloadBorder.setImageDrawable(filledCircle) + binding.downloadBorder.drawable.setTint(activeColor) + binding.downloadIcon.drawable.setTint(Color.WHITE) } - Download.NOT_DOWNLOADED -> { - download_border.visible() - download_progress.gone() - download_progress_indeterminate.gone() - download_border.setImageDrawable(borderCircle) - download_border.drawable.setTint(activeColor) - download_icon.drawable.setTint(activeColor) + Download.State.NOT_DOWNLOADED -> { + binding.downloadBorder.isVisible = true + binding.downloadProgress.isVisible = false + binding.downloadProgressIndeterminate.isVisible = false + binding.downloadBorder.setImageDrawable(borderCircle) + binding.downloadBorder.drawable.setTint(activeColor) + binding.downloadIcon.drawable.setTint(activeColor) } - Download.QUEUE -> { - download_border.gone() - download_progress.gone() - download_progress_indeterminate.visible() - download_progress.isIndeterminate = true - download_icon.drawable.setTint(disabledColor) + Download.State.QUEUE -> { + binding.downloadBorder.isVisible = false + binding.downloadProgress.isVisible = false + binding.downloadProgressIndeterminate.isVisible = true + binding.downloadProgress.isIndeterminate = true + binding.downloadIcon.drawable.setTint(disabledColor) } - Download.DOWNLOADING -> { - download_border.visible() - download_progress.visible() - download_progress_indeterminate.gone() - download_border.setImageDrawable(borderCircle) - download_progress.isIndeterminate = false - download_progress.progress = progress - download_border.drawable.setTint(progressBGColor) - download_progress.progressDrawable?.setTint(downloadedColor) - download_icon.drawable.setTint(disabledColor) + Download.State.DOWNLOADING -> { + binding.downloadBorder.isVisible = true + binding.downloadProgress.isVisible = true + binding.downloadProgressIndeterminate.isVisible = false + binding.downloadBorder.setImageDrawable(borderCircle) + binding.downloadProgress.isIndeterminate = false + binding.downloadProgress.progress = progress + binding.downloadBorder.drawable.setTint(progressBGColor) + binding.downloadProgress.progressDrawable?.setTint(downloadedColor) + binding.downloadIcon.drawable.setTint(disabledColor) if (!isAnimating) { - iconAnimation = ObjectAnimator.ofFloat(download_icon, "alpha", 1f, 0f).apply { + iconAnimation = ObjectAnimator.ofFloat(binding.downloadIcon, "alpha", 1f, 0f).apply { duration = 1000 repeatCount = ObjectAnimator.INFINITE repeatMode = ObjectAnimator.REVERSE @@ -106,21 +131,41 @@ class DownloadButton @JvmOverloads constructor(context: Context, attrs: Attribut isAnimating = true } } - Download.DOWNLOADED -> { - download_progress.gone() - download_border.visible() - download_progress_indeterminate.gone() - download_border.setImageDrawable(filledCircle) - download_border.drawable.setTint(downloadedColor) - download_icon.drawable.setTint(Color.BLACK) + Download.State.DOWNLOADED -> { + binding.downloadProgress.isVisible = false + binding.downloadBorder.isVisible = true + binding.downloadProgressIndeterminate.isVisible = false + binding.downloadBorder.drawable.setTint(downloadedColor) + if (animated) { + binding.downloadBorder.setImageDrawable(filledAnim) + binding.downloadIcon.setImageDrawable(checkAnim) + filledAnim?.start() + val alphaAnimation = ValueAnimator.ofArgb(disabledColor, downloadedTextColor) + alphaAnimation.addUpdateListener { valueAnimator -> + binding.downloadIcon.drawable.setTint(valueAnimator.animatedValue as Int) + } + alphaAnimation.addListener( + EndAnimatorListener { + binding.downloadIcon.drawable.setTint(downloadedTextColor) + checkAnim?.start() + } + ) + alphaAnimation.duration = 150 + alphaAnimation.start() + binding.downloadBorder.drawable.setTint(downloadedColor) + } else { + binding.downloadBorder.setImageDrawable(filledCircle) + binding.downloadBorder.drawable.setTint(downloadedColor) + binding.downloadIcon.drawable.setTint(downloadedTextColor) + } } - Download.ERROR -> { - download_progress.gone() - download_border.visible() - download_progress_indeterminate.gone() - download_border.setImageDrawable(borderCircle) - download_border.drawable.setTint(errorColor) - download_icon.drawable.setTint(errorColor) + Download.State.ERROR -> { + binding.downloadProgress.isVisible = false + binding.downloadBorder.isVisible = true + binding.downloadProgressIndeterminate.isVisible = false + binding.downloadBorder.setImageDrawable(borderCircle) + binding.downloadBorder.drawable.setTint(errorColor) + binding.downloadIcon.drawable.setTint(errorColor) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadHolder.kt index 6373c9c9d0..e0a92ed4bd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadHolder.kt @@ -2,13 +2,13 @@ package eu.kanade.tachiyomi.ui.download import android.view.View import androidx.appcompat.widget.PopupMenu +import androidx.core.view.isVisible import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.model.Download +import eu.kanade.tachiyomi.databinding.DownloadItemBinding import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.view.setVectorCompat -import eu.kanade.tachiyomi.util.view.visibleIf -import kotlinx.android.synthetic.main.download_item.* /** * Class used to hold the data of a download. @@ -20,9 +20,10 @@ import kotlinx.android.synthetic.main.download_item.* class DownloadHolder(private val view: View, val adapter: DownloadAdapter) : BaseFlexibleViewHolder(view, adapter) { + private val binding = DownloadItemBinding.bind(view) init { - setDragHandleView(reorder) - migration_menu.setOnClickListener { it.post { showPopupMenu(it) } } + setDragHandleView(binding.reorder) + binding.downloadMenu.setOnClickListener { it.post { showPopupMenu(it) } } } private lateinit var download: Download @@ -30,30 +31,29 @@ class DownloadHolder(private val view: View, val adapter: DownloadAdapter) : /** * Binds this holder with the given category. * - * @param category The category to bind. + * @param download The download to bind. */ fun bind(download: Download) { this.download = download // Update the chapter name. - chapter_title.text = download.chapter.name + binding.chapterTitle.text = download.chapter.name // Update the manga title - title.text = download.manga.title + binding.title.text = download.manga.title // Update the progress bar and the number of downloaded pages val pages = download.pages if (pages == null) { - download_progress.progress = 0 - download_progress.max = 1 - download_progress_text.text = "" + binding.downloadProgress.progress = 0 + binding.downloadProgress.max = 1 + binding.downloadProgressText.text = "" } else { - download_progress.max = pages.size * 100 + binding.downloadProgress.max = pages.size * 100 notifyProgress() notifyDownloadedPages() } - migration_menu.visibleIf(adapterPosition != 0 || adapterPosition != adapter.itemCount - 1) - migration_menu.setVectorCompat( + binding.downloadMenu.setVectorCompat( R.drawable.ic_more_vert_24dp, view.context .getResourceColor(android.R.attr.textColorPrimary) @@ -65,10 +65,10 @@ class DownloadHolder(private val view: View, val adapter: DownloadAdapter) : */ fun notifyProgress() { val pages = download.pages ?: return - if (download_progress.max == 1) { - download_progress.max = pages.size * 100 + if (binding.downloadProgress.max == 1) { + binding.downloadProgress.max = pages.size * 100 } - download_progress.progress = download.pageProgress + binding.downloadProgress.progress = download.pageProgress } /** @@ -76,7 +76,7 @@ class DownloadHolder(private val view: View, val adapter: DownloadAdapter) : */ fun notifyDownloadedPages() { val pages = download.pages ?: return - download_progress_text.text = "${download.downloadedImages}/${pages.size}" + binding.downloadProgressText.text = "${download.downloadedImages}/${pages.size}" } override fun onItemReleased(position: Int) { @@ -85,7 +85,7 @@ class DownloadHolder(private val view: View, val adapter: DownloadAdapter) : } private fun showPopupMenu(view: View) { - val item = adapter.getItem(adapterPosition) ?: return + val item = adapter.getItem(flexibleAdapterPosition) ?: return // Create a PopupMenu, giving it the clicked view for an anchor val popup = PopupMenu(view.context, view) @@ -93,15 +93,13 @@ class DownloadHolder(private val view: View, val adapter: DownloadAdapter) : // Inflate our menu resource into the PopupMenu's Menu popup.menuInflater.inflate(R.menu.download_single, popup.menu) - val download = item.download - - popup.menu.findItem(R.id.move_to_top).isVisible = adapterPosition != 0 - popup.menu.findItem(R.id.move_to_bottom).isVisible = adapterPosition != adapter + popup.menu.findItem(R.id.move_to_top).isVisible = flexibleAdapterPosition != 0 + popup.menu.findItem(R.id.move_to_bottom).isVisible = flexibleAdapterPosition != adapter .itemCount - 1 // Set a listener so we are notified if a menu item is clicked popup.setOnMenuItemClickListener { menuItem -> - adapter.downloadItemListener.onMenuItemClick(adapterPosition, menuItem) + adapter.downloadItemListener.onMenuItemClick(flexibleAdapterPosition, menuItem) true } @@ -110,14 +108,14 @@ class DownloadHolder(private val view: View, val adapter: DownloadAdapter) : } override fun getFrontView(): View { - return front_view + return binding.frontView } override fun getRearRightView(): View { - return right_view + return binding.rightView } override fun getRearLeftView(): View { - return left_view + return binding.leftView } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/RecyclerViewPagerAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/RecyclerViewPagerAdapter.kt new file mode 100644 index 0000000000..f446b838af --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/RecyclerViewPagerAdapter.kt @@ -0,0 +1,34 @@ +package eu.kanade.tachiyomi.ui.extension + +import android.view.View +import android.view.ViewGroup +import com.nightlynexus.viewstatepageradapter.ViewStatePagerAdapter +import java.util.Stack + +abstract class RecyclerViewPagerAdapter : ViewStatePagerAdapter() { + + private val pool = Stack() + + var recycle = true + set(value) { + if (!value) pool.clear() + field = value + } + + protected abstract fun createView(container: ViewGroup): View + + protected abstract fun bindView(view: View, position: Int) + + protected open fun recycleView(view: View, position: Int) {} + + override fun createView(container: ViewGroup, position: Int): View { + val view = if (pool.isNotEmpty()) pool.pop() else createView(container) + bindView(view, position) + return view + } + + override fun destroyView(container: ViewGroup, position: Int, view: View) { + recycleView(view, position) + if (recycle) pool.push(view) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/follows/FollowsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/follows/FollowsController.kt index 82777a7c0c..c445f30493 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/follows/FollowsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/follows/FollowsController.kt @@ -2,12 +2,12 @@ package eu.kanade.tachiyomi.ui.follows import android.os.Bundle import android.view.Menu +import android.view.MenuInflater import android.view.View +import androidx.core.view.isVisible import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.source.browse.BrowseSourceController import eu.kanade.tachiyomi.ui.source.browse.BrowseSourcePresenter -import eu.kanade.tachiyomi.util.view.gone -import kotlinx.android.synthetic.main.browse_source_controller.* /** * Controller that shows the latest manga from the catalogue. Inherit [BrowseCatalogueController]. @@ -31,16 +31,9 @@ class FollowsController(bundle: Bundle) : BrowseSourceController(bundle) { override fun onViewCreated(view: View) { super.onViewCreated(view) - fab.gone() + binding.fab.isVisible = false } - override fun onPrepareOptionsMenu(menu: Menu) { - super.onPrepareOptionsMenu(menu) - menu.findItem(R.id.action_search).isVisible = false - menu.findItem(R.id.action_open_in_web_view).isVisible = false - } - - override fun expandSearch() { - activity?.onBackPressed() + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/AddToLibraryCategoriesDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/AddToLibraryCategoriesDialog.kt deleted file mode 100644 index 1a8739f2b0..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/AddToLibraryCategoriesDialog.kt +++ /dev/null @@ -1,67 +0,0 @@ -package eu.kanade.tachiyomi.ui.library - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.afollestad.materialdialogs.callbacks.onCancel -import com.afollestad.materialdialogs.list.listItemsMultiChoice -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.ui.base.controller.DialogController - -/** - * This class is used when adding new manga to your library - */ -class AddToLibraryCategoriesDialog(bundle: Bundle? = null) : - DialogController(bundle) where T : Controller, T : AddToLibraryCategoriesDialog.Listener { - - private var manga: Manga? = null - - private var categories = emptyList() - - private var preselected = emptyArray() - - private var position = 0 - - constructor( - target: T, - manga: Manga, - categories: List, - preselected: Array, - position: Int = 0 - ) : this() { - - this.manga = manga - this.categories = categories - this.preselected = preselected - this.position = position - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - - return MaterialDialog(activity!!).title(R.string.add_to_library).message(R.string.add_to_categories) - .listItemsMultiChoice( - items = categories.map { it.name }, - initialSelection = preselected.toIntArray(), - allowEmptySelection = true - ) { _, selections, _ -> - val newCategories = selections.map { categories[it] } - (targetController as? Listener)?.updateCategoriesForManga(manga, newCategories) - } - .positiveButton(android.R.string.ok) - .negativeButton(android.R.string.cancel) { - (targetController as? Listener)?.addToLibraryCancelled(manga, position) - } - .onCancel { - (targetController as? Listener)?.addToLibraryCancelled(manga, position) - } - } - - interface Listener { - fun updateCategoriesForManga(manga: Manga?, categories: List) - fun addToLibraryCancelled(manga: Manga?, position: Int) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCategoriesDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCategoriesDialog.kt deleted file mode 100644 index f3ac85fc4b..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/ChangeMangaCategoriesDialog.kt +++ /dev/null @@ -1,53 +0,0 @@ -package eu.kanade.tachiyomi.ui.library - -import android.app.Dialog -import android.os.Bundle -import com.afollestad.materialdialogs.MaterialDialog -import com.afollestad.materialdialogs.list.listItemsMultiChoice -import com.bluelinelabs.conductor.Controller -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.ui.base.controller.DialogController - -class ChangeMangaCategoriesDialog(bundle: Bundle? = null) : - DialogController(bundle) where T : Controller, T : ChangeMangaCategoriesDialog.Listener { - - private var mangas = emptyList() - - private var categories = emptyList() - - private var preselected = emptyArray() - - constructor( - target: T, - mangas: List, - categories: List, - preselected: Array - ) : this() { - - this.mangas = mangas - this.categories = categories - this.preselected = preselected - targetController = target - } - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialDialog(activity!!) - .title(R.string.move_to_categories) - .listItemsMultiChoice( - items = categories.map { it.name }, - initialSelection = preselected.toIntArray(), - allowEmptySelection = true - ) { _, selections, _ -> - val newCategories = selections.map { categories[it] } - (targetController as? Listener)?.updateCategoriesForMangas(mangas, newCategories) - } - .positiveButton(android.R.string.ok) - .negativeButton(android.R.string.cancel) - } - - interface Listener { - fun updateCategoriesForMangas(mangas: List, categories: List) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/DisplayBottomSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/DisplayBottomSheet.kt deleted file mode 100644 index 5dbadbd7d1..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/DisplayBottomSheet.kt +++ /dev/null @@ -1,156 +0,0 @@ -package eu.kanade.tachiyomi.ui.library - -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import android.widget.CompoundButton -import android.widget.RadioButton -import android.widget.RadioGroup -import com.f2prateek.rx.preferences.Preference -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.ui.setting.SettingsLibraryController -import eu.kanade.tachiyomi.util.system.dpToPx -import eu.kanade.tachiyomi.util.view.expand -import eu.kanade.tachiyomi.util.view.isCollapsed -import eu.kanade.tachiyomi.util.view.setBottomEdge -import eu.kanade.tachiyomi.util.view.setEdgeToEdge -import eu.kanade.tachiyomi.util.view.visibleIf -import eu.kanade.tachiyomi.util.view.withFadeTransaction -import kotlinx.android.synthetic.main.display_bottom_sheet.* -import uy.kohesive.injekt.injectLazy - -class DisplayBottomSheet(private val controller: LibraryController) : BottomSheetDialog -(controller.activity!!, R.style.BottomSheetDialogTheme) { - - val activity = controller.activity!! - - /** - * Preferences helper. - */ - private val preferences by injectLazy() - - private var sheetBehavior: BottomSheetBehavior<*> - - init { - // Use activity theme for this layout - val view = activity.layoutInflater.inflate(R.layout.display_bottom_sheet, null) - setContentView(view) - - sheetBehavior = BottomSheetBehavior.from(view.parent as ViewGroup) - setEdgeToEdge(activity, view) - val height = activity.window.decorView.rootWindowInsets.systemWindowInsetBottom - sheetBehavior.peekHeight = 220.dpToPx + height - - sheetBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, progress: Float) {} - - override fun onStateChanged(p0: View, state: Int) { - if (state == BottomSheetBehavior.STATE_EXPANDED) { - sheetBehavior.skipCollapsed = true - } - } - }) - } - - override fun onStart() { - super.onStart() - sheetBehavior.skipCollapsed = true - sheetBehavior.expand() - } - - /** - * Called when the sheet is created. It initializes the listeners and values of the preferences. - */ - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - initGeneralPreferences() - setBottomEdge(display_layout, activity) - close_button.setOnClickListener { dismiss() } - settings_scroll_view.viewTreeObserver.addOnGlobalLayoutListener { - val isScrollable = - settings_scroll_view!!.height < display_layout.height + - settings_scroll_view.paddingTop + settings_scroll_view.paddingBottom - close_button.visibleIf(isScrollable) - } - } - - private fun initGeneralPreferences() { - display_group.bindToPreference(preferences.libraryLayout()) { - controller.reattachAdapter() - if (sheetBehavior.isCollapsed()) dismiss() - } - show_all.bindToPreference(preferences.showAllCategories()) { - controller.presenter.getLibrary() - category_show.isEnabled = it - } - category_show.isEnabled = show_all.isChecked - category_show.bindToPreference(preferences.showCategoryInTitle()) { - controller.showMiniBar() - } - hide_hopper.bindToPreference(preferences.hideHopper()) { - controller.hideHopper(it) - } - uniform_grid.bindToPreference(preferences.uniformGrid()) { - controller.reattachAdapter() - } - grid_size_toggle_group.bindToPreference(preferences.gridSize()) { - controller.reattachAdapter() - } - download_badge.bindToPreference(preferences.downloadBadge()) { - controller.presenter.requestDownloadBadgesUpdate() - } - unread_badge_group.bindToPreference(preferences.unreadBadgeType()) { - controller.presenter.requestUnreadBadgesUpdate() - } - hide_reading.bindToPreference(preferences.hideStartReadingButton()) { - controller.reattachAdapter() - } - hide_filters.bindToPreference(preferences.hideFiltersAtStart()) - more_settings.setOnClickListener { - controller.router.pushController(SettingsLibraryController().withFadeTransaction()) - dismiss() - } - } - - /** - * Binds a checkbox or switch view with a boolean preference. - */ - private fun CompoundButton.bindToPreference(pref: Preference, block: (() -> Unit)? = null) { - isChecked = pref.getOrDefault() - setOnCheckedChangeListener { _, isChecked -> - pref.set(isChecked) - block?.invoke() - } - } - - /** - * Binds a checkbox or switch view with a boolean preference. - */ - private fun CompoundButton.bindToPreference( - pref: com.tfcporciuncula.flow - .Preference, - block: ((Boolean) -> Unit)? = null - ) { - isChecked = pref.get() - setOnCheckedChangeListener { _, isChecked -> - pref.set(isChecked) - block?.invoke(isChecked) - } - } - - /** - * Binds a radio group with a int preference. - */ - private fun RadioGroup.bindToPreference(pref: Preference, block: (() -> Unit)? = null) { - (getChildAt(pref.getOrDefault()) as RadioButton).isChecked = true - setOnCheckedChangeListener { _, checkedId -> - val index = indexOfChild(findViewById(checkedId)) - pref.set(index) - block?.invoke() - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryBadge.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryBadge.kt index d09f26e398..a76b69530f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryBadge.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryBadge.kt @@ -2,50 +2,54 @@ package eu.kanade.tachiyomi.ui.library import android.content.Context import android.util.AttributeSet +import androidx.core.view.isVisible import com.google.android.material.card.MaterialCardView import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.UnreadDownloadBadgeBinding import eu.kanade.tachiyomi.source.online.utils.FollowStatus import eu.kanade.tachiyomi.util.system.contextCompatColor import eu.kanade.tachiyomi.util.system.dpToPx -import eu.kanade.tachiyomi.util.view.gone -import eu.kanade.tachiyomi.util.view.isVisible -import eu.kanade.tachiyomi.util.view.setTextColorRes +import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.view.updatePaddingRelative -import eu.kanade.tachiyomi.util.view.visible -import eu.kanade.tachiyomi.util.view.visibleIf -import kotlinx.android.synthetic.main.unread_download_badge.view.* class LibraryBadge @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : MaterialCardView(context, attrs) { + private lateinit var binding: UnreadDownloadBadgeBinding + + override fun onFinishInflate() { + super.onFinishInflate() + binding = UnreadDownloadBadgeBinding.bind(this) + } + fun setUnreadDownload(unread: Int, downloads: Int, showTotalChapters: Boolean) { // Update the unread count and its visibility. - val unreadBadgeBackground = context.contextCompatColor( - if (showTotalChapters) R.color.total_badge else R.color.unread_badge - ) + val unreadBadgeBackground = if (showTotalChapters) { + context.contextCompatColor(R.color.total_badge) + } else context.getResourceColor(R.attr.unreadBadgeColor) - with(unread_text) { - visibleIf(unread > 0 || unread == -1 || showTotalChapters) - if (!isVisible()) { + with(binding.unreadText) { + isVisible = unread > 0 || unread == -1 || showTotalChapters + if (!isVisible) { return@with } text = if (unread == -1) "0" else unread.toString() - setTextColorRes( + setTextColor( // hide the badge text when preference is only show badge when { - unread == -1 && !showTotalChapters -> R.color.unread_badge - showTotalChapters -> R.color.total_badge_text - else -> R.color.unread_badge_text + unread == -1 && !showTotalChapters -> unreadBadgeBackground + showTotalChapters -> context.contextCompatColor(R.color.total_badge_text) + else -> context.getResourceColor(R.attr.colorOnUnreadBadge) } ) setBackgroundColor(unreadBadgeBackground) } // Update the download count or local status and its visibility. - with(download_text) { - visibleIf(downloads == -2 || downloads > 0) - if (!isVisible()) { + with(binding.downloadText) { + isVisible = downloads == -2 || downloads > 0 + if (!isVisible) { return@with } text = if (downloads == -2) { @@ -53,21 +57,23 @@ class LibraryBadge @JvmOverloads constructor(context: Context, attrs: AttributeS } else { downloads.toString() } + setTextColor(context.getResourceColor(R.attr.colorOnDownloadBadge)) + setBackgroundColor(context.getResourceColor(R.attr.downloadBadgeColor)) } // Show the badge card if unread or downloads exists - visibleIf(download_text.isVisible() || unread_text.isVisible()) + isVisible = binding.downloadText.isVisible || binding.unreadText.isVisible // Show the angles divider if both unread and downloads exists - unread_angle.visibleIf(download_text.isVisible() && unread_text.isVisible()) + binding.unreadAngle.isVisible = binding.downloadText.isVisible && binding.unreadText.isVisible - unread_angle.setColorFilter(unreadBadgeBackground) - if (unread_angle.isVisible()) { - download_text.updatePaddingRelative(end = 8.dpToPx) - unread_text.updatePaddingRelative(start = 2.dpToPx) + binding.unreadAngle.setColorFilter(unreadBadgeBackground) + if (binding.unreadAngle.isVisible) { + binding.downloadText.updatePaddingRelative(end = 8.dpToPx) + binding.unreadText.updatePaddingRelative(start = 2.dpToPx) } else { - download_text.updatePaddingRelative(end = 5.dpToPx) - unread_text.updatePaddingRelative(start = 5.dpToPx) + binding.downloadText.updatePaddingRelative(end = 5.dpToPx) + binding.unreadText.updatePaddingRelative(start = 5.dpToPx) } } @@ -76,31 +82,32 @@ class LibraryBadge @JvmOverloads constructor(context: Context, attrs: AttributeS } fun setInLibrary(inLibrary: Boolean) { - this.visibleIf(inLibrary) - unread_angle.gone() - unread_text.updatePaddingRelative(start = 5.dpToPx) - unread_text.visibleIf(inLibrary) - unread_text.text = resources.getText(R.string.in_library) + this.isVisible = inLibrary + binding.unreadAngle.isVisible = false + binding.unreadText.updatePaddingRelative(start = 5.dpToPx) + binding.unreadText.isVisible = inLibrary + binding.unreadText.text = resources.getText(R.string.in_library) } fun setStatus(status: FollowStatus, inLibrary: Boolean) { - this.visible() + this.isVisible = true + with(binding) { + unreadAngle.isVisible = inLibrary + downloadText.isVisible = inLibrary + downloadText.text = resources.getText(R.string.in_library) - unread_angle.visibleIf(inLibrary) - download_text.visibleIf(inLibrary) - download_text.text = resources.getText(R.string.in_library) - - unread_text.updatePaddingRelative(start = 5.dpToPx) - unread_text.visible() - val statusText = when (status) { - FollowStatus.READING -> R.string.follows_reading - FollowStatus.UNFOLLOWED -> R.string.follows_unfollowed - FollowStatus.COMPLETED -> R.string.follows_completed - FollowStatus.ON_HOLD -> R.string.follows_on_hold - FollowStatus.PLAN_TO_READ -> R.string.follows_plan_to_read - FollowStatus.DROPPED -> R.string.follows_dropped - FollowStatus.RE_READING -> R.string.follows_re_reading + unreadText.updatePaddingRelative(start = 5.dpToPx) + unreadText.isVisible = true + val statusText = when (status) { + FollowStatus.READING -> R.string.follows_reading + FollowStatus.UNFOLLOWED -> R.string.follows_unfollowed + FollowStatus.COMPLETED -> R.string.follows_completed + FollowStatus.ON_HOLD -> R.string.follows_on_hold + FollowStatus.PLAN_TO_READ -> R.string.follows_plan_to_read + FollowStatus.DROPPED -> R.string.follows_dropped + FollowStatus.RE_READING -> R.string.follows_re_reading + } + unreadText.text = resources.getText(statusText) } - unread_text.text = resources.getText(statusText) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt index 8a2311eae1..62275112e1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryCategoryAdapter.kt @@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.util.lang.chop import eu.kanade.tachiyomi.util.lang.removeArticles import eu.kanade.tachiyomi.util.system.isLTR import eu.kanade.tachiyomi.util.system.timeSpanFromNow +import eu.kanade.tachiyomi.util.system.withDefContext import uy.kohesive.injekt.injectLazy import java.util.Locale @@ -64,6 +65,17 @@ class LibraryCategoryAdapter(val controller: LibraryController) : } } + /** + * Returns the position in the adapter for the given manga. + * + * @param manga the manga to find. + */ + fun findCategoryHeader(catId: Int): LibraryHeaderItem? { + return currentItems.find { + (it is LibraryHeaderItem) && it.category.id == catId + } as? LibraryHeaderItem + } + /** * Returns the position in the adapter for the given manga. * @@ -98,6 +110,9 @@ class LibraryCategoryAdapter(val controller: LibraryController) : fun performFilter() { val s = getFilter(String::class.java) if (s.isNullOrBlank()) { + if (mangas.firstOrNull()?.filter?.isNotBlank() == true) { + mangas.forEach { it.filter = "" } + } updateDataSet(mangas) } else { updateDataSet(mangas.filter { it.filter(s) }) @@ -105,6 +120,20 @@ class LibraryCategoryAdapter(val controller: LibraryController) : isLongPressDragEnabled = libraryListener.canDrag() && s.isNullOrBlank() } + suspend fun performFilterAsync() { + val s = getFilter(String::class.java) + if (s.isNullOrBlank()) { + if (mangas.firstOrNull()?.filter?.isNotBlank() == true) { + mangas.forEach { it.filter = "" } + } + updateDataSet(mangas) + } else { + val filteredManga = withDefContext { mangas.filter { it.filter(s) } } + updateDataSet(filteredManga) + } + isLongPressDragEnabled = libraryListener.canDrag() && s.isNullOrBlank() + } + private fun getFirstLetter(name: String): String { val letter = name.firstOrNull() ?: '#' return if (letter.isLetter()) getFirstChar(name) else "#" @@ -129,7 +158,7 @@ class LibraryCategoryAdapter(val controller: LibraryController) : is LibraryItem -> { val text = if (item.manga.isBlank()) return item.header?.category?.name.orEmpty() else when (getSort(position)) { - LibrarySort.DRAG_AND_DROP -> { + LibrarySort.DragAndDrop -> { if (item.header.category.isDynamic) { val category = db.getCategoriesForManga(item.manga).executeAsBlocking().firstOrNull()?.name category ?: recyclerView.context.getString(R.string.default_value) @@ -139,62 +168,68 @@ class LibraryCategoryAdapter(val controller: LibraryController) : else title.take(10) } } - LibrarySort.LAST_READ -> { + LibrarySort.DateFetched -> { + val id = item.manga.id ?: return "" + val history = db.getChapters(id).executeAsBlocking() + val last = history.maxOfOrNull { it.date_fetch } + if (last != null && last > 100) { + recyclerView.context.getString( + R.string.fetched_, + last.timeSpanFromNow(preferences.context) + ) + } else { + "N/A" + } + } + LibrarySort.LastRead -> { val id = item.manga.id ?: return "" val history = db.getHistoryByMangaId(id).executeAsBlocking() - val last = history.maxBy { it.last_read } - if (last != null && last.last_read > 100) { + val last = history.maxOfOrNull { it.last_read } + if (last != null && last > 100) { recyclerView.context.getString( - R.string.read_, last.last_read.timeSpanFromNow + R.string.read_, + last.timeSpanFromNow(preferences.context) ) } else { "N/A" } } - LibrarySort.UNREAD -> { + LibrarySort.Unread -> { val unread = item.manga.unread if (unread > 0) recyclerView.context.getString(R.string._unread, unread) else recyclerView.context.getString(R.string.read) } - LibrarySort.TOTAL -> { + LibrarySort.TotalChapters -> { val total = item.manga.totalChapters if (total > 0) recyclerView.resources.getQuantityString( - R.plurals.chapters, total, total + R.plurals.chapters_plural, + total, + total ) else { "N/A" } } - LibrarySort.LATEST_CHAPTER -> { + LibrarySort.LatestChapter -> { val lastUpdate = item.manga.last_update if (lastUpdate > 0) { recyclerView.context.getString( - R.string.updated_, lastUpdate.timeSpanFromNow + R.string.updated_, + lastUpdate.timeSpanFromNow(preferences.context) ) } else { "N/A" } } - LibrarySort.DATE_ADDED -> { + LibrarySort.DateAdded -> { val added = item.manga.date_added if (added > 0) { - recyclerView.context.getString(R.string.added_, added.timeSpanFromNow) + recyclerView.context.getString(R.string.added_, added.timeSpanFromNow(preferences.context)) } else { "N/A" } } - LibrarySort.RATING -> { - val added = item.manga.rating ?: return "" - val rating = added.toDoubleOrNull() - rating ?: return "" - - if (rating > 0) { - recyclerView.context.getString(R.string.added_, added) - } else { - "N/A" - } - } - else -> { + LibrarySort.Title -> { val title = if (preferences.removeArticles().getOrDefault()) { item.manga.title.removeArticles() } else { @@ -213,13 +248,9 @@ class LibraryCategoryAdapter(val controller: LibraryController) : } } - private fun getSort(position: Int): Int { + private fun getSort(position: Int): LibrarySort { val header = (getItem(position) as? LibraryItem)?.header - return if (header != null) { - header.category.sortingMode() ?: LibrarySort.DRAG_AND_DROP - } else { - LibrarySort.DRAG_AND_DROP - } + return header?.category?.sortingMode() ?: LibrarySort.DragAndDrop } interface LibraryListener { @@ -227,7 +258,7 @@ class LibraryCategoryAdapter(val controller: LibraryController) : fun onItemReleased(position: Int) fun canDrag(): Boolean fun updateCategory(catId: Int): Boolean - fun sortCategory(catId: Int, sortBy: Int) + fun sortCategory(catId: Int, sortBy: Char) fun selectAll(position: Int) fun allSelected(position: Int): Boolean fun toggleCategoryVisibility(position: Int) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt index d8829d476e..3dca3a50d2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt @@ -2,11 +2,13 @@ package eu.kanade.tachiyomi.ui.library import android.animation.AnimatorSet import android.animation.ObjectAnimator +import android.animation.ValueAnimator import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.Intent import android.graphics.Color +import android.os.Build import android.os.Bundle import android.os.Handler import android.util.TypedValue @@ -15,16 +17,22 @@ import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem +import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.ViewPropertyAnimator +import android.view.WindowInsets import android.view.inputmethod.InputMethodManager import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.SearchView import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.GestureDetectorCompat -import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsAnimationCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isInvisible +import androidx.core.view.isVisible import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager @@ -51,50 +59,56 @@ import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.databinding.LibraryControllerBinding +import eu.kanade.tachiyomi.ui.base.MaterialFastScroll import eu.kanade.tachiyomi.ui.base.MaterialMenuSheet -import eu.kanade.tachiyomi.ui.base.controller.BaseController +import eu.kanade.tachiyomi.ui.base.controller.BaseCoroutineController +import eu.kanade.tachiyomi.ui.category.CategoryController import eu.kanade.tachiyomi.ui.category.ManageCategoryDialog import eu.kanade.tachiyomi.ui.library.LibraryGroup.BY_DEFAULT import eu.kanade.tachiyomi.ui.library.LibraryGroup.BY_STATUS import eu.kanade.tachiyomi.ui.library.LibraryGroup.BY_TAG import eu.kanade.tachiyomi.ui.library.LibraryGroup.BY_TRACK_STATUS import eu.kanade.tachiyomi.ui.library.LibraryGroup.UNGROUPED +import eu.kanade.tachiyomi.ui.library.display.TabbedLibraryDisplaySheet import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet import eu.kanade.tachiyomi.ui.main.BottomSheetController +import eu.kanade.tachiyomi.ui.main.FloatingSearchInterface import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.RootSearchInterface import eu.kanade.tachiyomi.ui.manga.MangaDetailsController import eu.kanade.tachiyomi.ui.reader.ReaderActivity -import eu.kanade.tachiyomi.util.system.contextCompatColor +import eu.kanade.tachiyomi.util.moveCategories import eu.kanade.tachiyomi.util.system.dpToPx +import eu.kanade.tachiyomi.util.system.getBottomGestureInsets +import eu.kanade.tachiyomi.util.system.getResourceColor +import eu.kanade.tachiyomi.util.system.isImeVisible import eu.kanade.tachiyomi.util.system.launchUI -import eu.kanade.tachiyomi.util.view.applyWindowInsetsForRootController +import eu.kanade.tachiyomi.util.view.activityBinding import eu.kanade.tachiyomi.util.view.collapse import eu.kanade.tachiyomi.util.view.expand import eu.kanade.tachiyomi.util.view.getItemView -import eu.kanade.tachiyomi.util.view.gone import eu.kanade.tachiyomi.util.view.hide import eu.kanade.tachiyomi.util.view.isExpanded import eu.kanade.tachiyomi.util.view.isHidden -import eu.kanade.tachiyomi.util.view.isVisible import eu.kanade.tachiyomi.util.view.scrollViewWith import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener import eu.kanade.tachiyomi.util.view.setStyle +import eu.kanade.tachiyomi.util.view.smoothScrollToTop import eu.kanade.tachiyomi.util.view.snack import eu.kanade.tachiyomi.util.view.updateLayoutParams import eu.kanade.tachiyomi.util.view.updatePaddingRelative -import eu.kanade.tachiyomi.util.view.visibleIf import eu.kanade.tachiyomi.util.view.withFadeTransaction import eu.kanade.tachiyomi.widget.EndAnimatorListener -import kotlinx.android.synthetic.main.filter_bottom_sheet.* -import kotlinx.android.synthetic.main.library_grid_recycler.* -import kotlinx.android.synthetic.main.library_list_controller.* -import kotlinx.android.synthetic.main.main_activity.* -import kotlinx.android.synthetic.main.rounded_category_hopper.* import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import java.util.Locale import kotlin.math.abs +import kotlin.math.max import kotlin.math.roundToInt import kotlin.random.Random import kotlin.random.nextInt @@ -102,15 +116,15 @@ import kotlin.random.nextInt class LibraryController( bundle: Bundle? = null, val preferences: PreferencesHelper = Injekt.get() -) : BaseController(bundle), +) : BaseCoroutineController(bundle), ActionMode.Callback, - ChangeMangaCategoriesDialog.Listener, FlexibleAdapter.OnItemClickListener, FlexibleAdapter.OnItemLongClickListener, FlexibleAdapter.OnItemMoveListener, LibraryCategoryAdapter.LibraryListener, BottomSheetController, RootSearchInterface, + FloatingSearchInterface, LibraryServiceListener { init { @@ -131,7 +145,7 @@ class LibraryController( */ private var actionMode: ActionMode? = null - private var libraryLayout: Int = preferences.libraryLayout().getOrDefault() + private var libraryLayout: Int = preferences.libraryLayout().get() var singleCategory: Boolean = false private set @@ -153,21 +167,23 @@ class LibraryController( private var lastItemPosition: Int? = null private var lastItem: IFlexible<*>? = null - lateinit var presenter: LibraryPresenter - private set + override var presenter = LibraryPresenter(this) private var observeLater: Boolean = false var snack: Snackbar? = null + var displaySheet: TabbedLibraryDisplaySheet? = null private var scrollDistance = 0f private val scrollDistanceTilHidden = 1000.dpToPx private var textAnim: ViewPropertyAnimator? = null + private var hasExpanded = false + var hopperGravity: Int = preferences.hopperGravity().get() + @SuppressLint("RtlHardcoded") set(value) { field = value - if (category_hopper_frame == null) return - jumper_category_text.updateLayoutParams { + binding.jumperCategoryText.updateLayoutParams { anchorGravity = when (value) { 0 -> Gravity.RIGHT or Gravity.CENTER_VERTICAL 2 -> Gravity.LEFT or Gravity.CENTER_VERTICAL @@ -179,35 +195,73 @@ class LibraryController( private var filterTooltip: ViewTooltip? = null private var isAnimatingHopper: Boolean? = null + private var animatorSet: AnimatorSet? = null var hasMovedHopper = preferences.shownHopperSwipeTutorial().get() private var shouldScrollToTop = false private val showCategoryInTitle get() = preferences.showCategoryInTitle().get() && presenter.showAllCategories private lateinit var elevateAppBar: ((Boolean) -> Unit) + private var hopperOffset = 0f + private val maxHopperOffset: Float + get() = + if (activityBinding?.bottomNav != null) 55f.dpToPx + else (view?.rootWindowInsets?.systemWindowInsetBottom?.toFloat() ?: 0f) + 55f.dpToPx override fun getTitle(): String? { - return if (!showCategoryInTitle || header_title.text.isNullOrBlank() || recycler_cover?.isClickable == true) { - view?.context?.getString(R.string.library) - } else { - header_title.text.toString() + setSubtitle() + return searchTitle( + if (preferences.showLibrarySearchSuggestions().get() && + preferences.librarySearchSuggestion().get().isNotBlank() + ) { + "\"${preferences.librarySearchSuggestion().get()}\"" + } else { + view?.context?.getString(R.string.your_library)?.lowercase(Locale.ROOT) + } + ) + } + + val cb = object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) { + override fun onStart( + animation: WindowInsetsAnimationCompat, + bounds: WindowInsetsAnimationCompat.BoundsCompat + ): WindowInsetsAnimationCompat.BoundsCompat { + updateHopperY() + return bounds + } + + override fun onProgress( + insets: WindowInsetsCompat, + runningAnimations: List + ): WindowInsetsCompat { + updateHopperY(insets.toWindowInsets()) + return insets + } + + override fun onEnd(animation: WindowInsetsAnimationCompat) { + updateHopperY() } } private var scrollListener = object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) - val recyclerCover = recycler_cover ?: return + if (recyclerView.tag == MaterialFastScroll.noUpdate) return + val recyclerCover = binding.recyclerCover if (!recyclerCover.isClickable && isAnimatingHopper != true) { - category_hopper_frame.translationY += dy - category_hopper_frame.translationY = - category_hopper_frame.translationY.coerceIn(0f, 50f.dpToPx) - up_category.alpha = if (isAtTop()) 0.25f else 1f - down_category.alpha = if (isAtBottom()) 0.25f else 1f + if (preferences.autohideHopper().get()) { + hopperOffset += dy + hopperOffset = hopperOffset.coerceIn(0f, maxHopperOffset) + } + if (!preferences.hideBottomNavOnScroll().get() || activityBinding?.bottomNav == null) { + updateFilterSheetY() + } + binding.roundedCategoryHopper.upCategory.alpha = if (isAtTop()) 0.25f else 1f + binding.roundedCategoryHopper.downCategory.alpha = if (isAtBottom()) 0.25f else 1f } - if (!filter_bottom_sheet.sheetBehavior.isHidden()) { + if (!binding.filterBottomSheet.filterBottomSheet.sheetBehavior.isHidden()) { scrollDistance += abs(dy) if (scrollDistance > scrollDistanceTilHidden) { - filter_bottom_sheet.sheetBehavior?.hide() + binding.filterBottomSheet.filterBottomSheet.sheetBehavior?.hide() scrollDistance = 0f } } else scrollDistance = 0f @@ -227,90 +281,195 @@ class LibraryController( override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { super.onScrollStateChanged(recyclerView, newState) - val recyclerCover = recycler_cover ?: return when (newState) { RecyclerView.SCROLL_STATE_DRAGGING -> { - fast_scroller.showScrollbar() + binding.fastScroller.showScrollbar() } RecyclerView.SCROLL_STATE_IDLE -> { - val shortAnimationDuration = resources?.getInteger( - android.R.integer.config_shortAnimTime - ) ?: 0 - if (!recyclerCover.isClickable) { - category_hopper_frame.animate().translationY( - if (category_hopper_frame.translationY > 25f.dpToPx) 50f.dpToPx - else 0f - ).setDuration(shortAnimationDuration.toLong()).start() - } + updateHopperPosition() } } } } + fun updateFilterSheetY() { + val bottomBar = activityBinding?.bottomNav + if (bottomBar != null) { + if (binding.filterBottomSheet.filterBottomSheet.sheetBehavior.isHidden()) { + val pad = bottomBar.translationY - bottomBar.height + binding.filterBottomSheet.filterBottomSheet.translationY = pad + } else { + binding.filterBottomSheet.filterBottomSheet.translationY = 0f + } + val pad = bottomBar.translationY - bottomBar.height + binding.shadow2.translationY = pad + binding.filterBottomSheet.filterBottomSheet.updatePaddingRelative( + bottom = max( + (-pad).toInt(), + view?.rootWindowInsets?.getBottomGestureInsets() ?: 0 + ) + ) + + val padding = max( + (-pad).toInt(), + view?.rootWindowInsets?.getBottomGestureInsets() ?: 0 + ) + binding.filterBottomSheet.filterBottomSheet.sheetBehavior?.peekHeight = 60.dpToPx + padding + updateHopperY() + binding.fastScroller.updateLayoutParams { + bottomMargin = -pad.toInt() + } + } else { + binding.filterBottomSheet.filterBottomSheet.updatePaddingRelative( + bottom = view?.rootWindowInsets?.getBottomGestureInsets() ?: 0 + ) + updateHopperY() + } + } + + fun updateHopperPosition() { + val shortAnimationDuration = resources?.getInteger( + android.R.integer.config_shortAnimTime + ) ?: 0 + if (preferences.autohideHopper().get()) { + // Flow same snap rules as bottom nav + val closerToHopperBottom = hopperOffset > maxHopperOffset / 2 + val halfWayBottom = activityBinding?.bottomNav?.height?.toFloat()?.div(2) ?: 0f + val closerToBottom = (activityBinding?.bottomNav?.translationY ?: 0f) > halfWayBottom + val atTop = !binding.libraryGridRecycler.recycler.canScrollVertically(-1) + val closerToEdge = + if (preferences.hideBottomNavOnScroll().get() && activityBinding?.bottomNav != null) { + closerToBottom && !atTop + } else { + closerToHopperBottom + } + val end = if (closerToEdge) maxHopperOffset else 0f + val alphaAnimation = ValueAnimator.ofFloat(hopperOffset, end) + alphaAnimation.addUpdateListener { valueAnimator -> + hopperOffset = valueAnimator.animatedValue as Float + updateHopperY() + } + alphaAnimation.addListener( + EndAnimatorListener { + hopperOffset = end + updateHopperY() + } + ) + alphaAnimation.duration = shortAnimationDuration.toLong() + alphaAnimation.start() + } + } + fun saveActiveCategory(category: Category) { activeCategory = category.order val headerItem = getHeader() ?: return - header_title.text = headerItem.category.name + binding.headerTitle.text = headerItem.category.name setActiveCategory() } private fun setActiveCategory() { val currentCategory = presenter.categories.indexOfFirst { - if (presenter.showAllCategories) { - it.order == activeCategory - } else { - presenter.currentCategory == it.id - } + if (presenter.showAllCategories) it.order == activeCategory else presenter.currentCategory == it.id } - if (currentCategory > -1 && category_recycler != null) { - category_recycler.setCategories(currentCategory) - header_title.text = presenter.categories[currentCategory].name - setTitle() + if (currentCategory > -1) { + binding.categoryRecycler.setCategories(currentCategory) + binding.headerTitle.text = presenter.categories[currentCategory].name + setSubtitle() } } fun showMiniBar() { - header_title.visibleIf(showCategoryInTitle) - setTitle() + binding.headerCard.isVisible = showCategoryInTitle + setSubtitle() + } + + private fun setSubtitle() { + if (!singleCategory && presenter.showAllCategories && + !binding.headerTitle.text.isNullOrBlank() && !binding.recyclerCover.isClickable + ) { + activityBinding?.cardToolbar?.subtitle = binding.headerTitle.text.toString() + } else { + activityBinding?.cardToolbar?.subtitle = null + } } fun showCategoryText(name: String) { textAnim?.cancel() - textAnim = jumper_category_text.animate().alpha(0f).setDuration(250L).setStartDelay(2000) + textAnim = binding.jumperCategoryText.animate().alpha(0f).setDuration(250L).setStartDelay( + 2000 + ) textAnim?.start() - jumper_category_text.alpha = 1f - jumper_category_text.text = name + binding.jumperCategoryText.alpha = 1f + binding.jumperCategoryText.text = name } fun isAtTop(): Boolean { return if (presenter.showAllCategories) { - !recycler.canScrollVertically(-1) + !binding.libraryGridRecycler.recycler.canScrollVertically(-1) } else { - getVisibleHeader()?.category?.order == presenter.categories.minBy { it.order }?.order + getVisibleHeader()?.category?.order == presenter.categories.minOfOrNull { it.order } } } fun isAtBottom(): Boolean { return if (presenter.showAllCategories) { - !recycler.canScrollVertically(1) + !binding.libraryGridRecycler.recycler.canScrollVertically(1) } else { - getVisibleHeader()?.category?.order == presenter.categories.maxBy { it.order }?.order + getVisibleHeader()?.category?.order == presenter.categories.maxOfOrNull { it.order } } } private fun showFilterTip() { - if (preferences.shownFilterTutorial().get()) return + if (preferences.shownFilterTutorial().get() || !hasExpanded) return + val activityBinding = activityBinding ?: return val activity = activity ?: return - val icon = activity.bottom_nav.getItemView(R.id.nav_library) ?: return - - filterTooltip = ViewTooltip.on(activity, icon).autoHide(true, 4000).align(ViewTooltip.ALIGN.CENTER) - .position(ViewTooltip.Position.TOP).text(R.string.tap_library_to_show_filters) - .color(view!!.context!!.contextCompatColor(R.color.neko_green)) - .textSize(TypedValue.COMPLEX_UNIT_SP, 15f).textColor(Color.BLACK) + val icon = (activityBinding.bottomNav ?: activityBinding.sideNav)?.getItemView(R.id.nav_library) ?: return + filterTooltip = + ViewTooltip.on(activity, icon).autoHide(false, 0L).align(ViewTooltip.ALIGN.START) + .position(ViewTooltip.Position.TOP).text(R.string.tap_library_to_show_filters) + .color(activity.getResourceColor(R.attr.colorAccent)) + .textSize(TypedValue.COMPLEX_UNIT_SP, 15f).textColor(Color.WHITE).withShadow(false) + .corner(30).arrowWidth(15).arrowHeight(15).distanceWithView(0) filterTooltip?.show() } + private fun showGroupOptions() { + val groupItems = mutableListOf(BY_DEFAULT, BY_TAG, BY_STATUS) + if (presenter.isLoggedIntoTracking) { + groupItems.add(BY_TRACK_STATUS) + } + if (presenter.allCategories.size > 1) { + groupItems.add(UNGROUPED) + } + val items = groupItems.map { id -> + MaterialMenuSheet.MenuSheetItem( + id, + LibraryGroup.groupTypeDrawableRes(id), + LibraryGroup.groupTypeStringRes(id, presenter.allCategories.size > 1) + ) + } + MaterialMenuSheet( + activity!!, + items, + activity!!.getString(R.string.group_library_by), + presenter.groupType + ) { _, item -> + preferences.groupLibraryBy().set(item) + presenter.groupType = item + shouldScrollToTop = true + presenter.getLibrary() + true + }.show() + } + + private fun showDisplayOptions() { + if (displaySheet == null) { + displaySheet = TabbedLibraryDisplaySheet(this) + displaySheet?.show() + } + } + private fun closeTip() { if (filterTooltip != null) { filterTooltip?.close() @@ -319,69 +478,104 @@ class LibraryController( } } + override fun createBinding(inflater: LayoutInflater) = LibraryControllerBinding.inflate(inflater) + + @SuppressLint("ClickableViewAccessibility") override fun onViewCreated(view: View) { super.onViewCreated(view) - view.applyWindowInsetsForRootController(activity!!.bottom_nav) - if (!::presenter.isInitialized) presenter = LibraryPresenter(this) - adapter = LibraryCategoryAdapter(this) + adapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY setRecyclerLayout() - - recycler.manager.spanSizeLookup = ( + binding.libraryGridRecycler.recycler.manager.spanSizeLookup = ( object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { if (libraryLayout == 0) return 1 val item = this@LibraryController.adapter.getItem(position) return if (item is LibraryHeaderItem || (item is LibraryItem && item.manga.isBlank())) { - recycler?.manager?.spanCount ?: 1 + binding.libraryGridRecycler.recycler.manager.spanCount } else { 1 } } } ) - recycler.setHasFixedSize(true) - recycler.adapter = adapter + binding.libraryGridRecycler.recycler.setHasFixedSize(true) + binding.libraryGridRecycler.recycler.adapter = adapter - adapter.fastScroller = fast_scroller - recycler.addOnScrollListener(scrollListener) + adapter.fastScroller = binding.fastScroller + binding.libraryGridRecycler.recycler.addOnScrollListener(scrollListener) - swipe_refresh.setStyle() + binding.swipeRefresh.setStyle() - recycler_cover.setOnClickListener { + binding.recyclerCover.setOnClickListener { showCategories(false) } - category_recycler.onCategoryClicked = { - recycler.itemAnimator = null + binding.categoryRecycler.onCategoryClicked = { scrollToHeader(it) - showCategories(show = false) + showCategories(show = false, closeSearch = true) + } + binding.categoryRecycler.setOnTouchListener { _, _ -> + val searchView = activityBinding?.cardToolbar?.menu?.findItem(R.id.action_search)?.actionView + ?: return@setOnTouchListener false + val imm = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm!!.hideSoftInputFromWindow(searchView.windowToken, 0) + false } - category_recycler.onShowAllClicked = { isChecked -> + binding.categoryRecycler.onShowAllClicked = { isChecked -> preferences.showAllCategories().set(isChecked) presenter.getLibrary() } setupFilterSheet() setUpHopper() + setPreferenceFlows() elevateAppBar = scrollViewWith( - recycler, swipeRefreshLayout = swipe_refresh, + binding.libraryGridRecycler.recycler, + swipeRefreshLayout = binding.swipeRefresh, afterInsets = { insets -> - category_recycler?.updateLayoutParams { - topMargin = recycler?.paddingTop ?: 0 + binding.categoryRecycler.updateLayoutParams { + topMargin = binding.libraryGridRecycler.recycler.paddingTop + } + binding.fastScroller.updateLayoutParams { + topMargin = binding.libraryGridRecycler.recycler.paddingTop } - fast_scroller?.updateLayoutParams { - topMargin = recycler?.paddingTop ?: 0 + binding.headerCard.updateLayoutParams { + topMargin = insets.systemWindowInsetTop + 4.dpToPx } - header_title?.updatePaddingRelative(top = insets.systemWindowInsetTop + 2.dpToPx) }, onLeavingController = { - header_title?.gone() + binding.headerCard.isVisible = false + }, + onBottomNavUpdate = { + updateFilterSheetY() } ) - swipe_refresh.setOnRefreshListener { - swipe_refresh.isRefreshing = false + // Using a double post because when the filter sheet is open it hides the hopper + view.post { + view.post { + updateHopperY() + } + } + setSwipeRefresh() + + ViewCompat.setWindowInsetsAnimationCallback(view, cb) + + if (selectedMangas.isNotEmpty()) { + createActionModeIfNeeded() + } + + if (presenter.libraryItems.isNotEmpty()) { + presenter.restoreLibrary() + } else { + binding.recyclerLayout.alpha = 0f + } + } + + private fun setSwipeRefresh() = with(binding.swipeRefresh) { + setOnRefreshListener { + isRefreshing = false if (!LibraryUpdateService.isRunning()) { when { !presenter.showAllCategories && presenter.groupType == BY_DEFAULT -> { @@ -397,10 +591,11 @@ class LibraryController( .negativeButton(android.R.string.cancel) .listItemsSingleChoice( items = listOf( - view.context.getString( - R.string.top_category, presenter.allCategories.first().name + context.getString( + R.string.top_category, + presenter.allCategories.first().name ), - view.context.getString( + context.getString( R.string.categories_in_global_update ) ), @@ -422,80 +617,41 @@ class LibraryController( } } } - - if (selectedMangas.isNotEmpty()) { - createActionModeIfNeeded() - } - - presenter.onRestore() - if (presenter.libraryItems.isNotEmpty()) { - presenter.restoreLibrary() - } else { - recycler_layout.alpha = 0f - presenter.getLibrary() - } } private fun setupFilterSheet() { - filter_bottom_sheet.onCreate(this) + binding.filterBottomSheet.filterBottomSheet.onCreate(this) - filter_bottom_sheet.onGroupClicked = { + binding.filterBottomSheet.filterBottomSheet.onGroupClicked = { when (it) { FilterBottomSheet.ACTION_REFRESH -> onRefresh() FilterBottomSheet.ACTION_FILTER -> onFilterChanged() FilterBottomSheet.ACTION_HIDE_FILTER_TIP -> showFilterTip() - FilterBottomSheet.ACTION_DISPLAY -> DisplayBottomSheet(this).show() + FilterBottomSheet.ACTION_DISPLAY -> showDisplayOptions() FilterBottomSheet.ACTION_EXPAND_COLLAPSE_ALL -> presenter.toggleAllCategoryVisibility() - FilterBottomSheet.ACTION_GROUP_BY -> { - val groupItems = mutableListOf(BY_DEFAULT, BY_TAG, BY_STATUS) - if (presenter.isLoggedIntoTracking) { - groupItems.add(BY_TRACK_STATUS) - } - if (presenter.allCategories.size > 1) { - groupItems.add(UNGROUPED) - } - val items = groupItems.map { id -> - MaterialMenuSheet.MenuSheetItem( - id, - LibraryGroup.groupTypeDrawableRes(id), - LibraryGroup.groupTypeStringRes(id, presenter.allCategories.size > 1) - ) - } - MaterialMenuSheet( - activity!!, - items, - activity!!.getString(R.string.group_library_by), - presenter.groupType - ) { _, item -> - preferences.groupLibraryBy().set(item) - presenter.groupType = item - shouldScrollToTop = true - presenter.getLibrary() - true - }.show() - } + FilterBottomSheet.ACTION_GROUP_BY -> showGroupOptions() } } } - @SuppressLint("RtlHardcoded") + @SuppressLint("RtlHardcoded", "ClickableViewAccessibility") private fun setUpHopper() { - category_hopper_frame.gone() - down_category.setOnClickListener { + binding.categoryHopperFrame.isVisible = false + binding.roundedCategoryHopper.downCategory.setOnClickListener { jumpToNextCategory(true) } - up_category.setOnClickListener { + binding.roundedCategoryHopper.upCategory.setOnClickListener { jumpToNextCategory(false) } - down_category.setOnLongClickListener { - recycler.scrollToPosition(adapter.itemCount - 1) + binding.roundedCategoryHopper.downCategory.setOnLongClickListener { + binding.libraryGridRecycler.recycler.scrollToPosition(adapter.itemCount - 1) true } - up_category.setOnLongClickListener { - recycler.scrollToPosition(0) + binding.roundedCategoryHopper.upCategory.setOnLongClickListener { + binding.libraryGridRecycler.recycler.smoothScrollToTop() true } - category_button.setOnClickListener { + binding.roundedCategoryHopper.categoryButton.setOnClickListener { val items = presenter.categories.map { category -> MaterialMenuSheet.MenuSheetItem(category.order, text = category.name) } @@ -511,8 +667,16 @@ class LibraryController( }.show() } - category_button.setOnLongClickListener { - activity?.toolbar?.menu?.performIdentifierAction(R.id.action_search, 0) + binding.roundedCategoryHopper.categoryButton.setOnLongClickListener { + when (preferences.hopperLongPressAction().get()) { + 3 -> showGroupOptions() + 2 -> showDisplayOptions() + 1 -> if (canCollapseOrExpandCategory() != null) presenter.toggleAllCategoryVisibility() + else -> activityBinding?.cardToolbar?.menu?.performIdentifierAction( + R.id.action_search, + 0 + ) + } true } @@ -522,8 +686,8 @@ class LibraryController( preferences.hopperGravity().get() } hideHopper(preferences.hideHopper().get()) - category_hopper_frame.updateLayoutParams { - anchorGravity = Gravity.TOP or when (gravityPref) { + binding.categoryHopperFrame.updateLayoutParams { + gravity = Gravity.TOP or when (gravityPref) { 0 -> Gravity.LEFT 2 -> Gravity.RIGHT else -> Gravity.CENTER @@ -532,16 +696,62 @@ class LibraryController( hopperGravity = gravityPref val gestureDetector = GestureDetectorCompat(activity, LibraryGestureDetector(this)) - listOf(category_hopper_layout, up_category, down_category, category_button).forEach { - it.setOnTouchListener { _, event -> - gestureDetector.onTouchEvent(event) + with(binding.roundedCategoryHopper) { + listOf(categoryHopperLayout, upCategory, downCategory, categoryButton).forEach { + it.setOnTouchListener { _, event -> + if (event?.action == MotionEvent.ACTION_DOWN) { + animatorSet?.end() + } + if (event?.action == MotionEvent.ACTION_UP) { + val result = gestureDetector.onTouchEvent(event) + if (!result) { + binding.categoryHopperFrame.animate().setDuration(150L).translationX(0f) + .start() + } + result + } else { + gestureDetector.onTouchEvent(event) + } + } } } } + fun updateHopperY(windowInsets: WindowInsets? = null) { + val view = view ?: return + val insets = windowInsets ?: view.rootWindowInsets + val listOfYs = mutableListOf( + binding.filterBottomSheet.filterBottomSheet.y, + activityBinding?.bottomNav?.y ?: binding.filterBottomSheet.filterBottomSheet.y + ) + val insetBottom = insets?.systemWindowInsetBottom ?: 0 + if (!preferences.autohideHopper().get() || activityBinding?.bottomNav == null) { + listOfYs.add(view.height - (insetBottom).toFloat()) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && insets?.isImeVisible() == true) { + val insetKey = insets.getInsets(WindowInsets.Type.ime() or WindowInsets.Type.systemBars()).bottom + listOfYs.add(view.height - (insetKey).toFloat()) + } + binding.categoryHopperFrame.y = -binding.categoryHopperFrame.height + + (listOfYs.minOrNull() ?: binding.filterBottomSheet.filterBottomSheet.y) + + hopperOffset + + binding.libraryGridRecycler.recycler.translationY + if (view.height - insetBottom < binding.categoryHopperFrame.y) { + binding.jumperCategoryText.translationY = + -(binding.categoryHopperFrame.y - (view.height - insetBottom)) + + binding.libraryGridRecycler.recycler.translationY + } else { + binding.jumperCategoryText.translationY = binding.libraryGridRecycler.recycler.translationY + } + } + + fun resetHopperY() { + hopperOffset = 0f + } + fun hideHopper(hide: Boolean) { - category_hopper_frame.visibleIf(!hide) - jumper_category_text.visibleIf(!hide) + binding.categoryHopperFrame.isVisible = !hide + binding.jumperCategoryText.isVisible = !hide } private fun jumpToNextCategory(next: Boolean) { @@ -549,7 +759,7 @@ class LibraryController( if (presenter.showAllCategories) { if (!next) { val fPosition = - (recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() + (binding.libraryGridRecycler.recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() if (fPosition != adapter.currentItems.indexOf(category)) { scrollToHeader(category.category.order) return @@ -562,15 +772,15 @@ class LibraryController( scrollToHeader(newOrder) showCategoryText(newCategory.name) } else { - recycler.scrollToPosition(if (next) adapter.itemCount - 1 else 0) + binding.libraryGridRecycler.recycler.scrollToPosition(if (next) adapter.itemCount - 1 else 0) } } else { val newOffset = presenter.categories.indexOfFirst { presenter.currentCategory == it.id } + (if (next) 1 else -1) if (if (!next) { - newOffset > -1 - } else { + newOffset > -1 + } else { newOffset < presenter.categories.size } ) { @@ -584,7 +794,7 @@ class LibraryController( private fun getHeader(firstCompletelyVisible: Boolean = false): LibraryHeaderItem? { val position = if (firstCompletelyVisible) { - (recycler.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() + (binding.libraryGridRecycler.recycler.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() } else { -1 } @@ -595,7 +805,7 @@ class LibraryController( } } else { val fPosition = - (recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() + (binding.libraryGridRecycler.recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() when (val item = adapter.getItem(fPosition)) { is LibraryHeaderItem -> return item is LibraryItem -> return item.header @@ -606,7 +816,7 @@ class LibraryController( private fun getVisibleHeader(): LibraryHeaderItem? { val fPosition = - (recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() + (binding.libraryGridRecycler.recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() when (val item = adapter.getItem(fPosition)) { is LibraryHeaderItem -> return item is LibraryItem -> return item.header @@ -614,15 +824,11 @@ class LibraryController( return adapter.headerItems.firstOrNull() as? LibraryHeaderItem } - override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.library_list_controller, container, false) - } - - private fun anchorView(): View? { - return if (category_hopper_frame.isVisible()) { - category_hopper_frame + private fun anchorView(): View { + return if (binding.categoryHopperFrame.isVisible) { + binding.categoryHopperFrame } else { - filter_bottom_sheet + binding.filterBottomSheet.filterBottomSheet } } @@ -634,60 +840,104 @@ class LibraryController( view.elevation = 15f.dpToPx setAction(R.string.cancel) { LibraryUpdateService.stop(context) - Handler().post { NotificationReceiver.dismissNotification(context, Notifications.ID_LIBRARY_PROGRESS) } + Handler().post { + NotificationReceiver.dismissNotification( + context, + Notifications.ID_LIBRARY_PROGRESS + ) + } } } } private fun setRecyclerLayout() { - recycler.updatePaddingRelative(bottom = 50.dpToPx) - if (libraryLayout == 0) { - recycler.spanCount = 1 - recycler.updatePaddingRelative( - start = 0, end = 0 - ) - } else { - recycler.columnWidth = when (preferences.gridSize().getOrDefault()) { - 1 -> 1f - 2 -> 1.25f - 3 -> 1.66f - 4 -> 3f - else -> .75f + with(binding.libraryGridRecycler.recycler) { + post { + updatePaddingRelative( + bottom = 50.dpToPx + (activityBinding?.bottomNav?.height ?: 0) + ) } - recycler.updatePaddingRelative( - start = 5.dpToPx, - end = 5.dpToPx - ) + if (libraryLayout == 0) { + spanCount = 1 + updatePaddingRelative( + start = 0, + end = 0 + ) + } else { + setGridSize(preferences) + updatePaddingRelative( + start = 5.dpToPx, + end = 5.dpToPx + ) + } + } + } + + private fun setPreferenceFlows() { + listOf( + preferences.libraryLayout(), + preferences.uniformGrid(), + preferences.gridSize() + ).forEach { + it.asFlow() + .drop(1) + .onEach { + reattachAdapter() + } + .launchIn(viewScope) } + preferences.hideStartReadingButton().asFlow() + .drop(1) + .onEach { + adapter.notifyDataSetChanged() + } + .launchIn(viewScope) } override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { super.onChangeStarted(handler, type) if (type.isEnter) { - view?.applyWindowInsetsForRootController(activity!!.bottom_nav) - presenter.getLibrary() + binding.filterBottomSheet.filterBottomSheet.isVisible = true + if (type == ControllerChangeType.POP_ENTER) { + presenter.getLibrary() + } DownloadService.callListeners() LibraryUpdateService.setListener(this) - recycler_cover.isClickable = false - recycler_cover.isFocusable = false - showDropdown() + binding.recyclerCover.isClickable = false + binding.recyclerCover.isFocusable = false + singleCategory = presenter.categories.size <= 1 + + if (preferences.showLibrarySearchSuggestions().get()) { + activityBinding?.cardToolbar?.setOnLongClickListener { + val suggestion = preferences.librarySearchSuggestion().get() + if (suggestion.isNotBlank()) { + val searchItem = + activityBinding?.cardToolbar?.menu?.findItem(R.id.action_search) + val searchView = searchItem?.actionView as? SearchView + ?: return@setOnLongClickListener false + searchItem.expandActionView() + searchView.setQuery(suggestion, false) + true + } else { + false + } + } + } } else { + updateFilterSheetY() closeTip() - activity?.toolbar?.hideDropdown() - } - } - - override fun onChangeEnded(handler: ControllerChangeHandler, type: ControllerChangeType) { - super.onChangeEnded(handler, type) - if (!type.isEnter) { - activity?.toolbar?.hideDropdown() + if (binding.filterBottomSheet.filterBottomSheet.sheetBehavior.isHidden()) { + binding.filterBottomSheet.filterBottomSheet.isInvisible = true + } + activityBinding?.cardToolbar?.setOnLongClickListener(null) } } override fun onActivityResumed(activity: Activity) { super.onActivityResumed(activity) - if (view == null) return - if (observeLater && ::presenter.isInitialized) { + if (!isBindingInitialized) return + updateFilterSheetY() + if (observeLater) { presenter.getLibrary() } } @@ -695,18 +945,16 @@ class LibraryController( override fun onActivityPaused(activity: Activity) { super.onActivityPaused(activity) observeLater = true - if (::presenter.isInitialized) presenter.onDestroy() - } - - override fun onDestroy() { - if (::presenter.isInitialized) presenter.onDestroy() - super.onDestroy() } override fun onDestroyView(view: View) { LibraryUpdateService.removeListener(this) destroyActionModeIfNeeded() - recycler.removeOnScrollListener(scrollListener) + if (isBindingInitialized) { + binding.libraryGridRecycler.recycler.removeOnScrollListener(scrollListener) + } + displaySheet?.dismiss() + displaySheet = null super.onDestroyView(view) } @@ -714,53 +962,47 @@ class LibraryController( view ?: return destroyActionModeIfNeeded() if (mangaMap.isNotEmpty()) { - empty_view?.hide() + binding.emptyView.hide() } else { - empty_view?.show( + binding.emptyView.show( CommunityMaterial.Icon2.cmd_heart_off, - if (filter_bottom_sheet.hasActiveFilters()) R.string.no_matches_for_filters + if (binding.filterBottomSheet.filterBottomSheet.hasActiveFilters()) R.string.no_matches_for_filters else R.string.library_is_empty_add_from_browse ) } adapter.setItems(mangaMap) - if (recycler.itemAnimator == null) - recycler.post { - recycler.itemAnimator = DefaultItemAnimator() - } singleCategory = presenter.categories.size <= 1 - showDropdown() - progress.gone() + binding.progress.isVisible = false if (!freshStart) { justStarted = false - if (recycler_layout.alpha == 0f) recycler_layout.animate().alpha(1f).setDuration(500) - .start() - } else recycler_layout.alpha = 1f + } // else binding.recyclerLayout.alpha = 1f + if (binding.recyclerLayout.alpha == 0f) { + binding.recyclerLayout.animate().alpha(1f).setDuration(500).start() + } if (justStarted && freshStart) { scrollToHeader(activeCategory) } - recycler?.post { - elevateAppBar(recycler?.canScrollVertically(-1) == true) + binding.libraryGridRecycler.recycler.post { + elevateAppBar(binding.libraryGridRecycler.recycler.canScrollVertically(-1)) setActiveCategory() } - category_hopper_frame.visibleIf(!singleCategory && !preferences.hideHopper().get()) - filter_bottom_sheet.updateButtons( - showExpand = !singleCategory && presenter.showAllCategories, groupType = presenter.groupType - ) + binding.categoryHopperFrame.isVisible = !singleCategory && !preferences.hideHopper().get() adapter.isLongPressDragEnabled = canDrag() - category_recycler.setCategories(presenter.categories) - filter_bottom_sheet.setExpandText(preferences.collapsedCategories().getOrDefault().isNotEmpty()) + binding.categoryRecycler.setCategories(presenter.categories) + with(binding.filterBottomSheet.root) { + updateGroupTypeButton(presenter.groupType) + setExpandText(canCollapseOrExpandCategory()) + } if (shouldScrollToTop) { - recycler.scrollToPosition(0) + binding.libraryGridRecycler.recycler.scrollToPosition(0) shouldScrollToTop = false } if (onRoot) { - listOf(activity?.toolbar, header_title).forEach { + listOf(activityBinding?.toolbar, binding.headerTitle).forEach { it?.setOnClickListener { - val recycler = recycler ?: return@setOnClickListener - if (singleCategory) { - recycler.scrollToPosition(0) - } else { + val recycler = binding.libraryGridRecycler.recycler + if (!singleCategory) { showCategories(recycler.translationY == 0f) } } @@ -768,24 +1010,16 @@ class LibraryController( showSlideAnimation() } } + setSubtitle() showMiniBar() } } - private fun showDropdown() { - if (onRoot) { - if (!singleCategory) { - activity?.toolbar?.showDropdown() - } else { - activity?.toolbar?.hideDropdown() - } - } - } - private fun showSlideAnimation() { isAnimatingHopper = true val slide = 25f.dpToPx val animatorSet = AnimatorSet() + this.animatorSet = animatorSet val animations = listOf( slideAnimation(0f, slide, 200), slideAnimation(slide, -slide), @@ -797,43 +1031,49 @@ class LibraryController( animatorSet.startDelay = 1250 animatorSet.addListener( EndAnimatorListener { + binding.categoryHopperFrame.translationX = 0f isAnimatingHopper = false + this.animatorSet = null } ) animatorSet.start() } private fun slideAnimation(from: Float, to: Float, duration: Long = 400): ObjectAnimator { - return ObjectAnimator.ofFloat(category_hopper_frame, View.TRANSLATION_X, from, to) + return ObjectAnimator.ofFloat(binding.categoryHopperFrame, View.TRANSLATION_X, from, to) .setDuration(duration) } - private fun showCategories(show: Boolean) { - recycler_cover.isClickable = show - recycler_cover.isFocusable = show - val full = category_recycler.height.toFloat() + recycler.paddingTop + private fun showCategories(show: Boolean, closeSearch: Boolean = false) { + binding.recyclerCover.isClickable = show + binding.recyclerCover.isFocusable = show + if (closeSearch) { + activityBinding?.cardToolbar?.menu?.findItem(R.id.action_search)?.collapseActionView() + } + val full = binding.categoryRecycler.height.toFloat() + binding.libraryGridRecycler.recycler.paddingTop val translateY = if (show) full else 0f - recycler.animate().translationY(translateY).apply { + binding.libraryGridRecycler.recycler.animate().translationY(translateY).apply { setUpdateListener { - activity?.appbar?.y = 0f + activityBinding?.appBar?.y = 0f + updateHopperY() } }.start() - category_hopper_frame.animate().translationY(translateY).start() - recycler_shadow.animate().translationY(translateY - 8.dpToPx).start() - recycler_cover.animate().translationY(translateY).start() - recycler_cover.animate().alpha(if (show) 0.75f else 0f).start() - recycler.suppressLayout(show) - activity?.toolbar?.showDropdown(!show) - swipe_refresh.isEnabled = !show - setTitle() + binding.recyclerShadow.animate().translationY(translateY - 8.dpToPx).start() + binding.recyclerCover.animate().translationY(translateY).start() + binding.recyclerCover.animate().alpha(if (show) 0.75f else 0f).start() + binding.libraryGridRecycler.recycler.suppressLayout(show) + binding.swipeRefresh.isEnabled = !show + setSubtitle() if (show) { - category_recycler.scrollToCategory(activeCategory) - fast_scroller?.hideScrollbar() - activity?.appbar?.y = 0f + binding.categoryRecycler.post { + binding.categoryRecycler.scrollToCategory(activeCategory) + } + binding.fastScroller.hideScrollbar() + activityBinding?.appBar?.y = 0f elevateAppBar(false) - filter_bottom_sheet?.sheetBehavior?.hide() + binding.filterBottomSheet.filterBottomSheet.sheetBehavior?.hide() } else { - val notAtTop = recycler.canScrollVertically(-1) + val notAtTop = binding.libraryGridRecycler.recycler.canScrollVertically(-1) elevateAppBar(notAtTop) } } @@ -848,15 +1088,15 @@ class LibraryController( } val headerPosition = adapter.indexOf(pos) if (headerPosition > -1) { - val appbar = activity?.appbar - recycler.suppressLayout(true) + val appbar = activityBinding?.appBar + binding.libraryGridRecycler.recycler.suppressLayout(true) val appbarOffset = if (appbar?.y ?: 0f > -20) 0 else ( appbar?.y?.plus( view?.rootWindowInsets?.systemWindowInsetTop ?: 0 ) ?: 0f ).roundToInt() + 30.dpToPx val previousHeader = adapter.getItem(adapter.indexOf(pos - 1)) as? LibraryHeaderItem - (recycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset( + (binding.libraryGridRecycler.recycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset( headerPosition, ( when { @@ -871,7 +1111,7 @@ class LibraryController( } activeCategory = pos preferences.lastUsedCategory().set(pos) - recycler.suppressLayout(false) + binding.libraryGridRecycler.recycler.suppressLayout(false) } } @@ -891,20 +1131,29 @@ class LibraryController( destroyActionModeIfNeeded() } - fun reattachAdapter() { - libraryLayout = preferences.libraryLayout().getOrDefault() + private fun reattachAdapter() { + libraryLayout = preferences.libraryLayout().get() setRecyclerLayout() val position = - (recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() - recycler.adapter = adapter + (binding.libraryGridRecycler.recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() + binding.libraryGridRecycler.recycler.adapter = adapter - (recycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(position, 0) + (binding.libraryGridRecycler.recycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset( + position, + 0 + ) } fun search(query: String?): Boolean { + if (query != this.query && !query.isNullOrBlank()) { + binding.libraryGridRecycler.recycler.scrollToPosition(0) + } this.query = query ?: "" + adapter.setFilter(query) - adapter.performFilter() + viewScope.launchUI { + adapter.performFilterAsync() + } return true } @@ -933,7 +1182,7 @@ class LibraryController( } positions.forEach { position -> adapter.addSelection(position) - (recycler.findViewHolderForAdapterPosition(position) as? LibraryHolder)?.toggleActivation() + (binding.libraryGridRecycler.recycler.findViewHolderForAdapterPosition(position) as? LibraryHolder)?.toggleActivation() } } } else { @@ -946,7 +1195,7 @@ class LibraryController( } positions.forEach { position -> adapter.removeSelection(position) - (recycler.findViewHolderForAdapterPosition(position) as? LibraryHolder)?.toggleActivation() + (binding.libraryGridRecycler.recycler.findViewHolderForAdapterPosition(position) as? LibraryHolder)?.toggleActivation() } } } @@ -959,7 +1208,7 @@ class LibraryController( if (changedMode) { adapter.notifyItemChanged(it) } else { - (recycler.findViewHolderForAdapterPosition(it) as? LibraryHeaderHolder)?.setSelection() + (binding.libraryGridRecycler.recycler.findViewHolderForAdapterPosition(it) as? LibraryHeaderHolder)?.setSelection() } } } @@ -985,8 +1234,7 @@ class LibraryController( } override fun canDrag(): Boolean { - filter_bottom_sheet ?: return false - val filterOff = !filter_bottom_sheet.hasActiveFilters() && presenter.groupType == BY_DEFAULT + val filterOff = !binding.filterBottomSheet.filterBottomSheet.hasActiveFilters() && presenter.groupType == BY_DEFAULT return filterOff && adapter.mode != SelectableAdapter.Mode.MULTI } @@ -1036,16 +1284,16 @@ class LibraryController( } override fun onActionStateChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { - val position = viewHolder?.adapterPosition ?: return - swipe_refresh.isEnabled = actionState != ItemTouchHelper.ACTION_STATE_DRAG + val position = viewHolder?.bindingAdapterPosition ?: return + binding.swipeRefresh.isEnabled = actionState != ItemTouchHelper.ACTION_STATE_DRAG if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) { - if (lastItemPosition != null && position != lastItemPosition && lastItem == adapter.getItem( - position - ) + if (lastItemPosition != null && + position != lastItemPosition && + lastItem == adapter.getItem(position) ) { // because for whatever reason you can repeatedly tap on a currently dragging manga adapter.removeSelection(position) - (recycler.findViewHolderForAdapterPosition(position) as? LibraryHolder)?.toggleActivation() + (binding.libraryGridRecycler.recycler.findViewHolderForAdapterPosition(position) as? LibraryHolder)?.toggleActivation() adapter.moveItem(position, lastItemPosition!!) } else { lastItem = adapter.getItem(position) @@ -1055,9 +1303,9 @@ class LibraryController( } } - override fun onUpdateManga(manga: LibraryManga) { - if (manga.id == null) adapter.notifyDataSetChanged() - else presenter.updateManga(manga) + override fun onUpdateManga(manga: Manga?) { + if (manga == null) adapter.notifyDataSetChanged() + else presenter.updateManga() } private fun setSelection(position: Int, selected: Boolean = true) { @@ -1070,13 +1318,15 @@ class LibraryController( override fun onItemMove(fromPosition: Int, toPosition: Int) { // Because padding a recycler causes it to scroll up we have to scroll it back down... wild if (( - adapter.getItem(fromPosition) is LibraryItem && adapter.getItem(fromPosition) is - LibraryItem - ) || adapter.getItem( - fromPosition - ) == null + adapter.getItem(fromPosition) is LibraryItem && + adapter.getItem(fromPosition) is LibraryItem + ) || + adapter.getItem(fromPosition) == null ) { - recycler.scrollBy(0, recycler.paddingTop) + binding.libraryGridRecycler.recycler.scrollBy( + 0, + binding.libraryGridRecycler.recycler.paddingTop + ) } if (lastItemPosition == toPosition) lastItemPosition = null else if (lastItemPosition == null) lastItemPosition = fromPosition @@ -1089,7 +1339,8 @@ class LibraryController( if (toPosition < 1) return false return (adapter.getItem(toPosition) !is LibraryHeaderItem) && ( newHeader?.category?.id == item.manga.category || !presenter.mangaIsInCategory( - item.manga, newHeader?.category?.id + item.manga, + newHeader?.category?.id ) ) } @@ -1120,7 +1371,9 @@ class LibraryController( return } if (newHeader?.category != null) moveMangaToCategory( - item.manga, newHeader.category, mangaIds + item.manga, + newHeader.category, + mangaIds ) } lastItemPosition = null @@ -1147,8 +1400,8 @@ class LibraryController( } } - override fun updateCategory(position: Int): Boolean { - val category = (adapter.getItem(position) as? LibraryHeaderItem)?.category ?: return false + override fun updateCategory(catId: Int): Boolean { + val category = (adapter.getItem(catId) as? LibraryHeaderItem)?.category ?: return false val inQueue = LibraryUpdateService.categoryInQueue(category.id) snack?.dismiss() snack = view?.snack( @@ -1175,7 +1428,8 @@ class LibraryController( } } if (!inQueue) LibraryUpdateService.start( - view!!.context, category, + view!!.context, + category, mangaToUse = if (category.isDynamic) { presenter.getMangaInCategories(category.id) } else null @@ -1192,15 +1446,39 @@ class LibraryController( presenter.toggleCategoryVisibility(catId) } + /** + * Nullable Boolean to tell is all is collapsed/expanded/applicable + * true = all categories are expanded + * false = all or some categories are collapsed + * null = is in single category mode + */ + fun canCollapseOrExpandCategory(): Boolean? { + if (singleCategory || !presenter.showAllCategories) { + return null + } + return presenter.allCategoriesExpanded() + } + override fun manageCategory(position: Int) { val category = (adapter.getItem(position) as? LibraryHeaderItem)?.category ?: return if (!category.isDynamic) { - ManageCategoryDialog(this, category).showDialog(router) + ManageCategoryDialog(category) { + presenter.getLibrary() + }.showDialog(router) } } - override fun sortCategory(catId: Int, sortBy: Int) { - presenter.sortCategory(catId, sortBy) + override fun sortCategory(catId: Int, sortBy: Char) { + val category = presenter.categories.find { it.id == catId } + if (category?.isDynamic == false && sortBy == LibrarySort.DragAndDrop.categoryValue) { + val item = adapter.findCategoryHeader(catId) ?: return + val libraryItems = adapter.getSectionItems(item) + .filterIsInstance() + val mangaIds = libraryItems.mapNotNull { (it as? LibraryItem)?.manga?.id } + presenter.rearrangeCategory(catId, mangaIds) + } else { + presenter.sortCategory(catId, sortBy) + } } override fun selectAll(position: Int) { @@ -1220,30 +1498,30 @@ class LibraryController( override fun showSheet() { closeTip() when { - filter_bottom_sheet.sheetBehavior.isHidden() -> filter_bottom_sheet.sheetBehavior?.collapse() - !filter_bottom_sheet.sheetBehavior.isExpanded() -> filter_bottom_sheet.sheetBehavior?.expand() - else -> DisplayBottomSheet(this).show() + binding.filterBottomSheet.filterBottomSheet.sheetBehavior.isHidden() -> binding.filterBottomSheet.filterBottomSheet.sheetBehavior?.collapse() + !binding.filterBottomSheet.filterBottomSheet.sheetBehavior.isExpanded() -> binding.filterBottomSheet.filterBottomSheet.sheetBehavior?.expand() + else -> showDisplayOptions() } } override fun toggleSheet() { closeTip() when { - filter_bottom_sheet.sheetBehavior.isHidden() -> filter_bottom_sheet.sheetBehavior?.collapse() - !filter_bottom_sheet.sheetBehavior.isExpanded() -> filter_bottom_sheet.sheetBehavior?.expand() - else -> filter_bottom_sheet.sheetBehavior?.hide() + binding.filterBottomSheet.filterBottomSheet.sheetBehavior.isHidden() -> binding.filterBottomSheet.filterBottomSheet.sheetBehavior?.collapse() + !binding.filterBottomSheet.filterBottomSheet.sheetBehavior.isExpanded() -> binding.filterBottomSheet.filterBottomSheet.sheetBehavior?.expand() + else -> binding.filterBottomSheet.filterBottomSheet.sheetBehavior?.hide() } } override fun sheetIsExpanded(): Boolean = false override fun handleSheetBack(): Boolean { - if (recycler_cover.isClickable) { + if (binding.recyclerCover.isClickable) { showCategories(false) return true } - if (filter_bottom_sheet.sheetBehavior.isExpanded()) { - filter_bottom_sheet.sheetBehavior?.collapse() + if (binding.filterBottomSheet.filterBottomSheet.sheetBehavior.isExpanded()) { + binding.filterBottomSheet.filterBottomSheet.sheetBehavior?.collapse() return true } return false @@ -1253,6 +1531,7 @@ class LibraryController( //region Toolbar options methods override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { inflater.inflate(R.menu.library, menu) + val searchItem = menu.findItem(R.id.action_search) val searchView = searchItem.actionView as SearchView searchView.queryHint = resources?.getString(R.string.library_search_hint) @@ -1262,15 +1541,41 @@ class LibraryController( searchItem.expandActionView() searchView.setQuery(query, true) searchView.clearFocus() + search(query) } - setOnQueryTextChangeListener(searchView) { search(it) } + setOnQueryTextChangeListener(searchView) { + if (!it.isNullOrEmpty() && binding.recyclerCover.isClickable) { + showCategories(false) + } + search(it) + } + searchItem.fixExpand( + onExpand = { + if (!binding.recyclerCover.isClickable && query.isBlank() && + !singleCategory && presenter.showAllCategories + ) { + showCategories(true) + } + invalidateMenuOnExpand() + }, + onCollapse = { + if (binding.recyclerCover.isClickable) { + showCategories(false) + } + true + } + ) + hideItemsIfExpanded(searchItem, menu) } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { - R.id.action_search -> item.expandActionView() - R.id.action_display_options -> DisplayBottomSheet(this).show() + R.id.action_search -> expandActionViewFromInteraction = true + R.id.action_filter -> { + hasExpanded = true + binding.filterBottomSheet.filterBottomSheet.sheetBehavior?.expand() + } else -> return super.onOptionsItemSelected(item) } return true @@ -1292,6 +1597,11 @@ class LibraryController( } } + fun showCategoriesController() { + router.pushController(CategoryController().withFadeTransaction()) + displaySheet?.dismiss() + } + /** * Destroys the action mode. */ @@ -1314,10 +1624,10 @@ class LibraryController( override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { val count = selectedMangas.size // Destroy action mode if there are no items selected. + val shareItem = menu.findItem(R.id.action_share) val categoryItem = menu.findItem(R.id.action_move_to_category) categoryItem.isVisible = presenter.categories.size > 1 - val shareItem = menu.findItem(R.id.action_share) - shareItem.isVisible = selectedMangas.isNotEmpty() + shareItem.isVisible = true if (count == 0) destroyActionModeIfNeeded() else mode.title = resources?.getString(R.string.selected_, count) return false @@ -1326,7 +1636,7 @@ class LibraryController( override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { when (item.itemId) { - R.id.action_move_to_category -> showChangeMangaCategoriesDialog() + R.id.action_move_to_category -> showChangeMangaCategoriesSheet() R.id.action_share -> shareManga() R.id.action_delete -> { MaterialDialog(activity!!).message(R.string.remove_from_library_question) @@ -1373,7 +1683,8 @@ class LibraryController( destroyActionModeIfNeeded() snack?.dismiss() snack = view?.snack( - activity?.getString(R.string.removed_from_library) ?: "", Snackbar.LENGTH_INDEFINITE + activity?.getString(R.string.removed_from_library) ?: "", + Snackbar.LENGTH_INDEFINITE ) { anchorView = anchorView() view.elevation = 15f.dpToPx @@ -1382,37 +1693,26 @@ class LibraryController( presenter.reAddMangas(mangas) undoing = true } - addCallback(object : BaseTransientBottomBar.BaseCallback() { - override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { - super.onDismissed(transientBottomBar, event) - if (!undoing) presenter.confirmDeletion(mangas) + addCallback( + object : BaseTransientBottomBar.BaseCallback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + super.onDismissed(transientBottomBar, event) + if (!undoing) presenter.confirmDeletion(mangas) + } } - }) + ) } (activity as? MainActivity)?.setUndoSnackBar(snack) } - override fun updateCategoriesForMangas(mangas: List, categories: List) { - presenter.moveMangasToCategories(categories, mangas) - destroyActionModeIfNeeded() - } - /** * Move the selected manga to a list of categories. */ - private fun showChangeMangaCategoriesDialog() { - // Create a copy of selected manga - val mangas = selectedMangas.toList() - - // Hide the default category because it has a different behavior than the ones from db. - val categories = presenter.allCategories.filter { it.id != 0 } - - // Get indexes of the common categories to preselect. - val commonCategoriesIndexes = - presenter.getCommonCategories(mangas).map { categories.indexOf(it) }.toTypedArray() - - ChangeMangaCategoriesDialog(this, mangas, categories, commonCategoriesIndexes).showDialog( - router - ) + private fun showChangeMangaCategoriesSheet() { + val activity = activity ?: return + selectedMangas.toList().moveCategories(presenter.db, activity) { + presenter.getLibrary() + destroyActionModeIfNeeded() + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryGestureDetector.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryGestureDetector.kt index 226359978e..1d59b95ca6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryGestureDetector.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryGestureDetector.kt @@ -1,15 +1,15 @@ package eu.kanade.tachiyomi.ui.library +import android.annotation.SuppressLint import android.view.GestureDetector import android.view.Gravity import android.view.MotionEvent import androidx.coordinatorlayout.widget.CoordinatorLayout -import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.util.view.hide import eu.kanade.tachiyomi.util.view.updateLayoutParams -import kotlinx.android.synthetic.main.filter_bottom_sheet.* -import kotlinx.android.synthetic.main.library_list_controller.* import kotlin.math.abs +import kotlin.math.pow +import kotlin.math.sign class LibraryGestureDetector(private val controller: LibraryController) : GestureDetector .SimpleOnGestureListener() { @@ -17,6 +17,23 @@ class LibraryGestureDetector(private val controller: LibraryController) : Gestur return false } + override fun onScroll( + e1: MotionEvent?, + e2: MotionEvent?, + distanceX: Float, + distanceY: Float + ): Boolean { + val distance = ((e1?.rawX ?: 0f) - (e2?.rawX ?: 0f)) / 50 + val poa = 1.7f + controller.binding.categoryHopperFrame.translationX = abs(distance).pow(poa) * -sign(distance) + return super.onScroll(e1, e2, distanceX, distanceY) + } + + override fun onSingleTapUp(e: MotionEvent?): Boolean { + return super.onSingleTapUp(e) + } + + @SuppressLint("RtlHardcoded") override fun onFling( e1: MotionEvent, e2: MotionEvent, @@ -26,50 +43,81 @@ class LibraryGestureDetector(private val controller: LibraryController) : Gestur var result = false val diffY = e2.y - e1.y val diffX = e2.x - e1.x - if (abs(diffX) <= abs(diffY) && abs(diffY) > MainActivity.SWIPE_THRESHOLD && abs(velocityY) > MainActivity.SWIPE_VELOCITY_THRESHOLD) { + val hopperFrame = controller.binding.categoryHopperFrame + val animator = controller.binding.categoryHopperFrame.animate().setDuration(150L) + animator.translationX(0f) + animator.withEndAction { + hopperFrame.translationX = 0f + } + if (abs(diffX) <= abs(diffY) && + abs(diffY) > SWIPE_THRESHOLD && + abs(velocityY) > SWIPE_VELOCITY_THRESHOLD + ) { if (diffY <= 0) { controller.showSheet() } else { - controller.filter_bottom_sheet.sheetBehavior?.hide() + controller.binding.filterBottomSheet.filterBottomSheet.sheetBehavior?.hide() } - result = true - } else if (abs(diffX) >= abs(diffY) && abs(diffX) > MainActivity.SWIPE_THRESHOLD * 3 && abs( - velocityX - ) > MainActivity.SWIPE_VELOCITY_THRESHOLD + } else if (abs(diffX) >= abs(diffY) && + abs(diffX) > SWIPE_THRESHOLD * 5 && + abs(velocityX) > SWIPE_VELOCITY_THRESHOLD ) { + val hopperGravity = (controller.binding.categoryHopperFrame.layoutParams as CoordinatorLayout.LayoutParams).gravity if (diffX <= 0) { - controller.category_hopper_frame.updateLayoutParams { - anchorGravity = - Gravity.TOP or ( - if (anchorGravity == Gravity.TOP or Gravity.RIGHT) { - controller.preferences.hopperGravity().set(1) - Gravity.CENTER - } else { - controller.preferences.hopperGravity().set(0) - Gravity.LEFT - } - ) + animator.translationX( + if (hopperGravity == Gravity.TOP or Gravity.LEFT) 0f + else (-(controller.view!!.width - controller.binding.categoryHopperFrame.width) / 2).toFloat() + ).withEndAction { + hopperFrame.updateLayoutParams { + gravity = + Gravity.TOP or ( + if (gravity == Gravity.TOP or Gravity.RIGHT) { + controller.preferences.hopperGravity().set(1) + Gravity.CENTER + } else { + controller.preferences.hopperGravity().set(0) + Gravity.LEFT + } + ) + } + savePrefs() } } else { - controller.category_hopper_frame.updateLayoutParams { - anchorGravity = - Gravity.TOP or Gravity.TOP or ( - if (anchorGravity == Gravity.TOP or Gravity.LEFT) { - controller.preferences.hopperGravity().set(1) - Gravity.CENTER - } else { - controller.preferences.hopperGravity().set(2) - Gravity.RIGHT - } - ) + animator.translationX( + if (hopperGravity == Gravity.TOP or Gravity.RIGHT) 0f + else ((controller.view!!.width - hopperFrame.width) / 2).toFloat() + ).withEndAction { + hopperFrame.updateLayoutParams { + gravity = + Gravity.TOP or ( + if (gravity == Gravity.TOP or Gravity.LEFT) { + controller.preferences.hopperGravity().set(1) + Gravity.CENTER + } else { + controller.preferences.hopperGravity().set(2) + Gravity.RIGHT + } + ) + } + savePrefs() } } - if (!controller.hasMovedHopper) { - controller.preferences.shownHopperSwipeTutorial().set(true) - } - controller.hopperGravity = controller.preferences.hopperGravity().get() result = true } + animator.start() return result } + + private fun savePrefs() { + if (!controller.hasMovedHopper) { + controller.preferences.shownHopperSwipeTutorial().set(true) + } + controller.hopperGravity = controller.preferences.hopperGravity().get() + controller.binding.categoryHopperFrame.translationX = 0f + } + + private companion object { + const val SWIPE_THRESHOLD = 50 + const val SWIPE_VELOCITY_THRESHOLD = 100 + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryGridHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryGridHolder.kt index cc98a9a921..15e89c827a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryGridHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryGridHolder.kt @@ -4,15 +4,14 @@ import android.app.Activity import android.view.Gravity import android.view.View import android.widget.FrameLayout +import androidx.core.view.isVisible import coil.clear import coil.size.Precision import coil.size.Scale import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.image.coil.loadLibraryManga -import eu.kanade.tachiyomi.util.view.gone -import eu.kanade.tachiyomi.util.view.visibleIf -import kotlinx.android.synthetic.main.manga_grid_item.* -import kotlinx.android.synthetic.main.unread_download_badge.* +import eu.kanade.tachiyomi.data.image.coil.loadManga +import eu.kanade.tachiyomi.databinding.MangaGridItemBinding +import eu.kanade.tachiyomi.util.lang.highlightText /** * Class used to hold the displayed data of a manga in the library, like the cover or the title. @@ -31,19 +30,20 @@ class LibraryGridHolder( private var fixedSize: Boolean ) : LibraryHolder(view, adapter) { + private val binding = MangaGridItemBinding.bind(view) init { - play_layout.setOnClickListener { playButtonClicked() } + binding.playLayout.setOnClickListener { playButtonClicked() } if (compact) { - text_layout.gone() + binding.textLayout.isVisible = false } else { - compact_title.gone() - gradient.gone() - val playLayout = play_layout.layoutParams as FrameLayout.LayoutParams - val buttonLayout = play_button.layoutParams as FrameLayout.LayoutParams + binding.compactTitle.isVisible = false + binding.gradient.isVisible = false + val playLayout = binding.playLayout.layoutParams as FrameLayout.LayoutParams + val buttonLayout = binding.playButton.layoutParams as FrameLayout.LayoutParams playLayout.gravity = Gravity.BOTTOM or Gravity.END buttonLayout.gravity = Gravity.BOTTOM or Gravity.END - play_layout.layoutParams = playLayout - play_button.layoutParams = buttonLayout + binding.playLayout.layoutParams = playLayout + binding.playButton.layoutParams = buttonLayout } } @@ -55,23 +55,39 @@ class LibraryGridHolder( */ override fun onSetValues(item: LibraryItem) { // Update the title and subtitle of the manga. - constraint_layout.visibleIf(!item.manga.isBlank()) - title.text = item.manga.title - subtitle.text = item.manga.author?.trim() + binding.constraintLayout.isVisible = !item.manga.isBlank() + binding.title.text = item.manga.title.highlightText(item.filter, color) + val authorArtist = if (item.manga.author == item.manga.artist || item.manga.artist.isNullOrBlank()) { + item.manga.author?.trim() ?: "" + } else { + listOfNotNull( + item.manga.author?.trim()?.takeIf { it.isNotBlank() }, + item.manga.artist?.trim()?.takeIf { it.isNotBlank() } + ).joinToString(", ") + } + binding.subtitle.text = authorArtist.highlightText(item.filter, color) - compact_title.text = title.text + binding.compactTitle.text = binding.title.text?.toString()?.highlightText(item.filter, color) + + binding.title.post { + val hasAuthorInFilter = + item.filter.isNotBlank() && authorArtist.contains(item.filter, true) + binding.subtitle.isVisible = binding.title.lineCount <= 1 || hasAuthorInFilter + binding.title.maxLines = if (hasAuthorInFilter) 1 else 2 + } - setUnreadBadge(badge_view, item) + setUnreadBadge(binding.unreadDownloadBadge.badgeView, item) setReadingButton(item) // Update the cover. - if (item.manga.thumbnail_url == null) cover_thumbnail.clear() + if (item.manga.thumbnail_url == null) binding.coverThumbnail.clear() else { - if (cover_thumbnail.height == 0) { - val oldPos = adapterPosition + if (binding.coverThumbnail.height == 0) { + val oldPos = flexibleAdapterPosition adapter.recyclerView.post { - if (oldPos == adapterPosition) + if (oldPos == flexibleAdapterPosition) { setCover(item.manga) + } } } else setCover(item.manga) } @@ -79,7 +95,7 @@ class LibraryGridHolder( private fun setCover(manga: Manga) { if ((adapter.recyclerView.context as? Activity)?.isDestroyed == true) return - cover_thumbnail.loadLibraryManga(manga) { + binding.coverThumbnail.loadManga(manga) { if (!fixedSize) { precision(Precision.INEXACT) scale(Scale.FIT) @@ -88,20 +104,20 @@ class LibraryGridHolder( } private fun playButtonClicked() { - adapter.libraryListener.startReading(adapterPosition) + adapter.libraryListener.startReading(flexibleAdapterPosition) } override fun onActionStateChanged(position: Int, actionState: Int) { super.onActionStateChanged(position, actionState) if (actionState == 2) { - card.isDragged = true - badge_view.isDragged = true + binding.card.isDragged = true + binding.unreadDownloadBadge.badgeView.isDragged = true } } override fun onItemReleased(position: Int) { super.onItemReleased(position) - card.isDragged = false - badge_view.isDragged = false + binding.card.isDragged = false + binding.unreadDownloadBadge.badgeView.isDragged = false } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHeaderHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHeaderHolder.kt index e125ff3c09..5ff899b4dc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHeaderHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHeaderHolder.kt @@ -4,59 +4,50 @@ import android.app.Activity import android.graphics.Color import android.util.TypedValue import android.view.View -import android.widget.ImageView -import android.widget.ProgressBar -import android.widget.TextView +import androidx.annotation.DrawableRes import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat +import androidx.core.view.isInvisible +import androidx.core.view.isVisible import com.github.florent37.viewtooltip.ViewTooltip import eu.davidea.flexibleadapter.SelectableAdapter import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.databinding.LibraryCategoryHeaderItemBinding import eu.kanade.tachiyomi.ui.base.MaterialMenuSheet import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.getResourceColor -import eu.kanade.tachiyomi.util.view.gone -import eu.kanade.tachiyomi.util.view.invisible import eu.kanade.tachiyomi.util.view.updateLayoutParams -import eu.kanade.tachiyomi.util.view.visible -import eu.kanade.tachiyomi.util.view.visibleIf -import kotlinx.android.synthetic.main.library_category_header_item.* import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get class LibraryHeaderHolder(val view: View, private val adapter: LibraryCategoryAdapter) : BaseFlexibleViewHolder(view, adapter, true) { - private val sectionText: TextView = view.findViewById(R.id.category_title) - private val sortText: TextView = view.findViewById(R.id.category_sort) - private val updateButton: ImageView = view.findViewById(R.id.update_button) - private val checkboxImage: ImageView = view.findViewById(R.id.checkbox) - private val expandImage: ImageView = view.findViewById(R.id.collapse_arrow) - private val catProgress: ProgressBar = view.findViewById(R.id.cat_progress) + private val binding = LibraryCategoryHeaderItemBinding.bind(view) init { - category_header_layout.setOnClickListener { toggleCategory() } - updateButton.setOnClickListener { addCategoryToUpdate() } - sectionText.setOnLongClickListener { - val category = (adapter.getItem(adapterPosition) as? LibraryHeaderItem)?.category - adapter.libraryListener.manageCategory(adapterPosition) + binding.categoryHeaderLayout.setOnClickListener { toggleCategory() } + binding.updateButton.setOnClickListener { addCategoryToUpdate() } + binding.categoryTitle.setOnLongClickListener { + val category = (adapter.getItem(flexibleAdapterPosition) as? LibraryHeaderItem)?.category + adapter.libraryListener.manageCategory(flexibleAdapterPosition) category?.isDynamic == false } - sectionText.setOnClickListener { toggleCategory() } - sortText.setOnClickListener { it.post { showCatSortOptions() } } - checkboxImage.setOnClickListener { selectAll() } - updateButton.drawable.mutate() + binding.categoryTitle.setOnClickListener { toggleCategory() } + binding.categorySort.setOnClickListener { it.post { showCatSortOptions() } } + binding.checkbox.setOnClickListener { selectAll() } + binding.updateButton.drawable.mutate() } private fun toggleCategory() { - adapter.libraryListener.toggleCategoryVisibility(adapterPosition) + adapter.libraryListener.toggleCategoryVisibility(flexibleAdapterPosition) val tutorial = Injekt.get().shownLongPressCategoryTutorial() if (!tutorial.get()) { - ViewTooltip.on(itemView.context as? Activity, sectionText).autoHide(true, 5000L) + ViewTooltip.on(itemView.context as? Activity, binding.categoryTitle).autoHide(true, 5000L) .align(ViewTooltip.ALIGN.START).position(ViewTooltip.Position.TOP) .text(R.string.long_press_category) .color(itemView.context.getResourceColor(R.attr.colorAccent)) @@ -77,7 +68,7 @@ class LibraryHeaderHolder(val view: View, private val adapter: LibraryCategoryAd false } val shorterMargin = adapter.headerItems.firstOrNull() == item - sectionText.updateLayoutParams { + binding.categoryTitle.updateLayoutParams { topMargin = ( when { shorterMargin -> 2 @@ -88,120 +79,70 @@ class LibraryHeaderHolder(val view: View, private val adapter: LibraryCategoryAd } val category = item.category - if (category.isDynamic) { - category_header_layout.background = null - sectionText.background = null - } else { - category_header_layout.setBackgroundResource(R.drawable.list_item_selector) - sectionText.setBackgroundResource(R.drawable.square_ripple) - } - - if (category.isAlone && !category.isDynamic) sectionText.text = "" - else sectionText.text = category.name + if (category.isAlone && !category.isDynamic) binding.categoryTitle.text = "" + else binding.categoryTitle.text = category.name + binding.categoryTitle.setCompoundDrawablesRelative(null, null, null, null) val isAscending = category.isAscending() val sortingMode = category.sortingMode() - val sortDrawable = when { - sortingMode == LibrarySort.DRAG_AND_DROP || sortingMode == null -> R.drawable.ic_sort_24dp - if (sortingMode == LibrarySort.RATING || sortingMode == LibrarySort.DATE_ADDED || sortingMode == LibrarySort.LATEST_CHAPTER || sortingMode == LibrarySort.LAST_READ) !isAscending else isAscending -> R.drawable.ic_arrow_downward_24dp - else -> R.drawable.ic_arrow_upward_24dp - } + val sortDrawable = getSortRes(sortingMode, isAscending, R.drawable.ic_sort_24dp) - sortText.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, sortDrawable, 0) - sortText.setText(category.sortRes()) - expandImage.setImageResource( + binding.categorySort.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, sortDrawable, 0) + binding.categorySort.setText(category.sortRes()) + binding.collapseArrow.setImageResource( if (category.isHidden) R.drawable.ic_expand_more_24dp else R.drawable.ic_expand_less_24dp ) when { adapter.mode == SelectableAdapter.Mode.MULTI -> { - checkboxImage.visibleIf(!category.isHidden) - expandImage.visibleIf(category.isHidden && !adapter.isSingleCategory && !category.isDynamic) - updateButton.gone() - catProgress.gone() + binding.checkbox.isVisible = !category.isHidden + binding.collapseArrow.isVisible = category.isHidden && !adapter.isSingleCategory + binding.updateButton.isVisible = false + binding.catProgress.isVisible = false setSelection() } category.id ?: -1 < 0 -> { - expandImage.gone() - checkboxImage.gone() - catProgress.gone() - updateButton.gone() + binding.collapseArrow.isVisible = false + binding.checkbox.isVisible = false + binding.catProgress.isVisible = false + binding.updateButton.isVisible = false } LibraryUpdateService.categoryInQueue(category.id) -> { - expandImage.visibleIf(!adapter.isSingleCategory && !category.isDynamic) - checkboxImage.gone() - catProgress.visible() - updateButton.invisible() + binding.collapseArrow.isVisible = !adapter.isSingleCategory + binding.checkbox.isVisible = false + binding.catProgress.isVisible = true + binding.updateButton.isInvisible = true } else -> { - expandImage.visibleIf(!adapter.isSingleCategory && !category.isDynamic) - catProgress.gone() - checkboxImage.gone() - updateButton.visibleIf(!adapter.isSingleCategory) + binding.collapseArrow.isVisible = !adapter.isSingleCategory + binding.catProgress.isVisible = false + binding.checkbox.isVisible = false + binding.updateButton.isVisible = !adapter.isSingleCategory } } } private fun addCategoryToUpdate() { - if (adapter.libraryListener.updateCategory(adapterPosition)) { - catProgress.visible() - updateButton.invisible() + if (adapter.libraryListener.updateCategory(flexibleAdapterPosition)) { + binding.catProgress.isVisible = true + binding.updateButton.isInvisible = true } } private fun showCatSortOptions() { val category = - (adapter.getItem(adapterPosition) as? LibraryHeaderItem)?.category ?: return + (adapter.getItem(flexibleAdapterPosition) as? LibraryHeaderItem)?.category ?: return adapter.controller.activity?.let { activity -> - val items = mutableListOf( - MaterialMenuSheet.MenuSheetItem( - LibrarySort.ALPHA, R.drawable.ic_sort_by_alpha_24dp, R.string.title - ), - MaterialMenuSheet.MenuSheetItem( - LibrarySort.LAST_READ, - R.drawable.ic_recent_read_outline_24dp, - R.string.last_read - ), - MaterialMenuSheet.MenuSheetItem( - LibrarySort.LATEST_CHAPTER, - R.drawable.ic_new_releases_24dp, - R.string.latest_chapter - ), - MaterialMenuSheet.MenuSheetItem( - LibrarySort.UNREAD, R.drawable.ic_eye_24dp, R.string.unread - ), - MaterialMenuSheet.MenuSheetItem( - LibrarySort.TOTAL, - R.drawable.ic_sort_by_numeric_24dp, - R.string.total_chapters - ), - MaterialMenuSheet.MenuSheetItem( - LibrarySort.DATE_ADDED, - R.drawable.ic_heart_outline_24dp, - R.string.date_added - ), - MaterialMenuSheet.MenuSheetItem( - LibrarySort.RATING, - R.drawable.ic_poll_24dp, - R.string.rating - ) - ) - - if (category.isDynamic) { - items.add( - MaterialMenuSheet.MenuSheetItem( - LibrarySort.DRAG_AND_DROP, - R.drawable.ic_label_outline_24dp, - R.string.category - ) - ) - } - val sortingMode = category.sortingMode() + val items = LibrarySort.values().map { it.menuSheetItem(category.isDynamic) } + val sortingMode = category.sortingMode(true) val sheet = MaterialMenuSheet( - activity, items, activity.getString(R.string.sort_by), sortingMode + activity, + items, + activity.getString(R.string.sort_by), + sortingMode?.mainValue ) { sheet, item -> onCatSortClicked(category, item) - val nCategory = (adapter.getItem(adapterPosition) as? LibraryHeaderItem)?.category + val nCategory = (adapter.getItem(flexibleAdapterPosition) as? LibraryHeaderItem)?.category val isAscending = nCategory?.isAscending() ?: false val drawableRes = getSortRes(item, isAscending) sheet.setDrawable(item, drawableRes) @@ -209,69 +150,82 @@ class LibraryHeaderHolder(val view: View, private val adapter: LibraryCategoryAd } val isAscending = category.isAscending() val drawableRes = getSortRes(sortingMode, isAscending) - sheet.setDrawable(sortingMode ?: -1, drawableRes) + sheet.setDrawable(sortingMode?.mainValue ?: -1, drawableRes) sheet.show() } } - private fun getSortRes(sortingMode: Int?, isAscending: Boolean): Int = when { - sortingMode == LibrarySort.DRAG_AND_DROP -> R.drawable.ic_check_24dp - if (sortingMode == LibrarySort.RATING || - sortingMode == LibrarySort.DATE_ADDED || - sortingMode == LibrarySort.LATEST_CHAPTER || - sortingMode == LibrarySort.LAST_READ - ) !isAscending else isAscending -> - R.drawable.ic_arrow_downward_24dp - else -> R.drawable.ic_arrow_upward_24dp + private fun getSortRes( + sortMode: LibrarySort?, + isAscending: Boolean, + @DrawableRes defaultDrawableRes: Int = R.drawable.ic_check_24dp + ): Int { + sortMode ?: return defaultDrawableRes + return when (sortMode) { + LibrarySort.DragAndDrop -> defaultDrawableRes + else -> { + if (if (sortMode.hasInvertedSort) !isAscending else isAscending) { + R.drawable.ic_arrow_downward_24dp + } else { + R.drawable.ic_arrow_upward_24dp + } + } + } + } + + private fun getSortRes( + sortingMode: Int?, + isAscending: Boolean, + @DrawableRes defaultDrawableRes: Int = R.drawable.ic_check_24dp + ): Int { + sortingMode ?: return defaultDrawableRes + return when (val sortMode = LibrarySort.valueOf(sortingMode)) { + LibrarySort.DragAndDrop -> defaultDrawableRes + else -> { + if (if (sortMode?.hasInvertedSort == true) !isAscending else isAscending) { + R.drawable.ic_arrow_downward_24dp + } else { + R.drawable.ic_arrow_upward_24dp + } + } + } } private fun onCatSortClicked(category: Category, menuId: Int?) { val modType = if (menuId == null) { - val t = (category.mangaSort?.minus('a') ?: 0) + 1 - if (t % 2 != 0) t + 1 - else t - 1 - } else { - val order = when (menuId) { - LibrarySort.DRAG_AND_DROP -> { - adapter.libraryListener.sortCategory(category.id!!, 'D' - 'a' + 1) - return - } - LibrarySort.RATING -> 6 - LibrarySort.DATE_ADDED -> 5 - LibrarySort.TOTAL -> 4 - LibrarySort.LAST_READ -> 3 - LibrarySort.UNREAD -> 2 - LibrarySort.LATEST_CHAPTER -> 1 - else -> 0 + val sortingMode = category.sortingMode() ?: LibrarySort.Title + if (category.isAscending()) { + sortingMode.categoryValueDescending + } else { + sortingMode.categoryValue } - if (order == category.catSortingMode()) { + } else { + val sortingMode = LibrarySort.valueOf(menuId) ?: LibrarySort.Title + if (sortingMode != LibrarySort.DragAndDrop && sortingMode == category.sortingMode()) { onCatSortClicked(category, null) return } - (2 * order + 1) + sortingMode.categoryValue } adapter.libraryListener.sortCategory(category.id!!, modType) } private fun selectAll() { - adapter.libraryListener.selectAll(adapterPosition) + adapter.libraryListener.selectAll(flexibleAdapterPosition) } fun setSelection() { - val allSelected = adapter.libraryListener.allSelected(adapterPosition) + val allSelected = adapter.libraryListener.allSelected(flexibleAdapterPosition) val drawable = ContextCompat.getDrawable( contentView.context, if (allSelected) R.drawable.ic_check_circle_24dp else R.drawable.ic_radio_button_unchecked_24dp ) val tintedDrawable = drawable?.mutate() tintedDrawable?.setTint( - ContextCompat.getColor( - contentView.context, - if (allSelected) R.color.colorAccent - else R.color.gray_button - ) + if (allSelected) contentView.context.getResourceColor(R.attr.colorAccent) + else ContextCompat.getColor(contentView.context, R.color.gray_button) ) - checkboxImage.setImageDrawable(tintedDrawable) + binding.checkbox.setImageDrawable(tintedDrawable) } override fun onLongClick(view: View?): Boolean { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHeaderItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHeaderItem.kt index 82abc8b546..5fff77c0ba 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHeaderItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHeaderItem.kt @@ -54,6 +54,6 @@ class LibraryHeaderItem( } override fun hashCode(): Int { - return -(category.id!!) + return (category.id ?: 0L).hashCode() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt index 3b31def0e4..3ccd77bdb8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHolder.kt @@ -1,8 +1,11 @@ package eu.kanade.tachiyomi.ui.library import android.view.View +import androidx.core.graphics.ColorUtils +import androidx.core.view.isVisible +import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import kotlinx.android.synthetic.main.manga_grid_item.* +import eu.kanade.tachiyomi.util.system.getResourceColor /** * Generic class used to hold the displayed data of a manga in the library. @@ -16,6 +19,8 @@ abstract class LibraryHolder( val adapter: LibraryCategoryAdapter ) : BaseFlexibleViewHolder(view, adapter) { + protected val color = ColorUtils.setAlphaComponent(itemView.context.getResourceColor(R.attr.colorAccent), 75) + /** * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this * holder with the given manga. @@ -25,7 +30,7 @@ abstract class LibraryHolder( abstract fun onSetValues(item: LibraryItem) fun setUnreadBadge(badge: LibraryBadge, item: LibraryItem) { - val showTotal = item.header.category.sortingMode() == LibrarySort.TOTAL + val showTotal = item.header.category.sortingMode() == LibrarySort.TotalChapters badge.setUnreadDownload( when { showTotal -> item.manga.totalChapters @@ -42,8 +47,8 @@ abstract class LibraryHolder( } fun setReadingButton(item: LibraryItem) { - play_layout?.visibility = if (item.manga.unread > 0 && item.unreadType > 0 && !item.hideReadingButton) - View.VISIBLE else View.GONE + itemView.findViewById(R.id.play_layout)?.isVisible = + item.manga.unread > 0 && !item.hideReadingButton } /** @@ -58,8 +63,17 @@ abstract class LibraryHolder( override fun onLongClick(view: View?): Boolean { return if (adapter.isLongPressDragEnabled) { + val manga = (adapter.getItem(flexibleAdapterPosition) as LibraryItem).manga + if (!isDraggable && !manga.isBlank() && !manga.isHidden()) { + adapter.mItemLongClickListener.onItemLongClick(flexibleAdapterPosition) + toggleActivation() + true + } else { + super.onLongClick(view) + false + } + } else { super.onLongClick(view) - false - } else super.onLongClick(view) + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt index b272461121..3788d97d1b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt @@ -1,6 +1,5 @@ package eu.kanade.tachiyomi.ui.library -import android.annotation.SuppressLint import android.view.Gravity import android.view.View import android.view.ViewGroup @@ -16,12 +15,11 @@ import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.databinding.MangaGridItemBinding import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.view.updateLayoutParams import eu.kanade.tachiyomi.widget.AutofitRecyclerView -import kotlinx.android.synthetic.main.manga_grid_item.view.* import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy @@ -35,21 +33,24 @@ class LibraryItem( var downloadCount = -1 var unreadType = 2 + var filter = "" + private val sourceManager: SourceManager by injectLazy() private val uniformSize: Boolean - get() = preferences.uniformGrid().getOrDefault() + get() = preferences.uniformGrid().get() private val libraryLayout: Int - get() = preferences.libraryLayout().getOrDefault() + get() = preferences.libraryLayout().get() val hideReadingButton: Boolean - get() = preferences.hideStartReadingButton().getOrDefault() + get() = preferences.hideStartReadingButton().get() override fun getLayoutRes(): Int { - return if (libraryLayout == 0 || manga.isBlank()) + return if (libraryLayout == 0 || manga.isBlank()) { R.layout.manga_list_item - else + } else { R.layout.manga_grid_item + } } override fun createViewHolder(view: View, adapter: FlexibleAdapter>): LibraryHolder { @@ -61,38 +62,41 @@ class LibraryItem( LibraryListHolder(view, adapter as LibraryCategoryAdapter) } else { view.apply { + val binding = MangaGridItemBinding.bind(this) val coverHeight = (parent.itemWidth / 3f * 4f).toInt() if (libraryLayout == 1) { - gradient.layoutParams = FrameLayout.LayoutParams( + binding.gradient.layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, (coverHeight * 0.66f).toInt(), Gravity.BOTTOM ) - card.updateLayoutParams { + binding.card.updateLayoutParams { bottomMargin = 6.dpToPx } } else if (libraryLayout == 2) { - constraint_layout.background = ContextCompat.getDrawable( - context, R.drawable.library_item_selector + binding.constraintLayout.background = ContextCompat.getDrawable( + context, + R.drawable.library_item_selector ) } if (isFixedSize) { - constraint_layout.layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT + binding.constraintLayout.layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT ) - cover_thumbnail.maxHeight = Int.MAX_VALUE - cover_thumbnail.minimumHeight = 0 - constraint_layout.minHeight = 0 - cover_thumbnail.scaleType = ImageView.ScaleType.CENTER_CROP - cover_thumbnail.adjustViewBounds = false - cover_thumbnail.layoutParams = FrameLayout.LayoutParams( + binding.coverThumbnail.maxHeight = Int.MAX_VALUE + binding.coverThumbnail.minimumHeight = 0 + binding.constraintLayout.minHeight = 0 + binding.coverThumbnail.scaleType = ImageView.ScaleType.CENTER_CROP + binding.coverThumbnail.adjustViewBounds = false + binding.coverThumbnail.layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, (parent.itemWidth / 3f * 3.7f).toInt() ) } else { - constraint_layout.minHeight = coverHeight - cover_thumbnail.minimumHeight = (parent.itemWidth / 3f * 3.6f).toInt() - cover_thumbnail.maxHeight = (parent.itemWidth / 3f * 6f).toInt() + binding.constraintLayout.minHeight = coverHeight + binding.coverThumbnail.minimumHeight = (parent.itemWidth / 3f * 3.6f).toInt() + binding.coverThumbnail.maxHeight = (parent.itemWidth / 3f * 6f).toInt() } } LibraryGridHolder( @@ -121,7 +125,7 @@ class LibraryItem( * Returns true if this item is draggable. */ override fun isDraggable(): Boolean { - return !manga.isBlank() + return !manga.isBlank() && header.category.isDragAndDrop } override fun isEnabled(): Boolean { @@ -139,10 +143,11 @@ class LibraryItem( * @return true if the manga should be included, false otherwise. */ override fun filter(constraint: String): Boolean { - if (manga.isBlank() && manga.title.isBlank()) + filter = constraint + if (manga.isBlank() && manga.title.isBlank()) { return constraint.isEmpty() - val sourceManager by injectLazy() - val sourceName = sourceManager.getMangadex().name + } + val sourceName by lazy { sourceManager.getMangadex().name } return manga.title.contains(constraint, true) || (manga.author?.contains(constraint, true) ?: false) || (manga.artist?.contains(constraint, true) ?: false) || @@ -152,17 +157,17 @@ class LibraryItem( } else containsGenre(constraint, manga.genre?.split(", ")) } - @SuppressLint("DefaultLocale") private fun containsGenre(tag: String, genres: List?): Boolean { if (tag.trim().isEmpty()) return true - return if (tag.startsWith("-")) + return if (tag.startsWith("-")) { genres?.find { - it.trim().toLowerCase() == tag.substringAfter("-").toLowerCase() + it.trim().equals(tag.substringAfter("-"), ignoreCase = true) } == null - else + } else { genres?.find { - it.trim().toLowerCase() == tag.toLowerCase() + it.trim().equals(tag, ignoreCase = true) } != null + } } override fun equals(other: Any?): Boolean { @@ -173,6 +178,8 @@ class LibraryItem( } override fun hashCode(): Int { - return (manga.id!! + (manga.category shl 50).toLong()).hashCode() // !!.hashCode() + var result = manga.id!!.hashCode() + result = 31 * result + (header?.hashCode() ?: 0) + return result } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListHolder.kt index f08dac9ea5..e2c5718b52 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryListHolder.kt @@ -2,25 +2,21 @@ package eu.kanade.tachiyomi.ui.library import android.view.View import android.view.ViewGroup +import androidx.core.view.isVisible import coil.clear import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.image.coil.loadLibraryManga +import eu.kanade.tachiyomi.data.image.coil.loadManga +import eu.kanade.tachiyomi.databinding.MangaListItemBinding +import eu.kanade.tachiyomi.util.lang.highlightText import eu.kanade.tachiyomi.util.system.dpToPx -import eu.kanade.tachiyomi.util.view.gone import eu.kanade.tachiyomi.util.view.updateLayoutParams -import eu.kanade.tachiyomi.util.view.visible -import eu.kanade.tachiyomi.util.view.visibleIf -import kotlinx.android.synthetic.main.manga_list_item.* -import kotlinx.android.synthetic.main.manga_list_item.view.* -import kotlinx.android.synthetic.main.unread_download_badge.* /** - * Class used to hold the displayed data of a manga in the library, like the cover or the title. + * Class used to hold the displayed data of a manga in the library, like the cover or the binding.title. * All the elements from the layout file "item_library_list" are available in this class. * * @param view the inflated view for this holder. * @param adapter the adapter handling this holder. - * @param listener a listener to react to single tap and long tap events. * @constructor creates a new library holder. */ @@ -29,6 +25,8 @@ class LibraryListHolder( adapter: LibraryCategoryAdapter ) : LibraryHolder(view, adapter) { + private val binding = MangaListItemBinding.bind(view) + /** * Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this * holder with the given manga. @@ -36,62 +34,73 @@ class LibraryListHolder( * @param item the manga item to bind. */ override fun onSetValues(item: LibraryItem) { - - title.visible() - constraint_layout.minHeight = 56.dpToPx + binding.title.isVisible = true + binding.constraintLayout.minHeight = 56.dpToPx if (item.manga.isBlank()) { - constraint_layout.minHeight = 0 - constraint_layout.updateLayoutParams { + binding.constraintLayout.minHeight = 0 + binding.constraintLayout.updateLayoutParams { height = ViewGroup.MarginLayoutParams.WRAP_CONTENT } if (item.manga.status == -1) { - title.text = null - title.gone() - } else - title.text = itemView.context.getString(R.string.category_is_empty) - title.textAlignment = View.TEXT_ALIGNMENT_CENTER - card.gone() - badge_view.gone() - padding.gone() - subtitle.gone() + binding.title.text = null + binding.title.isVisible = false + } else { + binding.title.text = itemView.context.getString(R.string.category_is_empty) + } + binding.title.textAlignment = View.TEXT_ALIGNMENT_CENTER + binding.card.isVisible = false + binding.unreadDownloadBadge.badgeView.isVisible = false + binding.padding.isVisible = false + binding.subtitle.isVisible = false return } - constraint_layout.updateLayoutParams { + binding.constraintLayout.updateLayoutParams { height = 52.dpToPx } - padding.visible() - card.visible() - title.textAlignment = View.TEXT_ALIGNMENT_TEXT_START + binding.padding.isVisible = true + binding.card.isVisible = true + binding.title.textAlignment = View.TEXT_ALIGNMENT_TEXT_START - // Update the title of the manga. - title.text = item.manga.title - setUnreadBadge(badge_view, item) + // Update the binding.title of the manga. + binding.title.text = item.manga.title.highlightText(item.filter, color) + setUnreadBadge(binding.unreadDownloadBadge.badgeView, item) - subtitle.text = item.manga.author?.trim() - title.post { - if (title.text == item.manga.title) { - subtitle.visibleIf(title.lineCount == 1 && !item.manga.author.isNullOrBlank()) - } + val authorArtist = if (item.manga.author == item.manga.artist || item.manga.artist.isNullOrBlank()) { + item.manga.author?.trim() ?: "" + } else { + listOfNotNull( + item.manga.author?.trim()?.takeIf { it.isNotBlank() }, + item.manga.artist?.trim()?.takeIf { it.isNotBlank() } + ).joinToString(", ") + } + + binding.subtitle.text = authorArtist.highlightText(item.filter, color) + binding.title.maxLines = 2 + binding.title.post { + val hasAuthorInFilter = + item.filter.isNotBlank() && authorArtist.contains(item.filter, true) + binding.subtitle.isVisible = binding.title.lineCount <= 1 || hasAuthorInFilter + binding.title.maxLines = if (hasAuthorInFilter) 1 else 2 } // Update the cover. if (item.manga.thumbnail_url == null) { - cover_thumbnail.clear() + binding.coverThumbnail.clear() } else { - val id = item.manga.id ?: return - cover_thumbnail.loadLibraryManga(item.manga) + item.manga.id ?: return + binding.coverThumbnail.loadManga(item.manga) } } override fun onActionStateChanged(position: Int, actionState: Int) { super.onActionStateChanged(position, actionState) if (actionState == 2) { - view.card.isDragged = true + binding.card.isDragged = true } } override fun onItemReleased(position: Int) { super.onItemReleased(position) - view.card.isDragged = false + binding.card.isDragged = false } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt index 977dee306a..5341128903 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt @@ -10,8 +10,11 @@ import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.MangaCategory import eu.kanade.tachiyomi.data.database.models.filterIfUsingCache import eu.kanade.tachiyomi.data.download.DownloadManager +import eu.kanade.tachiyomi.data.preference.DelayedLibrarySuggestionsJob import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.data.preference.minusAssign +import eu.kanade.tachiyomi.data.preference.plusAssign import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.SManga @@ -20,6 +23,7 @@ import eu.kanade.tachiyomi.source.model.isMergedChapter import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.utils.FollowStatus import eu.kanade.tachiyomi.source.online.utils.MdUtil +import eu.kanade.tachiyomi.ui.base.presenter.BaseCoroutinePresenter import eu.kanade.tachiyomi.ui.library.LibraryGroup.BY_DEFAULT import eu.kanade.tachiyomi.ui.library.LibraryGroup.BY_TAG import eu.kanade.tachiyomi.ui.library.LibraryGroup.BY_TRACK_STATUS @@ -28,33 +32,39 @@ import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet.Companion.STATE_EXCLUDE import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet.Companion.STATE_IGNORE import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet.Companion.STATE_INCLUDE +import eu.kanade.tachiyomi.ui.recents.RecentsPresenter import eu.kanade.tachiyomi.util.lang.capitalizeWords +import eu.kanade.tachiyomi.util.lang.chopByWords import eu.kanade.tachiyomi.util.lang.removeArticles import eu.kanade.tachiyomi.util.system.executeOnIO +import eu.kanade.tachiyomi.util.system.launchIO +import eu.kanade.tachiyomi.util.system.withUIContext import eu.kanade.tachiyomi.util.view.snack -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.util.ArrayList +import java.util.Calendar import java.util.Comparator +import java.util.Date +import java.util.Locale +import java.util.concurrent.TimeUnit +import kotlin.random.Random /** * Presenter of [LibraryController]. */ class LibraryPresenter( private val view: LibraryController, - private val db: DatabaseHelper = Injekt.get(), + val db: DatabaseHelper = Injekt.get(), private val preferences: PreferencesHelper = Injekt.get(), private val coverCache: CoverCache = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get() -) { - - private var scope = CoroutineScope(Job() + Dispatchers.Default) +) : BaseCoroutinePresenter() { private val context = preferences.context @@ -86,26 +96,46 @@ class LibraryPresenter( val showAllCategories get() = preferences.showAllCategories().get() - val libraryIsGrouped + private val libraryIsGrouped get() = groupType != UNGROUPED /** Save the current list to speed up loading later */ - fun onDestroy() { + override fun onDestroy() { + super.onDestroy() lastLibraryItems = libraryItems lastCategories = categories } - /** Restore the static items to speed up reloading the view */ - fun onRestore() { - libraryItems = lastLibraryItems ?: return - categories = lastCategories ?: return + override fun onCreate() { + super.onCreate() + lastLibraryItems?.let { libraryItems = it } + lastCategories?.let { categories = it } lastCategories = null lastLibraryItems = null + getLibrary() + if (preferences.showLibrarySearchSuggestions().isNotSet()) { + DelayedLibrarySuggestionsJob.setupTask(true) + } else if (preferences.showLibrarySearchSuggestions().get() && + Date().time >= preferences.lastLibrarySuggestion().get() + TimeUnit.HOURS.toMillis(2) + ) { + // Doing this instead of a job in case the app isn't used often + presenterScope.launchIO { + setSearchSuggestion(preferences, db, sourceManager) + withUIContext { view.setTitle() } + } + } } /** Get favorited manga for library and sort and filter it */ fun getLibrary() { - scope.launch { + if (categories.isEmpty()) { + val dbCategories = db.getCategories().executeAsBlocking() + if ((dbCategories + Category.createDefault(context)).distinctBy { it.order }.size != dbCategories.size + 1) { + reorderCategories(dbCategories) + } + categories = lastCategories ?: db.getCategories().executeAsBlocking().toMutableList() + } + presenterScope.launch { val library = withContext(Dispatchers.IO) { getLibraryFromDB() } library.apply { setDownloadCount(library) @@ -120,6 +150,12 @@ class LibraryPresenter( } } + private fun reorderCategories(categories: List) { + val sortedCategories = categories.sortedBy { it.order } + sortedCategories.forEachIndexed { i, category -> category.order = i } + db.insertCategories(sortedCategories).executeAsBlocking() + } + fun getCurrentCategory() = categories.find { it.id == currentCategory } fun switchSection(order: Int) { @@ -134,7 +170,8 @@ class LibraryPresenter( private fun blankItem(id: Int = currentCategory): List { return listOf( LibraryItem( - LibraryManga.createBlank(id), LibraryHeaderItem({ getCategory(id) }, id) + LibraryManga.createBlank(id), + LibraryHeaderItem({ getCategory(id) }, id) ) ) } @@ -142,12 +179,10 @@ class LibraryPresenter( fun restoreLibrary() { val items = libraryItems val show = showAllCategories || !libraryIsGrouped || categories.size == 1 - if (!show) { - sectionedLibraryItems = items.groupBy { it.header.category.id!! }.toMutableMap() - if (currentCategory == -1) currentCategory = categories.find { - it.order == preferences.lastUsedCategory().getOrDefault() - }?.id ?: 0 - } + sectionedLibraryItems = items.groupBy { it.header.category.id!! }.toMutableMap() + if (!show && currentCategory == -1) currentCategory = categories.find { + it.order == preferences.lastUsedCategory().getOrDefault() + }?.id ?: 0 view.onNextLibraryUpdate( if (!show) sectionedLibraryItems[currentCategory] ?: sectionedLibraryItems[categories.first().id] ?: blankItem() @@ -165,13 +200,11 @@ class LibraryPresenter( libraryItems = items val showAll = showAllCategories || !libraryIsGrouped || categories.size <= 1 - if (!showAll) { - sectionedLibraryItems = items.groupBy { it.header.category.id ?: 0 }.toMutableMap() - if (currentCategory == -1) currentCategory = categories.find { - it.order == preferences.lastUsedCategory().getOrDefault() - }?.id ?: 0 - } - withContext(Dispatchers.Main) { + sectionedLibraryItems = items.groupBy { it.header.category.id ?: 0 }.toMutableMap() + if (!showAll && currentCategory == -1) currentCategory = categories.find { + it.order == preferences.lastUsedCategory().getOrDefault() + }?.id ?: 0 + withUIContext { view.onNextLibraryUpdate( if (!showAll) sectionedLibraryItems[currentCategory] ?: sectionedLibraryItems[categories.first().id] ?: blankItem() @@ -203,10 +236,9 @@ class LibraryPresenter( val filterMissingChapters = preferences.filterMissingChapters().getOrDefault() - val filtersOff = filterDownloaded == 0 && filterUnread == 0 && - filterCompleted == 0 && filterTracked == 0 && filterMangaType == 0 && - filterMerged == 0 && filterMissingChapters == 0 - + val filtersOff = + filterDownloaded == 0 && filterUnread == 0 && filterCompleted == 0 && filterTracked == 0 && filterMangaType == 0 && filterMangaType == 0 && + filterMerged == 0 && filterMissingChapters == 0 return items.filter f@{ item -> if (item.manga.status == -1) { val subItems = sectionedLibraryItems[item.manga.category] @@ -222,7 +254,7 @@ class LibraryPresenter( filterMangaType, filterTrackers, filterMerged, - filterMissingChapters + filterMissingChapters, ) } } @@ -242,7 +274,7 @@ class LibraryPresenter( filterMangaType, filterTrackers, filterMerged, - filterMissingChapters + filterMissingChapters, ) } } @@ -256,7 +288,7 @@ class LibraryPresenter( filterMangaType: Int, filterTrackers: String, filterMerged: Int, - filterMissingChapters: Int + filterMissingChapters: Int, ): Boolean { if (filterUnread == STATE_INCLUDE && item.manga.unread == 0) return false if (filterUnread == STATE_EXCLUDE && item.manga.unread > 0) return false @@ -267,9 +299,9 @@ class LibraryPresenter( if (filterMangaType > 0) { if (if (filterMangaType == Manga.TYPE_MANHWA) { - (filterMangaType != item.manga.mangaType() && filterMangaType != Manga.TYPE_WEBTOON) - } else { - filterMangaType != item.manga.mangaType() + (filterMangaType != item.manga.seriesType() && filterMangaType != Manga.TYPE_WEBTOON) + } else { + filterMangaType != item.manga.seriesType() } ) return false } @@ -350,7 +382,7 @@ class LibraryPresenter( } private fun setUnreadBadge(itemList: List) { - val unreadType = preferences.unreadBadgeType().getOrDefault() + val unreadType = preferences.unreadBadgeType().get() for (item in itemList) { item.unreadType = unreadType } @@ -367,6 +399,11 @@ class LibraryPresenter( db.getLastReadManga().executeAsBlocking().associate { it.id!! to counter++ } } + val lastFetchedManga by lazy { + var counter = 0 + db.getLastFetchedManga().executeAsBlocking().associate { it.id!! to counter++ } + } + val sortFn: (LibraryItem, LibraryItem) -> Int = { i1, i2 -> if (i1.header.category.id == i2.header.category.id) { val category = i1.header.category @@ -378,31 +415,35 @@ class LibraryPresenter( } val compare = when { category.mangaSort != null -> { - var sort = when (category.sortingMode()) { - LibrarySort.ALPHA -> sortAlphabetical(i1, i2) - LibrarySort.LATEST_CHAPTER -> i1.manga.last_update.compareTo(i2.manga.last_update) - LibrarySort.UNREAD -> when { + var sort = when (category.sortingMode() ?: LibrarySort.Title) { + LibrarySort.Title -> sortAlphabetical(i1, i2) + LibrarySort.LatestChapter -> i2.manga.last_update.compareTo(i2.manga.last_update) + LibrarySort.Unread -> when { i1.manga.unread == i2.manga.unread -> 0 i1.manga.unread == 0 -> if (category.isAscending()) 1 else -1 i2.manga.unread == 0 -> if (category.isAscending()) -1 else 1 else -> i2.manga.unread.compareTo(i1.manga.unread) } - LibrarySort.LAST_READ -> { - val manga1LastRead = lastReadManga[i1.manga.id!!] ?: lastReadManga.size - val manga2LastRead = lastReadManga[i2.manga.id!!] ?: lastReadManga.size - manga2LastRead.compareTo(manga1LastRead) + LibrarySort.LastRead -> { + val manga1LastRead = + lastReadManga[i1.manga.id!!] ?: lastReadManga.size + val manga2LastRead = + lastReadManga[i2.manga.id!!] ?: lastReadManga.size + manga1LastRead.compareTo(manga2LastRead) } - LibrarySort.TOTAL -> { + LibrarySort.TotalChapters -> { i2.manga.totalChapters.compareTo(i1.manga.totalChapters) } - LibrarySort.DATE_ADDED -> i1.manga.date_added.compareTo(i2.manga.date_added) - LibrarySort.RATING -> { - val manga1LastRead = i1.manga.rating?.toDoubleOrNull() ?: 0.0 - val manga2LastRead = i2.manga.rating?.toDoubleOrNull() ?: 0.0 + LibrarySort.DateFetched -> { + val manga1LastRead = + lastFetchedManga[i1.manga.id!!] ?: lastFetchedManga.size + val manga2LastRead = + lastFetchedManga[i2.manga.id!!] ?: lastFetchedManga.size manga1LastRead.compareTo(manga2LastRead) } - else -> { - if (LibrarySort.DRAG_AND_DROP == category.sortingMode() && category.isDynamic) { + LibrarySort.DateAdded -> i2.manga.date_added.compareTo(i1.manga.date_added) + LibrarySort.DragAndDrop -> { + if (category.isDynamic) { val category1 = allCategories.find { i1.manga.category == it.id }?.order ?: 0 @@ -477,7 +518,6 @@ class LibraryPresenter( removeArticles = preferences.removeArticles().getOrDefault() val categories = db.getCategories().executeAsBlocking().toMutableList() var libraryManga = db.getLibraryMangas().executeAsBlocking() - val showAll = showAllCategories if (groupType > BY_DEFAULT) { libraryManga = libraryManga.distinctBy { it.id } @@ -522,9 +562,9 @@ class LibraryPresenter( categories.forEach { category -> val catId = category.id ?: return@forEach if (catId > 0 && !categorySet.contains(catId) && ( - catId !in categoriesHidden || - !showAll - ) + catId !in categoriesHidden || + !showAll + ) ) { val headerItem = headerItems[catId] if (headerItem != null) items.add( @@ -617,20 +657,17 @@ class LibraryPresenter( } else -> listOf(LibraryItem(manga, makeOrGetHeader(mapStatus(manga.status)))) } - }.flatten() + }.flatten().toMutableList() - val headers = tagItems.map { item -> + val hiddenDynamics = preferences.collapsedDynamicCategories().get() + var headers = tagItems.map { item -> Category.createCustom( item.key, preferences.librarySortingMode().getOrDefault(), preferences.librarySortingAscending().getOrDefault() ).apply { id = item.value.catId - if (name.contains("◘•◘")) { - val split = name.split("◘•◘") - name = split.first() - sourceId = split.last().toLongOrNull() - } + isHidden = getDynamicCategoryName(this) in hiddenDynamics } }.sortedBy { if (groupType == BY_TRACK_STATUS) { @@ -639,6 +676,26 @@ class LibraryPresenter( it.name } } + if (preferences.collapsedDynamicAtBottom().get()) { + headers = headers.filterNot { it.isHidden } + headers.filter { it.isHidden } + } + headers.forEach { category -> + val catId = category.id ?: return@forEach + val headerItem = + tagItems[category.name] + if (category.isHidden) { + val mangaToRemove = items.filter { it.header.catId == catId } + val mergedTitle = mangaToRemove.joinToString("-") { + it.manga.title + "-" + it.manga.author + } + sectionedLibraryItems[catId] = mangaToRemove + items.removeAll { it.header.catId == catId } + if (headerItem != null) items.add( + LibraryItem(LibraryManga.createHide(catId, mergedTitle), headerItem) + ) + } + } + headers.forEachIndexed { index, category -> category.order = index } return items to headers } @@ -649,9 +706,6 @@ class LibraryPresenter( SManga.LICENSED -> R.string.licensed SManga.ONGOING -> R.string.ongoing SManga.COMPLETED -> R.string.completed - SManga.CANCELLED -> R.string.cancelled - SManga.HIATUS -> R.string.hiatus - SManga.PUBLICATION_COMPLETE -> R.string.publication_complete else -> R.string.unknown } ) @@ -673,7 +727,7 @@ class LibraryPresenter( /** Create a default category with the sort set */ private fun createDefaultCategory(): Category { - val default = Category.createDefault(context) + val default = Category.createDefault(view.applicationContext ?: context) default.order = -1 val defOrder = preferences.defaultMangaOrder().getOrDefault() if (defOrder.firstOrNull()?.isLetter() == true) default.mangaSort = defOrder.first() @@ -683,7 +737,7 @@ class LibraryPresenter( /** Requests the library to be filtered. */ fun requestFilterUpdate() { - scope.launch { + presenterScope.launch { var mangaMap = allLibraryItems mangaMap = applyFilters(mangaMap) mangaMap = applySort(mangaMap) @@ -693,7 +747,7 @@ class LibraryPresenter( /** Requests the library to have download badges added/removed. */ fun requestDownloadBadgesUpdate() { - scope.launch { + presenterScope.launch { val mangaMap = allLibraryItems setDownloadCount(mangaMap) allLibraryItems = mangaMap @@ -705,7 +759,7 @@ class LibraryPresenter( /** Requests the library to have unread badges changed. */ fun requestUnreadBadgesUpdate() { - scope.launch { + presenterScope.launch { val mangaMap = allLibraryItems setUnreadBadge(mangaMap) allLibraryItems = mangaMap @@ -717,7 +771,7 @@ class LibraryPresenter( /** Requests the library to be sorted. */ private fun requestSortUpdate() { - scope.launch { + presenterScope.launch { var mangaMap = libraryItems mangaMap = applySort(mangaMap) sectionLibrary(mangaMap) @@ -749,7 +803,7 @@ class LibraryPresenter( * @param mangas the list of manga to delete. */ fun removeMangaFromLibrary(mangas: List) { - scope.launch { + presenterScope.launch { // Create a set of the list val mangaToDelete = mangas.distinctBy { it.id } mangaToDelete.forEach { it.favorite = false } @@ -760,28 +814,28 @@ class LibraryPresenter( /** Remove manga from the library and delete the downloads */ fun confirmDeletion(mangas: List) { - scope.launch { + launchIO { val mangaToDelete = mangas.distinctBy { it.id } mangaToDelete.forEach { manga -> - db.resetMangaInfo(manga).executeOnIO() coverCache.deleteFromCache(manga) val source = sourceManager.get(manga.source) as? HttpSource - if (source != null) + if (source != null) { downloadManager.deleteManga(manga, source) + } } } } /** Called when Library Service updates a manga, update the item as well */ - fun updateManga(manga: LibraryManga) { - scope.launch { + fun updateManga() { + presenterScope.launch { getLibrary() } } /** Undo the removal of the manga once in library */ fun reAddMangas(mangas: List) { - scope.launch { + presenterScope.launch { val mangaToAdd = mangas.distinctBy { it.id } mangaToAdd.forEach { it.favorite = true } db.insertMangas(mangaToAdd).executeOnIO() @@ -815,12 +869,12 @@ class LibraryPresenter( } /** Update a category's sorting */ - fun sortCategory(catId: Int, order: Int) { + fun sortCategory(catId: Int, order: Char) { val category = categories.find { catId == it.id } ?: return - category.mangaSort = ('a' + (order - 1)) + category.mangaSort = order if (catId == -1 || category.isDynamic) { - val sort = category.sortingMode() ?: LibrarySort.ALPHA - preferences.librarySortingMode().set(sort) + val sort = category.sortingMode() ?: LibrarySort.Title + preferences.librarySortingMode().set(sort.mainValue) preferences.librarySortingAscending().set(category.isAscending()) categories.forEach { it.mangaSort = category.mangaSort @@ -834,7 +888,7 @@ class LibraryPresenter( /** Update a category's order */ fun rearrangeCategory(catId: Int?, mangaIds: List) { - scope.launch { + presenterScope.launch { val category = categories.find { catId == it.id } ?: return@launch if (category.isDynamic) return@launch category.mangaSort = null @@ -851,7 +905,7 @@ class LibraryPresenter( catId: Int?, mangaIds: List ) { - scope.launch { + presenterScope.launch { val categoryId = catId ?: return@launch val category = categories.find { catId == it.id } ?: return@launch if (category.isDynamic) return@launch @@ -862,9 +916,10 @@ class LibraryPresenter( val mc = ArrayList() val categories = if (catId == 0) emptyList() - else + else { db.getCategoriesForManga(manga).executeOnIO() .filter { it.id != oldCatId } + listOf(category) + } for (cat in categories) { mc.add(MangaCategory.create(manga, cat)) @@ -891,33 +946,74 @@ class LibraryPresenter( } fun toggleCategoryVisibility(categoryId: Int) { - if (categories.find { it.id == categoryId }?.isDynamic == true) return - val categoriesHidden = preferences.collapsedCategories().getOrDefault().mapNotNull { - it.toIntOrNull() - }.toMutableSet() - if (categoryId in categoriesHidden) - categoriesHidden.remove(categoryId) - else - categoriesHidden.add(categoryId) - preferences.collapsedCategories().set(categoriesHidden.map { it.toString() }.toMutableSet()) + // if (categories.find { it.id == categoryId }?.isDynamic == true) return + if (groupType == BY_DEFAULT) { + val categoriesHidden = preferences.collapsedCategories().getOrDefault().mapNotNull { + it.toIntOrNull() + }.toMutableSet() + if (categoryId in categoriesHidden) { + categoriesHidden.remove(categoryId) + } else { + categoriesHidden.add(categoryId) + } + preferences.collapsedCategories() + .set(categoriesHidden.map { it.toString() }.toMutableSet()) + } else { + val categoriesHidden = preferences.collapsedDynamicCategories().get().toMutableSet() + val category = getCategory(categoryId) + val dynamicName = getDynamicCategoryName(category) + if (dynamicName in categoriesHidden) { + categoriesHidden.remove(dynamicName) + } else { + categoriesHidden.add(dynamicName) + } + preferences.collapsedDynamicCategories().set(categoriesHidden) + } getLibrary() } + private fun getDynamicCategoryName(category: Category): String = groupType.toString() + fun toggleAllCategoryVisibility() { - if (preferences.collapsedCategories().getOrDefault().isEmpty()) { - preferences.collapsedCategories().set(allCategories.map { it.id.toString() }.toMutableSet()) + if (groupType == BY_DEFAULT) { + if (allCategoriesExpanded()) { + preferences.collapsedCategories() + .set(allCategories.map { it.id.toString() }.toMutableSet()) + } else { + preferences.collapsedCategories().set(mutableSetOf()) + } } else { - preferences.collapsedCategories().set(mutableSetOf()) + if (allCategoriesExpanded()) { + preferences.collapsedDynamicCategories() += categories.map { + getDynamicCategoryName( + it + ) + } + } else { + preferences.collapsedDynamicCategories() -= categories.map { + getDynamicCategoryName( + it + ) + } + } } getLibrary() } + fun allCategoriesExpanded(): Boolean { + return if (groupType == BY_DEFAULT) { + preferences.collapsedCategories().getOrDefault().isEmpty() + } else { + categories.none { it.isHidden } + } + } + /** download All unread */ fun downloadUnread(mangaList: List) { - scope.launch { + presenterScope.launch { withContext(Dispatchers.IO) { mangaList.forEach { - val chapters = db.getChapters(it).executeAsBlocking().filter { !it.read }.filterIfUsingCache(downloadManager, it, preferences.useCacheSource()) + val chapters = db.getChapters(it).executeAsBlocking().filter { !it.read } downloadManager.downloadChapters(it, chapters) } } @@ -928,18 +1024,18 @@ class LibraryPresenter( } fun markReadStatus(mangaList: List, markRead: Boolean) { - scope.launch { + presenterScope.launch { withContext(Dispatchers.IO) { mangaList.forEach { withContext(Dispatchers.IO) { - val chapters = db.getChapters(it).executeAsBlocking().filterIfUsingCache(downloadManager, it, preferences.useCacheSource()) + val chapters = db.getChapters(it).executeAsBlocking() chapters.forEach { it.read = markRead it.last_page_read = 0 } db.updateChaptersProgress(chapters).executeAsBlocking() if (markRead && preferences.removeAfterMarkedAsRead()) { - downloadManager.deleteChapters(chapters, it, source) + deleteChapters(it, chapters) } } } @@ -950,7 +1046,7 @@ class LibraryPresenter( /** sync selected manga to mangadex follows */ fun syncMangaToDex(mangaList: List) { - scope.launch { + presenterScope.launch { withContext(Dispatchers.IO) { val isDexUp = source.checkIfUp() @@ -970,8 +1066,126 @@ class LibraryPresenter( } } + private fun deleteChapters(manga: Manga, chapters: List) { + sourceManager.get(manga.source)?.let { source -> + downloadManager.deleteChapters(chapters, manga, source) + } + } + companion object { private var lastLibraryItems: List? = null private var lastCategories: List? = null + private const val sourceSplitter = "◘•◘" + private const val dynamicCategorySplitter = "▄╪\t▄╪\t▄" + + private val randomTags = arrayOf(0, 1, 2) + private const val randomSource = 4 + private const val randomTitle = 3 + private const val randomTag = 0 + private val randomGroupOfTags = arrayOf(1, 2) + private const val randomGroupOfTagsNormal = 1 + private const val randomGroupOfTagsNegate = 2 + + suspend fun setSearchSuggestion( + preferences: PreferencesHelper, + db: DatabaseHelper, + sourceManager: SourceManager + ) { + val random: Random = { + val cal = Calendar.getInstance() + cal.time = Date() + cal[Calendar.MINUTE] = 0 + cal[Calendar.SECOND] = 0 + cal[Calendar.MILLISECOND] = 0 + Random(cal.time.time) + }() + + val recentManga by lazy { + runBlocking { + RecentsPresenter.getRecentManga(true).map { it.first } + } + } + val libraryManga by lazy { db.getLibraryMangas().executeAsBlocking() } + preferences.librarySearchSuggestion().set( + when (val value = random.nextInt(0, 5)) { + randomSource -> { + val distinctSources = libraryManga.distinctBy { it.source } + val randomSource = + sourceManager.get( + distinctSources.randomOrNull(random)?.source ?: 0L + )?.name + randomSource?.chopByWords(15) + } + randomTitle -> { + libraryManga.randomOrNull(random)?.title?.chopByWords(15) + } + in randomTags -> { + val tags = recentManga.map { + it.genre.orEmpty().split(",").map(String::trim) + } + .flatten() + .filter { it.isNotBlank() } + val distinctTags = tags.distinct() + if (value in randomGroupOfTags && distinctTags.size > 6) { + val shortestTagsSort = distinctTags.sortedBy { it.length } + val offset = random.nextInt(0, distinctTags.size / 2 - 2) + var offset2 = random.nextInt(0, distinctTags.size / 2 - 2) + while (offset2 == offset) { + offset2 = random.nextInt(0, distinctTags.size / 2 - 2) + } + if (value == randomGroupOfTagsNormal) { + "${shortestTagsSort[offset]}, " + shortestTagsSort[offset2] + } else { + "${shortestTagsSort[offset]}, -" + shortestTagsSort[offset2] + } + } else { + val group = tags.groupingBy { it }.eachCount() + val groupedTags = distinctTags.sortedByDescending { group[it] } + groupedTags.take(8).randomOrNull(random) + } + } + else -> "" + } ?: "" + ) + + if (preferences.showLibrarySearchSuggestions().isNotSet()) { + preferences.showLibrarySearchSuggestions().set(true) + } + preferences.lastLibrarySuggestion().set(Date().time) + } + + /** Give library manga to a date added based on min chapter fetch */ + fun updateDB() { + val db: DatabaseHelper = Injekt.get() + db.inTransaction { + val libraryManga = db.getLibraryMangas().executeAsBlocking() + libraryManga.forEach { manga -> + if (manga.date_added == 0L) { + val chapters = db.getChapters(manga).executeAsBlocking() + manga.date_added = chapters.minByOrNull { it.date_fetch }?.date_fetch ?: 0L + db.insertManga(manga).executeAsBlocking() + } + } + } + } + + fun updateCustoms() { + val db: DatabaseHelper = Injekt.get() + val cc: CoverCache = Injekt.get() + db.inTransaction { + val libraryManga = db.getLibraryMangas().executeAsBlocking() + libraryManga.forEach { manga -> + if (manga.thumbnail_url?.startsWith("custom", ignoreCase = true) == true) { + val file = cc.getCoverFile(manga) + if (file.exists()) { + file.renameTo(cc.getCustomCoverFile(manga)) + } + manga.thumbnail_url = + manga.thumbnail_url!!.toLowerCase(Locale.ROOT).substringAfter("custom-") + db.insertManga(manga).executeAsBlocking() + } + } + } + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySort.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySort.kt index 3ae4420fcd..49c5a82da9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySort.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibrarySort.kt @@ -1,13 +1,61 @@ package eu.kanade.tachiyomi.ui.library -object LibrarySort { - - const val ALPHA = 0 - const val LAST_READ = 1 - const val LATEST_CHAPTER = 2 - const val UNREAD = 3 - const val TOTAL = 4 - const val DATE_ADDED = 5 - const val RATING = 6 - const val DRAG_AND_DROP = 7 +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.base.MaterialMenuSheet + +enum class LibrarySort( + val mainValue: Int, + @StringRes private val stringRes: Int, + @DrawableRes private val iconRes: Int, + private val catValue: Int = mainValue, + @StringRes private val dynamicStringRes: Int = stringRes, + @DrawableRes private val dynamicIconRes: Int = iconRes +) { + + Title(0, R.string.title, R.drawable.ic_sort_by_alpha_24dp), + LastRead(1, R.string.last_read, R.drawable.ic_recent_read_outline_24dp, 3), + LatestChapter(2, R.string.latest_chapter, R.drawable.ic_new_releases_24dp, 1), + Unread(3, R.string.unread, R.drawable.ic_eye_24dp, 2), + TotalChapters(4, R.string.total_chapters, R.drawable.ic_sort_by_numeric_24dp), + DateAdded(5, R.string.date_added, R.drawable.ic_heart_outline_24dp), + DateFetched(6, R.string.date_fetched, R.drawable.ic_calendar_text_outline_24dp), + DragAndDrop( + 7, + R.string.drag_and_drop, + R.drawable.ic_swap_vert_24dp, + 7, + R.string.category, + R.drawable.ic_label_outline_24dp + ) + ; + + val categoryValue: Char + get() = if (this == DragAndDrop) 'D' else 'a' + catValue * 2 + + val categoryValueDescending: Char + get() = if (this == DragAndDrop) 'D' else 'b' + catValue * 2 + + @StringRes + fun stringRes(isDynamic: Boolean) = if (isDynamic) dynamicStringRes else stringRes + + @DrawableRes + fun iconRes(isDynamic: Boolean) = if (isDynamic) dynamicIconRes else iconRes + + val hasInvertedSort: Boolean + get() = this in listOf(LastRead, DateAdded, LatestChapter, DateFetched) + + fun menuSheetItem(isDynamic: Boolean): MaterialMenuSheet.MenuSheetItem { + return MaterialMenuSheet.MenuSheetItem( + mainValue, + iconRes(isDynamic), + stringRes(isDynamic) + ) + } + + companion object { + fun valueOf(value: Int) = values().find { it.mainValue == value } + fun valueOf(char: Char?) = values().find { it.categoryValue == char || it.categoryValueDescending == char } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/category/CategoryRecyclerView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/category/CategoryRecyclerView.kt index 46b5f8fd01..ed00f63d00 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/category/CategoryRecyclerView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/category/CategoryRecyclerView.kt @@ -26,6 +26,7 @@ class CategoryRecyclerView @JvmOverloads constructor( init { fastAdapter = FastAdapter.with(itemAdapter) + fastAdapter.setHasStableIds(true) layoutManager = manager adapter = fastAdapter } @@ -47,8 +48,9 @@ class CategoryRecyclerView @JvmOverloads constructor( } ) fastAdapter.onClickListener = { _, _, item, _ -> - if (item.category.id != -1) + if (item.category.id != -1) { onCategoryClicked(item.category.order) + } true } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/display/LibraryBadgesView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/display/LibraryBadgesView.kt new file mode 100644 index 0000000000..8c6cc5ccf1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/display/LibraryBadgesView.kt @@ -0,0 +1,22 @@ +package eu.kanade.tachiyomi.ui.library.display + +import android.content.Context +import android.util.AttributeSet +import eu.kanade.tachiyomi.databinding.LibraryBadgesLayoutBinding +import eu.kanade.tachiyomi.util.bindToPreference +import eu.kanade.tachiyomi.widget.BaseLibraryDisplayView + +class LibraryBadgesView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + BaseLibraryDisplayView(context, attrs) { + + override fun inflateBinding() = LibraryBadgesLayoutBinding.bind(this) + override fun initGeneralPreferences() { + binding.unreadBadgeGroup.bindToPreference(preferences.unreadBadgeType()) { + controller?.presenter?.requestUnreadBadgesUpdate() + } + binding.hideReading.bindToPreference(preferences.hideStartReadingButton()) + binding.downloadBadge.bindToPreference(preferences.downloadBadge()) { + controller?.presenter?.requestDownloadBadgesUpdate() + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/display/LibraryCategoryView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/display/LibraryCategoryView.kt new file mode 100644 index 0000000000..8c51f6fd5b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/display/LibraryCategoryView.kt @@ -0,0 +1,50 @@ +package eu.kanade.tachiyomi.ui.library.display + +import android.content.Context +import android.util.AttributeSet +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.LibraryCategoryLayoutBinding +import eu.kanade.tachiyomi.util.bindToPreference +import eu.kanade.tachiyomi.util.lang.withSubtitle +import eu.kanade.tachiyomi.util.system.toInt +import eu.kanade.tachiyomi.widget.BaseLibraryDisplayView +import kotlin.math.min + +class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + BaseLibraryDisplayView(context, attrs) { + + override fun inflateBinding() = LibraryCategoryLayoutBinding.bind(this) + override fun initGeneralPreferences() { + with(binding) { + showAll.bindToPreference(preferences.showAllCategories()) { + controller?.presenter?.getLibrary() + binding.categoryShow.isEnabled = it + } + categoryShow.isEnabled = showAll.isChecked + categoryShow.bindToPreference(preferences.showCategoryInTitle()) { + controller?.showMiniBar() + } + dynamicToBottom.text = context.getString(R.string.move_dynamic_to_bottom) + .withSubtitle(context, R.string.when_grouping_by_sources_tags) + dynamicToBottom.bindToPreference(preferences.collapsedDynamicAtBottom()) { + controller?.presenter?.getLibrary() + } + val hideHopper = min( + 2, + preferences.hideHopper().get().toInt() * 2 + preferences.autohideHopper().get() + .toInt() + ) + hideHopperSpinner.setSelection(hideHopper) + hideHopperSpinner.onItemSelectedListener = { + preferences.hideHopper().set(it == 2) + preferences.autohideHopper().set(it == 1) + controller?.hideHopper(it == 2) + controller?.resetHopperY() + } + addCategoriesButton.setOnClickListener { + controller?.showCategoriesController() + } + hopperLongPress.bindToPreference(preferences.hopperLongPressAction()) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/display/LibraryDisplayView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/display/LibraryDisplayView.kt new file mode 100644 index 0000000000..03c9a8facf --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/display/LibraryDisplayView.kt @@ -0,0 +1,148 @@ +package eu.kanade.tachiyomi.ui.library.display + +import android.animation.ValueAnimator +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewTreeObserver +import android.widget.SeekBar +import androidx.core.animation.addListener +import androidx.core.view.isVisible +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.materialdialogs.customview.customView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.LibraryDisplayLayoutBinding +import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet +import eu.kanade.tachiyomi.ui.library.filter.ManageFilterItem +import eu.kanade.tachiyomi.util.bindToPreference +import eu.kanade.tachiyomi.util.lang.withSubtitle +import eu.kanade.tachiyomi.util.system.dpToPx +import eu.kanade.tachiyomi.util.view.rowsForValue +import eu.kanade.tachiyomi.widget.BaseLibraryDisplayView +import eu.kanade.tachiyomi.widget.EndAnimatorListener +import kotlin.math.roundToInt + +class LibraryDisplayView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + BaseLibraryDisplayView(context, attrs) { + + var mainView: View? = null + override fun inflateBinding() = LibraryDisplayLayoutBinding.bind(this) + override fun initGeneralPreferences() { + binding.displayGroup.bindToPreference(preferences.libraryLayout()) + binding.uniformGrid.bindToPreference(preferences.uniformGrid()) + binding.gridSeekbar.progress = ((preferences.gridSize().get() + .5f) * 2f).roundToInt() + binding.resetGridSize.setOnClickListener { + binding.gridSeekbar.progress = 3 + } + + binding.reorderFiltersButton.setOnClickListener { + val recycler = RecyclerView(context) + var filterOrder = preferences.filterOrder().get() + if (filterOrder.count() != 6) { + filterOrder = FilterBottomSheet.Filters.DEFAULT_ORDER + } + val adapter = FlexibleAdapter( + filterOrder.toCharArray().map { + if (FilterBottomSheet.Filters.filterOf(it) != null) ManageFilterItem(it) + else null + }.filterNotNull(), + this, + true + ) + recycler.layoutManager = LinearLayoutManager(context) + recycler.adapter = adapter + adapter.isHandleDragEnabled = true + adapter.isLongPressDragEnabled = true + MaterialDialog(context).title(R.string.reorder_filters) + .customView(view = recycler, scrollable = false) + .negativeButton(android.R.string.cancel) + .positiveButton(R.string.reorder) { + val order = adapter.currentItems.map { it.char }.joinToString("") + preferences.filterOrder().set(order) + recycler.adapter = null + } + .show() + } + + binding.root.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + if (binding.root.width > 0) { + setGridText(binding.gridSeekbar.progress) + binding.root.viewTreeObserver.removeOnGlobalLayoutListener(this) + } + } + }) + binding.gridSeekbar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + if (seekBar != null && fromUser) { + alpha = 1f + isVisible = true + adjustSeekBarTip(seekBar, progress) + } + if (!fromUser) { + preferences.gridSize().set((progress / 2f) - .5f) + } + setGridText(progress) + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) { + with(binding.seekBarTextView.root) { + alpha = 0f + isVisible = true + animate().alpha(1f).setDuration(250L).start() + seekBar?.post { + adjustSeekBarTip(seekBar, seekBar.progress) + } + } + } + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + preferences.gridSize().set(((seekBar?.progress ?: 2) / 2f) - .5f) + with(binding.seekBarTextView.root) { + isVisible = true + alpha = 1f + post { + val anim = + ValueAnimator.ofFloat( + 1f, + 0f + ) // animate().alpha(0f).setDuration(250L) + anim.duration = 250 + anim.startDelay = 500 + anim.addUpdateListener { + alpha = it.animatedValue as Float + } + anim.addListener { + EndAnimatorListener { + isVisible = false + } + } + anim.start() + } + } + } + }) + } + + private fun setGridText(progress: Int) { + with(binding.gridSizeText) { + val rows = (mainView ?: this@LibraryDisplayView).rowsForValue(progress) + val titleText = context.getString(R.string.grid_size) + val subtitleText = context.getString(R.string._per_row, rows) + text = titleText.withSubtitle(context, subtitleText) + } + } + + private fun adjustSeekBarTip(seekBar: SeekBar, progress: Int) { + with(binding.seekBarTextView.root) { + val value = + (progress * (seekBar.width - 12.dpToPx - 2 * seekBar.thumbOffset)) / seekBar.max + text = (mainView ?: this@LibraryDisplayView).rowsForValue(progress).toString() + x = seekBar.x + value + seekBar.thumbOffset / 2 + 5.dpToPx + y = seekBar.y + binding.gridSizeLayout.y - 6.dpToPx - height + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/display/TabbedLibraryDisplaySheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/display/TabbedLibraryDisplaySheet.kt new file mode 100644 index 0000000000..34f1c76f3f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/display/TabbedLibraryDisplaySheet.kt @@ -0,0 +1,60 @@ +package eu.kanade.tachiyomi.ui.library.display + +import android.view.View +import android.view.View.inflate +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import com.bluelinelabs.conductor.Controller +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.library.LibraryController +import eu.kanade.tachiyomi.ui.setting.SettingsLibraryController +import eu.kanade.tachiyomi.util.view.compatToolTipText +import eu.kanade.tachiyomi.util.view.withFadeTransaction +import eu.kanade.tachiyomi.widget.TabbedBottomSheetDialog + +open class TabbedLibraryDisplaySheet(val controller: Controller) : + TabbedBottomSheetDialog(controller.activity!!) { + + private val displayView: LibraryDisplayView = inflate(controller.activity!!, R.layout.library_display_layout, null) as LibraryDisplayView + private val badgesView: LibraryBadgesView = inflate(controller.activity!!, R.layout.library_badges_layout, null) as LibraryBadgesView + private val categoryView: LibraryCategoryView = inflate(controller.activity!!, R.layout.library_category_layout, null) as LibraryCategoryView + + init { + (controller as? LibraryController)?.let { libraryController -> + displayView.controller = libraryController + badgesView.controller = libraryController + categoryView.controller = libraryController + } + displayView.mainView = controller.view + binding.menu.isVisible = controller !is SettingsLibraryController + binding.menu.compatToolTipText = context.getString(R.string.more_library_settings) + binding.menu.setImageDrawable( + ContextCompat.getDrawable( + context, + R.drawable.ic_outline_settings_24dp + ) + ) + binding.menu.setOnClickListener { + controller.router.pushController(SettingsLibraryController().withFadeTransaction()) + dismiss() + } + categoryView.binding.addCategoriesButton.isVisible = controller is LibraryController + } + + override fun dismiss() { + super.dismiss() + (controller as? LibraryController)?.displaySheet = null + } + + override fun getTabViews(): List = listOf( + displayView, + badgesView, + categoryView + ) + + override fun getTabTitles(): List = listOf( + R.string.display, + R.string.badges, + R.string.categories + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/FilterBottomSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/FilterBottomSheet.kt index 64fac54739..010572b098 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/FilterBottomSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/FilterBottomSheet.kt @@ -1,42 +1,40 @@ package eu.kanade.tachiyomi.ui.library.filter import android.content.Context -import android.content.res.Configuration import android.os.Parcelable import android.util.AttributeSet import android.view.View import android.widget.ImageView import android.widget.LinearLayout -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.afollestad.materialdialogs.MaterialDialog -import com.afollestad.materialdialogs.customview.customView +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat import com.google.android.material.bottomsheet.BottomSheetBehavior -import eu.davidea.flexibleadapter.FlexibleAdapter import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.databinding.FilterBottomSheetBinding import eu.kanade.tachiyomi.ui.library.LibraryController import eu.kanade.tachiyomi.ui.library.LibraryGroup import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.launchUI +import eu.kanade.tachiyomi.util.view.activityBinding import eu.kanade.tachiyomi.util.view.collapse -import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.view.compatToolTipText import eu.kanade.tachiyomi.util.view.hide import eu.kanade.tachiyomi.util.view.inflate import eu.kanade.tachiyomi.util.view.isExpanded import eu.kanade.tachiyomi.util.view.isHidden import eu.kanade.tachiyomi.util.view.updatePaddingRelative -import eu.kanade.tachiyomi.util.view.visibleIf -import kotlinx.android.synthetic.main.filter_bottom_sheet.view.* -import kotlinx.android.synthetic.main.library_grid_recycler.* -import kotlinx.android.synthetic.main.library_list_controller.* import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import uy.kohesive.injekt.Injekt @@ -55,6 +53,8 @@ class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: Attri */ private val preferences: PreferencesHelper by injectLazy() + private lateinit var binding: FilterBottomSheetBinding + private val trackManager: TrackManager by injectLazy() private val hasTracking @@ -90,70 +90,74 @@ class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: Attri list.add(unread) list.add(downloaded) list.add(completed) - if (hasTracking) + if (hasTracking) { tracked?.let { list.add(it) } + } list.add(merged) list.add(missingChapters) list } var onGroupClicked: (Int) -> Unit = { _ -> } - var pager: View? = null + var libraryRecyler: View? = null var controller: LibraryController? = null + var bottomBarHeight = 0 + + override fun onFinishInflate() { + super.onFinishInflate() + binding = FilterBottomSheetBinding.bind(this) + } fun onCreate(controller: LibraryController) { - clearButton = clear_button - filter_layout.removeView(clearButton) + clearButton = binding.clearButton + binding.filterLayout.removeView(clearButton) sheetBehavior = BottomSheetBehavior.from(this) sheetBehavior?.isHideable = true this.controller = controller - pager = controller.recycler - val shadow2: View = controller.shadow2 - val shadow: View = controller.shadow + libraryRecyler = controller.binding.libraryGridRecycler.recycler + libraryRecyler?.post { + bottomBarHeight = + controller.activityBinding?.bottomNav?.height + ?: controller.activityBinding?.root?.rootWindowInsets?.systemWindowInsetBottom + ?: 0 + } + val shadow2: View = controller.binding.shadow2 + val shadow: View = controller.binding.shadow sheetBehavior?.addBottomSheetCallback( object : BottomSheetBehavior.BottomSheetCallback() { override fun onSlide(bottomSheet: View, progress: Float) { - pill.alpha = (1 - max(0f, progress)) * 0.25f + this@FilterBottomSheet.controller?.updateFilterSheetY() + binding.pill.alpha = (1 - max(0f, progress)) * 0.25f shadow2.alpha = (1 - max(0f, progress)) * 0.25f shadow.alpha = 1 + min(0f, progress) updateRootPadding(progress) } override fun onStateChanged(p0: View, state: Int) { + this@FilterBottomSheet.controller?.updateFilterSheetY() stateChanged(state) } } ) - if (context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { - second_layout.removeView(view_options) - second_layout.removeView(reorder_filters) - first_layout.addView(reorder_filters) - first_layout.addView(view_options) - second_layout.gone() - } - - if (preferences.hideFiltersAtStart().getOrDefault()) { - sheetBehavior?.hide() - } - expand_categories.setOnClickListener { + sheetBehavior?.hide() + binding.expandCategories.setOnClickListener { onGroupClicked(ACTION_EXPAND_COLLAPSE_ALL) } - group_by.setOnClickListener { + binding.groupBy.setOnClickListener { onGroupClicked(ACTION_GROUP_BY) } - view_options.setOnClickListener { + binding.viewOptions.setOnClickListener { onGroupClicked(ACTION_DISPLAY) } - reorder_filters.setOnClickListener { - manageFilterPopup() - } val activeFilters = hasActiveFiltersFromPref() - if (activeFilters && sheetBehavior.isHidden() && sheetBehavior?.skipCollapsed == false) + if (activeFilters && sheetBehavior.isHidden() && sheetBehavior?.skipCollapsed == false) { sheetBehavior?.collapse() + } post { + libraryRecyler ?: return@post updateRootPadding( when (sheetBehavior?.state) { BottomSheetBehavior.STATE_HIDDEN -> -1f @@ -162,43 +166,48 @@ class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: Attri } ) shadow.alpha = if (sheetBehavior.isHidden()) 0f else 1f + + if (binding.secondLayout.width + (binding.groupBy.width * 2) + 20.dpToPx < width) { + binding.secondLayout.removeView(binding.viewOptions) + binding.firstLayout.addView(binding.viewOptions) + binding.secondLayout.isVisible = false + } else if (binding.viewOptions.parent == binding.firstLayout) { + binding.firstLayout.removeView(binding.viewOptions) + binding.secondLayout.addView(binding.viewOptions) + binding.secondLayout.isVisible = true + } } createTags() clearButton.setOnClickListener { clearFilters() } - } - fun setExpandText(expand: Boolean) { - expand_categories.setText( - if (expand) { - R.string.expand_all_categories - } else { - R.string.collapse_all_categories - } - ) - expand_categories.setIconResource( - if (expand) { - R.drawable.ic_expand_less_24dp - } else { - R.drawable.ic_expand_more_24dp + setExpandText(controller.canCollapseOrExpandCategory(), false) + + clearButton.compatToolTipText = context.getString(R.string.clear_filters) + preferences.filterOrder().asFlow() + .drop(1) + .onEach { + filterOrder = it + clearFilters() } - ) + .launchIn(controller.viewScope) } private fun stateChanged(state: Int) { - val shadow = controller?.shadow ?: return + val shadow = controller?.binding?.shadow ?: return + controller?.updateHopperY() if (state == BottomSheetBehavior.STATE_COLLAPSED) { shadow.alpha = 1f - pager?.updatePaddingRelative(bottom = sheetBehavior?.peekHeight ?: 0 + 10.dpToPx) + libraryRecyler?.updatePaddingRelative(bottom = sheetBehavior?.peekHeight ?: 0 + 10.dpToPx + bottomBarHeight) } if (state == BottomSheetBehavior.STATE_EXPANDED) { - pill.alpha = 0f + binding.pill.alpha = 0f } if (state == BottomSheetBehavior.STATE_HIDDEN) { onGroupClicked(ACTION_HIDE_FILTER_TIP) reSortViews() shadow.alpha = 0f - pager?.updatePaddingRelative(bottom = 10.dpToPx) + libraryRecyler?.updatePaddingRelative(bottom = 10.dpToPx + bottomBarHeight) } } @@ -215,10 +224,42 @@ class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: Attri ?: if (sheetBehavior.isExpanded()) 1f else 0f val percent = (trueProgress * 100).roundToInt() val value = (percent * (maxHeight - minHeight) / 100) + minHeight - if (trueProgress >= 0) - pager?.updatePaddingRelative(bottom = value + 10.dpToPx) - else - pager?.updatePaddingRelative(bottom = (minHeight * (1 + trueProgress)).toInt()) + if (trueProgress >= 0) { + libraryRecyler?.updatePaddingRelative(bottom = value + 10.dpToPx + bottomBarHeight) + } else { + libraryRecyler?.updatePaddingRelative(bottom = (minHeight * (1 + trueProgress)).toInt() + bottomBarHeight) + } + } + + fun setExpandText(allExpanded: Boolean?, animated: Boolean = true) { + binding.expandCategories.isVisible = allExpanded != null + allExpanded ?: return + binding.expandCategories.setText( + if (!allExpanded) { + R.string.expand_all_categories + } else { + R.string.collapse_all_categories + } + ) + if (animated) { + binding.expandCategories.icon = AnimatedVectorDrawableCompat.create( + binding.expandCategories.context, + if (!allExpanded) { + R.drawable.anim_expand_less_to_more + } else { + R.drawable.anim_expand_more_to_less + } + ) + (binding.expandCategories.icon as? AnimatedVectorDrawableCompat)?.start() + } else { + binding.expandCategories.setIconResource( + if (!allExpanded) { + R.drawable.ic_expand_more_24dp + } else { + R.drawable.ic_expand_less_24dp + } + ) + } } fun hasActiveFilters() = filterItems.any { it.isActivated } @@ -235,27 +276,26 @@ class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: Attri } private fun createTags() { - - downloaded = inflate(R.layout.filter_buttons) as FilterTagGroup + downloaded = inflate(R.layout.filter_tag_group) as FilterTagGroup downloaded.setup(this, R.string.downloaded, R.string.not_downloaded) - completed = inflate(R.layout.filter_buttons) as FilterTagGroup + completed = inflate(R.layout.filter_tag_group) as FilterTagGroup completed.setup(this, R.string.completed, R.string.ongoing) - unreadProgress = inflate(R.layout.filter_buttons) as FilterTagGroup + unreadProgress = inflate(R.layout.filter_tag_group) as FilterTagGroup unreadProgress.setup(this, R.string.not_started, R.string.in_progress) - unread = inflate(R.layout.filter_buttons) as FilterTagGroup + unread = inflate(R.layout.filter_tag_group) as FilterTagGroup unread.setup(this, R.string.unread, R.string.read) - missingChapters = inflate(R.layout.filter_buttons) as FilterTagGroup + missingChapters = inflate(R.layout.filter_tag_group) as FilterTagGroup missingChapters.setup(this, R.string.has_missing_chp, R.string.no_missing_chp) - merged = inflate(R.layout.filter_buttons) as FilterTagGroup + merged = inflate(R.layout.filter_tag_group) as FilterTagGroup merged.setup(this, R.string.merged, R.string.not_merged) if (hasTracking) { - tracked = inflate(R.layout.filter_buttons) as FilterTagGroup + tracked = inflate(R.layout.filter_tag_group) as FilterTagGroup tracked?.setup(this, R.string.tracked, R.string.not_tracked) } @@ -273,12 +313,12 @@ class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: Attri } val libraryManga = db.getLibraryMangas().executeAsBlocking() val types = mutableListOf() - if (libraryManga.any { it.mangaType() == Manga.TYPE_MANHWA }) types.add(R.string.manhwa) - if (libraryManga.any { it.mangaType() == Manga.TYPE_MANHUA }) types.add(R.string.manhua) - if (libraryManga.any { it.mangaType() == Manga.TYPE_COMIC }) types.add(R.string.comic) + if (libraryManga.any { it.seriesType() == Manga.TYPE_MANHWA }) types.add(R.string.manhwa) + if (libraryManga.any { it.seriesType() == Manga.TYPE_MANHUA }) types.add(R.string.manhua) + if (libraryManga.any { it.seriesType() == Manga.TYPE_COMIC }) types.add(R.string.comic) if (types.isNotEmpty()) { launchUI { - val mangaType = inflate(R.layout.filter_buttons) as FilterTagGroup + val mangaType = inflate(R.layout.filter_tag_group) as FilterTagGroup mangaType.setup( this@FilterBottomSheet, R.string.manga, @@ -297,7 +337,7 @@ class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: Attri val unreadP = preferences.filterUnread().getOrDefault() if (unreadP <= 2) { unread.state = unreadP - 1 - } else if (unreadP > 3) { + } else if (unreadP >= 3) { unreadProgress.state = unreadP - 3 } tracked?.setState(preferences.filterTracked()) @@ -319,7 +359,7 @@ class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: Attri if (loggedServices.size > 1) { val serviceNames = loggedServices.map { context.getString(it.nameRes()) } withContext(Dispatchers.Main) { - trackers = inflate(R.layout.filter_buttons) as FilterTagGroup + trackers = inflate(R.layout.filter_tag_group) as FilterTagGroup trackers?.setup( this@FilterBottomSheet, serviceNames.first(), @@ -327,7 +367,7 @@ class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: Attri serviceNames.getOrNull(2) ) if (tracked?.isActivated == true) { - filter_layout.addView(trackers) + binding.filterLayout.addView(trackers) filterItems.add(trackers!!) trackers?.setState(FILTER_TRACKER) reSortViews() @@ -354,66 +394,18 @@ class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: Attri } } - private fun indexOf(filterTagGroup: FilterTagGroup): Int { - charOfFilter(filterTagGroup)?.let { - return filterOrder.indexOf(it) - } - return 0 - } - private fun addForClear(): Int { return if (clearButton.parent != null) 1 else 0 } - private fun charOfFilter(filterTagGroup: FilterTagGroup): Char? { - return when (filterTagGroup) { - unreadProgress -> 'u' - unread -> 'r' - downloaded -> 'd' - completed -> 'c' - mangaType -> 'm' - tracked -> 't' - merged -> 'n' - missingChapters -> 'o' - else -> null - } - } - - fun manageFilterPopup() { - val recycler = RecyclerView(context) - if (filterOrder.count() != 8) - filterOrder = "urdcmtno" - val adapter = FlexibleAdapter( - filterOrder.toCharArray().map(::ManageFilterItem), - this, true - ) - recycler.layoutManager = LinearLayoutManager(context) - recycler.adapter = adapter - adapter.isHandleDragEnabled = true - adapter.isLongPressDragEnabled = true - MaterialDialog(context).title(R.string.reorder_filters) - .customView(view = recycler, scrollable = false) - .negativeButton(android.R.string.cancel) - .positiveButton(android.R.string.ok) { - val order = adapter.currentItems.map { it.char }.joinToString("") - preferences.filterOrder().set(order) - filterOrder = order - clearFilters() - recycler.adapter = null - } - .show() - } - private fun mapOfFilters(char: Char): FilterTagGroup? { - return when (char) { - 'u' -> unreadProgress - 'r' -> unread - 'd' -> downloaded - 'c' -> completed - 'm' -> mangaType - 'n' -> merged - 'o' -> missingChapters - 't' -> if (hasTracking) tracked else null + return when (Filters.filterOf(char)) { + Filters.UnreadProgress -> unreadProgress + Filters.Unread -> unread + Filters.Downloaded -> downloaded + Filters.Completed -> completed + Filters.SeriesType -> mangaType + Filters.Tracked -> if (hasTracking) tracked else null else -> null } } @@ -461,27 +453,26 @@ class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: Attri } val hasFilters = hasActiveFilters() if (hasFilters && clearButton.parent == null) { - filter_layout.addView(clearButton, 0) + binding.filterLayout.addView(clearButton, 0) } else if (!hasFilters && clearButton.parent != null) { - filter_layout.removeView(clearButton) + binding.filterLayout.removeView(clearButton) } if (tracked?.isActivated == true && trackers != null && trackers?.parent == null) { - filter_layout.addView(trackers, filterItems.indexOf(tracked!!) + 2) + binding.filterLayout.addView(trackers, filterItems.indexOf(tracked!!) + 2) filterItems.add(filterItems.indexOf(tracked!!) + 1, trackers!!) } else if (tracked?.isActivated == false && trackers?.parent != null) { - filter_layout.removeView(trackers) + binding.filterLayout.removeView(trackers) trackers?.reset() FILTER_TRACKER = "" filterItems.remove(trackers!!) } } - fun updateButtons(showExpand: Boolean, groupType: Int) { - expand_categories.visibleIf(showExpand && groupType == 0) - group_by.setIconResource(LibraryGroup.groupTypeDrawableRes(groupType)) + fun updateGroupTypeButton(groupType: Int) { + binding.groupBy.setIconResource(LibraryGroup.groupTypeDrawableRes(groupType)) } - private fun clearFilters() { + fun clearFilters() { preferences.filterDownloaded().set(0) preferences.filterUnread().set(0) preferences.filterCompleted().set(0) @@ -493,7 +484,7 @@ class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: Attri val transition = androidx.transition.AutoTransition() transition.duration = 150 - androidx.transition.TransitionManager.beginDelayedTransition(filter_layout, transition) + androidx.transition.TransitionManager.beginDelayedTransition(binding.filterLayout, transition) reorderFilters() filterItems.forEach { it.reset() @@ -506,16 +497,17 @@ class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: Attri } private fun reSortViews() { - filter_layout.removeAllViews() - if (filterItems.any { it.isActivated }) - filter_layout.addView(clearButton) + binding.filterLayout.removeAllViews() + if (filterItems.any { it.isActivated }) { + binding.filterLayout.addView(clearButton) + } filterItems.filter { it.isActivated }.forEach { - filter_layout.addView(it) + binding.filterLayout.addView(it) } filterItems.filterNot { it.isActivated }.forEach { - filter_layout.addView(it) + binding.filterLayout.addView(it) } - filter_scroll.scrollTo(0, 0) + binding.filterScroll.scrollTo(0, 0) } companion object { @@ -533,4 +525,26 @@ class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: Attri var FILTER_TRACKER = "" private set } + + enum class Filters(val value: Char, @StringRes val stringRes: Int) { + UnreadProgress('u', R.string.read_progress), + Unread('r', R.string.unread), + Downloaded('d', R.string.downloaded), + Completed('c', R.string.status), + SeriesType('m', R.string.series_type), + Tracked('t', R.string.tracked); + + companion object { + val DEFAULT_ORDER = listOf( + UnreadProgress, + Unread, + Downloaded, + Completed, + SeriesType, + Tracked + ).joinToString("") { it.value.toString() } + + fun filterOf(char: Char) = values().find { it.value == char } + } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/FilterTagGroup.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/FilterTagGroup.kt index e3b68de6c2..3fbfa48565 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/FilterTagGroup.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/FilterTagGroup.kt @@ -1,19 +1,17 @@ package eu.kanade.tachiyomi.ui.library.filter import android.content.Context -import android.graphics.Color import android.util.AttributeSet import android.view.View import android.view.ViewGroup import android.widget.LinearLayout +import androidx.core.view.isVisible import com.f2prateek.rx.preferences.Preference +import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.databinding.FilterTagGroupBinding import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.getResourceColor -import eu.kanade.tachiyomi.util.view.gone -import eu.kanade.tachiyomi.util.view.visible -import eu.kanade.tachiyomi.util.view.visibleIf -import kotlinx.android.synthetic.main.filter_buttons.view.* class FilterTagGroup@JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : LinearLayout (context, attrs) { @@ -25,31 +23,52 @@ class FilterTagGroup@JvmOverloads constructor(context: Context, attrs: Attribute private var root: ViewGroup? = null - private val buttons by lazy { arrayOf(firstButton, secondButton, thirdButton, fourthButton) } - private val separators by lazy { arrayOf(separator1, separator2, separator3) } + private val buttons by lazy { + arrayOf( + binding.firstButton, + binding.secondButton, + binding.thirdButton, + binding.fourthButton + ) + } + + private val separators by lazy { + arrayOf( + binding.separator1, + binding.separator2, + binding.separator3 + ) + } override fun isActivated(): Boolean { return buttons.any { it.isActivated } } + lateinit var binding: FilterTagGroupBinding + + override fun onFinishInflate() { + super.onFinishInflate() + binding = FilterTagGroupBinding.bind(this) + } + fun nameOf(index: Int): String? = buttons.getOrNull(index)?.text as? String fun setup(root: ViewGroup, firstText: Int, vararg extra: Int?) { val text1 = context.getString(firstText) val strings = extra.mapNotNull { if (it != null) context.getString(it) else null } - setup(root, text1, extra = *strings.toTypedArray()) + setup(root, text1, extra = strings.toTypedArray()) } fun setup(root: ViewGroup, firstText: String, vararg extra: String?) { listener = root as? FilterTagGroupListener (layoutParams as? MarginLayoutParams)?.rightMargin = 5.dpToPx (layoutParams as? MarginLayoutParams)?.leftMargin = 5.dpToPx - firstButton.text = firstText + binding.firstButton.text = firstText val extras = (extra.toList() + listOf(null, null, null)).take(separators.size) extras.forEachIndexed { index, text -> buttons[index + 1].text = text - separators[index].visibleIf(text != null) - buttons[index + 1].visibleIf(text != null) + separators[index].isVisible = text != null + buttons[index + 1].isVisible = text != null } itemCount = buttons.count { !it.text.isNullOrBlank() } this.root = root @@ -75,31 +94,39 @@ class FilterTagGroup@JvmOverloads constructor(context: Context, attrs: Attribute it.isActivated = false } for (i in 0 until itemCount) { - buttons[i].visible() + buttons[i].isVisible = true buttons[i].setTextColor(context.getResourceColor(android.R.attr.textColorPrimary)) } - for (i in 0 until (itemCount - 1)) separators[i].visible() + for (i in 0 until (itemCount - 1)) separators[i].isVisible = true } private fun toggleButton(index: Int, callBack: Boolean = true) { if (index < 0 || itemCount == 0 || (isActivated && index != buttons.indexOfFirst { it.isActivated }) - ) + ) { return + } if (callBack) { val transition = androidx.transition.AutoTransition() transition.duration = 150 androidx.transition.TransitionManager.beginDelayedTransition( - parent.parent as ViewGroup, transition + parent.parent as ViewGroup, + transition ) } if (itemCount == 1) { - firstButton.isActivated = !firstButton.isActivated - firstButton.setTextColor( - if (firstButton.isActivated) Color.WHITE else context - .getResourceColor(android.R.attr.textColorPrimary) + binding.firstButton.isActivated = !binding.firstButton.isActivated + binding.firstButton.setTextColor( + context.getResourceColor( + if (binding.firstButton.isActivated) R.attr.colorOnAccent + else android.R.attr.textColorPrimary + ) + ) + listener?.onFilterClicked( + this, + if (binding.firstButton.isActivated) index else -1, + callBack ) - listener?.onFilterClicked(this, if (firstButton.isActivated) index else -1, callBack) return } val mainButton = buttons[index] @@ -109,21 +136,23 @@ class FilterTagGroup@JvmOverloads constructor(context: Context, attrs: Attribute listener?.onFilterClicked(this, -1, callBack) buttons.forEachIndexed { viewIndex, textView -> if (!textView.text.isNullOrBlank()) { - textView.visible() + textView.isVisible = true if (viewIndex > 0) { - separators[viewIndex - 1].visible() + separators[viewIndex - 1].isVisible = true } } } } else { mainButton.isActivated = true listener?.onFilterClicked(this, index, callBack) - buttons.forEach { if (it != mainButton) it.gone() } - separators.forEach { it.gone() } + buttons.forEach { if (it != mainButton) it.isVisible = false } + separators.forEach { it.isVisible = false } } mainButton.setTextColor( - if (mainButton.isActivated) Color.WHITE else context - .getResourceColor(android.R.attr.textColorPrimary) + context.getResourceColor( + if (mainButton.isActivated) R.attr.colorOnAccent + else android.R.attr.textColorPrimary + ) ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/ManageFilterItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/ManageFilterItem.kt index a9d9a2a246..cf6322fc42 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/ManageFilterItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/ManageFilterItem.kt @@ -1,14 +1,14 @@ package eu.kanade.tachiyomi.ui.library.filter import android.view.View +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractFlexibleItem import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.CategoriesItemBinding import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import eu.kanade.tachiyomi.util.view.gone -import kotlinx.android.synthetic.main.categories_item.* /** * Category item for a recycler view. @@ -74,27 +74,16 @@ class ManageFilterItem(val char: Char) : AbstractFlexibleItem>) : BaseFlexibleViewHolder(view, adapter, true) { + private val binding = CategoriesItemBinding.bind(view) init { - image.gone() - edit_button.gone() - edit_text.isEnabled = false - setDragHandleView(reorder) + binding.image.isVisible = false + binding.editButton.isVisible = false + binding.editText.isVisible = false + setDragHandleView(binding.reorder) } fun bind(char: Char) { - title.setText( - when (char) { - 'u' -> R.string.read_progress - 'r' -> R.string.unread - 'd' -> R.string.downloaded - 'c' -> R.string.status - 'm' -> R.string.series_type - 't' -> R.string.tracked - 'n' -> R.string.merged - 'o' -> R.string.has_missing_chp - else -> R.string.unread - } - ) + binding.title.setText(FilterBottomSheet.Filters.filterOf(char)?.stringRes ?: 0) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogController.kt deleted file mode 100644 index c26ab9911d..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/ChangelogDialogController.kt +++ /dev/null @@ -1,32 +0,0 @@ -package eu.kanade.tachiyomi.ui.main - -import android.app.Dialog -import android.content.Context -import android.os.Bundle -import android.util.AttributeSet -import com.afollestad.materialdialogs.MaterialDialog -import com.afollestad.materialdialogs.customview.customView -import eu.kanade.tachiyomi.BuildConfig -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.controller.DialogController -import it.gmariotti.changelibs.library.view.ChangeLogRecyclerView - -class ChangelogDialogController : DialogController() { - - override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val activity = activity!! - val view = WhatsNewRecyclerView(activity) - return MaterialDialog(activity) - .title(text = if (BuildConfig.DEBUG) "Notices" else "Changelog") - .customView(view = view, scrollable = false) - .positiveButton(android.R.string.yes) - } - - class WhatsNewRecyclerView(context: Context) : ChangeLogRecyclerView(context) { - override fun initAttrs(attrs: AttributeSet?, defStyle: Int) { - mRowLayoutId = R.layout.changelog_row_layout - mRowHeaderLayoutId = R.layout.changelog_header_layout - mChangeLogFileResourceId = R.raw.changelog_release - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index ff3f46a45c..05253669a9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.main import android.animation.AnimatorSet import android.animation.ValueAnimator +import android.app.Dialog import android.content.Intent import android.graphics.Color import android.graphics.Rect @@ -11,6 +12,8 @@ import android.os.Bundle import android.os.Handler import android.provider.Settings import android.view.GestureDetector +import android.view.Gravity +import android.view.Menu import android.view.MenuItem import android.view.MotionEvent import android.view.View @@ -18,20 +21,24 @@ import android.view.ViewGroup import android.view.WindowInsets import android.view.WindowManager import android.webkit.WebView +import androidx.annotation.IdRes +import androidx.appcompat.graphics.drawable.DrawerArrowDrawable +import androidx.appcompat.widget.Toolbar +import androidx.core.content.ContextCompat import androidx.core.graphics.ColorUtils +import androidx.core.net.toUri import androidx.core.view.GestureDetectorCompat +import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import com.bluelinelabs.conductor.Conductor import com.bluelinelabs.conductor.Controller import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.Router -import com.bluelinelabs.conductor.RouterTransaction -import com.bluelinelabs.conductor.changehandler.FadeChangeHandler import com.elvishew.xlog.XLog import com.getkeepsafe.taptargetview.TapTarget import com.getkeepsafe.taptargetview.TapTargetView +import com.google.android.material.navigation.NavigationBarView import com.google.android.material.snackbar.Snackbar -import com.mikepenz.iconics.typeface.library.materialdesigndx.MaterialDesignDx import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.Migrations import eu.kanade.tachiyomi.R @@ -41,61 +48,65 @@ import eu.kanade.tachiyomi.data.download.DownloadServiceListener import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.preference.asImmediateFlowIn import eu.kanade.tachiyomi.data.updater.UpdateChecker import eu.kanade.tachiyomi.data.updater.UpdateResult +import eu.kanade.tachiyomi.data.updater.UpdaterNotifier +import eu.kanade.tachiyomi.databinding.MainActivityBinding import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.ui.base.MaterialMenuSheet import eu.kanade.tachiyomi.ui.base.activity.BaseActivity import eu.kanade.tachiyomi.ui.base.controller.BaseController import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.library.LibraryController import eu.kanade.tachiyomi.ui.manga.MangaDetailsController -import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersController -import eu.kanade.tachiyomi.ui.recently_read.RecentlyReadController import eu.kanade.tachiyomi.ui.recents.RecentsController +import eu.kanade.tachiyomi.ui.recents.RecentsPresenter import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate import eu.kanade.tachiyomi.ui.setting.AboutController import eu.kanade.tachiyomi.ui.setting.SettingsController import eu.kanade.tachiyomi.ui.setting.SettingsMainController import eu.kanade.tachiyomi.ui.source.browse.BrowseSourceController +import eu.kanade.tachiyomi.util.manga.MangaShortcutManager import eu.kanade.tachiyomi.util.system.contextCompatDrawable import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.hasSideNavBar -import eu.kanade.tachiyomi.util.system.iconicsDrawableMedium import eu.kanade.tachiyomi.util.system.isBottomTappable +import eu.kanade.tachiyomi.util.system.isTablet import eu.kanade.tachiyomi.util.system.launchUI +import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.view.doOnApplyWindowInsets import eu.kanade.tachiyomi.util.view.getItemView import eu.kanade.tachiyomi.util.view.snack import eu.kanade.tachiyomi.util.view.updateLayoutParams import eu.kanade.tachiyomi.util.view.updatePadding -import eu.kanade.tachiyomi.util.view.visibleIf import eu.kanade.tachiyomi.util.view.withFadeTransaction import eu.kanade.tachiyomi.widget.EndAnimatorListener -import eu.kanade.tachiyomi.widget.preference.MangadexLoginDialog -import kotlinx.android.synthetic.main.main_activity.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import timber.log.Timber import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy import java.util.Date import java.util.concurrent.TimeUnit import kotlin.math.abs +import kotlin.math.max -open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLoginDialog.Listener { +open class MainActivity : BaseActivity(), DownloadServiceListener { protected lateinit var router: Router val source: Source by lazy { Injekt.get().getMangadex() } - var backArrow: Drawable? = null + var drawerArrow: DrawerArrowDrawable? = null private set private var searchDrawable: Drawable? = null private var dismissDrawable: Drawable? = null - private lateinit var gestureDetector: GestureDetectorCompat + private var gestureDetector: GestureDetectorCompat? = null private var snackBar: Snackbar? = null private var extraViewForUndo: View? = null @@ -103,11 +114,15 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin private var animationSet: AnimatorSet? = null private val downloadManager: DownloadManager by injectLazy() + private val mangaShortcutManager: MangaShortcutManager by injectLazy() private val hideBottomNav - get() = router.backstackSize > 1 && router.backstack[1].controller() !is DialogController + get() = router.backstackSize > 1 && router.backstack[1].controller !is DialogController private val updateChecker by lazy { UpdateChecker.getUpdateChecker() } private val isUpdaterEnabled = BuildConfig.INCLUDE_UPDATER + var tabAnimation: ValueAnimator? = null + var overflowDialog: Dialog? = null + var currentToolbar: Toolbar? = null fun setUndoSnackBar(snackBar: Snackbar?, extraViewToCheck: View? = null) { this.snackBar = snackBar @@ -121,6 +136,9 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin extraViewForUndo = extraViewToCheck } + val toolbarHeight: Int + get() = max(binding.toolbar.height, binding.cardFrame.height) + override fun onCreate(savedInstanceState: Bundle?) { // Create a webview before extensions do or else they will break night mode theme // https://stackoverflow.com/questions/54191883 @@ -138,77 +156,59 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin return } gestureDetector = GestureDetectorCompat(this, GestureListener()) + binding = MainActivityBinding.inflate(layoutInflater) - setContentView(R.layout.main_activity) - - setSupportActionBar(toolbar) + setContentView(binding.root) - backArrow = this.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_arrow_back) - searchDrawable = this.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_search) - dismissDrawable = this.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_close) + drawerArrow = DrawerArrowDrawable(this) + drawerArrow?.color = getResourceColor(R.attr.actionBarTintColor) + binding.toolbar.overflowIcon?.setTint(getResourceColor(R.attr.actionBarTintColor)) + searchDrawable = ContextCompat.getDrawable( + this, + R.drawable.ic_search_24dp + ) + dismissDrawable = ContextCompat.getDrawable( + this, + R.drawable.ic_close_24dp + ) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) + } var continueSwitchingTabs = false - bottom_nav.getItemView(R.id.nav_library)?.setOnLongClickListener { + nav.getItemView(R.id.nav_library)?.setOnLongClickListener { if (!LibraryUpdateService.isRunning()) { LibraryUpdateService.start(this) - main_content.snack(R.string.updating_library) { - anchorView = bottom_nav + binding.mainContent.snack(R.string.updating_library) { + anchorView = binding.bottomNav setAction(R.string.cancel) { LibraryUpdateService.stop(context) - Handler().post { NotificationReceiver.dismissNotification(context, Notifications.ID_LIBRARY_PROGRESS) } + Handler().post { + NotificationReceiver.dismissNotification( + context, + Notifications.ID_LIBRARY_PROGRESS + ) + } } } } true } for (id in listOf(R.id.nav_recents, R.id.nav_browse)) { - bottom_nav.getItemView(id)?.setOnLongClickListener { - bottom_nav.selectedItemId = id - bottom_nav.post { + nav.getItemView(id)?.setOnLongClickListener { + nav.selectedItemId = id + nav.post { val controller = - router.backstack.firstOrNull()?.controller() as? BottomSheetController - controller?.toggleSheet() + router.backstack.firstOrNull()?.controller as? BottomSheetController + controller?.showSheet() } true } } - bottom_nav.setOnNavigationItemSelectedListener { item -> - val id = item.itemId - val currentController = router.backstack.lastOrNull()?.controller() - if (!continueSwitchingTabs && currentController is BottomNavBarInterface) { - if (!currentController.canChangeTabs { - continueSwitchingTabs = true - this@MainActivity.bottom_nav.selectedItemId = id - } - ) return@setOnNavigationItemSelectedListener false - } - continueSwitchingTabs = false - val currentRoot = router.backstack.firstOrNull() - if (currentRoot?.tag()?.toIntOrNull() != id) { - when (id) { - R.id.nav_library -> setRoot(LibraryController(), id) - R.id.nav_recents -> setRoot(RecentsController(), id) - else -> { - if (!source.isLogged() && !preferences.useCacheSource()) { - val dialog = MangadexLoginDialog(source, this) - dialog.showDialog(router) - } else { - setBrowseRoot() - } - } - } - } else if (currentRoot.tag()?.toIntOrNull() == id) { - if (router.backstackSize == 1) { - val controller = - router.getControllerWithTag(id.toString()) as? BottomSheetController - controller?.toggleSheet() - } - } - true - } - val container: ViewGroup = findViewById(R.id.controller_container) - val content: ViewGroup = findViewById(R.id.main_content) + val container: ViewGroup = binding.controllerContainer + + val content: ViewGroup = binding.mainContent DownloadService.addListener(this) content.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION @@ -218,6 +218,7 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin supportActionBar?.setDisplayShowCustomEnabled(true) setNavBarColor(content.rootWindowInsets) + nav.isVisible = false content.doOnApplyWindowInsets { v, insets, _ -> setNavBarColor(insets) val contextView = window?.decorView?.findViewById(R.id.action_mode_bar) @@ -228,78 +229,184 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin // Consume any horizontal insets and pad all content in. There's not much we can do // with horizontal insets v.updatePadding( - left = insets.systemWindowInsetLeft, right = insets.systemWindowInsetRight + left = insets.systemWindowInsetLeft, + right = insets.systemWindowInsetRight ) - appbar.updatePadding( + binding.appBar.updatePadding( top = insets.systemWindowInsetTop ) - bottom_nav.updatePadding(bottom = insets.systemWindowInsetBottom) + binding.bottomNav?.updatePadding(bottom = insets.systemWindowInsetBottom) + binding.sideNav?.updatePadding( + left = insets.systemWindowInsetLeft, + right = insets.systemWindowInsetRight + ) + binding.bottomView?.isVisible = insets.systemWindowInsetBottom > 0 + binding.bottomView?.updateLayoutParams { + height = insets.systemWindowInsetBottom + } } router = Conductor.attachRouter(this, container, savedInstanceState) + + if (router.hasRootController()) { + nav.selectedItemId = + when (router.backstack.firstOrNull()?.controller) { + is RecentsController -> R.id.nav_recents + is BrowseSourceController -> R.id.nav_browse + else -> R.id.nav_library + } + } + + nav.setOnItemSelectedListener { item -> + val id = item.itemId + val currentController = router.backstack.lastOrNull()?.controller + if (!continueSwitchingTabs && currentController is BottomNavBarInterface) { + if (!currentController.canChangeTabs { + continueSwitchingTabs = true + this@MainActivity.nav.selectedItemId = id + } + ) return@setOnItemSelectedListener false + } + continueSwitchingTabs = false + val currentRoot = router.backstack.firstOrNull() + if (currentRoot?.tag()?.toIntOrNull() != id) { + setRoot( + when (id) { + R.id.nav_library -> LibraryController() + R.id.nav_recents -> RecentsController() + else -> BrowseSourceController() + }, + id + ) + } else if (currentRoot.tag()?.toIntOrNull() == id) { + if (router.backstackSize == 1) { + val controller = + router.getControllerWithTag(id.toString()) as? BottomSheetController + controller?.toggleSheet() + } + } + true + } + if (!router.hasRootController()) { // Set start screen if (!handleIntentAction(intent)) { - bottom_nav.selectedItemId = R.id.nav_library + goToStartingTab() } } - toolbar.setNavigationOnClickListener { - val rootSearchController = router.backstack.lastOrNull()?.controller() + binding.toolbar.setNavigationOnClickListener { + val rootSearchController = router.backstack.lastOrNull()?.controller if (rootSearchController is RootSearchInterface) { rootSearchController.expandSearch() } else onBackPressed() } - bottom_nav.visibleIf(!hideBottomNav) - bottom_nav.alpha = if (hideBottomNav) 0f else 1f - router.addChangeListener(object : ControllerChangeHandler.ControllerChangeListener { - override fun onChangeStarted( - to: Controller?, - from: Controller?, - isPush: Boolean, - container: ViewGroup, - handler: ControllerChangeHandler - ) { + binding.cardToolbar.setNavigationOnClickListener { + val rootSearchController = router.backstack.lastOrNull()?.controller + if (rootSearchController is RootSearchInterface) { + rootSearchController.expandSearch() + } else onBackPressed() + } - syncActivityViewWithController(to, from, isPush) - appbar.y = 0f - snackBar?.dismiss() - } + binding.cardToolbar.setOnClickListener { + binding.cardToolbar.menu.findItem(R.id.action_search)?.expandActionView() + } - override fun onChangeCompleted( - to: Controller?, - from: Controller?, - isPush: Boolean, - container: ViewGroup, - handler: ControllerChangeHandler - ) { - appbar.y = 0f - showDLQueueTutorial() + nav.isVisible = !hideBottomNav + binding.bottomView?.visibility = if (hideBottomNav) View.GONE else binding.bottomView?.visibility ?: View.GONE + nav.alpha = if (hideBottomNav) 0f else 1f + router.addChangeListener( + object : ControllerChangeHandler.ControllerChangeListener { + override fun onChangeStarted( + to: Controller?, + from: Controller?, + isPush: Boolean, + container: ViewGroup, + handler: ControllerChangeHandler + ) { + syncActivityViewWithController(to, from, isPush) + binding.appBar.y = 0f + if (!isPush || router.backstackSize == 1) { + nav.translationY = 0f + } + snackBar?.dismiss() + } + + override fun onChangeCompleted( + to: Controller?, + from: Controller?, + isPush: Boolean, + container: ViewGroup, + handler: ControllerChangeHandler + ) { + binding.appBar.y = 0f + nav.translationY = 0f + showDLQueueTutorial() + } } - }) + ) - syncActivityViewWithController(router.backstack.lastOrNull()?.controller()) + syncActivityViewWithController(router.backstack.lastOrNull()?.controller) - toolbar.navigationIcon = if (router.backstackSize > 1) backArrow else searchDrawable - (router.backstack.lastOrNull()?.controller() as? BaseController)?.setTitle() - (router.backstack.lastOrNull()?.controller() as? SettingsController)?.setTitle() + binding.toolbar.navigationIcon = if (router.backstackSize > 1) drawerArrow else searchDrawable + (router.backstack.lastOrNull()?.controller as? BaseController<*>)?.setTitle() + (router.backstack.lastOrNull()?.controller as? SettingsController)?.setTitle() if (savedInstanceState == null) { - // Show changelog or similar manga enabling on install prompt - // NOTE: we show the similar manga dialog first so it is behind the changelog + // Show changelog if needed if (Migrations.upgrade(preferences)) { - if (!BuildConfig.DEBUG) ChangelogDialogController().showDialog(router) + if (!BuildConfig.DEBUG) { + content.post { + whatsNewSheet().show() + } + } } } + + preferences.incognitoMode() + .asImmediateFlowIn(lifecycleScope) { + binding.toolbar.setIncognitoMode(it) + binding.cardToolbar.setIncognitoMode(it) + } + preferences.showSideNavOnBottom() + .asImmediateFlowIn(lifecycleScope) { + binding.sideNav?.menuGravity = if (!it) Gravity.TOP else Gravity.BOTTOM + } + setFloatingToolbar(canShowFloatingToolbar(router.backstack.lastOrNull()?.controller), changeBG = false) } - fun setDismissIcon(enabled: Boolean) { - toolbar.navigationIcon = if (enabled) dismissDrawable else searchDrawable + open fun setFloatingToolbar(show: Boolean, solidBG: Boolean = false, changeBG: Boolean = true) { + val oldTB = currentToolbar + currentToolbar = if (show) { + binding.cardToolbar + } else { + binding.toolbar + } + if (oldTB != currentToolbar) { + setSupportActionBar(currentToolbar) + } + binding.toolbar.isVisible = !show + binding.cardFrame.isVisible = show + if (changeBG) { + binding.appBar.setBackgroundColor( + if (show && !solidBG) Color.TRANSPARENT else getResourceColor(R.attr.colorSecondary) + ) + } + currentToolbar?.setNavigationOnClickListener { + val rootSearchController = router.backstack.lastOrNull()?.controller + if (rootSearchController is RootSearchInterface) { + rootSearchController.expandSearch() + } else onBackPressed() + } + if (oldTB != currentToolbar) { + invalidateOptionsMenu() + } } - fun showNavigationArrow() { - toolbar.navigationIcon = backArrow + fun setDismissIcon(enabled: Boolean) { + binding.cardToolbar.navigationIcon = if (enabled) dismissDrawable else searchDrawable + binding.toolbar.navigationIcon = if (enabled) dismissDrawable else searchDrawable } private fun setNavBarColor(insets: WindowInsets?) { @@ -307,9 +414,9 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin window.navigationBarColor = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) { // basically if in landscape on a phone // For lollipop, draw opaque nav bar - if (insets.hasSideNavBar()) + if (insets.hasSideNavBar()) { Color.BLACK - else Color.argb(179, 0, 0, 0) + } else Color.argb(179, 0, 0, 0) } // if the android q+ device has gesture nav, transparent nav bar // this is here in case some crazy with a notch uses landscape @@ -323,7 +430,8 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin // if in portrait with 2/3 button mode, translucent nav bar else { ColorUtils.setAlphaComponent( - getResourceColor(R.attr.colorPrimaryVariant), 179 + getResourceColor(R.attr.colorPrimaryVariant), + 179 ) } } @@ -336,7 +444,9 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin override fun onSupportActionModeFinished(mode: androidx.appcompat.view.ActionMode) { launchUI { val scale = Settings.Global.getFloat( - contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f + contentResolver, + Settings.Global.ANIMATOR_DURATION_SCALE, + 1.0f ) val duration = resources.getInteger(android.R.integer.config_mediumAnimTime) * scale delay(duration.toLong()) @@ -360,7 +470,8 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin if (router.backstackSize == 1 && this !is SearchActivity && downloadManager.hasQueue() && !preferences.shownDownloadQueueTutorial().get() ) { - val recentsItem = bottom_nav.getItemView(R.id.nav_recents) ?: return + if (!isBindingInitialized) return + val recentsItem = nav.getItemView(R.id.nav_recents) ?: return preferences.shownDownloadQueueTutorial().set(true) TapTargetView.showFor( this, @@ -368,7 +479,10 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin recentsItem, getString(R.string.manage_whats_downloading), getString(R.string.visit_recents_for_download_queue) - ).outerCircleColor(R.color.colorAccent).outerCircleAlpha(0.95f).titleTextSize(20) + ).outerCircleColorInt(getResourceColor(R.attr.colorAccent)).outerCircleAlpha(0.95f) + .titleTextSize( + 20 + ) .titleTextColor(android.R.color.white).descriptionTextSize(16) .descriptionTextColor(R.color.md_white_1000_76) .icon(contextCompatDrawable(R.drawable.ic_recent_read_32dp)) @@ -376,7 +490,7 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin object : TapTargetView.Listener() { override fun onTargetClick(view: TapTargetView) { super.onTargetClick(view) - bottom_nav.selectedItemId = R.id.nav_recents + nav.selectedItemId = R.id.nav_recents } } ) @@ -386,6 +500,8 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin override fun onPause() { super.onPause() snackBar?.dismiss() + setStartingTab() + mangaShortcutManager.updateShortcuts() } private fun getAppUpdates() { @@ -402,64 +518,73 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin // Create confirmation window withContext(Dispatchers.Main) { + UpdaterNotifier.releasePageUrl = result.release.releaseLink AboutController.NewUpdateDialogController(body, url).showDialog(router) } } } catch (error: Exception) { - XLog.e(error) + Timber.e(error) } } } } - /** - * Called when login dialog is closed, refreshes the adapter. - * - * @param source clicked item containing source information. - */ - override fun siteLoginDialogClosed(source: Source) { - if (source.isLogged()) { - setBrowseRoot() - } - } - override fun onNewIntent(intent: Intent) { if (!handleIntentAction(intent)) { super.onNewIntent(intent) } } - fun setBrowseRoot() { - setRoot(BrowseSourceController(), R.id.nav_browse) - } - protected open fun handleIntentAction(intent: Intent): Boolean { val notificationId = intent.getIntExtra("notificationId", -1) if (notificationId > -1) NotificationReceiver.dismissNotification( - applicationContext, notificationId, intent.getIntExtra("groupId", 0) + applicationContext, + notificationId, + intent.getIntExtra("groupId", 0) ) when (intent.action) { - SHORTCUT_LIBRARY -> bottom_nav.selectedItemId = R.id.nav_library + SHORTCUT_LIBRARY -> nav.selectedItemId = R.id.nav_library SHORTCUT_RECENTLY_UPDATED, SHORTCUT_RECENTLY_READ -> { - bottom_nav.selectedItemId = R.id.nav_recents - val controller: Controller = when (intent.action) { - SHORTCUT_RECENTLY_UPDATED -> RecentChaptersController() - else -> RecentlyReadController() + if (nav.selectedItemId != R.id.nav_recents) { + nav.selectedItemId = R.id.nav_recents + } else { + router.popToRoot() + } + nav.post { + val controller = + router.backstack.firstOrNull()?.controller as? RecentsController + controller?.tempJumpTo( + when (intent.action) { + SHORTCUT_RECENTLY_UPDATED -> RecentsPresenter.VIEW_TYPE_ONLY_UPDATES + else -> RecentsPresenter.VIEW_TYPE_ONLY_HISTORY + } + ) } - router.pushController(controller.withFadeTransaction()) } - SHORTCUT_BROWSE -> bottom_nav.selectedItemId = R.id.nav_browse + SHORTCUT_BROWSE -> nav.selectedItemId = R.id.nav_browse SHORTCUT_MANGA -> { val extras = intent.extras ?: return false - if (router.backstack.isEmpty()) bottom_nav.selectedItemId = R.id.nav_library + if (router.backstack.isEmpty()) nav.selectedItemId = R.id.nav_library router.pushController(MangaDetailsController(extras).withFadeTransaction()) } + SHORTCUT_UPDATE_NOTES -> { + val extras = intent.extras ?: return false + if (router.backstack.isEmpty()) nav.selectedItemId = R.id.nav_library + if (router.backstack.lastOrNull()?.controller !is AboutController.NewUpdateDialogController) { + AboutController.NewUpdateDialogController(extras).showDialog(router) + } + } + SHORTCUT_SOURCE -> { + val extras = intent.extras ?: return false + if (router.backstack.isEmpty()) nav.selectedItemId = R.id.nav_library + router.pushController(BrowseSourceController(extras).withFadeTransaction()) + } SHORTCUT_DOWNLOADS -> { - bottom_nav.selectedItemId = R.id.nav_recents + nav.selectedItemId = R.id.nav_recents router.popToRoot() - bottom_nav.post { + nav.post { val controller = - router.backstack.firstOrNull()?.controller() as? RecentsController + router.backstack.firstOrNull()?.controller as? RecentsController controller?.showSheet() } } @@ -470,41 +595,105 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin override fun onDestroy() { super.onDestroy() + overflowDialog?.dismiss() + overflowDialog = null DownloadService.removeListener(this) - toolbar?.setNavigationOnClickListener(null) + if (isBindingInitialized) { + binding.toolbar.setNavigationOnClickListener(null) + binding.cardToolbar.setNavigationOnClickListener(null) + } } override fun onBackPressed() { - val sheetController = router.backstack.last().controller() as? BottomSheetController + val sheetController = router.backstack.last().controller as? BottomSheetController if (if (router.backstackSize == 1) !(sheetController?.handleSheetBack() ?: false) else !router.handleBack() ) { - SecureActivityDelegate.locked = true - super.onBackPressed() + if (preferences.backReturnsToStart().get() && this !is SearchActivity && + startingTab() != nav.selectedItemId + ) { + goToStartingTab() + } else { + if (!preferences.backReturnsToStart().get() && this !is SearchActivity) { + setStartingTab() + } + SecureActivityDelegate.locked = this !is SearchActivity + mangaShortcutManager.updateShortcuts() + super.onBackPressed() + } + } + } + + protected val nav: NavigationBarView + get() = binding.bottomNav ?: binding.sideNav!! + + private fun setStartingTab() { + if (this is SearchActivity) return + if (nav.selectedItemId != R.id.nav_browse && + preferences.startingTab().get() >= 0 + ) { + preferences.startingTab().set( + when (nav.selectedItemId) { + R.id.nav_library -> 0 + else -> 1 + } + ) + } + } + + @IdRes + private fun startingTab(): Int { + return when (preferences.startingTab().get()) { + 0, -1 -> R.id.nav_library + 1, -2 -> R.id.nav_recents + -3 -> R.id.nav_browse + else -> R.id.nav_library } } + private fun goToStartingTab() { + nav.selectedItemId = startingTab() + } + private fun setRoot(controller: Controller, id: Int) { router.setRoot(controller.withFadeTransaction().tag(id.toString())) } + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + val searchItem = menu?.findItem(R.id.action_search) + if (currentToolbar == binding.cardToolbar) { + searchItem?.isVisible = false + } + return super.onPrepareOptionsMenu(menu) + } + override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { // Initialize option to open catalogue settings. - R.id.action_settings -> { - router.pushController( - (RouterTransaction.with(SettingsMainController())).popChangeHandler( - FadeChangeHandler() - ).pushChangeHandler(FadeChangeHandler()) - ) + R.id.action_more -> { + if (overflowDialog != null) return false + val overflowDialog = OverflowDialog(this) + this.overflowDialog = overflowDialog + overflowDialog.setOnDismissListener { + this.overflowDialog = null + } + overflowDialog.show() } else -> return super.onOptionsItemSelected(item) } return super.onOptionsItemSelected(item) } + fun showSettings() { + router.pushController(SettingsMainController().withFadeTransaction()) + } + + fun showAbout() { + router.pushController(AboutController().withFadeTransaction()) + } + override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { - gestureDetector.onTouchEvent(ev) + gestureDetector?.onTouchEvent(ev) if (ev?.action == MotionEvent.ACTION_DOWN) { if (snackBar != null && snackBar!!.isShown) { val sRect = Rect() @@ -514,10 +703,9 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin extraViewForUndo?.getGlobalVisibleRect(extRect) // This way the snackbar will only be dismissed if // the user clicks outside it. - if (canDismissSnackBar && !sRect.contains( - ev.x.toInt(), - ev.y.toInt() - ) && (extRect == null || !extRect.contains(ev.x.toInt(), ev.y.toInt())) + if (canDismissSnackBar && + !sRect.contains(ev.x.toInt(), ev.y.toInt()) && + (extRect == null || !extRect.contains(ev.x.toInt(), ev.y.toInt())) ) { snackBar?.dismiss() snackBar = null @@ -531,6 +719,9 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin return super.dispatchTouchEvent(ev) } + protected fun canShowFloatingToolbar(controller: Controller?) = + (controller is FloatingSearchInterface && controller.showFloatingBar()) + protected open fun syncActivityViewWithController( to: Controller?, from: Controller? = null, @@ -539,46 +730,131 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin if (from is DialogController || to is DialogController) { return } + setFloatingToolbar(canShowFloatingToolbar(to)) val onRoot = router.backstackSize == 1 if (onRoot) { - window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) - toolbar.navigationIcon = searchDrawable + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R && !isPush) { + window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) + } + binding.toolbar.navigationIcon = searchDrawable + binding.cardToolbar.navigationIcon = searchDrawable } else { - showNavigationArrow() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + } + binding.toolbar.navigationIcon = drawerArrow + binding.cardToolbar.navigationIcon = drawerArrow } + binding.cardToolbar.subtitle = null + drawerArrow?.progress = 1f - bottom_nav.visibility = if (!hideBottomNav) View.VISIBLE else bottom_nav.visibility - animationSet?.cancel() - animationSet = AnimatorSet() - val alphaAnimation = ValueAnimator.ofFloat( - bottom_nav.alpha, if (hideBottomNav) 0f else 1f - ) - alphaAnimation.addUpdateListener { valueAnimator -> - bottom_nav.alpha = valueAnimator.animatedValue as Float + nav.visibility = if (!hideBottomNav) View.VISIBLE else nav.visibility + if (isTablet()) { + nav.isVisible = !hideBottomNav + nav.alpha = 1f + } else { + animationSet?.cancel() + animationSet = AnimatorSet() + val alphaAnimation = ValueAnimator.ofFloat( + nav.alpha, + if (hideBottomNav) 0f else 1f + ) + alphaAnimation.addUpdateListener { valueAnimator -> + nav.alpha = valueAnimator.animatedValue as Float + } + alphaAnimation.addListener( + EndAnimatorListener { + nav.isVisible = !hideBottomNav + binding.bottomView?.visibility = + if (hideBottomNav) View.GONE else binding.bottomView?.visibility + ?: View.GONE + } + ) + alphaAnimation.duration = 200 + alphaAnimation.startDelay = 50 + animationSet?.playTogether(alphaAnimation) + animationSet?.start() } - alphaAnimation.addListener( - EndAnimatorListener { - bottom_nav.visibility = if (hideBottomNav) View.GONE else View.VISIBLE + } + + fun showTabBar(show: Boolean, animate: Boolean = true) { + tabAnimation?.cancel() + if (animate) { + if (show && !binding.tabsFrameLayout.isVisible) { + binding.tabsFrameLayout.alpha = 0f + binding.tabsFrameLayout.isVisible = true } - ) - alphaAnimation.duration = 200 - alphaAnimation.startDelay = 50 - animationSet?.playTogether(alphaAnimation) - animationSet?.start() + tabAnimation = ValueAnimator.ofFloat( + binding.tabsFrameLayout.alpha, + if (show) 1f else 0f + ) + tabAnimation?.addUpdateListener { valueAnimator -> + binding.tabsFrameLayout.alpha = valueAnimator.animatedValue as Float + } + tabAnimation?.addListener( + EndAnimatorListener { + binding.tabsFrameLayout.isVisible = show + if (!show) { + binding.mainTabs.clearOnTabSelectedListeners() + binding.mainTabs.removeAllTabs() + } + } + ) + tabAnimation?.duration = 200 + tabAnimation?.start() + } else { + binding.tabsFrameLayout.isVisible = show + } + if (show) { + binding.appBar.setBackgroundColor(getResourceColor(R.attr.colorSecondary)) + } } override fun downloadStatusChanged(downloading: Boolean) { val hasQueue = downloading || downloadManager.hasQueue() launchUI { if (hasQueue) { - bottom_nav?.getOrCreateBadge(R.id.nav_recents) + nav.getOrCreateBadge(R.id.nav_recents) showDLQueueTutorial() } else { - bottom_nav?.removeBadge(R.id.nav_recents) + nav.removeBadge(R.id.nav_recents) } } } + private fun whatsNewSheet() = MaterialMenuSheet( + this, + listOf( + MaterialMenuSheet.MenuSheetItem( + 0, + textRes = R.string.whats_new_this_release, + drawable = R.drawable.ic_new_releases_24dp + ), + MaterialMenuSheet.MenuSheetItem( + 1, + textRes = R.string.close, + drawable = R.drawable.ic_close_24dp + ) + ), + title = getString(R.string.updated_to_, BuildConfig.VERSION_NAME), + showDivider = true, + selectedId = 0, + onMenuItemClicked = { _, item -> + if (item == 0) { + try { + val intent = Intent( + Intent.ACTION_VIEW, + "https://github.com/CarlosEsco/Neko/releases/tag/${BuildConfig.VERSION_NAME}".toUri() + ) + startActivity(intent) + } catch (e: Throwable) { + toast(e.message) + } + } + true + } + ) + private inner class GestureListener : GestureDetector.SimpleOnGestureListener() { override fun onDown(e: MotionEvent): Boolean { return true @@ -595,13 +871,14 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin val diffX = e2.x - e1.x if (abs(diffX) <= abs(diffY)) { val sheetRect = Rect() - bottom_nav.getGlobalVisibleRect(sheetRect) - if (sheetRect.contains( - e1.x.toInt(), e1.y.toInt() - ) && abs(diffY) > Companion.SWIPE_THRESHOLD && abs(velocityY) > Companion.SWIPE_VELOCITY_THRESHOLD && diffY <= 0 + binding.bottomNav?.getGlobalVisibleRect(sheetRect) + if (sheetRect.contains(e1.x.toInt(), e1.y.toInt()) && + abs(diffY) > Companion.SWIPE_THRESHOLD && + abs(velocityY) > Companion.SWIPE_VELOCITY_THRESHOLD && + diffY <= 0 ) { val bottomSheetController = - router.backstack.lastOrNull()?.controller() as? BottomSheetController + router.backstack.lastOrNull()?.controller as? BottomSheetController bottomSheetController?.showSheet() } result = true @@ -612,8 +889,8 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin companion object { - const val SWIPE_THRESHOLD = 100 - const val SWIPE_VELOCITY_THRESHOLD = 100 + private const val SWIPE_THRESHOLD = 100 + private const val SWIPE_VELOCITY_THRESHOLD = 100 // Shortcut actions const val SHORTCUT_LIBRARY = "eu.kanade.tachiyomi.SHOW_LIBRARY" @@ -622,6 +899,11 @@ open class MainActivity : BaseActivity(), DownloadServiceListener, MangadexLogin const val SHORTCUT_BROWSE = "eu.kanade.tachiyomi.SHOW_BROWSE" const val SHORTCUT_DOWNLOADS = "eu.kanade.tachiyomi.SHOW_DOWNLOADS" const val SHORTCUT_MANGA = "eu.kanade.tachiyomi.SHOW_MANGA" + const val SHORTCUT_MANGA_BACK = "eu.kanade.tachiyomi.SHOW_MANGA_BACK" + const val SHORTCUT_UPDATE_NOTES = "eu.kanade.tachiyomi.SHOW_UPDATE_NOTES" + const val SHORTCUT_SOURCE = "eu.kanade.tachiyomi.SHOW_SOURCE" + const val SHORTCUT_READER_SETTINGS = "eu.kanade.tachiyomi.READER_SETTINGS" + const val SHORTCUT_EXTENSIONS = "eu.kanade.tachiyomi.EXTENSIONS" const val INTENT_SEARCH = "neko.SEARCH" const val INTENT_SEARCH_QUERY = "query" @@ -635,11 +917,24 @@ interface BottomNavBarInterface { interface RootSearchInterface { fun expandSearch() { - if (this is Controller) activity?.toolbar?.menu?.findItem(R.id.action_search) - ?.expandActionView() + if (this is Controller) { + val mainActivity = activity as? MainActivity ?: return + mainActivity.binding.cardToolbar.menu.findItem(R.id.action_search)?.expandActionView() + } } } +interface FloatingSearchInterface { + fun searchTitle(title: String?): String? { + if (this is Controller) { + return activity?.getString(R.string.search_, title) + } + return title + } + + fun showFloatingBar() = true +} + interface BottomSheetController { fun showSheet() fun toggleSheet() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/OverflowDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/OverflowDialog.kt new file mode 100644 index 0000000000..2953c0d5c8 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/OverflowDialog.kt @@ -0,0 +1,96 @@ +package eu.kanade.tachiyomi.ui.main + +import android.app.Dialog +import android.graphics.Color +import android.view.View +import android.view.ViewGroup +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat +import eu.kanade.tachiyomi.BuildConfig +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.toggle +import eu.kanade.tachiyomi.databinding.TachiOverflowLayoutBinding +import eu.kanade.tachiyomi.util.lang.withSubtitle +import eu.kanade.tachiyomi.util.system.dpToPx +import eu.kanade.tachiyomi.util.system.openInBrowser +import eu.kanade.tachiyomi.util.view.updateLayoutParams +import uy.kohesive.injekt.injectLazy + +class OverflowDialog(activity: MainActivity) : Dialog(activity, R.style.OverflowDialogTheme) { + + val binding = TachiOverflowLayoutBinding.inflate(activity.layoutInflater, null, false) + val preferences: PreferencesHelper by injectLazy() + + init { + setContentView(binding.root) + + binding.touchOutside.setOnClickListener { + dismiss() + } + val incogText = context.getString(R.string.incognito_mode) + with(binding.incognitoModeItem) { + val titleText = context.getString( + if (preferences.incognitoMode().get()) R.string.turn_off_ + else R.string.turn_on_, + incogText + ) + val subtitleText = context.getString(R.string.pauses_reading_history) + text = titleText.withSubtitle(context, subtitleText) + setIcon( + if (preferences.incognitoMode().get()) R.drawable.ic_incognito_24dp + else R.drawable.ic_glasses_24dp + ) + setOnClickListener { + preferences.incognitoMode().toggle() + val incog = preferences.incognitoMode().get() + val newTitle = context.getString( + if (incog) R.string.turn_off_ + else R.string.turn_on_, + incogText + ) + text = newTitle.withSubtitle(context, subtitleText) + val drawable = AnimatedVectorDrawableCompat.create( + context, + if (incog) R.drawable.anim_read_to_incog + else R.drawable.anim_incog_to_read + ) + setIcon(drawable) + (getIcon() as? AnimatedVectorDrawableCompat)?.start() + } + } + binding.settingsItem.setOnClickListener { + activity.showSettings() + dismiss() + } + + binding.helpItem.setOnClickListener { + activity.openInBrowser(URL_HELP) + dismiss() + } + + binding.aboutItem.text = context.getString(R.string.about).withSubtitle(binding.aboutItem.context, "v${BuildConfig.VERSION_NAME}") + + binding.aboutItem.setOnClickListener { + activity.showAbout() + dismiss() + } + + binding.overflowCardView.updateLayoutParams { + topMargin = activity.toolbarHeight - 2.dpToPx + } + window?.let { window -> + window.navigationBarColor = Color.TRANSPARENT + window.decorView.fitsSystemWindows = true + window.decorView.systemUiVisibility = window.decorView.systemUiVisibility + .rem(View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + window.decorView.systemUiVisibility = window.decorView.systemUiVisibility + .rem(View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR) + } + } + } + + private companion object { + private const val URL_HELP = "https://tachiyomi.org/help/" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/SearchActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/SearchActivity.kt index ddb1e56fb5..d1ac784a67 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/SearchActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/SearchActivity.kt @@ -4,6 +4,7 @@ import android.app.SearchManager import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.core.view.isVisible import com.bluelinelabs.conductor.Controller import com.bluelinelabs.conductor.RouterTransaction import com.bluelinelabs.conductor.changehandler.FadeChangeHandler @@ -11,22 +12,25 @@ import com.bluelinelabs.conductor.changehandler.SimpleSwapChangeHandler import com.elvishew.xlog.XLog import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.source.online.handlers.SearchHandler +import eu.kanade.tachiyomi.ui.base.controller.BaseController import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.manga.MangaDetailsController import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate +import eu.kanade.tachiyomi.ui.setting.SettingsController +import eu.kanade.tachiyomi.ui.setting.SettingsReaderController import eu.kanade.tachiyomi.ui.source.browse.BrowseSourceController -import eu.kanade.tachiyomi.util.view.gone import eu.kanade.tachiyomi.util.view.withFadeTransaction -import kotlinx.android.synthetic.main.main_activity.* class SearchActivity : MainActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - toolbar?.navigationIcon = backArrow - toolbar?.setNavigationOnClickListener { - popToRoot() - } + binding.toolbar.navigationIcon = drawerArrow + binding.cardToolbar.navigationIcon = drawerArrow + binding.toolbar.setNavigationOnClickListener { popToRoot() } + binding.cardToolbar.setNavigationOnClickListener { popToRoot() } + (router.backstack.lastOrNull()?.controller as? BaseController<*>)?.setTitle() + (router.backstack.lastOrNull()?.controller as? SettingsController)?.setTitle() } override fun onBackPressed() { @@ -37,7 +41,7 @@ class SearchActivity : MainActivity() { } private fun popToRoot() { - if (intent.action == SHORTCUT_MANGA) { + if (intentShouldGoBack()) { onBackPressed() } else if (!router.handleBack()) { val intent = Intent(this, MainActivity::class.java).apply { @@ -48,34 +52,50 @@ class SearchActivity : MainActivity() { } } + override fun setFloatingToolbar(show: Boolean, solidBG: Boolean, changeBG: Boolean) { + super.setFloatingToolbar(show, solidBG, changeBG) + currentToolbar?.setNavigationOnClickListener { popToRoot() } + } + + private fun intentShouldGoBack() = + intent.action in listOf(SHORTCUT_MANGA, SHORTCUT_READER_SETTINGS, SHORTCUT_BROWSE) + override fun syncActivityViewWithController( to: Controller?, from: Controller?, isPush: - Boolean + Boolean ) { if (from is DialogController || to is DialogController) { return } - toolbar.navigationIcon = backArrow + setFloatingToolbar(canShowFloatingToolbar(to)) + binding.cardToolbar.navigationIcon = drawerArrow + binding.toolbar.navigationIcon = drawerArrow + drawerArrow?.progress = 1f - bottom_nav.gone() + nav.isVisible = false + binding.bottomView?.isVisible = false } override fun handleIntentAction(intent: Intent): Boolean { val notificationId = intent.getIntExtra("notificationId", -1) if (notificationId > -1) NotificationReceiver.dismissNotification( - applicationContext, notificationId, intent.getIntExtra("groupId", 0) + applicationContext, + notificationId, + intent.getIntExtra("groupId", 0) ) when (intent.action) { - Intent.ACTION_SEARCH, "com.google.android.gms.actions.SEARCH_ACTION" -> { + Intent.ACTION_SEARCH, Intent.ACTION_SEND, "com.google.android.gms.actions.SEARCH_ACTION" -> { // If the intent match the "standard" Android search intent // or the Google-specific search intent (triggered by saying or typing "search *query* on *Tachiyomi*" in Google Search/Google Assistant) // Get the search query provided in extras, and if not null, perform a global search with it. - val query = intent.getStringExtra(SearchManager.QUERY) + val query = intent.getStringExtra(SearchManager.QUERY) ?: intent.getStringExtra(Intent.EXTRA_TEXT) if (query != null && query.isNotEmpty()) { router.replaceTopController(BrowseSourceController(query).withFadeTransaction()) + } else { + finish() } } INTENT_SEARCH -> { @@ -92,7 +112,7 @@ class SearchActivity : MainActivity() { } } } - SHORTCUT_MANGA -> { + SHORTCUT_MANGA, SHORTCUT_MANGA_BACK -> { val extras = intent.extras ?: return false router.replaceTopController( RouterTransaction.with(MangaDetailsController(extras)) @@ -100,20 +120,44 @@ class SearchActivity : MainActivity() { .popChangeHandler(FadeChangeHandler()) ) } + SHORTCUT_SOURCE -> { + val extras = intent.extras ?: return false + router.replaceTopController( + RouterTransaction.with(BrowseSourceController(extras)) + .pushChangeHandler(SimpleSwapChangeHandler()) + .popChangeHandler(FadeChangeHandler()) + ) + } + SHORTCUT_READER_SETTINGS -> { + router.replaceTopController( + RouterTransaction.with(SettingsReaderController()) + .pushChangeHandler(SimpleSwapChangeHandler()) + .popChangeHandler(FadeChangeHandler()) + ) + } else -> return false } return true } companion object { - fun openMangaIntent(context: Context, id: Long) = Intent( + fun openMangaIntent(context: Context, id: Long?, canReturnToMain: Boolean = false) = Intent( context, SearchActivity::class .java ) .apply { - action = SHORTCUT_MANGA + action = if (canReturnToMain) SHORTCUT_MANGA_BACK else SHORTCUT_MANGA putExtra(MangaDetailsController.MANGA_EXTRA, id) } + + fun openReaderSettings(context: Context) = Intent( + context, + SearchActivity::class + .java + ) + .apply { + action = SHORTCUT_READER_SETTINGS + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsAdapter.kt index 8413475618..9c8b790abc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsAdapter.kt @@ -1,6 +1,5 @@ package eu.kanade.tachiyomi.ui.manga -import android.graphics.Bitmap import android.view.View import androidx.recyclerview.widget.ItemTouchHelper import eu.davidea.flexibleadapter.items.IFlexible @@ -97,7 +96,8 @@ class MangaDetailsAdapter( private fun getChapterName(item: ChapterItem): String { return if (item.chapter_number > 0) { recyclerView.context.getString( - R.string.chapter_, decimalFormat.format(item.chapter_number) + R.string.chapter_, + decimalFormat.format(item.chapter_number) ) } else { item.name @@ -119,8 +119,6 @@ class MangaDetailsAdapter( fun coverColor(): Int? fun mangaPresenter(): MangaDetailsPresenter fun prepareToShareManga() - fun openSimilar() - fun openMerge() fun startDownloadRange(position: Int) fun readNextChapter() fun topCoverHeight(): Int @@ -132,7 +130,9 @@ class MangaDetailsAdapter( fun showTrackingSheet() fun updateScroll() fun setFavButtonPopup(popupView: View) + fun showExternalSheet() - fun generatePalette(input: Bitmap) + fun openSimilar() + fun openMerge() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt index 45d523c1b9..ad9c2c88a6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt @@ -1,6 +1,5 @@ package eu.kanade.tachiyomi.ui.manga -import android.Manifest.permission.WRITE_EXTERNAL_STORAGE import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.AnimatorSet @@ -11,7 +10,7 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent -import android.graphics.Bitmap +import android.content.res.Configuration import android.graphics.Color import android.graphics.Rect import android.graphics.drawable.BitmapDrawable @@ -31,7 +30,7 @@ import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.SearchView import androidx.core.graphics.ColorUtils -import androidx.core.view.iterator +import androidx.core.view.isVisible import androidx.palette.graphics.Palette import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager @@ -41,8 +40,7 @@ import androidx.transition.ChangeImageTransform import androidx.transition.TransitionManager import androidx.transition.TransitionSet import coil.Coil -import coil.loadAny -import coil.request.CachePolicy +import coil.imageLoader import coil.request.ImageRequest import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.checkbox.checkBoxPrompt @@ -53,8 +51,6 @@ import com.bluelinelabs.conductor.ControllerChangeType import com.elvishew.xlog.XLog import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar -import com.mikepenz.iconics.IconicsDrawable -import com.mikepenz.iconics.utils.colorInt import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.SelectableAdapter import eu.kanade.tachiyomi.R @@ -66,17 +62,17 @@ import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.databinding.MangaDetailsControllerBinding import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.isMerged -import eu.kanade.tachiyomi.source.model.isMergedChapter import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.utils.MdUtil +import eu.kanade.tachiyomi.ui.base.MaterialMenuSheet import eu.kanade.tachiyomi.ui.base.controller.BaseController import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import eu.kanade.tachiyomi.ui.library.AddToLibraryCategoriesDialog -import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog import eu.kanade.tachiyomi.ui.library.LibraryController +import eu.kanade.tachiyomi.ui.main.FloatingSearchInterface import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.SearchActivity import eu.kanade.tachiyomi.ui.manga.chapter.ChapterHolder @@ -90,31 +86,31 @@ import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate import eu.kanade.tachiyomi.ui.similar.SimilarController import eu.kanade.tachiyomi.ui.webview.WebViewActivity -import eu.kanade.tachiyomi.util.log.XLogLevel +import eu.kanade.tachiyomi.util.addOrRemoveToFavorites +import eu.kanade.tachiyomi.util.moveCategories import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.system.ThemeUtil +import eu.kanade.tachiyomi.util.system.contextCompatDrawable import eu.kanade.tachiyomi.util.system.dpToPx +import eu.kanade.tachiyomi.util.system.getPrefTheme import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.isInNightMode import eu.kanade.tachiyomi.util.system.isOnline +import eu.kanade.tachiyomi.util.system.isTablet import eu.kanade.tachiyomi.util.system.launchUI import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.util.view.activityBinding +import eu.kanade.tachiyomi.util.view.doOnApplyWindowInsets import eu.kanade.tachiyomi.util.view.getText -import eu.kanade.tachiyomi.util.view.requestPermissionsSafe +import eu.kanade.tachiyomi.util.view.requestFilePermissionsSafe import eu.kanade.tachiyomi.util.view.scrollViewWith import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener import eu.kanade.tachiyomi.util.view.setStyle import eu.kanade.tachiyomi.util.view.snack +import eu.kanade.tachiyomi.util.view.toolbarHeight import eu.kanade.tachiyomi.util.view.updateLayoutParams import eu.kanade.tachiyomi.util.view.updatePaddingRelative import eu.kanade.tachiyomi.util.view.withFadeTransaction -import kotlinx.android.synthetic.main.main_activity.* -import kotlinx.android.synthetic.main.manga_details_controller.* -import kotlinx.android.synthetic.main.manga_grid_item.* -import kotlinx.android.synthetic.main.manga_header_item.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.launch import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.File @@ -122,14 +118,12 @@ import java.util.Locale import kotlin.math.max class MangaDetailsController : - BaseController, + BaseController, FlexibleAdapter.OnItemClickListener, FlexibleAdapter.OnItemLongClickListener, ActionMode.Callback, MangaDetailsAdapter.MangaDetailsInterface, - FlexibleAdapter.OnItemMoveListener, - ChangeMangaCategoriesDialog.Listener, - AddToLibraryCategoriesDialog.Listener { + FlexibleAdapter.OnItemMoveListener { constructor( manga: Manga, @@ -142,6 +136,7 @@ class MangaDetailsController : putBoolean(UPDATE_EXTRA, update) } ) { + this.manga = manga presenter = MangaDetailsPresenter(this, manga) } @@ -153,22 +148,31 @@ class MangaDetailsController : val notificationId = bundle.getInt("notificationId", -1) val context = applicationContext ?: return if (notificationId > -1) NotificationReceiver.dismissNotification( - context, notificationId, bundle.getInt("groupId", 0) + context, + notificationId, + bundle.getInt("groupId", 0) ) } + private var manga: Manga? = null var colorAnimator: ValueAnimator? = null val presenter: MangaDetailsPresenter + var coverColor: Int? = null var toolbarIsColored = false private var snack: Snackbar? = null val fromCatalogue = args.getBoolean(FROM_CATALOGUE_EXTRA, false) private var trackingBottomSheet: TrackingBottomSheet? = null + private var startingRangeChapterPos: Int? = null + private var rangeMode: RangeMode? = null private var mergeSearchDialog: MergeSearchDialog? = null - private var startingDLChapterPos: Int? = null private var externalBottomSheet: ExternalBottomSheet? = null var refreshTracker: Int? = null var chapterPopupMenu: Pair? = null + // Tablet Layout + var isTablet = false + private var tabletAdapter: MangaDetailsAdapter? = null + private var query = "" private var adapter: MangaDetailsAdapter? = null @@ -184,25 +188,41 @@ class MangaDetailsController : return null } - override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.manga_details_controller, container, false) - } + override fun createBinding(inflater: LayoutInflater) = MangaDetailsControllerBinding.inflate(inflater) //region UI Methods override fun onViewCreated(view: View) { super.onViewCreated(view) + coverColor = null fullCoverActive = false + setTabletMode(view) setRecycler(view) - adapter?.fastScroller = fast_scroller - fast_scroller.addOnScrollStateChangeListener { - activity?.appbar?.y = 0f + setPaletteColor() + adapter?.fastScroller = binding.fastScroller + binding.fastScroller.addOnScrollStateChangeListener { + activityBinding?.appBar?.y = 0f } presenter.onCreate() - swipe_refresh.isRefreshing = presenter.isLoading - swipe_refresh.setOnRefreshListener { presenter.refreshAll() } - requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301) + binding.swipeRefresh.isRefreshing = presenter.isLoading + binding.swipeRefresh.setOnRefreshListener { presenter.refreshAll() } + requestFilePermissionsSafe(301) + } + + /** Check if device is tablet, and use a second recycler to hold the details header if so */ + private fun setTabletMode(view: View) { + isTablet = view.context.isTablet() && + view.context.resources.configuration?.orientation == Configuration.ORIENTATION_LANDSCAPE + binding.tabletOverlay.isVisible = isTablet + binding.tabletRecycler.isVisible = isTablet + binding.tabletDivider.isVisible = isTablet + if (isTablet) { + binding.recycler.updateLayoutParams { width = 0 } + tabletAdapter = MangaDetailsAdapter(this) + binding.tabletRecycler.adapter = tabletAdapter + binding.tabletRecycler.layoutManager = LinearLayoutManager(view.context) + } } override fun onDestroyView(view: View) { @@ -210,6 +230,7 @@ class MangaDetailsController : presenter.onDestroy() adapter = null trackingBottomSheet = null + externalBottomSheet = null mergeSearchDialog = null super.onDestroyView(view) } @@ -218,113 +239,175 @@ class MangaDetailsController : private fun setRecycler(view: View) { adapter = MangaDetailsAdapter(this) - recycler.adapter = adapter + binding.recycler.adapter = adapter adapter?.isSwipeEnabled = true - recycler.layoutManager = LinearLayoutManager(view.context) - recycler.addItemDecoration( + binding.recycler.layoutManager = LinearLayoutManager(view.context) + binding.recycler.addItemDecoration( MangaDetailsDivider(view.context) ) - recycler.setHasFixedSize(true) + binding.recycler.setHasFixedSize(true) val attrsArray = intArrayOf(android.R.attr.actionBarSize) val array = view.context.obtainStyledAttributes(attrsArray) val appbarHeight = array.getDimensionPixelSize(0, 0) array.recycle() val offset = 10.dpToPx - swipe_refresh.setStyle() - swipe_refresh.setDistanceToTriggerSync(70.dpToPx) - activity!!.appbar.elevation = 0f - - scrollViewWith( - recycler, padBottom = true, customPadding = true, - afterInsets = { insets -> + binding.swipeRefresh.setStyle() + binding.swipeRefresh.setDistanceToTriggerSync(70.dpToPx) + activityBinding!!.appBar.elevation = 0f + + if (isTablet) { + val tHeight = toolbarHeight.takeIf { it ?: 0 > 0 } ?: appbarHeight + headerHeight = tHeight + (activityBinding?.root?.rootWindowInsets?.systemWindowInsetTop ?: 0) + binding.recycler.updatePaddingRelative(top = headerHeight + 4.dpToPx) + binding.recycler.doOnApplyWindowInsets { _, insets, _ -> setInsets(insets, appbarHeight, offset) - }, - liftOnScroll = { - colorToolbar(it) } - ) + } else { + scrollViewWith( + binding.recycler, + padBottom = true, + customPadding = true, + afterInsets = { insets -> + setInsets(insets, appbarHeight, offset) + }, + liftOnScroll = { + colorToolbar(it) + } + ) + } - recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy) - val atTop = !recyclerView.canScrollVertically(-1) - val tY = getHeader()?.backdrop?.translationY ?: 0f - getHeader()?.backdrop?.translationY = max(0f, tY + dy * 0.25f) - if (atTop) getHeader()?.backdrop?.translationY = 0f - } + binding.recycler.addOnScrollListener( + object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + if (!isTablet) { + val atTop = !recyclerView.canScrollVertically(-1) + val tY = getHeader()?.binding?.backdrop?.translationY ?: 0f + getHeader()?.binding?.backdrop?.translationY = max(0f, tY + dy * 0.25f) + if (atTop) getHeader()?.binding?.backdrop?.translationY = 0f + } + } - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - val atTop = !recyclerView.canScrollVertically(-1) - if (atTop) getHeader()?.backdrop?.translationY = 0f + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + val atTop = !recyclerView.canScrollVertically(-1) + if (atTop) getHeader()?.binding?.backdrop?.translationY = 0f + } } - }) + ) } private fun setInsets(insets: WindowInsets, appbarHeight: Int, offset: Int) { - val recycler = recycler ?: return - recycler.updatePaddingRelative(bottom = insets.systemWindowInsetBottom) - headerHeight = appbarHeight + insets.systemWindowInsetTop - swipe_refresh.setProgressViewOffset(false, (-40).dpToPx, headerHeight + offset) - // 1dp extra to line up chapter header and manga header + binding.recycler.updatePaddingRelative(bottom = insets.systemWindowInsetBottom) + binding.tabletRecycler.updatePaddingRelative(bottom = insets.systemWindowInsetBottom) + val tHeight = toolbarHeight.takeIf { it ?: 0 > 0 } ?: appbarHeight + headerHeight = tHeight + insets.systemWindowInsetTop + binding.swipeRefresh.setProgressViewOffset(false, (-40).dpToPx, headerHeight + offset) + if (isTablet) { + binding.tabletOverlay.updateLayoutParams { height = headerHeight } + // 4dp extra to line up chapter header and manga header + binding.recycler.updatePaddingRelative(top = headerHeight + 4.dpToPx) + } getHeader()?.setTopHeight(headerHeight) - fast_scroller.updateLayoutParams { + binding.fastScroller.updateLayoutParams { topMargin = headerHeight bottomMargin = insets.systemWindowInsetBottom } - fast_scroller.scrollOffset = headerHeight + binding.fastScroller.scrollOffset = headerHeight } /** Set the toolbar to fully transparent or colored and translucent */ - fun colorToolbar(isColor: Boolean, animate: Boolean = true) { - if (isColor == toolbarIsColored) return + private fun colorToolbar(isColor: Boolean, animate: Boolean = true) { + if (isColor == toolbarIsColored || (isTablet && isColor)) return toolbarIsColored = isColor val isCurrentController = - router?.backstack?.lastOrNull()?.controller() == this@MangaDetailsController + router?.backstack?.lastOrNull()?.controller == this@MangaDetailsController if (isCurrentController) setTitle() if (actionMode != null) { - (activity as MainActivity).toolbar.setBackgroundColor(Color.TRANSPARENT) + activityBinding?.toolbar?.setBackgroundColor(Color.TRANSPARENT) return } val color = - presenter.coverColor ?: activity!!.getResourceColor(R.attr.colorPrimaryVariant) + coverColor ?: activity!!.getResourceColor(R.attr.colorPrimaryVariant) val colorFrom = if (colorAnimator?.isRunning == true) activity?.window?.statusBarColor ?: color else ColorUtils.setAlphaComponent( - color, if (toolbarIsColored) 0 else 175 + color, + if (toolbarIsColored) 0 else 175 ) val colorTo = ColorUtils.setAlphaComponent( - color, if (toolbarIsColored) 175 else 0 + color, + if (toolbarIsColored) 175 else 0 ) colorAnimator?.cancel() if (animate) { colorAnimator = ValueAnimator.ofObject( - android.animation.ArgbEvaluator(), colorFrom, colorTo + android.animation.ArgbEvaluator(), + colorFrom, + colorTo ) colorAnimator?.duration = 250 // milliseconds colorAnimator?.addUpdateListener { animator -> - (activity as MainActivity).toolbar.setBackgroundColor(animator.animatedValue as Int) + activityBinding?.toolbar?.setBackgroundColor(animator.animatedValue as Int) activity?.window?.statusBarColor = (animator.animatedValue as Int) } colorAnimator?.start() } else { - (activity as MainActivity).toolbar.setBackgroundColor(colorTo) + activityBinding?.toolbar?.setBackgroundColor(colorTo) activity?.window?.statusBarColor = colorTo } } + /** Get the color of the manga cover*/ + fun setPaletteColor() { + val view = view ?: return + + val request = ImageRequest.Builder(view.context).data(presenter.manga).allowHardware(false).memoryCacheKey(presenter.manga.key()) + .target( + onSuccess = { drawable -> + val bitmap = (drawable as BitmapDrawable).bitmap + // Generate the Palette on a background thread. + Palette.from(bitmap).generate { + if (it == null) return@generate + val colorBack = view.context.getResourceColor( + android.R.attr.colorBackground + ) + // this makes the color more consistent regardless of theme + val backDropColor = + ColorUtils.blendARGB(it.getVibrantColor(colorBack), colorBack, .35f) + + coverColor = backDropColor + getHeader()?.setBackDrop(backDropColor) + if (toolbarIsColored) { + val translucentColor = ColorUtils.setAlphaComponent(backDropColor, 175) + activityBinding?.toolbar?.setBackgroundColor(translucentColor) + activity?.window?.statusBarColor = translucentColor + } + } + binding.mangaCoverFull.setImageDrawable(drawable) + getHeader()?.updateCover(manga!!) + }, + onError = { + val file = presenter.coverCache.getCoverFile(manga!!) + if (file.exists()) { + file.delete() + setPaletteColor() + } + } + ).build() + view.context.imageLoader.enqueue(request) + } + /** Set toolbar theme for themes that are inverted (ie. light blue theme) */ private fun setActionBar(forThis: Boolean) { - val activity = activity ?: return + val activity = activity as? MainActivity ?: return + val activityBinding = activityBinding ?: return // if the theme is using inverted toolbar color - if (!activity.isInNightMode() && ThemeUtil.isBlueTheme( - presenter.preferences.theme() - ) - ) { - if (forThis) (activity as MainActivity).appbar.context.setTheme( + if (ThemeUtil.hasDarkActionBarInLight(activity, activity.getPrefTheme(presenter.preferences))) { + if (forThis) activityBinding.appBar.context.setTheme( R.style.ThemeOverlay_AppCompat_DayNight_ActionBar ) - else (activity as MainActivity).appbar.context.setTheme( + else activityBinding.appBar.context.setTheme( R.style.Theme_ActionBar_Dark_DayNight ) @@ -332,37 +415,32 @@ class MangaDetailsController : if (forThis) android.R.attr.textColorPrimary else R.attr.actionBarTintColor ) ?: Color.BLACK - activity.toolbar.setTitleTextColor(iconPrimary) - activity.backArrow?.setTint(iconPrimary) - activity.toolbar.overflowIcon?.setTint(iconPrimary) - if (forThis) activity.main_content.systemUiVisibility = - activity.main_content.systemUiVisibility.or( - View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR - ) - else activity.main_content.systemUiVisibility = - activity.main_content.systemUiVisibility.rem( + activityBinding.toolbar.setTitleTextColor(iconPrimary) + activity.drawerArrow?.color = iconPrimary + activityBinding.toolbar.overflowIcon?.setTint(iconPrimary) + activityBinding.mainContent.systemUiVisibility = if (forThis) { + activityBinding.mainContent.systemUiVisibility.or( View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR ) + } else activityBinding.mainContent.systemUiVisibility.rem( + View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + ) } } private fun setStatusBarAndToolbar() { activity?.window?.statusBarColor = if (toolbarIsColored) { - val translucentColor = ColorUtils.setAlphaComponent(presenter.coverColor ?: Color.TRANSPARENT, 175) - (activity as MainActivity).toolbar.setBackgroundColor(translucentColor) + val translucentColor = ColorUtils.setAlphaComponent(coverColor ?: Color.TRANSPARENT, 175) + activityBinding?.toolbar?.setBackgroundColor(translucentColor) translucentColor } else Color.TRANSPARENT - (activity as MainActivity).appbar.setBackgroundColor(Color.TRANSPARENT) - (activity as MainActivity).toolbar.setBackgroundColor( + activityBinding?.appBar?.setBackgroundColor(Color.TRANSPARENT) + activityBinding?.toolbar?.setBackgroundColor( activity?.window?.statusBarColor ?: Color.TRANSPARENT ) } - fun showSimilarToopTip() { - getHeader()?.showSimilarToolTip(activity) - } - //endregion //region Lifecycle methods @@ -370,14 +448,16 @@ class MangaDetailsController : super.onActivityResumed(activity) presenter.isLockedFromSearch = SecureActivityDelegate.shouldBeLocked() presenter.headerItem.isLocked = presenter.isLockedFromSearch - presenter.manga!!.thumbnail_url = presenter.refreshMangaFromDb().thumbnail_url + manga!!.thumbnail_url = presenter.refreshMangaFromDb().thumbnail_url presenter.fetchChapters(refreshTracker == null) if (refreshTracker != null) { trackingBottomSheet?.refreshItem(refreshTracker ?: 0) presenter.refreshTracking() refreshTracker = null } - val isCurrentController = router?.backstack?.lastOrNull()?.controller() == + // fetch cover again in case the user set a new cover while reading + setPaletteColor() + val isCurrentController = router?.backstack?.lastOrNull()?.controller == this if (isCurrentController) { setStatusBarAndToolbar() @@ -386,27 +466,30 @@ class MangaDetailsController : override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { super.onChangeStarted(handler, type) - if (type == ControllerChangeType.PUSH_ENTER || type == ControllerChangeType.POP_ENTER) { + if (type.isEnter) { setActionBar(true) setStatusBarAndToolbar() - } else if (type == ControllerChangeType.PUSH_EXIT || type == ControllerChangeType.POP_EXIT) { - if (router.backstack.lastOrNull()?.controller() is DialogController) + } else { + if (router.backstack.lastOrNull()?.controller is DialogController) { return + } if (type == ControllerChangeType.POP_EXIT) { setActionBar(false) presenter.cancelScope() } colorAnimator?.cancel() + getHeader()?.clearDescFocus() val colorSecondary = activity?.getResourceColor( R.attr.colorSecondary ) ?: Color.BLACK if (router.backstackSize > 0 && - router.backstack.last().controller() !is MangaDetailsController + router.backstack.last().controller !is MangaDetailsController ) { - (activity as? MainActivity)?.appbar?.setBackgroundColor(colorSecondary) - (activity as? MainActivity)?.toolbar?.setBackgroundColor(colorSecondary) - + if (router.backstack.last().controller !is FloatingSearchInterface) { + activityBinding?.appBar?.setBackgroundColor(colorSecondary) + } + activityBinding?.toolbar?.setBackgroundColor(colorSecondary) activity?.window?.statusBarColor = activity?.getResourceColor( android.R.attr.statusBarColor ) ?: colorSecondary @@ -420,13 +503,16 @@ class MangaDetailsController : ) { super.onChangeEnded(changeHandler, type) if (type == ControllerChangeType.PUSH_ENTER) { - swipe_refresh?.isRefreshing = presenter.isLoading + binding.swipeRefresh.isRefreshing = presenter.isLoading + } + if (!type.isEnter) { + activityBinding?.root?.clearFocus() } } override fun handleBack(): Boolean { - if (manga_cover_full?.visibility == View.VISIBLE) { - manga_cover_full?.performClick() + if (binding.mangaCoverFull.visibility == View.VISIBLE) { + binding.mangaCoverFull.performClick() return true } return super.handleBack() @@ -442,7 +528,7 @@ class MangaDetailsController : } fun showError(message: String) { - swipe_refresh?.isRefreshing = presenter.isLoading + binding.swipeRefresh.isRefreshing = presenter.isLoading view?.snack(message) } @@ -461,7 +547,7 @@ class MangaDetailsController : R.plurals.deleted_chapters, deletedChapters.size, deletedChapters.size, - deletedChapters.joinToString("\n") { "${it.name}" } + deletedChapters.joinToString("\n") { it.name } ) ).positiveButton(R.string.delete) { presenter.deleteChapters(deletedChapters, false) @@ -474,47 +560,57 @@ class MangaDetailsController : } fun setRefresh(enabled: Boolean) { - swipe_refresh?.isRefreshing = enabled + binding.swipeRefresh.isRefreshing = enabled } //region Recycler methods fun updateChapterDownload(download: Download) { getHolder(download.chapter)?.notifyStatus( - download.status, presenter.isLockedFromSearch, - download.progress + download.status, + presenter.isLockedFromSearch, + download.progress, + true ) } private fun getHolder(chapter: Chapter): ChapterHolder? { - return recycler?.findViewHolderForItemId(chapter.id!!) as? ChapterHolder + return binding.recycler.findViewHolderForItemId(chapter.id!!) as? ChapterHolder } private fun getHeader(): MangaHeaderHolder? { - return recycler?.findViewHolderForAdapterPosition(0) as? MangaHeaderHolder + return if (isTablet) binding.tabletRecycler.findViewHolderForAdapterPosition(0) as? MangaHeaderHolder + else binding.recycler.findViewHolderForAdapterPosition(0) as? MangaHeaderHolder } fun updateHeader() { - swipe_refresh?.isRefreshing = presenter.isLoading + binding.swipeRefresh.isRefreshing = presenter.isLoading adapter?.setChapters(presenter.chapters) + tabletAdapter?.notifyDataSetChanged() addMangaHeader() activity?.invalidateOptionsMenu() } fun updateChapters(chapters: List) { view ?: return - swipe_refresh?.isRefreshing = presenter.isLoading + binding.swipeRefresh.isRefreshing = presenter.isLoading if (presenter.chapters.isEmpty() && fromCatalogue && !presenter.hasRequested) { - launchUI { swipe_refresh?.isRefreshing = true } + launchUI { binding.swipeRefresh.isRefreshing = true } presenter.fetchChaptersFromSource() } + tabletAdapter?.notifyDataSetChanged() adapter?.setChapters(chapters) addMangaHeader() - colorToolbar(recycler?.canScrollVertically(-1) == true) + colorToolbar(binding.recycler.canScrollVertically(-1)) activity?.invalidateOptionsMenu() } private fun addMangaHeader() { - if (adapter?.scrollableHeaders?.isEmpty() == true) { + if (tabletAdapter?.scrollableHeaders?.isEmpty() == true) { + tabletAdapter?.removeAllScrollableHeaders() + tabletAdapter?.addScrollableHeader(presenter.headerItem) + adapter?.removeAllScrollableHeaders() + adapter?.addScrollableHeader(presenter.tabletChapterHeaderItem!!) + } else if (!isTablet && adapter?.scrollableHeaders?.isEmpty() == true) { adapter?.removeAllScrollableHeaders() adapter?.addScrollableHeader(presenter.headerItem) } @@ -526,16 +622,16 @@ class MangaDetailsController : val chapterItem = (adapter?.getItem(position) as? ChapterItem) ?: return false val chapter = chapterItem.chapter if (actionMode != null) { - if (startingDLChapterPos == null) { + if (startingRangeChapterPos == null) { adapter?.addSelection(position) - (recycler.findViewHolderForAdapterPosition(position) as? BaseFlexibleViewHolder) + (binding.recycler.findViewHolderForAdapterPosition(position) as? BaseFlexibleViewHolder) ?.toggleActivation() - (recycler.findViewHolderForAdapterPosition(position) as? ChapterHolder) - ?.notifyStatus(Download.CHECKED, false, 0) - startingDLChapterPos = position + (binding.recycler.findViewHolderForAdapterPosition(position) as? ChapterHolder) + ?.notifyStatus(Download.State.CHECKED, false, 0) + startingRangeChapterPos = position actionMode?.invalidate() } else { - val startingPosition = startingDLChapterPos ?: return false + val startingPosition = startingRangeChapterPos ?: return false var chapterList = listOf() when { startingPosition > position -> @@ -543,12 +639,17 @@ class MangaDetailsController : startingPosition <= position -> chapterList = presenter.chapters.subList(startingPosition - 1, position) } - downloadChapters(chapterList) + when (rangeMode) { + RangeMode.Download -> downloadChapters(chapterList) + RangeMode.Read -> markAsRead(chapterList) + RangeMode.Unread -> markAsUnread(chapterList) + } presenter.fetchChapters(false) adapter?.removeSelection(startingPosition) - (recycler.findViewHolderForAdapterPosition(startingPosition) as? BaseFlexibleViewHolder) + (binding.recycler.findViewHolderForAdapterPosition(startingPosition) as? BaseFlexibleViewHolder) ?.toggleActivation() - startingDLChapterPos = null + startingRangeChapterPos = null + rangeMode = null destroyActionModeIfNeeded() } return false @@ -561,32 +662,64 @@ class MangaDetailsController : override fun onItemLongClick(position: Int) { val adapter = adapter ?: return val item = (adapter.getItem(position) as? ChapterItem) ?: return - val itemView = getHolder(item)?.itemView ?: return - val popup = PopupMenu(itemView.context, itemView) - chapterPopupMenu = position to popup - - // Inflate our menu resource into the PopupMenu's Menu - popup.menuInflater.inflate(R.menu.chapter_single, popup.menu) - //if (!item.chapter.isMergedChapter()) { + val descending = presenter.sortDescending() + val items = listOf( + MaterialMenuSheet.MenuSheetItem( + 0, + if (descending) R.drawable.ic_eye_down_24dp else R.drawable.ic_eye_up_24dp, + R.string.mark_previous_as_read + ), + MaterialMenuSheet.MenuSheetItem( + 1, + if (descending) R.drawable.ic_eye_off_down_24dp else R.drawable.ic_eye_off_up_24dp, + R.string.mark_previous_as_unread + ), + MaterialMenuSheet.MenuSheetItem( + 2, + R.drawable.ic_eye_range_24dp, + R.string.mark_range_as_read + ), + MaterialMenuSheet.MenuSheetItem( + 3, + R.drawable.ic_eye_off_range_24dp, + R.string.mark_range_as_unread + ) + ) + // add comments in future + // if (!item.chapter.isMergedChapter()) { // popup.menu.findItem(R.id.action_view_comments).isVisible = true - //} - - popup.setOnMenuItemClickListener { menuItem -> - when (menuItem.itemId) { - R.id.action_mark_previous_as_read -> markPreviousAs(item, true) - R.id.action_mark_previous_as_unread -> markPreviousAs(item, false) - R.id.action_view_comments -> viewComments(item) + // } + val menuSheet = MaterialMenuSheet(activity!!, items, item.name) { _, itemPos -> + when (itemPos) { + 0 -> markPreviousAs(item, true) + 1 -> markPreviousAs(item, false) + 2 -> startReadRange(position, RangeMode.Read) + 3 -> startReadRange(position, RangeMode.Unread) } - chapterPopupMenu = null true } - - // Finally show the PopupMenu - popup.show() + menuSheet.show() +// val popup = PopupMenu(itemView.context, itemView) +// chapterPopupMenu = position to popup +// +// // Inflate our menu resource into the PopupMenu's Menu +// popup.menuInflater.inflate(R.menu.chapter_single, popup.menu) +// +// popup.setOnMenuItemClickListener { menuItem -> +// when (menuItem.itemId) { +// R.id.action_mark_previous_as_read -> markPreviousAs(item, true) +// R.id.action_mark_previous_as_unread -> markPreviousAs(item, false) +// } +// chapterPopupMenu = null +// true +// } +// +// // Finally show the PopupMenu +// popup.show() } override fun onActionStateChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { - swipe_refresh.isEnabled = actionState != ItemTouchHelper.ACTION_STATE_SWIPE + binding.swipeRefresh.isEnabled = actionState != ItemTouchHelper.ACTION_STATE_SWIPE } override fun onItemMove(fromPosition: Int, toPosition: Int) { @@ -619,7 +752,6 @@ class MangaDetailsController : fun bookmarkChapter(position: Int) { val item = adapter?.getItem(position) as? ChapterItem ?: return - val chapter = item.chapter val bookmarked = item.bookmark bookmarkChapters(listOf(item), !bookmarked) snack?.dismiss() @@ -653,14 +785,16 @@ class MangaDetailsController : presenter.markChaptersRead(listOf(item), read, true, lastRead, pagesLeft) undoing = true } - addCallback(object : BaseTransientBottomBar.BaseCallback() { - override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { - super.onDismissed(transientBottomBar, event) - if (!undoing && !read && presenter.preferences.removeAfterMarkedAsRead() && item.chapter.bookmark.not()) { - presenter.deleteChapters(listOf(item)) + addCallback( + object : BaseTransientBottomBar.BaseCallback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + super.onDismissed(transientBottomBar, event) + if (!undoing && !read && presenter.preferences.removeAfterMarkedAsRead() && item.chapter.bookmark.not()) { + presenter.deleteChapters(listOf(item)) + } } } - }) + ) } (activity as? MainActivity)?.setUndoSnackBar(snack) } @@ -673,7 +807,7 @@ class MangaDetailsController : presenter.markChaptersRead(chapters, true) } - private fun markAsUnread(chapters: List) { + fun markAsUnread(chapters: List) { presenter.markChaptersRead(chapters, false) } @@ -683,17 +817,26 @@ class MangaDetailsController : view?.snack(R.string.using_cached_source_cant_open) return } - if (XLogLevel.shouldLog(XLogLevel.EXTRA)) - XLog.d("-- Chapter List Before Reader --") - for (chapter in presenter.chapters) { - XLog.d(chapter.chapterLog()) - } - val intent = ReaderActivity.newIntent(activity, presenter.manga, chapter) + val intent = ReaderActivity.newIntent(activity, manga!!, chapter) startActivity(intent) } //region action bar menu methods override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + if (fullCoverActive) { + colorToolbar(isColor = false) + activityBinding?.toolbar?.navigationIcon = + view?.context?.contextCompatDrawable(R.drawable.ic_arrow_back_24dp)?.apply { + setTint(Color.WHITE) + } + inflater.inflate(R.menu.manga_details_cover, menu) + return + } + colorToolbar(binding.recycler.canScrollVertically(-1)) + activityBinding?.toolbar?.navigationIcon = + activityBinding?.toolbar?.navigationIcon?.mutate()?.apply { + setTint(view?.context?.getResourceColor(R.attr.actionBarTintColor) ?: Color.WHITE) + } inflater.inflate(R.menu.manga_details, menu) menu.findItem(R.id.action_download).isVisible = !presenter.isLockedFromSearch menu.findItem(R.id.action_mark_all_as_read).isVisible = @@ -703,7 +846,8 @@ class MangaDetailsController : menu.findItem(R.id.action_remove_downloads).isVisible = presenter.hasDownloads() && !presenter.isLockedFromSearch menu.findItem(R.id.remove_non_bookmarked).isVisible = - presenter.hasBookmarks() && !presenter.isLockedFromSearch + presenter.hasBookmark() && !presenter.isLockedFromSearch + val iconPrimary = view?.context?.getResourceColor(android.R.attr.textColorPrimary) ?: Color.BLACK menu.findItem(R.id.action_download).icon?.mutate()?.setTint(iconPrimary) @@ -719,28 +863,18 @@ class MangaDetailsController : searchView.clearFocus() } - val menuItems = menu.iterator() - while (menuItems.hasNext()) { - menuItems.next().isVisible = !fullCoverActive - } - val saveItem = menu.findItem(R.id.save) - val shareItem = menu.findItem(R.id.share) - saveItem.isVisible = fullCoverActive - shareItem.isVisible = fullCoverActive - if (fullCoverActive) { - saveItem.icon.setTint(Color.WHITE) - shareItem.icon.setTint(Color.WHITE) - } - setOnQueryTextChangeListener(searchView) { query = it ?: "" - if (query.isNotEmpty()) getHeader()?.collapse() - else getHeader()?.expand() + if (!isTablet) { + if (query.isNotEmpty()) getHeader()?.collapse() + else getHeader()?.expand() + } adapter?.setFilter(query) adapter?.performFilter() true } + searchItem.fixExpand(onExpand = { invalidateMenuOnExpand() }) } override fun onOptionsItemSelected(item: MenuItem): Boolean { @@ -753,7 +887,12 @@ class MangaDetailsController : }.negativeButton(android.R.string.cancel).show() } R.id.remove_all, R.id.remove_read, R.id.remove_non_bookmarked -> massDeleteChapters(item.itemId) - R.id.action_mark_all_as_unread -> markAsUnread(presenter.chapters) + R.id.action_mark_all_as_unread -> { + MaterialDialog(view!!.context).message(R.string.mark_all_chapters_as_unread) + .positiveButton(R.string.mark_as_unread) { + markAsUnread(presenter.chapters) + }.negativeButton(android.R.string.cancel).show() + } R.id.download_next, R.id.download_next_5, R.id.download_custom, R.id.download_unread, R.id.download_all -> downloadChapters( item.itemId ) @@ -787,7 +926,7 @@ class MangaDetailsController : override fun prepareToShareManga() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val request = ImageRequest.Builder(activity!!).data(presenter.manga).target( + val request = ImageRequest.Builder(activity!!).data(manga).target( onError = { shareManga() }, @@ -839,6 +978,7 @@ class MangaDetailsController : presenter.removeMerged() } } + negativeButton() positiveButton(android.R.string.yes) } } else { @@ -852,7 +992,9 @@ class MangaDetailsController : if (isNotOnline()) return val activity = activity ?: return val intent = WebViewActivity.newIntent( - activity.applicationContext, presenter.source.id, url, + activity.applicationContext, + presenter.source.id, + url, presenter.manga .title ) @@ -861,24 +1003,31 @@ class MangaDetailsController : private fun massDeleteChapters(choice: Int) { val chaptersToDelete = when (choice) { - R.id.remove_all -> presenter.chapters - R.id.remove_non_bookmarked -> presenter.chapters.filter { !it.bookmark } - R.id.remove_read -> presenter.chapters.filter { it.read } + R.id.remove_all -> presenter.allChapters + R.id.remove_non_bookmarked -> presenter.allChapters.filter { !it.bookmark } + R.id.remove_read -> presenter.allChapters.filter { it.read } else -> emptyList() }.filter { it.isDownloaded } - if (chaptersToDelete.isNotEmpty()) { - massDeleteChapters(chaptersToDelete) + if (chaptersToDelete.isNotEmpty() || choice == R.id.remove_all) { + massDeleteChapters(chaptersToDelete, choice == R.id.remove_all) + } else { + snack?.dismiss() + snack = view?.snack(R.string.no_chapters_to_delete) } } - private fun massDeleteChapters(chapters: List) { + private fun massDeleteChapters(chapters: List, isEverything: Boolean) { val context = view?.context ?: return MaterialDialog(context).message( - text = context.resources.getQuantityString( - R.plurals.remove_n_chapters, chapters.size, chapters.size + text = + if (isEverything) context.getString(R.string.remove_all_downloads) + else context.resources.getQuantityString( + R.plurals.remove_n_chapters, + chapters.size, + chapters.size ) ).positiveButton(R.string.remove) { - presenter.deleteChapters(chapters) + presenter.deleteChapters(chapters, isEverything = isEverything) }.negativeButton(android.R.string.cancel).show() } @@ -888,10 +1037,11 @@ class MangaDetailsController : R.id.download_next_5 -> presenter.getUnreadChaptersSorted().take(5) R.id.download_custom -> { createActionModeIfNeeded() + rangeMode = RangeMode.Download return } - R.id.download_unread -> presenter.chapters.filter { !it.read } - R.id.download_all -> presenter.chapters + R.id.download_unread -> presenter.allChapters.filter { !it.read } + R.id.download_all -> presenter.allChapters else -> emptyList() } if (chaptersToDownload.isNotEmpty()) { @@ -908,7 +1058,7 @@ class MangaDetailsController : } //region Interface methods - override fun coverColor(): Int? = presenter.coverColor + override fun coverColor(): Int? = coverColor override fun topCoverHeight(): Int = headerHeight override fun startDownloadNow(position: Int) { @@ -918,9 +1068,9 @@ class MangaDetailsController : // In case the recycler is at the bottom and collapsing the header makes it unscrollable override fun updateScroll() { - if (recycler?.canScrollVertically(-1) == false) { - getHeader()?.backdrop?.translationY = 0f - activity?.appbar?.y = 0f + if (!binding.recycler.canScrollVertically(-1)) { + getHeader()?.binding?.backdrop?.translationY = 0f + activityBinding?.appBar?.y = 0f colorToolbar(isColor = false, animate = false) } } @@ -930,31 +1080,40 @@ class MangaDetailsController : presenter.downloadChapters(chapters) val text = view.context.getString( R.string.add_x_to_library, - presenter.manga.mangaType - (view.context).toLowerCase(Locale.ROOT) + presenter.manga.seriesType + (view.context).toLowerCase(Locale.ROOT) ) if (!presenter.manga.favorite && ( - snack == null || - snack?.getText() != text - ) + snack == null || + snack?.getText() != text + ) ) { snack = view.snack(text, Snackbar.LENGTH_INDEFINITE) { setAction(R.string.add) { presenter.setFavorite(true) } - addCallback(object : BaseTransientBottomBar.BaseCallback() { - override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { - super.onDismissed(transientBottomBar, event) - if (snack == transientBottomBar) snack = null + addCallback( + object : BaseTransientBottomBar.BaseCallback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + super.onDismissed(transientBottomBar, event) + if (snack == transientBottomBar) snack = null + } } - }) + ) } (activity as? MainActivity)?.setUndoSnackBar(snack) } } override fun startDownloadRange(position: Int) { - if (actionMode == null) createActionModeIfNeeded() + createActionModeIfNeeded() + rangeMode = RangeMode.Download + onItemClick(null, position) + } + + private fun startReadRange(position: Int, mode: RangeMode) { + createActionModeIfNeeded() + rangeMode = mode onItemClick(null, position) } @@ -966,17 +1125,18 @@ class MangaDetailsController : val item = presenter.getNextUnreadChapter() if (item != null) { openChapter(item.chapter) - } else if (snack == null || snack?.getText() != view?.context?.getString( - R.string.next_chapter_not_found - ) + } else if (snack == null || + snack?.getText() != view?.context?.getString(R.string.next_chapter_not_found) ) { snack = view?.snack(R.string.next_chapter_not_found, Snackbar.LENGTH_LONG) { - addCallback(object : BaseTransientBottomBar.BaseCallback() { - override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { - super.onDismissed(transientBottomBar, event) - if (snack == transientBottomBar) snack = null + addCallback( + object : BaseTransientBottomBar.BaseCallback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + super.onDismissed(transientBottomBar, event) + if (snack == transientBottomBar) snack = null + } } - }) + ) } } } @@ -988,18 +1148,19 @@ class MangaDetailsController : onItemClick(null, position) return } - if (chapter.status != Download.NOT_DOWNLOADED && chapter.status != Download.ERROR) { + if (chapter.status != Download.State.NOT_DOWNLOADED && chapter.status != Download.State.ERROR) { presenter.deleteChapter(chapter) } else { - if (chapter.status == Download.ERROR) + if (chapter.status == Download.State.ERROR) { DownloadService.start(view.context) - else + } else { downloadChapters(listOf(chapter)) + } } } override fun tagClicked(text: String) { - val firstController = router.backstack.first()?.controller() + val firstController = router.backstack.first()?.controller if (firstController is LibraryController && router.backstack.size == 2) { router.handleBack() firstController.search(text) @@ -1012,32 +1173,31 @@ class MangaDetailsController : override fun favoriteManga(longPress: Boolean) { if (isLocked()) return - view ?: return val manga = presenter.manga val categories = presenter.getCategories() if (!manga.favorite) { toggleMangaFavorite() } else { - val favButton = getHeader()?.favorite_button ?: return + val favButton = getHeader()?.binding?.favoriteButton ?: return val popup = makeFavPopup(favButton, manga, categories) - popup.show() + popup?.show() } } override fun setFavButtonPopup(popupView: View) { if (isLocked()) return - view ?: return val manga = presenter.manga if (!manga.favorite) { popupView.setOnTouchListener(null) return } val popup = makeFavPopup(popupView, manga, presenter.getCategories()) - popupView.setOnTouchListener(popup.dragToOpenListener) + popupView.setOnTouchListener(popup?.dragToOpenListener) } - fun makeFavPopup(popupView: View, manga: Manga, categories: List): PopupMenu { - val popup = PopupMenu(view!!.context, popupView) + private fun makeFavPopup(popupView: View, manga: Manga, categories: List): PopupMenu? { + val view = view ?: return null + val popup = PopupMenu(view.context, popupView) popup.menu.add(0, 1, 0, R.string.remove_from_library) if (categories.isNotEmpty()) { popup.menu.add(0, 0, 1, R.string.edit_categories) @@ -1046,15 +1206,9 @@ class MangaDetailsController : // Set a listener so we are notified if a menu item is clicked popup.setOnMenuItemClickListener { menuItem -> if (menuItem.itemId == 0) { - val ids = presenter.getMangaCategoryIds() - val preselected = ids.mapNotNull { id -> - categories.indexOfFirst { it.id == id }.takeIf { it != -1 } - }.toTypedArray() - ChangeMangaCategoriesDialog( - this, listOf(manga), categories, preselected - ).showDialog( - router - ) + presenter.manga.moveCategories(presenter.db, activity!!) { + updateHeader() + } } else { toggleMangaFavorite() } @@ -1064,31 +1218,24 @@ class MangaDetailsController : } private fun toggleMangaFavorite() { - if (presenter.toggleFavorite()) { - val categories = presenter.getCategories() - val defaultCategoryId = presenter.preferences.defaultCategory() - val defaultCategory = categories.find { it.id == defaultCategoryId } - when { - defaultCategory != null -> presenter.moveMangaToCategory(defaultCategory) - defaultCategoryId == 0 || categories.isEmpty() -> // 'Default' or no category - presenter.moveMangaToCategory(null) - else -> { - val ids = presenter.getMangaCategoryIds() - val preselected = ids.mapNotNull { id -> - categories.indexOfFirst { it.id == id }.takeIf { it != -1 } - }.toTypedArray() - - AddToLibraryCategoriesDialog( - this, - presenter.manga, - categories, - preselected - ).showDialog(router) - } - } - showAddedSnack() - } else { - showRemovedSnack() + val view = view ?: return + val activity = activity ?: return + snack?.dismiss() + snack = presenter.manga.addOrRemoveToFavorites( + presenter.db, + presenter.preferences, + view, + activity, + onMangaAdded = { + updateHeader() + showAddedSnack() + }, + onMangaMoved = { updateHeader() }, + onMangaDeleted = { presenter.confirmDeletion() } + ) + if (snack?.duration == Snackbar.LENGTH_INDEFINITE) { + val favButton = getHeader()?.binding?.favoriteButton + (activity as? MainActivity)?.setUndoSnackBar(snack, favButton) } } @@ -1098,47 +1245,8 @@ class MangaDetailsController : snack = view.snack(view.context.getString(R.string.added_to_library)) } - private fun showRemovedSnack() { - val view = view ?: return - snack?.dismiss() - snack = view.snack( - view.context.getString(R.string.removed_from_library), - Snackbar.LENGTH_INDEFINITE - ) { - setAction(R.string.undo) { - presenter.setFavorite(true) - presenter.confirmDelete = false - } - addCallback(object : BaseTransientBottomBar.BaseCallback() { - override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { - super.onDismissed(transientBottomBar, event) - if (!presenter.manga.favorite) presenter.confirmDelete = true - } - }) - } - val favButton = getHeader()?.favorite_button - (activity as? MainActivity)?.setUndoSnackBar(snack, favButton) - } - - override fun onDestroy() { - if (!presenter.manga.favorite) presenter.clearMangaFromStorage() - super.onDestroy() - } - override fun mangaPresenter(): MangaDetailsPresenter = presenter - override fun updateCategoriesForMangas(mangas: List, categories: List) { - presenter.moveMangaToCategories(categories) - } - - override fun updateCategoriesForManga(manga: Manga?, categories: List) { - manga?.let { presenter.moveMangaToCategories(categories) } - } - - override fun addToLibraryCancelled(manga: Manga?, position: Int) { - manga?.let { presenter.toggleFavorite() } - } - /** * Copies a string to clipboard * @@ -1172,34 +1280,9 @@ class MangaDetailsController : externalBottomSheet?.show() } - override fun generatePalette(input: Bitmap) { - val view = view ?: return - presenter.scope.launch(Dispatchers.Main) { - val p = async(Dispatchers.IO) { Palette.from(input).generate() } - if (recycler != null) { - val colorBack = view.context.getResourceColor( - android.R.attr.colorBackground - ) - - // this makes the color more consistent regardless of theme - val backDropColor = - ColorUtils.blendARGB(p.await().getVibrantColor(colorBack), colorBack, .35f) - presenter.coverColor = backDropColor - getHeader()?.setBackDrop(backDropColor) - setStatusBarAndToolbar() - if (toolbarIsColored) { - val translucentColor = ColorUtils.setAlphaComponent(backDropColor, 175) - (activity as MainActivity).toolbar.setBackgroundColor(translucentColor) - activity?.window?.statusBarColor = translucentColor - } - } - } - } - //endregion //region Tracking methods - fun refreshTracking(trackings: List) { trackingBottomSheet?.onNextTrackings(trackings) } @@ -1208,6 +1291,10 @@ class MangaDetailsController : trackingBottomSheet?.onSearchResults(results) } + fun refreshTracker() { + // getHeader()?.updateTracking() + } + fun onMergeSearchResults(results: List) { mergeSearchDialog?.onSearchResults(results) } @@ -1230,18 +1317,16 @@ class MangaDetailsController : XLog.e(error) trackingBottomSheet?.onSearchResultsError(error) } - //endregion //region Action mode methods private fun createActionModeIfNeeded() { if (actionMode == null) { actionMode = (activity as AppCompatActivity).startSupportActionMode(this) - (activity as MainActivity).toolbar.setBackgroundColor(Color.TRANSPARENT) + activityBinding?.toolbar?.setBackgroundColor(Color.TRANSPARENT) val view = activity?.window?.currentFocus ?: return - val imm = - activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager - ?: return + val imm = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + ?: return imm.hideSoftInputFromWindow(view.windowToken, 0) if (adapter?.mode != SelectableAdapter.Mode.MULTI) { adapter?.mode = SelectableAdapter.Mode.MULTI @@ -1266,8 +1351,9 @@ class MangaDetailsController : override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean { mode?.title = view?.context?.getString( - if (startingDLChapterPos == null) - R.string.select_starting_chapter else R.string.select_ending_chapter + if (startingRangeChapterPos == null) { + R.string.select_starting_chapter + } else R.string.select_ending_chapter ) return false } @@ -1275,13 +1361,16 @@ class MangaDetailsController : override fun onDestroyActionMode(mode: ActionMode?) { actionMode = null setStatusBarAndToolbar() - if (startingDLChapterPos != null) { - val item = adapter?.getItem(startingDLChapterPos!!) as? ChapterItem - (recycler.findViewHolderForAdapterPosition(startingDLChapterPos!!) as? ChapterHolder)?.notifyStatus( - item?.status ?: Download.NOT_DOWNLOADED, false, 0 + if (startingRangeChapterPos != null && rangeMode == RangeMode.Download) { + val item = adapter?.getItem(startingRangeChapterPos!!) as? ChapterItem + (binding.recycler.findViewHolderForAdapterPosition(startingRangeChapterPos!!) as? ChapterHolder)?.notifyStatus( + item?.status ?: Download.State.NOT_DOWNLOADED, + false, + 0 ) } - startingDLChapterPos = null + rangeMode = null + startingRangeChapterPos = null adapter?.mode = SelectableAdapter.Mode.IDLE adapter?.clearSelection() return @@ -1297,19 +1386,29 @@ class MangaDetailsController : } //endregion + fun changeCover() { + if (manga?.favorite == true) { + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.type = "image/*" + startActivityForResult( + Intent.createChooser( + intent, + resources?.getString(R.string.select_cover_image) + ), + 101 + ) + } else { + activity?.toast(R.string.must_be_in_library_to_edit) + } + } + override fun zoomImageFromThumb(thumbView: View) { // If there's an animation in progress, cancel it immediately and proceed with this one. currentAnimator?.cancel() // Load the high-resolution "zoomed-in" image. - val expandedImageView = manga_cover_full ?: return - manga_cover_full.loadAny( - presenter.manga, - builder = { - if (presenter.manga.favorite) networkCachePolicy(CachePolicy.DISABLED) - } - ) - val fullBackdrop = full_backdrop + val expandedImageView = binding.mangaCoverFull + val fullBackdrop = binding.fullBackdrop // Hide the thumbnail and show the zoomed-in view. When the animation // begins, it will position the zoomed-in view in the place of the @@ -1320,7 +1419,7 @@ class MangaDetailsController : // Set the pivot point to 0 to match thumbnail - swipe_refresh.isEnabled = false + binding.swipeRefresh.isEnabled = false val rect = Rect() thumbView.getGlobalVisibleRect(rect) @@ -1335,15 +1434,14 @@ class MangaDetailsController : expandedImageView.requestLayout() val activity = activity as? MainActivity ?: return - val currTheme = activity.appbar.context.theme - val currColor = (activity.backArrow as IconicsDrawable).colorInt + val currColor = activity.drawerArrow?.color if (!activity.isInNightMode()) { - activity.appbar.context.setTheme(R.style.ThemeOverlay_AppCompat_Dark_ActionBar) + activityBinding?.appBar?.context?.setTheme(R.style.ThemeOverlay_AppCompat_Dark_ActionBar) val iconPrimary = Color.WHITE - activity.toolbar.setTitleTextColor(iconPrimary) - activity.backArrow?.setTint(iconPrimary) - activity.toolbar.overflowIcon?.setTint(iconPrimary) + activityBinding?.toolbar?.setTitleTextColor(iconPrimary) + activity.drawerArrow?.color = iconPrimary + activityBinding?.toolbar?.overflowIcon?.setTint(iconPrimary) activity.window.decorView.systemUiVisibility = activity.window.decorView.systemUiVisibility.rem( View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR @@ -1360,7 +1458,7 @@ class MangaDetailsController : topMargin = defMargin + headerHeight leftMargin = defMargin rightMargin = defMargin - bottomMargin = defMargin + recycler.paddingBottom + bottomMargin = defMargin + binding.recycler.paddingBottom } val shortAnimationDuration = resources?.getInteger( android.R.integer.config_shortAnimTime @@ -1373,7 +1471,7 @@ class MangaDetailsController : val changeImageTransform = ChangeImageTransform() transitionSet.addTransition(changeImageTransform) transitionSet.duration = shortAnimationDuration.toLong() - TransitionManager.beginDelayedTransition(frame_layout, transitionSet) + TransitionManager.beginDelayedTransition(binding.frameLayout, transitionSet) // AnimationSet for backdrop because idk how to use TransitionSet currentAnimator = AnimatorSet().apply { @@ -1382,18 +1480,20 @@ class MangaDetailsController : ) duration = shortAnimationDuration.toLong() interpolator = DecelerateInterpolator() - addListener(object : AnimatorListenerAdapter() { - - override fun onAnimationEnd(animation: Animator) { - TransitionManager.endTransitions(frame_layout) - currentAnimator = null + addListener( + object : AnimatorListenerAdapter() { + + override fun onAnimationEnd(animation: Animator) { + TransitionManager.endTransitions(binding.frameLayout) + currentAnimator = null + } + + override fun onAnimationCancel(animation: Animator) { + TransitionManager.endTransitions(binding.frameLayout) + currentAnimator = null + } } - - override fun onAnimationCancel(animation: Animator) { - TransitionManager.endTransitions(frame_layout) - currentAnimator = null - } - }) + ) start() } @@ -1420,7 +1520,7 @@ class MangaDetailsController : val changeImageTransform2 = ChangeImageTransform() transitionSet2.addTransition(changeImageTransform2) transitionSet2.duration = shortAnimationDuration.toLong() - TransitionManager.beginDelayedTransition(frame_layout, transitionSet2) + TransitionManager.beginDelayedTransition(binding.frameLayout, transitionSet2) // Animation to remove backdrop and hide the full cover currentAnimator = AnimatorSet().apply { @@ -1429,14 +1529,14 @@ class MangaDetailsController : interpolator = DecelerateInterpolator() if (!activity.isInNightMode()) { - activity.appbar.context.setTheme( - ThemeUtil.theme(presenter.preferences.theme()) + activityBinding?.appBar?.context?.setTheme( + activity.getPrefTheme(presenter.preferences).styleRes ) val iconPrimary = currColor ?: Color.WHITE - activity.toolbar.setTitleTextColor(iconPrimary) - activity.backArrow?.setTint(iconPrimary) - activity.toolbar.overflowIcon?.setTint(iconPrimary) + activityBinding?.toolbar?.setTitleTextColor(iconPrimary) + activity.drawerArrow?.color = iconPrimary + activityBinding?.toolbar?.overflowIcon?.setTint(iconPrimary) activity.window.decorView.systemUiVisibility = activity.window.decorView.systemUiVisibility.or( View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR @@ -1449,7 +1549,7 @@ class MangaDetailsController : thumbView.alpha = 1f expandedImageView.visibility = View.GONE fullBackdrop.visibility = View.GONE - swipe_refresh.isEnabled = true + binding.swipeRefresh.isEnabled = true currentAnimator = null } @@ -1457,27 +1557,27 @@ class MangaDetailsController : thumbView.alpha = 1f expandedImageView.visibility = View.GONE fullBackdrop.visibility = View.GONE - swipe_refresh.isEnabled = true + binding.swipeRefresh.isEnabled = true currentAnimator = null } - }) + } + ) start() } } } } - fun clearCoverCache() { - backdrop?.setImageDrawable(null) - true_backdrop?.setBackgroundColor(Color.TRANSPARENT) - manga_cover_full?.setImageDrawable(null) - manga_cover?.setImageDrawable(null) - } - companion object { const val UPDATE_EXTRA = "update" const val FROM_CATALOGUE_EXTRA = "from_catalogue" const val MANGA_EXTRA = "manga" + + private enum class RangeMode { + Download, + Read, + Unread + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt index d6bfdf93d2..dcb0dc9c3b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt @@ -3,21 +3,28 @@ package eu.kanade.tachiyomi.ui.manga import android.app.Application import android.graphics.Bitmap import android.os.Environment +import coil.Coil +import coil.imageLoader +import coil.memory.MemoryCache +import coil.request.CachePolicy +import coil.request.ImageRequest +import coil.request.Parameters +import coil.request.SuccessResult import com.elvishew.xlog.XLog import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.MangaCategory import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.filterIfUsingCache import eu.kanade.tachiyomi.data.database.models.scanlatorList import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.DownloadQueue +import eu.kanade.tachiyomi.data.image.coil.MangaFetcher +import eu.kanade.tachiyomi.data.library.CustomMangaManager import eu.kanade.tachiyomi.data.library.LibraryServiceListener import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.preference.PreferencesHelper @@ -40,22 +47,25 @@ import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate import eu.kanade.tachiyomi.util.chapter.ChapterFilter import eu.kanade.tachiyomi.util.chapter.ChapterUtil import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource +import eu.kanade.tachiyomi.util.manga.MangaShortcutManager +import eu.kanade.tachiyomi.util.shouldDownloadNewChapters import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.executeOnIO +import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.v5.db.V5DbHelper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy import java.io.File import java.io.FileOutputStream import java.io.OutputStream @@ -66,18 +76,17 @@ class MangaDetailsPresenter( val manga: Manga, val preferences: PreferencesHelper = Injekt.get(), val coverCache: CoverCache = Injekt.get(), - private val db: DatabaseHelper = Injekt.get(), + val db: DatabaseHelper = Injekt.get(), val downloadManager: DownloadManager = Injekt.get(), + private val chapterFilter: ChapterFilter = Injekt.get(), val sourceManager: SourceManager = Injekt.get(), val v5DbHelper: V5DbHelper = Injekt.get(), - private val chapterFilter: ChapterFilter = Injekt.get() ) : DownloadQueue.DownloadListener, LibraryServiceListener { - var scope = CoroutineScope(Job() + Dispatchers.Default) + private var scope = CoroutineScope(Job() + Dispatchers.Default) - var coverColor: Int? = null - - var confirmDelete = false + private val customMangaManager: CustomMangaManager by injectLazy() + private val mangaShortcutManager: MangaShortcutManager by injectLazy() val source = sourceManager.getMangadex() @@ -88,7 +97,7 @@ class MangaDetailsPresenter( var isLoading = false var scrollType = 0 - private val trackManager: TrackManager by lazy { Injekt.get() } + private val trackManager: TrackManager by injectLazy() private val loggedServices by lazy { trackManager.services.filter { it.isLogged || it.isMdList() } } var tracks = emptyList() @@ -100,36 +109,45 @@ class MangaDetailsPresenter( var chapters: List = emptyList() private set + var allChapters: List = emptyList() + private set + var allChapterScanlators: Set = emptySet() private set var filteredScanlators: Set = emptySet() var headerItem = MangaHeaderItem(manga, controller.fromCatalogue) + var tabletChapterHeaderItem: MangaHeaderItem? = null fun onCreate() { + headerItem.isTablet = controller.isTablet + if (controller.isTablet) { + tabletChapterHeaderItem = MangaHeaderItem(manga, false) + tabletChapterHeaderItem?.isChapterHeader = true + } isLockedFromSearch = SecureActivityDelegate.shouldBeLocked() headerItem.isLocked = isLockedFromSearch downloadManager.addListener(this) LibraryUpdateService.setListener(this) tracks = db.getTracks(manga).executeAsBlocking() - if (!manga.initialized) { isLoading = true controller.setRefresh(true) controller.updateHeader() refreshAll() } else { - manga.scanlator_filter?.let { - filteredScanlators = MdUtil.getScanlators(it).toSet() + runBlocking { + getChapters() + manga.scanlator_filter?.let { + filteredScanlators = MdUtil.getScanlators(it).toSet() + } } - updateChapters() controller.updateChapters(this.chapters) } fetchExternalLinks() setTrackItems() refreshTracking(false) - similarToolTip() } fun onDestroy() { @@ -144,7 +162,9 @@ class MangaDetailsPresenter( fun fetchChapters(andTracking: Boolean = true) { scope.launch { getChapters() - if (andTracking) fetchTracks() + if (andTracking) { + fetchTracks() + } withContext(Dispatchers.Main) { controller.updateChapters(chapters) } } } @@ -159,6 +179,7 @@ class MangaDetailsPresenter( setDownloadedChapters(chapters) // Store the last emission + allChapters = chapters this.chapters = applyChapterFilters(chapters) } @@ -177,7 +198,6 @@ class MangaDetailsPresenter( } fun filterScanlatorsClicked(selectedScanlators: List) { - allChapterScanlators.filter { selectedScanlators.contains(it) }.toSet() filteredScanlators = allChapterScanlators.filter { selectedScanlators.contains(it) }.toSet() @@ -190,20 +210,6 @@ class MangaDetailsPresenter( asyncUpdateMangaAndChapters() } - private fun updateChapters(fetchedChapters: List? = null) { - val chapters = - (fetchedChapters ?: db.getChapters(manga).executeAsBlocking()).filterIfUsingCache(downloadManager, manga, preferences.useCacheSource()).map { it.toModel() } - - // update all scanlators - updateScanlators(chapters) - - // Find downloaded chapters - setDownloadedChapters(chapters) - - // Store the last emission - this.chapters = applyChapterFilters(chapters) - } - /** * Finds and assigns the list of downloaded chapters. * @@ -211,11 +217,11 @@ class MangaDetailsPresenter( */ private fun setDownloadedChapters(chapters: List) { for (chapter in chapters) { - if (downloadManager.isChapterDownloaded(chapter.chapter, manga)) { - chapter.status = Download.DOWNLOADED + if (downloadManager.isChapterDownloaded(chapter, manga)) { + chapter.status = Download.State.DOWNLOADED } else if (downloadManager.hasQueue()) { chapter.status = downloadManager.queue.find { it.chapter.id == chapter.id } - ?.status ?: 0 + ?.status ?: Download.State.default } } } @@ -229,7 +235,7 @@ class MangaDetailsPresenter( override fun updateDownloads() { scope.launch(Dispatchers.Default) { - updateChapters(chapters) + getChapters() withContext(Dispatchers.Main) { controller.updateChapters(chapters) } @@ -275,8 +281,9 @@ class MangaDetailsPresenter( * @return an observable of the list of chapters filtered and sorted. */ private fun applyChapterFilters(chapterList: List): List { - if (isLockedFromSearch) + if (isLockedFromSearch) { return chapterList + } val chapters = chapterFilter.filterChapters(chapterList, manga) @@ -291,9 +298,8 @@ class MangaDetailsPresenter( } else -> { c1, c2 -> c1.source_order.compareTo(c2.source_order) } } - val sortedChapters = chapters.sortedWith(Comparator(sortFunction)) - getScrollType(sortedChapters) - return sortedChapters + getScrollType(chapters) + return chapters.sortedWith(Comparator(sortFunction)) } private fun getScrollType(chapters: List) { @@ -312,12 +318,12 @@ class MangaDetailsPresenter( return chapters.sortedByDescending { it.source_order }.find { !it.read } } - fun anyRead(): Boolean = chapters.any { it.read } - fun hasBookmarks(): Boolean = chapters.any { it.bookmark } - fun hasDownloads(): Boolean = chapters.any { it.isDownloaded } + fun anyRead(): Boolean = allChapters.any { it.read } + fun hasBookmark(): Boolean = allChapters.any { it.bookmark } + fun hasDownloads(): Boolean = allChapters.any { it.isDownloaded } fun getUnreadChaptersSorted() = - chapters.filter { !it.read && it.status == Download.NOT_DOWNLOADED }.distinctBy { it.name } + allChapters.filter { !it.read && it.status == Download.State.NOT_DOWNLOADED }.distinctBy { it.name } .sortedByDescending { it.source_order } fun startDownloadingNow(chapter: Chapter) { @@ -339,7 +345,7 @@ class MangaDetailsPresenter( fun deleteChapter(chapter: ChapterItem) { downloadManager.deleteChapters(listOf(chapter), manga, source) this.chapters.find { it.id == chapter.id }?.apply { - status = Download.QUEUE + status = Download.State.QUEUE download = null } @@ -350,11 +356,17 @@ class MangaDetailsPresenter( * Deletes the given list of chapter. * @param chapters the list of chapters to delete. */ - fun deleteChapters(chapters: List, update: Boolean = true) { - downloadManager.deleteChapters(chapters, manga, source) + fun deleteChapters(chapters: List, update: Boolean = true, isEverything: Boolean = false) { + launchIO { + if (isEverything) { + downloadManager.deleteManga(manga, source) + } else { + downloadManager.deleteChapters(chapters, manga, source) + } + } chapters.forEach { chapter -> this.chapters.find { it.id == chapter.id }?.apply { - status = Download.QUEUE + status = Download.State.QUEUE download = null } } @@ -407,10 +419,7 @@ class MangaDetailsPresenter( val usingCache = preferences.useCacheSource() scope.launch { - withContext(Dispatchers.Main) { - controller.setRefresh(true) - } - + isLoading = true var errorFromNetwork: java.lang.Exception? = null var errorFromMerged: java.lang.Exception? = null var error = false @@ -453,7 +462,7 @@ class MangaDetailsPresenter( val networkManga = networkPair.first val mangaWasInitalized = manga.initialized if (networkManga != null) { - //only copy if it had no data + // only copy if it had no data if (usingCache && manga.description.isNullOrEmpty()) { manga.copyFrom(networkManga) } @@ -463,27 +472,41 @@ class MangaDetailsPresenter( if (usingCache.not()) { if (networkManga.thumbnail_url != null || preferences.refreshCoversToo().getOrDefault()) { coverCache.deleteFromCache(thumbnailUrl) + } + } + + db.insertManga(manga).executeAsBlocking() + + fetchExternalLinks() + + launchIO { + val request = + ImageRequest.Builder(preferences.context).data(manga) + .memoryCachePolicy(CachePolicy.DISABLED) + .parameters( + Parameters.Builder().set(MangaFetcher.onlyFetchRemotely, true) + .build() + ) + .build() + + if (Coil.imageLoader(preferences.context).execute(request) is SuccessResult) { + preferences.context.imageLoader.memoryCache.remove(MemoryCache.Key(manga.key())) withContext(Dispatchers.Main) { - controller.clearCoverCache() + controller.setPaletteColor() } } } - - db.insertManga(manga).executeOnIO() } - fetchExternalLinks() val finChapters = networkPair.second if (!error && (usingCache.not() || (usingCache && manga.isMerged()))) { val newChapters = syncChaptersWithSource(db, finChapters, manga) if (newChapters.first.isNotEmpty()) { - val downloadNew = preferences.downloadNew().getOrDefault() + val downloadNew = preferences.downloadNew().get() if (downloadNew && !controller.fromCatalogue && mangaWasInitalized) { if (!hasMergeChaptersInitially && manga.isMerged()) { hasMergeChaptersInitially = true } else { - val categoriesToDownload = preferences.downloadNewCategories().getOrDefault().map(String::toInt) - val shouldDownload = categoriesToDownload.isEmpty() || getMangaCategoryIds().any { it in categoriesToDownload } - if (shouldDownload) { + if (manga.shouldDownloadNewChapters(db, preferences)) { downloadChapters( newChapters.first.sortedBy { it.chapter_number } .map { it.toModel() } @@ -491,6 +514,7 @@ class MangaDetailsPresenter( } } } + mangaShortcutManager.updateShortcuts() } if (newChapters.second.isNotEmpty()) { val removedChaptersId = newChapters.second.map { it.id } @@ -506,6 +530,7 @@ class MangaDetailsPresenter( } } } + getChapters() withContext(Dispatchers.IO) { val allChaps = db.getChapters(manga).executeAsBlocking().filterIfUsingCache(downloadManager, manga, preferences.useCacheSource()) @@ -521,7 +546,7 @@ class MangaDetailsPresenter( refreshTracking(false) } withContext(Dispatchers.IO) { - updateChapters() + getChapters() withContext(Dispatchers.Main) { isLoading = false @@ -551,12 +576,12 @@ class MangaDetailsPresenter( } catch (e: Exception) { withContext(Dispatchers.Main) { controller.showError(trimException(e)) } return@launch - } ?: listOf() + } isLoading = false try { syncChaptersWithSource(db, chapters, manga) - updateChapters() + getChapters() withContext(Dispatchers.Main) { controller.updateChapters(this@MangaDetailsPresenter.chapters) } } catch (e: java.lang.Exception) { withContext(Dispatchers.Main) { @@ -602,7 +627,6 @@ class MangaDetailsPresenter( pagesLeft: Int? = null ) { scope.launch(Dispatchers.IO) { - selectedChapters.forEach { it.read = read if (!read) { @@ -664,7 +688,7 @@ class MangaDetailsPresenter( if (!justChapters) { db.insertManga(manga).executeAsBlocking() } - updateChapters() + getChapters() withContext(Dispatchers.Main) { controller.updateChapters(chapters) } } } @@ -686,7 +710,6 @@ class MangaDetailsPresenter( true -> { manga.date_added = Date().time if (preferences.addToLibraryAsPlannedToRead()) { - val mdTrack = trackList.firstOrNull { it.service.isMdList() }?.track mdTrack?.let { @@ -716,52 +739,12 @@ class MangaDetailsPresenter( return db.getCategories().executeAsBlocking() } - /** - * Move the given manga to the category. - * - * @param manga the manga to move. - * @param category the selected category, or null for default category. - */ - fun moveMangaToCategory(category: Category?) { - moveMangaToCategories(listOfNotNull(category)) - } - - /** - * Move the given manga to categories. - * - * @param manga the manga to move. - * @param categories the selected categories. - */ - fun moveMangaToCategories(categories: List) { - val mc = categories.filter { it.id != 0 }.map { MangaCategory.create(manga, it) } - db.setMangaCategories(mc, listOf(manga)) - } - - /** - * Gets the category id's the manga is in, if the manga is not in a category, returns the default id. - * - * @param manga the manga to get categories from. - * @return Array of category ids the manga is in, if none returns default id - */ - fun getMangaCategoryIds(): Array { - val categories = db.getCategoriesForManga(manga).executeAsBlocking() - return categories.mapNotNull { it.id }.toTypedArray() - } - fun confirmDeletion() { - confirmDelete = true - db.resetMangaInfo(manga).executeAsBlocking() - asyncUpdateMangaAndChapters(true) - } - - fun clearMangaFromStorage() { - if (confirmDelete) { - GlobalScope.launch(Dispatchers.IO) { - coverCache.deleteFromCache(manga) - } - GlobalScope.launch(Dispatchers.IO) { - downloadManager.deleteManga(manga, source) - } + launchIO { + coverCache.deleteFromCache(manga) + customMangaManager.saveMangaInfo(CustomMangaManager.MangaJson(manga.id!!)) + downloadManager.deleteManga(manga, source) + asyncUpdateMangaAndChapters(true) } } @@ -772,8 +755,8 @@ class MangaDetailsPresenter( toggleFavorite() } - override fun onUpdateManga(manga: LibraryManga) { - if (manga.id == this.manga.id) { + override fun onUpdateManga(manga: Manga?) { + if (manga?.id == this.manga.id) { fetchChapters() } } @@ -808,6 +791,17 @@ class MangaDetailsPresenter( return destFile } +/* fun editCoverWithStream(uri: Uri): Boolean { + val inputStream = + downloadManager.context.contentResolver.openInputStream(uri) ?: return false + if (manga.favorite) { + coverCache.setCustomCoverToCache(manga, inputStream) + controller.setPaletteColor() + return true + } + return false + }*/ + fun shareCover(): File? { return try { val destDir = File(coverCache.context.cacheDir, "shared_image") @@ -823,7 +817,7 @@ class MangaDetailsPresenter( val directory = File( Environment.getExternalStorageDirectory().absolutePath + File.separator + Environment.DIRECTORY_PICTURES + - File.separator + "Neko" + File.separator + preferences.context.getString(R.string.app_name) ) saveCover(directory) true @@ -854,10 +848,14 @@ class MangaDetailsPresenter( fun isTracked(): Boolean = loggedServices.any { service -> tracks.any { it.sync_id == service.id } } + fun hasTrackers(): Boolean = loggedServices.isNotEmpty() + // Tracking private fun setTrackItems() { - trackList = loggedServices.map { service -> - TrackItem(tracks.find { it.sync_id == service.id }, service) + scope.launch { + trackList = loggedServices.map { service -> + TrackItem(tracks.find { it.sync_id == service.id }, service) + } } } @@ -896,7 +894,6 @@ class MangaDetailsPresenter( } if (trackItem != null) { if (item.service.isMdList()) { - if (manga.favorite && preferences.addToLibraryAsPlannedToRead() && trackItem.status == FollowStatus.UNFOLLOWED.int) { trackItem.status = FollowStatus.PLAN_TO_READ.int scope.launch { @@ -1043,8 +1040,9 @@ class MangaDetailsPresenter( fun setStatus(item: TrackItem, index: Int) { val track = item.track!! track.status = item.service.getStatusList()[index] - if (item.service.isCompletedStatus(index) && track.total_chapters > 0) + if (item.service.isCompletedStatus(index) && track.total_chapters > 0) { track.last_chapter_read = track.total_chapters + } updateRemote(track, item.service) } @@ -1060,20 +1058,6 @@ class MangaDetailsPresenter( updateRemote(track, item.service) } - fun similarToolTip() { - if (!preferences.shownSimilarTutorial().get()) { - scope.launch { - withContext(Dispatchers.IO) { - delay(1500) - withContext(Dispatchers.Main) { - controller.showSimilarToopTip() - preferences.shownSimilarTutorial().set(true) - } - } - } - } - } - fun setTrackerStartDate(item: TrackItem, date: Long) { val track = item.track!! track.started_reading_date = date diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt index e9fcd0e336..923d6eff86 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt @@ -1,181 +1,224 @@ package eu.kanade.tachiyomi.ui.manga import android.annotation.SuppressLint -import android.app.Activity import android.view.MotionEvent import android.view.View -import android.view.ViewGroup import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.graphics.drawable.toBitmap -import coil.Coil -import coil.loadAny +import androidx.core.view.isInvisible +import androidx.core.view.isVisible import coil.request.CachePolicy -import coil.request.ImageRequest import com.mikepenz.iconics.typeface.IIcon import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import com.mikepenz.iconics.typeface.library.materialdesigndx.MaterialDesignDx -import com.mikepenz.iconics.utils.colorInt -import com.mikepenz.iconics.utils.contourColorInt -import com.mikepenz.iconics.utils.sizeDp import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.image.coil.loadManga +import eu.kanade.tachiyomi.databinding.ChapterHeaderItemBinding +import eu.kanade.tachiyomi.databinding.MangaHeaderItemBinding import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.isMerged import eu.kanade.tachiyomi.source.model.isMergedChapter import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import eu.kanade.tachiyomi.util.system.contextCompatColor -import eu.kanade.tachiyomi.util.system.dpToPx -import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.iconicsDrawable import eu.kanade.tachiyomi.util.system.iconicsDrawableLarge import eu.kanade.tachiyomi.util.system.isLTR -import eu.kanade.tachiyomi.util.view.gone -import eu.kanade.tachiyomi.util.view.isVisible import eu.kanade.tachiyomi.util.view.updateLayoutParams -import eu.kanade.tachiyomi.util.view.visInvisIf -import eu.kanade.tachiyomi.util.view.visible -import eu.kanade.tachiyomi.util.view.visibleIf -import kotlinx.android.synthetic.main.manga_details_controller.* -import kotlinx.android.synthetic.main.manga_header_item.* import java.util.Locale +@SuppressLint("ClickableViewAccessibility") class MangaHeaderHolder( - private val view: View, + view: View, private val adapter: MangaDetailsAdapter, - startExpanded: Boolean + startExpanded: Boolean, + isTablet: Boolean = false ) : BaseFlexibleViewHolder(view, adapter) { + val binding: MangaHeaderItemBinding? = try { + MangaHeaderItemBinding.bind(view) + } catch (e: Exception) { + null + } + private val chapterBinding: ChapterHeaderItemBinding? = try { + ChapterHeaderItemBinding.bind(view) + } catch (e: Exception) { + null + } + private var showReadingButton = true private var showMoreButton = true + var hadSelection = false init { - chapter_layout.setOnClickListener { adapter.delegate.showChapterFilter() } - if (start_reading_button != null) { - start_reading_button.setOnClickListener { adapter.delegate.readNextChapter() } - top_view.updateLayoutParams { + + if (binding == null) { + with(chapterBinding) { + this ?: return@with + chapterLayout.setOnClickListener { adapter.delegate.showChapterFilter() } + } + } + with(binding) { + this ?: return@with + chapterLayout.setOnClickListener { adapter.delegate.showChapterFilter() } + startReadingButton.setOnClickListener { adapter.delegate.readNextChapter() } + topView.updateLayoutParams { height = adapter.delegate.topCoverHeight() } - more_button.setOnClickListener { expandDesc() } - manga_summary.setOnClickListener { - if (more_button_group.visibility == View.VISIBLE) { + moreButton.setOnClickListener { expandDesc() } + mangaSummary.setOnClickListener { + if (moreButton.isVisible) { expandDesc() - } else { + } else if (!hadSelection) { collapseDesc() + } else { + hadSelection = false } } - manga_summary.setOnLongClickListener { - if (manga_summary.isTextSelectable && !adapter.recyclerView.canScrollVertically(-1)) { - (adapter.delegate as MangaDetailsController).swipe_refresh.isEnabled = false + mangaSummary.setOnLongClickListener { + if (mangaSummary.isTextSelectable && !adapter.recyclerView.canScrollVertically( + -1 + ) + ) { + (adapter.delegate as MangaDetailsController).binding.swipeRefresh.isEnabled = + false } false } - manga_summary.setOnTouchListener { _, event -> - if (event.action == MotionEvent.ACTION_DOWN) view.requestFocus() + mangaSummary.setOnTouchListener { _, event -> + if (event.action == MotionEvent.ACTION_DOWN) { + view.requestFocus() + } + if (event.actionMasked == MotionEvent.ACTION_UP) { + hadSelection = mangaSummary.hasSelection() + (adapter.delegate as MangaDetailsController).binding.swipeRefresh.isEnabled = + true + } false } if (!itemView.resources.isLTR) { - more_bg_gradient.rotation = 180f + moreBgGradient.rotation = 180f } - less_button.setOnClickListener { collapseDesc() } - manga_genres_tags.setOnTagClickListener { + lessButton.setOnClickListener { collapseDesc() } + mangaGenresTags.setOnTagClickListener { adapter.delegate.tagClicked(it) } - webview_button.setOnClickListener { adapter.delegate.showExternalSheet() } - similar_button.setOnClickListener { adapter.delegate.openSimilar() } - + webviewButton.setOnClickListener { adapter.delegate.showExternalSheet() } + similarButton.setOnClickListener { adapter.delegate.openSimilar() } + mergeButton.setOnClickListener { adapter.delegate.openMerge() } - merge_button.setOnClickListener { adapter.delegate.openMerge() } - - share_button.setOnClickListener { adapter.delegate.prepareToShareManga() } - favorite_button.setOnClickListener { + shareButton.setOnClickListener { adapter.delegate.prepareToShareManga() } + favoriteButton.setOnClickListener { adapter.delegate.favoriteManga(false) } - favorite_button.setOnLongClickListener { - adapter.delegate.favoriteManga(true) - true - } title.setOnLongClickListener { adapter.delegate.copyToClipboard(title.text.toString(), R.string.title) true } - manga_author.setOnLongClickListener { - adapter.delegate.copyToClipboard(manga_author.text.toString(), R.string.author) + /*mangaAuthor.setOnClickListener { + mangaAuthor.text?.let { adapter.delegate.globalSearch(it.toString()) } + }*/ + mangaAuthor.setOnLongClickListener { + adapter.delegate.copyToClipboard( + mangaAuthor.text.toString(), + R.string.author + ) true } - manga_cover.setOnClickListener { adapter.delegate.zoomImageFromThumb(cover_card) } - track_button.setOnClickListener { adapter.delegate.showTrackingSheet() } + mangaCover.setOnClickListener { adapter.delegate.zoomImageFromThumb(coverCard) } + trackButton.setOnClickListener { adapter.delegate.showTrackingSheet() } if (startExpanded) expandDesc() else collapseDesc() - } else { - filter_button.updateLayoutParams { - marginEnd = 12.dpToPx - } + if (isTablet) chapterLayout.isVisible = false } } private fun expandDesc() { - if (more_button.visibility == View.VISIBLE) { - manga_summary.maxLines = Integer.MAX_VALUE - manga_summary.setTextIsSelectable(true) - manga_genres_tags.visible() - less_button.visible() - more_button_group.gone() - title.maxLines = Integer.MAX_VALUE + binding ?: return + if (binding.moreButton.visibility == View.VISIBLE) { + binding.mangaSummary.maxLines = Integer.MAX_VALUE + binding.mangaSummary.setTextIsSelectable(true) + binding.mangaGenresTags.isVisible = true + binding.lessButton.isVisible = true + binding.moreButtonGroup.isVisible = false + binding.title.maxLines = Integer.MAX_VALUE + binding.mangaSummary.requestFocus() } } private fun collapseDesc() { - manga_summary.setTextIsSelectable(false) - manga_summary.isClickable = true - manga_summary.maxLines = 3 - manga_genres_tags.gone() - less_button.gone() - more_button_group.visible() - title.maxLines = 4 + binding ?: return + binding.mangaSummary.setTextIsSelectable(false) + binding.mangaSummary.isClickable = true + binding.mangaSummary.maxLines = 3 + binding.mangaGenresTags.isVisible = false + binding.lessButton.isVisible = false + binding.moreButtonGroup.isVisible = true + binding.title.maxLines = 4 adapter.recyclerView.post { adapter.delegate.updateScroll() } } + fun bindChapters() { + val presenter = adapter.delegate.mangaPresenter() + val count = presenter.chapters.size + if (binding != null) { + binding.chaptersTitle.text = + itemView.resources.getQuantityString(R.plurals.chapters_plural, count, count) + binding.filtersText.text = presenter.currentFilters() + } else if (chapterBinding != null) { + chapterBinding.chaptersTitle.text = + itemView.resources.getQuantityString(R.plurals.chapters_plural, count, count) + chapterBinding.filtersText.text = presenter.currentFilters() + } + } + @SuppressLint("SetTextI18n") fun bind(item: MangaHeaderItem, manga: Manga) { val presenter = adapter.delegate.mangaPresenter() - title.text = manga.title + if (binding == null) { + if (chapterBinding != null) { + val count = presenter.chapters.size + chapterBinding.chaptersTitle.text = + itemView.resources.getQuantityString(R.plurals.chapters_plural, count, count) + chapterBinding.filtersText.text = presenter.currentFilters() + } + return + } + binding.title.text = manga.title - if (manga.genre.isNullOrBlank().not()) manga_genres_tags.setTags( + if (manga.genre.isNullOrBlank().not()) binding.mangaGenresTags.setTags( manga.genre?.split(",")?.map(String::trim) ) - else manga_genres_tags.setTags(emptyList()) - + else binding.mangaGenresTags.setTags(emptyList()) if (manga.author == manga.artist || manga.artist.isNullOrBlank()) { - manga_author.text = manga.author?.trim() + binding.mangaAuthor.text = manga.author?.trim() } else { - manga_author.text = listOfNotNull(manga.author?.trim(), manga.artist?.trim()).joinToString(", ") + binding.mangaAuthor.text = listOfNotNull(manga.author?.trim(), manga.artist?.trim()).joinToString(", ") } - - manga_summary.text = + binding.mangaSummary.text = if (manga.description.isNullOrBlank()) itemView.context.getString(R.string.no_description) else manga.description?.trim() - manga_summary.post { - if (sub_item_group.visibility != View.GONE) { - if ((manga_summary.lineCount < 3 && manga.genre.isNullOrBlank()) || less_button.isVisible()) { - manga_summary.setTextIsSelectable(true) - more_button_group.gone() - showMoreButton = less_button.isVisible() - } else { - more_button_group.visible() - } - } + binding.mangaSummary.post { +// if (binding.subItemGroup.isVisible) { +// if ((binding.mangaSummary.lineCount < 3 && manga.genre.isNullOrBlank()) || binding.lessButton.isVisible) { +// binding.mangaSummary.setTextIsSelectable(true) +// binding.moreButtonGroup.isVisible = false +// showMoreButton = binding.lessButton.isVisible +// } else { +// binding.moreButtonGroup.isVisible = true +// } +// } if (adapter.hasFilter()) collapse() else expand() } - manga_summary_label.text = itemView.context.getString( - R.string.about_this_, manga.mangaType(itemView.context) + binding.mangaSummaryLabel.text = itemView.context.getString( + R.string.about_this_, + manga.seriesType(itemView.context) ) - with(favorite_button) { + with(binding.favoriteButton) { val icon = when { item.isLocked -> MaterialDesignDx.Icon.gmf_lock item.manga.favorite -> CommunityMaterial.Icon2.cmd_heart as IIcon @@ -187,37 +230,34 @@ class MangaHeaderHolder( val tracked = presenter.isTracked() && !item.isLocked - with(track_button) { + with(binding.trackButton) { setImageDrawable(context.iconicsDrawable(MaterialDesignDx.Icon.gmf_art_track, size = 32)) } - with(similar_button) { + with(binding.similarButton) { setImageDrawable(context.iconicsDrawableLarge(MaterialDesignDx.Icon.gmf_account_tree)) } - with(merge_button) { - visibleIf(manga.status != SManga.COMPLETED || presenter.preferences.useCacheSource()) - val iconics = context.iconicsDrawableLarge(MaterialDesignDx.Icon.gmf_merge_type) - if (presenter.manga.isMerged().not()) { - iconics.colorInt = context.contextCompatColor(android.R.color.transparent) - iconics.contourColorInt = context.getResourceColor(R.attr.colorAccent) - iconics.contourWidthPx = 6 - iconics.sizeDp = 28 + with(binding.mergeButton) { + isVisible = (manga.status != SManga.COMPLETED || presenter.preferences.useCacheSource()) + val iconics = when (manga.isMerged()) { + true -> context.iconicsDrawableLarge(CommunityMaterial.Icon.cmd_check_decagram) + false -> context.iconicsDrawableLarge(CommunityMaterial.Icon2.cmd_source_merge) } setImageDrawable(iconics) } - with(webview_button) { + with(binding.webviewButton) { setImageDrawable(context.iconicsDrawableLarge(CommunityMaterial.Icon2.cmd_web)) } - with(share_button) { + with(binding.shareButton) { setImageDrawable(context.iconicsDrawableLarge(MaterialDesignDx.Icon.gmf_share)) } - with(start_reading_button) { + with(binding.startReadingButton) { val nextChapter = presenter.getNextUnreadChapter() - visibleIf(presenter.chapters.isNotEmpty() && !item.isLocked && !adapter.hasFilter()) - showReadingButton = isVisible() + isVisible = presenter.chapters.isNotEmpty() && !item.isLocked && !adapter.hasFilter() + showReadingButton = isVisible isEnabled = (nextChapter != null) text = if (nextChapter != null) { val readTxt = @@ -237,14 +277,14 @@ class MangaHeaderHolder( } val count = presenter.chapters.size - chapters_title.text = itemView.resources.getQuantityString(R.plurals.chapters, count, count) + binding.chaptersTitle.text = itemView.resources.getQuantityString(R.plurals.chapters_plural, count, count) - top_view.updateLayoutParams { + binding.topView.updateLayoutParams { height = adapter.delegate.topCoverHeight() } - manga_status.visibleIf(manga.status != 0) - manga_status.text = ( + binding.mangaStatus.isVisible = manga.status != 0 + binding.mangaStatus.text = ( itemView.context.getString( when (manga.status) { SManga.ONGOING -> R.string.ongoing @@ -258,100 +298,105 @@ class MangaHeaderHolder( ) ) - manga_rating.visibleIf(manga.rating != null) - manga_rating.text = " " + manga.rating + binding.mangaRating.isVisible = manga.rating != null + binding.mangaRating.text = " " + manga.rating - manga_users.visibleIf(manga.users != null) - manga_users.text = " " + manga.users + binding.mangaUsers.isVisible = manga.users != null + binding.mangaUsers.text = " " + manga.users - manga_missing_chapters.visibleIf(manga.missing_chapters != null) + binding.mangaMissingChapters.isVisible = manga.missing_chapters != null - manga_missing_chapters.text = itemView.context.getString(R.string.missing_chapters, manga.missing_chapters) + binding.mangaMissingChapters.text = itemView.context.getString(R.string.missing_chapters, manga.missing_chapters) manga.genre?.let { - r18_badge.visibleIf(it.contains("Hentai", true)) + binding.r18Badge.isVisible = (it.contains("pornographic", true)) } - manga_lang_flag.visibility = View.VISIBLE + binding.mangaLangFlag.visibility = View.VISIBLE when (manga.lang_flag?.toLowerCase(Locale.US)) { - "zh-hk" -> manga_lang_flag.setImageResource(R.drawable.ic_flag_china) - "zh" -> manga_lang_flag.setImageResource(R.drawable.ic_flag_china) - "ko" -> manga_lang_flag.setImageResource(R.drawable.ic_flag_korea) - "ja" -> manga_lang_flag.setImageResource(R.drawable.ic_flag_japan) - else -> manga_lang_flag.visibility = View.GONE + "zh-hk" -> binding.mangaLangFlag.setImageResource(R.drawable.ic_flag_china) + "zh" -> binding.mangaLangFlag.setImageResource(R.drawable.ic_flag_china) + "ko" -> binding.mangaLangFlag.setImageResource(R.drawable.ic_flag_korea) + "ja" -> binding.mangaLangFlag.setImageResource(R.drawable.ic_flag_japan) + else -> binding.mangaLangFlag.visibility = View.GONE } - filters_text.text = presenter.currentFilters() + binding.filtersText.text = presenter.currentFilters() if (!manga.initialized) return updateCover(manga) } + fun clearDescFocus() { + binding ?: return + binding.mangaSummary.setTextIsSelectable(false) + binding.mangaSummary.clearFocus() + } + fun setTopHeight(newHeight: Int) { - top_view.updateLayoutParams { + binding ?: return + if (newHeight == binding.topView.height) return + binding.topView.updateLayoutParams { height = newHeight } } fun setBackDrop(color: Int) { - true_backdrop.setBackgroundColor(color) + binding ?: return + binding.trueBackdrop.setBackgroundColor(color) + } + + fun updateTracking() { } fun collapse() { - val shouldHide = more_button.visibility == View.VISIBLE || more_button.visibility == View.INVISIBLE - sub_item_group.gone() - start_reading_button.gone() - more_button_group.visInvisIf(!shouldHide) - less_button.visibleIf(shouldHide) - manga_genres_tags.visibleIf(shouldHide) + binding ?: return + binding.subItemGroup.isVisible = false + binding.startReadingButton.isVisible = false + if (binding.moreButton.isVisible || binding.moreButton.isInvisible) { + binding.moreButtonGroup.isInvisible = true + } else { + binding.lessButton.isVisible = false + binding.mangaGenresTags.isVisible = false + } } fun updateCover(manga: Manga) { + binding ?: return if (!manga.initialized) return - - manga_cover.loadAny( + val drawable = adapter.controller.binding.mangaCoverFull.drawable + binding.mangaCover.loadManga( manga, builder = { - if (manga.favorite) networkCachePolicy(CachePolicy.DISABLED) + placeholder(drawable) + error(drawable) + if (manga.favorite) networkCachePolicy(CachePolicy.READ_ONLY) + diskCachePolicy(CachePolicy.READ_ONLY) } ) - - val request = ImageRequest.Builder(view.context) - .data(manga) - .allowHardware(false) // Disable hardware bitmaps. - .target { drawable -> - // Generate the Palette on a background thread. - adapter.delegate.generatePalette(drawable.toBitmap()) - } - .build() - - Coil.imageLoader(view.context).enqueue(request) - - - backdrop.loadAny( + binding.backdrop.loadManga( manga, builder = { - if (manga.favorite) networkCachePolicy(CachePolicy.DISABLED) + placeholder(drawable) + error(drawable) + if (manga.favorite) networkCachePolicy(CachePolicy.READ_ONLY) + diskCachePolicy(CachePolicy.READ_ONLY) } ) } fun expand() { - sub_item_group.visible() - if (!showMoreButton) more_button_group.gone() + binding ?: return + binding.subItemGroup.isVisible = true + if (!showMoreButton) binding.moreButtonGroup.isVisible = false else { - if (manga_summary.maxLines != Integer.MAX_VALUE) more_button_group.visible() + if (binding.mangaSummary.maxLines != Integer.MAX_VALUE) binding.moreButtonGroup.isVisible = true else { - less_button.visible() - manga_genres_tags.visible() + binding.lessButton.isVisible = true + binding.mangaGenresTags.isVisible = true } } - start_reading_button.visibleIf(showReadingButton) - } - - fun showSimilarToolTip(activity: Activity?) { - val act = activity ?: return - SimilarToolTip(activity, view.context, similar_button) + binding.startReadingButton.isVisible = showReadingButton } override fun onLongClick(view: View?): Boolean { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderItem.kt index 22b99570f7..5d6813ff82 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderItem.kt @@ -11,10 +11,12 @@ import eu.kanade.tachiyomi.data.database.models.Manga class MangaHeaderItem(val manga: Manga, var startExpanded: Boolean) : AbstractFlexibleItem() { + var isChapterHeader = false var isLocked = false + var isTablet = false override fun getLayoutRes(): Int { - return R.layout.manga_header_item + return if (isChapterHeader) R.layout.chapter_header_item else R.layout.manga_header_item } override fun isSelectable(): Boolean { @@ -26,7 +28,7 @@ class MangaHeaderItem(val manga: Manga, var startExpanded: Boolean) : } override fun createViewHolder(view: View, adapter: FlexibleAdapter>): MangaHeaderHolder { - return MangaHeaderHolder(view, adapter as MangaDetailsAdapter, startExpanded) + return MangaHeaderHolder(view, adapter as MangaDetailsAdapter, startExpanded, isTablet) } override fun bindViewHolder( @@ -35,7 +37,8 @@ class MangaHeaderItem(val manga: Manga, var startExpanded: Boolean) : position: Int, payloads: MutableList? ) { - holder.bind(this, manga) + if (isChapterHeader) holder.bindChapters() + else holder.bind(this, manga) } override fun equals(other: Any?): Boolean { @@ -43,6 +46,6 @@ class MangaHeaderItem(val manga: Manga, var startExpanded: Boolean) : } override fun hashCode(): Int { - return -(manga.id).hashCode() + return manga.id!!.hashCode() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/BaseChapterHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/BaseChapterHolder.kt index 48da5324ad..20bd852cb2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/BaseChapterHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/BaseChapterHolder.kt @@ -5,7 +5,6 @@ import androidx.appcompat.widget.PopupMenu import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import kotlinx.android.synthetic.main.download_button.* open class BaseChapterHolder( view: View, @@ -13,33 +12,35 @@ open class BaseChapterHolder( ) : BaseFlexibleViewHolder(view, adapter) { init { - download_button?.setOnClickListener { downloadOrRemoveMenu() } + view.findViewById(R.id.download_button)?.setOnClickListener { downloadOrRemoveMenu() } } private fun downloadOrRemoveMenu() { - val chapter = adapter.getItem(adapterPosition) as? BaseChapterItem<*, *> ?: return - if (chapter.status == Download.NOT_DOWNLOADED || chapter.status == Download.ERROR) { - adapter.baseDelegate.downloadChapter(adapterPosition) + val chapter = adapter.getItem(flexibleAdapterPosition) as? BaseChapterItem<*, *> ?: return + val downloadButton = itemView.findViewById(R.id.download_button) ?: return + + if (chapter.status == Download.State.NOT_DOWNLOADED || chapter.status == Download.State.ERROR) { + adapter.baseDelegate.downloadChapter(flexibleAdapterPosition) } else { - download_button.post { + downloadButton.post { // Create a PopupMenu, giving it the clicked view for an anchor - val popup = PopupMenu(download_button.context, download_button) + val popup = PopupMenu(downloadButton.context, downloadButton) // Inflate our menu resource into the PopupMenu's Menu popup.menuInflater.inflate(R.menu.chapter_download, popup.menu) - popup.menu.findItem(R.id.action_start).isVisible = chapter.status == Download.QUEUE + popup.menu.findItem(R.id.action_start).isVisible = chapter.status == Download.State.QUEUE // Hide download and show delete if the chapter is downloaded - if (chapter.status != Download.DOWNLOADED) popup.menu.findItem(R.id.action_delete).title = download_button.context.getString( + if (chapter.status != Download.State.DOWNLOADED) popup.menu.findItem(R.id.action_delete).title = downloadButton.context.getString( R.string.cancel ) // Set a listener so we are notified if a menu item is clicked popup.setOnMenuItemClickListener { item -> when (item.itemId) { - R.id.action_delete -> adapter.baseDelegate.downloadChapter(adapterPosition) - R.id.action_start -> adapter.baseDelegate.startDownloadNow(adapterPosition) + R.id.action_delete -> adapter.baseDelegate.downloadChapter(flexibleAdapterPosition) + R.id.action_start -> adapter.baseDelegate.startDownloadNow(flexibleAdapterPosition) } true } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/BaseChapterItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/BaseChapterItem.kt index 05c0bb68ff..29e6e9e766 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/BaseChapterItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/BaseChapterItem.kt @@ -14,7 +14,7 @@ abstract class BaseChapterItem> AbstractSectionableItem(header), Chapter by chapter { - private var _status: Int = 0 + private var _status: Download.State = Download.State.default val progress: Int get() { @@ -22,14 +22,14 @@ abstract class BaseChapterItem> return pages.map(Page::progress).average().toInt() } - var status: Int + var status: Download.State get() = download?.status ?: _status set(value) { _status = value } @Transient var download: Download? = null val isDownloaded: Boolean - get() = status == Download.DOWNLOADED + get() = status == Download.State.DOWNLOADED override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterFilterLayout.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterFilterLayout.kt index 219ec75cec..7ee36a104a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterFilterLayout.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterFilterLayout.kt @@ -3,50 +3,51 @@ package eu.kanade.tachiyomi.ui.manga.chapter import android.content.Context import android.util.AttributeSet import android.widget.CompoundButton -import android.widget.FrameLayout -import android.widget.RelativeLayout -import eu.kanade.tachiyomi.R +import android.widget.LinearLayout import eu.kanade.tachiyomi.data.database.models.Manga -import kotlinx.android.synthetic.main.chapter_filter_layout.view.* +import eu.kanade.tachiyomi.databinding.ChapterFilterLayoutBinding class ChapterFilterLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - FrameLayout(context, attrs) { + LinearLayout(context, attrs) { - init { - RelativeLayout.inflate(context, R.layout.chapter_filter_layout, this) - show_all.setOnCheckedChangeListener(::checkedFilter) - show_read.setOnCheckedChangeListener(::checkedFilter) - show_unread.setOnCheckedChangeListener(::checkedFilter) - show_download.setOnCheckedChangeListener(::checkedFilter) - show_bookmark.setOnCheckedChangeListener(::checkedFilter) + lateinit var binding: ChapterFilterLayoutBinding + + override fun onFinishInflate() { + super.onFinishInflate() + binding = ChapterFilterLayoutBinding.bind(this) + binding.showAll.setOnCheckedChangeListener(::checkedFilter) + binding.showRead.setOnCheckedChangeListener(::checkedFilter) + binding.showUnread.setOnCheckedChangeListener(::checkedFilter) + binding.showDownload.setOnCheckedChangeListener(::checkedFilter) + binding.showBookmark.setOnCheckedChangeListener(::checkedFilter) } private fun checkedFilter(checkBox: CompoundButton, isChecked: Boolean) { if (isChecked) { - if (show_all == checkBox) { - show_read.isChecked = false - show_unread.isChecked = false - show_download.isChecked = false - show_bookmark.isChecked = false + if (binding.showAll == checkBox) { + binding.showRead.isChecked = false + binding.showUnread.isChecked = false + binding.showDownload.isChecked = false + binding.showBookmark.isChecked = false } else { - show_all.isChecked = false - if (show_read == checkBox) show_unread.isChecked = false - else if (show_unread == checkBox) show_read.isChecked = false + binding.showAll.isChecked = false + if (binding.showRead == checkBox) binding.showUnread.isChecked = false + else if (binding.showUnread == checkBox) binding.showRead.isChecked = false } - } else if (!show_read.isChecked && !show_unread.isChecked && !show_download.isChecked && !show_bookmark.isChecked) { - show_all.isChecked = true + } else if (!binding.showRead.isChecked && !binding.showUnread.isChecked && !binding.showDownload.isChecked && !binding.showBookmark.isChecked) { + binding.showAll.isChecked = true } } fun setCheckboxes(manga: Manga) { - show_read.isChecked = manga.readFilter == Manga.SHOW_READ - show_unread.isChecked = manga.readFilter == Manga.SHOW_UNREAD - show_download.isChecked = manga.downloadedFilter == Manga.SHOW_DOWNLOADED - show_bookmark.isChecked = manga.bookmarkedFilter == Manga.SHOW_BOOKMARKED + binding.showRead.isChecked = manga.readFilter == Manga.SHOW_READ + binding.showUnread.isChecked = manga.readFilter == Manga.SHOW_UNREAD + binding.showDownload.isChecked = manga.downloadedFilter == Manga.SHOW_DOWNLOADED + binding.showBookmark.isChecked = manga.bookmarkedFilter == Manga.SHOW_BOOKMARKED - show_all.isChecked = !( - show_read.isChecked || show_unread.isChecked || - show_download.isChecked || show_bookmark.isChecked + binding.showAll.isChecked = !( + binding.showRead.isChecked || binding.showUnread.isChecked || + binding.showDownload.isChecked || binding.showBookmark.isChecked ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt index 206f0c3723..a2577cb575 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChapterHolder.kt @@ -3,29 +3,29 @@ package eu.kanade.tachiyomi.ui.manga.chapter import android.animation.AnimatorSet import android.animation.ObjectAnimator import android.view.View +import androidx.core.view.isVisible import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.model.Download +import eu.kanade.tachiyomi.databinding.ChaptersItemBinding import eu.kanade.tachiyomi.source.model.isMergedChapter import eu.kanade.tachiyomi.ui.manga.MangaDetailsAdapter import eu.kanade.tachiyomi.util.chapter.ChapterUtil import eu.kanade.tachiyomi.util.system.dpToPx -import eu.kanade.tachiyomi.util.view.gone -import eu.kanade.tachiyomi.util.view.isVisible -import eu.kanade.tachiyomi.util.view.visible import eu.kanade.tachiyomi.widget.EndAnimatorListener import eu.kanade.tachiyomi.widget.StartAnimatorListener -import kotlinx.android.synthetic.main.chapters_item.* -import kotlinx.android.synthetic.main.download_button.* class ChapterHolder( view: View, private val adapter: MangaDetailsAdapter ) : BaseChapterHolder(view, adapter) { + private val binding = ChaptersItemBinding.bind(view) + private var localSource = false + init { - download_button.setOnLongClickListener { - adapter.delegate.startDownloadRange(adapterPosition) + binding.downloadButton.downloadButton.setOnLongClickListener { + adapter.delegate.startDownloadRange(flexibleAdapterPosition) true } } @@ -33,10 +33,12 @@ class ChapterHolder( fun bind(item: ChapterItem, manga: Manga) { val chapter = item.chapter val isLocked = item.isLocked + if (adapter.preferences.useCacheSource() && item.chapter.isMergedChapter().not() && item.isDownloaded.not()) { - download_button.gone() + binding.downloadButton.root.isVisible = false } - chapter_title.text = when (manga.displayMode) { + + binding.chapterTitle.text = when (manga.displayMode) { Manga.DISPLAY_NUMBER -> { val number = adapter.decimalFormat.format(chapter.chapter_number.toDouble()) itemView.context.getString(R.string.chapter_, number) @@ -44,7 +46,7 @@ class ChapterHolder( else -> chapter.name } - ChapterUtil.setTextViewForChapter(chapter_title, item, hideStatus = isLocked) + ChapterUtil.setTextViewForChapter(binding.chapterTitle, item, hideStatus = isLocked) val statuses = mutableListOf() @@ -55,51 +57,61 @@ class ChapterHolder( if (showPagesLeft && chapter.pages_left > 0) { statuses.add( itemView.resources.getQuantityString( - R.plurals.pages_left, chapter.pages_left, chapter.pages_left + R.plurals.pages_left, + chapter.pages_left, + chapter.pages_left ) ) } else if (showPagesLeft) { statuses.add( itemView.context.getString( - R.string.page_, chapter.last_page_read + 1 + R.string.page_, + chapter.last_page_read + 1 ) ) } - - chapter.scanlator?.isNotBlank()?.let { statuses.add(chapter.scanlator!!) } - if (chapter.language.isNullOrBlank() || chapter.language.equals("english", true)) { - chapter_language.gone() + binding.chapterLanguage.isVisible = false } else { - chapter_language.visible() - chapter_language.text = chapter.language + binding.chapterLanguage.isVisible = true + binding.chapterLanguage.text = chapter.language ChapterUtil.setTextViewForChapter( - chapter_language, item, showBookmark = false, hideStatus = isLocked + binding.chapterLanguage, + item, + showBookmark = false, + hideStatus = isLocked ) } - if (front_view.translationX == 0f) { - read.setImageResource( + if (chapter.scanlator?.isNotBlank() == true) { + statuses.add(chapter.scanlator!!) + } + + if (binding.frontView.translationX == 0f) { + binding.read.setImageResource( if (item.read) R.drawable.ic_eye_off_24dp else R.drawable.ic_eye_24dp ) - bookmark.setImageResource( + binding.bookmark.setImageResource( if (item.bookmark) R.drawable.ic_bookmark_off_24dp else R.drawable.ic_bookmark_24dp ) } // this will color the scanlator the same bookmarks ChapterUtil.setTextViewForChapter( - chapter_scanlator, item, showBookmark = false, hideStatus = isLocked + binding.chapterScanlator, + item, + showBookmark = false, + hideStatus = isLocked ) - chapter_scanlator.text = statuses.joinToString(" • ") + binding.chapterScanlator.text = statuses.joinToString(" • ") val status = when { - adapter.isSelected(adapterPosition) -> Download.CHECKED + adapter.isSelected(flexibleAdapterPosition) -> Download.State.CHECKED else -> item.status } notifyStatus(status, item.isLocked, item.progress) resetFrontView() - if (adapterPosition == 1) { + if (flexibleAdapterPosition == 1) { if (!adapter.hasShownSwipeTut.get()) showSlideAnimation() } } @@ -109,14 +121,14 @@ class ChapterHolder( val animatorSet = AnimatorSet() val anim1 = slideAnimation(0f, slide) anim1.startDelay = 1000 - anim1.addListener(StartAnimatorListener { left_view.visible() }) + anim1.addListener(StartAnimatorListener { binding.leftView.isVisible = true }) val anim2 = slideAnimation(slide, -slide) anim2.duration = 600 anim2.startDelay = 500 anim2.addUpdateListener { - if (left_view.isVisible() && front_view.translationX <= 0) { - left_view.gone() - right_view.visible() + if (binding.leftView.isVisible && binding.frontView.translationX <= 0) { + binding.leftView.isVisible = false + binding.rightView.isVisible = true } } val anim3 = slideAnimation(-slide, 0f) @@ -131,31 +143,32 @@ class ChapterHolder( } private fun slideAnimation(from: Float, to: Float): ObjectAnimator { - return ObjectAnimator.ofFloat(front_view, View.TRANSLATION_X, from, to) + return ObjectAnimator.ofFloat(binding.frontView, View.TRANSLATION_X, from, to) .setDuration(300) } override fun getFrontView(): View { - return front_view + return binding.frontView } override fun getRearRightView(): View { - return right_view + return binding.rightView } override fun getRearLeftView(): View { - return left_view + return binding.leftView } private fun resetFrontView() { - if (front_view.translationX != 0f) itemView.post { adapter.notifyItemChanged(adapterPosition) } + if (binding.frontView.translationX != 0f) itemView.post { adapter.notifyItemChanged(flexibleAdapterPosition) } } - fun notifyStatus(status: Int, locked: Boolean, progress: Int) = with(download_button) { + fun notifyStatus(status: Download.State, locked: Boolean, progress: Int, animated: Boolean = false) = with(binding.downloadButton.downloadButton) { if (locked) { - gone() + isVisible = false return } - setDownloadStatus(status, progress) + isVisible = !localSource + setDownloadStatus(status, progress, animated) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSortBottomSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSortBottomSheet.kt index ad4723d79d..8ef3d46e06 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSortBottomSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/chapter/ChaptersSortBottomSheet.kt @@ -1,55 +1,52 @@ package eu.kanade.tachiyomi.ui.manga.chapter import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup +import androidx.core.view.isInvisible +import androidx.core.view.isVisible import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.list.listItemsMultiChoice import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.databinding.ChapterSortBottomSheetBinding import eu.kanade.tachiyomi.ui.manga.MangaDetailsController -import eu.kanade.tachiyomi.util.view.invisible +import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.view.setBottomEdge -import eu.kanade.tachiyomi.util.view.setEdgeToEdge -import eu.kanade.tachiyomi.util.view.visInvisIf -import eu.kanade.tachiyomi.util.view.visibleIf -import kotlinx.android.synthetic.main.chapter_filter_layout.* -import kotlinx.android.synthetic.main.chapter_sort_bottom_sheet.* +import eu.kanade.tachiyomi.widget.E2EBottomSheetDialog import kotlin.math.max -class ChaptersSortBottomSheet(controller: MangaDetailsController) : BottomSheetDialog - (controller.activity!!, R.style.BottomSheetDialogTheme) { +class ChaptersSortBottomSheet(controller: MangaDetailsController) : + E2EBottomSheetDialog(controller.activity!!) { val activity = controller.activity!! - private var sheetBehavior: BottomSheetBehavior<*> - private val presenter = controller.presenter + override fun createBinding(inflater: LayoutInflater) = ChapterSortBottomSheetBinding.inflate(inflater) + init { - // Use activity theme for this layout - val view = activity.layoutInflater.inflate(R.layout.chapter_sort_bottom_sheet, null) - setContentView(view) - - sheetBehavior = BottomSheetBehavior.from(view.parent as ViewGroup) - setEdgeToEdge(activity, view) - - sheetBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, progress: Float) { - if (progress.isNaN()) - pill.alpha = 0f - else - pill.alpha = (1 - max(0f, progress)) * 0.25f - } + val height = activity.window.decorView.rootWindowInsets.systemWindowInsetBottom + sheetBehavior.peekHeight = 415.dpToPx + height + + sheetBehavior.addBottomSheetCallback( + object : BottomSheetBehavior.BottomSheetCallback() { + override fun onSlide(bottomSheet: View, progress: Float) { + if (progress.isNaN()) { + binding.pill.alpha = 0f + } else { + binding.pill.alpha = (1 - max(0f, progress)) * 0.25f + } + } - override fun onStateChanged(p0: View, state: Int) { - if (state == BottomSheetBehavior.STATE_EXPANDED) { - sheetBehavior.skipCollapsed = true + override fun onStateChanged(p0: View, state: Int) { + if (state == BottomSheetBehavior.STATE_EXPANDED) { + sheetBehavior.skipCollapsed = true + } } } - }) + ) } override fun onStart() { @@ -63,70 +60,70 @@ class ChaptersSortBottomSheet(controller: MangaDetailsController) : BottomSheetD override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) initGeneralPreferences() - setBottomEdge(hide_titles, activity) - close_button.setOnClickListener { dismiss() } - settings_scroll_view.viewTreeObserver.addOnGlobalLayoutListener { + setBottomEdge(binding.hideTitles, activity) + binding.closeButton.setOnClickListener { dismiss() } + binding.settingsScrollView.viewTreeObserver.addOnGlobalLayoutListener { val isScrollable = - settings_scroll_view!!.height < sort_layout.height + - settings_scroll_view.paddingTop + settings_scroll_view.paddingBottom - close_button.visibleIf(isScrollable) + binding.settingsScrollView.height < binding.sortLayout.height + + binding.settingsScrollView.paddingTop + binding.settingsScrollView.paddingBottom + binding.closeButton.isVisible = isScrollable // making the view gone somehow breaks the layout so lets make it invisible - pill.visInvisIf(!isScrollable) + binding.pill.isInvisible = isScrollable } setOnDismissListener { presenter.setFilters( - show_read.isChecked, - show_unread.isChecked, - show_download.isChecked, - show_bookmark.isChecked + binding.chapterFilterLayout.showRead.isChecked, + binding.chapterFilterLayout.showUnread.isChecked, + binding.chapterFilterLayout.showDownload.isChecked, + binding.chapterFilterLayout.showBookmark.isChecked ) } } private fun initGeneralPreferences() { - chapter_filter_layout.setCheckboxes(presenter.manga) + binding.chapterFilterLayout.root.setCheckboxes(presenter.manga) var defPref = presenter.globalSort() - sort_group.check( - if (presenter.manga.sortDescending(defPref)) R.id.sort_newest else + binding.sortGroup.check( + if (presenter.manga.sortDescending(defPref)) R.id.sort_newest else { R.id.sort_oldest + } ) - hide_titles.isChecked = presenter.manga.displayMode != Manga.DISPLAY_NAME - sort_method_group.check( - if (presenter.manga.sorting == Manga.SORTING_SOURCE) R.id.sort_by_source else + binding.hideTitles.isChecked = presenter.manga.displayMode != Manga.DISPLAY_NAME + binding.sortMethodGroup.check( + if (presenter.manga.sorting == Manga.SORTING_SOURCE) R.id.sort_by_source else { R.id.sort_by_number + } ) - set_as_default_sort.visInvisIf( - defPref != presenter.manga.sortDescending() && - presenter.manga.usesLocalSort() - ) - sort_group.setOnCheckedChangeListener { _, checkedId -> + binding.setAsDefaultSort.isInvisible = defPref == presenter.manga.sortDescending() || + !presenter.manga.usesLocalSort() + binding.sortGroup.setOnCheckedChangeListener { _, checkedId -> presenter.setSortOrder(checkedId == R.id.sort_newest) - set_as_default_sort.visInvisIf( - defPref != presenter.manga.sortDescending() && - presenter.manga.usesLocalSort() - ) + binding.setAsDefaultSort.isInvisible = ( + defPref == presenter.manga.sortDescending() || + !presenter.manga.usesLocalSort() + ) } - set_as_default_sort.setOnClickListener { - val desc = sort_group.checkedRadioButtonId == R.id.sort_newest + binding.setAsDefaultSort.setOnClickListener { + val desc = binding.sortGroup.checkedRadioButtonId == R.id.sort_newest presenter.setGlobalChapterSort(desc) defPref = desc - set_as_default_sort.invisible() + binding.setAsDefaultSort.isInvisible = true } - sort_method_group.setOnCheckedChangeListener { _, checkedId -> + binding.sortMethodGroup.setOnCheckedChangeListener { _, checkedId -> presenter.setSortMethod(checkedId == R.id.sort_by_source) } - hide_titles.setOnCheckedChangeListener { _, isChecked -> + binding.hideTitles.setOnCheckedChangeListener { _, isChecked -> presenter.hideTitle(isChecked) } - filter_groups_button.setOnClickListener { + binding.filterGroupsButton.setOnClickListener { val scanlators = presenter.allChapterScanlators.toList() val preselected = presenter.filteredScanlators.map { scanlators.indexOf(it) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/external/ExternalBottomSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/external/ExternalBottomSheet.kt index 121a6aced9..e3e1bb3a85 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/external/ExternalBottomSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/external/ExternalBottomSheet.kt @@ -1,51 +1,22 @@ package eu.kanade.tachiyomi.ui.manga.external import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog +import android.view.LayoutInflater import com.mikepenz.fastadapter.FastAdapter import com.mikepenz.fastadapter.adapters.ItemAdapter -import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.ExternalBottomSheetBinding import eu.kanade.tachiyomi.ui.manga.MangaDetailsController -import eu.kanade.tachiyomi.util.system.dpToPx -import eu.kanade.tachiyomi.util.view.RecyclerWindowInsetsListener -import eu.kanade.tachiyomi.util.view.setEdgeToEdge -import kotlinx.android.synthetic.main.external_bottom_sheet.* +import eu.kanade.tachiyomi.util.view.expand +import eu.kanade.tachiyomi.util.view.updatePaddingRelative +import eu.kanade.tachiyomi.widget.E2EBottomSheetDialog -class ExternalBottomSheet(private val controller: MangaDetailsController) : BottomSheetDialog -(controller.activity!!, R.style.BottomSheetDialogTheme) { +class ExternalBottomSheet(private val controller: MangaDetailsController) : E2EBottomSheetDialog(controller.activity!!) { - val activity = controller.activity!! - - private var sheetBehavior: BottomSheetBehavior<*> - - val presenter = controller.presenter - - init { - // Use activity theme for this layout - val view = activity.layoutInflater.inflate(R.layout.external_bottom_sheet, null) - setContentView(view) - - sheetBehavior = BottomSheetBehavior.from(view.parent as ViewGroup) - setEdgeToEdge(activity, view) - val height = activity.window.decorView.rootWindowInsets.systemWindowInsetBottom - sheetBehavior.peekHeight = 380.dpToPx + height - - sheetBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, progress: Float) {} - - override fun onStateChanged(p0: View, state: Int) { - if (state == BottomSheetBehavior.STATE_EXPANDED) { - sheetBehavior.skipCollapsed = true - } - } - }) - } + override fun createBinding(inflater: LayoutInflater) = ExternalBottomSheetBinding.inflate(inflater) override fun onStart() { super.onStart() + sheetBehavior.expand() sheetBehavior.skipCollapsed = true } @@ -56,9 +27,11 @@ class ExternalBottomSheet(private val controller: MangaDetailsController) : Bott super.onCreate(savedInstanceState) val itemAdapter = ItemAdapter() val fastAdapter = FastAdapter.with(itemAdapter) - external_recycler.adapter = fastAdapter - external_recycler.setOnApplyWindowInsetsListener(RecyclerWindowInsetsListener) - itemAdapter.add(presenter.externalLinksList) + binding.externalRecycler.adapter = fastAdapter + binding.externalRecycler.updatePaddingRelative( + bottom = controller.activity!!.window.decorView.rootWindowInsets.systemWindowInsetBottom + ) + itemAdapter.add(controller.presenter.externalLinksList) fastAdapter.onClickListener = { _, _, item, _ -> controller.openInWebView(item.externalLink.getUrl()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/merge/MergeSearchAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/merge/MergeSearchAdapter.kt index 67bf40e200..c1c4a2a25b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/merge/MergeSearchAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/merge/MergeSearchAdapter.kt @@ -8,13 +8,13 @@ import coil.clear import coil.load import coil.transform.RoundedCornersTransformation import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.MergeSearchItemBinding import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.util.view.inflate -import kotlinx.android.synthetic.main.merge_search_item.view.* import java.util.ArrayList class MergeSearchAdapter(context: Context) : - ArrayAdapter(context, R.layout.merge_search_item, ArrayList()) { + ArrayAdapter(context, R.layout.merge_search_item, ArrayList()) { override fun getView(position: Int, view: View?, parent: ViewGroup): View { var v = view @@ -42,10 +42,11 @@ class MergeSearchAdapter(context: Context) : class MergeSearchHolder(private val view: View) { fun onSetValues(manga: SManga) { - view.merge_search_title.text = manga.title - view.merge_search_cover.clear() + val binding = MergeSearchItemBinding.bind(view) + binding.mergeSearchTitle.text = manga.title + binding.mergeSearchCover.clear() if (!manga.thumbnail_url.isNullOrEmpty()) { - view.merge_search_cover.load(manga.thumbnail_url) { + binding.mergeSearchCover.load(manga.thumbnail_url) { transformations(RoundedCornersTransformation(2f)) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/merge/MergeSearchDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/merge/MergeSearchDialog.kt index 6054c7e45d..045e6b5adc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/merge/MergeSearchDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/merge/MergeSearchDialog.kt @@ -4,27 +4,24 @@ import android.app.Dialog import android.os.Bundle import android.view.View import android.view.ViewGroup -import android.view.WindowManager import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.customview.customView +import com.afollestad.materialdialogs.customview.getCustomView import com.jakewharton.rxbinding.widget.itemClicks import com.jakewharton.rxbinding.widget.textChanges import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.MergeSearchDialogBinding import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.manga.MangaDetailsController import eu.kanade.tachiyomi.ui.manga.MangaDetailsPresenter import eu.kanade.tachiyomi.util.lang.plusAssign -import eu.kanade.tachiyomi.util.view.gone -import eu.kanade.tachiyomi.util.view.visible -import kotlinx.android.synthetic.main.merge_search_dialog.view.* import rx.Subscription import rx.android.schedulers.AndroidSchedulers import rx.subscriptions.CompositeSubscription import java.util.concurrent.TimeUnit - class MergeSearchDialog : DialogController { private var dialogView: View? = null @@ -39,6 +36,8 @@ class MergeSearchDialog : DialogController { private lateinit var presenter: MangaDetailsPresenter + lateinit var binding: MergeSearchDialogBinding + constructor(detailsController: MangaDetailsController) : super(Bundle()) { presenter = detailsController.presenter } @@ -52,6 +51,9 @@ class MergeSearchDialog : DialogController { customView(viewRes = R.layout.merge_search_dialog, scrollable = false, noVerticalPadding = true) negativeButton(android.R.string.cancel) } + + binding = MergeSearchDialogBinding.bind(dialog.getCustomView()) + val width = ViewGroup.LayoutParams.MATCH_PARENT val height = ViewGroup.LayoutParams.MATCH_PARENT dialog.window!!.setLayout(width, height) @@ -70,19 +72,20 @@ class MergeSearchDialog : DialogController { // Create adapter val adapter = MergeSearchAdapter(view.context) this.adapter = adapter - view.merge_search_list.adapter = adapter + + binding.mergeSearchList.adapter = adapter // Set listeners selectedItem = null - subscriptions += view.merge_search_list.itemClicks().subscribe { position -> + subscriptions += binding.mergeSearchList.itemClicks().subscribe { position -> MangaItem(position) } // Do an initial search based on the manga's title if (savedState == null) { val title = presenter.manga.title - view.merge_search.append(title) + binding.mergeSearch.append(title) search(title) } } @@ -103,7 +106,7 @@ class MergeSearchDialog : DialogController { override fun onAttach(view: View) { super.onAttach(view) - searchTextSubscription = dialogView!!.merge_search.textChanges() + searchTextSubscription = binding.mergeSearch.textChanges() .skip(1) .debounce(1, TimeUnit.SECONDS, AndroidSchedulers.mainThread()) .map { it.toString() } @@ -118,36 +121,35 @@ class MergeSearchDialog : DialogController { private fun search(query: String) { val view = dialogView ?: return - view.progress.visibility = View.VISIBLE - view.merge_search_list.visibility = View.INVISIBLE + binding.progress.visibility = View.VISIBLE + binding.mergeSearchList.visibility = View.INVISIBLE presenter.mergeSearch(query) } fun onSearchResults(results: List) { selectedItem = null - val view = dialogView ?: return - view.progress.visibility = View.INVISIBLE + binding.progress.visibility = View.GONE if (results.isEmpty()) { - noResults(view) + noResults() } else { - view.empty_view.gone() - view.merge_search_list.visible() + binding.emptyView.visibility = View.INVISIBLE + binding.mergeSearchList.visibility = View.VISIBLE adapter?.setItems(results) } } - private fun noResults(view: View) { - view.merge_search_list.gone() - view.empty_view.visible() - view.empty_view.showMedium( - CommunityMaterial.Icon.cmd_compass_off, view.context.getString(R.string.no_results_found) + private fun noResults() { + binding.progress.visibility = View.VISIBLE + binding.mergeSearchList.visibility = View.INVISIBLE + binding.emptyView.showMedium( + CommunityMaterial.Icon.cmd_compass_off, + binding.root.context.getString(R.string.no_results_found) ) } fun onSearchResultsError() { - val view = dialogView ?: return - view.progress.visibility = View.INVISIBLE - noResults(view) + binding.progress.visibility = View.INVISIBLE + noResults() } companion object { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackReadingDatesDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackReadingDatesDialog.kt index 271c526c07..5dc315c4aa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackReadingDatesDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackReadingDatesDialog.kt @@ -22,7 +22,7 @@ import java.text.DateFormat import java.util.Calendar class SetTrackReadingDatesDialog : DialogController - where T : Controller { + where T : Controller { private val item: TrackItem diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt index c611ac45e5..ee9fbc7a90 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/SetTrackStatusDialog.kt @@ -43,7 +43,8 @@ class SetTrackStatusDialog : DialogController .title(R.string.status) .negativeButton(android.R.string.cancel) .listItemsSingleChoice( - items = statusString, initialSelection = selectedIndex, + items = statusString, + initialSelection = selectedIndex, waitForPositiveButton = false ) { dialog, position, _ -> listener.setStatus(item, position) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt index 83eba3837d..84de2d4470 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt @@ -3,74 +3,80 @@ package eu.kanade.tachiyomi.ui.manga.track import android.annotation.SuppressLint import android.view.View import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.preference.PreferenceKeys.dateFormat +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.databinding.TrackItemBinding import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder import eu.kanade.tachiyomi.util.view.updateLayoutParams -import eu.kanade.tachiyomi.util.view.visibleIf -import kotlinx.android.synthetic.main.track_item.* +import uy.kohesive.injekt.injectLazy +import java.text.DateFormat class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) { + private val preferences: PreferencesHelper by injectLazy() + private val binding = TrackItemBinding.bind(view) + private val dateFormat: DateFormat by lazy { + preferences.dateFormat() + } + init { val listener = adapter.rowClickListener - logo_container.setOnClickListener { listener.onLogoClick(adapterPosition) } - add_tracking.setOnClickListener { listener.onSetClick(adapterPosition) } - track_title.setOnClickListener { listener.onSetClick(adapterPosition) } - track_remove.setOnClickListener { listener.onRemoveClick(adapterPosition) } - track_status.setOnClickListener { listener.onStatusClick(adapterPosition) } - track_chapters.setOnClickListener { listener.onChaptersClick(adapterPosition) } - score_container.setOnClickListener { listener.onScoreClick(adapterPosition) } - track_start_date.setOnClickListener { listener.onStartDateClick(adapterPosition) } - track_finish_date.setOnClickListener { listener.onFinishDateClick(adapterPosition) } + binding.logoContainer.setOnClickListener { listener.onLogoClick(bindingAdapterPosition) } + binding.addTracking.setOnClickListener { listener.onSetClick(bindingAdapterPosition) } + binding.trackTitle.setOnClickListener { listener.onSetClick(bindingAdapterPosition) } + binding.trackRemove.setOnClickListener { listener.onRemoveClick(bindingAdapterPosition) } + binding.trackStatus.setOnClickListener { listener.onStatusClick(bindingAdapterPosition) } + binding.trackChapters.setOnClickListener { listener.onChaptersClick(bindingAdapterPosition) } + binding.scoreContainer.setOnClickListener { listener.onScoreClick(bindingAdapterPosition) } + binding.trackStartDate.setOnClickListener { listener.onStartDateClick(bindingAdapterPosition) } + binding.trackFinishDate.setOnClickListener { listener.onFinishDateClick(bindingAdapterPosition) } } @SuppressLint("SetTextI18n") fun bind(item: TrackItem) { val track = item.track - track_logo.setImageResource(item.service.getLogo()) - logo_container.setBackgroundColor(item.service.getLogoColor()) - logo_container.updateLayoutParams { - bottomToBottom = if (track != null) divider.id else track_details.id + binding.trackLogo.setImageResource(item.service.getLogo()) + binding.logoContainer.setBackgroundColor(item.service.getLogoColor()) + binding.logoContainer.updateLayoutParams { + bottomToBottom = if (track != null) binding.divider.id else binding.trackDetails.id } - val serviceName = track_logo.context.getString(item.service.nameRes()) - track_logo.contentDescription = serviceName - track_group.visibleIf(track != null) - add_tracking.visibleIf(track == null) + val serviceName = binding.trackLogo.context.getString(item.service.nameRes()) + binding.trackLogo.contentDescription = serviceName + binding.trackGroup.isVisible = track != null + binding.addTracking.isVisible = track == null if (track != null) { - - with(track_title) { - text = track.title - isClickable = item.service.isMdList().not() - } - - track_remove.visibleIf(item.service.isMdList().not()) - - with(track_chapters) { + binding.trackTitle.text = track.title + binding.trackTitle.isClickable = item.service.isMdList().not() + binding.trackRemove.isVisible = item.service.isMdList().not() + with(binding.trackChapters) { text = when { track.total_chapters > 0 && track.last_chapter_read == track.total_chapters -> context.getString( R.string.all_chapters_read ) track.total_chapters > 0 -> context.getString( - R.string.chapter_x_of_y, track.last_chapter_read, track.total_chapters + R.string.chapter_x_of_y, + track.last_chapter_read, + track.total_chapters ) track.last_chapter_read > 0 -> context.getString( - R.string.chapter_, track.last_chapter_read.toString() + R.string.chapter_, + track.last_chapter_read.toString() ) else -> context.getString(R.string.not_started) } } val status = item.service.getStatus(track.status) - if (status.isEmpty()) track_status.setText(R.string.unknown_status) - else track_status.text = item.service.getStatus(track.status) - track_score.text = if (track.score == 0f) "-" else item.service.displayScore(track) - track_score.setCompoundDrawablesWithIntrinsicBounds(0, 0, starIcon(track), 0) - date_group.visibleIf(item.service.supportsReadingDates) + if (status.isEmpty()) binding.trackStatus.setText(R.string.unknown_status) + else binding.trackStatus.text = item.service.getStatus(track.status) + binding.trackScore.text = if (track.score == 0f) "-" else item.service.displayScore(track) + binding.trackScore.setCompoundDrawablesWithIntrinsicBounds(0, 0, starIcon(track), 0) + binding.dateGroup.isVisible = item.service.supportsReadingDates if (item.service.supportsReadingDates) { - track_start_date.text = + binding.trackStartDate.text = if (track.started_reading_date != 0L) dateFormat.format(track.started_reading_date) else "-" - track_finish_date.text = + binding.trackFinishDate.text = if (track.finished_reading_date != 0L) dateFormat.format(track.finished_reading_date) else "-" } else { } @@ -78,7 +84,7 @@ class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) { } private fun starIcon(track: Track): Int { - return if (track.score == 0f || track_score.text.toString().toFloatOrNull() != null) { + return if (track.score == 0f || binding.trackScore.text.toString().toFloatOrNull() != null) { R.drawable.ic_star_12dp } else { 0 @@ -86,6 +92,7 @@ class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) { } fun setProgress(enabled: Boolean) { - progress.visibleIf(enabled) + binding.progress.isVisible = enabled + binding.trackLogo.isVisible = !enabled } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackRemoveDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackRemoveDialog.kt index 08a3b7da9a..201a2cb2ed 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackRemoveDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackRemoveDialog.kt @@ -45,12 +45,15 @@ class TrackRemoveDialog : DialogController val serviceName = activity!!.getString(item.service.nameRes()) dialog.checkBoxPrompt( text = activity!!.getString( - R.string.remove_tracking_from_, serviceName + R.string.remove_tracking_from_, + serviceName ), - isCheckedDefault = true, onToggle = null + isCheckedDefault = true, + onToggle = null ).positiveButton(R.string.remove) { listener.removeTracker( - item, it.isCheckPromptChecked() + item, + it.isCheckPromptChecked() ) } dialog.getCheckBoxPrompt().textSize = 16f diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt index afdfc5dc68..7b6ede0f76 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt @@ -4,14 +4,15 @@ import android.content.Context import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter +import androidx.core.view.isVisible import coil.clear import coil.load import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.track.model.TrackSearch -import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.databinding.TrackSearchItemBinding import eu.kanade.tachiyomi.util.view.inflate -import kotlinx.android.synthetic.main.track_search_item.view.* import java.util.ArrayList +import java.util.Locale class TrackSearchAdapter(context: Context) : ArrayAdapter(context, R.layout.track_search_item, ArrayList()) { @@ -43,32 +44,33 @@ class TrackSearchAdapter(context: Context) : class TrackSearchHolder(private val view: View) { fun onSetValues(track: TrackSearch) { - view.track_search_title.text = track.title - view.track_search_summary.text = track.summary - view.track_search_cover.clear() - if (!track.cover_url.isNullOrEmpty()) { - view.track_search_cover.load(track.cover_url) + val binding = TrackSearchItemBinding.bind(view) + binding.trackSearchTitle.text = track.title + binding.trackSearchSummary.text = track.summary + binding.trackSearchCover.clear() + if (track.cover_url.isNotEmpty()) { + binding.trackSearchCover.load(track.cover_url) } - if (track.publishing_status.isNullOrBlank()) { - view.track_search_status.gone() - view.track_search_status_result.gone() + if (track.publishing_status.isBlank()) { + binding.trackSearchStatus.isVisible = false + binding.trackSearchStatusResult.isVisible = false } else { - view.track_search_status_result.text = track.publishing_status.capitalize() + binding.trackSearchStatusResult.text = track.publishing_status.capitalize(Locale.ROOT) } - if (track.publishing_type.isNullOrBlank()) { - view.track_search_type.gone() - view.track_search_type_result.gone() + if (track.publishing_type.isBlank()) { + binding.trackSearchType.isVisible = false + binding.trackSearchTypeResult.isVisible = false } else { - view.track_search_type_result.text = track.publishing_type.capitalize() + binding.trackSearchTypeResult.text = track.publishing_type.capitalize(Locale.ROOT) } - if (track.start_date.isNullOrBlank()) { - view.track_search_start.gone() - view.track_search_start_result.gone() + if (track.start_date.isBlank()) { + binding.trackSearchStart.isVisible = false + binding.trackSearchStartResult.isVisible = false } else { - view.track_search_start_result.text = track.start_date + binding.trackSearchStartResult.text = track.start_date } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt index d2ecbd5c47..eed15457fc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt @@ -5,6 +5,7 @@ import android.os.Bundle import android.view.View import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.customview.customView +import com.afollestad.materialdialogs.customview.getCustomView import com.jakewharton.rxbinding.widget.itemClicks import com.jakewharton.rxbinding.widget.textChanges import eu.kanade.tachiyomi.R @@ -12,10 +13,10 @@ import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.databinding.TrackSearchDialogBinding import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.manga.MangaDetailsPresenter import eu.kanade.tachiyomi.util.lang.plusAssign -import kotlinx.android.synthetic.main.track_search_dialog.view.* import rx.Subscription import rx.android.schedulers.AndroidSchedulers import rx.subscriptions.CompositeSubscription @@ -25,8 +26,6 @@ import java.util.concurrent.TimeUnit class TrackSearchDialog : DialogController { - private var dialogView: View? = null - private var adapter: TrackSearchAdapter? = null private var selectedItem: Track? = null @@ -42,6 +41,7 @@ class TrackSearchDialog : DialogController { private var wasPreviouslyTracked: Boolean = false private lateinit var presenter: MangaDetailsPresenter + lateinit var binding: TrackSearchDialogBinding constructor(target: TrackingBottomSheet, service: TrackService, wasTracked: Boolean) : super( Bundle() @@ -66,33 +66,32 @@ class TrackSearchDialog : DialogController { negativeButton(android.R.string.cancel) } + binding = TrackSearchDialogBinding.bind(dialog.getCustomView()) if (subscriptions.isUnsubscribed) { subscriptions = CompositeSubscription() } - - dialogView = dialog.view - onViewCreated(dialog.view, savedViewState) + onViewCreated(savedViewState) return dialog } - fun onViewCreated(view: View, savedState: Bundle?) { + fun onViewCreated(savedState: Bundle?) { // Create adapter - val adapter = TrackSearchAdapter(view.context) + val adapter = TrackSearchAdapter(binding.root.context) this.adapter = adapter - view.track_search_list.adapter = adapter + binding.trackSearchList.adapter = adapter // Set listeners selectedItem = null - subscriptions += view.track_search_list.itemClicks().subscribe { position -> + subscriptions += binding.trackSearchList.itemClicks().subscribe { position -> trackItem(position) } // Do an initial search based on the manga's title if (savedState == null) { val title = presenter.manga.title - view.track_search.append(title) + binding.trackSearch.append(title) search(title) } } @@ -107,13 +106,12 @@ class TrackSearchDialog : DialogController { override fun onDestroyView(view: View) { super.onDestroyView(view) subscriptions.unsubscribe() - dialogView = null adapter = null } override fun onAttach(view: View) { super.onAttach(view) - searchTextSubscription = dialogView!!.track_search.textChanges() + searchTextSubscription = binding.trackSearch.textChanges() .skip(1) .debounce(1, TimeUnit.SECONDS, AndroidSchedulers.mainThread()) .map { it.toString() } @@ -127,17 +125,15 @@ class TrackSearchDialog : DialogController { } private fun search(query: String) { - val view = dialogView ?: return - view.progress.visibility = View.VISIBLE - view.track_search_list.visibility = View.INVISIBLE + binding.progress.visibility = View.VISIBLE + binding.trackSearchList.visibility = View.INVISIBLE presenter.trackSearch(query, service, wasPreviouslyTracked) } fun onSearchResults(results: List) { selectedItem = null - val view = dialogView ?: return - view.progress.visibility = View.INVISIBLE - view.track_search_list.visibility = View.VISIBLE + binding.progress.visibility = View.INVISIBLE + binding.trackSearchList.visibility = View.VISIBLE adapter?.setItems(results) if (results.size == 1 && !wasPreviouslyTracked) { trackItem(0) @@ -145,9 +141,8 @@ class TrackSearchDialog : DialogController { } fun onSearchResultsError() { - val view = dialogView ?: return - view.progress.visibility = View.VISIBLE - view.track_search_list.visibility = View.INVISIBLE + binding.progress.visibility = View.VISIBLE + binding.trackSearchList.visibility = View.INVISIBLE adapter?.setItems(emptyList()) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackingBottomSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackingBottomSheet.kt index 296a490f6e..8574d4954d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackingBottomSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackingBottomSheet.kt @@ -2,26 +2,28 @@ package eu.kanade.tachiyomi.ui.manga.track import android.content.Intent import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.net.toUri import androidx.recyclerview.widget.LinearLayoutManager -import com.elvishew.xlog.XLog +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.databinding.TrackingBottomSheetBinding import eu.kanade.tachiyomi.ui.manga.MangaDetailsController import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.view.RecyclerWindowInsetsListener -import eu.kanade.tachiyomi.util.view.setEdgeToEdge -import kotlinx.android.synthetic.main.tracking_bottom_sheet.* +import eu.kanade.tachiyomi.util.view.checkHeightThen +import eu.kanade.tachiyomi.util.view.updateLayoutParams +import eu.kanade.tachiyomi.widget.E2EBottomSheetDialog +import com.elvishew.xlog.XLog class TrackingBottomSheet(private val controller: MangaDetailsController) : - BottomSheetDialog - (controller.activity!!, R.style.BottomSheetDialogTheme), + E2EBottomSheetDialog(controller.activity!!), TrackAdapter.OnClickListener, SetTrackStatusDialog.Listener, SetTrackChaptersDialog.Listener, @@ -31,36 +33,38 @@ class TrackingBottomSheet(private val controller: MangaDetailsController) : val activity = controller.activity!! - private var sheetBehavior: BottomSheetBehavior<*> - val presenter = controller.presenter private var adapter: TrackAdapter? = null - init { - // Use activity theme for this layout - val view = activity.layoutInflater.inflate(R.layout.tracking_bottom_sheet, null) - setContentView(view) + override fun createBinding(inflater: LayoutInflater) = + TrackingBottomSheetBinding.inflate(inflater) + + override var recyclerView: RecyclerView? = binding.trackRecycler - sheetBehavior = BottomSheetBehavior.from(view.parent as ViewGroup) - setEdgeToEdge(activity, view) + init { val height = activity.window.decorView.rootWindowInsets.systemWindowInsetBottom - sheetBehavior.peekHeight = 380.dpToPx + height + sheetBehavior.peekHeight = 500.dpToPx + height - sheetBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, progress: Float) {} + sheetBehavior.addBottomSheetCallback( + object : BottomSheetBehavior.BottomSheetCallback() { + override fun onSlide(bottomSheet: View, progress: Float) { } - override fun onStateChanged(p0: View, state: Int) { - if (state == BottomSheetBehavior.STATE_EXPANDED) { - sheetBehavior.skipCollapsed = true + override fun onStateChanged(p0: View, state: Int) { + if (state == BottomSheetBehavior.STATE_EXPANDED) { + sheetBehavior.skipCollapsed = true + } } } - }) - } - - override fun show() { - super.show() - sheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED + ) + binding.displayBottomSheet.checkHeightThen { + val fullHeight = activity.window.decorView.height + val insets = activity.window.decorView.rootWindowInsets + binding.trackRecycler.updateLayoutParams { + matchConstraintMaxHeight = + fullHeight - (insets?.systemWindowInsetTop ?: 0) - 30.dpToPx + } + } } override fun onStart() { @@ -74,9 +78,9 @@ class TrackingBottomSheet(private val controller: MangaDetailsController) : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) adapter = TrackAdapter(this) - track_recycler.layoutManager = LinearLayoutManager(context) - track_recycler.adapter = adapter - track_recycler.setOnApplyWindowInsetsListener(RecyclerWindowInsetsListener) + binding.trackRecycler.layoutManager = LinearLayoutManager(context) + binding.trackRecycler.adapter = adapter + binding.trackRecycler.setOnApplyWindowInsetsListener(RecyclerWindowInsetsListener) adapter?.items = presenter.trackList } @@ -84,6 +88,7 @@ class TrackingBottomSheet(private val controller: MangaDetailsController) : fun onNextTrackings(trackings: List) { onRefreshDone() adapter?.items = trackings + controller.refreshTracker() } fun onSearchResults(results: List) { @@ -102,13 +107,13 @@ class TrackingBottomSheet(private val controller: MangaDetailsController) : fun onRefreshDone() { for (i in adapter!!.items.indices) { - (track_recycler.findViewHolderForAdapterPosition(i) as? TrackHolder)?.setProgress(false) + (binding.trackRecycler.findViewHolderForAdapterPosition(i) as? TrackHolder)?.setProgress(false) } } fun onRefreshError(error: Throwable) { for (i in adapter!!.items.indices) { - (track_recycler.findViewHolderForAdapterPosition(i) as? TrackHolder)?.setProgress(false) + (binding.trackRecycler.findViewHolderForAdapterPosition(i) as? TrackHolder)?.setProgress(false) } activity.toast(error.message) } @@ -185,6 +190,36 @@ class TrackingBottomSheet(private val controller: MangaDetailsController) : SetTrackScoreDialog(this, item).showDialog(controller.router) } + override fun onStartDateClick(position: Int) { + val item = adapter?.getItem(position) ?: return + if (item.track == null) return + + val suggestedDate = presenter.getSuggestedDate(SetTrackReadingDatesDialog.ReadingDate.Start) + SetTrackReadingDatesDialog( + controller, + this, + SetTrackReadingDatesDialog.ReadingDate.Start, + item, + suggestedDate + ) + .showDialog(controller.router) + } + + override fun onFinishDateClick(position: Int) { + val item = adapter?.getItem(position) ?: return + if (item.track == null) return + + val suggestedDate = presenter.getSuggestedDate(SetTrackReadingDatesDialog.ReadingDate.Finish) + SetTrackReadingDatesDialog( + controller, + this, + SetTrackReadingDatesDialog.ReadingDate.Finish, + item, + suggestedDate + ) + .showDialog(controller.router) + } + override fun setStatus(item: TrackItem, selection: Int) { presenter.setStatus(item, selection) refreshItem(item) @@ -195,13 +230,13 @@ class TrackingBottomSheet(private val controller: MangaDetailsController) : } fun refreshItem(index: Int) { - (track_recycler.findViewHolderForAdapterPosition(index) as? TrackHolder)?.setProgress(true) + (binding.trackRecycler.findViewHolderForAdapterPosition(index) as? TrackHolder)?.setProgress(true) } fun refreshTrack(item: TrackService?) { val index = adapter?.indexOf(item) ?: -1 if (index > -1) { - (track_recycler.findViewHolderForAdapterPosition(index) as? TrackHolder) + (binding.trackRecycler.findViewHolderForAdapterPosition(index) as? TrackHolder) ?.setProgress(true) } } @@ -221,36 +256,6 @@ class TrackingBottomSheet(private val controller: MangaDetailsController) : presenter.removeTracker(item, fromServiceAlso) } - override fun onStartDateClick(position: Int) { - val item = adapter?.getItem(position) ?: return - if (item.track == null) return - - val suggestedDate = presenter.getSuggestedDate(SetTrackReadingDatesDialog.ReadingDate.Start) - SetTrackReadingDatesDialog( - controller, - this, - SetTrackReadingDatesDialog.ReadingDate.Start, - item, - suggestedDate - ) - .showDialog(controller.router) - } - - override fun onFinishDateClick(position: Int) { - val item = adapter?.getItem(position) ?: return - if (item.track == null) return - - val suggestedDate = presenter.getSuggestedDate(SetTrackReadingDatesDialog.ReadingDate.Finish) - SetTrackReadingDatesDialog( - controller, - this, - SetTrackReadingDatesDialog.ReadingDate.Finish, - item, - suggestedDate - ) - .showDialog(controller.router) - } - override fun setReadingDate(item: TrackItem, type: SetTrackReadingDatesDialog.ReadingDate, date: Long) { when (type) { SetTrackReadingDatesDialog.ReadingDate.Start -> controller.presenter.setTrackerStartDate(item, date) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/PageIndicatorTextView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/PageIndicatorTextView.kt index df6dca5e05..d3f4db747c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/PageIndicatorTextView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/PageIndicatorTextView.kt @@ -7,7 +7,6 @@ import android.text.Spannable import android.text.SpannableString import android.text.style.ScaleXSpan import android.util.AttributeSet -import android.widget.TextView import androidx.appcompat.widget.AppCompatTextView import eu.kanade.tachiyomi.widget.OutlineSpan @@ -39,7 +38,7 @@ class PageIndicatorTextView( } } - super.setText(finalText, TextView.BufferType.SPANNABLE) + super.setText(finalText, BufferType.SPANNABLE) } private companion object { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index 85d0e83454..70de57c97b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -1,17 +1,19 @@ package eu.kanade.tachiyomi.ui.reader import android.annotation.SuppressLint -import android.app.ProgressDialog import android.content.ClipData import android.content.Context import android.content.Intent -import android.content.pm.ActivityInfo +import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.Bitmap import android.graphics.Color +import android.graphics.drawable.LayerDrawable import android.os.Build import android.os.Bundle import android.view.KeyEvent +import android.view.Menu +import android.view.MenuItem import android.view.MotionEvent import android.view.View import android.view.ViewGroup @@ -20,31 +22,49 @@ import android.view.animation.Animation import android.view.animation.AnimationUtils import android.widget.SeekBar import android.widget.Toast -import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.ContextCompat import androidx.core.graphics.ColorUtils +import androidx.core.view.GestureDetectorCompat +import androidx.core.view.ViewCompat +import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat +import com.afollestad.materialdialogs.MaterialDialog import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.elvishew.xlog.XLog import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.snackbar.Snackbar -import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import com.mikepenz.iconics.typeface.library.materialdesigndx.MaterialDesignDx import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.isLongStrip import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.asImmediateFlowIn +import eu.kanade.tachiyomi.data.preference.toggle +import eu.kanade.tachiyomi.databinding.ReaderActivityBinding +import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.isMergedChapter import eu.kanade.tachiyomi.source.online.utils.MdUtil import eu.kanade.tachiyomi.ui.base.MaterialMenuSheet import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.SearchActivity +import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.AddToLibraryFirst +import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.Error +import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.Success import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters +import eu.kanade.tachiyomi.ui.reader.settings.OrientationType +import eu.kanade.tachiyomi.ui.reader.settings.PageLayout +import eu.kanade.tachiyomi.ui.reader.settings.ReaderBottomButton +import eu.kanade.tachiyomi.ui.reader.settings.ReadingModeType +import eu.kanade.tachiyomi.ui.reader.settings.TabbedReaderSettingsSheet import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer import eu.kanade.tachiyomi.ui.reader.viewer.pager.L2RPagerViewer +import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerViewer import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer import eu.kanade.tachiyomi.ui.reader.viewer.pager.VerticalPagerViewer import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer @@ -52,31 +72,34 @@ import eu.kanade.tachiyomi.ui.webview.WebViewActivity import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.system.GLUtil import eu.kanade.tachiyomi.util.system.ThemeUtil +import eu.kanade.tachiyomi.util.system.contextCompatColor import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.getBottomGestureInsets +import eu.kanade.tachiyomi.util.system.getPrefTheme import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.hasSideNavBar import eu.kanade.tachiyomi.util.system.iconicsDrawableMedium import eu.kanade.tachiyomi.util.system.isBottomTappable +import eu.kanade.tachiyomi.util.system.isLTR +import eu.kanade.tachiyomi.util.system.isTablet +import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.util.system.launchUI import eu.kanade.tachiyomi.util.system.openInBrowser +import eu.kanade.tachiyomi.util.system.setThemeAndNight +import eu.kanade.tachiyomi.util.system.spToPx import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.view.collapse +import eu.kanade.tachiyomi.util.view.compatToolTipText import eu.kanade.tachiyomi.util.view.doOnApplyWindowInsets -import eu.kanade.tachiyomi.util.view.expand -import eu.kanade.tachiyomi.util.view.gone import eu.kanade.tachiyomi.util.view.hide +import eu.kanade.tachiyomi.util.view.isCollapsed import eu.kanade.tachiyomi.util.view.isExpanded +import eu.kanade.tachiyomi.util.view.popupMenu import eu.kanade.tachiyomi.util.view.snack import eu.kanade.tachiyomi.util.view.updateLayoutParams import eu.kanade.tachiyomi.util.view.updatePaddingRelative -import eu.kanade.tachiyomi.util.view.visible import eu.kanade.tachiyomi.widget.SimpleAnimationListener import eu.kanade.tachiyomi.widget.SimpleSeekBarListener -import kotlinx.android.synthetic.main.reader_activity.* -import kotlinx.android.synthetic.main.reader_chapters_sheet.* -import kotlinx.android.synthetic.main.reader_chapters_sheet.view.* -import kotlinx.android.synthetic.main.reader_nav.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -104,6 +127,8 @@ class ReaderActivity : BaseRxActivity(), SystemUiHelper.OnVisibilityChangeListener { + lateinit var binding: ReaderActivityBinding + /** * Preferences helper. */ @@ -129,12 +154,11 @@ class ReaderActivity : /** * Whether the menu should stay visible. */ - var menuStickyVisible = false - private set + private var menuStickyVisible = false private var coroutine: Job? = null - var fromUrl = false + private var fromUrl = false /** * System UI helper to hide status & navigation bar on all different API levels. @@ -153,17 +177,19 @@ class ReaderActivity : var sheetManageNavColor = false - var lightStatusBar = false - - /** - * Progress dialog used when switching chapters from the menu buttons. - */ - @Suppress("DEPRECATION") - private var progressDialog: ProgressDialog? = null + private var lightStatusBar = false private var snackbar: Snackbar? = null - var intentPageNumber: Int? = null + private var intentPageNumber: Int? = null + + var isLoading = false + + private var lastShiftDoubleState: Boolean? = null + private var indexPageToShift: Int? = null + private var indexChapterToShift: Long? = null + + private var lastCropRes = 0 companion object { @Suppress("unused") @@ -173,6 +199,10 @@ class ReaderActivity : const val WEBTOON = 4 const val VERTICAL_PLUS = 5 + const val SHIFT_DOUBLE_PAGES = "shiftingDoublePages" + const val SHIFTED_PAGE_INDEX = "shiftedPageIndex" + const val SHIFTED_CHAP_INDEX = "shiftedChapterIndex" + fun newIntent(context: Context, manga: Manga, chapter: Chapter): Intent { val intent = Intent(context, ReaderActivity::class.java) intent.putExtra("manga", manga.id) @@ -186,10 +216,10 @@ class ReaderActivity : * Called when the activity is created. Initializes the presenter and configuration. */ override fun onCreate(savedInstanceState: Bundle?) { - AppCompatDelegate.setDefaultNightMode(ThemeUtil.nightMode(preferences.theme())) - setTheme(ThemeUtil.theme(preferences.theme())) + setThemeAndNight(preferences) super.onCreate(savedInstanceState) - setContentView(R.layout.reader_activity) + binding = ReaderActivityBinding.inflate(layoutInflater) + setContentView(binding.root) val a = obtainStyledAttributes(intArrayOf(android.R.attr.windowLightStatusBar)) lightStatusBar = a.getBoolean(0, false) a.recycle() @@ -199,9 +229,14 @@ class ReaderActivity : if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { systemUiFlag = systemUiFlag.or(View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR) } - reader_layout.systemUiVisibility = when (lightStatusBar) { - true -> reader_layout.systemUiVisibility.or(systemUiFlag) - false -> reader_layout.systemUiVisibility.rem(systemUiFlag) + binding.appBar.setBackgroundColor(contextCompatColor(R.color.secondary_alpha)) + ViewCompat.setBackgroundTintList( + binding.readerNav.root, + ColorStateList.valueOf(contextCompatColor(R.color.secondary_alpha)) + ) + binding.readerLayout.systemUiVisibility = when (lightStatusBar) { + true -> binding.readerLayout.systemUiVisibility.or(systemUiFlag) + false -> binding.readerLayout.systemUiVisibility.rem(systemUiFlag) } if (presenter.needsInit()) { @@ -215,17 +250,23 @@ class ReaderActivity : } presenter.init(manga, chapter) } else { - please_wait.visible() + binding.pleaseWait.isVisible = true } } if (savedInstanceState != null) { menuVisible = savedInstanceState.getBoolean(::menuVisible.name) + lastShiftDoubleState = savedInstanceState.get(SHIFT_DOUBLE_PAGES) as? Boolean + indexPageToShift = savedInstanceState.get(SHIFTED_PAGE_INDEX) as? Int + indexChapterToShift = savedInstanceState.get(SHIFTED_CHAP_INDEX) as? Long + binding.readerNav.root.isInvisible = !menuVisible + } else { + binding.readerNav.root.isInvisible = true } - chapters_bottom_sheet.setup(this) - if (ThemeUtil.isBlueTheme(preferences.theme())) { - chapter_recycler.setBackgroundColor(getResourceColor(android.R.attr.colorBackground)) + binding.chaptersSheet.chaptersBottomSheet.setup(this) + if (ThemeUtil.isColoredTheme(getPrefTheme(preferences))) { + binding.chaptersSheet.chapterRecycler.setBackgroundColor(getResourceColor(android.R.attr.colorBackground)) } config = ReaderConfig() initializeMenu() @@ -237,13 +278,11 @@ class ReaderActivity : override fun onDestroy() { super.onDestroy() viewer?.destroy() - chapters_bottom_sheet.adapter = null + binding.chaptersSheet.chaptersBottomSheet.adapter = null viewer = null config = null bottomSheet?.dismiss() bottomSheet = null - progressDialog?.dismiss() - progressDialog = null snackbar?.dismiss() snackbar = null } @@ -254,6 +293,16 @@ class ReaderActivity : */ override fun onSaveInstanceState(outState: Bundle) { outState.putBoolean(::menuVisible.name, menuVisible) + (viewer as? PagerViewer)?.let { pViewer -> + val config = pViewer.config + outState.putBoolean(SHIFT_DOUBLE_PAGES, config.shiftDoublePage) + if (config.shiftDoublePage && config.doublePages) { + pViewer.getShiftedPage()?.let { + outState.putInt(SHIFTED_PAGE_INDEX, it.index) + outState.putLong(SHIFTED_CHAP_INDEX, it.chapter.chapter.id ?: 0L) + } + } + } if (!isChangingConfigurations) { presenter.onSaveInstanceStateNonConfigurationChange() } @@ -274,7 +323,158 @@ class ReaderActivity : } } - fun popToMain() { + /** + * Called when the options menu of the binding.toolbar is being created. It adds our custom menu. + */ + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.reader, menu) + return true + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + val splitItem = menu?.findItem(R.id.action_shift_double_page) + splitItem?.isVisible = ((viewer as? PagerViewer)?.config?.doublePages ?: false) && !canShowSplitAtBottom() + binding.chaptersSheet.shiftPageButton.isVisible = ((viewer as? PagerViewer)?.config?.doublePages ?: false) && canShowSplitAtBottom() + (viewer as? PagerViewer)?.config?.let { config -> + val icon = ContextCompat.getDrawable( + this, + if ((!config.shiftDoublePage).xor(viewer is R2LPagerViewer)) R.drawable.ic_page_previous_outline_24dp else R.drawable.ic_page_next_outline_24dp + ) + splitItem?.icon = icon + binding.chaptersSheet.shiftPageButton.setImageDrawable(icon) + } + setBottomNavButtons(preferences.pageLayout().get()) + (binding.toolbar.background as? LayerDrawable)?.let { layerDrawable -> + val isDoublePage = splitItem?.isVisible ?: false + // Shout out to Google for not fixing setVisible https://issuetracker.google.com/issues/127538945 + layerDrawable.findDrawableByLayerId(R.id.layer_full_width).alpha = if (!isDoublePage) 255 else 0 + layerDrawable.findDrawableByLayerId(R.id.layer_one_item).alpha = if (isDoublePage) 255 else 0 + } + return super.onPrepareOptionsMenu(menu) + } + + private fun canShowSplitAtBottom(): Boolean { + return if (preferences.readerBottomButtons().isNotSet()) { + isTablet() + } else { + ReaderBottomButton.ShiftDoublePage.isIn(preferences.readerBottomButtons().get()) + } + } + + fun setBottomNavButtons(pageLayout: Int) { + val isDoublePage = pageLayout == PageLayout.DOUBLE_PAGES.value || + (pageLayout == PageLayout.AUTOMATIC.value && (viewer as? PagerViewer)?.config?.doublePages ?: false) + binding.chaptersSheet.doublePage.setImageDrawable( + ContextCompat.getDrawable( + this, + if (!isDoublePage) R.drawable.ic_single_page_24dp + else R.drawable.ic_book_open_variant_24dp + ) + ) + with(binding.readerNav) { + listOf(leftPageText, rightPageText).forEach { + it.updateLayoutParams { + val isCurrent = (viewer is R2LPagerViewer).xor(it === leftPageText) + width = if (isDoublePage && isCurrent) 48.spToPx else 32.spToPx + } + } + } + binding.chaptersSheet.doublePage.compatToolTipText = + getString( + if (isDoublePage) R.string.switch_to_single + else R.string.switch_to_double + ) + } + + private fun updateRotationShortcut(preference: Int) { + val orientation = OrientationType.fromPreference(preference) + binding.chaptersSheet.rotationSheetButton.setImageResource(orientation.iconRes) + } + + private fun updateCropBordersShortcut() { + val isPagerType = viewer is PagerViewer || (viewer as? WebtoonViewer)?.hasMargins == true + val enabled = if (isPagerType) { + preferences.cropBorders().get() + } else { + preferences.cropBordersWebtoon().get() + } + + with(binding.chaptersSheet.cropBordersSheetButton) { + val drawableRes = if (enabled) { + R.drawable.anim_free_to_crop + } else { + R.drawable.anim_crop_to_free + } + if (lastCropRes != drawableRes) { + val drawable = AnimatedVectorDrawableCompat.create(context, drawableRes) + setImageDrawable(drawable) + drawable?.start() + lastCropRes = drawableRes + } + compatToolTipText = + getString( + if (enabled) R.string.remove_crop + else R.string.crop_borders + ) + } + } + + private fun updateBottomShortcuts() { + val enabledButtons = preferences.readerBottomButtons().get() + with(binding.chaptersSheet) { + readingMode.isVisible = + presenter?.manga?.isLongStrip() != true && + ReaderBottomButton.ReadingMode.isIn(enabledButtons) + rotationSheetButton.isVisible = + ReaderBottomButton.Rotation.isIn(enabledButtons) + doublePage.isVisible = viewer is PagerViewer && + ReaderBottomButton.PageLayout.isIn(enabledButtons) + cropBordersSheetButton.isVisible = + if (viewer is PagerViewer) { + ReaderBottomButton.CropBordersPaged.isIn(enabledButtons) + } else { + ReaderBottomButton.CropBordersWebtoon.isIn(enabledButtons) + } + webviewButton.isVisible = + ReaderBottomButton.WebView.isIn(enabledButtons) + + commentsButton.isVisible = + ReaderBottomButton.Comment.isIn(enabledButtons) + chaptersButton.isVisible = + ReaderBottomButton.ViewChapters.isIn(enabledButtons) + shiftPageButton.isVisible = + ((viewer as? PagerViewer)?.config?.doublePages ?: false) && canShowSplitAtBottom() + binding.toolbar.menu.findItem(R.id.action_shift_double_page)?.isVisible = + ((viewer as? PagerViewer)?.config?.doublePages ?: false) && !canShowSplitAtBottom() + } + } + + /** + * Called when an item of the options menu was clicked. Used to handle clicks on our menu + * entries. + */ + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_shift_double_page -> { + shiftDoublePages() + } + else -> return super.onOptionsItemSelected(item) + } + return true + } + + private fun shiftDoublePages() { + (viewer as? PagerViewer)?.config?.let { config -> + config.shiftDoublePage = !config.shiftDoublePage + presenter.viewerChapters?.let { + (viewer as? PagerViewer)?.updateShifting() + (viewer as? PagerViewer)?.setChaptersDoubleShift(it) + invalidateOptionsMenu() + } + } + } + + private fun popToMain() { presenter.onBackPressed() if (fromUrl) { val intent = Intent(this, MainActivity::class.java).apply { @@ -288,20 +488,16 @@ class ReaderActivity : } /** - * Called when the user clicks the back key or the button on the toolbar. The call is + * Called when the user clicks the back key or the button on the binding.toolbar. The call is * delegated to the presenter. */ override fun onBackPressed() { - if (chapters_bottom_sheet.sheetBehavior.isExpanded()) { - chapters_bottom_sheet.sheetBehavior?.collapse() + if (binding.chaptersSheet.chaptersBottomSheet.sheetBehavior.isExpanded()) { + binding.chaptersSheet.chaptersBottomSheet.sheetBehavior?.collapse() return } presenter.onBackPressed() - if (fromUrl) { - finish() - } else { - super.onBackPressed() - } + finish() } /** @@ -312,6 +508,44 @@ class ReaderActivity : return handled || super.dispatchKeyEvent(event) } + override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { + when (keyCode) { + KeyEvent.KEYCODE_N -> { + if (viewer is R2LPagerViewer) { + binding.readerNav.leftChapter.performClick() + } else { + binding.readerNav.rightChapter.performClick() + } + return true + } + KeyEvent.KEYCODE_P -> { + if (viewer !is R2LPagerViewer) { + binding.readerNav.leftChapter.performClick() + } else { + binding.readerNav.rightChapter.performClick() + } + return true + } + KeyEvent.KEYCODE_L -> { + binding.readerNav.leftChapter.performClick() + return true + } + KeyEvent.KEYCODE_R -> { + binding.readerNav.rightChapter.performClick() + return true + } + KeyEvent.KEYCODE_E -> { + viewer?.moveToNext() + return true + } + KeyEvent.KEYCODE_Q -> { + viewer?.moveToPrevious() + return true + } + else -> return super.onKeyUp(keyCode, event) + } + } + /** * Dispatches a generic motion event. If the viewer doesn't handle it, call the default * implementation. @@ -324,91 +558,205 @@ class ReaderActivity : /** * Initializes the reader menu. It sets up click listeners and the initial visibility. */ + @SuppressLint("ClickableViewAccessibility") private fun initializeMenu() { - // Set toolbar - setSupportActionBar(toolbar) + // Set binding.toolbar + setSupportActionBar(binding.toolbar) val primaryColor = ColorUtils.setAlphaComponent( - getResourceColor(R.attr.colorSecondary), 200 + getResourceColor(R.attr.colorSecondary), + 200 ) - appbar.setBackgroundColor(primaryColor) + binding.appBar.setBackgroundColor(primaryColor) window.statusBarColor = Color.TRANSPARENT supportActionBar?.setDisplayHomeAsUpEnabled(true) - toolbar.setNavigationIcon(this.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_arrow_back)) - toolbar.setNavigationOnClickListener { + binding.toolbar.setNavigationIcon(this.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_arrow_back)) + binding.toolbar.setNavigationOnClickListener { popToMain() } - toolbar.setOnClickListener { + binding.toolbar.setOnClickListener { presenter.manga?.id?.let { id -> - val searchIntent = SearchActivity.openMangaIntent(this, id) - startActivity(searchIntent) + val intent = SearchActivity.openMangaIntent(this, id) + startActivity(intent) } } - // Init listeners on bottom menu - page_seekbar.setOnSeekBarChangeListener(object : SimpleSeekBarListener() { - override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { - if (viewer != null && fromUser) { - moveToPageIndex(value) + with(binding.chaptersSheet) { + doublePage.setOnClickListener { + if (preferences.pageLayout().get() == PageLayout.AUTOMATIC.value) { + (viewer as? PagerViewer)?.config?.let { config -> + config.doublePages = !config.doublePages + reloadChapters(config.doublePages, true) + } + } else { + preferences.pageLayout().set(1 - preferences.pageLayout().get()) } } - }) + cropBordersSheetButton.setOnClickListener { + val pref = + if ((viewer as? WebtoonViewer)?.hasMargins == true || + (viewer is PagerViewer) + ) preferences.cropBorders() else preferences.cropBordersWebtoon() + pref.toggle() + } + + with(rotationSheetButton) { + compatToolTipText = getString(R.string.rotation) - settings_button.setOnClickListener { - bottomSheet = ReaderSettingsSheet(this) - bottomSheet?.show() - if (chapters_bottom_sheet.sheetBehavior.isExpanded()) { - chapters_bottom_sheet.sheetBehavior?.collapse() + setOnClickListener { + popupMenu( + items = OrientationType.values().map { it.prefValue to it.stringRes }, + selectedItemId = preferences.rotation().get(), + ) { + val newOrientation = OrientationType.fromPreference(itemId) + + preferences.rotation().set(newOrientation.prefValue) + setOrientation(newOrientation.flag) + } + } } + + webviewButton.setOnClickListener { + openWebView(false) + } + + commentsButton.setOnClickListener { + openWebView(true) + } + + displayOptions.setOnClickListener { + TabbedReaderSettingsSheet(this@ReaderActivity).show() + } + + displayOptions.setOnLongClickListener { + TabbedReaderSettingsSheet(this@ReaderActivity, true).show() + true + } + + readingMode.setOnClickListener { readingMode -> + readingMode.popupMenu( + items = ReadingModeType.values().map { it.prefValue to it.stringRes }, + selectedItemId = presenter.manga?.viewer, + ) { + presenter.setMangaViewer(itemId) + } + } + } + + listOf(preferences.cropBorders(), preferences.cropBordersWebtoon()) + .forEach { pref -> + pref.asFlow() + .onEach { updateCropBordersShortcut() } + .launchIn(scope) + } + preferences.rotation().asImmediateFlowIn(scope) { updateRotationShortcut(it) } + + binding.chaptersSheet.shiftPageButton.setOnClickListener { + shiftDoublePages() } - brightness_button.setOnClickListener { - bottomSheet = ReaderColorFilterSheet(this) - bottomSheet?.show() - if (chapters_bottom_sheet.sheetBehavior.isExpanded()) { - chapters_bottom_sheet.sheetBehavior?.collapse() + binding.readerNav.leftChapter.setOnClickListener { + if (isLoading) { + return@setOnClickListener + } + val result = if (viewer is R2LPagerViewer) { + presenter.loadNextChapter() + } else { + presenter.loadPreviousChapter() + } + if (result) { + binding.readerNav.leftChapter.isVisible = false + binding.readerNav.leftProgress.isVisible = true + } else { + toast( + if (viewer is R2LPagerViewer) { + R.string.theres_no_next_chapter + } else { + R.string.theres_no_previous_chapter + } + ) + } + } + + binding.readerNav.rightChapter.setOnClickListener { + if (isLoading) { + return@setOnClickListener + } + val result = if (viewer !is R2LPagerViewer) { + presenter.loadNextChapter() + } else { + presenter.loadPreviousChapter() + } + if (result) { + binding.readerNav.rightChapter.isVisible = false + binding.readerNav.rightProgress.isVisible = true + } else { + toast( + if (viewer !is R2LPagerViewer) { + R.string.theres_no_next_chapter + } else { + R.string.theres_no_previous_chapter + } + ) } } - left_chapter.setImageDrawable(this.iconicsDrawableMedium(CommunityMaterial.Icon2.cmd_skip_previous)) - left_chapter.setOnClickListener { - viewer?.let { - when (it is R2LPagerViewer) { - true -> presenter.loadNextChapter() - false -> presenter.loadPreviousChapter() + + binding.touchView.setOnTouchListener { _, event -> + if (event.action == MotionEvent.ACTION_DOWN) { + if (binding.chaptersSheet.chaptersBottomSheet.sheetBehavior.isExpanded()) { + binding.chaptersSheet.chaptersBottomSheet.sheetBehavior?.collapse() } } + false } - right_chapter.setImageDrawable(this.iconicsDrawableMedium(CommunityMaterial.Icon2.cmd_skip_next)) - right_chapter.setOnClickListener { - viewer?.let { - when (it is R2LPagerViewer) { - true -> presenter.loadPreviousChapter() - false -> presenter.loadNextChapter() + val readerNavGestureDetector = ReaderNavGestureDetector(this) + val gestureDetector = GestureDetectorCompat(this, readerNavGestureDetector) + with(binding.readerNav) { + listOf(root, leftChapter, rightChapter, pageSeekbar).forEach { + it.setOnTouchListener { _, event -> + val result = gestureDetector.onTouchEvent(event) + if (event?.action == MotionEvent.ACTION_UP) { + if (!result) { + val sheetBehavior = binding.chaptersSheet.root.sheetBehavior + binding.chaptersSheet.root.dispatchTouchEvent(event) + if (sheetBehavior?.state != BottomSheetBehavior.STATE_SETTLING && !sheetBehavior.isCollapsed()) { + sheetBehavior?.collapse() + } + } + if (readerNavGestureDetector.lockVertical) { + // event.action = MotionEvent.ACTION_CANCEL + return@setOnTouchListener true + } + } else if ((event?.action != MotionEvent.ACTION_UP || event.action != MotionEvent.ACTION_DOWN) && result) { + event.action = MotionEvent.ACTION_CANCEL + return@setOnTouchListener false + } + if (it == pageSeekbar) { + readerNavGestureDetector.lockVertical || (!readerNavGestureDetector.hasScrollHorizontal && event?.action != MotionEvent.ACTION_UP) + } else { + result + } } } } - //comment_button.setImageDrawable(this.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_comment, size = 22)) - //comment_button.setOnClickListener { - // openComments() - //} - comment_button.isVisible = false - - chapters_button.setOnClickListener { - if (chapters_bottom_sheet.sheetBehavior?.isExpanded() == true) { - chapters_bottom_sheet.sheetBehavior?.collapse() - } else { - chapters_bottom_sheet.sheetBehavior?.expand() + // Init listeners on bottom menu + binding.readerNav.pageSeekbar.setOnSeekBarChangeListener( + object : SimpleSeekBarListener() { + override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { + if (viewer != null && fromUser) { + moveToPageIndex(value) + } + } } - } + ) // Set initial visibility setMenuVisibility(menuVisible) - chapters_bottom_sheet.sheetBehavior?.isHideable = !menuVisible - if (!menuVisible) { - chapters_bottom_sheet.sheetBehavior?.hide() - } - val peek = chapters_bottom_sheet.sheetBehavior?.peekHeight ?: 30.dpToPx - reader_layout.doOnApplyWindowInsets { v, insets, _ -> + binding.chaptersSheet.chaptersBottomSheet.sheetBehavior?.isHideable = !menuVisible + if (!menuVisible) binding.chaptersSheet.chaptersBottomSheet.sheetBehavior?.hide() + binding.chaptersSheet.root.sheetBehavior?.isGestureInsetBottomIgnored = true + val peek = 50.dpToPx + binding.readerLayout.doOnApplyWindowInsets { _, insets, _ -> sheetManageNavColor = when { insets.isBottomTappable() -> { window.navigationBarColor = Color.TRANSPARENT @@ -424,22 +772,26 @@ class ReaderActivity : } } - appbar.updateLayoutParams { + binding.appBar.updateLayoutParams { leftMargin = insets.systemWindowInsetLeft rightMargin = insets.systemWindowInsetRight } - toolbar.updateLayoutParams { + binding.toolbar.updateLayoutParams { topMargin = insets.systemWindowInsetTop } - chapters_bottom_sheet.updateLayoutParams { + binding.chaptersSheet.chaptersBottomSheet.updateLayoutParams { leftMargin = insets.systemWindowInsetLeft rightMargin = insets.systemWindowInsetRight height = 280.dpToPx + insets.systemWindowInsetBottom } - chapters_bottom_sheet.sheetBehavior?.peekHeight = peek + insets.getBottomGestureInsets() - - chapter_recycler.updatePaddingRelative(bottom = insets.systemWindowInsetBottom) - viewer_container.requestLayout() + binding.navLayout.updateLayoutParams { + leftMargin = 12.dpToPx + insets.systemWindowInsetLeft + rightMargin = 12.dpToPx + insets.systemWindowInsetRight + } + binding.chaptersSheet.root.sheetBehavior?.peekHeight = + peek + insets.getBottomGestureInsets() + binding.chaptersSheet.chapterRecycler.updatePaddingRelative(bottom = insets.systemWindowInsetBottom) + binding.viewerContainer.requestLayout() } } @@ -447,59 +799,52 @@ class ReaderActivity : * Sets the visibility of the menu according to [visible] and with an optional parameter to * [animate] the views. */ - private fun setMenuVisibility(visible: Boolean, animate: Boolean = true, force: Boolean = false) { + private fun setMenuVisibility(visible: Boolean, animate: Boolean = true) { menuVisible = visible if (visible) coroutine?.cancel() - viewer_container.requestLayout() + binding.viewerContainer.requestLayout() if (visible) { snackbar?.dismiss() systemUi?.show() - reader_menu.visible() - reader_nav.visible() + binding.readerMenu.isVisible = true - val readerNavAnim = AnimationUtils.loadAnimation(this, R.anim.readernav_enter) - reader_nav.startAnimation(readerNavAnim) - - if (chapters_bottom_sheet.sheetBehavior.isExpanded()) { - chapters_bottom_sheet.sheetBehavior?.isHideable = false + if (binding.chaptersSheet.chaptersBottomSheet.sheetBehavior.isExpanded()) { + binding.chaptersSheet.chaptersBottomSheet.sheetBehavior?.isHideable = false } - if (!chapters_bottom_sheet.sheetBehavior.isExpanded() && sheetManageNavColor) { + if (!binding.chaptersSheet.chaptersBottomSheet.sheetBehavior.isExpanded() && sheetManageNavColor) { window.navigationBarColor = Color.TRANSPARENT } if (animate) { if (!menuStickyVisible) { val toolbarAnimation = AnimationUtils.loadAnimation(this, R.anim.enter_from_top) - toolbarAnimation.setAnimationListener(object : SimpleAnimationListener() { - override fun onAnimationStart(animation: Animation) { - window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + toolbarAnimation.setAnimationListener( + object : SimpleAnimationListener() { + override fun onAnimationStart(animation: Animation) { + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + } } - }) - appbar.startAnimation(toolbarAnimation) + ) + binding.appBar.startAnimation(toolbarAnimation) } - chapters_bottom_sheet.sheetBehavior?.collapse() + binding.chaptersSheet.chaptersBottomSheet.sheetBehavior?.collapse() } } else { systemUi?.hide() if (animate) { val toolbarAnimation = AnimationUtils.loadAnimation(this, R.anim.exit_to_top) - toolbarAnimation.setAnimationListener(object : SimpleAnimationListener() { - override fun onAnimationEnd(animation: Animation) { - reader_menu.gone() - } - }) - appbar.startAnimation(toolbarAnimation) - BottomSheetBehavior.from(chapters_bottom_sheet).isHideable = true - chapters_bottom_sheet.sheetBehavior?.hide() - val readerNavAnimation = AnimationUtils.loadAnimation(this, R.anim.readernav_exit) - readerNavAnimation.setAnimationListener(object : SimpleAnimationListener() { - override fun onAnimationEnd(animation: Animation) { - reader_nav.gone() + toolbarAnimation.setAnimationListener( + object : SimpleAnimationListener() { + override fun onAnimationEnd(animation: Animation) { + binding.readerMenu.isVisible = false + } } - }) - reader_nav.startAnimation(readerNavAnimation) + ) + binding.appBar.startAnimation(toolbarAnimation) + BottomSheetBehavior.from(binding.chaptersSheet.chaptersBottomSheet).isHideable = true + binding.chaptersSheet.chaptersBottomSheet.sheetBehavior?.hide() } else { - reader_menu.gone() + binding.readerMenu.isVisible = false } } menuStickyVisible = false @@ -507,7 +852,7 @@ class ReaderActivity : /** * Called from the presenter when a manga is ready. Used to instantiate the appropriate viewer - * and the toolbar title. + * and the binding.toolbar title. */ fun setManga(manga: Manga) { val prevViewer = viewer @@ -517,12 +862,12 @@ class ReaderActivity : RIGHT_TO_LEFT -> R2LPagerViewer(this) VERTICAL -> VerticalPagerViewer(this) WEBTOON -> WebtoonViewer(this) - VERTICAL_PLUS -> WebtoonViewer(this, isContinuous = false) + VERTICAL_PLUS -> WebtoonViewer(this, hasMargins = true) else -> L2RPagerViewer(this) } if (noDefault && presenter.manga?.viewer!! > 0) { - snackbar = reader_layout.snack( + snackbar = binding.readerLayout.snack( getString( R.string.reading_, getString( @@ -536,7 +881,7 @@ class ReaderActivity : ), 4000 ) { - if (mangaViewer != WEBTOON) setAction(R.string.use_default) { + if (presenter.manga?.isLongStrip() != true) setAction(R.string.use_default) { presenter.setMangaViewer(0) } } @@ -545,12 +890,30 @@ class ReaderActivity : // Destroy previous viewer if there was one if (prevViewer != null) { prevViewer.destroy() - viewer_container.removeAllViews() + binding.viewerContainer.removeAllViews() } viewer = newViewer - viewer_container.addView(newViewer.getView()) + binding.viewerContainer.addView(newViewer.getView()) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (newViewer is R2LPagerViewer) { + binding.readerNav.leftChapter.tooltipText = getString(R.string.next_chapter) + binding.readerNav.rightChapter.tooltipText = getString(R.string.previous_chapter) + } else { + binding.readerNav.leftChapter.tooltipText = getString(R.string.previous_chapter) + binding.readerNav.rightChapter.tooltipText = getString(R.string.next_chapter) + } + } + + if (newViewer is PagerViewer) { + if (preferences.pageLayout().get() == PageLayout.AUTOMATIC.value) { + setDoublePageMode(newViewer) + } + lastShiftDoubleState?.let { newViewer.config.shiftDoublePage = it } + } - viewer_container.setBackgroundColor( + binding.navigationOverlay.isLTR = viewer !is L2RPagerViewer + binding.viewerContainer.setBackgroundColor( if (viewer is WebtoonViewer) { Color.BLACK } else { @@ -558,12 +921,17 @@ class ReaderActivity : } ) - toolbar.title = manga.title + binding.toolbar.title = manga.title - page_seekbar.isRTL = newViewer is R2LPagerViewer + binding.readerNav.pageSeekbar.isRTL = newViewer is R2LPagerViewer - please_wait.visible() - please_wait.startAnimation(AnimationUtils.loadAnimation(this, R.anim.fade_in_long)) + binding.pleaseWait.isVisible = true + binding.pleaseWait.startAnimation(AnimationUtils.loadAnimation(this, R.anim.fade_in_long)) + invalidateOptionsMenu() + updateCropBordersShortcut() + updateBottomShortcuts() + val viewerMode = ReadingModeType.fromPreference(presenter?.manga?.viewer ?: 0) + binding.chaptersSheet.readingMode.setImageResource(viewerMode.iconRes) } override fun onPause() { @@ -571,16 +939,65 @@ class ReaderActivity : super.onPause() } + fun reloadChapters(doublePages: Boolean, force: Boolean = false) { + val pViewer = viewer as? PagerViewer ?: return + pViewer.updateShifting() + if (!force && pViewer.config.autoDoublePages) { + setDoublePageMode(pViewer) + } else { + pViewer.config.doublePages = doublePages + } + val currentChapter = presenter.getCurrentChapter() + if (doublePages) { + // If we're moving from singe to double, we want the current page to be the first page + pViewer.config.shiftDoublePage = ( + binding.readerNav.pageSeekbar.progress + + ( + currentChapter?.pages?.take(binding.readerNav.pageSeekbar.progress) + ?.count { it.fullPage || it.isolatedPage } ?: 0 + ) + ) % 2 != 0 + } + presenter.viewerChapters?.let { + pViewer.setChaptersDoubleShift(it) + } + invalidateOptionsMenu() + } + /** * Called from the presenter whenever a new [viewerChapters] have been set. It delegates the - * method to the current viewer, but also set the subtitle on the toolbar. + * method to the current viewer, but also set the subtitle on the binding.toolbar. */ fun setChapters(viewerChapters: ViewerChapters) { - please_wait.gone() + binding.pleaseWait.isVisible = false + if (indexChapterToShift != null && indexPageToShift != null) { + viewerChapters.currChapter.pages?.find { it.index == indexPageToShift && it.chapter.chapter.id == indexChapterToShift }?.let { + (viewer as? PagerViewer)?.updateShifting(it) + } + indexChapterToShift = null + indexPageToShift = null + } else if (lastShiftDoubleState != null) { + val currentChapter = viewerChapters.currChapter + (viewer as? PagerViewer)?.config?.shiftDoublePage = ( + currentChapter.requestedPage + + ( + currentChapter.pages?.take(currentChapter.requestedPage) + ?.count { it.fullPage || it.isolatedPage } ?: 0 + ) + ) % 2 != 0 + } + lastShiftDoubleState = null viewer?.setChapters(viewerChapters) intentPageNumber?.let { moveToPageIndex(it) } intentPageNumber = null - toolbar.subtitle = viewerChapters.currChapter.chapter.name + binding.toolbar.subtitle = viewerChapters.currChapter.chapter.name + if (viewer is R2LPagerViewer) { + binding.readerNav.leftChapter.alpha = if (viewerChapters.nextChapter != null) 1f else 0.5f + binding.readerNav.rightChapter.alpha = if (viewerChapters.prevChapter != null) 1f else 0.5f + } else { + binding.readerNav.rightChapter.alpha = if (viewerChapters.nextChapter != null) 1f else 0.5f + binding.readerNav.leftChapter.alpha = if (viewerChapters.prevChapter != null) 1f else 0.5f + } } /** @@ -596,16 +1013,25 @@ class ReaderActivity : /** * Called from the presenter whenever it's loading the next or previous chapter. It shows or * dismisses a non-cancellable dialog to prevent user interaction according to the value of - * [show]. This is only used when the next/previous buttons on the toolbar are clicked; the + * [show]. This is only used when the next/previous buttons on the binding.toolbar are clicked; the * other cases are handled with chapter transitions on the viewers and chapter preloading. */ - @Suppress("DEPRECATION") fun setProgressDialog(show: Boolean) { - progressDialog?.dismiss() - progressDialog = if (show) { - ProgressDialog.show(this, null, getString(R.string.loading), true) + if (!show) { + binding.readerNav.leftChapter.isVisible = true + binding.readerNav.rightChapter.isVisible = true + + binding.readerNav.leftProgress.isVisible = false + binding.readerNav.rightProgress.isVisible = false + binding.chaptersSheet.root.resetChapter() + } + if (show) { + isLoading = show } else { - null + scope.launchIO { + delay(100) + isLoading = show + } } } @@ -613,15 +1039,15 @@ class ReaderActivity : * Moves the viewer to the given page [index]. It does nothing if the viewer is null or the * page is not found. */ - fun moveToPageIndex(index: Int) { + fun moveToPageIndex(index: Int, animated: Boolean = true) { val viewer = viewer ?: return val currentChapter = presenter.getCurrentChapter() ?: return val page = currentChapter.pages?.getOrNull(index) ?: return - viewer.moveToPage(page) + viewer.moveToPage(page, animated) } fun refreshChapters() { - chapters_bottom_sheet.refreshList() + binding.chaptersSheet.chaptersBottomSheet.refreshList() } /** @@ -629,58 +1055,131 @@ class ReaderActivity : * bottom menu and delegates the change to the presenter. */ @SuppressLint("SetTextI18n") - fun onPageSelected(page: ReaderPage) { - val newChapter = presenter.onPageSelected(page) + fun onPageSelected(page: ReaderPage, hasExtraPage: Boolean) { + presenter.onPageSelected(page, hasExtraPage) val pages = page.chapter.pages ?: return - // Set bottom page number - page_number.text = "${page.number}/${pages.size}" - // Set seekbar page number - if (viewer !is R2LPagerViewer) { - left_page_text.text = "${page.number}" - right_page_text.text = "${pages.size}" + val currentPage = if (hasExtraPage) { + if (resources.isLTR) "${page.number}-${page.number + 1}" else "${page.number + 1}-${page.number}" } else { - right_page_text.text = "${page.number}" - left_page_text.text = "${pages.size}" + "${page.number}" } - if (!newChapter && chapters_bottom_sheet.shouldCollapse && chapters_bottom_sheet - .sheetBehavior.isExpanded() - ) { - chapters_bottom_sheet.sheetBehavior?.collapse() + val totalPages = pages.size.toString() + binding.pageNumber.text = if (resources.isLTR) "$currentPage/$totalPages" else "$totalPages/$currentPage" + if (viewer is R2LPagerViewer) { + binding.readerNav.rightPageText.text = currentPage + binding.readerNav.leftPageText.text = totalPages + } else { + binding.readerNav.leftPageText.text = currentPage + binding.readerNav.rightPageText.text = totalPages } - if (chapters_bottom_sheet.selectedChapterId != page.chapter.chapter.id) { - chapters_bottom_sheet.refreshList() + if (binding.chaptersSheet.chaptersBottomSheet.selectedChapterId != page.chapter.chapter.id) { + binding.chaptersSheet.chaptersBottomSheet.refreshList() } - chapters_bottom_sheet.shouldCollapse = true - // Set seekbar progress - page_seekbar.max = pages.lastIndex - page_seekbar.progress = page.index + binding.readerNav.pageSeekbar.max = pages.lastIndex + val progress = page.index + if (hasExtraPage) 1 else 0 + // For a double page, show the last 2 pages as if it was the final part of the seekbar + binding.readerNav.pageSeekbar.progress = if (progress == pages.lastIndex) progress else page.index } /** * Called from the viewer whenever a [page] is long clicked. A bottom sheet with a list of * actions to perform is shown. */ - fun onPageLongTap(page: ReaderPage) { - val items = listOf( - MaterialMenuSheet.MenuSheetItem( - 0, R.drawable.ic_share_24dp, R.string.share - ), - MaterialMenuSheet.MenuSheetItem( - 1, R.drawable.ic_save_24dp, R.string.save + fun onPageLongTap(page: ReaderPage, extraPage: ReaderPage? = null) { + val items = if (extraPage != null) { + listOf( + MaterialMenuSheet.MenuSheetItem( + 3, + R.drawable.ic_outline_share_24dp, + R.string.share_second_page + ), + MaterialMenuSheet.MenuSheetItem( + 4, + R.drawable.ic_outline_save_24dp, + R.string.save_second_page + ), + MaterialMenuSheet.MenuSheetItem( + 5, + R.drawable.ic_outline_photo_24dp, + R.string.set_second_page_as_cover + ), + MaterialMenuSheet.MenuSheetItem( + 0, + R.drawable.ic_share_24dp, + R.string.share_first_page + ), + MaterialMenuSheet.MenuSheetItem( + 1, + R.drawable.ic_save_24dp, + R.string.save_first_page + ), + MaterialMenuSheet.MenuSheetItem( + 2, + R.drawable.ic_photo_24dp, + R.string.set_first_page_as_cover + ), + MaterialMenuSheet.MenuSheetItem( + 6, + R.drawable.ic_share_all_outline_24dp, + R.string.share_combined_pages + ), + MaterialMenuSheet.MenuSheetItem( + 7, + R.drawable.ic_save_all_outline_24dp, + R.string.save_combined_pages + ) ) - ) + } else { + listOf( + MaterialMenuSheet.MenuSheetItem( + 0, + R.drawable.ic_share_24dp, + R.string.share + ), + MaterialMenuSheet.MenuSheetItem( + 1, + R.drawable.ic_save_24dp, + R.string.save + ), + MaterialMenuSheet.MenuSheetItem( + 2, + R.drawable.ic_photo_24dp, + R.string.set_as_cover + ) + ) + } MaterialMenuSheet(this, items) { _, item -> when (item) { 0 -> shareImage(page) 1 -> saveImage(page) + 2 -> showSetCoverPrompt(page) + 3 -> extraPage?.let { shareImage(it) } + 4 -> extraPage?.let { saveImage(it) } + 5 -> extraPage?.let { showSetCoverPrompt(it) } + 6, 7 -> extraPage?.let { secondPage -> + (viewer as? PagerViewer)?.let { viewer -> + val isLTR = (viewer !is R2LPagerViewer).xor(viewer.config.invertDoublePages) + val bg = + if (viewer.config.readerTheme >= 2 || viewer.config.readerTheme == 0) { + Color.WHITE + } else { + Color.BLACK + } + if (item == 6) { + presenter.shareImages(page, secondPage, isLTR, bg) + } else { + presenter.saveImages(page, secondPage, isLTR, bg) + } + } + } } true }.show() - if (chapters_bottom_sheet.sheetBehavior.isExpanded()) { - chapters_bottom_sheet.sheetBehavior?.collapse() + if (binding.chaptersSheet.root.sheetBehavior.isExpanded()) { + binding.chaptersSheet.root.sheetBehavior?.collapse() } } @@ -713,27 +1212,41 @@ class ReaderActivity : * Called from the page sheet. It delegates the call to the presenter to do some IO, which * will call [onShareImageResult] with the path the image was saved on when it's ready. */ - fun shareImage(page: ReaderPage) { + private fun shareImage(page: ReaderPage) { presenter.shareImage(page) } + private fun showSetCoverPrompt(page: ReaderPage) { + if (page.status != Page.READY) return + + MaterialDialog(this).title(R.string.use_image_as_cover) + .positiveButton(android.R.string.yes) { + setAsCover(page) + }.negativeButton(android.R.string.no).show() + } + /** * Called from the presenter when a page is ready to be shared. It shows Android's default * sharing tool. */ - fun onShareImageResult(file: File, page: ReaderPage) { + fun onShareImageResult(file: File, page: ReaderPage, secondPage: ReaderPage? = null) { val manga = presenter.manga ?: return val chapter = page.chapter.chapter val decimalFormat = DecimalFormat("#.###", DecimalFormatSymbols().apply { decimalSeparator = '.' }) + val pageNumber = if (secondPage != null) { + getString(R.string.pages_, if (resources.isLTR) "${page.number}-${page.number + 1}" else "${page.number + 1}-${page.number}") + } else { + getString(R.string.page_, page.number) + } val text = "${manga.title}: ${ - getString( - R.string.chapter_, - decimalFormat.format(chapter.chapter_number) - ) - }, ${getString(R.string.page_, page.number)}" + getString( + R.string.chapter_, + decimalFormat.format(chapter.chapter_number) + ) + }, $pageNumber" val stream = file.getUriCompat(this) val intent = Intent(Intent.ACTION_SEND).apply { @@ -750,7 +1263,7 @@ class ReaderActivity : * Called from the page sheet. It delegates saving the image of the given [page] on external * storage to the presenter. */ - fun saveImage(page: ReaderPage) { + private fun saveImage(page: ReaderPage) { presenter.saveImage(page) } @@ -769,20 +1282,50 @@ class ReaderActivity : } } - fun openComments() { + fun openWebView(isComments: Boolean) { val currentChapter = presenter.getCurrentChapter() currentChapter ?: return - if (currentChapter.chapter.isMergedChapter()) { - toast(R.string.comments_unavailable, duration = Toast.LENGTH_SHORT) + if (isComments) { + if (currentChapter.chapter.isMergedChapter()) { + toast(R.string.comments_unavailable, duration = Toast.LENGTH_SHORT) + } else { + toast(R.string.comments_unavailable_dex, duration = Toast.LENGTH_SHORT) + } } else { - val url = MdUtil.baseUrl + "/chapter/" + MdUtil.getChapterId(currentChapter.chapter.url) + "/comments" + var url = MdUtil.baseUrl + "/chapter/" + MdUtil.getChapterId(currentChapter.chapter.url) + if (isComments) { + url += "/comments" + } + val intent = WebViewActivity.newIntent(this, presenter.manga!!.source, url, currentChapter.chapter.name) startActivity(intent) } } + /** + * Called from the page sheet. It delegates setting the image of the given [page] as the + * cover to the presenter. + */ + private fun setAsCover(page: ReaderPage) { + presenter.setAsCover(page) + } + + /** + * Called from the presenter when a page is set as cover or fails. It shows a different message + * depending on the [result]. + */ + fun onSetAsCoverResult(result: ReaderPresenter.SetAsCoverResult) { + toast( + when (result) { + Success -> R.string.cover_updated + AddToLibraryFirst -> R.string.must_be_in_library_to_edit + Error -> R.string.failed_to_update_cover + } + ) + } + override fun onVisibilityChange(visible: Boolean) { if (visible && !menuStickyVisible && !menuVisible) { menuStickyVisible = visible @@ -794,15 +1337,18 @@ class ReaderActivity : setMenuVisibility(false) } } - if (sheetManageNavColor) window.navigationBarColor = getResourceColor(R.attr.colorSecondary) - reader_menu.visible() + if (sheetManageNavColor) window.navigationBarColor = + getResourceColor(R.attr.colorSecondary) + binding.readerMenu.isVisible = true val toolbarAnimation = AnimationUtils.loadAnimation(this, R.anim.enter_from_top) - toolbarAnimation.setAnimationListener(object : SimpleAnimationListener() { - override fun onAnimationStart(animation: Animation) { - window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + toolbarAnimation.setAnimationListener( + object : SimpleAnimationListener() { + override fun onAnimationStart(animation: Animation) { + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + } } - }) - appbar.startAnimation(toolbarAnimation) + ) + binding.appBar.startAnimation(toolbarAnimation) } } else { if (menuStickyVisible && !menuVisible) { @@ -817,17 +1363,24 @@ class ReaderActivity : */ private fun setNotchCutoutMode() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - val currentOrientation = resources.configuration.orientation + val params = window.attributes if (currentOrientation == Configuration.ORIENTATION_LANDSCAPE) { - val params = window.attributes params.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER + } else { + params.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES } } } + private fun setDoublePageMode(viewer: PagerViewer) { + val currentOrientation = resources.configuration.orientation + viewer.config.doublePages = (currentOrientation == Configuration.ORIENTATION_LANDSCAPE) + } + private fun handleIntentAction(intent: Intent): Boolean { val pathSegments = intent.data?.pathSegments if (pathSegments != null && pathSegments.size > 1) { @@ -841,7 +1394,7 @@ class ReaderActivity : } } else if (!id.isNullOrBlank()) { intentPageNumber = secondary?.toIntOrNull()?.minus(1) - setMenuVisibility(visible = false, animate = true, force = true) + setMenuVisibility(visible = false, animate = true) scope.launch(Dispatchers.IO) { try { presenter.loadChapterURL(id) @@ -857,6 +1410,16 @@ class ReaderActivity : return false } + /** + * Forces the user preferred [orientation] on the activity. + */ + private fun setOrientation(orientation: Int) { + val newOrientation = OrientationType.fromPreference(orientation) + if (newOrientation.flag != requestedOrientation) { + requestedOrientation = newOrientation.flag + } + } + /** * Class that handles the user preferences of the reader. */ @@ -877,63 +1440,32 @@ class ReaderActivity : } .launchIn(scope) - preferences.showPageNumber().asFlow() - .onEach { setPageNumberVisibility(it) } - .launchIn(scope) + preferences.showPageNumber().asImmediateFlowIn(scope) { setPageNumberVisibility(it) } - preferences.trueColor().asFlow() - .onEach { setTrueColor(it) } - .launchIn(scope) + preferences.trueColor().asImmediateFlowIn(scope) { setTrueColor(it) } - preferences.fullscreen().asFlow() - .onEach { setFullscreen(it) } - .launchIn(scope) + preferences.fullscreen().asImmediateFlowIn(scope) { setFullscreen(it) } - preferences.keepScreenOn().asFlow() - .onEach { setKeepScreenOn(it) } - .launchIn(scope) + preferences.keepScreenOn().asImmediateFlowIn(scope) { setKeepScreenOn(it) } - preferences.customBrightness().asFlow() - .onEach { setCustomBrightness(it) } - .launchIn(scope) + preferences.customBrightness().asImmediateFlowIn(scope) { setCustomBrightness(it) } - preferences.colorFilter().asFlow() - .onEach { setColorFilter(it) } - .launchIn(scope) + preferences.colorFilter().asImmediateFlowIn(scope) { setColorFilter(it) } - preferences.colorFilterMode().asFlow() - .onEach { setColorFilter(preferences.colorFilter().get()) } - .launchIn(scope) - - preferences.alwaysShowChapterTransition().asFlow() - .onEach { showNewChapter = it } - .launchIn(scope) - } + preferences.colorFilterMode().asImmediateFlowIn(scope) { + setColorFilter(preferences.colorFilter().get()) + } - /** - * Forces the user preferred [orientation] on the activity. - */ - private fun setOrientation(orientation: Int) { - val newOrientation = when (orientation) { - // Lock in current orientation - 2 -> { - val currentOrientation = resources.configuration.orientation - if (currentOrientation == Configuration.ORIENTATION_PORTRAIT) { - ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - } else { - ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - } - } - // Lock in portrait - 3 -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - // Lock in landscape - 4 -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - // Rotation free - else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + preferences.alwaysShowChapterTransition().asImmediateFlowIn(scope) { + showNewChapter = it } - if (newOrientation != requestedOrientation) { - requestedOrientation = newOrientation + preferences.pageLayout().asImmediateFlowIn(scope) { setBottomNavButtons(it) } + + preferences.readerBottomButtons().asImmediateFlowIn(scope) { updateBottomShortcuts() } + + preferences.readWithTapping().asImmediateFlowIn(scope) { + binding.navigationOverlay.tappingEnabled = it } } @@ -941,17 +1473,18 @@ class ReaderActivity : * Sets the visibility of the bottom page indicator according to [visible]. */ private fun setPageNumberVisibility(visible: Boolean) { - page_number.visibility = if (visible) View.VISIBLE else View.INVISIBLE + binding.pageNumber.visibility = if (visible) View.VISIBLE else View.INVISIBLE } /** * Sets the 32-bit color mode according to [enabled]. */ private fun setTrueColor(enabled: Boolean) { - if (enabled) + if (enabled) { SubsamplingScaleImageView.setPreferredBitmapConfig(Bitmap.Config.ARGB_8888) - else + } else { SubsamplingScaleImageView.setPreferredBitmapConfig(Bitmap.Config.RGB_565) + } } /** @@ -1003,7 +1536,7 @@ class ReaderActivity : .onEach { setColorFilterValue(it) } .launchIn(scope) } else { - color_overlay.gone() + binding.colorOverlay.isVisible = false } } @@ -1029,11 +1562,11 @@ class ReaderActivity : // Set black overlay visibility. if (value < 0) { - brightness_overlay.visible() + binding.brightnessOverlay.isVisible = true val alpha = (abs(value) * 2.56).toInt() - brightness_overlay.setBackgroundColor(Color.argb(alpha, 0, 0, 0)) + binding.brightnessOverlay.setBackgroundColor(Color.argb(alpha, 0, 0, 0)) } else { - brightness_overlay.gone() + binding.brightnessOverlay.isVisible = false } } @@ -1041,19 +1574,8 @@ class ReaderActivity : * Sets the color filter [value]. */ private fun setColorFilterValue(value: Int) { - color_overlay.visible() - color_overlay.setFilterColor(value, preferences.colorFilterMode().get()) + binding.colorOverlay.isVisible = true + binding.colorOverlay.setFilterColor(value, preferences.colorFilterMode().get()) } } - - override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { - if (keyCode == KeyEvent.KEYCODE_N) { - presenter.loadNextChapter() - return true - } else if (keyCode == KeyEvent.KEYCODE_P) { - presenter.loadPreviousChapter() - return true - } - return super.onKeyUp(keyCode, event) - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderColorFilterSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderColorFilterSheet.kt deleted file mode 100644 index 9be860c4f2..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderColorFilterSheet.kt +++ /dev/null @@ -1,314 +0,0 @@ -package eu.kanade.tachiyomi.ui.reader - -import android.graphics.Color -import android.os.Build -import android.view.View -import android.view.ViewGroup -import android.widget.SeekBar -import androidx.annotation.ColorInt -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.util.system.hasSideNavBar -import eu.kanade.tachiyomi.util.system.isInNightMode -import eu.kanade.tachiyomi.util.view.expand -import eu.kanade.tachiyomi.util.view.gone -import eu.kanade.tachiyomi.util.view.setBottomEdge -import eu.kanade.tachiyomi.util.view.setEdgeToEdge -import eu.kanade.tachiyomi.util.view.visible -import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener -import eu.kanade.tachiyomi.widget.SimpleSeekBarListener -import kotlinx.android.synthetic.main.reader_color_filter.* -import kotlinx.android.synthetic.main.reader_color_filter_sheet.* -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.sample -import uy.kohesive.injekt.injectLazy -import kotlin.math.abs - -/** - * Color filter sheet to toggle custom filter and brightness overlay. - */ -class ReaderColorFilterSheet(private val activity: ReaderActivity) : BottomSheetDialog -(activity, R.style.BottomSheetDialogTheme) { - - private val preferences by injectLazy() - - private var sheetBehavior: BottomSheetBehavior<*>? = null - - init { - val view = activity.layoutInflater.inflate(R.layout.reader_color_filter_sheet, null) - setContentView(view) - - setEdgeToEdge(activity, view, 0) - window?.navigationBarColor = Color.TRANSPARENT - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - !context.isInNightMode() && - !activity.window.decorView.rootWindowInsets.hasSideNavBar() - ) - window?.decorView?.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR - setBottomEdge(brightness_seekbar, activity) - - sheetBehavior = BottomSheetBehavior.from(view.parent as ViewGroup) - - preferences.colorFilter().asFlow() - .onEach { setColorFilter(it, view) } - .launchIn(activity.scope) - - preferences.colorFilterMode().asFlow() - .onEach { setColorFilter(preferences.colorFilter().get(), view) } - .launchIn(activity.scope) - - preferences.customBrightness().asFlow() - .onEach { setCustomBrightness(it, view) } - .launchIn(activity.scope) - - // Get color and update values - val color = preferences.colorFilterValue().get() - val brightness = preferences.customBrightnessValue().get() - - val argb = setValues(color, view) - - // Set brightness value - txt_brightness_seekbar_value.text = brightness.toString() - brightness_seekbar.progress = brightness - - // Initialize seekBar progress - seekbar_color_filter_alpha.progress = argb[0] - seekbar_color_filter_red.progress = argb[1] - seekbar_color_filter_green.progress = argb[2] - seekbar_color_filter_blue.progress = argb[3] - - // Set listeners - switch_color_filter.isChecked = preferences.colorFilter().get() - switch_color_filter.setOnCheckedChangeListener { _, isChecked -> - preferences.colorFilter().set(isChecked) - } - - custom_brightness.isChecked = preferences.customBrightness().get() - custom_brightness.setOnCheckedChangeListener { _, isChecked -> - preferences.customBrightness().set(isChecked) - } - - color_filter_mode.onItemSelectedListener = IgnoreFirstSpinnerListener { position -> - preferences.colorFilterMode().set(position) - } - color_filter_mode.setSelection(preferences.colorFilterMode().get(), false) - - seekbar_color_filter_alpha.setOnSeekBarChangeListener(object : SimpleSeekBarListener() { - override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { - if (fromUser) { - setColorValue(value, ALPHA_MASK, 24) - } - } - }) - - seekbar_color_filter_red.setOnSeekBarChangeListener(object : SimpleSeekBarListener() { - override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { - if (fromUser) { - setColorValue(value, RED_MASK, 16) - } - } - }) - - seekbar_color_filter_green.setOnSeekBarChangeListener(object : SimpleSeekBarListener() { - override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { - if (fromUser) { - setColorValue(value, GREEN_MASK, 8) - } - } - }) - - seekbar_color_filter_blue.setOnSeekBarChangeListener(object : SimpleSeekBarListener() { - override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { - if (fromUser) { - setColorValue(value, BLUE_MASK, 0) - } - } - }) - - brightness_seekbar.setOnSeekBarChangeListener(object : SimpleSeekBarListener() { - override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { - if (fromUser) { - preferences.customBrightnessValue().set(value) - } - } - }) - } - - override fun onStart() { - super.onStart() - sheetBehavior?.skipCollapsed = true - sheetBehavior?.expand() - } - - /** - * Set enabled status of seekBars belonging to color filter - * @param enabled determines if seekBar gets enabled - * @param view view of the dialog - */ - private fun setColorFilterSeekBar(enabled: Boolean, view: View) = with(view) { - seekbar_color_filter_red.isEnabled = enabled - seekbar_color_filter_green.isEnabled = enabled - seekbar_color_filter_blue.isEnabled = enabled - seekbar_color_filter_alpha.isEnabled = enabled - } - - /** - * Set enabled status of seekBars belonging to custom brightness - * @param enabled value which determines if seekBar gets enabled - * @param view view of the dialog - */ - private fun setCustomBrightnessSeekBar(enabled: Boolean, view: View) = with(view) { - brightness_seekbar.isEnabled = enabled - } - - /** - * Set the text value's of color filter - * @param color integer containing color information - * @param view view of the dialog - */ - fun setValues(color: Int, view: View): Array { - val alpha = getAlphaFromColor(color) - val red = getRedFromColor(color) - val green = getGreenFromColor(color) - val blue = getBlueFromColor(color) - - // Initialize values - txt_color_filter_alpha_value.text = alpha.toString() - txt_color_filter_red_value.text = red.toString() - txt_color_filter_green_value.text = green.toString() - txt_color_filter_blue_value.text = blue.toString() - - return arrayOf(alpha, red, green, blue) - } - - /** - * Manages the custom brightness value subscription - * @param enabled determines if the subscription get (un)subscribed - * @param view view of the dialog - */ - private fun setCustomBrightness(enabled: Boolean, view: View) { - if (enabled) { - preferences.customBrightnessValue().asFlow() - .sample(100) - .onEach { setCustomBrightnessValue(it, view) } - .launchIn(activity.scope) - } else { - setCustomBrightnessValue(0, view, true) - } - setCustomBrightnessSeekBar(enabled, view) - } - - /** - * Sets the brightness of the screen. Range is [-75, 100]. - * From -75 to -1 a semi-transparent black view is shown at the top with the minimum brightness. - * From 1 to 100 it sets that value as brightness. - * 0 sets system brightness and hides the overlay. - */ - private fun setCustomBrightnessValue(value: Int, view: View, isDisabled: Boolean = false) = with(view) { - // Set black overlay visibility. - if (value < 0) { - brightness_overlay.visible() - val alpha = (abs(value) * 2.56).toInt() - brightness_overlay.setBackgroundColor(Color.argb(alpha, 0, 0, 0)) - } else { - brightness_overlay.gone() - } - - if (!isDisabled) { - txt_brightness_seekbar_value.text = value.toString() - } - } - - /** - * Manages the color filter value subscription - * @param enabled determines if the subscription get (un)subscribed - * @param view view of the dialog - */ - private fun setColorFilter(enabled: Boolean, view: View) { - if (enabled) { - preferences.colorFilterValue().asFlow() - .sample(100) - .onEach { setColorFilterValue(it, view) } - .launchIn(activity.scope) - } else { - color_overlay.gone() - } - setColorFilterSeekBar(enabled, view) - } - - /** - * Sets the color filter overlay of the screen. Determined by HEX of integer - * @param color hex of color. - * @param view view of the dialog - */ - private fun setColorFilterValue(@ColorInt color: Int, view: View) = with(view) { - color_overlay.visible() - color_overlay.setFilterColor(color, preferences.colorFilterMode().get()) - setValues(color, view) - } - - /** - * Updates the color value in preference - * @param color value of color range [0,255] - * @param mask contains hex mask of chosen color - * @param bitShift amounts of bits that gets shifted to receive value - */ - fun setColorValue(color: Int, mask: Long, bitShift: Int) { - val currentColor = preferences.colorFilterValue().get() - val updatedColor = (color shl bitShift) or (currentColor and mask.inv().toInt()) - preferences.colorFilterValue().set(updatedColor) - } - - /** - * Returns the alpha value from the Color Hex - * @param color color hex as int - * @return alpha of color - */ - fun getAlphaFromColor(color: Int): Int { - return color shr 24 and 0xFF - } - - /** - * Returns the red value from the Color Hex - * @param color color hex as int - * @return red of color - */ - fun getRedFromColor(color: Int): Int { - return color shr 16 and 0xFF - } - - /** - * Returns the green value from the Color Hex - * @param color color hex as int - * @return green of color - */ - fun getGreenFromColor(color: Int): Int { - return color shr 8 and 0xFF - } - - /** - * Returns the blue value from the Color Hex - * @param color color hex as int - * @return blue of color - */ - fun getBlueFromColor(color: Int): Int { - return color and 0xFF - } - - private companion object { - /** Integer mask of alpha value **/ - const val ALPHA_MASK: Long = 0xFF000000 - - /** Integer mask of red value **/ - const val RED_MASK: Long = 0x00FF0000 - - /** Integer mask of green value **/ - const val GREEN_MASK: Long = 0x0000FF00 - - /** Integer mask of blue value **/ - const val BLUE_MASK: Long = 0x000000FF - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderNavGestureDetector.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderNavGestureDetector.kt new file mode 100644 index 0000000000..b664d98c7a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderNavGestureDetector.kt @@ -0,0 +1,69 @@ +package eu.kanade.tachiyomi.ui.reader + +import android.view.GestureDetector +import android.view.MotionEvent +import eu.kanade.tachiyomi.util.view.collapse +import eu.kanade.tachiyomi.util.view.expand +import kotlin.math.abs + +class ReaderNavGestureDetector(private val activity: ReaderActivity) : GestureDetector +.SimpleOnGestureListener() { + + var hasScrollHorizontal = false + private set + var lockVertical = false + private set + + override fun onDown(e: MotionEvent): Boolean { + lockVertical = false + hasScrollHorizontal = false + return false + } + + override fun onScroll( + e1: MotionEvent?, + e2: MotionEvent?, + distanceX: Float, + distanceY: Float + ): Boolean { + val newDistanceX = (e1?.rawX ?: 0f) - (e2?.rawX ?: 0f) + val newDistanceY = (e1?.rawY ?: 0f) - (e2?.rawY ?: 0f) + if ((!hasScrollHorizontal || lockVertical) && e2 != null) { + hasScrollHorizontal = abs(newDistanceX) > abs(newDistanceY) && abs(newDistanceX) > 40 + if (!lockVertical) { + lockVertical = abs(newDistanceX) < abs(newDistanceY) && abs(newDistanceY) > 150 + } + } + return !hasScrollHorizontal && lockVertical + } + + override fun onFling( + e1: MotionEvent, + e2: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + var result = false + val diffY = e2.y - e1.y + val diffX = e2.x - e1.x + val sheetBehavior = activity.binding.chaptersSheet.root.sheetBehavior + if (!hasScrollHorizontal && abs(diffX) < abs(diffY) && + (abs(diffY) > SWIPE_THRESHOLD || abs(velocityY) > SWIPE_VELOCITY_THRESHOLD) && + diffY <= 0 + ) { + lockVertical = true + sheetBehavior?.expand() + result = true + } + + if (!result) { + activity.binding.chaptersSheet.root.sheetBehavior?.collapse() + } + return result + } + + private companion object { + const val SWIPE_THRESHOLD = 50 + const val SWIPE_VELOCITY_THRESHOLD = 100 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderNavigationOverlayView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderNavigationOverlayView.kt new file mode 100644 index 0000000000..197d87bc79 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderNavigationOverlayView.kt @@ -0,0 +1,130 @@ +package eu.kanade.tachiyomi.ui.reader + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.view.ViewPropertyAnimator +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation +import kotlin.math.abs + +class ReaderNavigationOverlayView(context: Context, attributeSet: AttributeSet) : View(context, attributeSet) { + + private var viewPropertyAnimator: ViewPropertyAnimator? = null + + private var navigation: ViewerNavigation? = null + + var isLTR = true + var tappingEnabled = true + + fun setNavigation(navigation: ViewerNavigation, showOnStart: Boolean) { + if (!showOnStart && (this.navigation == null || this.navigation === navigation)) { + if (this.navigation == null) { + this.navigation = navigation + isVisible = false + } + return + } + + this.navigation = navigation + showNavigationAgain() + } + + fun showNavigationAgain() { + invalidate() + + if (isVisible || !tappingEnabled) return + + viewPropertyAnimator = animate() + .alpha(1f) + .setDuration(FADE_DURATION) + .withStartAction { + isVisible = true + } + .withEndAction { + viewPropertyAnimator = null + } + viewPropertyAnimator?.start() + } + + private val regionPaint = Paint() + + private val textPaint = Paint().apply { + textAlign = Paint.Align.CENTER + color = Color.WHITE + textSize = 64f + } + + private val textBorderPaint = Paint().apply { + textAlign = Paint.Align.CENTER + color = Color.BLACK + textSize = 64f + style = Paint.Style.STROKE + strokeWidth = 8f + } + + override fun onDraw(canvas: Canvas?) { + if (navigation == null) return + + navigation?.regions?.forEach { + val region = it.invert(navigation!!.invertMode) + val rect = region.rectF + + canvas?.save() + + // Scale rect from 1f,1f to screen width and height + canvas?.scale(width.toFloat(), height.toFloat()) + val directionalRegion = region.type.directionalRegion(isLTR) + regionPaint.color = ContextCompat.getColor(context, directionalRegion.colorRes) + canvas?.drawRect(rect, regionPaint) + + canvas?.restore() + // Don't want scale anymore because it messes with drawText + canvas?.save() + + // Translate origin to rect start (left, top) + canvas?.translate((width * rect.left), (height * rect.top)) + + // Calculate center of rect width on screen + val x = width * (abs(rect.left - rect.right) / 2) + + // Calculate center of rect height on screen + val y = height * (abs(rect.top - rect.bottom) / 2) + + canvas?.drawText(context.getString(directionalRegion.nameRes), x, y, textBorderPaint) + canvas?.drawText(context.getString(directionalRegion.nameRes), x, y, textPaint) + + canvas?.restore() + } + } + + override fun performClick(): Boolean { + super.performClick() + + if (viewPropertyAnimator == null && isVisible) { + viewPropertyAnimator = animate() + .alpha(0f) + .setDuration(FADE_DURATION) + .withEndAction { + isVisible = false + viewPropertyAnimator = null + } + viewPropertyAnimator?.start() + } + + return true + } + + override fun onTouchEvent(event: MotionEvent?): Boolean { + // Hide overlay if user start tapping or swiping + performClick() + return super.onTouchEvent(event) + } +} + +private const val FADE_DURATION = 1000L diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt index 74c7e8806c..24f6af98ba 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt @@ -1,28 +1,29 @@ package eu.kanade.tachiyomi.ui.reader import android.app.Application +import android.graphics.BitmapFactory import android.os.Bundle import android.os.Environment +import androidx.annotation.ColorInt import com.elvishew.xlog.XLog import com.jakewharton.rxrelay.BehaviorRelay +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.History import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.MangaImpl -import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.filterIfUsingCache -import eu.kanade.tachiyomi.data.database.models.isWebtoon +import eu.kanade.tachiyomi.data.database.models.isLongStrip import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.Page -import eu.kanade.tachiyomi.source.online.utils.FollowStatus import eu.kanade.tachiyomi.source.online.utils.MdUtil import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.reader.chapter.ReaderChapterItem @@ -32,12 +33,15 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters import eu.kanade.tachiyomi.util.chapter.ChapterFilter import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource -import eu.kanade.tachiyomi.util.log.XLogLevel import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.executeOnIO +import eu.kanade.tachiyomi.util.system.launchUI +import eu.kanade.tachiyomi.util.system.withUIContext +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import rx.Completable @@ -58,9 +62,9 @@ class ReaderPresenter( private val db: DatabaseHelper = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(), + private val coverCache: CoverCache = Injekt.get(), private val preferences: PreferencesHelper = Injekt.get(), private val chapterFilter: ChapterFilter = Injekt.get() - ) : BasePresenter() { /** @@ -89,6 +93,9 @@ class ReaderPresenter( */ private val viewerChaptersRelay = BehaviorRelay.create() + val viewerChapters: ViewerChapters? + get() = viewerChaptersRelay.value + /** * Relay used when loading prev/next chapter needed to lock the UI (with a dialog). */ @@ -106,20 +113,26 @@ class ReaderPresenter( ?: error("Requested chapter of id $chapterId not found in chapter list") val chaptersForReader = - chapterFilter.filterChaptersForReader(dbChapters, manga, selectedChapter).filterIfUsingCache(downloadManager, manga, preferences.useCacheSource()) - XLog.d("--chapter list--") + chapterFilter.filterChaptersForReader(dbChapters, manga, selectedChapter) + when (manga.sorting) { Manga.SORTING_SOURCE -> ChapterLoadBySource().get(chaptersForReader) Manga.SORTING_NUMBER -> ChapterLoadByNumber().get(chaptersForReader, selectedChapter) else -> error("Unknown sorting method") - }.map { it -> - XLog.d("chapterList lazy ${it.chapterLog()}") - ReaderChapter(it) - } + }.map(::ReaderChapter) } var chapterItems = emptyList() + private var scope = CoroutineScope(Job() + Dispatchers.Default) + + private var hasTrackers: Boolean = false + private val checkTrackers: (Manga) -> Unit = { manga -> + val tracks = db.getTracks(manga).executeAsBlocking() + + hasTrackers = tracks.size > 0 + } + /** * Called when the presenter is created. It retrieves the saved active chapter if the process * was restored. @@ -206,7 +219,9 @@ class ReaderPresenter( } }.map { ReaderChapterItem( - it, manga, it.id == getCurrentChapter()?.chapter?.id ?: chapterId + it, + manga, + it.id == getCurrentChapter()?.chapter?.id ?: chapterId ) } if (!manga.sortDescending(preferences.chaptersDescAsDefault().getOrDefault())) { @@ -215,13 +230,25 @@ class ReaderPresenter( list } } - if (XLogLevel.shouldLog(XLogLevel.EXTRA)) { - XLog.d("chapter items") - chapterItems.forEach { XLog.d(it.chapter.name) } - } + return chapterItems } + /** + * Removes all filters and requests an UI update. + */ + fun setFilters(read: Boolean, unread: Boolean, downloaded: Boolean, bookmarked: Boolean) { + val manga = manga ?: return + manga.readFilter = when { + read -> Manga.SHOW_READ + unread -> Manga.SHOW_UNREAD + else -> Manga.SHOW_ALL + } + manga.downloadedFilter = if (downloaded) Manga.SHOW_DOWNLOADED else Manga.SHOW_ALL + manga.bookmarkedFilter = if (bookmarked) Manga.SHOW_BOOKMARKED else Manga.SHOW_ALL + db.updateFlags(manga).executeAsBlocking() + } + /** * Initializes this presenter with the given [manga] and [initialChapterId]. This method will * set the chapter loader, view subscriptions and trigger an initial load. @@ -232,8 +259,12 @@ class ReaderPresenter( this.manga = manga if (chapterId == -1L) chapterId = initialChapterId + checkTrackers(manga) + NotificationReceiver.dismissNotification( - preferences.context, manga.id!!.hashCode(), Notifications.ID_NEW_CHAPTERS + preferences.context, + manga.id!!.hashCode(), + Notifications.ID_NEW_CHAPTERS ) loader = ChapterLoader(downloadManager, manga, sourceManager) @@ -272,13 +303,7 @@ class ReaderPresenter( .andThen( Observable.fromCallable { val chapterPos = chapterList.indexOf(chapter) - if (XLogLevel.shouldLog(XLogLevel.EXTRA)) { - XLog.disableStackTrace().d("ChapterList in loadObservable") - XLog.disableStackTrace().d("chapter position $chapterPos") - chapterList.forEach { - XLog.disableStackTrace().d(it.urlAndName() + " source order: " + it.chapter.source_order) - } - } + ViewerChapters( chapter, chapterList.getOrNull(chapterPos - 1), @@ -288,25 +313,12 @@ class ReaderPresenter( ) .observeOn(AndroidSchedulers.mainThread()) .doOnNext { newChapters -> - val oldChapters = viewerChaptersRelay.value - XLog.disableStackTrace().d("loadObservable oldChapters previousChapter %s", oldChapters?.prevChapter?.urlAndName()) - XLog.disableStackTrace().d("loadObservable oldChapters currentChapter %s", oldChapters?.currChapter?.urlAndName()) - XLog.disableStackTrace().d("loadObservable oldChapters nextChapter %s", oldChapters?.nextChapter?.urlAndName()) - - XLog.disableStackTrace().d("loadObservable newChapters previousChapter %s", newChapters?.prevChapter?.urlAndName()) - XLog.disableStackTrace().d("loadObservable newChapters currentChapter %s", newChapters?.currChapter?.urlAndName()) - XLog.disableStackTrace().d("loadObservable newChapters nextChapter %s", newChapters?.nextChapter?.urlAndName()) - // Add new references first to avoid unnecessary recycling newChapters.ref() oldChapters?.unref() - XLog.disableStackTrace().d("loadObservable newChapters afterRef previousChapter %s", newChapters.prevChapter?.urlAndName()) - XLog.disableStackTrace().d("loadObservable newChapters afterRef currentChapter %s", newChapters.currChapter.urlAndName()) - XLog.disableStackTrace().d("loadObservable newChapters afterRef nextChapter %s", newChapters.nextChapter?.urlAndName()) - viewerChaptersRelay.call(newChapters) } } @@ -381,13 +393,15 @@ class ReaderPresenter( XLog.d("Loading ${chapter.url}") activeChapterSubscription?.unsubscribe() + val lastPage = if (chapter.pages_left <= 1) 0 else chapter.last_page_read activeChapterSubscription = getLoadObservable(loader, ReaderChapter(chapter)) .doOnSubscribe { isLoadingAdjacentChapterRelay.call(true) } .doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) } .subscribeFirst( { view, _ -> - val lastPage = if (chapter.pages_left <= 1) 0 else chapter.last_page_read - view.moveToPageIndex(lastPage) + scope.launchUI { + view.moveToPageIndex(lastPage, false) + } view.refreshChapters() }, { _, _ -> @@ -423,13 +437,31 @@ class ReaderPresenter( .also(::add) } + /** + * Called from the activity to load and set the next chapter as active. + */ + fun loadNextChapter(): Boolean { + val nextChapter = viewerChaptersRelay.value?.nextChapter ?: return false + loadChapter(nextChapter.chapter) + return true + } + + /** + * Called from the activity to load and set the previous chapter as active. + */ + fun loadPreviousChapter(): Boolean { + val prevChapter = viewerChaptersRelay.value?.prevChapter ?: return false + loadChapter(prevChapter.chapter) + return true + } + /** * Called every time a page changes on the reader. Used to mark the flag of chapters being * read, update tracking services, enqueue downloaded chapter deletion, and updating the active chapter if this * [page]'s chapter is different from the currently active. */ - fun onPageSelected(page: ReaderPage): Boolean { - val currentChapters = viewerChaptersRelay.value ?: return false + fun onPageSelected(page: ReaderPage, hasExtraPage: Boolean) { + val currentChapters = viewerChaptersRelay.value ?: return val selectedChapter = page.chapter @@ -437,7 +469,14 @@ class ReaderPresenter( selectedChapter.chapter.last_page_read = page.index selectedChapter.chapter.pages_left = (selectedChapter.pages?.size ?: page.index) - page.index - if (selectedChapter.pages?.lastIndex == page.index) { + val shouldTrack = !preferences.incognitoMode().get() || hasTrackers + if (shouldTrack && + // For double pages, check if the second to last page is doubled up + ( + selectedChapter.pages?.lastIndex == page.index || + (hasExtraPage && selectedChapter.pages?.lastIndex?.minus(1) == page.index) + ) + ) { selectedChapter.chapter.read = true updateTrackChapterRead(selectedChapter) deleteChapterIfNeeded(selectedChapter) @@ -446,9 +485,7 @@ class ReaderPresenter( if (selectedChapter != currentChapters.currChapter) { onChapterChanged(currentChapters.currChapter) loadNewChapter(selectedChapter) - return true } - return false } /** @@ -480,20 +517,25 @@ class ReaderPresenter( /** * Saves this [chapter] progress (last read page and whether it's read). + * If incognito mode isn't on or has at least 1 tracker */ private fun saveChapterProgress(chapter: ReaderChapter) { db.getChapter(chapter.chapter.id!!).executeAsBlocking()?.let { dbChapter -> chapter.chapter.bookmark = dbChapter.bookmark } - db.updateChapterProgress(chapter.chapter).executeAsBlocking() + if (!preferences.incognitoMode().get() || hasTrackers) { + db.updateChapterProgress(chapter.chapter).executeAsBlocking() + } } /** * Saves this [chapter] last read history. */ private fun saveChapterHistory(chapter: ReaderChapter) { - val history = History.create(chapter.chapter).apply { last_read = Date().time } - db.updateHistoryLastRead(history).executeAsBlocking() + if (!preferences.incognitoMode().get()) { + val history = History.create(chapter.chapter).apply { last_read = Date().time } + db.updateHistoryLastRead(history).executeAsBlocking() + } } /** @@ -517,7 +559,10 @@ class ReaderPresenter( val default = preferences.defaultViewer() val manga = manga ?: return default val readerType = manga.defaultReaderType() - if (manga.viewer == -1 || (readerType == ReaderActivity.WEBTOON && readerType != manga.viewer)) { + if (manga.viewer == -1 || + // Force webtoon mode + (manga.isLongStrip() && readerType != manga.viewer) + ) { val cantSwitchToLTR = (readerType == ReaderActivity.LEFT_TO_RIGHT && default != ReaderActivity.RIGHT_TO_LEFT) manga.viewer = if (cantSwitchToLTR) 0 else readerType @@ -527,7 +572,7 @@ class ReaderPresenter( val viewer = if (manga.viewer == 0) preferences.defaultViewer() else manga.viewer return when { - !manga.isWebtoon() && viewer == ReaderActivity.WEBTOON -> ReaderActivity.VERTICAL_PLUS + !manga.isLongStrip() && viewer == ReaderActivity.WEBTOON -> ReaderActivity.VERTICAL_PLUS else -> viewer } } @@ -618,6 +663,40 @@ class ReaderPresenter( return builder.toString().trim() } + /** + * Saves the image of this [page] in the given [directory] and returns the file location. + */ + private fun saveImages(page1: ReaderPage, page2: ReaderPage, isLTR: Boolean, @ColorInt bg: Int, directory: File, manga: Manga): File { + val stream1 = page1.stream!! + ImageUtil.findImageType(stream1) ?: throw Exception("Not an image") + val stream2 = page2.stream!! + ImageUtil.findImageType(stream2) ?: throw Exception("Not an image") + val imageBytes = stream1().readBytes() + val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) + + val imageBytes2 = stream2().readBytes() + val imageBitmap2 = BitmapFactory.decodeByteArray(imageBytes2, 0, imageBytes2.size) + + val stream = ImageUtil.mergeBitmaps(imageBitmap, imageBitmap2, isLTR, bg) + directory.mkdirs() + + val chapter = page1.chapter.chapter + + // Build destination file. + val filename = DiskUtil.buildValidFilename( + "${manga.title} - ${chapter.name}".take(225) + ) + " - ${page1.number}-${page2.number}.jpg" + + val destFile = File(directory, filename) + stream.use { input -> + destFile.outputStream().use { output -> + input.copyTo(output) + } + } + stream.close() + return destFile + } + /** * Saves the image of this [page] on the pictures directory and notifies the UI of the result. * There's also a notification to allow sharing the image somewhere else or deleting it. @@ -631,11 +710,14 @@ class ReaderPresenter( notifier.onClear() // Pictures directory. - val destDir = File( - Environment.getExternalStorageDirectory().absolutePath + - File.separator + Environment.DIRECTORY_PICTURES + - File.separator + "Neko" - ) + val baseDir = Environment.getExternalStorageDirectory().absolutePath + + File.separator + Environment.DIRECTORY_PICTURES + + File.separator + context.getString(R.string.app_name) + val destDir = if (preferences.folderPerManga()) { + File(baseDir + File.separator + manga.title) + } else { + File(baseDir) + } // Copy file in background. Observable.fromCallable { saveImage(page, destDir, manga) } @@ -652,6 +734,34 @@ class ReaderPresenter( ) } + fun saveImages(firstPage: ReaderPage, secondPage: ReaderPage, isLTR: Boolean, @ColorInt bg: Int) { + scope.launch { + if (firstPage.status != Page.READY) return@launch + if (secondPage.status != Page.READY) return@launch + val manga = manga ?: return@launch + val context = Injekt.get() + + val notifier = SaveImageNotifier(context) + notifier.onClear() + + // Pictures directory. + val destDir = File( + Environment.getExternalStorageDirectory().absolutePath + + File.separator + Environment.DIRECTORY_PICTURES + + File.separator + context.getString(R.string.app_name) + ) + + try { + val file = saveImages(firstPage, secondPage, isLTR, bg, destDir, manga) + DiskUtil.scanMedia(context, file) + notifier.onComplete(file) + withUIContext { view?.onSaveImageResult(SaveImageResult.Success(file)) } + } catch (e: Exception) { + withUIContext { view?.onSaveImageResult(SaveImageResult.Error(e)) } + } + } + } + /** * Shares the image of this [page] and notifies the UI with the path of the file to share. * The image must be first copied to the internal partition because there are many possible @@ -676,6 +786,57 @@ class ReaderPresenter( ) } + fun shareImages(firstPage: ReaderPage, secondPage: ReaderPage, isLTR: Boolean, @ColorInt bg: Int) { + scope.launch { + if (firstPage.status != Page.READY) return@launch + if (secondPage.status != Page.READY) return@launch + val manga = manga ?: return@launch + val context = Injekt.get() + + val destDir = File(context.cacheDir, "shared_image") + destDir.deleteRecursively() + try { + val file = saveImages(firstPage, secondPage, isLTR, bg, destDir, manga) + withUIContext { + view?.onShareImageResult(file, firstPage, secondPage) + } + } catch (e: Exception) { + } + } + } + + /** + * Sets the image of this [page] as cover and notifies the UI of the result. + */ + fun setAsCover(page: ReaderPage) { + if (page.status != Page.READY) return + val manga = manga ?: return + val stream = page.stream ?: return + + Observable + .fromCallable { + if (manga.favorite) { + coverCache.setCustomCoverToCache(manga, stream()) + SetAsCoverResult.Success + } else { + SetAsCoverResult.AddToLibraryFirst + } + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeFirst( + { view, result -> view.onSetAsCoverResult(result) }, + { view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) } + ) + } + + /** + * Results of the set as cover feature. + */ + enum class SetAsCoverResult { + Success, AddToLibraryFirst, Error + } + /** * Results of the save image feature. */ @@ -702,12 +863,11 @@ class ReaderPresenter( val trackList = db.getTracks(manga).executeAsBlocking() trackList.map { track -> val service = trackManager.getService(track.sync_id) - - if (shouldUpdateTracker(service, chapterRead, track)) { + if (service != null && service.isLogged && chapterRead > track.last_chapter_read) { try { track.last_chapter_read = chapterRead - val updatedTrack = service!!.update(track) - db.insertTrack(updatedTrack).executeAsBlocking() + service.update(track, true) + db.insertTrack(track).executeAsBlocking() } catch (e: Exception) { XLog.e(e) } @@ -717,20 +877,6 @@ class ReaderPresenter( } } - private fun shouldUpdateTracker( - service: TrackService?, - chapterRead: Int, - track: Track - ): Boolean { - if (service == null || !service.isLogged || chapterRead <= track.last_chapter_read) { - return false - } - if (service.isMdList() && track.status == FollowStatus.UNFOLLOWED.int) { - return false - } - return true - } - /** * Enqueues this [chapter] to be deleted when [deletePendingChapters] is called. The download * manager handles persisting it across process deaths. @@ -758,39 +904,4 @@ class ReaderPresenter( .subscribeOn(Schedulers.io()) .subscribe() } - - /** - * Called from the activity to load and set the next chapter as active. - */ - fun loadNextChapter() { - val nextChapter = viewerChaptersRelay.value?.nextChapter ?: return - loadAdjacent(nextChapter) - } - - /** - * Called from the activity to load and set the previous chapter as active. - */ - fun loadPreviousChapter() { - val prevChapter = viewerChaptersRelay.value?.prevChapter ?: return - loadAdjacent(prevChapter) - } - - private fun loadAdjacent(chapter: ReaderChapter) { - val loader = loader ?: return - - XLog.d("Loading adjacent ${chapter.chapter.url}") - - activeChapterSubscription?.unsubscribe() - activeChapterSubscription = getLoadObservable(loader, chapter) - .doOnSubscribe { isLoadingAdjacentChapterRelay.call(true) } - .doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) } - .subscribeFirst( - { view, _ -> - view.moveToPageIndex(0) - }, - { _, _ -> - // Ignore onError event, viewers handle that state - } - ) - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsSheet.kt deleted file mode 100644 index 840f3cde58..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderSettingsSheet.kt +++ /dev/null @@ -1,190 +0,0 @@ -package eu.kanade.tachiyomi.ui.reader - -import android.content.res.Configuration -import android.graphics.Color -import android.os.Build -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import android.widget.CompoundButton -import android.widget.Spinner -import androidx.annotation.ArrayRes -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.tfcporciuncula.flow.Preference -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerViewer -import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer -import eu.kanade.tachiyomi.util.system.dpToPx -import eu.kanade.tachiyomi.util.system.hasSideNavBar -import eu.kanade.tachiyomi.util.system.isInNightMode -import eu.kanade.tachiyomi.util.view.gone -import eu.kanade.tachiyomi.util.view.setBottomEdge -import eu.kanade.tachiyomi.util.view.setEdgeToEdge -import eu.kanade.tachiyomi.util.view.visible -import eu.kanade.tachiyomi.util.view.visibleIf -import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener -import kotlinx.android.synthetic.main.reader_settings_sheet.* -import uy.kohesive.injekt.injectLazy -import kotlin.math.max - -/** - * Sheet to show reader and viewer preferences. - */ -class ReaderSettingsSheet(private val activity: ReaderActivity) : - BottomSheetDialog(activity, R.style.BottomSheetDialogTheme) { - - /** - * Preferences helper. - */ - private val preferences by injectLazy() - - private var sheetBehavior: BottomSheetBehavior<*> - - init { - // Use activity theme for this layout - val view = activity.layoutInflater.inflate(R.layout.reader_settings_sheet, null) - setContentView(view) - - sheetBehavior = BottomSheetBehavior.from(view.parent as ViewGroup) - setEdgeToEdge( - activity, view, - if (context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) - 0 else -1 - ) - window?.navigationBarColor = Color.TRANSPARENT - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - !context.isInNightMode() && - !activity.window.decorView.rootWindowInsets.hasSideNavBar() - ) { - window?.decorView?.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR - } - - val height = activity.window.decorView.rootWindowInsets.systemWindowInsetBottom - sheetBehavior.peekHeight = 450.dpToPx + height - - sheetBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, progress: Float) { - if (progress.isNaN()) - pill.alpha = 0f - else - pill.alpha = (1 - max(0f, progress)) * 0.25f - } - - override fun onStateChanged(p0: View, state: Int) { - if (state == BottomSheetBehavior.STATE_EXPANDED) { - sheetBehavior.skipCollapsed = true - } - } - }) - } - - /** - * Called when the sheet is created. It initializes the listeners and values of the preferences. - */ - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - initGeneralPreferences() - - when (activity.viewer) { - is PagerViewer -> initPagerPreferences() - is WebtoonViewer -> initWebtoonPreferences() - } - - setBottomEdge(constraint_layout, activity) - - close_button.setOnClickListener { - dismiss() - } - settings_scroll_view.viewTreeObserver.addOnGlobalLayoutListener { - val isScrollable = - settings_scroll_view.height < constraint_layout.height + - settings_scroll_view.paddingTop + settings_scroll_view.paddingBottom - close_button.visibleIf(isScrollable) - pill.visibleIf(!isScrollable) - } - } - - /** - * Init general reader preferences. - */ - private fun initGeneralPreferences() { - viewer.onItemSelectedListener = IgnoreFirstSpinnerListener { position -> - activity.presenter.setMangaViewer(position) - - val mangaViewer = activity.presenter.getMangaViewer() - if (mangaViewer == ReaderActivity.WEBTOON || mangaViewer == ReaderActivity.VERTICAL_PLUS) { - initWebtoonPreferences() - } else { - initPagerPreferences() - } - } - viewer.setSelection(activity.presenter.manga?.viewer ?: 0, false) - - rotation_mode.bindToPreference(preferences.rotation(), 1) - background_color.bindToPreference(preferences.readerTheme(), 0) - show_page_number.bindToPreference(preferences.showPageNumber()) - fullscreen.bindToPreference(preferences.fullscreen()) - keepscreen.bindToPreference(preferences.keepScreenOn()) - always_show_chapter_transition.bindToPreference(preferences.alwaysShowChapterTransition()) - } - - /** - * Init the preferences for the pager reader. - */ - private fun initPagerPreferences() { - pager_prefs_group.visible() - webtoon_prefs_group.gone() - scale_type.bindToPreference(preferences.imageScaleType(), 1) - zoom_start.bindToPreference(preferences.zoomStart(), 1) - crop_borders.bindToPreference(preferences.cropBorders()) - page_transitions.bindToPreference(preferences.pageTransitions()) - } - - /** - * Init the preferences for the webtoon reader. - */ - private fun initWebtoonPreferences() { - webtoon_prefs_group.visible() - pager_prefs_group.gone() - crop_borders_webtoon.bindToPreference(preferences.cropBordersWebtoon()) - webtoon_side_padding.bindToIntPreference(preferences.webtoonSidePadding(), R.array.webtoon_side_padding_values) - webtoon_enable_zoom_out.bindToPreference(preferences.webtoonEnableZoomOut()) - } - - /** - * Binds a checkbox or switch view with a boolean preference. - */ - private fun CompoundButton.bindToPreference(pref: Preference) { - isChecked = pref.get() - setOnCheckedChangeListener { _, isChecked -> pref.set(isChecked) } - } - - /** - * Binds a spinner to an int preference with an optional offset for the value. - */ - private fun Spinner.bindToPreference( - pref: Preference, - offset: Int = 0 - ) { - onItemSelectedListener = IgnoreFirstSpinnerListener { position -> - pref.set(position + offset) - } - setSelection(pref.get() - offset, false) - } - - /** - * Binds a spinner to an int preference. The position of the spinner item must - * correlate with the [intValues] resource item (in arrays.xml), which is a - * of int values that will be parsed here and applied to the preference. - */ - private fun Spinner.bindToIntPreference(pref: Preference, @ArrayRes intValuesResource: Int) { - val intValues = resources.getStringArray(intValuesResource).map { it.toIntOrNull() } - onItemSelectedListener = IgnoreFirstSpinnerListener { position -> - pref.set(intValues[position] ?: 0) - } - setSelection(intValues.indexOf(pref.get()), false) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt index 7fb343d216..cf609f52b5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/SaveImageNotifier.kt @@ -38,7 +38,6 @@ class SaveImageNotifier(private val context: Context) { * @param file image file containing downloaded page image. */ fun onComplete(file: File) { - val request = ImageRequest.Builder(context).memoryCachePolicy(CachePolicy.DISABLED).diskCachePolicy(CachePolicy.DISABLED) .data(file) .size(720, 1280) @@ -64,8 +63,7 @@ class SaveImageNotifier(private val context: Context) { setAutoCancel(true) color = ContextCompat.getColor(context, R.color.colorAccent) // Clear old actions if they exist - if (mActions.isNotEmpty()) - mActions.clear() + clearActions() setContentIntent(NotificationHandler.openImagePendingActivity(context, file)) // Share action diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/chapter/ReaderChapterItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/chapter/ReaderChapterItem.kt index 836afc1304..ad781d65cf 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/chapter/ReaderChapterItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/chapter/ReaderChapterItem.kt @@ -1,19 +1,17 @@ package eu.kanade.tachiyomi.ui.reader.chapter +import android.graphics.Typeface import android.view.View -import android.widget.FrameLayout -import android.widget.ImageView -import android.widget.TextView import androidx.core.content.res.ResourcesCompat import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.view.isVisible import com.mikepenz.fastadapter.FastAdapter import com.mikepenz.fastadapter.items.AbstractItem import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.databinding.ReaderChapterItemBinding import eu.kanade.tachiyomi.util.chapter.ChapterUtil -import eu.kanade.tachiyomi.util.view.gone -import eu.kanade.tachiyomi.util.view.visible import java.text.DecimalFormat import java.text.DecimalFormatSymbols @@ -37,22 +35,16 @@ class ReaderChapterItem(val chapter: Chapter, val manga: Manga, val isCurrent: B } class ViewHolder(view: View) : FastAdapter.ViewHolder(view) { - private var chapterTitle: TextView = view.findViewById(R.id.chapter_title) - private var chapterSubtitle: TextView = view.findViewById(R.id.chapter_scanlator) - private var chapterLanguage: TextView = view.findViewById(R.id.chapter_language) - var bookmarkButton: FrameLayout = view.findViewById(R.id.bookmark_layout) - private var bookmarkImage: ImageView = view.findViewById(R.id.bookmark_image) + val binding = ReaderChapterItemBinding.bind(view) override fun bindView(item: ReaderChapterItem, payloads: List) { val manga = item.manga - val chapterColor = - if (item.isCurrent) itemView.context.getColor(R.color.neko_green_darker) - else ChapterUtil.chapterColor(itemView.context, item.chapter) + val chapterColor = ChapterUtil.chapterColor(itemView.context, item.chapter) val typeface = if (item.isCurrent) ResourcesCompat.getFont(itemView.context, R.font.metropolis_bold_italic) else null - chapterTitle.text = when (manga.displayMode) { + binding.chapterTitle.text = when (manga.displayMode) { Manga.DISPLAY_NUMBER -> { val number = item.decimalFormat.format(item.chapter_number.toDouble()) itemView.context.getString(R.string.chapter_, number) @@ -62,39 +54,46 @@ class ReaderChapterItem(val chapter: Chapter, val manga: Manga, val isCurrent: B val statuses = mutableListOf() ChapterUtil.relativeDate(item)?.let { statuses.add(it) } - item.scanlator?.isNotBlank()?.let { statuses.add(item.scanlator!!) } + item.scanlator?.takeIf { it.isNotBlank() }?.let { statuses.add(item.scanlator ?: "") } + + if (item.isCurrent) { + binding.chapterTitle.setTypeface(null, Typeface.BOLD_ITALIC) + binding.chapterSubtitle.setTypeface(null, Typeface.BOLD_ITALIC) + } else { + binding.chapterTitle.setTypeface(null, Typeface.NORMAL) + binding.chapterSubtitle.setTypeface(null, Typeface.NORMAL) + } if (item.chapter.language.isNullOrBlank() || item.chapter.language.equals("english", true)) { - chapterLanguage.gone() + binding.chapterLanguage.isVisible = false } else { - chapterLanguage.visible() - chapterLanguage.text = item.chapter.language + binding.chapterLanguage.isVisible = true + binding.chapterLanguage.text = item.chapter.language } // match color of the chapter title - chapterTitle.setTextColor(chapterColor) - chapterSubtitle.setTextColor(chapterColor) - chapterLanguage.setTextColor(chapterColor) + binding.chapterTitle.setTextColor(chapterColor) + binding.chapterSubtitle.setTextColor(chapterColor) + binding.chapterLanguage.setTextColor(chapterColor) - bookmarkImage.setImageResource( + binding.bookmarkImage.setImageResource( if (item.bookmark) R.drawable.ic_bookmark_24dp else R.drawable.ic_bookmark_border_24dp ) val drawableColor = ChapterUtil.bookmarkColor(itemView.context, item) - DrawableCompat.setTint(bookmarkImage.drawable, drawableColor) - - chapterTitle.setTypeface(typeface) - chapterSubtitle.setTypeface(typeface) - chapterLanguage.setTypeface(typeface) - chapterSubtitle.text = statuses.joinToString(" • ") + DrawableCompat.setTint(binding.bookmarkImage.drawable, drawableColor) + binding.chapterTitle.setTypeface(typeface) + binding.chapterSubtitle.setTypeface(typeface) + binding.chapterLanguage.setTypeface(typeface) + binding.chapterSubtitle.text = statuses.joinToString(" • ") } override fun unbindView(item: ReaderChapterItem) { - chapterTitle.text = null - chapterSubtitle.text = null - chapterLanguage.text = null + binding.chapterTitle.text = null + binding.chapterSubtitle.text = null + binding.chapterLanguage.text = null } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/chapter/ReaderChapterSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/chapter/ReaderChapterSheet.kt index 2d69fe1a5b..bafd419248 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/chapter/ReaderChapterSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/chapter/ReaderChapterSheet.kt @@ -7,6 +7,8 @@ import android.util.AttributeSet import android.view.View import android.widget.LinearLayout import androidx.core.graphics.ColorUtils +import androidx.core.view.isInvisible +import androidx.core.view.isVisible import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -14,13 +16,17 @@ import com.mikepenz.fastadapter.FastAdapter import com.mikepenz.fastadapter.adapters.ItemAdapter import com.mikepenz.fastadapter.listeners.ClickEventHook import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.ReaderChaptersSheetBinding import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.ReaderPresenter import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.launchUI +import eu.kanade.tachiyomi.util.view.collapse +import eu.kanade.tachiyomi.util.view.expand +import eu.kanade.tachiyomi.util.view.isCollapsed import eu.kanade.tachiyomi.util.view.isExpanded -import kotlinx.android.synthetic.main.reader_chapters_sheet.view.* +import kotlin.math.abs import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt @@ -32,89 +38,140 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr lateinit var presenter: ReaderPresenter var adapter: FastAdapter? = null private val itemAdapter = ItemAdapter() - var shouldCollapse = true var selectedChapterId = -1L + var loadingPos = 0 + lateinit var binding: ReaderChaptersSheetBinding + + override fun onFinishInflate() { + super.onFinishInflate() + binding = ReaderChaptersSheetBinding.bind(this) + } + fun setup(activity: ReaderActivity) { presenter = activity.presenter val fullPrimary = activity.getResourceColor(R.attr.colorSecondary) + val primary = ColorUtils.setAlphaComponent(fullPrimary, 200) sheetBehavior = BottomSheetBehavior.from(this) - - sheetBehavior?.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, progress: Float) { - pill.alpha = (1 - max(0f, progress)) * 0.25f - val trueProgress = max(progress, 0f) - backgroundTintList = - ColorStateList.valueOf(lerpColor(primary, fullPrimary, trueProgress)) - chapter_recycler.alpha = trueProgress - if (activity.sheetManageNavColor && progress > 0f) { - activity.window.navigationBarColor = - lerpColor(ColorUtils.setAlphaComponent(primary, 0), primary, trueProgress) - } + binding.chaptersButton.setOnClickListener { + if (sheetBehavior.isExpanded()) { + sheetBehavior?.collapse() + } else { + sheetBehavior?.expand() } + } + + post { + binding.chapterRecycler.alpha = if (sheetBehavior.isExpanded()) 1f else 0f + binding.chapterRecycler.isClickable = sheetBehavior.isExpanded() + binding.chapterRecycler.isFocusable = sheetBehavior.isExpanded() + activity.binding.readerNav.root.isVisible = sheetBehavior.isCollapsed() + } - override fun onStateChanged(p0: View, state: Int) { - if (state == BottomSheetBehavior.STATE_COLLAPSED) { - shouldCollapse = true - sheetBehavior?.isHideable = false - (chapter_recycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset( - adapter?.getPosition(presenter.getCurrentChapter()?.chapter?.id ?: 0L) ?: 0, - chapter_recycler.height / 2 - 30.dpToPx - ) + sheetBehavior?.addBottomSheetCallback( + object : BottomSheetBehavior.BottomSheetCallback() { + override fun onSlide(bottomSheet: View, progress: Float) { + binding.pill.alpha = (1 - max(0f, progress)) * 0.25f + val trueProgress = max(progress, 0f) + activity.binding.readerNav.root.alpha = (1 - abs(progress)).coerceIn(0f, 1f) + backgroundTintList = + ColorStateList.valueOf(lerpColor(primary, fullPrimary, trueProgress)) + binding.chapterRecycler.alpha = trueProgress + if (activity.sheetManageNavColor && progress > 0f) { + activity.window.navigationBarColor = + lerpColor(ColorUtils.setAlphaComponent(primary, 0), primary, trueProgress) + } } - if (state == BottomSheetBehavior.STATE_EXPANDED) { - chapter_recycler.alpha = 1f - if (activity.sheetManageNavColor) activity.window.navigationBarColor = primary + + override fun onStateChanged(p0: View, state: Int) { + if (state == BottomSheetBehavior.STATE_COLLAPSED) { + sheetBehavior?.isHideable = false + (binding.chapterRecycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset( + adapter?.getPosition(presenter.getCurrentChapter()?.chapter?.id ?: 0L) ?: 0, + binding.chapterRecycler.height / 2 - 30.dpToPx + ) + activity.binding.readerNav.root.isVisible = true + activity.binding.readerNav.root.alpha = 1f + } + if (state == BottomSheetBehavior.STATE_DRAGGING || state == BottomSheetBehavior.STATE_SETTLING) { + activity.binding.readerNav.root.isVisible = true + } + if (state == BottomSheetBehavior.STATE_EXPANDED) { + activity.binding.readerNav.root.isInvisible = true + activity.binding.readerNav.root.alpha = 0f + binding.chapterRecycler.alpha = 1f + if (activity.sheetManageNavColor) activity.window.navigationBarColor = primary + } + if (state == BottomSheetBehavior.STATE_HIDDEN) { + activity.binding.readerNav.root.alpha = 0f + activity.binding.readerNav.root.isInvisible = true + } + binding.chapterRecycler.isClickable = state == BottomSheetBehavior.STATE_EXPANDED + binding.chapterRecycler.isFocusable = state == BottomSheetBehavior.STATE_EXPANDED } - chapter_recycler.isClickable = state == BottomSheetBehavior.STATE_EXPANDED - chapter_recycler.isFocusable = state == BottomSheetBehavior.STATE_EXPANDED } - }) + ) adapter = FastAdapter.with(itemAdapter) - chapter_recycler.adapter = adapter - adapter?.onClickListener = { _, _, item, _ -> - if (!sheetBehavior.isExpanded()) { + binding.chapterRecycler.adapter = adapter + adapter?.onClickListener = { _, _, item, position -> + if (!sheetBehavior.isExpanded() || activity.isLoading) { false } else { if (item.chapter.id != presenter.getCurrentChapter()?.chapter?.id) { - shouldCollapse = false + activity.binding.readerNav.leftChapter.isInvisible = true + activity.binding.readerNav.rightChapter.isInvisible = true + presenter.loadChapter(item.chapter) + loadingPos = position + val itemView = (binding.chapterRecycler.findViewHolderForAdapterPosition(position) as? ReaderChapterItem.ViewHolder)?.binding + itemView?.bookmarkImage?.isVisible = false + itemView?.progress?.isVisible = true } true } } - adapter?.addEventHook(object : ClickEventHook() { - override fun onBind(viewHolder: RecyclerView.ViewHolder): View? { - return if (viewHolder is ReaderChapterItem.ViewHolder) { - viewHolder.bookmarkButton - } else { - null + adapter?.addEventHook( + object : ClickEventHook() { + override fun onBind(viewHolder: RecyclerView.ViewHolder): View? { + return if (viewHolder is ReaderChapterItem.ViewHolder) { + viewHolder.binding.bookmarkButton + } else { + null + } } - } - override fun onClick( - v: View, - position: Int, - fastAdapter: FastAdapter, - item: ReaderChapterItem - ) { - presenter.toggleBookmark(item.chapter) - refreshList() + override fun onClick( + v: View, + position: Int, + fastAdapter: FastAdapter, + item: ReaderChapterItem + ) { + if (!activity.isLoading && sheetBehavior.isExpanded()) { + presenter.toggleBookmark(item.chapter) + refreshList() + } + } } - }) + ) backgroundTintList = ColorStateList.valueOf( if (!sheetBehavior.isExpanded()) primary else fullPrimary ) - chapter_recycler.layoutManager = LinearLayoutManager(context) + binding.chapterRecycler.layoutManager = LinearLayoutManager(context) refreshList() } + fun resetChapter() { + val itemView = (binding.chapterRecycler.findViewHolderForAdapterPosition(loadingPos) as? ReaderChapterItem.ViewHolder)?.binding + itemView?.bookmarkImage?.isVisible = true + itemView?.progress?.isVisible = false + } + fun refreshList() { launchUI { val chapters = presenter.getChapters() @@ -123,9 +180,9 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr itemAdapter.clear() itemAdapter.add(chapters) - (chapter_recycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset( + (binding.chapterRecycler.layoutManager as LinearLayoutManager).scrollToPositionWithOffset( adapter?.getPosition(presenter.getCurrentChapter()?.chapter?.id ?: 0L) ?: 0, - chapter_recycler.height / 2 - 30.dpToPx + binding.chapterRecycler.height / 2 - 30.dpToPx ) } } @@ -143,7 +200,8 @@ class ReaderChapterSheet @JvmOverloads constructor(context: Context, attrs: Attr fun lerpColorCalc(colorStart: Int, colorEnd: Int, percent: Int): Int { return ( min(colorStart, colorEnd) * (100 - percent) + max( - colorStart, colorEnd + colorStart, + colorEnd ) * percent ) / 100 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt index 137f0c009e..b9bbaa2ade 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.ui.reader.loader +import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.source.SourceManager @@ -33,10 +34,10 @@ class ChapterLoader( return Observable.just(chapter) .doOnNext { chapter.state = ReaderChapter.State.Loading } .observeOn(Schedulers.io()) - .flatMap { + .flatMap { readerChapter -> XLog.d("Loading pages for ${chapter.chapter.name}") - val loader = getPageLoader(it) + val loader = getPageLoader(readerChapter) chapter.pageLoader = loader loader.getPages().take(1).doOnNext { pages -> @@ -46,7 +47,7 @@ class ChapterLoader( .observeOn(AndroidSchedulers.mainThread()) .doOnNext { pages -> if (pages.isEmpty()) { - throw Exception("Page list is empty") + throw Exception(downloadManager.context.getString(R.string.no_pages_found)) } chapter.state = ReaderChapter.State.Loaded(pages) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt index bdad72519a..463b728950 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt @@ -34,7 +34,9 @@ class DownloadPageLoader( .map { pages -> pages.map { page -> ReaderPage( - page.index, page.url, page.imageUrl, + page.index, + page.url, + page.imageUrl, { context.contentResolver.openInputStream(page.uri ?: Uri.EMPTY)!! } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt index c8892068ec..21ea9a692b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt @@ -1,16 +1,18 @@ package eu.kanade.tachiyomi.ui.reader.loader -import android.graphics.BitmapFactory -import com.elvishew.xlog.XLog import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage -import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerPageHolder import eu.kanade.tachiyomi.util.lang.plusAssign -import eu.kanade.tachiyomi.util.system.ImageUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import rx.Completable import rx.Observable import rx.schedulers.Schedulers @@ -23,6 +25,7 @@ import uy.kohesive.injekt.injectLazy import java.util.concurrent.PriorityBlockingQueue import java.util.concurrent.atomic.AtomicInteger import kotlin.math.min +import com.elvishew.xlog.XLog /** * Loader used to load chapters from an online source. @@ -41,9 +44,16 @@ class HttpPageLoader( private val subscriptions = CompositeSubscription() private val preferences by injectLazy() - private val preloadSize = 30 + private var preloadSize = preferences.preloadSize().get() + private val scope = CoroutineScope(Job() + Dispatchers.IO) init { + // Adding flow since we can reach reader settings after this is created + preferences.preloadSize().asFlow() + .onEach { + preloadSize = it + } + .launchIn(scope) subscriptions += Observable.defer { Observable.just(queue.take().page) } .filter { it.status == Page.QUEUE } .concatMap { source.fetchImageFromCacheThenNet(it) } @@ -65,6 +75,7 @@ class HttpPageLoader( */ override fun recycle() { super.recycle() + scope.cancel() subscriptions.unsubscribe() queue.clear() @@ -88,11 +99,11 @@ class HttpPageLoader( * the local cache, otherwise fallbacks to network. */ override fun getPages(): Observable> { - return chapterCache - .getPageListFromCache(chapter.chapter) + return Observable.fromCallable { chapterCache.getPageListFromCache(chapter.chapter) } .onErrorResumeNext { source.fetchPageList(chapter.chapter) } .map { pages -> - pages.mapIndexed { index, page -> // Don't trust sources and use our own indexing + pages.mapIndexed { index, page -> + // Don't trust sources and use our own indexing ReaderPage(index, page.url, page.imageUrl) } } @@ -192,10 +203,11 @@ class HttpPageLoader( * @param page the page whose source image has to be downloaded. */ private fun HttpSource.fetchImageFromCacheThenNet(page: ReaderPage): Observable { - return if (page.imageUrl.isNullOrEmpty()) + return if (page.imageUrl.isNullOrEmpty()) { getImageUrl(page).flatMap { getCachedImage(it) } - else + } else { getCachedImage(page) + } } private fun HttpSource.getImageUrl(page: ReaderPage): Observable { @@ -216,26 +228,20 @@ class HttpPageLoader( private fun HttpSource.getCachedImage(page: ReaderPage): Observable { val imageUrl = page.imageUrl ?: return Observable.just(page) - return Observable.just(page).flatMap { - if (!chapterCache.isImageInCache(imageUrl)) { - cacheImage(page) - } else { - Observable.just(page) + return Observable.just(page) + .flatMap { + if (!chapterCache.isImageInCache(imageUrl)) { + cacheImage(page) + } else { + Observable.just(page) + } } - }.doOnNext { - val readerTheme = preferences.readerTheme().get() - if (readerTheme >= 2) { - val stream = chapterCache.getImageFile(imageUrl).inputStream() - val image = BitmapFactory.decodeStream(stream) - page.bg = ImageUtil.autoSetBackground( - image, readerTheme == 2, preferences.context - ) - page.bgType = PagerPageHolder.getBGType(readerTheme, preferences.context) - stream.close() + .doOnNext { + page.stream = { chapterCache.getImageFile(imageUrl).inputStream() } + page.status = Page.READY } - page.stream = { chapterCache.getImageFile(imageUrl).inputStream() } - page.status = Page.READY - }.doOnError { page.status = Page.ERROR }.onErrorReturn { page } + .doOnError { page.status = Page.ERROR } + .onErrorReturn { page } } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ChapterTransition.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ChapterTransition.kt index 41b6a3d787..47da72bf52 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ChapterTransition.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ChapterTransition.kt @@ -17,8 +17,9 @@ sealed class ChapterTransition { override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is ChapterTransition) return false - if (from == other.from && to == other.to) return true - if (from == other.to && to == other.from) return true + if (from == other.from && to == other.to && to != null) return true + if (from == other.to && to == other.from && to != null) return true + if (to == other.to && to == null && from == other.from && other::class == this::class) return true return false } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt index fbca23521a..30bc4faa1f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt @@ -10,8 +10,19 @@ class ReaderPage( imageUrl: String? = null, var stream: (() -> InputStream)? = null, var bg: Drawable? = null, - var bgType: Int? = null + var bgType: Int? = null, + /** Value to check if this page is used to as if it was too wide */ + var shiftedPage: Boolean = false, + /** Value to check if a page is can be doubled up, but can't because the next page is too wide */ + var isolatedPage: Boolean = false ) : Page(index, url, imageUrl, null) { lateinit var chapter: ReaderChapter + + /** Value to check if a page is too wide to be doubled up */ + var fullPage: Boolean = false + set(value) { + field = value + if (value) shiftedPage = false + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/OrientationType.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/OrientationType.kt new file mode 100644 index 0000000000..86f94e7024 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/OrientationType.kt @@ -0,0 +1,20 @@ +package eu.kanade.tachiyomi.ui.reader.settings + +import android.content.pm.ActivityInfo +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import eu.kanade.tachiyomi.R + +enum class OrientationType(val prefValue: Int, val flag: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int) { + FREE(1, ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, R.string.free, R.drawable.ic_screen_rotation_24dp), + PORTRAIT(2, ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT, R.string.portrait, R.drawable.ic_stay_current_portrait_24dp), + LANDSCAPE(3, ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, R.string.landscape, R.drawable.ic_stay_current_landscape_24dp), + LOCKED_PORTRAIT(4, ActivityInfo.SCREEN_ORIENTATION_PORTRAIT, R.string.locked_portrait, R.drawable.ic_screen_lock_portrait_24dp), + LOCKED_LANDSCAPE(5, ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, R.string.locked_landscape, R.drawable.ic_screen_lock_landscape_24dp), + ; + + companion object { + fun fromPreference(preference: Int): OrientationType = + values().find { it.prefValue == preference } ?: FREE + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/PageLayout.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/PageLayout.kt new file mode 100644 index 0000000000..7fa43319a0 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/PageLayout.kt @@ -0,0 +1,18 @@ +package eu.kanade.tachiyomi.ui.reader.settings + +import androidx.annotation.StringRes +import eu.kanade.tachiyomi.R + +enum class PageLayout(val value: Int, @StringRes val stringRes: Int, @StringRes private val _fullStringRes: Int? = null) { + SINGLE_PAGE(0, R.string.single_page), + DOUBLE_PAGES(1, R.string.double_pages), + AUTOMATIC(2, R.string.automatic, R.string.automatic_orientation), + ; + + @StringRes val fullStringRes = _fullStringRes ?: stringRes + + companion object { + fun fromPreference(preference: Int): PageLayout = + values().find { it.value == preference } ?: SINGLE_PAGE + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderBottomButton.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderBottomButton.kt new file mode 100644 index 0000000000..edd6fab967 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderBottomButton.kt @@ -0,0 +1,27 @@ +package eu.kanade.tachiyomi.ui.reader.settings + +import androidx.annotation.StringRes +import eu.kanade.tachiyomi.R + +enum class ReaderBottomButton(val value: String, @StringRes val stringRes: Int) { + ViewChapters("vc", R.string.view_chapters), + WebView("wb", R.string.open_in_webview), + Comment("com", R.string.view_comments), + ReadingMode("rm", R.string.reading_mode), + Rotation("rot", R.string.rotation), + CropBordersPaged("cbp", R.string.crop_borders_paged), + CropBordersWebtoon("cbw", R.string.crop_borders_webtoon), + PageLayout("pl", R.string.page_layout), + ShiftDoublePage("sdp", R.string.shift_double_pages) + ; + + fun isIn(buttons: Collection) = value in buttons + + companion object { + val BUTTONS_DEFAULTS = setOf( + ViewChapters, + Comment, + ShiftDoublePage, + ).map { it.value }.toSet() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderFilterView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderFilterView.kt new file mode 100644 index 0000000000..4b6ee8d39b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderFilterView.kt @@ -0,0 +1,294 @@ +package eu.kanade.tachiyomi.ui.reader.settings + +import android.content.Context +import android.graphics.Rect +import android.os.Build +import android.util.AttributeSet +import android.view.Window +import android.view.WindowManager +import android.widget.SeekBar +import androidx.annotation.ColorInt +import eu.kanade.tachiyomi.databinding.ReaderColorFilterBinding +import eu.kanade.tachiyomi.ui.reader.ReaderActivity +import eu.kanade.tachiyomi.widget.BaseReaderSettingsView +import eu.kanade.tachiyomi.widget.SimpleSeekBarListener +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.sample +import kotlin.math.max + +class ReaderFilterView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + BaseReaderSettingsView(context, attrs) { + + var window: Window? = null + private val boundingBox: Rect = Rect() + private val exclusions = listOf(boundingBox) + + override fun inflateBinding() = ReaderColorFilterBinding.bind(this) + override fun initGeneralPreferences() { + activity = context as? ReaderActivity ?: return + preferences.colorFilter().asFlow() + .onEach { setColorFilter(it) } + .launchIn(activity.scope) + + preferences.colorFilterMode().asFlow() + .onEach { setColorFilter(preferences.colorFilter().get()) } + .launchIn(activity.scope) + + preferences.customBrightness().asFlow() + .onEach { setCustomBrightness(it) } + .launchIn(activity.scope) + + // Get color and update values + val color = preferences.colorFilterValue().get() + val brightness = preferences.customBrightnessValue().get() + + val argb = setValues(color) + + // Set brightness value + binding.txtBrightnessSeekbarValue.text = brightness.toString() + binding.brightnessSeekbar.progress = brightness + + // Initialize seekBar progress + binding.seekbarColorFilterAlpha.progress = argb[0] + binding.seekbarColorFilterRed.progress = argb[1] + binding.seekbarColorFilterGreen.progress = argb[2] + binding.seekbarColorFilterBlue.progress = argb[3] + + // Set listeners + binding.switchColorFilter.isChecked = preferences.colorFilter().get() + binding.switchColorFilter.setOnCheckedChangeListener { _, isChecked -> + preferences.colorFilter().set(isChecked) + } + + binding.customBrightness.isChecked = preferences.customBrightness().get() + binding.customBrightness.setOnCheckedChangeListener { _, isChecked -> + preferences.customBrightness().set(isChecked) + } + + binding.colorFilterMode.bindToPreference(preferences.colorFilterMode()) + binding.seekbarColorFilterAlpha.setOnSeekBarChangeListener( + object : SimpleSeekBarListener() { + override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { + binding.seekbarColorFilterRed.isEnabled = value > 0 && binding.seekbarColorFilterAlpha.isEnabled + binding.seekbarColorFilterGreen.isEnabled = value > 0 && binding.seekbarColorFilterAlpha.isEnabled + binding.seekbarColorFilterBlue.isEnabled = value > 0 && binding.seekbarColorFilterAlpha.isEnabled + if (fromUser) { + setColorValue(value, ALPHA_MASK, 24) + } + } + } + ) + + setColorFilterSeekBar(binding.switchColorFilter.isChecked) + + binding.seekbarColorFilterRed.setOnSeekBarChangeListener( + object : SimpleSeekBarListener() { + override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { + if (fromUser) { + setColorValue(value, RED_MASK, 16) + } + } + } + ) + + binding.seekbarColorFilterGreen.setOnSeekBarChangeListener( + object : SimpleSeekBarListener() { + override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { + if (fromUser) { + setColorValue(value, GREEN_MASK, 8) + } + } + } + ) + + binding.seekbarColorFilterBlue.setOnSeekBarChangeListener( + object : SimpleSeekBarListener() { + override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { + if (fromUser) { + setColorValue(value, BLUE_MASK, 0) + } + } + } + ) + + binding.brightnessSeekbar.setOnSeekBarChangeListener( + object : SimpleSeekBarListener() { + override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) { + if (fromUser) { + preferences.customBrightnessValue().set(value) + } + } + } + ) + } + + /** + * Set enabled status of seekBars belonging to color filter + * @param enabled determines if seekBar gets enabled + */ + private fun setColorFilterSeekBar(enabled: Boolean) { + binding.seekbarColorFilterRed.isEnabled = binding.seekbarColorFilterAlpha.progress > 0 && enabled + binding.seekbarColorFilterGreen.isEnabled = binding.seekbarColorFilterAlpha.progress > 0 && enabled + binding.seekbarColorFilterBlue.isEnabled = binding.seekbarColorFilterAlpha.progress > 0 && enabled + binding.seekbarColorFilterAlpha.isEnabled = enabled + } + + /** + * Set enabled status of seekBars belonging to custom brightness + * @param enabled value which determines if seekBar gets enabled + */ + private fun setCustomBrightnessSeekBar(enabled: Boolean) { + binding.brightnessSeekbar.isEnabled = enabled + } + + /** + * Set the text value's of color filter + * @param color integer containing color information + */ + private fun setValues(color: Int): Array { + val alpha = getAlphaFromColor(color) + val red = getRedFromColor(color) + val green = getGreenFromColor(color) + val blue = getBlueFromColor(color) + + // Initialize values + binding.txtColorFilterAlphaValue.text = alpha.toString() + binding.txtColorFilterRedValue.text = red.toString() + binding.txtColorFilterGreenValue.text = green.toString() + binding.txtColorFilterBlueValue.text = blue.toString() + + return arrayOf(alpha, red, green, blue) + } + + /** + * Manages the custom brightness value subscription + * @param enabled determines if the subscription get (un)subscribed + */ + private fun setCustomBrightness(enabled: Boolean) { + if (enabled) { + preferences.customBrightnessValue().asFlow() + .sample(100) + .onEach { setCustomBrightnessValue(it) } + .launchIn(activity.scope) + } else { + setCustomBrightnessValue(0, true) + } + setCustomBrightnessSeekBar(enabled) + } + + fun setWindowBrightness() { + setCustomBrightnessValue(preferences.customBrightnessValue().get(), !preferences.customBrightness().get()) + } + + /** + * Sets the brightness of the screen. Range is [-75, 100]. + * From -75 to -1 a semi-transparent black view is shown at the top with the minimum brightness. + * From 1 to 100 it sets that value as brightness. + * 0 sets system brightness and hides the overlay. + */ + private fun setCustomBrightnessValue(value: Int, isDisabled: Boolean = false) { + // Set black overlay visibility. + if (!isDisabled) { + binding.txtBrightnessSeekbarValue.text = value.toString() + window?.attributes = window?.attributes?.apply { screenBrightness = max(0.01f, value / 100f) } + } else { + window?.attributes = window?.attributes?.apply { screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE } + } + } + + /** + * Manages the color filter value subscription + * @param enabled determines if the subscription get (un)subscribed + * @param view view of the dialog + */ + private fun setColorFilter(enabled: Boolean) { + if (enabled) { + preferences.colorFilterValue().asFlow() + .sample(100) + .onEach { setColorFilterValue(it) } + .launchIn(activity.scope) + } + setColorFilterSeekBar(enabled) + } + + /** + * Sets the color filter overlay of the screen. Determined by HEX of integer + * @param color hex of color. + */ + private fun setColorFilterValue(@ColorInt color: Int) { + setValues(color) + } + + /** + * Updates the color value in preference + * @param color value of color range [0,255] + * @param mask contains hex mask of chosen color + * @param bitShift amounts of bits that gets shifted to receive value + */ + fun setColorValue(color: Int, mask: Long, bitShift: Int) { + val currentColor = preferences.colorFilterValue().get() + val updatedColor = (color shl bitShift) or (currentColor and mask.inv().toInt()) + preferences.colorFilterValue().set(updatedColor) + } + + /** + * Returns the alpha value from the Color Hex + * @param color color hex as int + * @return alpha of color + */ + fun getAlphaFromColor(color: Int): Int { + return color shr 24 and 0xFF + } + + /** + * Returns the red value from the Color Hex + * @param color color hex as int + * @return red of color + */ + fun getRedFromColor(color: Int): Int { + return color shr 16 and 0xFF + } + + /** + * Returns the blue value from the Color Hex + * @param color color hex as int + * @return blue of color + */ + fun getBlueFromColor(color: Int): Int { + return color and 0xFF + } + + private companion object { + /** Integer mask of alpha value **/ + const val ALPHA_MASK: Long = 0xFF000000 + + /** Integer mask of red value **/ + const val RED_MASK: Long = 0x00FF0000 + + /** Integer mask of green value **/ + const val GREEN_MASK: Long = 0x0000FF00 + + /** Integer mask of blue value **/ + const val BLUE_MASK: Long = 0x000000FF + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + if (Build.VERSION.SDK_INT >= 29 && changed) { + with(binding.brightnessSeekbar) { + boundingBox.set(this.left, this.top, this.right, this.bottom) + this.systemGestureExclusionRects = exclusions + } + } + } +} + +/** + * Returns the green value from the Color Hex + * @param color color hex as int + * @return green of color + */ +fun ReaderFilterView.getGreenFromColor(color: Int): Int { + return color shr 8 and 0xFF +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderGeneralView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderGeneralView.kt new file mode 100644 index 0000000000..e36d65e56a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderGeneralView.kt @@ -0,0 +1,53 @@ +package eu.kanade.tachiyomi.ui.reader.settings + +import android.content.Context +import android.util.AttributeSet +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.isLongStrip +import eu.kanade.tachiyomi.databinding.ReaderGeneralLayoutBinding +import eu.kanade.tachiyomi.ui.reader.ReaderActivity +import eu.kanade.tachiyomi.util.bindToPreference +import eu.kanade.tachiyomi.widget.BaseReaderSettingsView + +class ReaderGeneralView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + BaseReaderSettingsView(context, attrs) { + + lateinit var sheet: TabbedReaderSettingsSheet + override fun inflateBinding() = ReaderGeneralLayoutBinding.bind(this) + override fun initGeneralPreferences() { + binding.viewerSeries.onItemSelectedListener = { position -> + activity.presenter.setMangaViewer(position) + + val mangaViewer = activity.presenter.getMangaViewer() + if (mangaViewer == ReaderActivity.WEBTOON || mangaViewer == ReaderActivity.VERTICAL_PLUS) { + initWebtoonPreferences() + } else { + initPagerPreferences() + } + } + binding.viewerSeries.setSelection((context as? ReaderActivity)?.presenter?.manga?.viewer ?: 0) + binding.rotationMode.bindToPreference(preferences.rotation(), 1) + binding.backgroundColor.bindToPreference(preferences.readerTheme(), 0) + binding.showPageNumber.bindToPreference(preferences.showPageNumber()) + binding.fullscreen.bindToPreference(preferences.fullscreen()) + binding.keepscreen.bindToPreference(preferences.keepScreenOn()) + binding.alwaysShowChapterTransition.bindToPreference(preferences.alwaysShowChapterTransition()) + } + + fun checkIfShouldDisableReadingMode() { + if (activity.presenter.manga?.isLongStrip() == true) { + binding.viewerSeries.setDisabledState(R.string.webtoon_cannot_change) + } + } + + /** + * Init the preferences for the webtoon reader. + */ + private fun initWebtoonPreferences() { + sheet.updateTabs(true) + } + + private fun initPagerPreferences() { + sheet.updateTabs(false) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderPagedView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderPagedView.kt new file mode 100644 index 0000000000..448db56c23 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReaderPagedView.kt @@ -0,0 +1,100 @@ +package eu.kanade.tachiyomi.ui.reader.settings + +import android.app.Activity +import android.content.Context +import android.util.AttributeSet +import androidx.core.view.isVisible +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.ReaderPagedLayoutBinding +import eu.kanade.tachiyomi.ui.reader.ReaderActivity +import eu.kanade.tachiyomi.util.bindToPreference +import eu.kanade.tachiyomi.util.lang.addBetaTag +import eu.kanade.tachiyomi.widget.BaseReaderSettingsView + +class ReaderPagedView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + BaseReaderSettingsView(context, attrs) { + + override fun inflateBinding() = ReaderPagedLayoutBinding.bind(this) + override fun initGeneralPreferences() { + with(binding) { + scaleType.bindToPreference(preferences.imageScaleType(), 1) { + val mangaViewer = (context as? ReaderActivity)?.presenter?.getMangaViewer() ?: 0 + val isWebtoonView = + mangaViewer == ReaderActivity.WEBTOON || mangaViewer == ReaderActivity.VERTICAL_PLUS + updatePagedGroup(!isWebtoonView) + } + zoomStart.bindToPreference(preferences.zoomStart(), 1) + cropBorders.bindToPreference(preferences.cropBorders()) + pageTransitions.bindToPreference(preferences.pageTransitions()) + pagerNav.bindToPreference(preferences.navigationModePager()) + pagerInvert.bindToPreference(preferences.pagerNavInverted()) + extendPastCutout.bindToPreference(preferences.pagerCutoutBehavior()) + pageLayout.bindToPreference(preferences.pageLayout()) { + val mangaViewer = (context as? ReaderActivity)?.presenter?.getMangaViewer() ?: 0 + val isWebtoonView = + mangaViewer == ReaderActivity.WEBTOON || mangaViewer == ReaderActivity.VERTICAL_PLUS + updatePagedGroup(!isWebtoonView) + } + invertDoublePages.bindToPreference(preferences.invertDoublePages()) + + pageLayout.title = pageLayout.title.toString().addBetaTag(context) + + val mangaViewer = (context as? ReaderActivity)?.presenter?.getMangaViewer() ?: 0 + val isWebtoonView = + mangaViewer == ReaderActivity.WEBTOON || mangaViewer == ReaderActivity.VERTICAL_PLUS + val hasMargins = mangaViewer == ReaderActivity.VERTICAL_PLUS + cropBordersWebtoon.bindToPreference(if (hasMargins) preferences.cropBorders() else preferences.cropBordersWebtoon()) + webtoonSidePadding.bindToIntPreference( + preferences.webtoonSidePadding(), + R.array.webtoon_side_padding_values + ) + webtoonEnableZoomOut.bindToPreference(preferences.webtoonEnableZoomOut()) + webtoonNav.bindToPreference(preferences.navigationModeWebtoon()) + webtoonInvert.bindToPreference(preferences.webtoonNavInverted()) + + updatePagedGroup(!isWebtoonView) + } + } + + fun updatePrefs() { + val mangaViewer = activity.presenter.getMangaViewer() + val isWebtoonView = mangaViewer == ReaderActivity.WEBTOON || mangaViewer == ReaderActivity.VERTICAL_PLUS + val hasMargins = mangaViewer == ReaderActivity.VERTICAL_PLUS + binding.cropBordersWebtoon.bindToPreference(if (hasMargins) preferences.cropBorders() else preferences.cropBordersWebtoon()) + updatePagedGroup(!isWebtoonView) + } + + private fun updatePagedGroup(show: Boolean) { + listOf( + binding.scaleType, + binding.zoomStart, + binding.cropBorders, + binding.pageTransitions, + binding.pagerNav, + binding.pagerInvert, + binding.pageLayout + ).forEach { it.isVisible = show } + listOf( + binding.cropBordersWebtoon, + binding.webtoonSidePadding, + binding.webtoonEnableZoomOut, + binding.webtoonNav, + binding.webtoonInvert + ).forEach { it.isVisible = !show } + val isFullFit = when (preferences.imageScaleType().get()) { + SubsamplingScaleImageView.SCALE_TYPE_FIT_HEIGHT, + SubsamplingScaleImageView.SCALE_TYPE_SMART_FIT, + SubsamplingScaleImageView.SCALE_TYPE_CENTER_CROP -> true + else -> false + } + val ogView = (context as? Activity)?.window?.decorView + val hasCutout = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { + ogView?.rootWindowInsets?.displayCutout?.safeInsetTop != null || ogView?.rootWindowInsets?.displayCutout?.safeInsetBottom != null + } else { + false + } + binding.extendPastCutout.isVisible = show && isFullFit && hasCutout + binding.invertDoublePages.isVisible = show && preferences.pageLayout().get() != PageLayout.SINGLE_PAGE.value + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReadingModeType.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReadingModeType.kt new file mode 100644 index 0000000000..4e7fd1b3ea --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/ReadingModeType.kt @@ -0,0 +1,30 @@ +package eu.kanade.tachiyomi.ui.reader.settings + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.lang.next + +enum class ReadingModeType(val prefValue: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int) { + DEFAULT(0, R.string.default_value, R.drawable.ic_reader_default_24dp), + LEFT_TO_RIGHT(1, R.string.left_to_right_viewer, R.drawable.ic_reader_ltr_24dp), + RIGHT_TO_LEFT(2, R.string.right_to_left_viewer, R.drawable.ic_reader_rtl_24dp), + VERTICAL(3, R.string.vertical_viewer, R.drawable.ic_reader_vertical_24dp), + WEBTOON(4, R.string.webtoon, R.drawable.ic_reader_webtoon_24dp), + CONTINUOUS_VERTICAL(5, R.string.continuous_vertical, R.drawable.ic_reader_continuous_vertical_24dp), + ; + + companion object { + fun fromPreference(preference: Int): ReadingModeType = values().find { it.prefValue == preference } ?: DEFAULT + + fun getNextReadingMode(preference: Int): ReadingModeType { + val current = fromPreference(preference) + return current.next() + } + + fun isPagerType(preference: Int): Boolean { + val mode = fromPreference(preference) + return mode == LEFT_TO_RIGHT || mode == RIGHT_TO_LEFT || mode == VERTICAL + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/TabbedReaderSettingsSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/TabbedReaderSettingsSheet.kt new file mode 100644 index 0000000000..0331b346dd --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/settings/TabbedReaderSettingsSheet.kt @@ -0,0 +1,137 @@ +package eu.kanade.tachiyomi.ui.reader.settings + +import android.view.View +import android.view.WindowManager +import androidx.core.content.ContextCompat +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import com.google.android.material.tabs.TabLayout +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.ReaderColorFilterBinding +import eu.kanade.tachiyomi.ui.main.SearchActivity +import eu.kanade.tachiyomi.ui.reader.ReaderActivity +import eu.kanade.tachiyomi.util.system.dpToPx +import eu.kanade.tachiyomi.util.view.collapse +import eu.kanade.tachiyomi.util.view.expand +import eu.kanade.tachiyomi.util.view.isCollapsed +import eu.kanade.tachiyomi.widget.TabbedBottomSheetDialog + +class TabbedReaderSettingsSheet( + val readerActivity: ReaderActivity, + showColorFilterSettings: Boolean = false +) : TabbedBottomSheetDialog(readerActivity) { + private val generalView: ReaderGeneralView = View.inflate( + readerActivity, + R.layout.reader_general_layout, + null + ) as ReaderGeneralView + private val pagedView: ReaderPagedView = View.inflate( + readerActivity, + R.layout.reader_paged_layout, + null + ) as ReaderPagedView + private val filterView: ReaderFilterView = View.inflate( + readerActivity, + R.layout.reader_color_filter, + null + ) as ReaderFilterView + + var showWebview: Boolean = run { + val mangaViewer = readerActivity.presenter.getMangaViewer() + mangaViewer == ReaderActivity.WEBTOON || mangaViewer == ReaderActivity.VERTICAL_PLUS + } + + override var offset = 0 + + override fun getTabViews(): List = listOf( + generalView, + pagedView, + filterView + ) + + override fun getTabTitles(): List = listOf( + R.string.general, + if (showWebview) R.string.webtoon else R.string.paged, + R.string.filter + ) + + init { + generalView.activity = readerActivity + pagedView.activity = readerActivity + filterView.activity = readerActivity + generalView.checkIfShouldDisableReadingMode() + filterView.window = window + generalView.sheet = this + + ReaderColorFilterBinding.bind(filterView).swipeDown.setOnClickListener { + if (sheetBehavior.isCollapsed()) { + sheetBehavior.expand() + } else { + sheetBehavior.collapse() + } + } + + binding.menu.isVisible = true + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + binding.menu.tooltipText = context.getString(R.string.reader_settings) + } + binding.menu.setImageDrawable( + ContextCompat.getDrawable( + context, + R.drawable.ic_outline_settings_24dp + ) + ) + binding.menu.setOnClickListener { + val intent = SearchActivity.openReaderSettings(readerActivity) + readerActivity.startActivity(intent) + dismiss() + } + + val attrs = window?.attributes + val ogDim = attrs?.dimAmount ?: 0.25f + val filterTabIndex = getTabViews().indexOf(filterView) + binding.pager.adapter?.notifyDataSetChanged() + binding.tabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab?) { + window?.setDimAmount(if (tab?.position == filterTabIndex) 0f else ogDim) + readerActivity.binding.appBar.isInvisible = tab?.position == filterTabIndex + if (tab?.position == 2) { + sheetBehavior.skipCollapsed = false + sheetBehavior.peekHeight = 110.dpToPx + filterView.setWindowBrightness() + } else { + sheetBehavior.expand() + sheetBehavior.skipCollapsed = true + window?.attributes = window?.attributes?.apply { screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE } + } + } + + override fun onTabUnselected(tab: TabLayout.Tab?) { + } + + override fun onTabReselected(tab: TabLayout.Tab?) { + } + }) + + if (showColorFilterSettings) { + binding.tabs.getTabAt(filterTabIndex)?.select() + } + } + + override fun onStart() { + super.onStart() + val filterTabIndex = getTabViews().indexOf(filterView) + sheetBehavior.skipCollapsed = binding.tabs.selectedTabPosition != filterTabIndex + } + + override fun dismiss() { + super.dismiss() + readerActivity.binding.appBar.isVisible = true + } + + fun updateTabs(isWebtoon: Boolean) { + showWebview = isWebtoon + binding.pager.adapter?.notifyDataSetChanged() + pagedView.updatePrefs() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/BaseViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/BaseViewer.kt index 223cb087f5..f511108c32 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/BaseViewer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/BaseViewer.kt @@ -29,7 +29,17 @@ interface BaseViewer { /** * Tells this viewer to move to the given [page]. */ - fun moveToPage(page: ReaderPage) + fun moveToPage(page: ReaderPage, animated: Boolean = true) + + /** + * Moves to the next page. + */ + fun moveToNext() + + /** + * Moves to the previous page. + */ + fun moveToPrevious() /** * Called from the containing activity when a key [event] is received. It should return true diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderProgressBar.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderProgressBar.kt index f584bf8211..045c6434d3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderProgressBar.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderProgressBar.kt @@ -61,9 +61,12 @@ class ReaderProgressBar @JvmOverloads constructor( */ private val rotationAnimation by lazy { RotateAnimation( - 0f, 360f, - Animation.RELATIVE_TO_SELF, 0.5f, - Animation.RELATIVE_TO_SELF, 0.5f + 0f, + 360f, + Animation.RELATIVE_TO_SELF, + 0.5f, + Animation.RELATIVE_TO_SELF, + 0.5f ).apply { interpolator = LinearInterpolator() repeatCount = Animation.INFINITE @@ -161,16 +164,18 @@ class ReaderProgressBar @JvmOverloads constructor( ObjectAnimator.ofFloat(this, "alpha", 1f, 0f).apply { interpolator = DecelerateInterpolator() duration = 1000 - addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { - visibility = View.GONE - alpha = 1f + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator?) { + visibility = View.GONE + alpha = 1f + } + + override fun onAnimationCancel(animation: Animator?) { + alpha = 1f + } } - - override fun onAnimationCancel(animation: Animator?) { - alpha = 1f - } - }) + ) start() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ViewerConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ViewerConfig.kt index fab2958314..d049bdb648 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ViewerConfig.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ViewerConfig.kt @@ -3,27 +3,35 @@ package eu.kanade.tachiyomi.ui.reader.viewer import com.tfcporciuncula.flow.Preference import eu.kanade.tachiyomi.data.preference.PreferencesHelper import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach /** * Common configuration for all viewers. */ -abstract class ViewerConfig(preferences: PreferencesHelper) { - - private val scope = CoroutineScope(Job() + Dispatchers.Main) +abstract class ViewerConfig(preferences: PreferencesHelper, protected val scope: CoroutineScope) { var imagePropertyChangedListener: (() -> Unit)? = null + var reloadChapterListener: ((Boolean) -> Unit)? = null + + var navigationModeChangedListener: (() -> Unit)? = null + var navigationModeInvertedListener: (() -> Unit)? = null var tappingEnabled = true var longTapEnabled = true + var tappingInverted = ViewerNavigation.TappingInvertMode.NONE var doubleTapAnimDuration = 500 var volumeKeysEnabled = false var volumeKeysInverted = false var alwaysShowChapterTransition = true + var navigationOverlayForNewUser = false + var navigationMode = 0 + protected set + + abstract var navigator: ViewerNavigation + protected set + init { preferences.readWithTapping() .register({ tappingEnabled = it }) @@ -55,4 +63,8 @@ abstract class ViewerConfig(preferences: PreferencesHelper) { } .launchIn(scope) } + + protected abstract fun defaultNavigation(): ViewerNavigation + + abstract fun updateNavigation(navigationMode: Int) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ViewerNavigation.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ViewerNavigation.kt new file mode 100644 index 0000000000..2b4b9a0f01 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ViewerNavigation.kt @@ -0,0 +1,71 @@ +package eu.kanade.tachiyomi.ui.reader.viewer + +import android.graphics.PointF +import android.graphics.RectF +import androidx.annotation.StringRes +import eu.kanade.tachiyomi.R + +abstract class ViewerNavigation { + + sealed class NavigationRegion(@StringRes val nameRes: Int, val colorRes: Int) { + object MENU : NavigationRegion(R.string.menu, R.color.navigation_menu) + object PREV : NavigationRegion(R.string.previous, R.color.navigation_prev) + object NEXT : NavigationRegion(R.string.next, R.color.navigation_next) + object LEFT : NavigationRegion(R.string.left, R.color.navigation_next) + object RIGHT : NavigationRegion(R.string.right, R.color.navigation_prev) + + fun directionalRegion(LTR: Boolean): NavigationRegion { + return if (this === LEFT || this === RIGHT) { + if (if (LTR) this === LEFT else this === RIGHT) NEXT else PREV + } else this + } + } + + data class Region( + val rectF: RectF, + val type: NavigationRegion + ) { + fun invert(invertMode: TappingInvertMode): Region { + if (invertMode == TappingInvertMode.NONE) return this + return this.copy( + rectF = this.rectF.invert(invertMode) + ) + } + } + + private var constantMenuRegion: RectF = RectF(0f, 0f, 1f, 0.05f) + + abstract var regions: List + + var invertMode: TappingInvertMode = TappingInvertMode.NONE + + fun getAction(pos: PointF): NavigationRegion { + val x = pos.x + val y = pos.y + val region = regions.map { it.invert(invertMode) } + .find { it.rectF.contains(x, y) } + return when { + region != null -> region.type + constantMenuRegion.contains(x, y) -> NavigationRegion.MENU + else -> NavigationRegion.MENU + } + } + + enum class TappingInvertMode(val shouldInvertHorizontal: Boolean = false, val shouldInvertVertical: Boolean = false) { + NONE, + HORIZONTAL(shouldInvertHorizontal = true), + VERTICAL(shouldInvertVertical = true), + BOTH(shouldInvertHorizontal = true, shouldInvertVertical = true) + } +} + +fun RectF.invert(invertMode: ViewerNavigation.TappingInvertMode): RectF { + val horizontal = invertMode.shouldInvertHorizontal + val vertical = invertMode.shouldInvertVertical + return when { + horizontal && vertical -> RectF(1f - this.right, 1f - this.bottom, 1f - this.left, 1f - this.top) + vertical -> RectF(this.left, 1f - this.bottom, this.right, 1f - this.top) + horizontal -> RectF(1f - this.right, this.top, 1f - this.left, this.bottom) + else -> this + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/navigation/EdgeNavigation.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/navigation/EdgeNavigation.kt new file mode 100644 index 0000000000..c06c703a34 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/navigation/EdgeNavigation.kt @@ -0,0 +1,32 @@ +package eu.kanade.tachiyomi.ui.reader.viewer.navigation + +import android.graphics.RectF +import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation + +/** + * Visualization of default state without any inversion + * +---+---+---+ + * | N | N | N | P: Previous + * +---+---+---+ + * | N | M | N | M: Menu + * +---+---+---+ + * | N | P | N | N: Next + * +---+---+---+ +*/ +class EdgeNavigation : ViewerNavigation() { + + override var regions: List = listOf( + Region( + rectF = RectF(0f, 0f, 0.33f, 1f), + type = NavigationRegion.NEXT + ), + Region( + rectF = RectF(0.33f, 0.66f, 0.66f, 1f), + type = NavigationRegion.PREV + ), + Region( + rectF = RectF(0.66f, 0f, 1f, 1f), + type = NavigationRegion.NEXT + ), + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/navigation/KindlishNavigation.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/navigation/KindlishNavigation.kt new file mode 100644 index 0000000000..43c081f870 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/navigation/KindlishNavigation.kt @@ -0,0 +1,28 @@ +package eu.kanade.tachiyomi.ui.reader.viewer.navigation + +import android.graphics.RectF +import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation + +/** + * Visualization of default state without any inversion + * +---+---+---+ + * | M | M | M | P: Previous + * +---+---+---+ + * | P | N | N | M: Menu + * +---+---+---+ + * | P | N | N | N: Next + * +---+---+---+ +*/ +class KindlishNavigation : ViewerNavigation() { + + override var regions: List = listOf( + Region( + rectF = RectF(0.33f, 0.33f, 1f, 1f), + type = NavigationRegion.NEXT + ), + Region( + rectF = RectF(0f, 0.33f, 0.33f, 1f), + type = NavigationRegion.PREV + ) + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/navigation/LNavigation.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/navigation/LNavigation.kt new file mode 100644 index 0000000000..6d3390818a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/navigation/LNavigation.kt @@ -0,0 +1,36 @@ +package eu.kanade.tachiyomi.ui.reader.viewer.navigation + +import android.graphics.RectF +import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation + +/** + * Visualization of default state without any inversion + * +---+---+---+ + * | P | P | P | P: Previous + * +---+---+---+ + * | P | M | N | M: Menu + * +---+---+---+ + * | N | N | N | N: Next + * +---+---+---+ + */ +open class LNavigation : ViewerNavigation() { + + override var regions: List = listOf( + Region( + rectF = RectF(0f, 0.33f, 0.33f, 0.66f), + type = NavigationRegion.PREV + ), + Region( + rectF = RectF(0f, 0f, 1f, 0.33f), + type = NavigationRegion.PREV + ), + Region( + rectF = RectF(0.66f, 0.33f, 1f, 0.66f), + type = NavigationRegion.NEXT + ), + Region( + rectF = RectF(0f, 0.66f, 1f, 1f), + type = NavigationRegion.NEXT + ) + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/navigation/RightAndLeftNavigation.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/navigation/RightAndLeftNavigation.kt new file mode 100644 index 0000000000..f0df1afa2d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/navigation/RightAndLeftNavigation.kt @@ -0,0 +1,28 @@ +package eu.kanade.tachiyomi.ui.reader.viewer.navigation + +import android.graphics.RectF +import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation + +/** + * Visualization of default state without any inversion + * +---+---+---+ + * | N | M | P | P: Move Right + * +---+---+---+ + * | N | M | P | M: Menu + * +---+---+---+ + * | N | M | P | N: Move Left + * +---+---+---+ + */ +class RightAndLeftNavigation : ViewerNavigation() { + + override var regions: List = listOf( + Region( + rectF = RectF(0f, 0f, 0.33f, 1f), + type = NavigationRegion.LEFT + ), + Region( + rectF = RectF(0.66f, 0f, 1f, 1f), + type = NavigationRegion.RIGHT + ), + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerConfig.kt index c6ac8ad7ce..60a3afa09d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerConfig.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerConfig.kt @@ -1,15 +1,30 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.reader.settings.PageLayout import eu.kanade.tachiyomi.ui.reader.viewer.ViewerConfig +import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation +import eu.kanade.tachiyomi.ui.reader.viewer.navigation.EdgeNavigation +import eu.kanade.tachiyomi.ui.reader.viewer.navigation.KindlishNavigation +import eu.kanade.tachiyomi.ui.reader.viewer.navigation.LNavigation +import eu.kanade.tachiyomi.ui.reader.viewer.navigation.RightAndLeftNavigation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get /** * Configuration used by pager viewers. */ -class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelper = Injekt.get()) : - ViewerConfig(preferences) { +class PagerConfig( + scope: CoroutineScope, + private val viewer: PagerViewer, + preferences: PreferencesHelper = Injekt.get() +) : + ViewerConfig(preferences, scope) { var usePageTransitions = false private set @@ -26,6 +41,23 @@ class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelpe var readerTheme = 0 private set + var cutoutBehavior = 0 + private set + + var shiftDoublePage = false + + var doublePages = preferences.pageLayout().get() == PageLayout.DOUBLE_PAGES.value + set(value) { + field = value + if (!value) { + shiftDoublePage = false + } + } + + var invertDoublePages = false + + var autoDoublePages = preferences.pageLayout().get() == PageLayout.AUTOMATIC.value + init { preferences.pageTransitions() .register({ usePageTransitions = it }) @@ -33,6 +65,27 @@ class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelpe preferences.imageScaleType() .register({ imageScaleType = it }, { imagePropertyChangedListener?.invoke() }) + preferences.navigationModePager() + .register({ navigationMode = it }, { updateNavigation(navigationMode) }) + + preferences.pagerNavInverted() + .register( + { tappingInverted = it }, + { + navigator.invertMode = it + } + ) + + preferences.pagerNavInverted().asFlow() + .drop(1) + .onEach { + navigationModeInvertedListener?.invoke() + } + .launchIn(scope) + + preferences.pagerCutoutBehavior() + .register({ cutoutBehavior = it }, { imagePropertyChangedListener?.invoke() }) + preferences.zoomStart() .register({ zoomTypeFromPreference(it) }, { imagePropertyChangedListener?.invoke() }) @@ -41,6 +94,33 @@ class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelpe preferences.readerTheme() .register({ readerTheme = it }, { imagePropertyChangedListener?.invoke() }) + + preferences.invertDoublePages() + .register({ invertDoublePages = it }, { imagePropertyChangedListener?.invoke() }) + + preferences.pageLayout() + .asFlow() + .drop(1) + .onEach { + autoDoublePages = it == PageLayout.AUTOMATIC.value + if (!autoDoublePages) { + doublePages = it == PageLayout.DOUBLE_PAGES.value + } + reloadChapterListener?.invoke(doublePages) + } + .launchIn(scope) + preferences.pageLayout() + .register({ + autoDoublePages = it == PageLayout.AUTOMATIC.value + if (!autoDoublePages) { + doublePages = it == PageLayout.DOUBLE_PAGES.value + } + }) + + navigationOverlayForNewUser = preferences.showNavigationOverlayNewUser().get() + if (navigationOverlayForNewUser) { + preferences.showNavigationOverlayNewUser().set(false) + } } private fun zoomTypeFromPreference(value: Int) { @@ -60,7 +140,46 @@ class PagerConfig(private val viewer: PagerViewer, preferences: PreferencesHelpe } } + override var navigator: ViewerNavigation = defaultNavigation() + set(value) { + field = value.also { it.invertMode = this.tappingInverted } + } + + override fun defaultNavigation(): ViewerNavigation { + return when (viewer) { + is VerticalPagerViewer -> LNavigation() + else -> RightAndLeftNavigation() + } + } + + fun scaleTypeIsFullFit(): Boolean { + return when (imageScaleType) { + SubsamplingScaleImageView.SCALE_TYPE_FIT_HEIGHT, + SubsamplingScaleImageView.SCALE_TYPE_SMART_FIT, + SubsamplingScaleImageView.SCALE_TYPE_CENTER_CROP -> true + else -> false + } + } + + override fun updateNavigation(navigationMode: Int) { + navigator = when (navigationMode) { + 0 -> defaultNavigation() + 1 -> LNavigation() + 2 -> KindlishNavigation() + 3 -> EdgeNavigation() + 4 -> RightAndLeftNavigation() + else -> defaultNavigation() + } + navigationModeChangedListener?.invoke() + } + enum class ZoomType { Left, Center, Right } + + companion object { + const val CUTOUT_PAD = 0 + const val CUTOUT_START_EXTENDED = 1 + const val CUTOUT_IGNORE = 2 + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt index 83408b1746..148e26818e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt @@ -6,6 +6,7 @@ import android.content.Intent import android.graphics.BitmapFactory import android.graphics.Color import android.graphics.PointF +import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.view.GestureDetector import android.view.Gravity @@ -19,6 +20,7 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import androidx.core.net.toUri +import androidx.core.view.isVisible import coil.loadAny import coil.request.CachePolicy import com.davemorrissey.labs.subscaleview.ImageSource @@ -29,25 +31,30 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressBar +import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerConfig.Companion.CUTOUT_IGNORE +import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerConfig.Companion.CUTOUT_START_EXTENDED import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerConfig.ZoomType import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.ThemeUtil import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.isInNightMode import eu.kanade.tachiyomi.util.system.launchUI -import eu.kanade.tachiyomi.util.view.gone -import eu.kanade.tachiyomi.util.view.visible import eu.kanade.tachiyomi.widget.GifViewTarget import eu.kanade.tachiyomi.widget.ViewPagerAdapter +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.Default +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import kotlinx.coroutines.withContext import rx.Observable import rx.Subscription import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers +import timber.log.Timber import uy.kohesive.injekt.injectLazy import java.io.InputStream import java.util.concurrent.TimeUnit +import kotlin.math.roundToInt /** * View of the ViewPager that contains a page of a chapter. @@ -55,14 +62,15 @@ import java.util.concurrent.TimeUnit @SuppressLint("ViewConstructor") class PagerPageHolder( val viewer: PagerViewer, - val page: ReaderPage + val page: ReaderPage, + private var extraPage: ReaderPage? = null ) : FrameLayout(viewer.activity), ViewPagerAdapter.PositionableView { /** * Item that identifies this view. Needed by the adapter to not recreate views. */ override val item - get() = page + get() = page to extraPage /** * Loading progress bar to indicate the current progress. @@ -99,14 +107,33 @@ class PagerPageHolder( */ private var progressSubscription: Subscription? = null + /** + * Subscription for status changes of the page. + */ + private var extraStatusSubscription: Subscription? = null + + /** + * Subscription for progress changes of the page. + */ + private var extraProgressSubscription: Subscription? = null + /** * Subscription used to read the header of the image. This is needed in order to instantiate * the appropiate image view depending if the image is animated (GIF). */ private var readImageHeaderSubscription: Subscription? = null + var status: Int = 0 + var extraStatus: Int = 0 + var progress: Int = 0 + var extraProgress: Int = 0 + private var skipExtra = false + + var scope: CoroutineScope? = null + init { addView(progressBar) + scope = CoroutineScope(Job() + Default) observeStatus() setBackgroundColor( when (val theme = viewer.config.readerTheme) { @@ -122,9 +149,13 @@ class PagerPageHolder( @SuppressLint("ClickableViewAccessibility") override fun onDetachedFromWindow() { super.onDetachedFromWindow() - unsubscribeProgress() - unsubscribeStatus() + unsubscribeProgress(1) + unsubscribeStatus(1) + unsubscribeProgress(2) + unsubscribeStatus(2) unsubscribeReadImageHeader() + scope?.cancel() + scope = null subsamplingImageView?.setOnImageEventListener(null) } @@ -139,7 +170,18 @@ class PagerPageHolder( val loader = page.chapter.pageLoader ?: return statusSubscription = loader.getPage(page) .observeOn(AndroidSchedulers.mainThread()) - .subscribe { processStatus(it) } + .subscribe { + status = it + processStatus(it) + } + val extraPage = extraPage ?: return + val loader2 = extraPage.chapter.pageLoader ?: return + extraStatusSubscription = loader2.getPage(extraPage) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + extraStatus = it + processStatus2(it) + } } /** @@ -153,7 +195,28 @@ class PagerPageHolder( .distinctUntilChanged() .onBackpressureLatest() .observeOn(AndroidSchedulers.mainThread()) - .subscribe { value -> progressBar.setProgress(value) } + .subscribe { value -> + progress = value + if (extraPage == null) { + progressBar.setProgress(progress) + } else { + progressBar.setProgress(((progress + extraProgress) / 2 * 0.95f).roundToInt()) + } + } + } + + private fun observeProgress2() { + extraProgressSubscription?.unsubscribe() + val extraPage = extraPage ?: return + extraProgressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS) + .map { extraPage.progress } + .distinctUntilChanged() + .onBackpressureLatest() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { value -> + extraProgress = value + progressBar.setProgress(((progress + extraProgress) / 2 * 0.95f).roundToInt()) + } } /** @@ -170,12 +233,40 @@ class PagerPageHolder( setDownloading() } Page.READY -> { - setImage() - unsubscribeProgress() + if (extraStatus == Page.READY || extraPage == null) { + setImage() + } + unsubscribeProgress(1) } Page.ERROR -> { setError() - unsubscribeProgress() + unsubscribeProgress(1) + } + } + } + + /** + * Called when the status of the page changes. + * + * @param status the new status of the page. + */ + private fun processStatus2(status: Int) { + when (status) { + Page.QUEUE -> setQueued() + Page.LOAD_PAGE -> setLoading() + Page.DOWNLOAD_IMAGE -> { + observeProgress2() + setDownloading() + } + Page.READY -> { + if (this.status == Page.READY) { + setImage() + } + unsubscribeProgress(2) + } + Page.ERROR -> { + setError() + unsubscribeProgress(2) } } } @@ -183,17 +274,19 @@ class PagerPageHolder( /** * Unsubscribes from the status subscription. */ - private fun unsubscribeStatus() { - statusSubscription?.unsubscribe() - statusSubscription = null + private fun unsubscribeStatus(page: Int) { + val subscription = if (page == 1) statusSubscription else extraStatusSubscription + subscription?.unsubscribe() + if (page == 1) statusSubscription = null else extraStatusSubscription = null } /** * Unsubscribes from the progress subscription. */ - private fun unsubscribeProgress() { - progressSubscription?.unsubscribe() - progressSubscription = null + private fun unsubscribeProgress(page: Int) { + val subscription = if (page == 1) progressSubscription else extraProgressSubscription + subscription?.unsubscribe() + if (page == 1) progressSubscription = null else extraProgressSubscription = null } /** @@ -208,59 +301,68 @@ class PagerPageHolder( * Called when the page is queued. */ private fun setQueued() { - progressBar.visible() - retryButton?.gone() - decodeErrorLayout?.gone() + progressBar.isVisible = true + retryButton?.isVisible = false + decodeErrorLayout?.isVisible = false } /** * Called when the page is loading. */ private fun setLoading() { - progressBar.visible() - retryButton?.gone() - decodeErrorLayout?.gone() + progressBar.isVisible = true + retryButton?.isVisible = false + decodeErrorLayout?.isVisible = false } /** * Called when the page is downloading. */ private fun setDownloading() { - progressBar.visible() - retryButton?.gone() - decodeErrorLayout?.gone() + progressBar.isVisible = true + retryButton?.isVisible = false + decodeErrorLayout?.isVisible = false } /** * Called when the page is ready. */ private fun setImage() { - progressBar.visible() - progressBar.completeAndFadeOut() - retryButton?.gone() - decodeErrorLayout?.gone() + progressBar.isVisible = true + if (extraPage == null) { + progressBar.completeAndFadeOut() + } else { + progressBar.setProgress(95) + } + retryButton?.isVisible = false + decodeErrorLayout?.isVisible = false unsubscribeReadImageHeader() val streamFn = page.stream ?: return + val streamFn2 = extraPage?.stream var openStream: InputStream? = null + readImageHeaderSubscription = Observable .fromCallable { val stream = streamFn().buffered(16) - openStream = stream - ImageUtil.findImageType(stream) == ImageUtil.ImageType.GIF + val stream2 = if (extraPage != null) streamFn2?.invoke()?.buffered(16) else null + openStream = this@PagerPageHolder.mergePages(stream, stream2) + ImageUtil.findImageType(stream) == ImageUtil.ImageType.GIF || + if (stream2 != null) ImageUtil.findImageType(stream2) == ImageUtil.ImageType.GIF else false } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doOnNext { isAnimated -> + if (skipExtra) { + splitDoublePages() + } if (!isAnimated) { if (viewer.config.readerTheme >= 2) { val imageView = initSubsamplingImageView() - if (page.bg != null && page.bgType == getBGType( - viewer.config.readerTheme, - context - ) + if (page.bg != null && + page.bgType == getBGType(viewer.config.readerTheme, context) + item.hashCode() ) { imageView.setImage(ImageSource.inputStream(openStream!!)) imageView.background = page.bg @@ -273,9 +375,18 @@ class PagerPageHolder( bytesStream.close() launchUI { - imageView.background = setBG(bytesArray) - page.bg = imageView.background - page.bgType = getBGType(viewer.config.readerTheme, context) + try { + imageView.background = setBG(bytesArray) + } catch (e: Exception) { + Timber.e(e.localizedMessage) + imageView.background = ColorDrawable(Color.WHITE) + } finally { + page.bg = imageView.background + page.bgType = getBGType( + viewer.config.readerTheme, + context + ) + item.hashCode() + } } } } else { @@ -295,8 +406,12 @@ class PagerPageHolder( .doOnUnsubscribe { try { openStream?.close() - } catch (e: Exception) { - } + } catch (e: Exception) {} + } + .doOnError { + try { + openStream?.close() + } catch (e: Exception) {} } .subscribe({}, {}) } @@ -306,9 +421,12 @@ class PagerPageHolder( val preferences by injectLazy() ImageUtil.autoSetBackground( BitmapFactory.decodeByteArray( - bytesArray, 0, bytesArray.size + bytesArray, + 0, + bytesArray.size ), - preferences.readerTheme().get() == 2, context + preferences.readerTheme().get() == 2, + context ) } } @@ -317,23 +435,23 @@ class PagerPageHolder( * Called when the page has an error. */ private fun setError() { - progressBar.gone() - initRetryButton().visible() + progressBar.isVisible = false + initRetryButton().isVisible = true } /** * Called when the image is decoded and going to be displayed. */ private fun onImageDecoded() { - progressBar.gone() + progressBar.isVisible = false } /** * Called when an image fails to decode. */ private fun onImageDecodeError() { - progressBar.gone() - initDecodeErrorLayout().visible() + progressBar.isVisible = false + initDecodeErrorLayout().isVisible = true } /** @@ -342,7 +460,6 @@ class PagerPageHolder( @SuppressLint("PrivateResource") private fun createProgressBar(): ReaderProgressBar { return ReaderProgressBar(context, null).apply { - val size = 48.dpToPx layoutParams = LayoutParams(size, size).apply { gravity = Gravity.CENTER @@ -368,7 +485,18 @@ class PagerPageHolder( setMinimumDpi(90) setMinimumTileDpi(180) setCropBorders(config.imageCropBorders) - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + val topInsets = + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { + viewer.activity.window.decorView.rootWindowInsets.displayCutout?.safeInsetTop?.toFloat() ?: 0f + } else 0f + val bottomInsets = + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { + viewer.activity.window.decorView.rootWindowInsets.displayCutout?.safeInsetBottom?.toFloat() ?: 0f + } else 0f + setExtendPastCutout(config.cutoutBehavior == CUTOUT_START_EXTENDED && config.scaleTypeIsFullFit() && topInsets + bottomInsets > 0) + if ((config.cutoutBehavior != CUTOUT_IGNORE || !config.scaleTypeIsFullFit()) && + android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q + ) { val insets: WindowInsets? = viewer.activity.window.decorView.rootWindowInsets setExtraSpace( 0f, @@ -380,10 +508,28 @@ class PagerPageHolder( setOnImageEventListener( object : SubsamplingScaleImageView.DefaultOnImageEventListener() { override fun onReady() { + var centerV = 0f when (config.imageZoomType) { - ZoomType.Left -> setScaleAndCenter(scale, PointF(0f, 0f)) - ZoomType.Right -> setScaleAndCenter(scale, PointF(sWidth.toFloat(), 0f)) - ZoomType.Center -> setScaleAndCenter(scale, center.also { it?.y = 0f }) + ZoomType.Left -> { + setScaleAndCenter(scale, PointF(0f, 0f)) + } + ZoomType.Right -> { + setScaleAndCenter(scale, PointF(sWidth.toFloat(), 0f)) + centerV = sWidth.toFloat() + } + ZoomType.Center -> { + setScaleAndCenter(scale, center.also { it?.y = 0f }) + centerV = center?.x ?: 0f + } + } + if (config.cutoutBehavior == CUTOUT_START_EXTENDED && + topInsets + bottomInsets > 0 && + config.scaleTypeIsFullFit() + ) { + setScaleAndCenter( + scale, + PointF(centerV, (center?.y?.plus(topInsets)?.minus(bottomInsets) ?: 0f)) + ) } onImageDecoded() } @@ -391,7 +537,8 @@ class PagerPageHolder( override fun onImageLoadError(e: Exception) { onImageDecodeError() } - }) + } + ) } addView(subsamplingImageView) return subsamplingImageView!! @@ -409,16 +556,18 @@ class PagerPageHolder( setZoomTransitionDuration(viewer.config.doubleTapAnimDuration) setScaleLevels(1f, 2f, 3f) // Force 2 scale levels on double tap - setOnDoubleTapListener(object : GestureDetector.SimpleOnGestureListener() { - override fun onDoubleTap(e: MotionEvent): Boolean { - if (scale > 1f) { - setScale(1f, e.x, e.y, true) - } else { - setScale(2f, e.x, e.y, true) + setOnDoubleTapListener( + object : GestureDetector.SimpleOnGestureListener() { + override fun onDoubleTap(e: MotionEvent): Boolean { + if (scale > 1f) { + setScale(1f, e.x, e.y, true) + } else { + setScale(2f, e.x, e.y, true) + } + return true } - return true } - }) + ) } addView(imageView) return imageView!! @@ -437,6 +586,9 @@ class PagerPageHolder( setText(R.string.retry) setOnClickListener { page.chapter.pageLoader?.retryPage(page) + extraPage?.let { + it.chapter.pageLoader?.retryPage(it) + } } } addView(retryButton) @@ -480,14 +632,14 @@ class PagerPageHolder( } val imageUrl = page.imageUrl - if (imageUrl.orEmpty().startsWith("http", true)) { + if (imageUrl != null && imageUrl.startsWith("http", true)) { PagerButton(context, viewer).apply { layoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { setMargins(margins, margins, margins, margins) } setText(R.string.open_in_browser) setOnClickListener { - val intent = Intent(Intent.ACTION_VIEW, imageUrl!!.toUri()) + val intent = Intent(Intent.ACTION_VIEW, imageUrl.toUri()) context.startActivity(intent) } @@ -499,6 +651,94 @@ class PagerPageHolder( return decodeLayout } + private fun mergePages(imageStream: InputStream, imageStream2: InputStream?): InputStream { + imageStream2 ?: return imageStream + if (page.fullPage) return imageStream + if (ImageUtil.findImageType(imageStream) == ImageUtil.ImageType.GIF) { + page.fullPage = true + skipExtra = true + return imageStream + } else if (ImageUtil.findImageType(imageStream2) == ImageUtil.ImageType.GIF) { + page.isolatedPage = true + extraPage?.fullPage = true + skipExtra = true + return imageStream + } + val imageBytes = imageStream.readBytes() + val imageBitmap = try { + BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) + } catch (e: Exception) { + imageStream2.close() + imageStream.close() + page.fullPage = true + skipExtra = true + Timber.e("Cannot combine pages ${e.message}") + return imageBytes.inputStream() + } + scope?.launchUI { progressBar.setProgress(96) } + val height = imageBitmap.height + val width = imageBitmap.width + + if (height < width) { + imageStream2.close() + imageStream.close() + page.fullPage = true + skipExtra = true + return imageBytes.inputStream() + } + + val imageBytes2 = imageStream2.readBytes() + val imageBitmap2 = try { + BitmapFactory.decodeByteArray(imageBytes2, 0, imageBytes2.size) + } catch (e: Exception) { + imageStream2.close() + imageStream.close() + extraPage?.fullPage = true + skipExtra = true + page.isolatedPage = true + Timber.e("Cannot combine pages ${e.message}") + return imageBytes.inputStream() + } + scope?.launchUI { progressBar.setProgress(97) } + val height2 = imageBitmap2.height + val width2 = imageBitmap2.width + + if (height2 < width2) { + imageStream2.close() + imageStream.close() + extraPage?.fullPage = true + page.isolatedPage = true + skipExtra = true + return imageBytes.inputStream() + } + val isLTR = (viewer !is R2LPagerViewer).xor(viewer.config.invertDoublePages) + val bg = if (viewer.config.readerTheme >= 2 || viewer.config.readerTheme == 0) { + Color.WHITE + } else { + Color.BLACK + } + + imageStream.close() + imageStream2.close() + return ImageUtil.mergeBitmaps(imageBitmap, imageBitmap2, isLTR, bg) { + scope?.launchUI { + if (it == 100) { + progressBar.completeAndFadeOut() + } else { + progressBar.setProgress(it) + } + } + } + } + + private fun splitDoublePages() { + extraPage ?: return + viewer.splitDoublePages(page) + if (extraPage?.fullPage == true) { + extraPage = null + } + } + /** * Extension method to set a [stream] into this ImageView. */ @@ -514,7 +754,7 @@ class PagerPageHolder( fun getBGType(readerTheme: Int, context: Context): Int { return if (readerTheme == 3) { if (context.isInNightMode()) 2 else 1 - } else 0 + } else 0 + (context.resources.configuration?.orientation ?: 0) * 10 } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt index f6e459b742..f8de6a200e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt @@ -98,7 +98,7 @@ class PagerTransitionHolder( val nextChapter = transition.to textView.text = if (nextChapter != null) { - SpannableStringBuilder().append(context.getString(R.string.finished)) + SpannableStringBuilder().append(context.getString(R.string.finished_chapter)) .bold { append("\n${transition.from.chapter.name}\n\n") } .append(context.getString(R.string.next)) .bold { append("\n${nextChapter.chapter.name}\n\n") } @@ -128,11 +128,11 @@ class PagerTransitionHolder( textView.text = if (prevChapter != null) { SpannableStringBuilder().apply { - append(context.getString(R.string.current)) + append(context.getString(R.string.current_chapter)) setSpan(StyleSpan(Typeface.BOLD), 0, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) append("\n${transition.from.chapter.name}\n\n") val currSize = length - append(context.getString(R.string.previous)) + append(context.getString(R.string.previous_title)) setSpan(StyleSpan(Typeface.BOLD), currSize, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) append("\n${prevChapter.chapter.name}\n\n") } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt index 790f8eba64..39bccd186a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt @@ -1,10 +1,12 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager +import android.graphics.PointF import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent import android.view.View import android.view.ViewGroup.LayoutParams +import androidx.core.view.isVisible import androidx.viewpager.widget.ViewPager import com.elvishew.xlog.XLog import eu.kanade.tachiyomi.R @@ -13,8 +15,10 @@ import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer -import eu.kanade.tachiyomi.util.view.gone -import eu.kanade.tachiyomi.util.view.visible +import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import timber.log.Timber /** * Implementation of a [BaseViewer] to display pages with a [ViewPager]. @@ -22,6 +26,8 @@ import eu.kanade.tachiyomi.util.view.visible @Suppress("LeakingThis") abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { + private val scope = MainScope() + /** * View pager used by this viewer. It's abstract to implement L2R, R2L and vertical pagers on * top of this class. @@ -31,7 +37,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { /** * Configuration used by the pager, like allow taps, scale mode on images, page transitions... */ - val config = PagerConfig(this) + val config = PagerConfig(scope, this) /** * Adapter of the pager. @@ -58,43 +64,52 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { field = value if (value) { awaitingIdleViewerChapters?.let { - XLog.d("isIdle previousChapter %s", it.prevChapter?.urlAndName()) - XLog.d("isIdle currentChapter %s", it.currChapter.urlAndName()) - XLog.d("isIdle nextChaptter %s", it.nextChapter?.urlAndName()) - setChaptersInternal(it) + setChaptersDoubleShift(it) awaitingIdleViewerChapters = null } } } + private var pagerListener = object : ViewPager.SimpleOnPageChangeListener() { + override fun onPageSelected(position: Int) { + onPageChange(position) + } + + override fun onPageScrollStateChanged(state: Int) { + isIdle = state == ViewPager.SCROLL_STATE_IDLE + } + } + init { - pager.gone() // Don't layout the pager yet + pager.isVisible = false // Don't layout the pager yet pager.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) pager.offscreenPageLimit = 1 pager.id = R.id.reader_pager pager.adapter = adapter - pager.addOnPageChangeListener(object : ViewPager.SimpleOnPageChangeListener() { - override fun onPageSelected(position: Int) { - onPageChange(position) + pager.addOnPageChangeListener(pagerListener) + pager.tapListener = f@{ event -> + if (!config.tappingEnabled) { + activity.toggleMenu() + return@f } - override fun onPageScrollStateChanged(state: Int) { - isIdle = state == ViewPager.SCROLL_STATE_IDLE - } - }) - pager.tapListener = { event -> - val positionX = event.x - when { - positionX < pager.width * 0.33f && config.tappingEnabled -> moveLeft() - positionX > pager.width * 0.66f && config.tappingEnabled -> moveRight() - else -> activity.toggleMenu() + val pos = PointF(event.rawX / pager.width, event.rawY / pager.height) + val navigator = config.navigator + when (navigator.getAction(pos)) { + ViewerNavigation.NavigationRegion.MENU -> activity.toggleMenu() + ViewerNavigation.NavigationRegion.NEXT -> moveToNext() + ViewerNavigation.NavigationRegion.PREV -> moveToPrevious() + ViewerNavigation.NavigationRegion.RIGHT -> moveRight() + ViewerNavigation.NavigationRegion.LEFT -> moveLeft() } } pager.longTapListener = f@{ if (activity.menuVisible || config.longTapEnabled) { - val item = adapter.items.getOrNull(pager.currentItem) - if (item is ReaderPage) { - activity.onPageLongTap(item) + val item = adapter.joinedItems.getOrNull(pager.currentItem) + val firstPage = item?.first as? ReaderPage + val secondPage = item?.second as? ReaderPage + if (firstPage is ReaderPage) { + activity.onPageLongTap(firstPage, secondPage) return@f true } } @@ -104,6 +119,16 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { config.imagePropertyChangedListener = { refreshAdapter() } + + config.reloadChapterListener = { + activity.reloadChapters(it) + } + + config.navigationModeChangedListener = { + val showOnStart = config.navigationOverlayForNewUser + activity.binding.navigationOverlay.setNavigation(config.navigator, showOnStart) + } + config.navigationModeInvertedListener = { activity.binding.navigationOverlay.showNavigationAgain() } } /** @@ -118,17 +143,22 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { return pager } + override fun destroy() { + super.destroy() + scope.cancel() + } + /** * Called when a new page (either a [ReaderPage] or [ChapterTransition]) is marked as active */ - private fun onPageChange(position: Int) { - val page = adapter.items.getOrNull(position) + fun onPageChange(position: Int) { + val page = adapter.joinedItems.getOrNull(position) if (page != null && currentPage != page) { - val allowPreload = checkAllowPreload(page as? ReaderPage) - currentPage = page - when (page) { - is ReaderPage -> onReaderPageSelected(page, allowPreload) - is ChapterTransition -> onTransitionSelected(page) + val allowPreload = checkAllowPreload(page.first as? ReaderPage) + currentPage = page.first + when (val aPage = page.first) { + is ReaderPage -> onReaderPageSelected(aPage, allowPreload, page.second != null) + is ChapterTransition -> onTransitionSelected(aPage) } } } @@ -156,18 +186,21 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { * Called when a [ReaderPage] is marked as active. It notifies the * activity of the change and requests the preload of the next chapter if this is the last page. */ - private fun onReaderPageSelected(page: ReaderPage, allowPreload: Boolean) { - activity.onPageSelected(page) + private fun onReaderPageSelected(page: ReaderPage, allowPreload: Boolean, hasExtraPage: Boolean) { + activity.onPageSelected(page, hasExtraPage) + val offset = if (hasExtraPage) 1 else 0 val pages = page.chapter.pages ?: return - XLog.d("onReaderPageSelected: %s/%s", page.number, pages.size) + if (hasExtraPage) { + Timber.d("onReaderPageSelected: ${page.number}-${page.number + offset}/${pages.size}") + } else { + Timber.d("onReaderPageSelected: ${page.number}/${pages.size}") + } // Preload next chapter once we're within the last 5 pages of the current chapter val inPreloadRange = pages.size - page.number < 5 if (inPreloadRange && allowPreload && page.chapter == adapter.currentChapter) { - XLog.d("Request preload next chapter because we're at page %s of %s", page.number, pages.size) - XLog.d("Current chapter %s", adapter.currentChapter?.urlAndName()) + Timber.d("Request preload next chapter because we're at page ${page.number} of ${pages.size}") adapter.nextTransition?.to?.let { - XLog.d("Next preload chapter %s", adapter.nextTransition?.to?.urlAndName()) activity.requestPreloadChapter(it) } } @@ -181,7 +214,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { XLog.d("onTransitionSelected: %s", transition) val toChapter = transition.to if (toChapter != null) { - XLog.d("Request preload destination chapter because we're on the transition") + Timber.d("Request preload destination chapter because we're on the transition") activity.requestPreloadChapter(toChapter) } else if (transition is ChapterTransition.Next) { // No more chapters, show menu because the user is probably going to close the reader @@ -189,13 +222,29 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { } } + fun setChaptersDoubleShift(chapters: ViewerChapters) { + // Remove Listener since we're about to change the size of the items + // If we don't the size change could put us on a new chapter + pager.removeOnPageChangeListener(pagerListener) + setChaptersInternal(chapters) + pager.addOnPageChangeListener(pagerListener) + // Since we removed the listener while shifting, call page change to update the ui + onPageChange(pager.currentItem) + } + + fun updateShifting(page: ReaderPage? = null) { + adapter.pageToShift = page ?: adapter.joinedItems[pager.currentItem].first as? ReaderPage + } + + fun getShiftedPage(): ReaderPage? = adapter.pageToShift + /** * Tells this viewer to set the given [chapters] as active. If the pager is currently idle, * it sets the chapters immediately, otherwise they are saved and set when it becomes idle. */ override fun setChapters(chapters: ViewerChapters) { if (isIdle) { - setChaptersInternal(chapters) + setChaptersDoubleShift(chapters) } else { awaitingIdleViewerChapters = chapters } @@ -205,8 +254,8 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { * Sets the active [chapters] on this pager. */ private fun setChaptersInternal(chapters: ViewerChapters) { - XLog.d("setChaptersInternal") - val forceTransition = config.alwaysShowChapterTransition || adapter.items.getOrNull( + Timber.d("setChaptersInternal") + val forceTransition = config.alwaysShowChapterTransition || adapter.joinedItems.getOrNull( pager .currentItem ) is ChapterTransition @@ -217,39 +266,42 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { XLog.d("Pager first layout") val pages = chapters.currChapter.pages ?: return moveToPage(pages[chapters.currChapter.requestedPage]) - pager.visible() + pager.isVisible = true } + activity.invalidateOptionsMenu() } /** * Tells this viewer to move to the given [page]. */ - override fun moveToPage(page: ReaderPage) { - XLog.d("moveToPage %s", page.number) - val position = adapter.items.indexOf(page) + override fun moveToPage(page: ReaderPage, animated: Boolean) { + Timber.d("moveToPage ${page.number}") + val position = adapter.joinedItems.indexOfFirst { it.first == page || it.second == page } if (position != -1) { val currentPosition = pager.currentItem - pager.setCurrentItem(position, true) + pager.setCurrentItem(position, animated) // manually call onPageChange since ViewPager listener is not triggered in this case if (currentPosition == position) { onPageChange(position) + } else { + // Call this since with double shift onPageChange wont get called (it shouldn't) + // Instead just update the page count in ui + val joinedItem = adapter.joinedItems.firstOrNull { it.first == page || it.second == page } + activity.onPageSelected( + joinedItem?.first as? ReaderPage ?: page, + joinedItem?.second != null + ) } } else { XLog.d("Page %s not found in adapter", page) } } - /** - * Moves to the next page. - */ - open fun moveToNext() { + override fun moveToNext() { moveRight() } - /** - * Moves to the previous page. - */ - open fun moveToPrevious() { + override fun moveToPrevious() { moveLeft() } @@ -338,6 +390,10 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer { return true } + fun splitDoublePages(currentPage: ReaderPage) { + adapter.splitDoublePages(currentPage) + } + /** * Called from the containing activity when a generic motion [event] is received. It should * return true if the event was handled, false otherwise. diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt index 19dfa1b84a..1dd3a530b3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt @@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters import eu.kanade.tachiyomi.widget.ViewPagerAdapter +import kotlin.math.max /** * Pager adapter used by this [viewer] to where [ViewerChapters] updates are posted. @@ -15,15 +16,26 @@ import eu.kanade.tachiyomi.widget.ViewPagerAdapter class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() { /** - * List of currently set items. + * Paired list of currently set items. */ - var items: List = emptyList() + var joinedItems: MutableList> = mutableListOf() private set + /** Single list of items */ + private var subItems: MutableList = mutableListOf() + var nextTransition: ChapterTransition.Next? = null private set + /** Page used to start the shifted pages */ + var pageToShift: ReaderPage? = null + + /** Varibles used to check if config of the pages have changed */ + private var shifted = viewer.config.shiftDoublePage + private var doubledUp = viewer.config.doublePages + var currentChapter: ReaderChapter? = null + var forceTransition = false /** * Updates this adapter with the given [chapters]. It handles setting a few pages of the @@ -33,17 +45,21 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() { fun setChapters(chapters: ViewerChapters, forceTransition: Boolean) { val newItems = mutableListOf() - XLog.d("ViewerChapter previous chapter %s", chapters.prevChapter?.urlAndName()) - XLog.d("ViewerChapter current chapter %s", chapters.currChapter.urlAndName()) - XLog.d("ViewerChapter next chapter %s", chapters.nextChapter?.urlAndName()) - + this.forceTransition = forceTransition // Add previous chapter pages and transition. if (chapters.prevChapter != null) { // We only need to add the last few pages of the previous chapter, because it'll be // selected as the current chapter when one of those pages is selected. val prevPages = chapters.prevChapter.pages + // We will take an even number of pages if the page count if even + // however we should take account full pages when deciding + val numberOfFullPages = + ( + chapters.prevChapter.pages?.count { it.fullPage || it.isolatedPage } + ?: 0 + ) if (prevPages != null) { - newItems.addAll(prevPages.takeLast(2)) + newItems.addAll(prevPages.takeLast(if ((prevPages.size + numberOfFullPages) % 2 == 0) 2 else 3)) } } @@ -79,27 +95,34 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() { } } - if (viewer is R2LPagerViewer) { - newItems.reverse() - } + subItems = newItems.toMutableList() - items = newItems - notifyDataSetChanged() + var useSecondPage = false + if (shifted != viewer.config.shiftDoublePage || (doubledUp != viewer.config.doublePages && doubledUp)) { + if (shifted && (doubledUp == viewer.config.doublePages)) { + useSecondPage = true + } + shifted = viewer.config.shiftDoublePage + } + doubledUp = viewer.config.doublePages + setJoinedItems(useSecondPage) } /** * Returns the amount of items of the adapter. */ override fun getCount(): Int { - return items.size + return joinedItems.size } /** * Creates a new view for the item at the given [position]. */ override fun createView(container: ViewGroup, position: Int): View { - return when (val item = items[position]) { - is ReaderPage -> PagerPageHolder(viewer, item) + val item = joinedItems[position].first + val item2 = joinedItems[position].second + return when (item) { + is ReaderPage -> PagerPageHolder(viewer, item, item2 as? ReaderPage) is ChapterTransition -> PagerTransitionHolder(viewer, item) else -> throw NotImplementedError("Holder for ${item.javaClass} not implemented") } @@ -110,13 +133,165 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() { */ override fun getItemPosition(view: Any): Int { if (view is PositionableView) { - val position = items.indexOf(view.item) + val position = joinedItems.indexOfFirst { + view.item == (it.first to it.second) + } if (position != -1) { return position } else { - XLog.d("Position for %s not found", view.item) + XLog.d("Position for ${view.item} not found") } } return POSITION_NONE } + + fun splitDoublePages(current: ReaderPage) { + val oldCurrent = joinedItems.getOrNull(viewer.pager.currentItem) + setJoinedItems( + oldCurrent?.second == current || + (current.index + 1) < ( + ( + oldCurrent?.second + ?: oldCurrent?.first + ) as? ReaderPage + )?.index ?: 0 + ) + + // The listener may be removed when we split a page, so the ui may not have updated properly + // This case usually happens when we load a new chapter and the first 2 pages need to split og + viewer.pager.post { + viewer.onPageChange(viewer.pager.currentItem) + } + } + + private fun setJoinedItems(useSecondPage: Boolean = false) { + val oldCurrent = joinedItems.getOrNull(viewer.pager.currentItem) + if (!viewer.config.doublePages) { + // If not in double mode, set up items like before + subItems.forEach { + (it as? ReaderPage)?.shiftedPage = false + } + this.joinedItems = subItems.map { Pair(it, null) }.toMutableList() + if (viewer is R2LPagerViewer) { + joinedItems.reverse() + } + } else { + val pagedItems = mutableListOf>() + val otherItems = mutableListOf() + pagedItems.add(mutableListOf()) + // Step 1: segment the pages and transition pages + subItems.forEach { + if (it is ReaderPage) { + pagedItems.last().add(it) + } else { + otherItems.add(it) + pagedItems.add(mutableListOf()) + } + } + var pagedIndex = 0 + val subJoinedItems = mutableListOf>() + // Step 2: run through each set of pages + pagedItems.forEach { items -> + + items.forEach { + it?.shiftedPage = false + } + // Step 3: If pages have been shifted, + if (viewer.config.shiftDoublePage) { + run loop@{ + var index = items.indexOf(pageToShift) + if (pageToShift?.fullPage == true) { + index = max(0, index - 1) + } + // Go from the current page and work your way back to the first page, + // or the first page that's a full page. + // This is done in case user tries to shift a page after a full page + val fullPageBeforeIndex = max( + 0, + ( + if (index > -1) ( + items.take(index).indexOfLast { it?.fullPage == true } + ) else -1 + ) + ) + // Add a shifted page to the first place there isnt a full page + (fullPageBeforeIndex until items.size).forEach { + if (items[it]?.fullPage == false) { + items[it]?.shiftedPage = true + return@loop + } + } + } + } + + // Step 4: Add blanks for chunking + var itemIndex = 0 + while (itemIndex < items.size) { + items[itemIndex]?.isolatedPage = false + if (items[itemIndex]?.fullPage == true || items[itemIndex]?.shiftedPage == true) { + // Add a 'blank' page after each full page. It will be used when chunked to solo a page + items.add(itemIndex + 1, null) + if (items[itemIndex]?.fullPage == true && itemIndex > 0 && + items[itemIndex - 1] != null && (itemIndex - 1) % 2 == 0 + ) { + // If a page is a full page, check if the previous page needs to be isolated + // we should check if it's an even or odd page, since even pages need shifting + // For example if Page 1 is full, Page 0 needs to be isolated + // No need to take account shifted pages, because null additions should + // always have an odd index in the list + items[itemIndex - 1]?.isolatedPage = true + items.add(itemIndex, null) + itemIndex++ + } + itemIndex++ + } + itemIndex++ + } + + // Step 5: chunk em + if (items.isNotEmpty()) { + subJoinedItems.addAll( + items.chunked(2).map { Pair(it.first()!!, it.getOrNull(1)) } + ) + } + otherItems.getOrNull(pagedIndex)?.let { + subJoinedItems.add(Pair(it, null)) + pagedIndex++ + } + } + if (viewer is R2LPagerViewer) { + subJoinedItems.reverse() + } + + this.joinedItems = subJoinedItems + } + notifyDataSetChanged() + + // Step 6: Move back to our previous page or transition page + // The listener is likely off around now, but either way when shifting or doubling, + // we need to set the page back correctly + // We will however shift to the first page of the new chapter if the last page we were are + // on is not in the new chapter that has loaded + val newPage = + when { + (oldCurrent?.first as? ReaderPage)?.chapter != currentChapter && + (oldCurrent?.first as? ChapterTransition)?.from != currentChapter -> subItems.find { (it as? ReaderPage)?.chapter == currentChapter } + useSecondPage -> (oldCurrent?.second ?: oldCurrent?.first) + else -> oldCurrent?.first ?: return + } + var index = joinedItems.indexOfFirst { it.first == newPage || it.second == newPage } + if (newPage is ChapterTransition && index == -1 && !forceTransition) { + val newerPage = if (newPage is ChapterTransition.Next) { + joinedItems.filter { + (it.first as? ReaderPage)?.chapter == newPage.to + }.minByOrNull { (it.first as? ReaderPage)?.index ?: Int.MAX_VALUE }?.first + } else { + joinedItems.filter { + (it.first as? ReaderPage)?.chapter == newPage.to + }.maxByOrNull { (it.first as? ReaderPage)?.index ?: Int.MIN_VALUE }?.first + } + index = joinedItems.indexOfFirst { it.first == newerPage || it.second == newerPage } + } + viewer.pager.setCurrentItem(index, false) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt index 409d9c71f1..a312f7b4c5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt @@ -22,6 +22,7 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter PAGE_VIEW is ChapterTransition -> TRANSITION_VIEW else -> error("Unknown view type for ${item.javaClass}") diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonBaseHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonBaseHolder.kt index 10a01b1377..eff894517c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonBaseHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonBaseHolder.kt @@ -3,13 +3,13 @@ package eu.kanade.tachiyomi.ui.reader.viewer.webtoon import android.content.Context import android.view.View import android.view.ViewGroup.LayoutParams -import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder +import androidx.recyclerview.widget.RecyclerView import rx.Subscription abstract class WebtoonBaseHolder( view: View, protected val viewer: WebtoonViewer -) : BaseViewHolder(view) { +) : RecyclerView.ViewHolder(view) { /** * Context getter because it's used often. diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt index 4637b1b037..f9c8168a5c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt @@ -2,15 +2,30 @@ package eu.kanade.tachiyomi.ui.reader.viewer.webtoon import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.ui.reader.viewer.ViewerConfig +import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation +import eu.kanade.tachiyomi.ui.reader.viewer.navigation.EdgeNavigation +import eu.kanade.tachiyomi.ui.reader.viewer.navigation.KindlishNavigation +import eu.kanade.tachiyomi.ui.reader.viewer.navigation.LNavigation +import eu.kanade.tachiyomi.ui.reader.viewer.navigation.RightAndLeftNavigation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get /** * Configuration used by webtoon viewers. */ -class WebtoonConfig(preferences: PreferencesHelper = Injekt.get()) : ViewerConfig(preferences) { +class WebtoonConfig( + scope: CoroutineScope, + preferences: PreferencesHelper = Injekt.get() +) : ViewerConfig(preferences, scope) { - var imageCropBorders = false + var webtoonCropBorders = false + private set + + var verticalCropBorders = true private set var sidePadding = 0 @@ -18,16 +33,64 @@ class WebtoonConfig(preferences: PreferencesHelper = Injekt.get()) : ViewerConfi var enableZoomOut = false private set + var zoomPropertyChangedListener: ((Boolean) -> Unit)? = null init { + preferences.navigationModeWebtoon() + .register({ navigationMode = it }, { updateNavigation(it) }) + + preferences.webtoonNavInverted() + .register( + { tappingInverted = it }, + { + navigator.invertMode = it + } + ) + + preferences.webtoonNavInverted().asFlow() + .drop(1) + .onEach { + navigationModeInvertedListener?.invoke() + } + .launchIn(scope) + preferences.cropBordersWebtoon() - .register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() }) + .register({ webtoonCropBorders = it }, { imagePropertyChangedListener?.invoke() }) + + preferences.cropBorders() + .register({ verticalCropBorders = it }, { imagePropertyChangedListener?.invoke() }) preferences.webtoonSidePadding() .register({ sidePadding = it }, { imagePropertyChangedListener?.invoke() }) preferences.webtoonEnableZoomOut() .register({ enableZoomOut = it }, { zoomPropertyChangedListener?.invoke(it) }) + + navigationOverlayForNewUser = preferences.showNavigationOverlayNewUserWebtoon().get() + if (navigationOverlayForNewUser) { + preferences.showNavigationOverlayNewUserWebtoon().set(false) + } + } + + override var navigator: ViewerNavigation = defaultNavigation() + set(value) { + field = value.also { it.invertMode = tappingInverted } + } + + override fun defaultNavigation(): ViewerNavigation { + return LNavigation() + } + + override fun updateNavigation(navigationMode: Int) { + this.navigator = when (navigationMode) { + 0 -> defaultNavigation() + 1 -> LNavigation() + 2 -> KindlishNavigation() + 3 -> EdgeNavigation() + 4 -> RightAndLeftNavigation() + else -> defaultNavigation() + } + navigationModeChangedListener?.invoke() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt index f6a00d2d49..b9dc28907c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt @@ -42,12 +42,13 @@ class WebtoonLayoutManager(activity: ReaderActivity) : LinearLayoutManager(activ val fromIndex = childCount - 1 val toIndex = -1 - val child = if (mOrientation == HORIZONTAL) + val child = if (mOrientation == HORIZONTAL) { mHorizontalBoundCheck .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, 0) - else + } else { mVerticalBoundCheck .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, 0) + } return if (child == null) NO_POSITION else getPosition(child) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt index 07429f1b20..55aa834e52 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt @@ -15,6 +15,7 @@ import android.widget.TextView import androidx.appcompat.widget.AppCompatButton import androidx.appcompat.widget.AppCompatImageView import androidx.core.net.toUri +import androidx.core.view.isVisible import coil.clear import coil.loadAny import coil.request.CachePolicy @@ -26,9 +27,7 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressBar import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.dpToPx -import eu.kanade.tachiyomi.util.view.gone import eu.kanade.tachiyomi.util.view.updatePaddingRelative -import eu.kanade.tachiyomi.util.view.visible import eu.kanade.tachiyomi.widget.GifViewTarget import rx.Observable import rx.Subscription @@ -64,6 +63,7 @@ class WebtoonPageHolder( * Image view that supports subsampling on zoom. */ private var subsamplingImageView: SubsamplingScaleImageView? = null + private var cropBorders: Boolean = false /** * Simple image view only used on GIFs. @@ -108,7 +108,7 @@ class WebtoonPageHolder( private var readImageHeaderSubscription: Subscription? = null init { - frame.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + refreshLayoutParams() frame.setBackgroundColor(Color.BLACK) } @@ -127,7 +127,7 @@ class WebtoonPageHolder( marginEnd = margin.toInt() marginStart = margin.toInt() } - if (!viewer.isContinuous) { + if (viewer.hasMargins) { frame.updatePaddingRelative(bottom = 15.dpToPx) } } @@ -142,9 +142,9 @@ class WebtoonPageHolder( removeDecodeErrorLayout() subsamplingImageView?.recycle() - subsamplingImageView?.gone() + subsamplingImageView?.isVisible = false imageView?.clear() - imageView?.gone() + imageView?.isVisible = false progressBar.setProgress(0) } @@ -235,9 +235,9 @@ class WebtoonPageHolder( * Called when the page is queued. */ private fun setQueued() { - progressContainer.visible() - progressBar.visible() - retryContainer?.gone() + progressContainer.isVisible = true + progressBar.isVisible = true + retryContainer?.isVisible = false removeDecodeErrorLayout() } @@ -245,9 +245,9 @@ class WebtoonPageHolder( * Called when the page is loading. */ private fun setLoading() { - progressContainer.visible() - progressBar.visible() - retryContainer?.gone() + progressContainer.isVisible = true + progressBar.isVisible = true + retryContainer?.isVisible = false removeDecodeErrorLayout() } @@ -255,9 +255,9 @@ class WebtoonPageHolder( * Called when the page is downloading */ private fun setDownloading() { - progressContainer.visible() - progressBar.visible() - retryContainer?.gone() + progressContainer.isVisible = true + progressBar.isVisible = true + retryContainer?.isVisible = false removeDecodeErrorLayout() } @@ -265,10 +265,10 @@ class WebtoonPageHolder( * Called when the page is ready. */ private fun setImage() { - progressContainer.visible() - progressBar.visible() + progressContainer.isVisible = true + progressBar.isVisible = true progressBar.completeAndFadeOut() - retryContainer?.gone() + retryContainer?.isVisible = false removeDecodeErrorLayout() unsubscribeReadImageHeader() @@ -287,11 +287,11 @@ class WebtoonPageHolder( .doOnNext { isAnimated -> if (!isAnimated) { val subsamplingView = initSubsamplingImageView() - subsamplingView.visible() + subsamplingView.isVisible = true subsamplingView.setImage(ImageSource.inputStream(openStream!!)) } else { val imageView = initImageView() - imageView.visible() + imageView.isVisible = true imageView.setImage(openStream!!) } } @@ -307,23 +307,23 @@ class WebtoonPageHolder( * Called when the page has an error. */ private fun setError() { - progressContainer.gone() - initRetryLayout().visible() + progressContainer.isVisible = false + initRetryLayout().isVisible = true } /** * Called when the image is decoded and going to be displayed. */ private fun onImageDecoded() { - progressContainer.gone() + progressContainer.isVisible = false } /** * Called when the image fails to decode. */ private fun onImageDecodeError() { - progressContainer.gone() - initDecodeErrorLayout().visible() + progressContainer.isVisible = false + initDecodeErrorLayout().isVisible = true } /** @@ -349,9 +349,18 @@ class WebtoonPageHolder( * Initializes a subsampling scale view. */ private fun initSubsamplingImageView(): SubsamplingScaleImageView { - if (subsamplingImageView != null) return subsamplingImageView!! - val config = viewer.config + val newCropBorders = if (viewer.hasMargins) config.verticalCropBorders else config.webtoonCropBorders + + subsamplingImageView?.apply { + if (newCropBorders != cropBorders) { + cropBorders = newCropBorders + setCropBorders(newCropBorders) + } + return this + } + + cropBorders = newCropBorders subsamplingImageView = WebtoonSubsamplingImageView(context).apply { setMaxTileSize(viewer.activity.maxBitmapSize) @@ -359,16 +368,18 @@ class WebtoonPageHolder( setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_FIT_WIDTH) setMinimumDpi(90) setMinimumTileDpi(180) - setCropBorders(config.imageCropBorders) - setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() { - override fun onReady() { - onImageDecoded() - } - - override fun onImageLoadError(e: Exception) { - onImageDecodeError() + setCropBorders(cropBorders) + setOnImageEventListener( + object : SubsamplingScaleImageView.DefaultOnImageEventListener() { + override fun onReady() { + onImageDecoded() + } + + override fun onImageLoadError(e: Exception) { + onImageDecodeError() + } } - }) + ) } frame.addView(subsamplingImageView, MATCH_PARENT, MATCH_PARENT) return subsamplingImageView!! @@ -451,14 +462,14 @@ class WebtoonPageHolder( } val imageUrl = page?.imageUrl - if (imageUrl.orEmpty().startsWith("http")) { + if (imageUrl != null && imageUrl.startsWith("http")) { AppCompatButton(context).apply { layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { setMargins(0, margins, 0, margins) } setText(R.string.open_in_browser) setOnClickListener { - val intent = Intent(Intent.ACTION_VIEW, imageUrl!!.toUri()) + val intent = Intent(Intent.ACTION_VIEW, imageUrl.toUri()) context.startActivity(intent) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt index 0ee2aeebc8..197142922c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt @@ -121,21 +121,23 @@ open class WebtoonRecyclerView @JvmOverloads constructor( animatorSet.duration = ANIMATOR_DURATION_TIME.toLong() animatorSet.interpolator = DecelerateInterpolator() animatorSet.start() - animatorSet.addListener(object : Animator.AnimatorListener { - override fun onAnimationStart(animation: Animator) { - } + animatorSet.addListener( + object : Animator.AnimatorListener { + override fun onAnimationStart(animation: Animator) { + } - override fun onAnimationEnd(animation: Animator) { - isZooming = false - currentScale = toRate - } + override fun onAnimationEnd(animation: Animator) { + isZooming = false + currentScale = toRate + } - override fun onAnimationCancel(animation: Animator) { - } + override fun onAnimationCancel(animation: Animator) { + } - override fun onAnimationRepeat(animation: Animator) { + override fun onAnimationRepeat(animation: Animator) { + } } - }) + ) } fun zoomFling(velocityX: Int, velocityY: Int): Boolean { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt index 1472f31374..8d74385fc2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt @@ -17,13 +17,13 @@ import androidx.appcompat.widget.AppCompatButton import androidx.appcompat.widget.AppCompatTextView import androidx.core.text.bold import androidx.core.text.inSpans +import androidx.core.view.isVisible import com.mikepenz.iconics.typeface.library.materialdesigndx.MaterialDesignDx import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.iconicsDrawableMedium -import eu.kanade.tachiyomi.util.view.visibleIf import rx.Subscription import rx.android.schedulers.AndroidSchedulers @@ -100,7 +100,7 @@ class WebtoonTransitionHolder( val nextChapter = transition.to textView.text = if (nextChapter != null) { - SpannableStringBuilder().append(context.getString(R.string.finished)) + SpannableStringBuilder().append(context.getString(R.string.finished_chapter)) .bold { append("\n${transition.from.chapter.name}\n\n") } .append(context.getString(R.string.next)) .bold { append("\n${nextChapter.chapter.name}\n\n") } @@ -130,11 +130,11 @@ class WebtoonTransitionHolder( textView.text = if (prevChapter != null) { SpannableStringBuilder().apply { - append(context.getString(R.string.current)) + append(context.getString(R.string.current_chapter)) setSpan(StyleSpan(Typeface.BOLD), 0, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) append("\n${transition.from.chapter.name}\n\n") val currSize = length - append(context.getString(R.string.previous)) + append(context.getString(R.string.previous_title)) setSpan(StyleSpan(Typeface.BOLD), currSize, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) append("\n${prevChapter.chapter.name}\n\n") } @@ -165,7 +165,7 @@ class WebtoonTransitionHolder( is ReaderChapter.State.Error -> setError(state.error, transition) is ReaderChapter.State.Loaded -> setLoaded() } - pagesContainer.visibleIf(pagesContainer.childCount > 0) + pagesContainer.isVisible = pagesContainer.childCount > 0 } addSubscription(statusSubscription) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt index 78d42d4dfb..a45f19809e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt @@ -1,11 +1,14 @@ package eu.kanade.tachiyomi.ui.reader.viewer.webtoon import android.graphics.Color +import android.graphics.PointF import android.view.KeyEvent import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import androidx.core.view.isGone +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.WebtoonLayoutManager import com.elvishew.xlog.XLog @@ -14,7 +17,9 @@ import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer -import eu.kanade.tachiyomi.util.view.visible +import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel import rx.subscriptions.CompositeSubscription import kotlin.math.max import kotlin.math.min @@ -22,7 +27,9 @@ import kotlin.math.min /** * Implementation of a [BaseViewer] to display pages with a [RecyclerView]. */ -class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = true) : BaseViewer { +class WebtoonViewer(val activity: ReaderActivity, val hasMargins: Boolean = false) : BaseViewer { + + private val scope = MainScope() /** * Recycler view used by this viewer. @@ -57,7 +64,7 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr /** * Configuration used by this viewer, like allow taps, or crop image borders. */ - val config = WebtoonConfig() + val config = WebtoonConfig(scope) /** * Subscriptions to keep while this viewer is used. @@ -66,43 +73,42 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr init { recycler.setBackgroundColor(Color.BLACK) - recycler.visibility = View.GONE // Don't let the recycler layout yet + recycler.isVisible = false // Don't let the recycler layout yet recycler.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) recycler.itemAnimator = null recycler.layoutManager = layoutManager recycler.adapter = adapter - recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - val position = layoutManager.findLastEndVisibleItemPosition() - val item = adapter.items.getOrNull(position) - val allowPreload = checkAllowPreload(item as? ReaderPage) - if (item != null && currentPage != item) { - currentPage = item - when (item) { - is ReaderPage -> onPageSelected(item, allowPreload) - is ChapterTransition -> onTransitionSelected(item) + recycler.addOnScrollListener( + object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + onScrolled() + + if (dy < 0) { + val firstIndex = layoutManager.findFirstVisibleItemPosition() + val firstItem = adapter.items.getOrNull(firstIndex) + if (firstItem is ChapterTransition.Prev && firstItem.to != null) { + activity.requestPreloadChapter(firstItem.to) + } } } + } + ) + recycler.tapListener = f@{ event -> + if (!config.tappingEnabled) { + activity.toggleMenu() + return@f + } - if (dy < 0) { - val firstIndex = layoutManager.findFirstVisibleItemPosition() - val firstItem = adapter.items.getOrNull(firstIndex) - if (firstItem is ChapterTransition.Prev && firstItem.to != null) { - activity.requestPreloadChapter(firstItem.to) - } + val pos = PointF(event.rawX / recycler.width, event.rawY / recycler.height) + if (!config.tappingEnabled) activity.toggleMenu() + else { + val navigator = config.navigator + when (navigator.getAction(pos)) { + ViewerNavigation.NavigationRegion.MENU -> activity.toggleMenu() + ViewerNavigation.NavigationRegion.NEXT, ViewerNavigation.NavigationRegion.RIGHT -> moveToNext() + ViewerNavigation.NavigationRegion.PREV, ViewerNavigation.NavigationRegion.LEFT -> moveToPrevious() } } - }) - recycler.tapListener = { event -> - val positionX = event.rawX - val positionY = event.rawY - when { - positionY < recycler.height * 0.25 && config.tappingEnabled -> scrollUp() - positionY > recycler.height * 0.75 && config.tappingEnabled -> scrollDown() - positionX < recycler.width * 0.33 && config.tappingEnabled -> scrollUp() - positionX > recycler.width * 0.66 && config.tappingEnabled -> scrollDown() - else -> activity.toggleMenu() - } } recycler.longTapListener = f@{ event -> if (activity.menuVisible || config.longTapEnabled) { @@ -127,6 +133,12 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr frame.enableZoomOut = it } + config.navigationModeChangedListener = { + val showOnStart = config.navigationOverlayForNewUser + activity.binding.navigationOverlay.setNavigation(config.navigator, showOnStart) + } + config.navigationModeInvertedListener = { activity.binding.navigationOverlay.showNavigationAgain() } + frame.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) frame.addView(recycler) } @@ -138,7 +150,7 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr // Initial opening - preload allowed currentPage ?: return true - val nextItem = adapter.items.getOrNull(adapter.items.count() - 1) + val nextItem = adapter.items.getOrNull(adapter.items.size - 1) val nextChapter = (nextItem as? ChapterTransition.Next)?.to ?: (nextItem as? ReaderPage)?.chapter // Allow preload for @@ -163,6 +175,7 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr */ override fun destroy() { super.destroy() + scope.cancel() subscriptions.unsubscribe() } @@ -171,7 +184,7 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr * activity of the change and requests the preload of the next chapter if this is the last page. */ private fun onPageSelected(page: ReaderPage, allowPreload: Boolean) { - activity.onPageSelected(page) + activity.onPageSelected(page, false) val pages = page.chapter.pages ?: return XLog.d("onReaderPageSelected: ${page.number}/${pages.size}") @@ -212,38 +225,54 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr val forceTransition = config.alwaysShowChapterTransition || currentPage is ChapterTransition adapter.setChapters(chapters, forceTransition) - if (recycler.visibility == View.GONE) { + if (recycler.isGone) { XLog.d("Recycler first layout") val pages = chapters.currChapter.pages ?: return - moveToPage(pages[chapters.currChapter.requestedPage]) - recycler.visible() + moveToPage(pages[min(chapters.currChapter.requestedPage, pages.lastIndex)]) + recycler.isVisible = true } } /** * Tells this viewer to move to the given [page]. */ - override fun moveToPage(page: ReaderPage) { + override fun moveToPage(page: ReaderPage, animated: Boolean) { XLog.d("moveToPage") val position = adapter.items.indexOf(page) if (position != -1) { recycler.scrollToPosition(position) + if (layoutManager.findLastEndVisibleItemPosition() == -1) { + onScrolled(position) + } } else { XLog.d("Page $page not found in adapter") } } + fun onScrolled(pos: Int? = null) { + val position = pos ?: layoutManager.findLastEndVisibleItemPosition() + val item = adapter.items.getOrNull(position) + val allowPreload = checkAllowPreload(item as? ReaderPage) + if (item != null && currentPage != item) { + currentPage = item + when (item) { + is ReaderPage -> onPageSelected(item, allowPreload) + is ChapterTransition -> onTransitionSelected(item) + } + } + } + /** * Scrolls up by [scrollDistance]. */ - private fun scrollUp() { + override fun moveToPrevious() { recycler.smoothScrollBy(0, -scrollDistance) } /** * Scrolls down by [scrollDistance]. */ - private fun scrollDown() { + override fun moveToNext() { recycler.smoothScrollBy(0, scrollDistance) } @@ -259,25 +288,25 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr if (!config.volumeKeysEnabled || activity.menuVisible) { return false } else if (isUp) { - if (!config.volumeKeysInverted) scrollDown() else scrollUp() + if (!config.volumeKeysInverted) moveToNext() else moveToPrevious() } } KeyEvent.KEYCODE_VOLUME_UP -> { if (!config.volumeKeysEnabled || activity.menuVisible) { return false } else if (isUp) { - if (!config.volumeKeysInverted) scrollUp() else scrollDown() + if (!config.volumeKeysInverted) moveToPrevious() else moveToNext() } } KeyEvent.KEYCODE_MENU -> if (isUp) activity.toggleMenu() - KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_UP, - KeyEvent.KEYCODE_PAGE_UP -> if (isUp) scrollUp() + KeyEvent.KEYCODE_PAGE_UP -> if (isUp) moveToPrevious() - KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_DOWN, - KeyEvent.KEYCODE_PAGE_DOWN -> if (isUp) scrollDown() + KeyEvent.KEYCODE_PAGE_DOWN -> if (isUp) moveToNext() else -> return false } return true diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterHolder.kt deleted file mode 100644 index a7c5ed03f9..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterHolder.kt +++ /dev/null @@ -1,110 +0,0 @@ -package eu.kanade.tachiyomi.ui.recent_updates - -import android.app.Activity -import android.view.View -import androidx.core.content.ContextCompat -import coil.clear -import coil.transform.CircleCropTransformation -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.image.coil.loadLibraryManga -import eu.kanade.tachiyomi.ui.manga.chapter.BaseChapterHolder -import eu.kanade.tachiyomi.util.chapter.ChapterUtil -import eu.kanade.tachiyomi.util.system.getResourceColor -import kotlinx.android.synthetic.main.download_button.* -import kotlinx.android.synthetic.main.recent_chapters_item.* - -/** - * Holder that contains chapter item - * Uses R.layout.item_recent_chapters. - * UI related actions should be called from here. - * - * @param view the inflated view for this holder. - * @param adapter the adapter handling this holder. - * @param listener a listener to react to single tap and long tap events. - * @constructor creates a new recent chapter holder. - */ -class RecentChapterHolder(private val view: View, private val adapter: RecentChaptersAdapter) : - BaseChapterHolder(view, adapter) { - - /** - * Color of read chapter - */ - private var readColor = view.context.getResourceColor(android.R.attr.textColorHint) - - /** - * Color of unread chapter - */ - private var unreadColor = view.context.getResourceColor(android.R.attr.textColorPrimary) - - /** - * Currently bound item. - */ - private var item: RecentChapterItem? = null - - init { - manga_cover.setOnClickListener { - adapter.coverClickListener.onCoverClick(adapterPosition) - } - } - - /** - * Set values of view - * - * @param item item containing chapter information - */ - fun bind(item: RecentChapterItem) { - this.item = item - - // Set chapter title - chapter_title.text = item.chapter.name - - // Set manga title - title.text = item.manga.title - - if (front_view.translationX == 0f) { - read.setImageDrawable( - ContextCompat.getDrawable( - read.context, - if (item.read) R.drawable.ic_eye_off_24dp - else R.drawable.ic_eye_24dp - ) - ) - } - - // Set cover - if ((view.context as? Activity)?.isDestroyed != true) { - manga_cover.clear() - manga_cover.loadLibraryManga(item.manga) { - transformations(CircleCropTransformation()) - } - } - - val chapterColor = ChapterUtil.chapterColor(itemView.context, item) - chapter_title.setTextColor(chapterColor) - title.setTextColor(chapterColor) - - // Set chapter status - notifyStatus(item.status, item.progress) - resetFrontView() - } - - private fun resetFrontView() { - if (front_view.translationX != 0f) itemView.post { adapter.notifyItemChanged(adapterPosition) } - } - - override fun getFrontView(): View { - return front_view - } - - override fun getRearRightView(): View { - return right_view - } - - /** - * Updates chapter status in view. - * - * @param status download status - */ - fun notifyStatus(status: Int, progress: Int) = - download_button.setDownloadStatus(status, progress) -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterItem.kt deleted file mode 100644 index 4f221d1ca7..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChapterItem.kt +++ /dev/null @@ -1,36 +0,0 @@ -package eu.kanade.tachiyomi.ui.recent_updates - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.ui.manga.chapter.BaseChapterItem - -class RecentChapterItem(chapter: Chapter, val manga: Manga, header: DateItem) : - BaseChapterItem(chapter, header) { - - override fun getLayoutRes(): Int { - return R.layout.recent_chapters_item - } - - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): RecentChapterHolder { - return RecentChapterHolder(view, adapter as RecentChaptersAdapter) - } - - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: RecentChapterHolder, - position: Int, - payloads: MutableList? - ) { - holder.bind(this) - } - - fun filter(text: String): Boolean { - return chapter.name.contains(text, false) || - manga.title.contains(text, false) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersAdapter.kt deleted file mode 100644 index 48b29ef4e3..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersAdapter.kt +++ /dev/null @@ -1,42 +0,0 @@ -package eu.kanade.tachiyomi.ui.recent_updates - -import androidx.recyclerview.widget.ItemTouchHelper -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.ui.manga.chapter.BaseChapterAdapter - -class RecentChaptersAdapter(val controller: RecentChaptersController) : - BaseChapterAdapter>(controller) { - - val coverClickListener: OnCoverClickListener = controller - var recents = emptyList() - - init { - setDisplayHeadersAtStartUp(true) - // setStickyHeaders(true) - } - - fun setItems(recents: List) { - this.recents = recents - performFilter() - } - - fun performFilter() { - val s = getFilter(String::class.java) - if (s.isNullOrBlank()) { - updateDataSet(recents) - } else { - updateDataSet(recents.filter { it.filter(s) }) - } - } - - interface OnCoverClickListener { - fun onCoverClick(position: Int) - } - - override fun onItemSwiped(position: Int, direction: Int) { - super.onItemSwiped(position, direction) - when (direction) { - ItemTouchHelper.LEFT -> controller.toggleMarkAsRead(position) - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersController.kt deleted file mode 100644 index 1cde03f7a8..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersController.kt +++ /dev/null @@ -1,275 +0,0 @@ -package eu.kanade.tachiyomi.ui.recent_updates - -import android.app.Activity -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.elvishew.xlog.XLog -import com.google.android.material.snackbar.BaseTransientBottomBar -import com.google.android.material.snackbar.Snackbar -import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.download.DownloadService -import eu.kanade.tachiyomi.data.download.model.Download -import eu.kanade.tachiyomi.data.library.LibraryUpdateService -import eu.kanade.tachiyomi.data.notification.Notifications -import eu.kanade.tachiyomi.source.model.isMerged -import eu.kanade.tachiyomi.ui.base.controller.BaseController -import eu.kanade.tachiyomi.ui.main.MainActivity -import eu.kanade.tachiyomi.ui.manga.MangaDetailsController -import eu.kanade.tachiyomi.ui.manga.chapter.BaseChapterAdapter -import eu.kanade.tachiyomi.ui.reader.ReaderActivity -import eu.kanade.tachiyomi.util.system.notificationManager -import eu.kanade.tachiyomi.util.view.scrollViewWith -import eu.kanade.tachiyomi.util.view.setStyle -import eu.kanade.tachiyomi.util.view.snack -import eu.kanade.tachiyomi.util.view.withFadeTransaction -import kotlinx.android.synthetic.main.download_bottom_sheet.* -import kotlinx.android.synthetic.main.recent_chapters_controller.* -import kotlinx.android.synthetic.main.recent_chapters_controller.empty_view - -/** - * Fragment that shows recent chapters. - * Uses [R.layout.recent_chapters_controller]. - * UI related actions should be called from here. - */ -class RecentChaptersController(bundle: Bundle? = null) : - BaseController(bundle), - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnUpdateListener, - FlexibleAdapter.OnItemMoveListener, - RecentChaptersAdapter.OnCoverClickListener, - BaseChapterAdapter.DownloadInterface { - - /** - * Adapter containing the recent chapters. - */ - var adapter: RecentChaptersAdapter? = null - private set - - private var presenter = RecentChaptersPresenter(this) - private var snack: Snackbar? = null - private var lastChapterId: Long? = null - - override fun getTitle(): String? { - return resources?.getString(R.string.recent_updates) - } - - override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.recent_chapters_controller, container, false) - } - - /** - * Called when view is created - * @param view created view - */ - override fun onViewCreated(view: View) { - super.onViewCreated(view) - // view.applyWindowInsetsForController() - - view.context.notificationManager.cancel(Notifications.ID_NEW_CHAPTERS) - // Init RecyclerView and adapter - val layoutManager = LinearLayoutManager(view.context) - recycler.layoutManager = layoutManager - recycler.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) - recycler.setHasFixedSize(true) - adapter = RecentChaptersAdapter(this@RecentChaptersController) - recycler.adapter = adapter - - adapter?.isSwipeEnabled = true - adapter?.itemTouchHelperCallback?.setSwipeFlags( - ItemTouchHelper.LEFT - ) - if (presenter.chapters.isNotEmpty()) adapter?.updateDataSet(presenter.chapters.toList()) - swipe_refresh.setStyle() - swipe_refresh.setDistanceToTriggerSync((2 * 64 * view.resources.displayMetrics.density).toInt()) - swipe_refresh.setOnRefreshListener { - if (!LibraryUpdateService.isRunning()) { - LibraryUpdateService.start(view.context) - snack = view.snack(R.string.updating_library) - } - // It can be a very long operation, so we disable swipe refresh and show a snackbar. - swipe_refresh.isRefreshing = false - } - - scrollViewWith(recycler, swipeRefreshLayout = swipe_refresh, padBottom = true) - - presenter.onCreate() - } - - override fun onDestroy() { - super.onDestroy() - presenter.onDestroy() - } - - override fun onDestroyView(view: View) { - adapter = null - snack = null - super.onDestroyView(view) - } - - override fun onActivityResumed(activity: Activity) { - super.onActivityResumed(activity) - if (view != null) { - refresh() - dl_bottom_sheet?.update() - } - } - - fun refresh() = presenter.getUpdates() - - /** - * Called when item in list is clicked - * @param position position of clicked item - */ - override fun onItemClick(view: View?, position: Int): Boolean { - val adapter = adapter ?: return false - - // Get item from position - val item = adapter.getItem(position) as? RecentChapterItem ?: return false - openChapter(item) - return false - } - - /** - * Open chapter in reader - * @param chapter selected chapter - */ - private fun openChapter(item: RecentChapterItem) { - val activity = activity ?: return - if (presenter.preferences.useCacheSource() && item.manga.isMerged().not() && presenter.downloadManager.isChapterDownloaded(item.chapter, item.manga).not()) { - view?.snack(R.string.using_cached_source_cant_open) - return - } - val intent = ReaderActivity.newIntent(activity, item.manga, item.chapter) - startActivity(intent) - } - - /** - * Populate adapter with chapters - * @param chapters list of [Any] - */ - fun onNextRecentChapters(chapters: List) { - adapter?.setItems(chapters) - } - - fun updateChapterDownload(download: Download) { - if (view == null) return - val id = download.chapter.id ?: return - val holder = recycler.findViewHolderForItemId(id) as? RecentChapterHolder ?: return - holder.notifyStatus(download.status, download.progress) - } - - override fun onUpdateEmptyView(size: Int) { - if (size > 0) { - empty_view?.hide() - } else { - empty_view?.show(CommunityMaterial.Icon2.cmd_update, R.string.no_recent_chapters) - } - } - - override fun onItemMove(fromPosition: Int, toPosition: Int) {} - override fun shouldMoveItem(fromPosition: Int, toPosition: Int) = true - - override fun onActionStateChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { - swipe_refresh.isEnabled = actionState != ItemTouchHelper.ACTION_STATE_SWIPE - } - - /** - * Update download status of chapter - * @param download [Download] object containing download progress. - */ - fun onChapterStatusChange(download: Download) { - getHolder(download)?.notifyStatus(download.status, download.progress) - } - - /** - * Returns holder belonging to chapter - * @param download [Download] object containing download progress. - */ - private fun getHolder(download: Download): RecentChapterHolder? { - return recycler?.findViewHolderForItemId(download.chapter.id!!) as? RecentChapterHolder - } - - /** - * Mark chapter as read - * @param position position of chapter item - */ - fun toggleMarkAsRead(position: Int) { - val item = adapter?.getItem(position) as? RecentChapterItem ?: return - val chapter = item.chapter - val lastRead = chapter.last_page_read - val pagesLeft = chapter.pages_left - val read = item.chapter.read - lastChapterId = chapter.id - presenter.markChapterRead(item, !read) - if (!read) { - snack = view?.snack(R.string.marked_as_read, Snackbar.LENGTH_INDEFINITE) { - var undoing = false - setAction(R.string.undo) { - presenter.markChapterRead(item, read, lastRead, pagesLeft) - undoing = true - } - addCallback(object : BaseTransientBottomBar.BaseCallback() { - override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { - super.onDismissed(transientBottomBar, event) - if (!undoing && presenter.preferences.removeAfterMarkedAsRead()) { - lastChapterId = chapter.id - presenter.deleteChapter(chapter, item.manga) - } - } - }) - } - (activity as? MainActivity)?.setUndoSnackBar(snack) - } - // presenter.markChapterRead(item, !item.chapter.read) - } - - override fun downloadChapter(position: Int) { - val view = view ?: return - val item = adapter?.getItem(position) as? RecentChapterItem ?: return - val chapter = item.chapter - val manga = item.manga - if (item.status != Download.NOT_DOWNLOADED && item.status != Download.ERROR) { - presenter.deleteChapter(chapter, manga) - } else { - if (item.status == Download.ERROR) DownloadService.start(view.context) - else presenter.downloadChapters(listOf(item)) - } - } - - override fun startDownloadNow(position: Int) { - val chapter = (adapter?.getItem(position) as? RecentChapterItem)?.chapter ?: return - presenter.startDownloadChapterNow(chapter) - } - - override fun onCoverClick(position: Int) { - val chapterClicked = adapter?.getItem(position) as? RecentChapterItem ?: return - openManga(chapterClicked) - } - - fun openManga(chapter: RecentChapterItem) { - router.pushController(MangaDetailsController(chapter.manga).withFadeTransaction()) - } - - /** - * Called when chapters are deleted - */ - fun onChaptersDeleted() { - adapter?.notifyDataSetChanged() - } - - /** - * Called when error while deleting - * @param error error message - */ - fun onChaptersDeletedError(error: Throwable) { - XLog.e(error) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt deleted file mode 100644 index 1897e487bc..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt +++ /dev/null @@ -1,184 +0,0 @@ -package eu.kanade.tachiyomi.ui.recent_updates - -import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.LibraryManga -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.MangaChapter -import eu.kanade.tachiyomi.data.download.DownloadManager -import eu.kanade.tachiyomi.data.download.model.Download -import eu.kanade.tachiyomi.data.download.model.DownloadQueue -import eu.kanade.tachiyomi.data.library.LibraryServiceListener -import eu.kanade.tachiyomi.data.library.LibraryUpdateService -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.source.SourceManager -import eu.kanade.tachiyomi.util.system.executeOnIO -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.util.Calendar -import java.util.Date -import java.util.TreeMap - -class RecentChaptersPresenter( - private val controller: RecentChaptersController, - val preferences: PreferencesHelper = Injekt.get(), - private val db: DatabaseHelper = Injekt.get(), - val downloadManager: DownloadManager = Injekt.get(), - private val sourceManager: SourceManager = Injekt.get() -) : DownloadQueue.DownloadListener, LibraryServiceListener { - - /** - * List containing chapter and manga information - */ - var chapters: List = emptyList() - - private var scope = CoroutineScope(Job() + Dispatchers.Default) - - fun onCreate() { - downloadManager.addListener(this) - LibraryUpdateService.setListener(this) - getUpdates() - } - - fun getUpdates() { - scope.launch { - val cal = Calendar.getInstance().apply { - time = Date() - add(Calendar.MONTH, -1) - } - val mangaChapters = db.getRecentChapters(cal.time).executeOnIO() - val map = TreeMap> { d1, d2 -> d2.compareTo(d1) } - val byDay = mangaChapters.groupByTo(map, { getMapKey(it.chapter.date_fetch) }) - val items = byDay.flatMap { - val dateItem = DateItem(it.key) - it.value.map { mc -> - RecentChapterItem(mc.chapter, mc.manga, dateItem) - } - } - setDownloadedChapters(items) - chapters = items - withContext(Dispatchers.Main) { controller.onNextRecentChapters(chapters) } - } - } - - fun onDestroy() { - downloadManager.removeListener(this) - LibraryUpdateService.removeListener(this) - } - - fun cancelScope() { - scope.cancel() - } - - override fun updateDownload(download: Download) { - chapters.find { it.chapter.id == download.chapter.id }?.download = download - scope.launch(Dispatchers.Main) { - controller.updateChapterDownload(download) - } - } - - override fun updateDownloads() { - scope.launch { - setDownloadedChapters(chapters) - withContext(Dispatchers.Main) { - controller.onNextRecentChapters(chapters) - } - } - } - - override fun onUpdateManga(manga: LibraryManga) { - getUpdates() - } - - /** - * Get date as time key - * - * @param date desired date - * @return date as time key - */ - private fun getMapKey(date: Long): Date { - val cal = Calendar.getInstance() - cal.time = Date(date) - cal[Calendar.HOUR_OF_DAY] = 0 - cal[Calendar.MINUTE] = 0 - cal[Calendar.SECOND] = 0 - cal[Calendar.MILLISECOND] = 0 - return cal.time - } - - /** - * Finds and assigns the list of downloaded chapters. - * - * @param chapters the list of chapter from the database. - */ - private fun setDownloadedChapters(chapters: List) { - for (item in chapters) { - if (downloadManager.isChapterDownloaded(item.chapter, item.manga)) { - item.status = Download.DOWNLOADED - } else if (downloadManager.hasQueue()) { - item.status = downloadManager.queue.find { it.chapter.id == item.chapter.id } - ?.status ?: 0 - } - } - } - - /** - * Mark selected chapter as read - * - * @param items list of selected chapters - * @param read read status - */ - fun markChapterRead( - item: RecentChapterItem, - read: Boolean, - lastRead: Int? = null, - pagesLeft: Int? = null - ) { - item.chapter.apply { - this.read = read - if (!read) { - last_page_read = lastRead ?: 0 - pages_left = pagesLeft ?: 0 - } - } - db.updateChapterProgress(item.chapter).executeAsBlocking() - controller.onNextRecentChapters(this.chapters) - } - - fun startDownloadChapterNow(chapter: Chapter) { - downloadManager.startDownloadNow(chapter) - } - - /** - * Deletes the given list of chapter. - * @param chapter the chapter to delete. - */ - fun deleteChapter(chapter: Chapter, manga: Manga, update: Boolean = true) { - val source = Injekt.get().getMangadex() - downloadManager.deleteChapters(listOf(chapter), manga, source) - - if (update) { - val item = chapters.find { it.chapter.id == chapter.id } ?: return - item.apply { - status = Download.NOT_DOWNLOADED - download = null - } - - controller.onNextRecentChapters(chapters) - } - } - - /** - * Download selected chapters - * @param items list of recent chapters seleted. - */ - fun downloadChapters(items: List) { - items.forEach { downloadManager.downloadChapters(it.manga, listOf(it.chapter)) } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadAdapter.kt deleted file mode 100644 index da3a103346..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadAdapter.kt +++ /dev/null @@ -1,55 +0,0 @@ -package eu.kanade.tachiyomi.ui.recently_read - -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.source.SourceManager -import uy.kohesive.injekt.injectLazy -import java.text.DateFormat -import java.text.DecimalFormat -import java.text.DecimalFormatSymbols - -/** - * Adapter of RecentlyReadHolder. - * Connection between Fragment and Holder - * Holder updates should be called from here. - * - * @param controller a RecentlyReadController object - * @constructor creates an instance of the adapter. - */ -class RecentlyReadAdapter(controller: RecentlyReadController) : - FlexibleAdapter>(null, controller, true) { - - val sourceManager by injectLazy() - - val resumeClickListener: OnResumeClickListener = controller - - val removeClickListener: OnRemoveClickListener = controller - - val coverClickListener: OnCoverClickListener = controller - - /** - * DecimalFormat used to display correct chapter number - */ - val decimalFormat = DecimalFormat( - "#.###", - DecimalFormatSymbols() - .apply { decimalSeparator = '.' } - ) - - private val preferences: PreferencesHelper by injectLazy() - - val dateFormat: DateFormat = preferences.dateFormat() - - interface OnResumeClickListener { - fun onResumeClick(position: Int) - } - - interface OnRemoveClickListener { - fun onRemoveClick(position: Int) - } - - interface OnCoverClickListener { - fun onCoverClick(position: Int) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadController.kt deleted file mode 100644 index a80cd523c0..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadController.kt +++ /dev/null @@ -1,253 +0,0 @@ -package eu.kanade.tachiyomi.ui.recently_read - -import android.app.Activity -import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.widget.SearchView -import androidx.recyclerview.widget.LinearLayoutManager -import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.backup.BackupRestoreService -import eu.kanade.tachiyomi.data.database.models.History -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.source.model.isMerged -import eu.kanade.tachiyomi.ui.base.controller.BaseController -import eu.kanade.tachiyomi.ui.manga.MangaDetailsController -import eu.kanade.tachiyomi.ui.reader.ReaderActivity -import eu.kanade.tachiyomi.ui.source.browse.ProgressItem -import eu.kanade.tachiyomi.util.system.launchUI -import eu.kanade.tachiyomi.util.system.toast -import eu.kanade.tachiyomi.util.view.RecyclerWindowInsetsListener -import eu.kanade.tachiyomi.util.view.scrollViewWith -import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener -import eu.kanade.tachiyomi.util.view.snack -import eu.kanade.tachiyomi.util.view.withFadeTransaction -import kotlinx.android.synthetic.main.recently_read_controller.* - -/** - * Fragment that shows recently read manga. - * Uses R.layout.fragment_recently_read. - * UI related actions should be called from here. - */ -class RecentlyReadController(bundle: Bundle? = null) : - BaseController(bundle), - FlexibleAdapter.OnUpdateListener, - FlexibleAdapter.EndlessScrollListener, - RecentlyReadAdapter.OnRemoveClickListener, - RecentlyReadAdapter.OnResumeClickListener, - RecentlyReadAdapter.OnCoverClickListener, - RemoveHistoryDialog.Listener { - - init { - setHasOptionsMenu(true) - } - - /** - * Adapter containing the recent manga. - */ - var adapter: RecentlyReadAdapter? = null - private set - - /** - * Endless loading item. - */ - private var progressItem: ProgressItem? = null - private var observeLater: Boolean = false - private var query = "" - - private var presenter = RecentlyReadPresenter(this) - private var recentItems: MutableList? = null - - override fun getTitle(): String? { - return resources?.getString(R.string.history) - } - - override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.recently_read_controller, container, false) - } - - /** - * Called when view is created - * - * @param view created view - */ - override fun onViewCreated(view: View) { - super.onViewCreated(view) - // view.applyWindowInsetsForController() - // Initialize adapter - adapter = RecentlyReadAdapter(this) - recycler.adapter = adapter - recycler.layoutManager = LinearLayoutManager(view.context) - recycler.setHasFixedSize(true) - recycler.setOnApplyWindowInsetsListener(RecyclerWindowInsetsListener) - resetProgressItem() - scrollViewWith(recycler, padBottom = true) - - if (recentItems != null) - adapter?.updateDataSet(recentItems!!.toList()) - - launchUI { - val manga = presenter.refresh(query) - recentItems = manga.toMutableList() - adapter?.updateDataSet(manga) - } - } - - override fun onActivityResumed(activity: Activity) { - super.onActivityResumed(activity) - if (observeLater) { - launchUI { - val manga = presenter.refresh(query) - recentItems = manga.toMutableList() - adapter?.updateDataSet(manga) - } - observeLater = false - } - } - - /** - * Populate adapter with chapters - * - * @param mangaHistory list of manga history - */ - fun onNextManga(mangaHistory: List) { - val adapter = adapter ?: return - adapter.updateDataSet(mangaHistory) - adapter.onLoadMoreComplete(null) - if (recentItems == null) - resetProgressItem() - recentItems = mangaHistory.toMutableList() - } - - fun onAddPageError() { - adapter?.onLoadMoreComplete(null) - adapter?.endlessTargetCount = 1 - } - - override fun onUpdateEmptyView(size: Int) { - if (size > 0) { - empty_view?.hide() - } else { - empty_view.show( - CommunityMaterial.Icon2.cmd_history, - R.string - .no_recently_read_manga - ) - } - } - - /** - * Sets a new progress item and reenables the scroll listener. - */ - private fun resetProgressItem() { - progressItem = ProgressItem() - adapter?.endlessTargetCount = 0 - adapter?.setEndlessScrollListener(this, progressItem!!) - } - - override fun onLoadMore(lastPosition: Int, currentPage: Int) { - val view = view ?: return - if (BackupRestoreService.isRunning(view.context.applicationContext)) { - onAddPageError() - return - } - presenter.requestNext(query) - } - - override fun noMoreLoad(newItemsSize: Int) {} - - override fun onResumeClick(position: Int) { - val activity = activity ?: return - - observeLater = true - val (manga, chapter, _) = (adapter?.getItem(position) as? RecentlyReadItem)?.mch ?: return - - if (presenter.preferences.useCacheSource() && manga.isMerged().not() && presenter.downloadManager.isChapterDownloaded(chapter, manga).not()) { - view?.snack(R.string.using_cached_source_cant_open) - return - } - - val nextChapter = presenter.getNextChapter(chapter, manga) - if (nextChapter != null) { - val intent = ReaderActivity.newIntent(activity, manga, nextChapter) - startActivity(intent) - } else { - activity.toast(R.string.next_chapter_not_found) - } - } - - override fun onRemoveClick(position: Int) { - val (manga, _, history) = (adapter?.getItem(position) as? RecentlyReadItem)?.mch ?: return - RemoveHistoryDialog(this, manga, history).showDialog(router) - } - - override fun onCoverClick(position: Int) { - val manga = (adapter?.getItem(position) as? RecentlyReadItem)?.mch?.manga ?: return - router.pushController(MangaDetailsController(manga).withFadeTransaction()) - } - - override fun removeHistory(manga: Manga, history: History, all: Boolean) { - presenter.lastCount = adapter?.itemCount ?: 25 - if (all) { - // Reset last read of chapter to 0L - presenter.removeAllFromHistory(manga.id!!) - } else { - // Remove all chapters belonging to manga from library - presenter.removeFromHistory(history) - } - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.recently_read, menu) - val searchItem = menu.findItem(R.id.action_search) - val searchView = searchItem.actionView as SearchView - if (query.isNotEmpty()) { - searchItem.expandActionView() - searchView.setQuery(query, true) - searchView.clearFocus() - } - setOnQueryTextChangeListener(searchView) { - if (query != it) { - query = it ?: return@setOnQueryTextChangeListener false - launchUI { - resetProgressItem() - presenter.lastCount = 25 - val manga = presenter.refresh(query) - recentItems = manga.toMutableList() - adapter?.updateDataSet(manga) - } - } - true - } - - // Fixes problem with the overflow icon showing up in lieu of search - searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - return true - } - - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - activity?.invalidateOptionsMenu() - return true - } - }) - } - - /*override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_recents -> { - router.setRoot( - RecentChaptersController().withFadeTransaction().tag(R.id.nav_recents.toString())) - Injekt.get().showRecentUpdates().set(true) - (activity as? MainActivity)?.updateRecentsIcon() - } - } - return super.onOptionsItemSelected(item) - }*/ -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadHolder.kt deleted file mode 100644 index 460e66e069..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadHolder.kt +++ /dev/null @@ -1,66 +0,0 @@ -package eu.kanade.tachiyomi.ui.recently_read - -import android.view.View -import coil.clear -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory -import eu.kanade.tachiyomi.data.image.coil.loadLibraryManga -import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder -import eu.kanade.tachiyomi.util.lang.toTimestampString -import kotlinx.android.synthetic.main.recently_read_item.* -import java.util.Date - -/** - * Holder that contains recent manga item - * Uses R.layout.item_recently_read. - * UI related actions should be called from here. - * - * @param view the inflated view for this holder. - * @param adapter the adapter handling this holder. - * @constructor creates a new recent chapter holder. - */ -class RecentlyReadHolder( - view: View, - val adapter: RecentlyReadAdapter -) : BaseFlexibleViewHolder(view, adapter) { - - init { - remove.setOnClickListener { - adapter.removeClickListener.onRemoveClick(adapterPosition) - } - - resume.setOnClickListener { - adapter.resumeClickListener.onResumeClick(adapterPosition) - } - - cover.setOnClickListener { - adapter.coverClickListener.onCoverClick(adapterPosition) - } - } - - /** - * Set values of view - * - * @param item item containing history information - */ - fun bind(item: MangaChapterHistory) { - // Retrieve objects - val (manga, chapter, history) = item - - // Set manga title - title.text = manga.title - - // Set source + chapter title - - val formattedNumber = adapter.decimalFormat.format(chapter.chapter_number.toDouble()) - manga_source.text = itemView.context.getString(R.string.source_dash_chapter_) - .format(adapter.sourceManager.getMangadex().toString(), formattedNumber) - - // Set last read timestamp title - last_read.text = Date(history.last_read).toTimestampString(adapter.dateFormat) - - // Set cover - cover.clear() - cover.loadLibraryManga(manga) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadItem.kt deleted file mode 100644 index 527a4f21bc..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadItem.kt +++ /dev/null @@ -1,41 +0,0 @@ -package eu.kanade.tachiyomi.ui.recently_read - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory - -class RecentlyReadItem(val mch: MangaChapterHistory) : AbstractFlexibleItem() { - - override fun getLayoutRes(): Int { - return R.layout.recently_read_item - } - - override fun createViewHolder(view: View, adapter: FlexibleAdapter>): RecentlyReadHolder { - return RecentlyReadHolder(view, adapter as RecentlyReadAdapter) - } - - override fun bindViewHolder( - adapter: FlexibleAdapter>, - holder: RecentlyReadHolder, - position: Int, - payloads: MutableList? - ) { - - holder.bind(mch) - } - - override fun equals(other: Any?): Boolean { - if (other is RecentlyReadItem) { - return mch.manga.id == other.mch.manga.id - } - return false - } - - override fun hashCode(): Int { - return mch.manga.id!!.hashCode() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadPresenter.kt deleted file mode 100644 index dcf782e492..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RecentlyReadPresenter.kt +++ /dev/null @@ -1,136 +0,0 @@ -package eu.kanade.tachiyomi.ui.recently_read - -import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.History -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.download.DownloadManager -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.util.system.executeOnIO -import eu.kanade.tachiyomi.util.system.launchUI -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import uy.kohesive.injekt.injectLazy -import java.util.Calendar -import java.util.Comparator -import java.util.Date - -/** - * Presenter of RecentlyReadFragment. - * Contains information and data for fragment. - * Observable updates should be called from here. - */ -class RecentlyReadPresenter(private val view: RecentlyReadController) { - - /** - * Used to connect to database - */ - val db: DatabaseHelper by injectLazy() - val preferences: PreferencesHelper by injectLazy() - val downloadManager: DownloadManager by injectLazy() - - var lastCount = 25 - var lastSearch = "" - - fun requestNext(search: String = "") { - lastCount += 25 - lastSearch = search - updateList(search) - } - - /** - * Get all recent manga up to a point - * @return list of history - */ - private suspend fun getRecentMangaLimit(search: String = ""): List { - // Set date for recent manga - val cal = Calendar.getInstance() - cal.time = Date() - cal.add(Calendar.YEAR, -50) - - return db.getRecentMangaLimit(cal.time, lastCount, search).executeOnIO() - .map(::RecentlyReadItem) - } - - /** - * Reset last read of chapter to 0L - * @param history history belonging to chapter - */ - fun removeFromHistory(history: History) { - history.last_read = 0L - db.updateHistoryLastRead(history).executeAsBlocking() - updateList() - } - - suspend fun refresh(search: String? = null): List { - val manga = getRecentMangaLimit(search ?: "") - checkIfNew(manga.size, search) - lastSearch = search ?: lastSearch - lastCount = manga.size - return manga - } - - private fun updateList(search: String? = null) { - launchUI { - val manga = withContext(Dispatchers.IO) { getRecentMangaLimit(search ?: "") } - checkIfNew(manga.size, search) - lastSearch = search ?: lastSearch - lastCount = manga.size - view.onNextManga(manga) - } - } - - private fun checkIfNew(newCount: Int, newSearch: String?) { - if (lastCount > newCount && newSearch == lastSearch) { - view.onAddPageError() - } - } - - /** - * Removes all chapters belonging to manga from history. - * @param mangaId id of manga - */ - fun removeAllFromHistory(mangaId: Long) { - val history = db.getHistoryByMangaId(mangaId).executeAsBlocking() - history.forEach { it.last_read = 0L } - db.updateHistoryLastRead(history).executeAsBlocking() - updateList() - } - - /** - * Retrieves the next chapter of the given one. - * - * @param chapter the chapter of the history object. - * @param manga the manga of the chapter. - */ - fun getNextChapter(chapter: Chapter, manga: Manga): Chapter? { - if (!chapter.read) { - return chapter - } - - val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) { - Manga.SORTING_SOURCE -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) } - Manga.SORTING_NUMBER -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) } - else -> throw NotImplementedError("Unknown sorting method") - } - - val chapters = db.getChapters(manga).executeAsBlocking() - .sortedWith(Comparator { c1, c2 -> sortFunction(c1, c2) }) - - val currChapterIndex = chapters.indexOfFirst { chapter.id == it.id } - return when (manga.sorting) { - Manga.SORTING_SOURCE -> chapters.getOrNull(currChapterIndex + 1) - Manga.SORTING_NUMBER -> { - val chapterNumber = chapter.chapter_number - - ((currChapterIndex + 1) until chapters.size) - .map { chapters[it] } - .firstOrNull { - it.chapter_number > chapterNumber && - it.chapter_number <= chapterNumber + 1 - } - } - else -> throw NotImplementedError("Unknown sorting method") - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/DateItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/DateItem.kt similarity index 81% rename from app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/DateItem.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/recents/DateItem.kt index 81e8a2e317..d5419149a4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/DateItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/DateItem.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.ui.recent_updates +package eu.kanade.tachiyomi.ui.recents import android.text.format.DateUtils import android.view.View @@ -11,8 +11,7 @@ import eu.davidea.viewholders.FlexibleViewHolder import eu.kanade.tachiyomi.R import java.util.Date -class DateItem(val date: Date, val addedString: Boolean = false) : AbstractHeaderItem() { +class DateItem(val date: Date, val addedString: Boolean = false) : AbstractHeaderItem() { override fun getLayoutRes(): Int { return R.layout.recent_chapters_section_item @@ -46,12 +45,17 @@ class DateItem(val date: Date, val addedString: Boolean = false) : AbstractHeade private val now = Date().time - val section_text: TextView = view.findViewById(R.id.section_text) + private val sectionText: TextView = view.findViewById(R.id.section_text) fun bind(item: DateItem) { val dateString = DateUtils.getRelativeTimeSpanString(item.date.time, now, DateUtils.DAY_IN_MILLIS) - section_text.text = - if (item.addedString) itemView.context.getString(R.string.added_, dateString) else dateString + sectionText.text = + if (item.addedString) itemView.context.getString(R.string.fetched_, dateString) else dateString + } + + override fun onLongClick(view: View?): Boolean { + super.onLongClick(view) + return false } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaAdapter.kt index b3d5094e49..c87921c9ba 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaAdapter.kt @@ -1,17 +1,30 @@ package eu.kanade.tachiyomi.ui.recents import androidx.recyclerview.widget.ItemTouchHelper +import com.tfcporciuncula.flow.Preference import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.ui.manga.chapter.BaseChapterAdapter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import uy.kohesive.injekt.injectLazy import java.text.DecimalFormat import java.text.DecimalFormatSymbols class RecentMangaAdapter(val delegate: RecentsInterface) : BaseChapterAdapter>(delegate) { - init { - setDisplayHeadersAtStartUp(true) - } + private val preferences: PreferencesHelper by injectLazy() + + var showDownloads = preferences.showRecentsDownloads().get() + var showRemoveHistory = preferences.showRecentsRemHistory().get() + var showTitleFirst = preferences.showTitleFirstInRecents().get() + var showUpdatedTime = preferences.showUpdatedTime().get() + + val viewType: Int + get() = delegate.getViewType() fun updateItems(items: List>?) { updateDataSet(items) @@ -23,14 +36,36 @@ class RecentMangaAdapter(val delegate: RecentsInterface) : .apply { decimalSeparator = '.' } ) + init { + setDisplayHeadersAtStartUp(true) + } + + fun setPreferenceFlows() { + preferences.showRecentsDownloads().register { showDownloads = it } + preferences.showRecentsRemHistory().register { showRemoveHistory = it } + preferences.showTitleFirstInRecents().register { showTitleFirst = it } + preferences.showUpdatedTime().register { showUpdatedTime = it } + } + + private fun Preference.register(onChanged: (T) -> Unit) { + asFlow() + .drop(1) + .onEach { + onChanged(it) + notifyDataSetChanged() + } + .launchIn(delegate.scope()) + } + interface RecentsInterface : RecentMangaInterface, DownloadInterface interface RecentMangaInterface { fun onCoverClick(position: Int) + fun onRemoveHistoryClicked(position: Int) fun markAsRead(position: Int) fun isSearching(): Boolean - fun showHistory() - fun showUpdates() + fun scope(): CoroutineScope + fun getViewType(): Int } override fun onItemSwiped(position: Int, direction: Int) { @@ -39,4 +74,12 @@ class RecentMangaAdapter(val delegate: RecentsInterface) : ItemTouchHelper.LEFT -> delegate.markAsRead(position) } } + + enum class ShowRecentsDLs { + None, + OnlyUnread, + OnlyDownloaded, + UnreadOrDownloaded, + All, + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaFooterHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaFooterHolder.kt new file mode 100644 index 0000000000..270abbdccf --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaFooterHolder.kt @@ -0,0 +1,29 @@ +package eu.kanade.tachiyomi.ui.recents + +import android.view.View +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.RecentsFooterItemBinding +import eu.kanade.tachiyomi.ui.manga.chapter.BaseChapterHolder + +class RecentMangaFooterHolder( + view: View, + val adapter: RecentMangaAdapter +) : BaseChapterHolder(view, adapter) { + private val binding = RecentsFooterItemBinding.bind(view) + + fun bind(recentsType: Int) { + when (recentsType) { + RecentMangaHeaderItem.CONTINUE_READING -> { + binding.title.setText(R.string.view_history) + } + RecentMangaHeaderItem.NEW_CHAPTERS -> { + binding.title.setText(R.string.view_all_updates) + } + } + } + + override fun onLongClick(view: View?): Boolean { + super.onLongClick(view) + return false + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaHeaderItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaHeaderItem.kt index 045800886a..687e2a47ac 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaHeaderItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaHeaderItem.kt @@ -6,10 +6,9 @@ import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractHeaderItem import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.RecentsHeaderItemBinding import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder import eu.kanade.tachiyomi.ui.library.LibraryHeaderItem -import eu.kanade.tachiyomi.util.view.visibleIf -import kotlinx.android.synthetic.main.recents_header_item.* class RecentMangaHeaderItem(val recentsType: Int) : AbstractHeaderItem() { @@ -55,17 +54,15 @@ class RecentMangaHeaderItem(val recentsType: Int) : } class Holder(val view: View, adapter: RecentMangaAdapter) : BaseFlexibleViewHolder( - view, adapter, + view, + adapter, true ) { - init { - action_history.setOnClickListener { adapter.delegate.showHistory() } - action_update.setOnClickListener { adapter.delegate.showUpdates() } - } + private val binding = RecentsHeaderItemBinding.bind(view) fun bind(recentsType: Int) { - title.setText( + binding.title.setText( when (recentsType) { CONTINUE_READING -> R.string.continue_reading NEW_CHAPTERS -> R.string.new_chapters @@ -73,9 +70,11 @@ class RecentMangaHeaderItem(val recentsType: Int) : else -> R.string.continue_reading } ) - action_history.visibleIf(recentsType == -1) - action_update.visibleIf(recentsType == -1) - title.visibleIf(recentsType != -1) + } + + override fun onLongClick(view: View?): Boolean { + super.onLongClick(view) + return false } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaHolder.kt index c90a41ceff..4da4696b30 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaHolder.kt @@ -2,97 +2,175 @@ package eu.kanade.tachiyomi.ui.recents import android.app.Activity import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.image.coil.loadLibraryManga import eu.kanade.tachiyomi.data.download.model.Download +import eu.kanade.tachiyomi.data.image.coil.loadManga +import eu.kanade.tachiyomi.databinding.RecentMangaItemBinding import eu.kanade.tachiyomi.ui.manga.chapter.BaseChapterHolder import eu.kanade.tachiyomi.util.chapter.ChapterUtil +import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.timeSpanFromNow -import kotlinx.android.synthetic.main.download_button.* -import kotlinx.android.synthetic.main.recent_manga_item.* class RecentMangaHolder( view: View, val adapter: RecentMangaAdapter ) : BaseChapterHolder(view, adapter) { + private val binding = RecentMangaItemBinding.bind(view) + init { - card_layout?.setOnClickListener { adapter.delegate.onCoverClick(adapterPosition) } + binding.cardLayout.setOnClickListener { adapter.delegate.onCoverClick(flexibleAdapterPosition) } + binding.removeHistory.setOnClickListener { adapter.delegate.onRemoveHistoryClicked(flexibleAdapterPosition) } } - fun bind(recentsType: Int) { - when (recentsType) { - RecentMangaHeaderItem.CONTINUE_READING -> { - title.setText(R.string.view_history) + fun bind(item: RecentMangaItem) { + val showDLs = adapter.showDownloads + val showRemoveHistory = adapter.showRemoveHistory + val showTitleFirst = adapter.showTitleFirst + binding.downloadButton.downloadButton.isVisible = when (showDLs) { + RecentMangaAdapter.ShowRecentsDLs.None -> false + RecentMangaAdapter.ShowRecentsDLs.OnlyUnread, RecentMangaAdapter.ShowRecentsDLs.UnreadOrDownloaded -> !item.chapter.read + RecentMangaAdapter.ShowRecentsDLs.OnlyDownloaded -> true + RecentMangaAdapter.ShowRecentsDLs.All -> true + } + + val isUpdates = adapter.viewType == RecentsPresenter.VIEW_TYPE_ONLY_UPDATES && + !adapter.showUpdatedTime + binding.cardLayout.updateLayoutParams { + height = (if (isUpdates) 40 else 80).dpToPx + width = (if (isUpdates) 40 else 60).dpToPx + } + listOf(binding.title, binding.subtitle).forEach { + it.updateLayoutParams { + if (isUpdates) { + if (it == binding.title) topMargin = 5.dpToPx + endToStart = R.id.button_layout + endToEnd = -1 + } else { + if (it == binding.title) topMargin = 2.dpToPx + endToStart = -1 + endToEnd = R.id.front_view + } } - RecentMangaHeaderItem.NEW_CHAPTERS -> { - title.setText(R.string.view_all_updates) + } + binding.buttonLayout.updateLayoutParams { + if (isUpdates) { + topToBottom = -1 + topToTop = R.id.front_view + } else { + topToTop = -1 + topToBottom = R.id.subtitle } } - } - - fun bind(item: RecentMangaItem) { - title.apply { - text = item.chapter.name + with(binding.coverThumbnail) { + adjustViewBounds = !isUpdates + scaleType = if (isUpdates) ImageView.ScaleType.CENTER_CROP else ImageView.ScaleType.FIT_CENTER + } + listOf(binding.coverThumbnail, binding.card).forEach { + it.updateLayoutParams { + width = if (isUpdates) { + ViewGroup.LayoutParams.MATCH_PARENT + } else { + ViewGroup.LayoutParams.WRAP_CONTENT + } + } + } + binding.removeHistory.isVisible = item.mch.history.id != null && showRemoveHistory + binding.title.apply { + text = if (!showTitleFirst) { + item.chapter.name + } else { + item.mch.manga.title + } ChapterUtil.setTextViewForChapter(this, item) } - subtitle.apply { - text = item.mch.manga.title + binding.subtitle.apply { + text = if (!showTitleFirst) { + item.mch.manga.title + } else { + item.chapter.name + } setTextColor(ChapterUtil.readColor(context, item)) } + if (binding.frontView.translationX == 0f) { + binding.read.setImageResource( + if (item.read) R.drawable.ic_eye_off_24dp else R.drawable.ic_eye_24dp + ) + } val notValidNum = item.mch.chapter.chapter_number <= 0 - body.text = when { - item.mch.chapter.id == null -> body.context.getString( - R.string.added_, item.mch.manga.date_added.timeSpanFromNow + binding.body.isVisible = !isUpdates + binding.body.text = when { + item.mch.chapter.id == null -> binding.body.context.getString( + R.string.added_, + item.mch.manga.date_added.timeSpanFromNow(itemView.context) ) - item.mch.history.id == null -> body.context.getString( - R.string.updated_, item.chapter.date_upload.timeSpanFromNow + isUpdates -> "" + item.mch.history.id == null -> binding.body.context.getString( + R.string.updated_, + item.chapter.date_upload.timeSpanFromNow(itemView.context) ) item.chapter.id != item.mch.chapter.id -> - body.context.getString( - R.string.read_, item.mch.history.last_read.timeSpanFromNow - ) + "\n" + body.context.getString( + binding.body.context.getString( + R.string.read_, + item.mch.history.last_read.timeSpanFromNow + ) + "\n" + binding.body.context.getString( if (notValidNum) R.string.last_read_ else R.string.last_read_chapter_, if (notValidNum) item.mch.chapter.name else adapter.decimalFormat.format(item.mch.chapter.chapter_number) ) item.chapter.pages_left > 0 && !item.chapter.read -> - body.context.getString( - R.string.read_, item.mch.history.last_read.timeSpanFromNow + binding.body.context.getString( + R.string.read_, + item.mch.history.last_read.timeSpanFromNow(itemView.context) ) + "\n" + itemView.resources.getQuantityString( - R.plurals.pages_left, item.chapter.pages_left, item.chapter.pages_left + R.plurals.pages_left, + item.chapter.pages_left, + item.chapter.pages_left ) - else -> body.context.getString( - R.string.read_, item.mch.history.last_read.timeSpanFromNow + else -> binding.body.context.getString( + R.string.read_, + item.mch.history.last_read.timeSpanFromNow(itemView.context) ) } if ((itemView.context as? Activity)?.isDestroyed != true) { - cover_thumbnail.loadLibraryManga(item.mch.manga) + binding.coverThumbnail.loadManga(item.mch.manga) } - notifyStatus( - if (adapter.isSelected(adapterPosition)) Download.CHECKED else item.status, - item.progress - ) + resetFrontView() } private fun resetFrontView() { - if (front_view.translationX != 0f) itemView.post { adapter.notifyItemChanged(adapterPosition) } + if (binding.frontView.translationX != 0f) itemView.post { adapter.notifyItemChanged(flexibleAdapterPosition) } } override fun onLongClick(view: View?): Boolean { super.onLongClick(view) - val item = adapter.getItem(adapterPosition) as? RecentMangaItem ?: return false + val item = adapter.getItem(flexibleAdapterPosition) as? RecentMangaItem ?: return false return item.mch.history.id != null } - fun notifyStatus(status: Int, progress: Int) = - download_button.setDownloadStatus(status, progress) + fun notifyStatus(status: Download.State, progress: Int, isRead: Boolean, animated: Boolean = false) { + binding.downloadButton.downloadButton.setDownloadStatus(status, progress, animated) + val isChapterRead = + if (adapter.showDownloads == RecentMangaAdapter.ShowRecentsDLs.UnreadOrDownloaded) isRead else false + binding.downloadButton.downloadButton.isVisible = + when (adapter.showDownloads) { + RecentMangaAdapter.ShowRecentsDLs.UnreadOrDownloaded, + RecentMangaAdapter.ShowRecentsDLs.OnlyDownloaded -> + status !in Download.State.CHECKED..Download.State.NOT_DOWNLOADED || !isChapterRead + else -> binding.downloadButton.downloadButton.isVisible + } + } override fun getFrontView(): View { - return front_view + return binding.frontView } override fun getRearRightView(): View { - return right_view + return binding.rightView } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaItem.kt index 21698e4237..5879951064 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentMangaItem.kt @@ -9,6 +9,7 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.ChapterImpl import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory +import eu.kanade.tachiyomi.ui.manga.chapter.BaseChapterHolder import eu.kanade.tachiyomi.ui.manga.chapter.BaseChapterItem class RecentMangaItem( @@ -16,7 +17,7 @@ class RecentMangaItem( chapter: Chapter = ChapterImpl(), header: AbstractHeaderItem<*>? ) : - BaseChapterItem>(chapter, header) { + BaseChapterItem>(chapter, header) { override fun getLayoutRes(): Int { return if (mch.manga.id == null) R.layout.recents_footer_item @@ -26,12 +27,13 @@ class RecentMangaItem( override fun createViewHolder( view: View, adapter: FlexibleAdapter> - ): RecentMangaHolder { - return RecentMangaHolder(view, adapter as RecentMangaAdapter) + ): BaseChapterHolder { + return if (mch.manga.id == null) RecentMangaFooterHolder(view, adapter as RecentMangaAdapter) + else RecentMangaHolder(view, adapter as RecentMangaAdapter) } override fun isSwipeable(): Boolean { - return mch.manga.id != null && !chapter.read + return mch.manga.id != null } override fun equals(other: Any?): Boolean { @@ -51,11 +53,11 @@ class RecentMangaItem( override fun bindViewHolder( adapter: FlexibleAdapter>, - holder: RecentMangaHolder, + holder: BaseChapterHolder, position: Int, payloads: MutableList? ) { - if (mch.manga.id == null) holder.bind((header as? RecentMangaHeaderItem)?.recentsType ?: 0) - else holder.bind(this) + if (mch.manga.id == null) (holder as? RecentMangaFooterHolder)?.bind((header as? RecentMangaHeaderItem)?.recentsType ?: 0) + else if (chapter.id != null) (holder as? RecentMangaHolder)?.bind(this) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsController.kt index 11be89daed..1077520b18 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsController.kt @@ -1,59 +1,70 @@ package eu.kanade.tachiyomi.ui.recents -import android.Manifest.permission.WRITE_EXTERNAL_STORAGE import android.app.Activity +import android.content.res.ColorStateList import android.os.Bundle +import android.os.Handler import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.widget.Toast import androidx.appcompat.widget.SearchView +import androidx.core.graphics.ColorUtils +import androidx.core.view.isVisible import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import com.bluelinelabs.conductor.Controller import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeType import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar +import com.google.android.material.tabs.TabLayout import eu.davidea.flexibleadapter.FlexibleAdapter import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.backup.BackupRestoreService import eu.kanade.tachiyomi.data.database.models.History import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.library.LibraryUpdateService -import eu.kanade.tachiyomi.ui.base.controller.BaseController +import eu.kanade.tachiyomi.data.notification.NotificationReceiver +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.databinding.RecentsControllerBinding +import eu.kanade.tachiyomi.ui.base.controller.BaseCoroutineController +import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.main.BottomSheetController +import eu.kanade.tachiyomi.ui.main.FloatingSearchInterface import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.RootSearchInterface import eu.kanade.tachiyomi.ui.manga.MangaDetailsController import eu.kanade.tachiyomi.ui.reader.ReaderActivity -import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersController -import eu.kanade.tachiyomi.ui.recently_read.RecentlyReadController -import eu.kanade.tachiyomi.ui.recently_read.RemoveHistoryDialog +import eu.kanade.tachiyomi.ui.recents.options.TabbedRecentsOptionsSheet +import eu.kanade.tachiyomi.ui.source.browse.ProgressItem import eu.kanade.tachiyomi.util.system.dpToPx -import eu.kanade.tachiyomi.util.system.toast -import eu.kanade.tachiyomi.util.view.applyWindowInsetsForRootController +import eu.kanade.tachiyomi.util.system.getResourceColor +import eu.kanade.tachiyomi.util.system.spToPx +import eu.kanade.tachiyomi.util.system.toInt +import eu.kanade.tachiyomi.util.view.activityBinding import eu.kanade.tachiyomi.util.view.expand +import eu.kanade.tachiyomi.util.view.isCollapsed import eu.kanade.tachiyomi.util.view.isExpanded -import eu.kanade.tachiyomi.util.view.requestPermissionsSafe +import eu.kanade.tachiyomi.util.view.requestFilePermissionsSafe import eu.kanade.tachiyomi.util.view.scrollViewWith import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener import eu.kanade.tachiyomi.util.view.setStyle +import eu.kanade.tachiyomi.util.view.smoothScrollToTop import eu.kanade.tachiyomi.util.view.snack +import eu.kanade.tachiyomi.util.view.toolbarHeight import eu.kanade.tachiyomi.util.view.updateLayoutParams import eu.kanade.tachiyomi.util.view.updatePaddingRelative import eu.kanade.tachiyomi.util.view.withFadeTransaction -import kotlinx.android.synthetic.main.download_bottom_sheet.* -import kotlinx.android.synthetic.main.main_activity.* -import kotlinx.android.synthetic.main.recents_controller.* +import java.util.Locale import kotlin.math.abs import kotlin.math.max +import kotlin.math.min /** * Fragment that shows recently read manga. @@ -61,12 +72,14 @@ import kotlin.math.max * UI related actions should be called from here. */ class RecentsController(bundle: Bundle? = null) : - BaseController(bundle), + BaseCoroutineController(bundle), RecentMangaAdapter.RecentsInterface, FlexibleAdapter.OnItemClickListener, FlexibleAdapter.OnItemLongClickListener, FlexibleAdapter.OnItemMoveListener, + FlexibleAdapter.EndlessScrollListener, RootSearchInterface, + FloatingSearchInterface, BottomSheetController, RemoveHistoryDialog.Listener { @@ -75,26 +88,43 @@ class RecentsController(bundle: Bundle? = null) : retainViewMode = RetainViewMode.RETAIN_DETACH } + constructor(viewType: Int) : this() { + presenter.toggleGroupRecents(viewType, false) + } + /** * Adapter containing the recent manga. */ - private var adapter = RecentMangaAdapter(this) + private lateinit var adapter: RecentMangaAdapter + var displaySheet: TabbedRecentsOptionsSheet? = null - private var presenter = RecentsPresenter(this) + private var progressItem: ProgressItem? = null + override var presenter = RecentsPresenter(this) private var snack: Snackbar? = null private var lastChapterId: Long? = null private var showingDownloads = false var headerHeight = 0 + private var query = "" + set(value) { + field = value + presenter.query = value + } override fun getTitle(): String? { - return if (showingDownloads) + return if (showingDownloads) { resources?.getString(R.string.download_queue) - else resources?.getString(R.string.recents) + } else searchTitle( + view?.context?.getString( + when (presenter.viewType) { + RecentsPresenter.VIEW_TYPE_ONLY_HISTORY -> R.string.history + RecentsPresenter.VIEW_TYPE_ONLY_UPDATES -> R.string.updates + else -> R.string.updates_and_history + } + )?.lowercase(Locale.ROOT) + ) } - override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.recents_controller, container, false) - } + override fun createBinding(inflater: LayoutInflater) = RecentsControllerBinding.inflate(inflater) /** * Called when view is created @@ -103,14 +133,14 @@ class RecentsController(bundle: Bundle? = null) : */ override fun onViewCreated(view: View) { super.onViewCreated(view) - view.applyWindowInsetsForRootController(activity!!.bottom_nav) // Initialize adapter adapter = RecentMangaAdapter(this) - recycler.adapter = adapter - recycler.layoutManager = LinearLayoutManager(view.context) - recycler.setHasFixedSize(true) - recycler.recycledViewPool.setMaxRecycledViews(0, 0) - recycler.addItemDecoration( + adapter.setPreferenceFlows() + binding.recycler.adapter = adapter + binding.recycler.layoutManager = LinearLayoutManager(view.context) + binding.recycler.setHasFixedSize(true) + binding.recycler.recycledViewPool.setMaxRecycledViews(0, 0) + binding.recycler.addItemDecoration( RecentMangaDivider(view.context) ) adapter.isSwipeEnabled = true @@ -121,165 +151,363 @@ class RecentsController(bundle: Bundle? = null) : val array = view.context.obtainStyledAttributes(attrsArray) val appBarHeight = array.getDimensionPixelSize(0, 0) array.recycle() - swipe_refresh.setStyle() + binding.swipeRefresh.setStyle() scrollViewWith( - recycler, swipeRefreshLayout = swipe_refresh, + binding.recycler, + swipeRefreshLayout = binding.swipeRefresh, + includeTabView = true, afterInsets = { - headerHeight = it.systemWindowInsetTop + appBarHeight + headerHeight = it.systemWindowInsetTop + appBarHeight + 48.dpToPx + binding.fakeAppBar.updateLayoutParams { + height = it.systemWindowInsetTop + (toolbarHeight ?: appBarHeight) + } + binding.recycler.updatePaddingRelative( + bottom = activityBinding?.bottomNav?.height ?: it.systemWindowInsetBottom + ) + binding.recentsEmptyView.updateLayoutParams { + topMargin = headerHeight + bottomMargin = activityBinding?.bottomNav?.height ?: it.systemWindowInsetBottom + } + }, + onBottomNavUpdate = { + setBottomPadding() + } + ) + binding.recycler.addOnScrollListener( + object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + binding.fakeAppBar.y = activityBinding?.appBar?.y ?: 0f + } } ) - presenter.onCreate() + activityBinding?.root?.post { + val height = activityBinding?.bottomNav?.height ?: view.rootWindowInsets?.systemWindowInsetBottom ?: 0 + binding.recycler.updatePaddingRelative(bottom = height) + binding.downloadBottomSheet.dlRecycler.updatePaddingRelative( + bottom = height + ) + val isExpanded = binding.downloadBottomSheet.root.sheetBehavior.isExpanded() + activityBinding?.tabsFrameLayout?.isVisible = !isExpanded + if (isExpanded) { + (activity as? MainActivity)?.showTabBar(show = false, animate = false) + } + val isCollapsed = binding.downloadBottomSheet.root.sheetBehavior.isCollapsed() + binding.shadow2.alpha = if (isCollapsed) 0.25f else 0f + binding.shadow.alpha = if (isCollapsed) 0.5f else 0f + binding.fakeAppBar.alpha = if (isExpanded) 1f else 0f + binding.downloadBottomSheet.dlRecycler.alpha = isExpanded.toInt().toFloat() + binding.downloadBottomSheet.sheetLayout.backgroundTintList = ColorStateList.valueOf( + ColorUtils.blendARGB( + view.context.getResourceColor(R.attr.colorPrimaryVariant), + view.context.getResourceColor(android.R.attr.colorBackground), + isExpanded.toInt().toFloat() + ) + ) + binding.downloadBottomSheet.root.backgroundTintList = binding.downloadBottomSheet.sheetLayout.backgroundTintList + updateTitleAndMenu() + } + if (presenter.recentItems.isNotEmpty()) { adapter.updateDataSet(presenter.recentItems) - if (presenter.viewType > 0) { - adapter.addScrollableHeader(presenter.generalHeader) - } + } else { + binding.frameLayout.alpha = 0f } - dl_bottom_sheet.onCreate(this) + binding.downloadBottomSheet.dlBottomSheet.onCreate(this) - shadow2.alpha = - if (dl_bottom_sheet.sheetBehavior?.state == BottomSheetBehavior.STATE_COLLAPSED) 0.25f else 0f - shadow.alpha = - if (dl_bottom_sheet.sheetBehavior?.state == BottomSheetBehavior.STATE_COLLAPSED) 0.5f else 0f + binding.shadow2.alpha = + if (binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.state == BottomSheetBehavior.STATE_COLLAPSED) 0.25f else 0f + binding.shadow.alpha = + if (binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.state == BottomSheetBehavior.STATE_COLLAPSED) 0.5f else 0f - dl_bottom_sheet.sheetBehavior?.addBottomSheetCallback(object : + binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.addBottomSheetCallback( + object : BottomSheetBehavior.BottomSheetCallback() { override fun onSlide(bottomSheet: View, progress: Float) { - val shadow2 = shadow2 ?: return - shadow2.alpha = (1 - abs(progress)) * 0.25f - shadow.alpha = (1 - abs(progress)) * 0.5f - if (progress >= 0) activity?.appbar?.elevation = max( - progress * 15f, if (recycler.canScrollVertically(-1)) 15f else 0f + binding.shadow2.alpha = (1 - abs(progress)) * 0.25f + binding.shadow.alpha = (1 - abs(progress)) * 0.5f + val height = + binding.root.height - binding.downloadBottomSheet.dlRecycler.paddingTop + // Doing some fun math to hide the tab bar just as the title text of the + // dl sheet is under the toolbar + val cap = height * (1 / 12600f) + 479f / 700 + activityBinding?.appBar?.elevation = min( + (1f - progress / cap) * 15f, + if (binding.recycler.canScrollVertically(-1)) 15f else 0f + ).coerceIn(0f, 15f) + binding.fakeAppBar.alpha = max(0f, (progress - cap) / (1f - cap)) + binding.downloadBottomSheet.sheetLayout.alpha = 1 - max(0f, progress / cap) + binding.downloadBottomSheet.dlRecycler.alpha = progress * 10 + binding.downloadBottomSheet.sheetLayout.backgroundTintList = ColorStateList.valueOf( + ColorUtils.blendARGB( + view.context.getResourceColor(R.attr.colorPrimaryVariant), + view.context.getResourceColor(android.R.attr.colorBackground), + (progress * 2f).coerceIn(0f, 1f) + ) + ) + binding.downloadBottomSheet.root.backgroundTintList = binding.downloadBottomSheet.sheetLayout.backgroundTintList + activityBinding?.appBar?.y = max( + activityBinding!!.appBar.y, + -headerHeight * (1 - progress) ) - sheet_layout.alpha = 1 - progress - activity?.appbar?.y = max(activity!!.appbar.y, -headerHeight * (1 - progress)) + binding.fakeAppBar.y = activityBinding?.appBar?.y ?: 0f + activityBinding?.tabsFrameLayout?.let { tabs -> + tabs.alpha = 1 - max(0f, progress / cap) + if (tabs.alpha <= 0 && tabs.isVisible) { + tabs.isVisible = false + } else if (tabs.alpha > 0 && !tabs.isVisible) { + tabs.isVisible = true + } + } val oldShow = showingDownloads showingDownloads = progress > 0.92f if (oldShow != showingDownloads) { - setTitle() + updateTitleAndMenu() activity?.invalidateOptionsMenu() } } override fun onStateChanged(p0: View, state: Int) { if (this@RecentsController.view == null) return - if (state == BottomSheetBehavior.STATE_EXPANDED) activity?.appbar?.y = 0f + if (state == BottomSheetBehavior.STATE_EXPANDED) activityBinding?.appBar?.y = 0f if (state == BottomSheetBehavior.STATE_EXPANDED || state == BottomSheetBehavior.STATE_COLLAPSED) { - sheet_layout.alpha = + binding.downloadBottomSheet.sheetLayout.alpha = if (state == BottomSheetBehavior.STATE_COLLAPSED) 1f else 0f showingDownloads = state == BottomSheetBehavior.STATE_EXPANDED - setTitle() + updateTitleAndMenu() activity?.invalidateOptionsMenu() } + activityBinding?.tabsFrameLayout?.isVisible = + state != BottomSheetBehavior.STATE_EXPANDED if (state == BottomSheetBehavior.STATE_COLLAPSED) { if (hasQueue()) { - dl_bottom_sheet.sheetBehavior?.isHideable = false + binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.isHideable = + false } else { - dl_bottom_sheet.sheetBehavior?.isHideable = true - dl_bottom_sheet.sheetBehavior?.state = BottomSheetBehavior.STATE_HIDDEN + binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.isHideable = + true + binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.state = + BottomSheetBehavior.STATE_HIDDEN } } else if (state == BottomSheetBehavior.STATE_HIDDEN) { if (!hasQueue()) { - dl_bottom_sheet.sheetBehavior?.skipCollapsed = true + binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.skipCollapsed = + true } else { - dl_bottom_sheet.sheetBehavior?.skipCollapsed = false - dl_bottom_sheet.sheetBehavior?.state = BottomSheetBehavior.STATE_COLLAPSED + binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.skipCollapsed = + false + binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.state = + BottomSheetBehavior.STATE_COLLAPSED } } + if (presenter.downloadManager.hasQueue()) { + binding.downloadBottomSheet.downloadFab.alpha = 1f + if (state == BottomSheetBehavior.STATE_EXPANDED) { + binding.downloadBottomSheet.downloadFab.show() + } else { + binding.downloadBottomSheet.downloadFab.hide() + } + } if (state == BottomSheetBehavior.STATE_HIDDEN || state == BottomSheetBehavior.STATE_COLLAPSED) { - shadow2.alpha = if (state == BottomSheetBehavior.STATE_COLLAPSED) 0.25f else 0f - shadow.alpha = if (state == BottomSheetBehavior.STATE_COLLAPSED) 0.5f else 0f + binding.shadow2.alpha = + if (state == BottomSheetBehavior.STATE_COLLAPSED) 0.25f else 0f + binding.shadow.alpha = + if (state == BottomSheetBehavior.STATE_COLLAPSED) 0.5f else 0f } - sheet_layout?.isClickable = state == BottomSheetBehavior.STATE_COLLAPSED - sheet_layout?.isFocusable = state == BottomSheetBehavior.STATE_COLLAPSED - setPadding(dl_bottom_sheet.sheetBehavior?.isHideable == true) + binding.downloadBottomSheet.sheetLayout.isClickable = + state == BottomSheetBehavior.STATE_COLLAPSED + binding.downloadBottomSheet.sheetLayout.isFocusable = + state == BottomSheetBehavior.STATE_COLLAPSED + setPadding(binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.isHideable == true) } - }) - swipe_refresh.isRefreshing = LibraryUpdateService.isRunning() - swipe_refresh.setOnRefreshListener { + } + ) + binding.swipeRefresh.isRefreshing = LibraryUpdateService.isRunning() + binding.swipeRefresh.setOnRefreshListener { if (!LibraryUpdateService.isRunning()) { + snack?.dismiss() + snack = view.snack(R.string.updating_library) { + anchorView = + if (binding.downloadBottomSheet.root.sheetBehavior.isCollapsed()) { + binding.downloadBottomSheet.root + } else { + activityBinding?.bottomNav ?: binding.downloadBottomSheet.root + } + setAction(R.string.cancel) { + LibraryUpdateService.stop(context) + Handler().post { + NotificationReceiver.dismissNotification( + context, + Notifications.ID_LIBRARY_PROGRESS + ) + } + } + addCallback( + object : BaseTransientBottomBar.BaseCallback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + super.onDismissed(transientBottomBar, event) + binding.swipeRefresh.isRefreshing = LibraryUpdateService.isRunning() + } + } + ) + } LibraryUpdateService.start(view.context) } } if (showingDownloads) { - dl_bottom_sheet.sheetBehavior?.expand() + binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.expand() + } + setPadding(binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.isHideable == true) + requestFilePermissionsSafe(301) + } + + fun updateTitleAndMenu() { + if (router.backstack.lastOrNull()?.controller == this) { + (activity as? MainActivity)?.setFloatingToolbar(!showingDownloads, true) + setTitle() + } + } + + private fun setBottomPadding() { + val bottomBar = activityBinding?.bottomNav ?: return + val pad = bottomBar.translationY - bottomBar.height + val padding = max( + (-pad).toInt(), + if (binding.downloadBottomSheet.dlBottomSheet.sheetBehavior.isExpanded()) 0 else { + view?.rootWindowInsets?.systemWindowInsetBottom ?: 0 + } + ) + binding.shadow2.translationY = pad + binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.peekHeight = 48.spToPx + padding + binding.downloadBottomSheet.fastScroller.updateLayoutParams { + bottomMargin = -pad.toInt() + } + binding.downloadBottomSheet.dlRecycler.updatePaddingRelative( + bottom = max(-pad.toInt(), view?.rootWindowInsets?.systemWindowInsetBottom ?: 0) + + binding.downloadBottomSheet.downloadFab.height + 20.dpToPx + ) + binding.downloadBottomSheet.downloadFab.updateLayoutParams { + bottomMargin = max(-pad.toInt(), view?.rootWindowInsets?.systemWindowInsetBottom ?: 0) + 16.dpToPx } - setPadding(dl_bottom_sheet.sheetBehavior?.isHideable == true) - requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301) } fun setRefreshing(refresh: Boolean) { - swipe_refresh?.isRefreshing = refresh + binding.swipeRefresh.isRefreshing = refresh } - override fun onItemMove(fromPosition: Int, toPosition: Int) {} + override fun onItemMove(fromPosition: Int, toPosition: Int) { } override fun shouldMoveItem(fromPosition: Int, toPosition: Int) = true override fun onActionStateChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { - swipe_refresh.isEnabled = actionState != ItemTouchHelper.ACTION_STATE_SWIPE || - swipe_refresh.isRefreshing + binding.swipeRefresh.isEnabled = actionState != ItemTouchHelper.ACTION_STATE_SWIPE || + binding.swipeRefresh.isRefreshing } override fun handleSheetBack(): Boolean { - if (dl_bottom_sheet?.sheetBehavior?.state == BottomSheetBehavior.STATE_EXPANDED) { - dl_bottom_sheet.dismiss() + if (showingDownloads) { + binding.downloadBottomSheet.dlBottomSheet.dismiss() + return true + } + if (presenter.preferences.recentsViewType().get() != presenter.viewType) { + tempJumpTo(presenter.preferences.recentsViewType().get()) return true } return false } fun setPadding(sheetIsHidden: Boolean) { - recycler?.updatePaddingRelative(bottom = if (sheetIsHidden) 0 else 20.dpToPx) - recycler?.updateLayoutParams { + binding.recycler.updatePaddingRelative(bottom = if (sheetIsHidden) 0 else 20.dpToPx) + binding.recycler.updateLayoutParams { bottomMargin = if (sheetIsHidden) 0 else 30.dpToPx } } override fun onActivityResumed(activity: Activity) { super.onActivityResumed(activity) - if (view != null) { + if (!isBindingInitialized) return + if (!presenter.isLoading) { refresh() - dl_bottom_sheet?.update() } + setBottomPadding() + binding.downloadBottomSheet.dlBottomSheet.update() } override fun onDestroy() { super.onDestroy() snack?.dismiss() - presenter.onDestroy() snack = null } + override fun onDestroyView(view: View) { + super.onDestroyView(view) + displaySheet?.dismiss() + displaySheet = null + } + fun refresh() = presenter.getRecents() - fun showLists(recents: List) { + fun showLists( + recents: List, + hasNewItems: Boolean, + shouldMoveToTop: Boolean = false + ) { if (view == null) return - swipe_refresh.isRefreshing = LibraryUpdateService.isRunning() - adapter.updateItems(recents) + binding.progress.isVisible = false + binding.frameLayout.alpha = 1f + binding.swipeRefresh.isRefreshing = LibraryUpdateService.isRunning() adapter.removeAllScrollableHeaders() - if (presenter.viewType > 0) - adapter.addScrollableHeader(presenter.generalHeader) + adapter.updateItems(recents) + adapter.onLoadMoreComplete(null) + if (!hasNewItems || presenter.viewType == RecentsPresenter.VIEW_TYPE_GROUP_ALL || + recents.isEmpty() + ) { + loadNoMore() + } else if (hasNewItems && presenter.viewType != RecentsPresenter.VIEW_TYPE_GROUP_ALL) { + resetProgressItem() + } + if (recents.isEmpty()) { + binding.recentsEmptyView.show( + if (!isSearching()) R.drawable.ic_history_off_24dp + else R.drawable.ic_search_off_24dp, + if (isSearching()) R.string.no_results_found + else when (presenter.viewType) { + RecentsPresenter.VIEW_TYPE_ONLY_UPDATES -> R.string.no_recent_chapters + RecentsPresenter.VIEW_TYPE_ONLY_HISTORY -> R.string.no_recently_read_manga + else -> R.string.no_recent_read_updated_manga + } + ) + } else { + binding.recentsEmptyView.hide() + } + if (shouldMoveToTop) { + binding.recycler.scrollToPosition(0) + } if (lastChapterId != null) { refreshItem(lastChapterId ?: 0L) lastChapterId = null } } - fun updateChapterDownload(download: Download) { + fun updateChapterDownload(download: Download, updateDLSheet: Boolean = true) { if (view == null) return - dl_bottom_sheet.update() - dl_bottom_sheet.onUpdateProgress(download) - dl_bottom_sheet.onUpdateDownloadedPages(download) + if (updateDLSheet) { + binding.downloadBottomSheet.dlBottomSheet.update() + binding.downloadBottomSheet.dlBottomSheet.onUpdateProgress(download) + binding.downloadBottomSheet.dlBottomSheet.onUpdateDownloadedPages(download) + } val id = download.chapter.id ?: return - val holder = recycler.findViewHolderForItemId(id) as? RecentMangaHolder ?: return - holder.notifyStatus(download.status, download.progress) + val holder = binding.recycler.findViewHolderForItemId(id) as? RecentMangaHolder ?: return + holder.notifyStatus(download.status, download.progress, download.chapter.read, true) + } + + fun updateDownloadStatus() { + binding.downloadBottomSheet.dlBottomSheet.update() } private fun refreshItem(chapterId: Long) { @@ -295,10 +523,10 @@ class RecentsController(bundle: Bundle? = null) : val item = adapter.getItem(position) as? RecentMangaItem ?: return val chapter = item.chapter val manga = item.mch.manga - if (item.status != Download.NOT_DOWNLOADED && item.status != Download.ERROR) { + if (item.status != Download.State.NOT_DOWNLOADED && item.status != Download.State.ERROR) { presenter.deleteChapter(chapter, manga) } else { - if (item.status == Download.ERROR) DownloadService.start(view.context) + if (item.status == Download.State.ERROR) DownloadService.start(view.context) else presenter.downloadChapter(manga, chapter) } } @@ -313,20 +541,39 @@ class RecentsController(bundle: Bundle? = null) : router.pushController(MangaDetailsController(manga).withFadeTransaction()) } - override fun showHistory() = router.pushController(RecentlyReadController().withFadeTransaction()) - override fun showUpdates() = router.pushController(RecentChaptersController().withFadeTransaction()) + override fun onRemoveHistoryClicked(position: Int) { + onItemLongClick(position) + } + + fun tempJumpTo(viewType: Int) { + presenter.toggleGroupRecents(viewType, false) + activityBinding?.mainTabs?.selectTab(activityBinding?.mainTabs?.getTabAt(viewType)) + updateTitleAndMenu() + } + + private fun setViewType(viewType: Int) { + if (viewType != presenter.viewType) { + presenter.toggleGroupRecents(viewType) + updateTitleAndMenu() + } + } + + override fun getViewType(): Int = presenter.viewType + + override fun scope() = viewScope override fun onItemClick(view: View?, position: Int): Boolean { val item = adapter.getItem(position) ?: return false if (item is RecentMangaItem) { if (item.mch.manga.id == null) { val headerItem = adapter.getHeaderOf(item) as? RecentMangaHeaderItem - val controller: Controller = when (headerItem?.recentsType) { - RecentMangaHeaderItem.NEW_CHAPTERS -> RecentChaptersController() - RecentMangaHeaderItem.CONTINUE_READING -> RecentlyReadController() - else -> return false - } - router.pushController(controller.withFadeTransaction()) + tempJumpTo( + when (headerItem?.recentsType) { + RecentMangaHeaderItem.NEW_CHAPTERS -> RecentsPresenter.VIEW_TYPE_ONLY_UPDATES + RecentMangaHeaderItem.CONTINUE_READING -> RecentsPresenter.VIEW_TYPE_ONLY_HISTORY + else -> return false + } + ) } else { val activity = activity ?: return false val intent = ReaderActivity.newIntent(activity, item.mch.manga, item.chapter) @@ -341,8 +588,9 @@ class RecentsController(bundle: Bundle? = null) : val manga = item.mch.manga val history = item.mch.history val chapter = item.mch.chapter - if (history.id != null) + if (history.id != null) { RemoveHistoryDialog(this, manga, history, chapter).showDialog(router) + } } override fun removeHistory(manga: Manga, history: History, all: Boolean) { @@ -362,28 +610,37 @@ class RecentsController(bundle: Bundle? = null) : val lastRead = chapter.last_page_read val pagesLeft = chapter.pages_left lastChapterId = chapter.id - presenter.markChapterRead(chapter, true) - snack = view?.snack(R.string.marked_as_read, Snackbar.LENGTH_INDEFINITE) { - anchorView = activity?.bottom_nav + val wasRead = chapter.read + presenter.markChapterRead(chapter, !wasRead) + snack = view?.snack( + if (wasRead) R.string.marked_as_unread + else R.string.marked_as_read, + Snackbar.LENGTH_INDEFINITE + ) { + anchorView = activityBinding?.bottomNav var undoing = false setAction(R.string.undo) { - presenter.markChapterRead(chapter, false, lastRead, pagesLeft) + presenter.markChapterRead(chapter, wasRead, lastRead, pagesLeft) undoing = true } - addCallback(object : BaseTransientBottomBar.BaseCallback() { - override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { - super.onDismissed(transientBottomBar, event) - if (!undoing && presenter.preferences.removeAfterMarkedAsRead()) { - lastChapterId = chapter.id - presenter.deleteChapter(chapter, manga) + addCallback( + object : BaseTransientBottomBar.BaseCallback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + super.onDismissed(transientBottomBar, event) + if (!undoing && presenter.preferences.removeAfterMarkedAsRead() && + !wasRead + ) { + lastChapterId = chapter.id + presenter.deleteChapter(chapter, manga) + } } } - }) + ) } (activity as? MainActivity)?.setUndoSnackBar(snack) } - override fun isSearching() = presenter.query.isNotEmpty() + override fun isSearching() = query.isNotEmpty() override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { if (onRoot) (activity as? MainActivity)?.setDismissIcon(showingDownloads) @@ -392,89 +649,157 @@ class RecentsController(bundle: Bundle? = null) : } else { inflater.inflate(R.menu.recents, menu) - when (presenter.viewType) { - 0 -> menu.findItem(R.id.action_group_all) - 1 -> menu.findItem(R.id.action_ungroup_all) - 2 -> menu.findItem(R.id.action_only_history) - 3 -> menu.findItem(R.id.action_only_updates) - else -> null - }?.isChecked = true - val searchItem = menu.findItem(R.id.action_search) val searchView = searchItem.actionView as SearchView searchView.queryHint = view?.context?.getString(R.string.search_recents) - if (presenter.query.isNotEmpty()) { + searchItem.collapseActionView() + if (isSearching()) { searchItem.expandActionView() - searchView.setQuery(presenter.query, true) + searchView.setQuery(query, true) searchView.clearFocus() } setOnQueryTextChangeListener(searchView) { - if (presenter.query != it) { - presenter.query = it ?: return@setOnQueryTextChangeListener false + if (query != it) { + query = it ?: return@setOnQueryTextChangeListener false + // loadNoMore() + resetProgressItem() refresh() } true } + searchItem.fixExpandInvalidate() + hideItemsIfExpanded(searchItem, menu) } } override fun onPrepareOptionsMenu(menu: Menu) { super.onPrepareOptionsMenu(menu) - if (showingDownloads) dl_bottom_sheet.prepareMenu(menu) + if (showingDownloads) binding.downloadBottomSheet.dlBottomSheet.prepareMenu(menu) } override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { super.onChangeStarted(handler, type) if (type.isEnter) { - view?.applyWindowInsetsForRootController(activity!!.bottom_nav) if (type == ControllerChangeType.POP_ENTER) presenter.onCreate() - dl_bottom_sheet.dismiss() + binding.downloadBottomSheet.dlBottomSheet.dismiss() + activityBinding?.mainTabs?.let { tabs -> + tabs.removeAllTabs() + tabs.clearOnTabSelectedListeners() + val selectedTab = presenter.viewType + listOf( + R.string.grouped, + R.string.all, + R.string.history, + R.string.updates + ).forEachIndexed { index, resId -> + tabs.addTab( + tabs.newTab().setText(resId).also { tab -> + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + tab.view.tooltipText = null + } + }, + index == selectedTab + ) + } + tabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab?) { + setViewType(tab?.position ?: 0) + } + + override fun onTabUnselected(tab: TabLayout.Tab?) {} + override fun onTabReselected(tab: TabLayout.Tab?) { + binding.recycler.smoothScrollToTop() + } + }) + (activity as? MainActivity)?.showTabBar(true) + } } else { if (type == ControllerChangeType.POP_EXIT) presenter.onDestroy() + if (router.backstack.lastOrNull()?.controller !is DialogController) { + (activity as? MainActivity)?.showTabBar(false) + } snack?.dismiss() } + setBottomPadding() + } + + override fun onChangeEnded(handler: ControllerChangeHandler, type: ControllerChangeType) { + super.onChangeEnded(handler, type) + if (type == ControllerChangeType.POP_ENTER) { + setBottomPadding() + } + if (type.isEnter) { + updateTitleAndMenu() + } } fun hasQueue() = presenter.downloadManager.hasQueue() override fun showSheet() { - if (dl_bottom_sheet.sheetBehavior?.isHideable == false || hasQueue()) - dl_bottom_sheet.sheetBehavior?.expand() + if (!isBindingInitialized) return + if (binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.isHideable == false || hasQueue()) { + binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.expand() + } } override fun toggleSheet() { - if (showingDownloads) dl_bottom_sheet.dismiss() - else dl_bottom_sheet.sheetBehavior?.expand() + if (showingDownloads) binding.downloadBottomSheet.dlBottomSheet.dismiss() + else binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.expand() } - override fun sheetIsExpanded(): Boolean = dl_bottom_sheet.sheetBehavior.isExpanded() + override fun sheetIsExpanded(): Boolean = binding.downloadBottomSheet.dlBottomSheet.sheetBehavior.isExpanded() override fun expandSearch() { if (showingDownloads) { - dl_bottom_sheet.dismiss() - } else - activity?.toolbar?.menu?.findItem(R.id.action_search)?.expandActionView() + binding.downloadBottomSheet.dlBottomSheet.dismiss() + } else { + activityBinding?.cardToolbar?.menu?.findItem(R.id.action_search)?.expandActionView() + } } override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (showingDownloads) - return dl_bottom_sheet.onOptionsItemSelected(item) + if (showingDownloads) { + return binding.downloadBottomSheet.dlBottomSheet.onOptionsItemSelected(item) + } when (item.itemId) { - R.id.action_group_all, R.id.action_ungroup_all, R.id.action_only_history, - R.id.action_only_updates -> { - presenter.toggleGroupRecents( - when (item.itemId) { - R.id.action_ungroup_all -> 1 - R.id.action_only_history -> 2 - R.id.action_only_updates -> 3 - else -> 0 - } + R.id.display_options -> { + displaySheet = TabbedRecentsOptionsSheet( + this, + (presenter.viewType - 1).coerceIn(0, 2) ) - if (item.itemId == R.id.action_only_history) - activity?.toast(R.string.press_and_hold_to_reset_history, Toast.LENGTH_LONG) - activity?.invalidateOptionsMenu() + displaySheet?.show() } } return super.onOptionsItemSelected(item) } + + override fun noMoreLoad(newItemsSize: Int) {} + + override fun onLoadMore(lastPosition: Int, currentPage: Int) { + val view = view ?: return + if (presenter.finished || + BackupRestoreService.isRunning(view.context.applicationContext) || + ( + presenter.viewType == RecentsPresenter.VIEW_TYPE_GROUP_ALL && + !isSearching() + ) + ) { + loadNoMore() + return + } + presenter.requestNext() + } + + private fun loadNoMore() { + adapter.onLoadMoreComplete(null) + } + + /** + * Sets a new progress item and reenables the scroll listener. + */ + private fun resetProgressItem() { + adapter.onLoadMoreComplete(null) + progressItem = ProgressItem() + adapter.setEndlessScrollListener(this, progressItem!!) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsPresenter.kt index a182e0c393..df82ca6d71 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsPresenter.kt @@ -4,23 +4,26 @@ import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.History import eu.kanade.tachiyomi.data.database.models.HistoryImpl -import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory import eu.kanade.tachiyomi.data.download.DownloadManager +import eu.kanade.tachiyomi.data.download.DownloadService +import eu.kanade.tachiyomi.data.download.DownloadServiceListener import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.DownloadQueue import eu.kanade.tachiyomi.data.library.LibraryServiceListener import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.source.SourceManager -import eu.kanade.tachiyomi.ui.recent_updates.DateItem +import eu.kanade.tachiyomi.ui.base.presenter.BaseCoroutinePresenter import eu.kanade.tachiyomi.util.system.executeOnIO -import kotlinx.coroutines.CoroutineScope +import eu.kanade.tachiyomi.util.system.launchIO +import eu.kanade.tachiyomi.util.system.launchUI import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import uy.kohesive.injekt.Injekt @@ -32,172 +35,284 @@ import java.util.concurrent.TimeUnit import kotlin.math.abs class RecentsPresenter( - val controller: RecentsController, + val controller: RecentsController?, val preferences: PreferencesHelper = Injekt.get(), val downloadManager: DownloadManager = Injekt.get(), private val db: DatabaseHelper = Injekt.get() -) : DownloadQueue.DownloadListener, LibraryServiceListener { - - private var scope = CoroutineScope(Job() + Dispatchers.Default) +) : BaseCoroutinePresenter(), DownloadQueue.DownloadListener, LibraryServiceListener, DownloadServiceListener { + private var recentsJob: Job? = null var recentItems = listOf() private set var query = "" + set(value) { + field = value + resetOffsets() + } private val newAdditionsHeader = RecentMangaHeaderItem(RecentMangaHeaderItem.NEWLY_ADDED) private val newChaptersHeader = RecentMangaHeaderItem(RecentMangaHeaderItem.NEW_CHAPTERS) - val generalHeader = RecentMangaHeaderItem(-1) private val continueReadingHeader = RecentMangaHeaderItem( RecentMangaHeaderItem .CONTINUE_READING ) - var viewType: Int = preferences.recentsViewType().getOrDefault() + var finished = false + var heldItems: HashMap> = hashMapOf() + private var shouldMoveToTop = false + var viewType: Int = preferences.recentsViewType().get() + private set + + private fun resetOffsets() { + finished = false + shouldMoveToTop = true + pageOffset = 0 + } - fun onCreate() { + private var pageOffset = 0 + var isLoading = false + private set + + private val isOnFirstPage: Boolean + get() = pageOffset == 0 + + override fun onCreate() { + super.onCreate() downloadManager.addListener(this) + DownloadService.addListener(this) LibraryUpdateService.setListener(this) if (lastRecents != null) { - if (recentItems.isEmpty()) + if (recentItems.isEmpty()) { recentItems = lastRecents ?: emptyList() + } lastRecents = null } getRecents() + listOf( + preferences.groupChaptersHistory(), + preferences.showReadInAllRecents(), + preferences.groupChaptersUpdates() + ).forEach { + it.asFlow() + .drop(1) + .onEach { + resetOffsets() + getRecents() + } + .launchIn(presenterScope) + } } - fun getRecents() { + fun getRecents(updatePageCount: Boolean = false) { val oldQuery = query - scope.launch { - val isUngrouped = viewType > 0 && query.isEmpty() - // groupRecents && query.isEmpty() - val cal = Calendar.getInstance().apply { - time = Date() - when { - query.isNotEmpty() -> add(Calendar.YEAR, -50) - isUngrouped -> add(Calendar.MONTH, -1) - else -> add(Calendar.MONTH, -1) - } - } + recentsJob?.cancel() + recentsJob = presenterScope.launch { + runRecents(oldQuery, updatePageCount) + } + } - val calWeek = Calendar.getInstance().apply { - time = Date() - when { - query.isNotEmpty() -> add(Calendar.YEAR, -50) - isUngrouped -> add(Calendar.MONTH, -1) - else -> add(Calendar.WEEK_OF_YEAR, -1) + private suspend fun runRecents( + oldQuery: String = "", + updatePageCount: Boolean = false, + retryCount: Int = 0, + itemCount: Int = 0, + limit: Boolean = false, + customViewType: Int? = null, + includeReadAnyway: Boolean = false + ) { + if (retryCount > 5) { + finished = true + setDownloadedChapters(recentItems) + if (customViewType == null) { + withContext(Dispatchers.Main) { + controller?.showLists(recentItems, false) + isLoading = false } } + return + } + val viewType = customViewType ?: viewType - val calDay = Calendar.getInstance().apply { - time = Date() - when { - query.isNotEmpty() -> add(Calendar.YEAR, -50) - isUngrouped -> add(Calendar.MONTH, -1) - else -> add(Calendar.DAY_OF_YEAR, -1) - } - } + val showRead = ((preferences.showReadInAllRecents().get() || query.isNotEmpty()) && !limit) || + includeReadAnyway == true + val isUngrouped = viewType > VIEW_TYPE_GROUP_ALL || query.isNotEmpty() + val groupChaptersUpdates = preferences.groupChaptersUpdates().get() + val groupChaptersHistory = preferences.groupChaptersHistory().get() - val cReading = if (viewType != 3) - if (query.isEmpty() && viewType != 2) - db.getRecentsWithUnread(cal.time, query, isUngrouped).executeOnIO() - else db.getRecentMangaLimit( - cal.time, - if (viewType == 2) 200 else 8, - query - ).executeOnIO() else emptyList() - val rUpdates = when { - viewType == 3 -> db.getRecentChapters(calWeek.time).executeOnIO().map { - MangaChapterHistory(it.manga, it.chapter, HistoryImpl()) - } - viewType != 2 -> db.getUpdatedManga(calWeek.time, query, isUngrouped).executeOnIO() - else -> emptyList() + val isCustom = customViewType != null + val isEndless = isUngrouped && !limit + val cReading = when { + viewType <= VIEW_TYPE_UNGROUP_ALL -> { + db.getAllRecentsTypes( + query, + showRead, + isEndless, + if (isCustom) ENDLESS_LIMIT else pageOffset, + !updatePageCount && !isOnFirstPage + ).executeOnIO() } - rUpdates.forEach { - it.history.last_read = it.chapter.date_fetch - } - val nAdditions = if (viewType < 2) - db.getRecentlyAdded(calDay.time, query, isUngrouped).executeOnIO() else emptyList() - nAdditions.forEach { - it.history.last_read = it.manga.date_added + viewType == VIEW_TYPE_ONLY_HISTORY -> { + if (groupChaptersHistory) { + db.getRecentMangaLimit( + query, + if (isCustom) ENDLESS_LIMIT else pageOffset, + !updatePageCount && !isOnFirstPage + ) + } else { + db.getHistoryUngrouped( + query, + if (isCustom) ENDLESS_LIMIT else pageOffset, + !updatePageCount && !isOnFirstPage + ) + }.executeOnIO() } - if (query != oldQuery) return@launch - val mangaList = (cReading + rUpdates + nAdditions).sortedByDescending { - it.history.last_read - }.distinctBy { - if (query.isEmpty() && viewType != 3) it.manga.id else it.chapter.id + viewType == VIEW_TYPE_ONLY_UPDATES -> { + if (groupChaptersUpdates) { + db.getUpdatedChaptersDistinct( + query, + if (isCustom) ENDLESS_LIMIT else pageOffset, + !updatePageCount && !isOnFirstPage + ) + } else { + db.getRecentChapters( + query, + if (isCustom) ENDLESS_LIMIT else pageOffset, + !updatePageCount && !isOnFirstPage + ) + }.executeOnIO() + .map { + MangaChapterHistory( + it.manga, + it.chapter, + HistoryImpl().apply { + last_read = it.chapter.date_fetch + } + ) + } } - val pairs = mangaList.mapNotNull { - val chapter = when { - viewType == 3 -> it.chapter - it.chapter.read || it.chapter.id == null -> getNextChapter(it.manga) - it.history.id == null -> getFirstUpdatedChapter(it.manga, it.chapter) - else -> it.chapter + else -> emptyList() + } + + if (!isCustom && + (pageOffset == 0 || updatePageCount) + ) { + pageOffset += cReading.size + } + + if (query != oldQuery) return + val mangaList = cReading.distinctBy { + if (query.isEmpty() && viewType != VIEW_TYPE_ONLY_HISTORY && viewType != VIEW_TYPE_ONLY_UPDATES) it.manga.id else it.chapter.id + }.filter { mch -> + if (updatePageCount && !isOnFirstPage && query.isEmpty()) { + if (viewType != VIEW_TYPE_ONLY_HISTORY && viewType != VIEW_TYPE_ONLY_UPDATES) { + recentItems.none { mch.manga.id == it.mch.manga.id } + } else { + recentItems.none { mch.chapter.id == it.mch.chapter.id } + } + } else true + } + val pairs = mangaList.mapNotNull { + val chapter = when { + (viewType == VIEW_TYPE_ONLY_UPDATES && !groupChaptersUpdates) || + (viewType == VIEW_TYPE_ONLY_HISTORY && !groupChaptersHistory) -> { + it.chapter + } + (it.chapter.read && viewType != VIEW_TYPE_ONLY_UPDATES) || it.chapter.id == null -> { + getNextChapter(it.manga) + ?: if (showRead && it.chapter.id != null) it.chapter else null + } + it.history.id == null -> { + getFirstUpdatedChapter(it.manga, it.chapter) + ?: if ((showRead && it.chapter.id != null) || viewType == VIEW_TYPE_ONLY_UPDATES) it.chapter else null + } + else -> { + it.chapter } - if (chapter == null) if ((query.isNotEmpty() || viewType > 1) && - it.chapter.id != null - ) Pair(it, it.chapter) - else null - else Pair(it, chapter) } - if (query.isEmpty() && !isUngrouped) { - val nChaptersItems = - pairs.filter { it.first.history.id == null && it.first.chapter.id != null } - .sortedWith( - Comparator> { f1, f2 -> - if (abs(f1.second.date_fetch - f2.second.date_fetch) <= - TimeUnit.HOURS.toMillis(12) - ) - f2.second.date_upload.compareTo(f1.second.date_upload) - else - f2.second.date_fetch.compareTo(f1.second.date_fetch) - } - ) - .take(4).map { - RecentMangaItem( - it.first, - it.second, - newChaptersHeader - ) - } + - RecentMangaItem(header = newChaptersHeader) - val cReadingItems = - pairs.filter { it.first.history.id != null }.take(9 - nChaptersItems.size).map { + if (chapter == null) if ((query.isNotEmpty() || viewType > VIEW_TYPE_UNGROUP_ALL) && + it.chapter.id != null + ) Pair(it, it.chapter) + else null + else Pair(it, chapter) + } + val newItems = if (query.isEmpty() && !isUngrouped) { + val nChaptersItems = + pairs.asSequence() + .filter { it.first.history.id == null && it.first.chapter.id != null } + .sortedWith { f1, f2 -> + if (abs(f1.second.date_fetch - f2.second.date_fetch) <= + TimeUnit.HOURS.toMillis(12) + ) { + f2.second.date_upload.compareTo(f1.second.date_upload) + } else { + f2.second.date_fetch.compareTo(f1.second.date_fetch) + } + } + .take(4).map { RecentMangaItem( it.first, it.second, - continueReadingHeader + newChaptersHeader ) - } + RecentMangaItem(header = continueReadingHeader) - val nAdditionsItems = pairs.filter { it.first.chapter.id == null }.take(4) - .map { RecentMangaItem(it.first, it.second, newAdditionsHeader) } - recentItems = - listOf(nChaptersItems, cReadingItems, nAdditionsItems).sortedByDescending { - it.firstOrNull()?.mch?.history?.last_read ?: 0L - }.flatten() - } else { - recentItems = - if (viewType == 3) { - val map = TreeMap>> { - d1, d2 -> - d2 - .compareTo(d1) - } - val byDay = - pairs.groupByTo(map, { getMapKey(it.first.history.last_read) }) - byDay.flatMap { - val dateItem = DateItem(it.key, true) - it.value.map { item -> - RecentMangaItem(item.first, item.second, dateItem) - } - } - } else pairs.map { RecentMangaItem(it.first, it.second, null) } - if (isUngrouped && recentItems.isEmpty()) { - recentItems = listOf( - RecentMangaItem(header = newChaptersHeader), - RecentMangaItem(header = continueReadingHeader) + }.toMutableList() + val cReadingItems = + pairs.filter { it.first.history.id != null }.take(9 - nChaptersItems.size).map { + RecentMangaItem( + it.first, + it.second, + continueReadingHeader ) + }.toMutableList() + if (nChaptersItems.isNotEmpty()) { + nChaptersItems.add(RecentMangaItem(header = newChaptersHeader)) + } + if (cReadingItems.isNotEmpty()) { + cReadingItems.add(RecentMangaItem(header = continueReadingHeader)) + } + val nAdditionsItems = pairs.filter { it.first.chapter.id == null }.take(4) + .map { RecentMangaItem(it.first, it.second, newAdditionsHeader) } + listOf(nChaptersItems, cReadingItems, nAdditionsItems).sortedByDescending { + it.firstOrNull()?.mch?.history?.last_read ?: 0L + }.flatten() + } else { + if (viewType == VIEW_TYPE_ONLY_UPDATES) { + val map = + TreeMap>> { d1, d2 -> + d2 + .compareTo(d1) + } + val byDay = + pairs.groupByTo(map, { getMapKey(it.first.history.last_read) }) + byDay.flatMap { + val dateItem = DateItem(it.key, true) + it.value + .map { item -> RecentMangaItem(item.first, item.second, dateItem) } + .sortedByDescending { item -> item.chapter.date_upload } } + } else pairs.map { RecentMangaItem(it.first, it.second, null) } + } + if (customViewType == null) { + recentItems = if (isOnFirstPage || !updatePageCount) { + newItems + } else { + recentItems + newItems } + } else { + heldItems[customViewType] = newItems + } + val newCount = itemCount + newItems.size + val hasNewItems = newItems.isNotEmpty() + if (updatePageCount && newCount < 25 && (viewType != VIEW_TYPE_GROUP_ALL || query.isNotEmpty()) && !limit) { + runRecents(oldQuery, true, retryCount + (if (hasNewItems) 0 else 1), newCount) + return + } + if (!limit) { setDownloadedChapters(recentItems) - withContext(Dispatchers.Main) { controller.showLists(recentItems) } + if (customViewType == null) { + withContext(Dispatchers.Main) { + controller?.showLists(recentItems, hasNewItems, shouldMoveToTop) + isLoading = false + shouldMoveToTop = false + } + } } } @@ -213,19 +328,20 @@ class RecentsPresenter( } } - fun onDestroy() { + override fun onDestroy() { + super.onDestroy() downloadManager.removeListener(this) LibraryUpdateService.removeListener(this) + DownloadService.removeListener(this) lastRecents = recentItems } - fun cancelScope() { - scope.cancel() - } - - fun toggleGroupRecents(pref: Int) { - preferences.recentsViewType().set(pref) + fun toggleGroupRecents(pref: Int, updatePref: Boolean = true) { + if (updatePref) { + preferences.recentsViewType().set(pref) + } viewType = pref + resetOffsets() getRecents() } @@ -235,37 +351,44 @@ class RecentsPresenter( * @param chapters the list of chapter from the database. */ private fun setDownloadedChapters(chapters: List) { - for (item in chapters) { + for (item in chapters.filter { it.chapter.id != null }) { if (downloadManager.isChapterDownloaded(item.chapter, item.mch.manga)) { - item.status = Download.DOWNLOADED + item.status = Download.State.DOWNLOADED } else if (downloadManager.hasQueue()) { item.status = downloadManager.queue.find { it.chapter.id == item.chapter.id } - ?.status ?: 0 + ?.status ?: Download.State.default } } } override fun updateDownload(download: Download) { recentItems.find { it.chapter.id == download.chapter.id }?.download = download - scope.launch(Dispatchers.Main) { - controller.updateChapterDownload(download) - } + presenterScope.launchUI { controller?.updateChapterDownload(download) } } override fun updateDownloads() { - scope.launch { + presenterScope.launch { setDownloadedChapters(recentItems) withContext(Dispatchers.Main) { - controller.showLists(recentItems) + controller?.showLists(recentItems, true) + controller?.updateDownloadStatus() + } + } + } + + override fun downloadStatusChanged(downloading: Boolean) { + presenterScope.launch { + withContext(Dispatchers.Main) { + controller?.updateDownloadStatus() } } } - override fun onUpdateManga(manga: LibraryManga) { - if (manga.id == null && !LibraryUpdateService.isRunning()) { - scope.launch(Dispatchers.Main) { controller.setRefreshing(false) } - } else if (manga.id == null) { - scope.launch(Dispatchers.Main) { controller.setRefreshing(true) } + override fun onUpdateManga(manga: Manga?) { + if (manga == null && !LibraryUpdateService.isRunning()) { + presenterScope.launchUI { controller?.setRefreshing(false) } + } else if (manga == null) { + presenterScope.launchUI { controller?.setRefreshing(true) } } else { getRecents() } @@ -277,16 +400,17 @@ class RecentsPresenter( */ fun deleteChapter(chapter: Chapter, manga: Manga, update: Boolean = true) { val source = Injekt.get().getMangadex() - downloadManager.deleteChapters(listOf(chapter), manga, source) - + launchIO { + downloadManager.deleteChapters(listOf(chapter), manga, source) + } if (update) { val item = recentItems.find { it.chapter.id == chapter.id } ?: return item.apply { - status = Download.NOT_DOWNLOADED + status = Download.State.NOT_DOWNLOADED download = null } - controller.showLists(recentItems) + controller?.showLists(recentItems, true) } } @@ -329,7 +453,7 @@ class RecentsPresenter( lastRead: Int? = null, pagesLeft: Int? = null ) { - scope.launch(Dispatchers.IO) { + presenterScope.launch(Dispatchers.IO) { chapter.apply { this.read = read if (!read) { @@ -364,7 +488,31 @@ class RecentsPresenter( getRecents() } + fun requestNext() { + if (!isLoading) { + isLoading = true + getRecents(true) + } + } + companion object { - var lastRecents: List? = null + private var lastRecents: List? = null + + const val VIEW_TYPE_GROUP_ALL = 0 + const val VIEW_TYPE_UNGROUP_ALL = 1 + const val VIEW_TYPE_ONLY_HISTORY = 2 + const val VIEW_TYPE_ONLY_UPDATES = 3 + const val ENDLESS_LIMIT = 50 + var SHORT_LIMIT = 25 + private set + + suspend fun getRecentManga(includeRead: Boolean = false): List> { + val presenter = RecentsPresenter(null) + presenter.viewType = 1 + SHORT_LIMIT = if (includeRead) 50 else 25 + presenter.runRecents(limit = true, includeReadAnyway = includeRead) + SHORT_LIMIT = 25 + return presenter.recentItems.filter { it.mch.manga.id != null }.map { it.mch.manga to it.mch.history.last_read } + } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RemoveHistoryDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RemoveHistoryDialog.kt similarity index 91% rename from app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RemoveHistoryDialog.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/recents/RemoveHistoryDialog.kt index cd34cb8e98..00175b4d37 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recently_read/RemoveHistoryDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RemoveHistoryDialog.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.ui.recently_read +package eu.kanade.tachiyomi.ui.recents import android.app.Dialog import android.os.Bundle @@ -33,12 +33,14 @@ class RemoveHistoryDialog(bundle: Bundle? = null) : DialogController(bundle) return MaterialDialog(activity).title(R.string.reset_chapter_question).message( text = if (chapter?.name != null) activity.getString( - R.string.this_will_remove_the_read_date_for_x_question, chapter?.name ?: "" + R.string.this_will_remove_the_read_date_for_x_question, + chapter?.name ?: "" ) else activity.getString(R.string.this_will_remove_the_read_date_question) ).checkBoxPrompt( text = activity.getString( - R.string.reset_all_chapters_for_this_, manga!!.mangaType(activity) + R.string.reset_all_chapters_for_this_, + manga!!.seriesType(activity) ) ) {}.negativeButton(android.R.string.cancel).positiveButton(R.string.reset) { onPositive(it.isCheckPromptChecked()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/options/RecentsGeneralView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/options/RecentsGeneralView.kt new file mode 100644 index 0000000000..7f850ad50d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/options/RecentsGeneralView.kt @@ -0,0 +1,24 @@ +package eu.kanade.tachiyomi.ui.recents.options + +import android.content.Context +import android.util.AttributeSet +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.RecentsGeneralViewBinding +import eu.kanade.tachiyomi.util.bindToPreference +import eu.kanade.tachiyomi.util.lang.withSubtitle +import eu.kanade.tachiyomi.widget.BaseRecentsDisplayView + +class RecentsGeneralView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + BaseRecentsDisplayView(context, attrs) { + + override fun inflateBinding() = RecentsGeneralViewBinding.bind(this) + override fun initGeneralPreferences() { + val titleText = context.getString(R.string.show_reset_history_button) + binding.showRemoveHistory.text = titleText + .withSubtitle(binding.showRemoveHistory.context, R.string.press_and_hold_to_also_reset) + binding.showRecentsDownload.bindToPreference(preferences.showRecentsDownloads()) + binding.showRemoveHistory.bindToPreference(preferences.showRecentsRemHistory()) + binding.showReadInAll.bindToPreference(preferences.showReadInAllRecents()) + binding.showTitleFirst.bindToPreference(preferences.showTitleFirstInRecents()) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/options/RecentsHistoryView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/options/RecentsHistoryView.kt new file mode 100644 index 0000000000..e9c14616b8 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/options/RecentsHistoryView.kt @@ -0,0 +1,16 @@ +package eu.kanade.tachiyomi.ui.recents.options + +import android.content.Context +import android.util.AttributeSet +import eu.kanade.tachiyomi.databinding.RecentsHistoryViewBinding +import eu.kanade.tachiyomi.util.bindToPreference +import eu.kanade.tachiyomi.widget.BaseRecentsDisplayView + +class RecentsHistoryView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + BaseRecentsDisplayView(context, attrs) { + + override fun inflateBinding() = RecentsHistoryViewBinding.bind(this) + override fun initGeneralPreferences() { + binding.groupChapters.bindToPreference(preferences.groupChaptersHistory()) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/options/RecentsUpdatesView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/options/RecentsUpdatesView.kt new file mode 100644 index 0000000000..e9508d1052 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/options/RecentsUpdatesView.kt @@ -0,0 +1,17 @@ +package eu.kanade.tachiyomi.ui.recents.options + +import android.content.Context +import android.util.AttributeSet +import eu.kanade.tachiyomi.databinding.RecentsUpdatesViewBinding +import eu.kanade.tachiyomi.util.bindToPreference +import eu.kanade.tachiyomi.widget.BaseRecentsDisplayView + +class RecentsUpdatesView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + BaseRecentsDisplayView(context, attrs) { + + override fun inflateBinding() = RecentsUpdatesViewBinding.bind(this) + override fun initGeneralPreferences() { + binding.showUpdatedTime.bindToPreference(preferences.showUpdatedTime()) + binding.groupChapters.bindToPreference(preferences.groupChaptersUpdates()) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/options/TabbedRecentsOptionsSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/options/TabbedRecentsOptionsSheet.kt new file mode 100644 index 0000000000..72a94c241b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/options/TabbedRecentsOptionsSheet.kt @@ -0,0 +1,43 @@ +package eu.kanade.tachiyomi.ui.recents.options + +import android.view.View +import android.view.View.inflate +import androidx.annotation.IntRange +import androidx.core.view.isVisible +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.recents.RecentsController +import eu.kanade.tachiyomi.widget.TabbedBottomSheetDialog + +class TabbedRecentsOptionsSheet(val controller: RecentsController, @IntRange(from = 0, to = 2) startingTab: Int) : + TabbedBottomSheetDialog(controller.activity!!) { + + private val generalView = inflate(controller.activity!!, R.layout.recents_general_view, null) as RecentsGeneralView + private val historyView = inflate(controller.activity!!, R.layout.recents_history_view, null) as RecentsHistoryView + private val updatesView = inflate(controller.activity!!, R.layout.recents_updates_view, null) as RecentsUpdatesView + + init { + binding.menu.isVisible = false + generalView.controller = controller + historyView.controller = controller + updatesView.controller = controller + + binding.tabs.getTabAt(startingTab)?.select() + } + + override fun dismiss() { + super.dismiss() + controller.displaySheet = null + } + + override fun getTabViews(): List = listOf( + generalView, + historyView, + updatesView + ) + + override fun getTabTitles(): List = listOf( + R.string.general, + R.string.history, + R.string.updates + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/security/BiometricActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/security/BiometricActivity.kt index 583abcde28..9111af9e3e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/security/BiometricActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/security/BiometricActivity.kt @@ -3,19 +3,21 @@ package eu.kanade.tachiyomi.ui.security import android.os.Bundle import androidx.biometric.BiometricPrompt import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.MainActivityBinding import eu.kanade.tachiyomi.ui.base.activity.BaseActivity import java.util.Date import java.util.concurrent.ExecutorService import java.util.concurrent.Executors -class BiometricActivity : BaseActivity() { +class BiometricActivity : BaseActivity() { private val executor: ExecutorService = Executors.newSingleThreadExecutor() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val fromSearch = intent.getBooleanExtra("fromSearch", false) val biometricPrompt = BiometricPrompt( - this, executor, + this, + executor, object : BiometricPrompt .AuthenticationCallback() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/AboutController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/AboutController.kt index 94d2119792..714b7413c2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/AboutController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/AboutController.kt @@ -13,15 +13,14 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.updater.UpdateChecker import eu.kanade.tachiyomi.data.updater.UpdateResult +import eu.kanade.tachiyomi.data.updater.UpdaterNotifier import eu.kanade.tachiyomi.data.updater.UpdaterService import eu.kanade.tachiyomi.ui.base.controller.DialogController -import eu.kanade.tachiyomi.ui.main.ChangelogDialogController import eu.kanade.tachiyomi.util.lang.toTimestampString import eu.kanade.tachiyomi.util.system.isOnline import eu.kanade.tachiyomi.util.system.toast -import kotlinx.coroutines.CoroutineScope +import eu.kanade.tachiyomi.util.view.openInBrowser import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import uy.kohesive.injekt.injectLazy @@ -40,30 +39,34 @@ class AboutController : SettingsController() { private val userPreferences: PreferencesHelper by injectLazy() - private val dateFormat: DateFormat = userPreferences.dateFormat() + private val dateFormat: DateFormat by lazy { + preferences.dateFormat() + } - /** - * The subscribtion service of the obtained release object - */ - private val scope = CoroutineScope(Job() + Dispatchers.IO) + private val isUpdaterEnabled = BuildConfig.INCLUDE_UPDATER - override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { + override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { titleRes = R.string.about - preferenceCategory { - preference { - titleRes = R.string.version - summary = if (BuildConfig.DEBUG) "r" + BuildConfig.COMMIT_COUNT - else BuildConfig.VERSION_NAME - } - preference { - titleRes = R.string.build_time - summary = getFormattedBuildTime() + preference { + key = "pref_whats_new" + titleRes = R.string.whats_new_this_release + onClick { + val intent = Intent( + Intent.ACTION_VIEW, + if (BuildConfig.DEBUG) { + "https://github.com/CarlosEsco/Neko/commits/master" + } else { + "https://github.com/CarlosEsco/Neko/releases/tag/${BuildConfig.VERSION_NAME}" + }.toUri() + ) + startActivity(intent) } - + } + if (isUpdaterEnabled) { preference { + key = "pref_check_for_updates" titleRes = R.string.check_for_updates - onClick { if (activity!!.isOnline()) { checkVersion() @@ -72,58 +75,53 @@ class AboutController : SettingsController() { } } } - - preference { - titleRes = R.string.view_changelog - onClick { - ChangelogDialogController().showDialog(router) - } - } - - preference { - titleRes = R.string.view_release_notes - onClick { - val intent = Intent( - Intent.ACTION_VIEW, - if (BuildConfig.DEBUG) { - "https://github.com/CarlosEsco/Neko/commits/master" - } else { - "https://github.com/CarlosEsco/Neko/releases/tag/${BuildConfig.VERSION_NAME}" - }.toUri() - ) - startActivity(intent) - } - } + } + preference { + key = "pref_version" + titleRes = R.string.version + summary = if (BuildConfig.DEBUG) "r" + BuildConfig.COMMIT_COUNT + else BuildConfig.VERSION_NAME + } + preference { + key = "pref_build_time" + titleRes = R.string.build_time + summary = getFormattedBuildTime() } preferenceCategory { - preference { + key = "pref_about_website" titleRes = R.string.website - val url = "https://tachiyomi.org/forks/Neko/" - summary = url - onClick { - val intent = Intent(Intent.ACTION_VIEW, url.toUri()) - startActivity(intent) + "https://tachiyomi.org".also { + summary = it + onClick { openInBrowser(it) } } } preference { + key = "pref_about_discord" title = "Discord" - val url = "https://discord.gg/tachiyomi" - summary = url - onClick { - val intent = Intent(Intent.ACTION_VIEW, url.toUri()) - startActivity(intent) + "https://discord.gg/tachiyomi".also { + summary = it + onClick { openInBrowser(it) } } } + preference { + key = "pref_about_github" title = "Github" - val url = "https://github.com/CarlosEsco/Neko" - summary = url - onClick { - val intent = Intent(Intent.ACTION_VIEW, url.toUri()) - startActivity(intent) + "https://github.com/CarlosEsco/Neko".also { + summary = it + onClick { openInBrowser(it) } + } + } + + preference { + key = "pref_about_twitter" + title = "Twitter" + "https://twitter.com/tachiyomiorg".also { + summary = it + onClick { openInBrowser(it) } } } @@ -156,7 +154,7 @@ class AboutController : SettingsController() { if (activity == null) return activity?.toast(R.string.searching_for_updates) - scope.launch { + viewScope.launch { val result = try { updateChecker.checkForUpdate() } catch (error: Exception) { @@ -172,6 +170,7 @@ class AboutController : SettingsController() { // Create confirmation window withContext(Dispatchers.Main) { + UpdaterNotifier.releasePageUrl = result.release.releaseLink NewUpdateDialogController(body, url).showDialog(router) } } @@ -208,7 +207,7 @@ class AboutController : SettingsController() { .negativeButton(R.string.ignore) } - private companion object { + companion object { const val BODY_KEY = "NewUpdateDialogController.body" const val URL_KEY = "NewUpdateDialogController.key" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/PreferenceDSL.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/PreferenceDSL.kt index e60dc9db32..371ddc491f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/PreferenceDSL.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/PreferenceDSL.kt @@ -2,6 +2,8 @@ package eu.kanade.tachiyomi.ui.setting import android.app.Activity import android.graphics.drawable.Drawable +import androidx.annotation.StringRes +import androidx.core.graphics.drawable.DrawableCompat import androidx.preference.CheckBoxPreference import androidx.preference.DialogPreference import androidx.preference.DropDownPreference @@ -12,7 +14,10 @@ import androidx.preference.PreferenceGroup import androidx.preference.PreferenceManager import androidx.preference.PreferenceScreen import androidx.preference.SwitchPreferenceCompat +import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat import com.mikepenz.iconics.IconicsDrawable +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.widget.preference.IntListMatPreference import eu.kanade.tachiyomi.widget.preference.ListMatPreference import eu.kanade.tachiyomi.widget.preference.MultiListMatPreference @@ -29,6 +34,10 @@ inline fun PreferenceGroup.preference(block: (@DSL Preference).() -> Unit): Pref return initThenAdd(Preference(context), block) } +inline fun PreferenceGroup.themePreference(block: (@DSL ThemePreference).() -> Unit): ThemePreference { + return initThenAdd(ThemePreference(context), block) +} + inline fun PreferenceGroup.switchPreference(block: (@DSL SwitchPreferenceCompat).() -> Unit): SwitchPreferenceCompat { return initThenAdd(SwitchPreferenceCompat(context), block) } @@ -43,8 +52,8 @@ inline fun PreferenceGroup.editTextPreference(block: (@DSL EditTextPreference).( inline fun PreferenceGroup.dropDownPreference(block: (@DSL DropDownPreference).() -> Unit): DropDownPreference { - return initThenAdd(DropDownPreference(context), block).also(::initDialog) - } + return initThenAdd(DropDownPreference(context), block).also(::initDialog) +} inline fun PreferenceGroup.listPreference( activity: Activity?, @@ -52,8 +61,8 @@ inline fun PreferenceGroup.listPreference( -> Unit ): ListMatPreference { - return initThenAdd(ListMatPreference(activity, context), block) - } + return initThenAdd(ListMatPreference(activity, context), block) +} inline fun PreferenceGroup.intListPreference( activity: Activity?, @@ -63,8 +72,8 @@ inline fun PreferenceGroup.intListPreference( ).() -> Unit ): IntListMatPreference { - return initThenAdd(IntListMatPreference(activity, context), block) - } + return initThenAdd(IntListMatPreference(activity, context), block) +} inline fun PreferenceGroup.multiSelectListPreferenceMat( activity: Activity?, @@ -86,6 +95,22 @@ inline fun PreferenceScreen.preferenceCategory(block: (@DSL PreferenceCategory). ) } +inline fun PreferenceScreen.switchPreference(block: (@DSL SwitchPreferenceCompat).() -> Unit): SwitchPreferenceCompat { + return initThenAdd(SwitchPreferenceCompat(context), block) +} + +inline fun PreferenceGroup.infoPreference(@StringRes infoRes: Int): Preference { + return initThenAdd( + Preference(context), + { + iconRes = R.drawable.ic_info_outline_24dp + iconTint = context.getResourceColor(android.R.attr.textColorSecondary) + summaryRes = infoRes + isSelectable = false + } + ) +} + inline fun PreferenceScreen.preferenceScreen(block: (@DSL PreferenceScreen).() -> Unit): PreferenceScreen { return addThenInit(preferenceManager.createPreferenceScreen(context), block) } @@ -134,12 +159,24 @@ var Preference.titleRes: Int setTitle(value) } +var Preference.iconRes: Int + get() = 0 // set only + set(value) { + icon = VectorDrawableCompat.create(context.resources, value, context.theme) + } + var Preference.summaryRes: Int get() = 0 // set only set(value) { setSummary(value) } +var Preference.iconTint: Int + get() = 0 // set only + set(value) { + DrawableCompat.setTint(icon, value) + } + var Preference.iconDrawable: Drawable get() = IconicsDrawable(context) // set only set(value) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt index 602e87c16d..7dad12d1f4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.app.Dialog import android.content.Context import android.content.Intent +import android.os.Build import android.os.Bundle import android.os.Environment import android.os.PowerManager @@ -25,6 +26,8 @@ import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Target import eu.kanade.tachiyomi.data.preference.PreferenceKeys import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE +import eu.kanade.tachiyomi.network.PREF_DOH_GOOGLE import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.util.log.XLogLevel @@ -56,7 +59,7 @@ class SettingsAdvancedController : SettingsController() { private val downloadMangager: DownloadManager by injectLazy() @SuppressLint("BatteryLife") - override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { + override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { titleRes = R.string.advanced switchPreference { @@ -71,23 +74,36 @@ class SettingsAdvancedController : SettingsController() { preference { key = "dump_crash_logs" - titleRes = R.string.pref_dump_crash_logs - summaryRes = R.string.pref_dump_crash_logs_summary + titleRes = R.string.dump_crash_logs + summaryRes = R.string.saves_error_logs onClick { CrashLogUtil(context).dumpLogs() } } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager? + if (pm != null) preference { + key = "disable_batt_opt" + titleRes = R.string.disable_battery_optimization + summaryRes = R.string.disable_if_issues_with_updating + onClick { + val packageName: String = context.packageName + if (!pm.isIgnoringBatteryOptimizations(packageName)) { + val intent = Intent().apply { + action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + data = "package:$packageName".toUri() + } + startActivity(intent) + } else { + context.toast(R.string.battery_optimization_disabled) + } + } + } + } preferenceCategory { titleRes = R.string.data_management - - preference { - titleRes = R.string.force_download_cache_refresh - summaryRes = R.string.force_download_cache_refresh_summary - onClick { downloadMangager.refreshCache() } - } - preference { key = CLEAR_CACHE_KEY titleRes = R.string.clear_chapter_cache @@ -97,16 +113,26 @@ class SettingsAdvancedController : SettingsController() { } preference { + titleRes = R.string.force_download_cache_refresh + summaryRes = R.string.force_download_cache_refresh_summary + onClick { downloadMangager.refreshCache() } + } + + preference { + key = "clean_cached_covers" titleRes = R.string.clean_up_cached_covers - summary = context.getString(R.string.delete_old_covers_in_library_used_, coverCache.getChapterCacheSize()) + summary = context.getString( + R.string.delete_old_covers_in_library_used_, + coverCache.getChapterCacheSize() + ) onClick { context.toast(R.string.starting_cleanup) coverCache.deleteOldCovers() } } - preference { + key = "clear_cached_not_library" titleRes = R.string.clear_cached_covers_non_library summary = context.getString( R.string.delete_all_covers__not_in_library_used_, @@ -118,10 +144,12 @@ class SettingsAdvancedController : SettingsController() { coverCache.deleteAllCachedCovers() } } - preference { + key = "clean_downloaded_chapters" titleRes = R.string.clean_up_downloaded_chapters + summaryRes = R.string.delete_unused_chapters + onClick { val ctrl = CleanupDownloadsDialogController() ctrl.targetController = this@SettingsAdvancedController @@ -129,6 +157,7 @@ class SettingsAdvancedController : SettingsController() { } } preference { + key = "clear_database" titleRes = R.string.clear_database summaryRes = R.string.clear_database_summary @@ -142,8 +171,8 @@ class SettingsAdvancedController : SettingsController() { preferenceCategory { titleRes = R.string.network - preference { + key = "clear_cookies" titleRes = R.string.clear_cookies onClick { @@ -151,29 +180,35 @@ class SettingsAdvancedController : SettingsController() { activity?.toast(R.string.cookies_cleared) } } - switchPreference { - key = PreferenceKeys.enableDoh - titleRes = R.string.dns_over_https - summaryRes = R.string.requires_app_restart - defaultValue = false + + intListPreference(activity) { + key = PreferenceKeys.dohProvider + titleRes = R.string.doh + entriesRes = arrayOf(R.string.disabled, R.string.cloudflare, R.string.google) + entryValues = listOf(-1, PREF_DOH_CLOUDFLARE, PREF_DOH_GOOGLE) + + defaultValue = -1 + onChange { + activity?.toast(R.string.requires_app_restart) + true + } } } preferenceCategory { titleRes = R.string.library preference { + key = "refresh_teacking_meta" titleRes = R.string.refresh_tracking_metadata summaryRes = R.string.updates_tracking_details onClick { LibraryUpdateService.start(context, target = Target.TRACKING) } } - } - intListPreference(activity) { key = PreferenceKeys.logLevel titleRes = R.string.log_level - customSummary = context.getString(R.string.log_level_summary) + "\nCurrent Level: " + XLogLevel.values()[prefs.logLevel()] + summary = context.getString(R.string.log_level_summary) + "\nCurrent Level: " + XLogLevel.values()[prefs.logLevel()] entries = XLogLevel.values().map { "${it.name.toLowerCase().capitalize()} (${it.description})" } @@ -189,32 +224,11 @@ class SettingsAdvancedController : SettingsController() { logFolder.deleteRecursively() } } - - val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager? - if (pm != null) preference { - titleRes = R.string.disable_battery_optimization - summaryRes = R.string.disable_if_issues_with_updating - - onClick { - val packageName: String = context.packageName - if (!pm.isIgnoringBatteryOptimizations(packageName)) { - val intent = Intent().apply { - action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS - data = "package:$packageName".toUri() - } - startActivity(intent) - } else { - context.toast(R.string.battery_optimization_disabled) - } - } - } } class CleanupDownloadsDialogController() : DialogController() { override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialDialog(activity!!).show { - title(R.string.clean_up_downloaded_chapters) .listItemsMultiChoice(R.array.clean_up_downloads, disabledIndices = intArrayOf(0), initialSelection = intArrayOf(0, 1, 2)) { dialog, selections, items -> val deleteRead = selections.contains(1) @@ -294,7 +308,8 @@ class SettingsAdvancedController : SettingsController() { activity?.toast( resources?.getQuantityString( R.plurals.cache_cleared, - deletedFiles, deletedFiles + deletedFiles, + deletedFiles ) ) findPreference(CLEAR_CACHE_KEY)?.summary = diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt index 0a4f1fedd7..bb6ca1ca4c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt @@ -1,6 +1,5 @@ package eu.kanade.tachiyomi.ui.setting -import android.Manifest.permission.WRITE_EXTERNAL_STORAGE import android.app.Activity import android.app.Dialog import android.content.ActivityNotFoundException @@ -21,13 +20,15 @@ import eu.kanade.tachiyomi.data.backup.BackupConst import eu.kanade.tachiyomi.data.backup.BackupCreateService import eu.kanade.tachiyomi.data.backup.BackupCreatorJob import eu.kanade.tachiyomi.data.backup.BackupRestoreService -import eu.kanade.tachiyomi.data.backup.full.BackupFull -import eu.kanade.tachiyomi.data.backup.models.Backup -import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.data.backup.full.models.BackupFull +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup +import eu.kanade.tachiyomi.data.preference.asImmediateFlow import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.util.system.getFilePicker import eu.kanade.tachiyomi.util.system.toast -import eu.kanade.tachiyomi.util.view.requestPermissionsSafe +import eu.kanade.tachiyomi.util.view.requestFilePermissionsSafe +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys class SettingsBackupController : SettingsController() { @@ -39,75 +40,80 @@ class SettingsBackupController : SettingsController() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 500) + requestFilePermissionsSafe(500) } - override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { + override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { titleRes = R.string.backup - preference { - key = "pref_create_backup" - titleRes = R.string.create_backup - summaryRes = R.string.can_be_used_to_restore + preferenceCategory { + titleRes = R.string.backup - onClick { backup(context, BackupConst.BACKUP_TYPE_FULL) } - } - preference { - key = "pref_create_legacy_backup" - titleRes = R.string.create_backup_legacy - summaryRes = R.string.can_be_used_to_restore_legacy + preference { + key = "pref_create_backup" + titleRes = R.string.create_backup + summaryRes = R.string.can_be_used_to_restore - onClick { backup(context, BackupConst.BACKUP_TYPE_LEGACY) } - } + onClick { backup(context, BackupConst.BACKUP_TYPE_FULL) } + } + preference { + key = "pref_create_legacy_backup" + titleRes = R.string.create_legacy_backup + summaryRes = R.string.can_be_used_in_older_tachi - preference { - titleRes = R.string.restore_backup - summaryRes = R.string.restore_from_backup_file - - onClick { - if (!BackupRestoreService.isRunning(context)) { - val intent = Intent(Intent.ACTION_GET_CONTENT) - intent.addCategory(Intent.CATEGORY_OPENABLE) - intent.type = "application/*" - val title = resources?.getString(R.string.select_backup_file) - val chooser = Intent.createChooser(intent, title) - startActivityForResult(chooser, CODE_BACKUP_RESTORE) - } else { - context.toast(R.string.restore_in_progress) + onClick { backup(context, BackupConst.BACKUP_TYPE_LEGACY) } + } + preference { + key = "pref_restore_backup" + titleRes = R.string.restore_backup + summaryRes = R.string.restore_from_backup_file + + onClick { + if (!BackupRestoreService.isRunning(context)) { + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "application/*" + val title = resources?.getString(R.string.select_backup_file) + val chooser = Intent.createChooser(intent, title) + startActivityForResult(chooser, CODE_BACKUP_RESTORE) + } else { + context.toast(R.string.restore_in_progress) + } } } } + preferenceCategory { - titleRes = R.string.service + titleRes = R.string.automatic_backups intListPreference(activity) { key = Keys.backupInterval titleRes = R.string.backup_frequency entriesRes = arrayOf( - R.string.manual, R.string.every_6_hours, - R.string.every_12_hours, R.string.daily, - R.string.every_2_days, R.string.weekly + R.string.manual, + R.string.every_6_hours, + R.string.every_12_hours, + R.string.daily, + R.string.every_2_days, + R.string.weekly ) entryValues = listOf(0, 6, 12, 24, 48, 168) defaultValue = 0 onChange { newValue -> // Always cancel the previous task, it seems that sometimes they are not updated - BackupCreatorJob.setupTask(0) val interval = newValue as Int - if (interval > 0) { - BackupCreatorJob.setupTask(interval) - } + BackupCreatorJob.setupTask(interval) true } } - val backupDir = preference { + preference { key = Keys.backupDirectory titleRes = R.string.backup_location onClick { - val currentDir = preferences.backupsDirectory().getOrDefault() + val currentDir = preferences.backupsDirectory().get() try { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) startActivityForResult(intent, CODE_BACKUP_DIR) @@ -117,32 +123,33 @@ class SettingsBackupController : SettingsController() { } } - preferences.backupsDirectory().asObservable() - .subscribeUntilDestroy { path -> + preferences.backupsDirectory().asFlow() + .onEach { path -> val dir = UniFile.fromUri(context, path.toUri()) summary = dir.filePath + "/automatic" } + .launchIn(viewScope) + preferences.backupInterval().asImmediateFlow { isVisible = it > 0 } + .launchIn(viewScope) } - val backupNumber = intListPreference(activity) { + intListPreference(activity) { key = Keys.numberOfBackups titleRes = R.string.max_auto_backups entries = listOf("1", "2", "3", "4", "5") entryRange = 1..5 defaultValue = 1 - } - val createLegacy = switchPreference { + preferences.backupInterval().asImmediateFlow { isVisible = it > 0 } + .launchIn(viewScope) + } + switchPreference { key = Keys.createLegacyBackup - titleRes = R.string.backup_auto_create_legacy + titleRes = R.string.also_create_legacy_backup defaultValue = true - } - preferences.backupInterval().asObservable() - .subscribeUntilDestroy { - backupDir.isVisible = it > 0 - backupNumber.isVisible = it > 0 - createLegacy.isVisible = it > 0 - } + preferences.backupInterval().asImmediateFlow { isVisible = it > 0 } + .launchIn(viewScope) + } } } @@ -157,55 +164,61 @@ class SettingsBackupController : SettingsController() { } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - when (requestCode) { - CODE_BACKUP_DIR -> if (data != null && resultCode == Activity.RESULT_OK) { - val activity = activity ?: return - // Get uri of backup folder. - val uri = data.data - - // Get UriPermission so it's possible to write files - val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION - - if (uri != null) { - activity.contentResolver.takePersistableUriPermission(uri, flags) - } - - // Set backup Uri - preferences.backupsDirectory().set(uri.toString()) - } - CODE_FULL_BACKUP_CREATE, CODE_LEGACY_BACKUP_CREATE -> if (data != null && resultCode == Activity.RESULT_OK) { - val activity = activity ?: return - - val uri = data.data - val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION + if (data != null && resultCode == Activity.RESULT_OK) { + val activity = activity ?: return + val uri = data.data + when (requestCode) { + CODE_BACKUP_DIR -> { + // Get UriPermission so it's possible to write files + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + + if (uri != null) { + activity.contentResolver.takePersistableUriPermission(uri, flags) + } - if (uri != null) { - activity.contentResolver.takePersistableUriPermission(uri, flags) + // Set backup Uri + preferences.backupsDirectory().set(uri.toString()) } + CODE_FULL_BACKUP_CREATE, CODE_LEGACY_BACKUP_CREATE -> { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION - val file = UniFile.fromUri(activity, uri) + if (uri != null) { + activity.contentResolver.takePersistableUriPermission(uri, flags) + } - activity.toast(R.string.creating_backup) + val file = UniFile.fromUri(activity, uri) - val backuptype = if (requestCode == CODE_FULL_BACKUP_CREATE) BackupConst.BACKUP_TYPE_FULL else BackupConst.BACKUP_TYPE_LEGACY + activity.toast(R.string.creating_backup) - BackupCreateService.start(activity, file.uri, backupFlags, backuptype) - } - CODE_BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) { - val uri = data.data - uri?.path?.let { - val fileName = DocumentFile.fromSingleUri(activity!!, uri)?.name ?: uri.toString() - val pair = when { - fileName.endsWith(".proto.gz") -> Pair(BackupConst.BACKUP_TYPE_FULL, false) - fileName.endsWith(".json") -> Pair(BackupConst.BACKUP_TYPE_LEGACY, true) - else -> null - } - if (pair == null) { - activity!!.toast(activity!!.getString(R.string.invalid_backup_file_type, fileName)) - } else { - RestoreBackupDialog(uri, pair.first, pair.second).showDialog(router) + BackupCreateService.start( + activity, + file.uri, + backupFlags, + if (requestCode == CODE_FULL_BACKUP_CREATE) BackupConst.BACKUP_TYPE_FULL else BackupConst.BACKUP_TYPE_LEGACY + ) + } + CODE_BACKUP_RESTORE -> { + uri?.path?.let { + val fileName = DocumentFile.fromSingleUri(activity, uri)?.name ?: uri.toString() + when { + fileName.endsWith(".proto.gz") -> { + RestoreBackupDialog( + uri, + BackupConst.BACKUP_TYPE_FULL + ).showDialog(router) + } + fileName.endsWith(".json") -> { + RestoreBackupDialog( + uri, + BackupConst.BACKUP_TYPE_LEGACY + ).showDialog(router) + } + else -> { + activity.toast(activity.getString(R.string.invalid_backup_file_type, fileName)) + } + } } } } @@ -214,15 +227,18 @@ class SettingsBackupController : SettingsController() { fun createBackup(flags: Int, type: Int) { backupFlags = flags + val code = when (type) { BackupConst.BACKUP_TYPE_FULL -> CODE_FULL_BACKUP_CREATE else -> CODE_LEGACY_BACKUP_CREATE } - val fileName = when (type) { BackupConst.BACKUP_TYPE_FULL -> BackupFull.getDefaultFilename() else -> Backup.getDefaultFilename() } + // Setup custom file picker intent + // Get dirs + val currentDir = preferences.backupsDirectory().get() try { // Use Android's built-in file creator @@ -238,7 +254,6 @@ class SettingsBackupController : SettingsController() { } class CreateBackupDialog(bundle: Bundle? = null) : DialogController(bundle) { - constructor(type: Int) : this( bundleOf( KEY_TYPE to type @@ -249,8 +264,11 @@ class SettingsBackupController : SettingsController() { val type = args.getInt(KEY_TYPE) val activity = activity!! val options = arrayOf( - R.string.manga, R.string.categories, R.string.chapters, - R.string.tracking, R.string.history + R.string.manga, + R.string.categories, + R.string.chapters, + R.string.tracking, + R.string.history, ) .map { activity.getString(it) } @@ -258,8 +276,9 @@ class SettingsBackupController : SettingsController() { .title(R.string.create_backup) .message(R.string.what_should_backup) .listItemsMultiChoice( - items = options, disabledIndices = intArrayOf(0), - initialSelection = intArrayOf(0, 1, 2, 3, 4) + items = options, + disabledIndices = intArrayOf(0), + initialSelection = intArrayOf(0, 1, 2, 3, 4, 5) ) { _, positions, _ -> var flags = 0 for (i in 1 until positions.size) { @@ -283,16 +302,14 @@ class SettingsBackupController : SettingsController() { } class RestoreBackupDialog(bundle: Bundle? = null) : DialogController(bundle) { - constructor(uri: Uri, type: Int, isOnline: Boolean) : this( + constructor(uri: Uri, type: Int) : this( bundleOf( KEY_URI to uri, - KEY_TYPE to type, - KEY_MODE to isOnline + KEY_TYPE to type ) ) override fun onCreateDialog(savedViewState: Bundle?): Dialog { - val activity = activity!! val uri: Uri = args.getParcelable(KEY_URI)!! val type: Int = args.getInt(KEY_TYPE) @@ -301,7 +318,7 @@ class SettingsBackupController : SettingsController() { return try { MaterialDialog(activity) .title(R.string.restore_backup) - .message(R.string.restore_message) + .message(R.string.restore_neko) .positiveButton(R.string.restore) { val context = applicationContext if (context != null) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsController.kt index 54bd57347c..960191c342 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsController.kt @@ -1,6 +1,9 @@ package eu.kanade.tachiyomi.ui.setting +import android.animation.ArgbEvaluator +import android.animation.ValueAnimator import android.content.Context +import android.graphics.Color import android.os.Bundle import android.util.TypedValue import android.view.ContextThemeWrapper @@ -8,23 +11,30 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat import androidx.preference.PreferenceController +import androidx.preference.PreferenceGroup import androidx.preference.PreferenceScreen import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeType import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.ui.base.controller.BaseController +import eu.kanade.tachiyomi.ui.main.FloatingSearchInterface import eu.kanade.tachiyomi.util.view.scrollViewWith +import kotlinx.coroutines.MainScope import rx.Observable import rx.Subscription import rx.subscriptions.CompositeSubscription import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import java.util.Locale abstract class SettingsController : PreferenceController() { + var preferenceKey: String? = null val preferences: PreferencesHelper = Injekt.get() + val viewScope = MainScope() var untilDestroySubscriptions = CompositeSubscription() private set @@ -42,6 +52,25 @@ abstract class SettingsController : PreferenceController() { return view } + override fun onAttach(view: View) { + super.onAttach(view) + + preferenceKey?.let { prefKey -> + val adapter = listView.adapter + scrollToPreference(prefKey) + + listView.post { + if (adapter is PreferenceGroup.PreferencePositionCallback) { + val pos = adapter.getPreferenceAdapterPosition(prefKey) + listView.findViewHolderForAdapterPosition(pos)?.let { + animatePreferenceHighlight(it.itemView) + } + preferenceKey = null + } + } + } + } + override fun onDestroyView(view: View) { super.onDestroyView(view) untilDestroySubscriptions.unsubscribe() @@ -53,7 +82,7 @@ abstract class SettingsController : PreferenceController() { setupPreferenceScreen(screen) } - abstract fun setupPreferenceScreen(screen: PreferenceScreen): Any? + abstract fun setupPreferenceScreen(screen: PreferenceScreen): PreferenceScreen private fun getThemedContext(): Context { val tv = TypedValue() @@ -61,14 +90,28 @@ abstract class SettingsController : PreferenceController() { return ContextThemeWrapper(activity, tv.resourceId) } + private fun animatePreferenceHighlight(view: View) { + ValueAnimator + .ofObject(ArgbEvaluator(), Color.TRANSPARENT, ContextCompat.getColor(view.context, R.color.fullRippleColor)) + .apply { + duration = 500L + repeatCount = 2 + addUpdateListener { animator -> view.setBackgroundColor(animator.animatedValue as Int) } + reverse() + } + } + open fun getTitle(): String? { + if (this is FloatingSearchInterface) { + return searchTitle(preferenceScreen?.title?.toString()?.lowercase(Locale.ROOT)) + } return preferenceScreen?.title?.toString() } fun setTitle() { var parentController = parentController while (parentController != null) { - if (parentController is BaseController && parentController.getTitle() != null) { + if (parentController is BaseController<*> && parentController.getTitle() != null) { return } parentController = parentController.parentController diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt index 3c6176eca0..b59dfc0ed7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsDownloadController.kt @@ -15,7 +15,9 @@ import com.afollestad.materialdialogs.list.listItemsSingleChoice import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.preference.asImmediateFlowIn import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.util.system.getFilePicker @@ -29,7 +31,7 @@ class SettingsDownloadController : SettingsController() { private val db: DatabaseHelper by injectLazy() - override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { + override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { titleRes = R.string.downloads preference { @@ -64,8 +66,11 @@ class SettingsDownloadController : SettingsController() { key = Keys.removeAfterReadSlots titleRes = R.string.remove_after_read entriesRes = arrayOf( - R.string.never, R.string.last_read_chapter, - R.string.second_to_last, R.string.third_to_last, R.string.fourth_to_last, + R.string.never, + R.string.last_read_chapter, + R.string.second_to_last, + R.string.third_to_last, + R.string.fourth_to_last, R.string.fifth_to_last ) entryRange = -1..4 @@ -74,6 +79,7 @@ class SettingsDownloadController : SettingsController() { } val dbCategories = db.getCategories().executeAsBlocking() + val categories = listOf(Category.createDefault(context)) + dbCategories preferenceCategory { titleRes = R.string.download_new_chapters @@ -86,30 +92,19 @@ class SettingsDownloadController : SettingsController() { multiSelectListPreferenceMat(activity) { key = Keys.downloadNewCategories titleRes = R.string.categories_to_include_in_download - entries = dbCategories.map { it.name } - entryValues = dbCategories.map { it.id.toString() } + entries = categories.map { it.name } + entryValues = categories.map { it.id.toString() } allSelectionRes = R.string.all - preferences.downloadNew().asObservable() - .subscribeUntilDestroy { isVisible = it } - - preferences.downloadNewCategories().asObservable() - .subscribeUntilDestroy { - val selectedCategories = it - .mapNotNull { id -> dbCategories.find { it.id == id.toInt() } } - .sortedBy { it.order } - - customSummary = if (selectedCategories.isEmpty()) - resources?.getString(R.string.all) - else - selectedCategories.joinToString { it.name } - } + preferences.downloadNew().asImmediateFlowIn(viewScope) { isVisible = it } } preferenceCategory { + titleRes = R.string.automatic_removal + intListPreference(activity) { key = Keys.deleteRemovedChapters titleRes = R.string.delete_removed_chapters - customSummary = activity?.getString(R.string.delete_downloaded_if_removed_online) + summary = activity?.getString(R.string.delete_downloaded_if_removed_online) entriesRes = arrayOf( R.string.ask_on_chapters_page, R.string.always_keep, @@ -178,7 +173,7 @@ class SettingsDownloadController : SettingsController() { private fun getExternalDirs(): List { val defaultDir = Environment.getExternalStorageDirectory().absolutePath + - File.separator + resources?.getString(R.string.neko_app_name) + + File.separator + resources?.getString(R.string.app_name) + File.separator + "downloads" return mutableListOf(File(defaultDir)) + diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt index d31c50e265..03af4cc2f8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt @@ -2,38 +2,64 @@ package eu.kanade.tachiyomi.ui.setting import android.content.Intent import android.os.Build +import android.os.Bundle import android.provider.Settings -import androidx.biometric.BiometricManager +import android.view.View +import androidx.appcompat.app.AppCompatDelegate import androidx.preference.PreferenceScreen import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.data.updater.UpdaterJob -import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate -import eu.kanade.tachiyomi.widget.preference.IntListMatPreference +import eu.kanade.tachiyomi.data.preference.asImmediateFlow +import eu.kanade.tachiyomi.util.system.appDelegateNightMode +import eu.kanade.tachiyomi.util.system.getPrefTheme +import eu.kanade.tachiyomi.util.system.isInNightMode +import eu.kanade.tachiyomi.util.system.isTablet +import kotlinx.coroutines.flow.launchIn import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys class SettingsGeneralController : SettingsController() { private val isUpdaterEnabled = BuildConfig.INCLUDE_UPDATER - override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { + var lastThemeX: Int? = null + var themePreference: ThemePreference? = null + override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { titleRes = R.string.general intListPreference(activity) { - key = Keys.theme - titleRes = R.string.app_theme + key = Keys.startingTab + titleRes = R.string.starting_screen + summaryRes = when (preferences.startingTab().get()) { + -1 -> R.string.library + -2 -> R.string.recents + -3 -> R.string.browse + else -> R.string.last_used_library_recents + } entriesRes = arrayOf( - R.string.white_theme, R.string.dark, - R.string.black, R.string.system_default_dark, - R.string - .system_default_amoled + R.string.last_used_library_recents, + R.string.library, + R.string.recents, + R.string.browse ) - entryValues = listOf(1, 2, 3, 5, 6) - defaultValue = 5 + entryValues = (0 downTo -3).toList() + defaultValue = 0 + customSelectedValue = when (val value = preferences.startingTab().get()) { + in -3..-1 -> value + else -> 0 + } - onChange { - activity?.recreate() + onChange { newValue -> + summaryRes = when (newValue) { + 0, 1 -> R.string.last_used_library_recents + -1 -> R.string.library + -2 -> R.string.recents + -3 -> R.string.browse + else -> R.string.last_used_library_recents + } + customSelectedValue = when (newValue) { + in -3..-1 -> newValue as Int + else -> 0 + } true } } @@ -50,7 +76,20 @@ class SettingsGeneralController : SettingsController() { } } defaultValue = "" - summary = "%s" + } + + switchPreference { + key = Keys.backToStart + titleRes = R.string.back_to_start + summaryRes = R.string.pressing_back_to_start + defaultValue = true + } + + switchPreference { + key = Keys.hideBottomNavOnScroll + titleRes = R.string.hide_bottom_nav + summaryRes = R.string.hides_on_scroll + defaultValue = true } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -66,73 +105,78 @@ class SettingsGeneralController : SettingsController() { } switchPreference { - key = Keys.automaticUpdates - titleRes = R.string.check_for_updates - summaryRes = R.string.auto_check_for_app_versions - defaultValue = true + key = Keys.showSideNavOnBottom + titleRes = R.string.move_side_nav_to_bottom + defaultValue = false + isVisible = activity?.isTablet() == true + } - if (isUpdaterEnabled) { - onChange { newValue -> - val checked = newValue as Boolean - if (checked) { - UpdaterJob.setupTask() - } else { - UpdaterJob.cancelTask() - } - true - } - } else { - isVisible = false - } + switchPreference { + key = Keys.showMangaAppShortcuts + titleRes = R.string.app_shortcuts + summaryRes = R.string.show_recent_in_shortcuts + defaultValue = true } preferenceCategory { - titleRes = R.string.security - - val biometricManager = BiometricManager.from(context) - if (biometricManager.canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS) { - var preference: IntListMatPreference? = null - switchPreference { - key = Keys.useBiometrics - titleRes = R.string.lock_with_biometrics - defaultValue = false - - onChange { - preference?.isVisible = it as Boolean - true - } - } - preference = intListPreference(activity) { - key = Keys.lockAfter - titleRes = R.string.lock_when_idle - isVisible = preferences.useBiometrics().getOrDefault() - val values = listOf(0, 2, 5, 10, 20, 30, 60, 90, 120, -1) - entries = values.mapNotNull { - when (it) { - 0 -> context.getString(R.string.always) - -1 -> context.getString(R.string.never) - else -> resources?.getQuantityString( - R.plurals.after_minutes, it.toInt(), it - ) - } - } - entryValues = values - defaultValue = 0 + titleRes = R.string.display + + themePreference = themePreference { + key = "theme_preference" + titleRes = R.string.app_theme + lastScrollPostion = lastThemeX + summary = if (preferences.nightMode() + .get() == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + ) { + val lightTheme = preferences.lightTheme().get().nameRes + val darkTheme = preferences.darkTheme().get().nameRes + val nightMode = context.isInNightMode() + mutableListOf(context.getString(lightTheme), context.getString(darkTheme)).apply { + if (nightMode) reverse() + }.joinToString(" / ") + } else { + context.getString(context.getPrefTheme(preferences).nameRes) } + activity = this@SettingsGeneralController.activity } switchPreference { - key = Keys.secureScreen - titleRes = R.string.secure_screen - summaryRes = R.string.hide_tachi_from_recents - defaultValue = false + key = "night_mode_switch" + isPersistent = false + titleRes = R.string.follow_system_theme + isChecked = + preferences.nightMode().get() == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM onChange { - it as Boolean - SecureActivityDelegate.setSecure(activity, it) + if (it == true) { + preferences.nightMode().set(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + activity?.recreate() + } else { + preferences.nightMode().set(context.appDelegateNightMode()) + themePreference?.fastAdapter?.notifyDataSetChanged() + } true } + preferences.nightMode().asImmediateFlow { mode -> + isChecked = mode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + }.launchIn(viewScope) } } } + + override fun onDestroyView(view: View) { + super.onDestroyView(view) + themePreference = null + } + + override fun onSaveViewState(view: View, outState: Bundle) { + outState.putInt(::lastThemeX.name, themePreference?.lastScrollPostion ?: 0) + super.onSaveInstanceState(outState) + } + + override fun onRestoreViewState(view: View, savedViewState: Bundle) { + super.onRestoreViewState(view, savedViewState) + lastThemeX = savedViewState.getInt(::lastThemeX.name) + themePreference?.lastScrollPostion = lastThemeX + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsLibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsLibraryController.kt index cd311b5e08..79aa430c96 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsLibraryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsLibraryController.kt @@ -6,7 +6,11 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.library.LibraryUpdateJob +import eu.kanade.tachiyomi.data.preference.DelayedLibrarySuggestionsJob import eu.kanade.tachiyomi.ui.category.CategoryController +import eu.kanade.tachiyomi.ui.library.LibraryPresenter +import eu.kanade.tachiyomi.ui.library.display.TabbedLibraryDisplaySheet +import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.util.view.withFadeTransaction import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -16,7 +20,7 @@ class SettingsLibraryController : SettingsController() { private val db: DatabaseHelper = Injekt.get() - override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { + override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { titleRes = R.string.library preferenceCategory { titleRes = R.string.general @@ -26,6 +30,36 @@ class SettingsLibraryController : SettingsController() { summaryRes = R.string.when_sorting_ignore_articles defaultValue = false } + + switchPreference { + key = Keys.showLibrarySearchSuggestions + titleRes = R.string.search_suggestions + summaryRes = R.string.search_tips_show_periodically + + onChange { + it as Boolean + if (it) { + launchIO { + LibraryPresenter.setSearchSuggestion(preferences, db, Injekt.get()) + } + } else { + DelayedLibrarySuggestionsJob.setupTask(false) + preferences.librarySearchSuggestion().set("") + } + true + } + } + + preference { + key = "library_display_options" + isPersistent = false + titleRes = R.string.display_options + summaryRes = R.string.can_be_found_in_library_filters + + onClick { + TabbedLibraryDisplaySheet(this@SettingsLibraryController).show() + } + } } val dbCategories = db.getCategories().executeAsBlocking() @@ -33,9 +67,11 @@ class SettingsLibraryController : SettingsController() { preferenceCategory { titleRes = R.string.categories preference { + key = "edit_categories" + isPersistent = false val catCount = db.getCategories().executeAsBlocking().size titleRes = if (catCount > 0) R.string.edit_categories else R.string.add_categories - if (catCount > 0) summary = context.resources.getQuantityString(R.plurals.category, catCount, catCount) + if (catCount > 0) summary = context.resources.getQuantityString(R.plurals.category_plural, catCount, catCount) onClick { router.pushController(CategoryController().withFadeTransaction()) } } intListPreference(activity) { @@ -94,7 +130,7 @@ class SettingsLibraryController : SettingsController() { titleRes = R.string.library_update_restriction entriesRes = arrayOf(R.string.wifi, R.string.charging) entryValues = listOf("wifi", "ac") - customSummaryRes = R.string.library_update_restriction_summary + summaryRes = R.string.library_update_restriction_summary preferences.libraryUpdateInterval().asObservable() .subscribeUntilDestroy { isVisible = it > 0 } @@ -118,7 +154,9 @@ class SettingsLibraryController : SettingsController() { // The following array lines up with the list rankingScheme in: // ../../data/library/LibraryUpdateRanker.kt entriesRes = arrayOf( - R.string.alphabetically, R.string.last_updated, R.string.next_updated + R.string.alphabetically, + R.string.last_updated, + R.string.next_updated ) entryRange = 0..2 defaultValue = 0 @@ -128,26 +166,21 @@ class SettingsLibraryController : SettingsController() { multiSelectListPreferenceMat(activity) { key = Keys.libraryUpdateCategories titleRes = R.string.categories_to_include_in_global_update - entries = dbCategories.map { it.name } - entryValues = dbCategories.map { it.id.toString() } - allSelectionRes = R.string.all - preferences.libraryUpdateCategories().asObservable().subscribeUntilDestroy { - val selectedCategories = - it.mapNotNull { id -> dbCategories.find { it.id == id.toInt() } } - .sortedBy { it.order } + val categories = listOf(Category.createDefault(context)) + dbCategories + entries = categories.map { it.name } + entryValues = categories.map { it.id.toString() } - customSummary = - if (selectedCategories.isEmpty()) context.getString(R.string.all) - else selectedCategories.joinToString { it.name } - } + allSelectionRes = R.string.all } + intListPreference(activity) { key = Keys.updateOnRefresh titleRes = R.string.categories_on_manual entriesRes = arrayOf( - R.string.first_category, R.string.categories_in_global_update + R.string.first_category, + R.string.categories_in_global_update ) entryRange = 0..1 defaultValue = -1 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt index 9dd9274c49..c3edd25075 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsMainController.kt @@ -4,18 +4,21 @@ import android.content.Intent import android.view.Menu import android.view.MenuInflater import android.view.MenuItem +import androidx.appcompat.widget.SearchView import androidx.core.net.toUri import androidx.preference.PreferenceScreen import com.bluelinelabs.conductor.Controller +import com.bluelinelabs.conductor.RouterTransaction import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import com.mikepenz.iconics.typeface.library.materialdesigndx.MaterialDesignDx -import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.main.FloatingSearchInterface +import eu.kanade.tachiyomi.ui.setting.search.SettingsSearchController +import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.iconicsDrawableMedium -import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.view.withFadeTransaction -class SettingsMainController : SettingsController() { +class SettingsMainController : SettingsController(), FloatingSearchInterface { init { setHasOptionsMenu(true) @@ -25,7 +28,8 @@ class SettingsMainController : SettingsController() { titleRes = R.string.settings val size = 18 - + val tintColor = context.getResourceColor(R.attr.colorAccent) + preference { iconDrawable = context.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_tune) titleRes = R.string.general @@ -51,7 +55,6 @@ class SettingsMainController : SettingsController() { titleRes = R.string.downloads onClick { navigateTo(SettingsDownloadController()) } } - preference { iconDrawable = context.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_sync) titleRes = R.string.tracking @@ -62,16 +65,16 @@ class SettingsMainController : SettingsController() { titleRes = R.string.backup onClick { navigateTo(SettingsBackupController()) } } + preference { + iconDrawable = context.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_security) + titleRes = R.string.security + onClick { navigateTo(SettingsSecurityController()) } + } preference { iconDrawable = context.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_code) titleRes = R.string.advanced onClick { navigateTo(SettingsAdvancedController()) } } - preference { - iconDrawable = context.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_info) - titleRes = R.string.about - onClick { navigateTo(AboutController()) } - } preference { iconDrawable = context.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_volunteer_activism) titleRes = R.string.dex_donations @@ -80,28 +83,38 @@ class SettingsMainController : SettingsController() { startActivity(intent) } } + this } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { inflater.inflate(R.menu.settings_main, menu) - menu.findItem(R.id.action_bug_report).isVisible = BuildConfig.DEBUG - } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_help -> activity?.openInBrowser(URL_HELP) - R.id.action_bug_report -> activity?.openInBrowser(URL_BUG_REPORT) - else -> return super.onOptionsItemSelected(item) - } - return true + // Initialize search option. + val searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.actionView as SearchView + searchView.maxWidth = Int.MAX_VALUE + + // Change hint to show global search. + searchView.queryHint = applicationContext?.getString(R.string.search_settings) + + searchItem.setOnActionExpandListener( + object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(item: MenuItem?): Boolean { + SettingsSearchController.lastSearch = "" // reset saved search query + router.pushController( + RouterTransaction.with(SettingsSearchController()) + ) + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem?): Boolean { + return true + } + } + ) } private fun navigateTo(controller: Controller) { router.pushController(controller.withFadeTransaction()) } - - private companion object { - private const val URL_HELP = "https://tachiyomi.org/help/" - private const val URL_BUG_REPORT = "https://github.com/CarlosEsco/Neko/issues" - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt index c162e08511..4911f47b29 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt @@ -3,18 +3,28 @@ package eu.kanade.tachiyomi.ui.setting import android.os.Build import androidx.preference.PreferenceScreen import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.asImmediateFlow +import eu.kanade.tachiyomi.data.preference.asImmediateFlowIn +import eu.kanade.tachiyomi.ui.reader.settings.ReaderBottomButton +import eu.kanade.tachiyomi.ui.reader.settings.OrientationType +import eu.kanade.tachiyomi.ui.reader.settings.PageLayout +import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation +import eu.kanade.tachiyomi.util.lang.addBetaTag +import eu.kanade.tachiyomi.util.system.isTablet +import eu.kanade.tachiyomi.util.view.activityBinding +import kotlinx.coroutines.flow.launchIn import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys class SettingsReaderController : SettingsController() { - override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { + override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { titleRes = R.string.reader preferenceCategory { titleRes = R.string.general intListPreference(activity) { key = Keys.defaultViewer - titleRes = R.string.default_viewer + titleRes = R.string.default_reading_mode entriesRes = arrayOf( R.string.left_to_right_viewer, R.string.right_to_left_viewer, @@ -22,15 +32,70 @@ class SettingsReaderController : SettingsController() { R.string.webtoon ) entryRange = 1..4 - defaultValue = 1 + defaultValue = 2 + } + intListPreference(activity) { + key = Keys.doubleTapAnimationSpeed + titleRes = R.string.double_tap_anim_speed + entries = listOf( + context.getString(R.string.no_animation), + context.getString( + R.string.fast + ), + context.getString(R.string.normal) + ) + entryValues = listOf(1, 250, 500) // using a value of 0 breaks the image viewer, so + // min is 1 + defaultValue = 500 + } + switchPreference { + key = Keys.enableTransitions + titleRes = R.string.animate_page_transitions + defaultValue = true + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + switchPreference { + key = Keys.trueColor + titleRes = R.string.true_32bit_color + summaryRes = R.string.reduces_banding_impacts_performance + defaultValue = false + } + } + intListPreference(activity) { + key = Keys.preloadSize + titleRes = R.string.page_preload_amount + entryValues = listOf(4, 6, 8, 10, 12, 14, 16, 20) + entries = entryValues.map { context.resources.getQuantityString(R.plurals.pages_plural, it, it) } + defaultValue = 6 + summaryRes = R.string.amount_of_pages_to_preload } + multiSelectListPreferenceMat(activity) { + key = Keys.readerBottomButtons + titleRes = R.string.display_buttons_bottom_reader + val enumConstants = ReaderBottomButton.values() + entriesRes = ReaderBottomButton.values().map { it.stringRes }.toTypedArray() + entryValues = enumConstants.map { it.value } + allSelectionRes = R.string.display_options + allIsAlwaysSelected = true + showAllLast = true + val defaults = ReaderBottomButton.BUTTONS_DEFAULTS.toMutableList() + if (context.isTablet()) { + defaults.add(ReaderBottomButton.ShiftDoublePage.value) + } + defaultValue = defaults + } + infoPreference(R.string.certain_buttons_can_be_found) + } + + preferenceCategory { + titleRes = R.string.display + intListPreference(activity) { key = Keys.rotation titleRes = R.string.rotation - entriesRes = arrayOf( - R.string.free, R.string.lock, R.string.force_portrait, R.string.force_landscape - ) - entryRange = 1..4 + val enumConstants = OrientationType.values() + entriesRes = enumConstants.map { it.stringRes }.toTypedArray() + entryRange = 1..enumConstants.size defaultValue = 1 } intListPreference(activity) { @@ -45,20 +110,6 @@ class SettingsReaderController : SettingsController() { entryRange = 0..3 defaultValue = 2 } - intListPreference(activity) { - key = Keys.doubleTapAnimationSpeed - titleRes = R.string.double_tap_anim_speed - entries = listOf( - context.getString(R.string.no_animation), - context.getString( - R.string.fast - ), - context.getString(R.string.normal) - ) - entryValues = listOf(1, 250, 500) // using a value of 0 breaks the image viewer, so - // min is 1 - defaultValue = 500 - } switchPreference { key = Keys.fullscreen titleRes = R.string.fullscreen @@ -74,14 +125,6 @@ class SettingsReaderController : SettingsController() { titleRes = R.string.show_page_number defaultValue = true } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - switchPreference { - key = Keys.trueColor - titleRes = R.string.true_32bit_color - summaryRes = R.string.reduces_banding_impacts_performance - defaultValue = true - } - } } preferenceCategory { @@ -92,10 +135,9 @@ class SettingsReaderController : SettingsController() { titleRes = R.string.skip_read_chapters defaultValue = false } - switchPreference { key = Keys.skipFiltered - titleRes = R.string.skip_hidden_chapters + titleRes = R.string.skip_filtered_chapters defaultValue = true } switchPreference { @@ -109,6 +151,36 @@ class SettingsReaderController : SettingsController() { preferenceCategory { titleRes = R.string.paged + intListPreference(activity) { + key = Keys.navigationModePager + titleRes = R.string.nav_layout + entries = context.resources.getStringArray(R.array.reader_nav).also { values -> + entryRange = 0..values.size + }.toList() + defaultValue = "0" + + preferences.readWithTapping().asImmediateFlow { isVisible = it }.launchIn(viewScope) + } + listPreference(activity) { + key = Keys.pagerNavInverted + titleRes = R.string.invert_tapping + entriesRes = arrayOf( + R.string.none, + R.string.horizontally, + R.string.vertically, + R.string.both_axes + ) + entryValues = listOf( + ViewerNavigation.TappingInvertMode.NONE.name, + ViewerNavigation.TappingInvertMode.HORIZONTAL.name, + ViewerNavigation.TappingInvertMode.VERTICAL.name, + ViewerNavigation.TappingInvertMode.BOTH.name + ) + defaultValue = ViewerNavigation.TappingInvertMode.NONE.name + + preferences.readWithTapping().asImmediateFlow { isVisible = it }.launchIn(viewScope) + } + intListPreference(activity) { key = Keys.imageScaleType titleRes = R.string.scale_type @@ -123,29 +195,107 @@ class SettingsReaderController : SettingsController() { entryRange = 1..6 defaultValue = 1 } + + intListPreference(activity) { + key = Keys.pagerCutoutBehavior + titleRes = R.string.cutout_area_behavior + entriesRes = arrayOf( + R.string.pad_cutout_areas, + R.string.start_past_cutout, + R.string.ignore_cutout_areas, + ) + summaryRes = R.string.cutout_behavior_only_applies + entryRange = 0..2 + defaultValue = 0 + // Calling this once to show only on cutout + isVisible = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + activityBinding?.root?.rootWindowInsets?.displayCutout?.safeInsetTop != null || + activityBinding?.root?.rootWindowInsets?.displayCutout?.safeInsetBottom != null + } else { + false + } + // Calling this a second time in case activity is recreated while on this page + // Keep the first so it shouldn't animate hiding the preference for phones without + // cutouts + activityBinding?.root?.post { + isVisible = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + activityBinding?.root?.rootWindowInsets?.displayCutout?.safeInsetTop != null || + activityBinding?.root?.rootWindowInsets?.displayCutout?.safeInsetBottom != null + } else { + false + } + } + } + intListPreference(activity) { key = Keys.zoomStart titleRes = R.string.zoom_start_position entriesRes = arrayOf( - R.string.automatic, R.string.left, R.string.right, R.string.center + R.string.automatic, + R.string.left, + R.string.right, + R.string.center ) entryRange = 1..4 defaultValue = 1 } - switchPreference { - key = Keys.enableTransitions - titleRes = R.string.page_transitions - defaultValue = true - } switchPreference { key = Keys.cropBorders titleRes = R.string.crop_borders defaultValue = false } + intListPreference(activity) { + key = Keys.pageLayout + title = context.getString(R.string.page_layout).addBetaTag(context) + dialogTitleRes = R.string.page_layout + val enumConstants = PageLayout.values() + entriesRes = enumConstants.map { it.fullStringRes }.toTypedArray() + entryValues = enumConstants.map { it.value } + defaultValue = PageLayout.AUTOMATIC.value + } + infoPreference(R.string.automatic_can_still_switch).apply { + preferences.pageLayout().asImmediateFlowIn(viewScope) { isVisible = it == PageLayout.AUTOMATIC.value } + } + switchPreference { + key = Keys.invertDoublePages + titleRes = R.string.invert_double_pages + defaultValue = false + preferences.pageLayout().asImmediateFlowIn(viewScope) { isVisible = it != PageLayout.SINGLE_PAGE.value } + } } preferenceCategory { titleRes = R.string.webtoon + intListPreference(activity) { + key = Keys.navigationModeWebtoon + titleRes = R.string.nav_layout + entries = context.resources.getStringArray(R.array.reader_nav).also { values -> + entryRange = 0..values.size + }.toList() + defaultValue = "0" + + preferences.readWithTapping().asImmediateFlow { isVisible = it }.launchIn(viewScope) + } + listPreference(activity) { + key = Keys.webtoonNavInverted + titleRes = R.string.invert_tapping + entriesRes = arrayOf( + R.string.none, + R.string.horizontally, + R.string.vertically, + R.string.both_axes + ) + entryValues = listOf( + ViewerNavigation.TappingInvertMode.NONE.name, + ViewerNavigation.TappingInvertMode.HORIZONTAL.name, + ViewerNavigation.TappingInvertMode.VERTICAL.name, + ViewerNavigation.TappingInvertMode.BOTH.name + ) + defaultValue = ViewerNavigation.TappingInvertMode.NONE.name + + preferences.readWithTapping().asImmediateFlow { isVisible = it }.launchIn(viewScope) + } + switchPreference { key = Keys.cropBordersWebtoon titleRes = R.string.crop_borders @@ -181,11 +331,6 @@ class SettingsReaderController : SettingsController() { titleRes = R.string.tapping defaultValue = true } - switchPreference { - key = Keys.readWithLongTap - titleRes = R.string.long_tap_dialog - defaultValue = true - } switchPreference { key = Keys.readWithVolumeKeys titleRes = R.string.volume_keys @@ -195,7 +340,25 @@ class SettingsReaderController : SettingsController() { key = Keys.readWithVolumeKeysInverted titleRes = R.string.invert_volume_keys defaultValue = false - }.apply { dependency = Keys.readWithVolumeKeys } + + preferences.readWithVolumeKeys().asImmediateFlow { isVisible = it }.launchIn(viewScope) + } + } + + preferenceCategory { + titleRes = R.string.actions + + switchPreference { + key = Keys.readWithLongTap + titleRes = R.string.show_on_long_press + defaultValue = true + } + switchPreference { + key = Keys.folderPerManga + titleRes = R.string.save_pages_separately + summaryRes = R.string.create_folders_by_manga_title + defaultValue = false + } } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSecurityController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSecurityController.kt new file mode 100644 index 0000000000..190a77a833 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSecurityController.kt @@ -0,0 +1,62 @@ +package eu.kanade.tachiyomi.ui.setting + +import androidx.biometric.BiometricManager +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferenceKeys +import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate +import eu.kanade.tachiyomi.widget.preference.IntListMatPreference + +class SettingsSecurityController : SettingsController() { + override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { + titleRes = R.string.security + + val biometricManager = BiometricManager.from(context) + if (biometricManager.canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS) { + var preference: IntListMatPreference? = null + switchPreference { + key = PreferenceKeys.useBiometrics + titleRes = R.string.lock_with_biometrics + defaultValue = false + + onChange { + preference?.isVisible = it as Boolean + true + } + } + preference = intListPreference(activity) { + key = PreferenceKeys.lockAfter + titleRes = R.string.lock_when_idle + isVisible = preferences.useBiometrics().getOrDefault() + val values = listOf(0, 2, 5, 10, 20, 30, 60, 90, 120, -1) + entries = values.mapNotNull { + when (it) { + 0 -> context.getString(R.string.always) + -1 -> context.getString(R.string.never) + else -> resources?.getQuantityString( + R.plurals.after_minutes, + it, + it + ) + } + } + entryValues = values + defaultValue = 0 + } + } + + switchPreference { + key = PreferenceKeys.secureScreen + titleRes = R.string.secure_screen + summaryRes = R.string.hide_app_block_screenshots + defaultValue = false + + onChange { + it as Boolean + SecureActivityDelegate.setSecure(activity, it) + true + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSiteController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSiteController.kt index 4df74c82a1..829941508f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSiteController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSiteController.kt @@ -31,7 +31,7 @@ class SettingsSiteController : private val mdex by lazy { Injekt.get().getMangadex() as HttpSource } - override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { + override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { titleRes = R.string.site_specific_settings val sourcePreference = SiteLoginPreference(context, mdex).apply { @@ -48,9 +48,10 @@ class SettingsSiteController : dialog.showDialog(router) } } + this.isIconSpaceReserved = false } - preferenceScreen.addPreference(sourcePreference) + addPreference(sourcePreference) preference { titleRes = R.string.show_languages @@ -80,7 +81,7 @@ class SettingsSiteController : multiSelectListPreferenceMat(activity) { key = PreferenceKeys.contentRating titleRes = R.string.content_rating_title - customSummaryRes = R.string.content_rating_summary + summaryRes = R.string.content_rating_summary entriesRes = arrayOf( R.string.content_rating_safe, R.string.content_rating_suggestive, @@ -88,15 +89,17 @@ class SettingsSiteController : R.string.content_rating_pornographic, ) entryValues = listOf( - "safe", "suggestive", "erotica", "pornographic" + "safe", + "suggestive", + "erotica", + "pornographic" ) - defSet = setOf("safe", "suggestive") + defValue = setOf("safe", "suggestive") defaultValue = listOf("safe", "suggestive") } - switchPreference { key = PreferenceKeys.showContentRatingFilter titleRes = R.string.show_content_rating_filter_in_search @@ -110,7 +113,6 @@ class SettingsSiteController : defaultValue = true } - switchPreference { key = PreferenceKeys.dataSaver titleRes = R.string.data_saver @@ -151,7 +153,6 @@ class SettingsSiteController : } } - preference { titleRes = R.string.push_favorites_to_mangadex summaryRes = R.string.push_favorites_to_mangadex_summary @@ -163,7 +164,7 @@ class SettingsSiteController : ) } } - + switchPreference { key = PreferenceKeys.addToLibraryAsPlannedToRead titleRes = R.string.add_favorites_as_planned_to_read @@ -183,7 +184,6 @@ class SettingsSiteController : } } } - } override fun siteLoginDialogClosed(source: Source) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt index ac2cb0e336..7c31e3d56f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt @@ -21,7 +21,7 @@ class SettingsTrackingController : private val trackManager: TrackManager by injectLazy() - override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { + override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { titleRes = R.string.tracking switchPreference { @@ -71,7 +71,7 @@ class SettingsTrackingController : override fun onActivityResumed(activity: Activity) { super.onActivityResumed(activity) - // Manually refresh anilist holder + updatePreference(trackManager.myAnimeList.id) updatePreference(trackManager.aniList.id) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/ThemePreference.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/ThemePreference.kt new file mode 100644 index 0000000000..9044ccc1b9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/ThemePreference.kt @@ -0,0 +1,179 @@ +package eu.kanade.tachiyomi.ui.setting + +import android.app.Activity +import android.content.Context +import android.content.res.ColorStateList +import android.util.AttributeSet +import android.view.View +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.mikepenz.fastadapter.FastAdapter +import com.mikepenz.fastadapter.ISelectionListener +import com.mikepenz.fastadapter.adapters.ItemAdapter +import com.mikepenz.fastadapter.items.AbstractItem +import com.mikepenz.fastadapter.select.SelectExtension +import com.mikepenz.fastadapter.select.getSelectExtension +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.databinding.ThemeItemBinding +import eu.kanade.tachiyomi.databinding.ThemesPreferenceBinding +import eu.kanade.tachiyomi.util.system.Themes +import eu.kanade.tachiyomi.util.system.appDelegateNightMode +import eu.kanade.tachiyomi.util.system.dpToPx +import eu.kanade.tachiyomi.util.system.isInNightMode +import uy.kohesive.injekt.injectLazy + +class ThemePreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + Preference(context, attrs) { + + var fastAdapter: FastAdapter + private val itemAdapter = ItemAdapter() + private var selectExtension: SelectExtension + private val preferences: PreferencesHelper by injectLazy() + var activity: Activity? = null + var lastScrollPostion: Int? = null + lateinit var binding: ThemesPreferenceBinding + val manager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + init { + layoutResource = R.layout.themes_preference + fastAdapter = FastAdapter.with(itemAdapter) + fastAdapter.setHasStableIds(true) + selectExtension = fastAdapter.getSelectExtension().apply { + isSelectable = true + multiSelect = true + selectionListener = object : ISelectionListener { + override fun onSelectionChanged(item: ThemeItem, selected: Boolean) { + if (item.theme.nightMode == AppCompatDelegate.MODE_NIGHT_YES) { + preferences.darkTheme().set(item.theme) + } else { + preferences.lightTheme().set(item.theme) + } + if (!selected) { + preferences.nightMode().set(item.theme.nightMode) + } else if (preferences.nightMode() + .get() != AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + ) { + preferences.nightMode().set(item.theme.nightMode) + } + if (( + preferences.nightMode().get() == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM && + item.theme.nightMode != context.appDelegateNightMode() + ) || + (!selected && item.theme.nightMode == context.appDelegateNightMode()) + ) { + fastAdapter.notifyDataSetChanged() + } else { + activity?.recreate() + } + } + } + } + + val enumConstants = Themes.values() + itemAdapter.set(enumConstants.map(::ThemeItem)) + isSelectable = false + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + binding = ThemesPreferenceBinding.bind(holder.itemView) + + binding.themePrefTitle.text = title + binding.themeRecycler.setHasFixedSize(true) + binding.themeRecycler.layoutManager = manager + + binding.themeRecycler.adapter = fastAdapter + + binding.themeRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + lastScrollPostion = + recyclerView.computeHorizontalScrollOffset() + } + }) + + if (lastScrollPostion != null) { + val lX = lastScrollPostion!! + (binding.themeRecycler.layoutManager as LinearLayoutManager).apply { + scrollToPositionWithOffset( + lX / 110.dpToPx, + -lX % 110.dpToPx + ) + } + lastScrollPostion = binding.themeRecycler.computeHorizontalScrollOffset() + } else { + binding.themeRecycler.scrollToPosition( + selectExtension.selections.firstOrNull() ?: 0 + ) + } + } + + inner class ThemeItem(val theme: Themes) : AbstractItem>() { + + /** defines the type defining this item. must be unique. preferably an id */ + override val type: Int = R.id.theme_card_view + + /** defines the layout which will be used for this item in the list */ + override val layoutRes: Int = R.layout.theme_item + + override var identifier = theme.hashCode().toLong() + + override fun getViewHolder(v: View): FastAdapter.ViewHolder { + return ViewHolder(v) + } + + val colors = theme.getColors() + + @Suppress("UNUSED_PARAMETER") + override var isSelected: Boolean + get() = when (preferences.nightMode().get()) { + AppCompatDelegate.MODE_NIGHT_YES -> preferences.darkTheme().get() == theme + AppCompatDelegate.MODE_NIGHT_NO -> preferences.lightTheme().get() == theme + else -> preferences.darkTheme().get() == theme || + preferences.lightTheme().get() == theme + } + set(value) {} + + inner class ViewHolder(view: View) : FastAdapter.ViewHolder(view) { + + val binding = ThemeItemBinding.bind(view) + override fun bindView(item: ThemeItem, payloads: List) { + binding.themeNameText.setText(item.theme.nameRes) + + binding.checkbox.isVisible = item.isSelected + binding.themeSelected.isInvisible = !item.isSelected + + if (binding.checkbox.isVisible) { + val themeMatchesApp = if (context.isInNightMode()) { + item.theme.nightMode == AppCompatDelegate.MODE_NIGHT_YES + } else { + item.theme.nightMode == AppCompatDelegate.MODE_NIGHT_NO + } + binding.themeSelected.alpha = if (themeMatchesApp) 1f else 0.5f + binding.checkbox.alpha = if (themeMatchesApp) 1f else 0.5f + } + binding.themeToolbar.setBackgroundColor(item.colors.appBar) + binding.themeAppBarText.imageTintList = ColorStateList.valueOf(item.colors.appBarText) + binding.themeHeroImage.imageTintList = ColorStateList.valueOf(item.colors.primaryText) + binding.themePrimaryText.imageTintList = ColorStateList.valueOf(item.colors.primaryText) + binding.themeAccentedButton.imageTintList = ColorStateList.valueOf(item.colors.colorAccent) + binding.themeSecondaryText.imageTintList = ColorStateList.valueOf(item.colors.secondaryText) + binding.themeSecondaryText2.imageTintList = ColorStateList.valueOf(item.colors.secondaryText) + + binding.themeBottomBar.setBackgroundColor(item.colors.bottomBar) + binding.themeItem1.imageTintList = ColorStateList.valueOf(item.colors.inactiveTab) + binding.themeItem2.imageTintList = ColorStateList.valueOf(item.colors.activeTab) + binding.themeItem3.imageTintList = ColorStateList.valueOf(item.colors.inactiveTab) + binding.themeLayout.setBackgroundColor(item.colors.colorBackground) + } + + override fun unbindView(item: ThemeItem) { + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchAdapter.kt new file mode 100644 index 0000000000..93dd4820e6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchAdapter.kt @@ -0,0 +1,84 @@ +package eu.kanade.tachiyomi.ui.setting.search + +import android.os.Bundle +import android.os.Parcelable +import android.util.SparseArray +import androidx.recyclerview.widget.RecyclerView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.kanade.tachiyomi.ui.setting.SettingsController + +/** + * Adapter that holds the search cards. + * + * @param controller instance of [SettingsSearchController]. + */ +class SettingsSearchAdapter(val controller: SettingsSearchController) : + FlexibleAdapter(null, controller, true) { + + val titleClickListener: OnTitleClickListener = controller + + /** + * Bundle where the view state of the holders is saved. + */ + private var bundle = Bundle() + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: List + ) { + super.onBindViewHolder(holder, position, payloads) + restoreHolderState(holder) + } + + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + super.onViewRecycled(holder) + saveHolderState(holder, bundle) + } + + override fun onSaveInstanceState(outState: Bundle) { + val holdersBundle = Bundle() + allBoundViewHolders.forEach { saveHolderState(it, holdersBundle) } + outState.putBundle(HOLDER_BUNDLE_KEY, holdersBundle) + super.onSaveInstanceState(outState) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + bundle = savedInstanceState.getBundle(HOLDER_BUNDLE_KEY)!! + } + + /** + * Saves the view state of the given holder. + * + * @param holder The holder to save. + * @param outState The bundle where the state is saved. + */ + private fun saveHolderState(holder: RecyclerView.ViewHolder, outState: Bundle) { + val key = "holder_${holder.bindingAdapterPosition}" + val holderState = SparseArray() + holder.itemView.saveHierarchyState(holderState) + outState.putSparseParcelableArray(key, holderState) + } + + /** + * Restores the view state of the given holder. + * + * @param holder The holder to restore. + */ + private fun restoreHolderState(holder: RecyclerView.ViewHolder) { + val key = "holder_${holder.bindingAdapterPosition}" + bundle.getSparseParcelableArray(key)?.let { + holder.itemView.restoreHierarchyState(it) + bundle.remove(key) + } + } + + interface OnTitleClickListener { + fun onTitleClick(ctrl: SettingsController) + } + + private companion object { + const val HOLDER_BUNDLE_KEY = "holder_bundle" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchController.kt new file mode 100644 index 0000000000..1708e42072 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchController.kt @@ -0,0 +1,172 @@ +package eu.kanade.tachiyomi.ui.setting.search + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import androidx.appcompat.widget.SearchView +import androidx.recyclerview.widget.LinearLayoutManager +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.SettingsSearchControllerBinding +import eu.kanade.tachiyomi.ui.base.controller.NucleusController +import eu.kanade.tachiyomi.ui.main.FloatingSearchInterface +import eu.kanade.tachiyomi.ui.setting.SettingsController +import eu.kanade.tachiyomi.util.view.liftAppbarWith +import eu.kanade.tachiyomi.util.view.withFadeTransaction + +/** + * This controller shows and manages the different search result in settings search. + * [SettingsSearchAdapter.OnTitleClickListener] called when preference is clicked in settings search + */ +class SettingsSearchController : + NucleusController(), + FloatingSearchInterface, + SettingsSearchAdapter.OnTitleClickListener { + + /** + * Adapter containing search results grouped by lang. + */ + private var adapter: SettingsSearchAdapter? = null + private lateinit var searchView: SearchView + + init { + setHasOptionsMenu(true) + } + + override fun createBinding(inflater: LayoutInflater) = SettingsSearchControllerBinding.inflate(inflater) + + override fun getTitle(): String { + return presenter.query + } + + /** + * Create the [SettingsSearchPresenter] used in controller. + * + * @return instance of [SettingsSearchPresenter] + */ + override fun createPresenter(): SettingsSearchPresenter { + return SettingsSearchPresenter() + } + + /** + * Adds items to the options menu. + * + * @param menu menu containing options. + * @param inflater used to load the menu xml. + */ + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + // Inflate menu. + inflater.inflate(R.menu.settings_main, menu) + + // Initialize search menu + val searchItem = menu.findItem(R.id.action_search) + searchView = searchItem.actionView as SearchView + searchView.maxWidth = Int.MAX_VALUE + + // Change hint to show "search settings." + searchView.queryHint = applicationContext?.getString(R.string.search_settings) + + searchItem.expandActionView() + setItems(getResultSet()) + + searchItem.setOnActionExpandListener( + object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(item: MenuItem?): Boolean { + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem?): Boolean { + router.popCurrentController() + return false + } + } + ) + + searchView.setOnQueryTextListener( + object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + setItems(getResultSet(query)) + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + if (!newText.isNullOrBlank()) { + lastSearch = newText + } + setItems(getResultSet(newText)) + return false + } + } + ) + + searchView.setQuery(lastSearch, true) + } + + override fun onViewCreated(view: View) { + super.onViewCreated(view) + + adapter = SettingsSearchAdapter(this) + + liftAppbarWith(binding.recycler, true) + // Create recycler and set adapter. + binding.recycler.layoutManager = LinearLayoutManager(view.context) + binding.recycler.adapter = adapter + + // load all search results + SettingsSearchHelper.initPreferenceSearchResultCollection(presenter.preferences.context) + } + + override fun onDestroyView(view: View) { + adapter = null + super.onDestroyView(view) + } + + override fun onSaveViewState(view: View, outState: Bundle) { + super.onSaveViewState(view, outState) + adapter?.onSaveInstanceState(outState) + } + + override fun onRestoreViewState(view: View, savedViewState: Bundle) { + super.onRestoreViewState(view, savedViewState) + adapter?.onRestoreInstanceState(savedViewState) + } + + /** + * returns a list of `SettingsSearchItem` to be shown as search results + * Future update: should we add a minimum length to the query before displaying results? Consider other languages. + */ + fun getResultSet(query: String? = null): List { + if (!query.isNullOrBlank()) { + return SettingsSearchHelper.getFilteredResults(query) + .map { SettingsSearchItem(it, null, query) } + } + + return mutableListOf() + } + + /** + * Add search result to adapter. + * + * @param searchResult result of search. + */ + fun setItems(searchResult: List) { + adapter?.updateDataSet(searchResult) + } + + /** + * Opens a catalogue with the given search. + */ + override fun onTitleClick(ctrl: SettingsController) { + searchView.query.let { + lastSearch = it.toString() + } + + router.pushController(ctrl.withFadeTransaction()) + } + + companion object { + var lastSearch = "" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHelper.kt new file mode 100644 index 0000000000..9690f39e7f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHelper.kt @@ -0,0 +1,139 @@ +package eu.kanade.tachiyomi.ui.setting.search + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Resources +import androidx.preference.Preference +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceGroup +import androidx.preference.PreferenceManager +import eu.kanade.tachiyomi.ui.setting.SettingsAdvancedController +import eu.kanade.tachiyomi.ui.setting.SettingsBackupController +import eu.kanade.tachiyomi.ui.setting.SettingsController +import eu.kanade.tachiyomi.ui.setting.SettingsDownloadController +import eu.kanade.tachiyomi.ui.setting.SettingsGeneralController +import eu.kanade.tachiyomi.ui.setting.SettingsLibraryController +import eu.kanade.tachiyomi.ui.setting.SettingsReaderController +import eu.kanade.tachiyomi.ui.setting.SettingsSecurityController +import eu.kanade.tachiyomi.ui.setting.SettingsTrackingController +import eu.kanade.tachiyomi.util.system.isLTR +import eu.kanade.tachiyomi.util.system.launchNow +import kotlin.reflect.KClass +import kotlin.reflect.full.createInstance + +object SettingsSearchHelper { + private var prefSearchResultList: MutableList = mutableListOf() + + /** + * All subclasses of `SettingsController` should be listed here, in order to have their preferences searchable. + */ + private val settingControllersList: List> = listOf( + SettingsAdvancedController::class, + SettingsBackupController::class, + SettingsDownloadController::class, + SettingsGeneralController::class, + SettingsSecurityController::class, + SettingsLibraryController::class, + SettingsReaderController::class, + SettingsTrackingController::class + ) + + /** + * Must be called to populate `prefSearchResultList` + */ + @SuppressLint("RestrictedApi") + fun initPreferenceSearchResultCollection(context: Context) { + val preferenceManager = PreferenceManager(context) + prefSearchResultList.clear() + + launchNow { + settingControllersList.forEach { kClass -> + val ctrl = kClass.createInstance() + val settingsPrefScreen = ctrl.setupPreferenceScreen(preferenceManager.createPreferenceScreen(context)) + val prefCount = settingsPrefScreen.preferenceCount + for (i in 0 until prefCount) { + val rootPref = settingsPrefScreen.getPreference(i) + if (rootPref.title == null) continue // no title, not a preference. (note: only info notes appear to not have titles) + getSettingSearchResult(ctrl, rootPref, "${settingsPrefScreen.title}") + } + } + } + } + + fun getFilteredResults(query: String): List { + return prefSearchResultList.filter { + val inTitle = it.title.contains(query, true) + val inSummary = it.summary.contains(query, true) + val inBreadcrumb = it.breadcrumb.replace(">", "").contains(query, true) + + return@filter inTitle || inSummary || inBreadcrumb + } + } + + /** + * Extracts the data needed from a `Preference` to create a `SettingsSearchResult`, and then adds it to `prefSearchResultList` + * Future enhancement: make bold the text matched by the search query. + */ + private fun getSettingSearchResult( + ctrl: SettingsController, + pref: Preference, + breadcrumbs: String = "" + ) { + when { + pref is PreferenceGroup -> { + val breadcrumbsStr = addLocalizedBreadcrumb(breadcrumbs, "${pref.title}") + + for (x in 0 until pref.preferenceCount) { + val subPref = pref.getPreference(x) + getSettingSearchResult(ctrl, subPref, breadcrumbsStr) // recursion + } + } + pref is PreferenceCategory -> { + val breadcrumbsStr = addLocalizedBreadcrumb(breadcrumbs, "${pref.title}") + + for (x in 0 until pref.preferenceCount) { + val subPref = pref.getPreference(x) + getSettingSearchResult(ctrl, subPref, breadcrumbsStr) // recursion + } + } + (pref.title != null && pref.isVisible) -> { + // Is an actual preference + val title = pref.title.toString() + // ListPreferences occasionally run into ArrayIndexOutOfBoundsException issues + val summary = try { + pref.summary?.toString() ?: "" + } catch (e: Throwable) { + "" + } + + prefSearchResultList.add( + SettingsSearchResult( + key = pref.key, + title = title, + summary = summary, + breadcrumb = breadcrumbs, + searchController = ctrl + ) + ) + } + } + } + + private fun addLocalizedBreadcrumb(path: String, node: String): String { + return if (Resources.getSystem().isLTR) { + // This locale reads left to right. + "$path > $node" + } else { + // This locale reads right to left. + "$node < $path" + } + } + + data class SettingsSearchResult( + val key: String?, + val title: String, + val summary: String, + val breadcrumb: String, + val searchController: SettingsController + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHolder.kt new file mode 100644 index 0000000000..b5449ecd04 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchHolder.kt @@ -0,0 +1,45 @@ +package eu.kanade.tachiyomi.ui.setting.search + +import android.view.View +import androidx.core.graphics.ColorUtils +import eu.davidea.viewholders.FlexibleViewHolder +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.SettingsSearchControllerCardBinding +import eu.kanade.tachiyomi.util.lang.highlightText +import eu.kanade.tachiyomi.util.system.getResourceColor +import kotlin.reflect.full.createInstance + +/** + * Holder that binds the [SettingsSearchItem] containing catalogue cards. + * + * @param view view of [SettingsSearchItem] + * @param adapter instance of [SettingsSearchAdapter] + */ +class SettingsSearchHolder(view: View, val adapter: SettingsSearchAdapter) : + FlexibleViewHolder(view, adapter) { + + private val binding = SettingsSearchControllerCardBinding.bind(view) + init { + binding.titleWrapper.setOnClickListener { + adapter.getItem(bindingAdapterPosition)?.let { + val ctrl = it.settingsSearchResult.searchController::class.createInstance() + ctrl.preferenceKey = it.settingsSearchResult.key + + // must pass a new Controller instance to avoid this error https://github.com/bluelinelabs/Conductor/issues/446 + adapter.titleClickListener.onTitleClick(ctrl) + } + } + } + + /** + * Show the loading of source search result. + * + * @param item item of card. + */ + fun bind(item: SettingsSearchItem) { + val color = ColorUtils.setAlphaComponent(itemView.context.getResourceColor(R.attr.colorAccent), 75) + binding.searchResultPrefTitle.text = item.settingsSearchResult.title.highlightText(item.searchResult, color) + binding.searchResultPrefSummary.text = item.settingsSearchResult.summary.highlightText(item.searchResult, color) + binding.searchResultPrefBreadcrumb.text = item.settingsSearchResult.breadcrumb.highlightText(item.searchResult, color) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchItem.kt new file mode 100644 index 0000000000..3d0dc860a9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchItem.kt @@ -0,0 +1,58 @@ +package eu.kanade.tachiyomi.ui.setting.search + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem +import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.tachiyomi.R + +/** + * Item that contains search result information. + * + * @param pref the source for the search results. + * @param results the search results. + */ +class SettingsSearchItem( + val settingsSearchResult: SettingsSearchHelper.SettingsSearchResult, + val results: List?, + val searchResult: String +) : + AbstractFlexibleItem() { + + override fun getLayoutRes(): Int { + return R.layout.settings_search_controller_card + } + + /** + * Create view holder (see [SettingsSearchAdapter]. + * + * @return holder of view. + */ + override fun createViewHolder( + view: View, + adapter: FlexibleAdapter> + ): SettingsSearchHolder { + return SettingsSearchHolder(view, adapter as SettingsSearchAdapter) + } + + override fun bindViewHolder( + adapter: FlexibleAdapter>, + holder: SettingsSearchHolder, + position: Int, + payloads: List? + ) { + holder.bind(this) + } + + override fun equals(other: Any?): Boolean { + if (other is SettingsSearchItem) { + return settingsSearchResult == settingsSearchResult + } + return false + } + + override fun hashCode(): Int { + return settingsSearchResult.hashCode() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchPresenter.kt new file mode 100644 index 0000000000..acb595359f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchPresenter.kt @@ -0,0 +1,32 @@ +package eu.kanade.tachiyomi.ui.setting.search + +import android.os.Bundle +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +/** + * Presenter of [SettingsSearchController] + * Function calls should be done from here. UI calls should be done from the controller. + */ +open class SettingsSearchPresenter : BasePresenter() { + + /** + * Query from the view. + */ + var query = "" + private set + + val preferences: PreferencesHelper = Injekt.get() + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + query = savedState?.getString(SettingsSearchPresenter::query.name) ?: "" // TODO - Some way to restore previous query? + } + + override fun onSave(state: Bundle) { + state.putString(SettingsSearchPresenter::query.name, query) + super.onSave(state) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarController.kt index 40cf9d5db1..0bf65a5a69 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarController.kt @@ -1,42 +1,28 @@ package eu.kanade.tachiyomi.ui.similar -import android.app.Activity import android.os.Bundle import android.view.Menu +import android.view.MenuInflater import android.view.View -import android.view.ViewGroup -import com.afollestad.materialdialogs.MaterialDialog -import com.afollestad.materialdialogs.list.listItemsSingleChoice +import androidx.core.view.isVisible import com.google.android.material.snackbar.Snackbar import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.library.LibraryUpdateService -import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.ui.library.LibraryGroup -import eu.kanade.tachiyomi.ui.manga.MangaDetailsPresenter import eu.kanade.tachiyomi.ui.manga.similar.SimilarPresenter import eu.kanade.tachiyomi.ui.source.browse.BrowseSourceController import eu.kanade.tachiyomi.ui.source.browse.BrowseSourcePresenter import eu.kanade.tachiyomi.util.system.dpToPx -import eu.kanade.tachiyomi.util.view.gone -import eu.kanade.tachiyomi.util.view.marginTop -import eu.kanade.tachiyomi.util.view.scrollViewWith +import eu.kanade.tachiyomi.util.view.setStyle import eu.kanade.tachiyomi.util.view.snack -import eu.kanade.tachiyomi.util.view.updateLayoutParams -import kotlinx.android.synthetic.main.browse_source_controller.* -import kotlinx.android.synthetic.main.browse_source_controller.empty_view -import kotlinx.android.synthetic.main.browse_source_controller.swipe_refresh -import kotlinx.android.synthetic.main.library_list_controller.* -import kotlinx.android.synthetic.main.manga_details_controller.* /** * Controller that shows the latest manga from the catalogue. Inherit [BrowseCatalogueController]. */ class SimilarController(bundle: Bundle) : BrowseSourceController(bundle) { - lateinit var similarPresenter : SimilarPresenter + lateinit var similarPresenter: SimilarPresenter constructor(manga: Manga, source: Source) : this( Bundle().apply { @@ -57,23 +43,21 @@ class SimilarController(bundle: Bundle) : BrowseSourceController(bundle) { override fun onViewCreated(view: View) { super.onViewCreated(view) - fab.gone() - swipe_refresh.isEnabled = true - swipe_refresh.isRefreshing = similarPresenter.isRefreshing - swipe_refresh.setProgressViewOffset(false, 0.dpToPx, 120.dpToPx) - swipe_refresh.setOnRefreshListener { + binding.fab.isVisible = false + binding.swipeRefresh.setStyle() + + binding.swipeRefresh.setProgressViewOffset(false, 20.dpToPx, binding.swipeRefresh.progressViewEndOffset + 25.dpToPx) + binding.swipeRefresh.isEnabled = true + binding.swipeRefresh.setOnRefreshListener { similarPresenter.refreshSimilarManga() } } - override fun onPrepareOptionsMenu(menu: Menu) { - super.onPrepareOptionsMenu(menu) - menu.findItem(R.id.action_search).isVisible = false - menu.findItem(R.id.action_open_in_web_view).isVisible = false + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { } fun showUserMessage(message: String) { - swipe_refresh?.isRefreshing = similarPresenter.isRefreshing + binding.swipeRefresh.isRefreshing = false view?.snack(message, Snackbar.LENGTH_LONG) } @@ -84,13 +68,9 @@ class SimilarController(bundle: Bundle) : BrowseSourceController(bundle) { */ override fun onAddPageError(error: Throwable) { super.onAddPageError(error) - empty_view.show( + binding.emptyView.show( CommunityMaterial.Icon.cmd_compass_off, "No Similar Manga found" ) } - - override fun expandSearch() { - activity?.onBackPressed() - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarPresenter.kt index c3fdfbbe1c..b17b2feeef 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/similar/SimilarPresenter.kt @@ -42,7 +42,7 @@ class SimilarPresenter( source.fetchMangaSimilarObservable(manga!!, true).toBlocking().first() isRefreshing = false withContext(Dispatchers.Main) { - controller.showUserMessage("Updated Similar Mangas!") + controller.showUserMessage("Updated Similar Manga") } } catch (e: java.lang.Exception) { isRefreshing = false @@ -57,10 +57,9 @@ class SimilarPresenter( private fun trimException(e: java.lang.Exception): String { return ( - if (e.message?.contains(": ") == true) e.message?.split(": ")?.drop(1) - ?.joinToString(": ") - else e.message - ) ?: preferences.context.getString(R.string.unknown_error) + if (e.message?.contains(": ") == true) e.message?.split(": ")?.drop(1) + ?.joinToString(": ") + else e.message + ) ?: preferences.context.getString(R.string.unknown_error) } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt index 101715ee34..95a72a5246 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt @@ -1,84 +1,63 @@ package eu.kanade.tachiyomi.ui.source.browse -import android.app.SearchManager -import android.database.Cursor -import android.database.MatrixCursor +import android.Manifest +import android.os.Build import android.os.Bundle -import android.os.Handler -import android.provider.BaseColumns import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import android.widget.AutoCompleteTextView -import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView -import androidx.cursoradapter.widget.CursorAdapter -import androidx.cursoradapter.widget.SimpleCursorAdapter +import androidx.core.view.isVisible import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.elvishew.xlog.XLog -import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar -import com.jakewharton.rxbinding.support.v7.widget.queryTextChangeEvents import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.databinding.BrowseSourceControllerBinding import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.source.online.handlers.SearchHandler import eu.kanade.tachiyomi.ui.base.controller.NucleusController import eu.kanade.tachiyomi.ui.follows.FollowsController -import eu.kanade.tachiyomi.ui.library.AddToLibraryCategoriesDialog +import eu.kanade.tachiyomi.ui.main.FloatingSearchInterface import eu.kanade.tachiyomi.ui.main.MainActivity -import eu.kanade.tachiyomi.ui.main.RootSearchInterface import eu.kanade.tachiyomi.ui.manga.MangaDetailsController import eu.kanade.tachiyomi.ui.webview.WebViewActivity +import eu.kanade.tachiyomi.util.addOrRemoveToFavorites import eu.kanade.tachiyomi.util.system.connectivityManager import eu.kanade.tachiyomi.util.system.dpToPx -import eu.kanade.tachiyomi.util.view.applyWindowInsetsForRootController -import eu.kanade.tachiyomi.util.view.gone +import eu.kanade.tachiyomi.util.system.pxToDp +import eu.kanade.tachiyomi.util.view.activityBinding +import eu.kanade.tachiyomi.util.view.applyBottomAnimatedInsets import eu.kanade.tachiyomi.util.view.inflate +import eu.kanade.tachiyomi.util.view.requestPermissionsSafe import eu.kanade.tachiyomi.util.view.scrollViewWith -import eu.kanade.tachiyomi.util.view.setStyle +import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener import eu.kanade.tachiyomi.util.view.snack import eu.kanade.tachiyomi.util.view.updateLayoutParams -import eu.kanade.tachiyomi.util.view.visible -import eu.kanade.tachiyomi.util.view.visibleIf import eu.kanade.tachiyomi.util.view.withFadeTransaction import eu.kanade.tachiyomi.widget.AutofitRecyclerView import eu.kanade.tachiyomi.widget.EmptyView -import kotlinx.android.synthetic.main.browse_source_controller.* -import kotlinx.android.synthetic.main.browse_source_controller.empty_view -import kotlinx.android.synthetic.main.browse_source_controller.progress -import kotlinx.android.synthetic.main.browse_source_controller.swipe_refresh -import kotlinx.android.synthetic.main.library_list_controller.* -import kotlinx.android.synthetic.main.main_activity.* -import rx.Observable -import rx.Subscription import uy.kohesive.injekt.injectLazy /** * Controller to manage the catalogues available in the app. */ open class BrowseSourceController(bundle: Bundle) : - NucleusController(bundle), + NucleusController(bundle), FlexibleAdapter.OnItemClickListener, FlexibleAdapter.OnItemLongClickListener, - FlexibleAdapter.EndlessScrollListener, - AddToLibraryCategoriesDialog.Listener, - RootSearchInterface { + FloatingSearchInterface, + FlexibleAdapter.EndlessScrollListener { constructor( searchQuery: String? = null, @@ -90,8 +69,9 @@ open class BrowseSourceController(bundle: Bundle) : putBoolean(APPLY_INSET, applyInset) putBoolean(DEEP_LINK, deepLink) - if (searchQuery != null) + if (searchQuery != null) { putString(SEARCH_QUERY_KEY, searchQuery) + } } ) @@ -106,11 +86,6 @@ open class BrowseSourceController(bundle: Bundle) : */ private val preferences: PreferencesHelper by injectLazy() - /** - * Database used for autocomplete - */ - private val db: DatabaseHelper by injectLazy() - /** * Adapter containing the list of manga from the catalogue. */ @@ -126,68 +101,47 @@ open class BrowseSourceController(bundle: Bundle) : */ private var recycler: RecyclerView? = null - /** - * Subscription for the search view. - */ - private var searchViewSubscription: Subscription? = null - /** * Endless loading item. */ private var progressItem: ProgressItem? = null - /** - * Our query mangadex which will query at a slow rate - */ - private var canRun: Boolean = true - private var handler: Handler = Handler() - init { setHasOptionsMenu(true) } override fun getTitle(): String? { - return presenter.source.name + return searchTitle(presenter.source.name) } override fun createPresenter(): BrowseSourcePresenter { return BrowseSourcePresenter(args.getString(SEARCH_QUERY_KEY) ?: "", args.getBoolean(DEEP_LINK)) } - override fun inflateView(inflater: LayoutInflater, container: ViewGroup): View { - return inflater.inflate(R.layout.browse_source_controller, container, false) - } + override fun createBinding(inflater: LayoutInflater) = BrowseSourceControllerBinding.inflate(inflater) override fun onViewCreated(view: View) { super.onViewCreated(view) - if (presenter.source.isLogged().not()) { - view.snack("You must be logged it. please login") - } if (preferences.useCacheSource()) { view.snack("Browsing Cached Source") } - if (bundle?.getBoolean(APPLY_INSET) == true) { - view.applyWindowInsetsForRootController(activity!!.bottom_nav) - } // Initialize adapter, scroll listener and recycler views adapter = FlexibleAdapter(null, this) setupRecycler(view) - // Disable refresh by default - swipe_refresh.setStyle() - swipe_refresh.isRefreshing = false - swipe_refresh.isEnabled = false + binding.fab.isVisible = presenter.sourceFilters.isNotEmpty() + + binding.fab.setOnClickListener { showFilters() } + binding.swipeRefresh.isEnabled = false - fab.visibleIf(presenter.sourceFilters.isNotEmpty() && preferences.useCacheSource().not()) - fab.setOnClickListener { showFilters() } - progress?.visible() + updateFab() + binding.progress.isVisible = true + requestPermissionsSafe(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 301) } override fun onDestroyView(view: View) { - searchViewSubscription?.unsubscribe() - searchViewSubscription = null adapter = null snack = null recycler = null @@ -196,12 +150,12 @@ open class BrowseSourceController(bundle: Bundle) : private fun setupRecycler(view: View) { var oldPosition = RecyclerView.NO_POSITION - val oldRecycler = catalogue_view?.getChildAt(1) + val oldRecycler = binding.catalogueView.getChildAt(1) if (oldRecycler is RecyclerView) { - oldPosition = (oldRecycler.layoutManager as androidx.recyclerview.widget.LinearLayoutManager).findFirstVisibleItemPosition() + oldPosition = (oldRecycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() oldRecycler.adapter = null - catalogue_view?.removeView(oldRecycler) + binding.catalogueView.removeView(oldRecycler) } val recycler = if (presenter.isListMode) { @@ -212,14 +166,8 @@ open class BrowseSourceController(bundle: Bundle) : addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) } } else { - (catalogue_view.inflate(R.layout.manga_recycler_autofit) as AutofitRecyclerView).apply { - columnWidth = when (preferences.gridSize().getOrDefault()) { - 1 -> 1f - 2 -> 1.25f - 3 -> 1.66f - 4 -> 3f - else -> .75f - } + (binding.catalogueView.inflate(R.layout.manga_recycler_autofit) as AutofitRecyclerView).apply { + setGridSize(preferences) (layoutManager as androidx.recyclerview.widget.GridLayoutManager).spanSizeLookup = object : androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { @@ -236,24 +184,32 @@ open class BrowseSourceController(bundle: Bundle) : recycler.adapter = adapter scrollViewWith( - recycler, true, + recycler, + true, afterInsets = { insets -> - fab?.updateLayoutParams { - bottomMargin = insets.systemWindowInsetBottom + 16.dpToPx + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + binding.fab.updateLayoutParams { + bottomMargin = insets.systemWindowInsetBottom + 16.dpToPx + } } } ) - recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - if (dy <= 0) - fab.extend() - else - fab.shrink() + binding.fab.applyBottomAnimatedInsets(16.dpToPx) + + recycler.addOnScrollListener( + object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + if (dy <= 0) { + binding.fab.extend() + } else { + binding.fab.shrink() + } + } } - }) + ) - catalogue_view.addView(recycler, 1) + binding.catalogueView.addView(recycler, 1) if (oldPosition != RecyclerView.NO_POSITION) { recycler.layoutManager?.scrollToPosition(oldPosition) } @@ -261,37 +217,59 @@ open class BrowseSourceController(bundle: Bundle) : } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - if (bundle?.getBoolean(APPLY_INSET) != true) { - (activity as? MainActivity)?.showNavigationArrow() - } inflater.inflate(R.menu.browse_source, menu) + // Initialize search menu + val searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.actionView as SearchView + + val query = presenter.query + if (query.isNotBlank()) { + searchItem.expandActionView() + searchView.setQuery(query, true) + searchView.clearFocus() + } + + setOnQueryTextChangeListener(searchView, onlyOnSubmit = true, hideKbOnSubmit = false) { + searchWithQuery(it ?: "") + true + } + + searchItem.fixExpand( + onExpand = { invalidateMenuOnExpand() }, + onCollapse = { + searchWithQuery("") + true + } + ) + // Show next display mode menu.findItem(R.id.action_display_mode).apply { - val icon = if (presenter.isListMode) + val icon = if (presenter.isListMode) { R.drawable.ic_view_module_24dp - else + } else { R.drawable.ic_view_list_24dp + } setIcon(icon) } - - // Show toggle library visibility menu.findItem(R.id.action_toggle_have_already).apply { - title = if (presenter.isLibraryVisible) { - activity!!.getString(R.string.hide_library_manga) + val icon = if (preferences.browseShowLibrary().get()) { + R.drawable.ic_eye_off_24dp } else { - activity!!.getString(R.string.show_library_manga) + R.drawable.ic_eye_24dp } + setIcon(icon) } + hideItemsIfExpanded(searchItem, menu) } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { - R.id.action_search -> item.expandActionView() + R.id.action_search -> expandActionViewFromInteraction = true R.id.action_display_mode -> swapDisplayMode() R.id.action_toggle_have_already -> swapLibraryVisibility() - R.id.action_open_in_web_view -> openInWebView() - R.id.action_open_merged_source_in_web_view -> openInWebView(false) + /* R.id.action_open_in_web_view -> openInWebView() + R.id.action_open_merged_source_in_web_view -> openInWebView(false)*/ else -> return super.onOptionsItemSelected(item) } @@ -299,7 +277,7 @@ open class BrowseSourceController(bundle: Bundle) : } private fun showFilters() { - val sheet = SourceSearchSheet(activity!!) + val sheet = SourceFilterSheet(activity!!) sheet.setFilters(presenter.filterItems) presenter.filtersChanged = false val oldFilters = mutableListOf() @@ -372,14 +350,18 @@ open class BrowseSourceController(bundle: Bundle) : val source = presenter.source as? HttpSource ?: return val activity = activity ?: return WebViewActivity.newIntent( - activity, source.id, source.baseUrl, + activity, + source.id, + source.baseUrl, presenter.source.name ) } else { val source = presenter.sourceManager.getMergeSource() as? HttpSource ?: return val activity = activity ?: return WebViewActivity.newIntent( - activity, source.id, source.baseUrl, + activity, + source.id, + source.baseUrl, source.name ) } @@ -394,8 +376,9 @@ open class BrowseSourceController(bundle: Bundle) : */ private fun searchWithQuery(newQuery: String) { // If text didn't change, do nothing - if (presenter.query == newQuery) + if (presenter.query == newQuery) { return + } showProgressBar() adapter?.clear() @@ -438,7 +421,7 @@ open class BrowseSourceController(bundle: Bundle) : val message = getErrorMessage(error) val retryAction = View.OnClickListener { - // If not the first page, show bottom progress bar. + // If not the first page, show bottom binding.progress bar. if (adapter.mainItemCount > 0 && progressItem != null) { adapter.addScrollableFooterWithDelay(progressItem!!, 0, true) } else { @@ -453,13 +436,13 @@ open class BrowseSourceController(bundle: Bundle) : actions += EmptyView.Action(R.string.retry, retryAction) actions += EmptyView.Action(R.string.open_in_webview, View.OnClickListener { openInWebView() }) - empty_view.show( + binding.emptyView.show( CommunityMaterial.Icon.cmd_compass_off, message, actions ) } else { - snack = source_layout?.snack(message, Snackbar.LENGTH_INDEFINITE) { + snack = binding.sourceLayout.snack(message, Snackbar.LENGTH_INDEFINITE) { setAction(R.string.retry, retryAction) } } @@ -478,7 +461,7 @@ open class BrowseSourceController(bundle: Bundle) : } /** - * Sets a new progress item and reenables the scroll listener. + * Sets a new binding.progress item and reenables the scroll listener. */ private fun resetProgressItem() { progressItem = ProgressItem() @@ -548,7 +531,7 @@ open class BrowseSourceController(bundle: Bundle) : val adapter = adapter ?: return null adapter.allBoundViewHolders.forEach { holder -> - val item = adapter.getItem(holder.adapterPosition) as? BrowseSourceItem + val item = adapter.getItem(holder.flexibleAdapterPosition) as? BrowseSourceItem if (item != null && item.manga.id!! == manga.id!!) { return holder as BrowseSourceHolder } @@ -558,21 +541,21 @@ open class BrowseSourceController(bundle: Bundle) : } /** - * Shows the progress bar. + * Shows the binding.progress bar. */ private fun showProgressBar() { - empty_view.gone() - progress?.visible() + binding.emptyView.isVisible = false + binding.progress.isVisible = true snack?.dismiss() snack = null } /** - * Hides active progress bars. + * Hides active binding.progress bars. */ private fun hideProgressBar() { - empty_view.gone() - progress?.gone() + binding.emptyView.isVisible = false + binding.progress.isVisible = false } /** @@ -599,167 +582,38 @@ open class BrowseSourceController(bundle: Bundle) : */ override fun onItemLongClick(position: Int) { val manga = (adapter?.getItem(position) as? BrowseSourceItem?)?.manga ?: return + val view = view ?: return + val activity = activity ?: return snack?.dismiss() - if (manga.favorite) { - presenter.changeMangaFavorite(manga) - adapter?.notifyItemChanged(position) - snack = source_layout?.snack(R.string.removed_from_library, Snackbar.LENGTH_INDEFINITE) { - setAction(R.string.undo) { - if (!manga.favorite) addManga(manga, position) - } - addCallback(object : BaseTransientBottomBar.BaseCallback() { - override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { - super.onDismissed(transientBottomBar, event) - if (!manga.favorite) presenter.confirmDeletion(manga) - } - }) - } + snack = manga.addOrRemoveToFavorites( + presenter.db, + preferences, + view, + activity, + onMangaAdded = { + adapter?.notifyItemChanged(position) + snack = view.snack(R.string.added_to_library) + }, + onMangaMoved = { adapter?.notifyItemChanged(position) }, + onMangaDeleted = { presenter.confirmDeletion(manga) } + ) + if (snack?.duration == Snackbar.LENGTH_INDEFINITE) { (activity as? MainActivity)?.setUndoSnackBar(snack) - } else { - addManga(manga, position) - snack = source_layout?.snack(R.string.added_to_library) - } - } - - private fun addManga(manga: Manga, position: Int) { - presenter.changeMangaFavorite(manga) - adapter?.notifyItemChanged(position) - - val categories = presenter.getCategories() - val defaultCategoryId = preferences.defaultCategory() - val defaultCategory = categories.find { it.id == defaultCategoryId } - when { - defaultCategory != null -> presenter.moveMangaToCategory(manga, defaultCategory) - defaultCategoryId == 0 || categories.isEmpty() -> // 'Default' or no category - presenter.moveMangaToCategory(manga, null) - else -> { - val ids = presenter.getMangaCategoryIds(manga) - if (ids.isNullOrEmpty()) { - presenter.moveMangaToCategory(manga, null) - } - val preselected = ids.mapNotNull { id -> - categories.indexOfFirst { it.id == id }.takeIf { it != -1 } - }.toTypedArray() - - AddToLibraryCategoriesDialog(this, manga, categories, preselected, position) - .showDialog(router) - } } } - /** - * Update manga to use selected categories. - * - * @param mangas The list of manga to move to categories. - * @param categories The list of categories where manga will be placed. - */ - override fun updateCategoriesForManga(manga: Manga?, categories: List) { - manga?.let { presenter.updateMangaCategories(manga, categories) } + fun updateFab() { + binding.fab.y = -(activityBinding!!.bottomNav!!.height.pxToDp.toFloat() + 25f.dpToPx) } - /** - * Update manga to remove from favorites - */ - override fun addToLibraryCancelled(manga: Manga?, position: Int) { - manga?.let { - presenter.changeMangaFavorite(manga) - adapter?.notifyItemChanged(position) - } - } - - override fun expandSearch() { - - // Initialize search menu - val searchItem = activity?.toolbar?.menu?.findItem(R.id.action_search)!! - val searchView = searchItem.actionView as SearchView - - // Autocomplete searching cursor - // https://github.com/korydondzila/kotlin-search - searchView.queryHint = activity!!.getString(R.string.search) - val autoCompleteTextView = searchView.findViewById(R.id.search_src_text) - autoCompleteTextView.threshold = 1 - val from = arrayOf(SearchManager.SUGGEST_COLUMN_TEXT_1) - val to = intArrayOf(R.id.item_label) - val cursorAdapter = SimpleCursorAdapter(activity!!, R.layout.search_item, null, from, to, CursorAdapter.FLAG_AUTO_REQUERY) - searchView.suggestionsAdapter = cursorAdapter - - // Create our subscribers for this search menu - val query = presenter.query - if (!query.isBlank()) { - searchItem.expandActionView() - searchView.setQuery(query, true) - searchView.clearFocus() - } else { - searchItem.expandActionView() - } - - val searchEventsObservable = searchView.queryTextChangeEvents() - .skip(1) - .filter { router.backstack.lastOrNull()?.controller() == this@BrowseSourceController } - .share() - val writingObservable = searchEventsObservable - .filter { !it.isSubmitted } - val submitObservable = searchEventsObservable - .filter { it.isSubmitted } - - searchViewSubscription?.unsubscribe() - searchViewSubscription = Observable.merge(writingObservable, submitObservable) - .filter { it.queryText().toString() != SearchHandler.PREFIX_ID_SEARCH } - .subscribeUntilDestroy { - - // Update our cursor for our autocomplete - val cursor = MatrixCursor(arrayOf(BaseColumns._ID, SearchManager.SUGGEST_COLUMN_TEXT_1)) - if (!it.isSubmitted) { - try { - val matches = db.searchCachedManga(it.queryText().toString(), 0, 10).executeAsBlocking() - val matchesClean = matches.filter { manga -> - manga.rating in preferences.contentRatingSelections() - } - matchesClean.take(3).forEachIndexed { index, suggestion -> - cursor.addRow(arrayOf(index, suggestion.title)) - } - } catch (e: Exception) { - XLog.e(e) - } - } - cursorAdapter.changeCursor(cursor) - - // Actually search mangadex for the result with debounce - // https://stackoverflow.com/a/34994785/7718197 - if (canRun || it.isSubmitted) { - canRun = false - handler.postDelayed({ - canRun = true - searchWithQuery(it.queryText().toString()) - }, 1250) - } - - } - - // Callback if the user presses one of auto complete items - // This should fill in the query text and then submit the form - searchView.setOnSuggestionListener(object : SearchView.OnSuggestionListener { - override fun onSuggestionSelect(position: Int): Boolean { - return false - } - - override fun onSuggestionClick(position: Int): Boolean { - val inputMethodManager = activity!!.getSystemService(AppCompatActivity.INPUT_METHOD_SERVICE) as InputMethodManager - inputMethodManager.hideSoftInputFromWindow(view?.windowToken, 0) - val cursor = searchView.suggestionsAdapter.getItem(position) as Cursor - val selection = cursor.getString(cursor.getColumnIndex(SearchManager.SUGGEST_COLUMN_TEXT_1)) - searchView.setQuery(selection, true) - return true - } - }) - } - - protected companion object { + companion object { const val SOURCE_ID_KEY = "sourceId" - const val MANGA_ID = "mangaId" - const val SEARCH_QUERY_KEY = "searchQuery" const val APPLY_INSET = "applyInset" const val DEEP_LINK = "deepLink" const val FOLLOWS = "follows" + const val MANGA_ID = "mangaId" + + const val SEARCH_QUERY_KEY = "searchQuery" + const val SMART_SEARCH_CONFIG_KEY = "smartSearchConfig" } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceGridHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceGridHolder.kt index 0b42595ddc..8d1c1926fb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceGridHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceGridHolder.kt @@ -2,18 +2,18 @@ package eu.kanade.tachiyomi.ui.source.browse import android.app.Activity import android.view.View +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import coil.Coil import coil.clear +import coil.imageLoader import coil.request.ImageRequest import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.image.coil.CoverViewTarget +import eu.kanade.tachiyomi.databinding.MangaGridItemBinding import eu.kanade.tachiyomi.ui.library.LibraryCategoryAdapter -import eu.kanade.tachiyomi.util.view.gone -import kotlinx.android.synthetic.main.manga_grid_item.* -import kotlinx.android.synthetic.main.unread_download_badge.* /** * Class used to hold the displayed data of a manga in the library, like the cover or the title. @@ -28,15 +28,16 @@ class BrowseSourceGridHolder( private val view: View, private val adapter: FlexibleAdapter>, compact: Boolean, - private val isFollows: Boolean = false + private val isFollows: Boolean = false, ) : BrowseSourceHolder(view, adapter) { + private val binding = MangaGridItemBinding.bind(view) init { if (compact) { - text_layout.gone() + binding.textLayout.isVisible = false } else { - compact_title.gone() - gradient.gone() + binding.compactTitle.isVisible = false + binding.gradient.isVisible = false } } @@ -48,12 +49,9 @@ class BrowseSourceGridHolder( */ override fun onSetValues(manga: Manga) { // Update the title of the manga. - title.text = manga.title - compact_title.text = title.text - when (isFollows) { - true -> badge_view.setStatus(manga.follow_status!!, manga.favorite) - false -> badge_view.setInLibrary(manga.favorite) - } + binding.title.text = manga.title + binding.compactTitle.text = binding.title.text + binding.unreadDownloadBadge.root.setInLibrary(manga.favorite) // Update the cover. setImage(manga) @@ -62,11 +60,11 @@ class BrowseSourceGridHolder( override fun setImage(manga: Manga) { if ((view.context as? Activity)?.isDestroyed == true) return if (manga.thumbnail_url == null) { - cover_thumbnail.clear() + binding.coverThumbnail.clear() } else { manga.id ?: return val request = ImageRequest.Builder(view.context).data(manga) - .target(CoverViewTarget(cover_thumbnail, progress /* manga.potentialAltThumbnail()*/)).build() + .target(CoverViewTarget(binding.coverThumbnail, binding.progress)).build() Coil.imageLoader(view.context).enqueue(request) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceItem.kt index 6949ec3986..7d77940105 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceItem.kt @@ -8,17 +8,16 @@ import android.widget.ImageView import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView -import com.f2prateek.rx.preferences.Preference +import com.tfcporciuncula.flow.Preference import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractFlexibleItem import eu.davidea.flexibleadapter.items.IFlexible import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.databinding.MangaGridItemBinding import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.view.updateLayoutParams import eu.kanade.tachiyomi.widget.AutofitRecyclerView -import kotlinx.android.synthetic.main.manga_grid_item.view.* class BrowseSourceItem( val manga: Manga, @@ -29,41 +28,45 @@ class BrowseSourceItem( AbstractFlexibleItem() { override fun getLayoutRes(): Int { - return if (catalogueAsList.getOrDefault()) + return if (catalogueAsList.get()) { R.layout.manga_list_item - else + } else { R.layout.manga_grid_item + } } override fun createViewHolder(view: View, adapter: FlexibleAdapter>): BrowseSourceHolder { val parent = adapter.recyclerView - return if (parent is AutofitRecyclerView && !catalogueAsList.getOrDefault()) { - val listType = catalogueListType.getOrDefault() + return if (parent is AutofitRecyclerView && !catalogueAsList.get()) { + val listType = catalogueListType.get() view.apply { + val binding = MangaGridItemBinding.bind(this) val coverHeight = (parent.itemWidth / 3 * 4f).toInt() if (listType == 1) { - gradient.layoutParams = FrameLayout.LayoutParams( + binding.gradient.layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, (coverHeight * 0.66f).toInt(), Gravity.BOTTOM ) - card.updateLayoutParams { + binding.card.updateLayoutParams { bottomMargin = 6.dpToPx } } else { - constraint_layout.background = ContextCompat.getDrawable( - context, R.drawable.library_item_selector + binding.constraintLayout.background = ContextCompat.getDrawable( + context, + R.drawable.library_item_selector ) } - constraint_layout.layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT + binding.constraintLayout.layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT ) - cover_thumbnail.maxHeight = Int.MAX_VALUE - cover_thumbnail.minimumHeight = 0 - constraint_layout.minHeight = 0 - cover_thumbnail.scaleType = ImageView.ScaleType.CENTER_CROP - cover_thumbnail.adjustViewBounds = false - cover_thumbnail.layoutParams = FrameLayout.LayoutParams( + binding.coverThumbnail.maxHeight = Int.MAX_VALUE + binding.coverThumbnail.minimumHeight = 0 + binding.constraintLayout.minHeight = 0 + binding.coverThumbnail.scaleType = ImageView.ScaleType.CENTER_CROP + binding.coverThumbnail.adjustViewBounds = false + binding.coverThumbnail.layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, (parent.itemWidth / 3f * 3.7f).toInt() ) @@ -80,7 +83,6 @@ class BrowseSourceItem( position: Int, payloads: MutableList? ) { - holder.onSetValues(manga) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceListHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceListHolder.kt index 7264d4bc7e..cb9348a7c1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceListHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceListHolder.kt @@ -7,12 +7,9 @@ import coil.clear import coil.request.ImageRequest import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.potentialAltThumbnail import eu.kanade.tachiyomi.data.image.coil.CoverViewTarget -import eu.kanade.tachiyomi.util.system.getResourceColor -import kotlinx.android.synthetic.main.manga_list_item.* +import eu.kanade.tachiyomi.databinding.MangaListItemBinding /** * Class used to hold the displayed data of a manga in the catalogue, like the cover or the title. @@ -25,6 +22,8 @@ import kotlinx.android.synthetic.main.manga_list_item.* class BrowseSourceListHolder(private val view: View, adapter: FlexibleAdapter>) : BrowseSourceHolder(view, adapter) { + private val binding = MangaListItemBinding.bind(view) + /** * Method called from [CatalogueAdapter.onBindViewHolder]. It updates the data for this * holder with the given manga. @@ -32,24 +31,19 @@ class BrowseSourceListHolder(private val view: View, adapter: FlexibleAdapter { val page = currentPage - val observable = if (query.isBlank() && filters.isEmpty()) + val observable = if (query.isBlank() && filters.isEmpty()) { source.fetchPopularManga(page) - else + } else { source.fetchSearchManga(page, query, filters) + } return observable .subscribeOn(Schedulers.io()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePresenter.kt index d99e5311f5..9cae63ac79 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePresenter.kt @@ -8,7 +8,6 @@ import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.MangaCategory import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.source.SourceManager @@ -30,6 +29,13 @@ import eu.kanade.tachiyomi.ui.source.filter.TextItem import eu.kanade.tachiyomi.ui.source.filter.TextSectionItem import eu.kanade.tachiyomi.ui.source.filter.TriStateItem import eu.kanade.tachiyomi.ui.source.filter.TriStateSectionItem +import eu.kanade.tachiyomi.util.system.launchIO +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import rx.Observable import rx.Subscription import rx.android.schedulers.AndroidSchedulers @@ -37,13 +43,12 @@ import rx.schedulers.Schedulers import rx.subjects.PublishSubject import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.util.Date /** * Presenter of [BrowseSourceController]. */ open class BrowseSourcePresenter( - var query: String = "", + var searchQuery: String = "", private var isDeepLink: Boolean = false, val sourceManager: SourceManager = Injekt.get(), val db: DatabaseHelper = Injekt.get(), @@ -56,6 +61,12 @@ open class BrowseSourcePresenter( */ val source = sourceManager.getMangadex() + /** + * Query from the view. + */ + var query = "" + private set + var filtersChanged = false var isFollows = false @@ -112,6 +123,12 @@ open class BrowseSourcePresenter( */ private var initializerSubscription: Subscription? = null + private var scope = CoroutineScope(Job() + Dispatchers.IO) + + init { + query = searchQuery ?: "" + } + override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) @@ -121,19 +138,22 @@ open class BrowseSourcePresenter( query = savedState.getString(::query.name, "") } - add( - prefs.browseAsList().asObservable() - .subscribe { setDisplayMode(it) } - ) + prefs.browseAsList().asFlow() + .onEach { setDisplayMode(it) } + .launchIn(scope) - add( - prefs.browseShowLibrary().asObservable() - .subscribe { setShowLibrary(it) } - ) + prefs.browseShowLibrary().asFlow() + .onEach { setShowLibrary(it) } + .launchIn(scope) restartPager() } + override fun onDestroy() { + super.onDestroy() + scope.cancel() + } + override fun onSave(state: Bundle) { state.putString(::query.name, query) super.onSave(state) @@ -172,14 +192,14 @@ open class BrowseSourcePresenter( .map { it.first to it.second.map { BrowseSourceItem(it, browseAsList, sourceListType, isFollows) } .filter { isDeepLink || isLibraryVisible || !it.manga.favorite } - } - .observeOn(AndroidSchedulers.mainThread()) + }.observeOn(AndroidSchedulers.mainThread()) .subscribeReplay( { view, (page, mangas) -> if (isDeepLink) { view.goDirectlyForDeepLink(mangas.first().manga) + } else { + view.onAddPage(page, mangas) } - view.onAddPage(page, mangas) }, { _, error -> XLog.e(error) @@ -272,6 +292,9 @@ open class BrowseSourcePresenter( val result = db.insertManga(newManga).executeAsBlocking() newManga.id = result.insertedId() localManga = newManga + } else if (localManga.title.isBlank()) { + localManga.title = sManga.title + db.insertManga(localManga).executeAsBlocking() } return localManga @@ -303,27 +326,12 @@ open class BrowseSourcePresenter( .onErrorResumeNext { Observable.just(manga) } } - /** - * Adds or removes a manga from the library. - * - * @param manga the manga to update. - */ - fun changeMangaFavorite(manga: Manga) { - manga.favorite = !manga.favorite - - when (manga.favorite) { - true -> manga.date_added = Date().time - false -> manga.date_added = 0 - } - - db.insertManga(manga).executeAsBlocking() - } - fun confirmDeletion(manga: Manga) { - coverCache.deleteFromCache(manga) - val downloadManager: DownloadManager = Injekt.get() - downloadManager.deleteManga(manga, source) - db.resetMangaInfo(manga).executeAsBlocking() + launchIO { + coverCache.deleteFromCache(manga) + val downloadManager: DownloadManager = Injekt.get() + downloadManager.deleteManga(manga, source) + } } /** @@ -340,6 +348,23 @@ open class BrowseSourcePresenter( prefs.browseShowLibrary().set(!isLibraryVisible) } + /** + * Search for manga based off of a random manga id by utilizing the [query] and the [restartPager]. + */ + fun searchRandomManga() { + source.apply { + fetchRandomMangaId() + .observeOn(Schedulers.io()) + .subscribeOn(Schedulers.io()) + .subscribe { randMangaId -> + // Query string, e.g. "id:350" + restartPager("${SearchHandler.PREFIX_ID_SEARCH}$randMangaId") + // Clear search query so user can browse all manga again when they hit the Search button + query = "" + } + } + } + /** * Set the filter states for the current source. * @@ -397,71 +422,4 @@ open class BrowseSourcePresenter( fun getCategories(): List { return db.getCategories().executeAsBlocking() } - - /** - * Gets the category id's the manga is in, if the manga is not in a category, returns the default id. - * - * @param manga the manga to get categories from. - * @return Array of category ids the manga is in, if none returns default id - */ - fun getMangaCategoryIds(manga: Manga): Array { - val categories = db.getCategoriesForManga(manga).executeAsBlocking() - return categories.mapNotNull { it.id }.toTypedArray() - } - - /** - * Move the given manga to categories. - * - * @param categories the selected categories. - * @param manga the manga to move. - */ - private fun moveMangaToCategories(manga: Manga, categories: List) { - val mc = categories.filter { it.id != 0 }.map { MangaCategory.create(manga, it) } - db.setMangaCategories(mc, listOf(manga)) - } - - /** - * Move the given manga to the category. - * - * @param category the selected category. - * @param manga the manga to move. - */ - fun moveMangaToCategory(manga: Manga, category: Category?) { - moveMangaToCategories(manga, listOfNotNull(category)) - } - - /** - * Update manga to use selected categories. - * - * @param manga needed to change - * @param selectedCategories selected categories - */ - fun updateMangaCategories(manga: Manga, selectedCategories: List) { - if (selectedCategories.isNotEmpty()) { - if (!manga.favorite) - changeMangaFavorite(manga) - - moveMangaToCategories(manga, selectedCategories.filter { it.id != 0 }) - } else { - if (!manga.favorite) - changeMangaFavorite(manga) - } - } - - /** - * Search for manga based off of a random manga id by utilizing the [query] and the [restartPager]. - */ - fun searchRandomManga() { - source.apply { - fetchRandomMangaId() - .observeOn(Schedulers.io()) - .subscribeOn(Schedulers.io()) - .subscribe { randMangaId -> - // Query string, e.g. "id:350" - restartPager("${SearchHandler.PREFIX_ID_SEARCH}$randMangaId") - // Clear search query so user can browse all manga again when they hit the Search button - query = "" - } - } - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/SourceFilterSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/SourceFilterSheet.kt new file mode 100644 index 0000000000..5dc3f695de --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/SourceFilterSheet.kt @@ -0,0 +1,167 @@ +package eu.kanade.tachiyomi.ui.source.browse + +import android.app.Activity +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewTreeObserver.OnGlobalLayoutListener +import android.view.WindowInsets +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.bottomsheet.BottomSheetBehavior +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.IFlexible +import eu.kanade.tachiyomi.databinding.SourceFilterSheetBinding +import eu.kanade.tachiyomi.util.system.dpToPx +import eu.kanade.tachiyomi.util.view.doOnApplyWindowInsets +import eu.kanade.tachiyomi.util.view.expand +import eu.kanade.tachiyomi.util.view.updateLayoutParams +import eu.kanade.tachiyomi.util.view.updatePaddingRelative +import eu.kanade.tachiyomi.widget.E2EBottomSheetDialog + +class SourceFilterSheet(val activity: Activity) : + E2EBottomSheetDialog(activity) { + + private var filterChanged = true + + val adapter: FlexibleAdapter> = FlexibleAdapter>(null) + .setDisplayHeadersAtStartUp(true) + + var onSearchClicked = {} + + var onResetClicked = {} + + var onRandomClicked = {} + + var onFollowsClicked = {} + + override var recyclerView: RecyclerView? = binding.filtersRecycler + + override fun createBinding(inflater: LayoutInflater) = SourceFilterSheetBinding.inflate(inflater) + + init { + binding.searchBtn.setOnClickListener { dismiss() } + binding.resetBtn.setOnClickListener { onResetClicked() } + binding.randomMangaBtn.setOnClickListener { onRandomClicked() } + binding.followsBtn.setOnClickListener { onFollowsClicked() } + + binding.titleLayout.viewTreeObserver.addOnGlobalLayoutListener(object : + OnGlobalLayoutListener { + override fun onGlobalLayout() { + activity.window.decorView.rootWindowInsets?.let { + setCardViewMax(it) + } + if (binding.titleLayout.height > 0) { + binding.titleLayout.viewTreeObserver.removeOnGlobalLayoutListener(this) + } + } + }) + + binding.cardView.doOnApplyWindowInsets { _, insets, _ -> + binding.cardView.updateLayoutParams { + val fullHeight = activity.window.decorView.height + matchConstraintMaxHeight = + fullHeight - insets.systemWindowInsetTop - + binding.titleLayout.height - 75.dpToPx + } + } + + val attrsArray = intArrayOf(android.R.attr.actionBarSize) + val array = context.obtainStyledAttributes(attrsArray) + val headerHeight = array.getDimensionPixelSize(0, 0) + array.recycle() + binding.root.doOnApplyWindowInsets { _, insets, _ -> + binding.titleLayout.updatePaddingRelative( + bottom = insets.systemWindowInsetBottom + ) + binding.titleLayout.updateLayoutParams { + height = headerHeight + headerHeight + binding.titleLayout.paddingBottom + } + setCardViewMax(insets) + } + + (binding.root.parent.parent as? View)?.viewTreeObserver?.addOnGlobalLayoutListener(object : OnGlobalLayoutListener { + override fun onGlobalLayout() { + updateBottomButtons() + if (sheetBehavior.state != BottomSheetBehavior.STATE_COLLAPSED) { + (binding.root.parent.parent as? View)?.viewTreeObserver?.removeOnGlobalLayoutListener(this) + } + } + }) + + setOnShowListener { + updateBottomButtons() + } + + binding.filtersRecycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(context) + binding.filtersRecycler.clipToPadding = false + binding.filtersRecycler.adapter = adapter + binding.filtersRecycler.setHasFixedSize(true) + + sheetBehavior.addBottomSheetCallback( + object : BottomSheetBehavior.BottomSheetCallback() { + override fun onSlide(bottomSheet: View, progress: Float) { + updateBottomButtons() + } + + override fun onStateChanged(p0: View, state: Int) { + updateBottomButtons() + } + } + ) + } + + fun setCardViewMax(insets: WindowInsets) { + val fullHeight = activity.window.decorView.height + val newHeight = fullHeight - insets.systemWindowInsetTop - + binding.titleLayout.height - 75.dpToPx + if ((binding.cardView.layoutParams as ConstraintLayout.LayoutParams).matchConstraintMaxHeight != newHeight) { + binding.cardView.updateLayoutParams { + matchConstraintMaxHeight = newHeight + } + } + } + + override fun onStart() { + super.onStart() + sheetBehavior.expand() + sheetBehavior.skipCollapsed = true + updateBottomButtons() + binding.root.post { + updateBottomButtons() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val attrsArray = intArrayOf(android.R.attr.actionBarSize) + val array = context.obtainStyledAttributes(attrsArray) + val headerHeight = array.getDimensionPixelSize(0, 0) + binding.titleLayout.updatePaddingRelative( + bottom = activity.window.decorView.rootWindowInsets.systemWindowInsetBottom + ) + + binding.titleLayout.updateLayoutParams { + height = headerHeight + headerHeight + binding.titleLayout.paddingBottom + } + array.recycle() + } + + private fun updateBottomButtons() { + val bottomSheet = binding.root.parent as View + val bottomSheetVisibleHeight = -bottomSheet.top + (activity.window.decorView.height - bottomSheet.height) + + binding.titleLayout.translationY = bottomSheetVisibleHeight.toFloat() + } + + override fun dismiss() { + super.dismiss() + if (filterChanged) { + onSearchClicked() + } + } + + fun setFilters(items: List>) { + adapter.updateDataSet(items) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/SourceSearchSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/SourceSearchSheet.kt deleted file mode 100644 index f80a81dac1..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/SourceSearchSheet.kt +++ /dev/null @@ -1,147 +0,0 @@ -package eu.kanade.tachiyomi.ui.source.browse - -import android.animation.ObjectAnimator -import android.animation.ValueAnimator -import android.app.Activity -import android.view.ActionMode -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.IFlexible -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.util.system.dpToPx -import eu.kanade.tachiyomi.util.view.RecyclerWindowInsetsListener -import eu.kanade.tachiyomi.util.view.expand -import eu.kanade.tachiyomi.util.view.setEdgeToEdge -import kotlinx.android.synthetic.main.source_filter_sheet.* -import uy.kohesive.injekt.injectLazy - -class SourceSearchSheet(activity: Activity) : - BottomSheetDialog(activity, R.style.BottomSheetDialogTheme) { - - /** - * Preferences helper. - */ - private val preferences by injectLazy() - - private var sheetBehavior: BottomSheetBehavior<*> - - private var elevationAnimator: ValueAnimator? = null - - private var bottomElevationAnimator: ValueAnimator? = null - - var filterChanged = true - - var isNotElevated = false - - var isBottomElevated = true - - val adapter: FlexibleAdapter> = FlexibleAdapter>(null) - .setDisplayHeadersAtStartUp(true) - - var onSearchClicked = {} - - var onResetClicked = {} - - var onRandomClicked = {} - - var onFollowsClicked = {} - - init { - val view = activity.layoutInflater.inflate(R.layout.source_filter_sheet, null) - setContentView(view) - toolbar_title.text = context.getString(R.string.search_filters) - search_btn.setOnClickListener { dismiss() } - reset_btn.setOnClickListener { onResetClicked() } - follows_btn.setOnClickListener { onFollowsClicked() } - random_manga_btn.setOnClickListener { onRandomClicked() } - - sheetBehavior = BottomSheetBehavior.from(view.parent as ViewGroup) - sheetBehavior.skipCollapsed = true - sheetBehavior.expand() - setEdgeToEdge( - activity, view, 50.dpToPx - ) - - recycler.layoutManager = androidx.recyclerview.widget.LinearLayoutManager(context) - recycler.clipToPadding = false - recycler.adapter = adapter - recycler.setHasFixedSize(true) - recycler.setOnApplyWindowInsetsListener(RecyclerWindowInsetsListener) - - // the spinner in the recycler can break the sheet's layout on change - // this is to reset it back - source_filter_sheet.post { - (source_filter_sheet.parent as View).fitsSystemWindows = false - source_filter_sheet.viewTreeObserver.addOnDrawListener { - (source_filter_sheet.parent as View).fitsSystemWindows = false - } - } - - sheetBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, progress: Float) {} - - override fun onStateChanged(p0: View, state: Int) { - if (state == BottomSheetBehavior.STATE_EXPANDED) { - sheetBehavior.skipCollapsed = true - } - } - }) - - recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy) - val atTop = !recycler.canScrollVertically(-1) - if (atTop != isNotElevated) { - elevationAnimator?.cancel() - isNotElevated = atTop - elevationAnimator?.cancel() - elevationAnimator = ObjectAnimator.ofFloat( - title_layout, - "elevation", - title_layout.elevation, - if (atTop) 0f else 10f.dpToPx - ) - elevationAnimator?.duration = 100 - elevationAnimator?.start() - } - val atBottom = !recycler.canScrollVertically(1) - if (atBottom != isBottomElevated) { - bottomElevationAnimator?.cancel() - isBottomElevated = atBottom - bottomElevationAnimator?.cancel() - bottomElevationAnimator = ObjectAnimator.ofFloat( - footer_layout, - "elevation", - footer_layout.elevation, - if (atBottom) 0f else 20f.dpToPx - ) - bottomElevationAnimator?.duration = 100 - bottomElevationAnimator?.start() - } - } - }) - } - - override fun onWindowStartingActionMode( - callback: ActionMode.Callback?, - type: Int - ): ActionMode? { - (source_filter_sheet.parent as View).fitsSystemWindows = false - return super.onWindowStartingActionMode(callback, type) - } - - override fun dismiss() { - super.dismiss() - if (filterChanged) - onSearchClicked() - } - - fun setFilters(items: List>) { - adapter.updateDataSet(items) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/GroupItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/GroupItem.kt index 50f4d6f983..016ea6214a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/GroupItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/GroupItem.kt @@ -11,7 +11,7 @@ import eu.davidea.flexibleadapter.items.ISectionable import eu.davidea.viewholders.ExpandableViewHolder import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.source.model.Filter -import eu.kanade.tachiyomi.util.view.setVectorCompat +import eu.kanade.tachiyomi.util.view.setAnimVectorCompat class GroupItem(val filter: Filter.Group<*>) : AbstractExpandableHeaderItem>() { @@ -34,11 +34,12 @@ class GroupItem(val filter: Filter.Group<*>) : AbstractExpandableHeaderItem>, holder: Holder, position: Int, payloads: MutableList?) { holder.title.text = filter.name - holder.icon.setVectorCompat( - if (isExpanded) - R.drawable.ic_expand_more_24dp - else - R.drawable.ic_chevron_right_24dp + holder.icon.setAnimVectorCompat( + if (isExpanded) { + R.drawable.anim_expand_more_to_less + } else { + R.drawable.anim_expand_less_to_more + } ) holder.itemView.setOnClickListener(holder) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/SelectItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/SelectItem.kt index 7d69aa14f2..5afe787893 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/SelectItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/SelectItem.kt @@ -1,9 +1,6 @@ package eu.kanade.tachiyomi.ui.source.filter import android.view.View -import android.widget.ArrayAdapter -import android.widget.Spinner -import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractFlexibleItem @@ -11,7 +8,7 @@ import eu.davidea.flexibleadapter.items.IFlexible import eu.davidea.viewholders.FlexibleViewHolder import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.source.model.Filter -import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener +import eu.kanade.tachiyomi.widget.MaterialSpinnerView open class SelectItem(val filter: Filter.Select<*>) : AbstractFlexibleItem() { @@ -24,20 +21,13 @@ open class SelectItem(val filter: Filter.Select<*>) : AbstractFlexibleItem>, holder: Holder, position: Int, payloads: MutableList?) { - holder.text.text = filter.name + ": " - - val spinner = holder.spinner - spinner.prompt = filter.name - spinner.adapter = ArrayAdapter( - holder.itemView.context, - android.R.layout.simple_spinner_item, filter.values - ).apply { - setDropDownViewResource(R.layout.common_spinner_item) - } - spinner.onItemSelectedListener = IgnoreFirstSpinnerListener { pos -> + holder.spinnerView.title = filter.name + ": " + + holder.spinnerView.setEntries(filter.values.map { it.toString() }) + holder.spinnerView.setSelection(filter.state) + holder.spinnerView.onItemSelectedListener = { pos -> filter.state = pos } - spinner.setSelection(filter.state) } override fun equals(other: Any?): Boolean { @@ -51,8 +41,6 @@ open class SelectItem(val filter: Filter.Select<*>) : AbstractFlexibleItem>) : FlexibleViewHolder(view, adapter) { - - val text: TextView = itemView.findViewById(R.id.nav_view_item_text) - val spinner: Spinner = itemView.findViewById(R.id.nav_view_item) + val spinnerView: MaterialSpinnerView = itemView.findViewById(R.id.nav_view_item) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/SortGroup.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/SortGroup.kt index 79b68d7703..e7385044aa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/SortGroup.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/SortGroup.kt @@ -8,7 +8,7 @@ import eu.davidea.flexibleadapter.items.IFlexible import eu.davidea.flexibleadapter.items.ISectionable import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.source.model.Filter -import eu.kanade.tachiyomi.util.view.setVectorCompat +import eu.kanade.tachiyomi.util.view.setAnimVectorCompat class SortGroup(val filter: Filter.Sort) : AbstractExpandableHeaderItem>() { @@ -31,11 +31,12 @@ class SortGroup(val filter: Filter.Sort) : AbstractExpandableHeaderItem>, holder: Holder, position: Int, payloads: MutableList?) { holder.title.text = filter.name - holder.icon.setVectorCompat( - if (isExpanded) - R.drawable.ic_expand_more_24dp - else - R.drawable.ic_chevron_right_24dp + holder.icon.setAnimVectorCompat( + if (isExpanded) { + R.drawable.anim_expand_more_to_less + } else { + R.drawable.anim_expand_less_to_more + } ) holder.itemView.setOnClickListener(holder) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/TextItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/TextItem.kt index 1dfba705cf..99071fa232 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/TextItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/TextItem.kt @@ -25,11 +25,13 @@ open class TextItem(val filter: Filter.Text) : AbstractFlexibleItem>, holder: Holder, position: Int, payloads: MutableList?) { holder.wrapper.hint = filter.name holder.edit.setText(filter.state) - holder.edit.addTextChangedListener(object : SimpleTextWatcher() { - override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { - filter.state = s.toString() + holder.edit.addTextChangedListener( + object : SimpleTextWatcher() { + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + filter.state = s.toString() + } } - }) + ) } override fun equals(other: Any?): Boolean { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/TriStateItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/TriStateItem.kt index b8382a2314..e44b9bd547 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/TriStateItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/filter/TriStateItem.kt @@ -42,10 +42,11 @@ open class TriStateItem(val filter: Filter.TriState) : AbstractFlexibleItem() { private var bundle: Bundle? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - delegate.localNightMode = ThemeUtil.nightMode(preferences.theme()) - setContentView(R.layout.webview_activity) - setSupportActionBar(toolbar) + binding = WebviewActivityBinding.inflate(layoutInflater) + delegate.localNightMode = preferences.nightMode().get() + setContentView(binding.root) + setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) - toolbar.setNavigationOnClickListener { + binding.toolbar.setNavigationOnClickListener { super.onBackPressed() } - toolbar.navigationIcon?.setTint(getResourceColor(R.attr.actionBarTintColor)) + val tintColor = getResourceColor(R.attr.actionBarTintColor) + binding.toolbar.navigationIcon?.setTint(tintColor) + binding.toolbar.navigationIcon?.setTint(tintColor) + binding.toolbar.overflowIcon?.mutate() + binding.toolbar.overflowIcon?.setTint(tintColor) val container: ViewGroup = findViewById(R.id.web_view_layout) - val content: LinearLayout = findViewById(R.id.web_linear_layout) + val content: LinearLayout = binding.webLinearLayout container.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION content.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION @@ -65,8 +69,8 @@ open class BaseWebViewActivity : BaseActivity() { insets.systemWindowInsetBottom ) } - swipe_refresh.setStyle() - swipe_refresh.setOnRefreshListener { + binding.swipeRefresh.setStyle() + binding.swipeRefresh.setOnRefreshListener { refreshPage() } @@ -84,18 +88,9 @@ open class BaseWebViewActivity : BaseActivity() { window.statusBarColor = Color.BLACK else window.statusBarColor = getResourceColor(R.attr.colorPrimary)*/ window.navigationBarColor = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - val colorPrimary = getResourceColor(R.attr.colorPrimaryVariant) - if (colorPrimary == Color.WHITE) Color.BLACK - else getResourceColor(android.R.attr.colorPrimary) - } - // if the android q+ device has gesture nav, transparent nav bar - else if (v.rootWindowInsets.isBottomTappable()) { - getColor(android.R.color.transparent) + Color.BLACK } else { - ColorUtils.setAlphaComponent( - getResourceColor(R.attr.colorPrimaryVariant), - 179 - ) + getResourceColor(R.attr.colorPrimaryVariant) } v.setPadding( insets.systemWindowInsetLeft, @@ -109,41 +104,46 @@ open class BaseWebViewActivity : BaseActivity() { insets } - swipe_refresh.isEnabled = false + binding.swipeRefresh.isEnabled = false if (bundle == null) { - webview.setDefaultSettings() + binding.webview.setDefaultSettings() - webview.webChromeClient = object : WebChromeClient() { + binding.webview.webChromeClient = object : WebChromeClient() { override fun onProgressChanged(view: WebView?, newProgress: Int) { - progressBar.visible() - progressBar.progress = newProgress - if (newProgress == 100) - progressBar.invisible() + binding.progressBar.isVisible = true + binding.progressBar.progress = newProgress + if (newProgress == 100) { + binding.progressBar.isInvisible = true + } super.onProgressChanged(view, newProgress) } } - val marginB = webview.marginBottom - webview.setOnApplyWindowInsetsListener { v, insets -> + val marginB = binding.webview.marginBottom + binding.swipeRefresh.setOnApplyWindowInsetsListener { v, insets -> val bottomInset = insets.systemWindowInsetBottom +// v.updatePaddingRelative(bottom = bottomInset) v.updateLayoutParams { bottomMargin = marginB + bottomInset } insets } } else { - webview.restoreState(bundle) + bundle?.let { + binding.webview.restoreState(it) + } } } private fun refreshPage() { - swipe_refresh.isRefreshing = true - webview.reload() + binding.swipeRefresh.isRefreshing = true + binding.webview.reload() } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) val lightMode = !isInNightMode() + setTheme(getPrefTheme(preferences).styleRes) window.statusBarColor = ColorUtils.setAlphaComponent( getResourceColor( R.attr @@ -151,42 +151,61 @@ open class BaseWebViewActivity : BaseActivity() { ), 255 ) - toolbar.setBackgroundColor(getResourceColor(R.attr.colorSecondary)) - toolbar.popupTheme = if (lightMode) R.style.ThemeOverlay_MaterialComponents else R + binding.toolbar.setBackgroundColor(getResourceColor(R.attr.colorSecondary)) + binding.toolbar.popupTheme = if (lightMode) R.style.ThemeOverlay_MaterialComponents else R .style.ThemeOverlay_MaterialComponents_Dark val tintColor = getResourceColor(R.attr.actionBarTintColor) - toolbar.navigationIcon?.setTint(tintColor) - toolbar.overflowIcon?.mutate() - toolbar.setTitleTextColor(tintColor) - toolbar.overflowIcon?.setTint(tintColor) + binding.toolbar.navigationIcon?.setTint(tintColor) + binding.toolbar.overflowIcon?.mutate() + binding.toolbar.setTitleTextColor(tintColor) + binding.toolbar.overflowIcon?.setTint(tintColor) + binding.swipeRefresh.setStyle() - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) - window.navigationBarColor = getResourceColor(R.attr.colorPrimaryVariant) - else if (window.navigationBarColor != getColor(android.R.color.transparent)) - window.navigationBarColor = getResourceColor(android.R.attr.colorBackground) + window.navigationBarColor = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) getResourceColor(R.attr.colorPrimaryVariant) + else Color.BLACK - web_linear_layout.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + binding.webLinearLayout.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && lightMode) { - web_linear_layout.systemUiVisibility = web_linear_layout.systemUiVisibility.or( - View - .SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR - ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val lightNav = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + val typedValue = TypedValue() + theme.resolveAttribute( + android.R.attr.windowLightNavigationBar, + typedValue, + true + ) + typedValue.data == -1 + } else { + lightMode + } + if (lightNav) { + window.decorView.systemUiVisibility = window.decorView.systemUiVisibility.or( + View + .SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR + ) + } else { + window.decorView.systemUiVisibility = window.decorView.systemUiVisibility.rem( + View + .SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR + ) + } } + val typedValue = TypedValue() theme.resolveAttribute(android.R.attr.windowLightStatusBar, typedValue, true) - - if (typedValue.data == -1) + if (typedValue.data == -1) { window.decorView.systemUiVisibility = window.decorView.systemUiVisibility .or(View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR) - else + } else { window.decorView.systemUiVisibility = window.decorView.systemUiVisibility .rem(View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR) + } } - override fun onBackPressed() { - if (webview.canGoBack()) webview.goBack() + if (binding.webview.canGoBack()) binding.webview.goBack() else super.onBackPressed() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt index 2f8e3afde6..053b000c39 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/webview/WebViewActivity.kt @@ -25,7 +25,6 @@ import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.iconicsDrawableMedium import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.toast -import kotlinx.android.synthetic.main.webview_activity.* import uy.kohesive.injekt.injectLazy open class WebViewActivity : BaseWebViewActivity() { @@ -56,14 +55,14 @@ open class WebViewActivity : BaseWebViewActivity() { container.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION content.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - swipe_refresh.isEnabled = false + binding.swipeRefresh.isEnabled = false if (bundle == null) { val source = sourceManager.get(intent.extras!!.getLong(SOURCE_KEY)) as? HttpSource ?: return val url = intent.extras!!.getString(URL_KEY) ?: return val headers = source.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" } - webview.webViewClient = object : WebViewClientCompat() { + binding.webview.webViewClient = object : WebViewClientCompat() { override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean { view.loadUrl(url) return true @@ -73,11 +72,8 @@ open class WebViewActivity : BaseWebViewActivity() { super.onPageFinished(view, url) invalidateOptionsMenu() title = view?.title - swipe_refresh.isEnabled = true - swipe_refresh?.isRefreshing = false - val thing = view?.evaluateJavascript("getComputedStyle(document.querySelector('body')).backgroundColor") { - nested_view.setBackgroundColor(parseHTMLColor(it)) - } + binding.swipeRefresh.isEnabled = true + binding.swipeRefresh.isRefreshing = false } override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { @@ -87,12 +83,12 @@ open class WebViewActivity : BaseWebViewActivity() { override fun onPageCommitVisible(view: WebView?, url: String?) { super.onPageCommitVisible(view, url) - nested_view.scrollTo(0, 0) + binding.webview.scrollTo(0, 0) } } - webview.settings.userAgentString = source.headers["User-Agent"] - webview.loadUrl(url, headers) + binding.webview.settings.userAgentString = source.headers["User-Agent"] + binding.webview.loadUrl(url, headers) } } @@ -120,27 +116,27 @@ open class WebViewActivity : BaseWebViewActivity() { } override fun onPrepareOptionsMenu(menu: Menu?): Boolean { - val backItem = toolbar.menu.findItem(R.id.action_web_back) - val forwardItem = toolbar.menu.findItem(R.id.action_web_forward) - backItem?.isEnabled = webview.canGoBack() - forwardItem?.isEnabled = webview.canGoForward() - val hasHistory = webview.canGoBack() || webview.canGoForward() + val backItem = binding.toolbar.menu.findItem(R.id.action_web_back) + val forwardItem = binding.toolbar.menu.findItem(R.id.action_web_forward) + backItem?.isEnabled = binding.webview.canGoBack() + forwardItem?.isEnabled = binding.webview.canGoForward() + val hasHistory = binding.webview.canGoBack() || binding.webview.canGoForward() backItem?.isVisible = hasHistory forwardItem?.isVisible = hasHistory val tintColor = getResourceColor(R.attr.actionBarTintColor) val translucentWhite = ColorUtils.setAlphaComponent(tintColor, 127) - val backwardColor = if (webview.canGoBack()) tintColor else translucentWhite + val backwardColor = if (binding.webview.canGoBack()) tintColor else translucentWhite backItem?.icon = this.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_arrow_back).apply { colorInt = backwardColor } - val forwardColor = if (webview.canGoForward()) tintColor else translucentWhite + val forwardColor = if (binding.webview.canGoForward()) tintColor else translucentWhite forwardItem?.icon = this.iconicsDrawableMedium(MaterialDesignDx.Icon.gmf_arrow_forward).apply { colorInt = backwardColor } return super.onPrepareOptionsMenu(menu) } override fun onBackPressed() { - if (webview.canGoBack()) webview.goBack() + if (binding.webview.canGoBack()) binding.webview.goBack() else super.onBackPressed() } @@ -150,8 +146,8 @@ open class WebViewActivity : BaseWebViewActivity() { */ override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { - R.id.action_web_back -> webview.goBack() - R.id.action_web_forward -> webview.goForward() + R.id.action_web_back -> binding.webview.goBack() + R.id.action_web_forward -> binding.webview.goForward() R.id.action_web_share -> shareWebpage() R.id.action_web_browser -> openInBrowser() } @@ -162,7 +158,7 @@ open class WebViewActivity : BaseWebViewActivity() { try { val intent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" - putExtra(Intent.EXTRA_TEXT, webview.url) + putExtra(Intent.EXTRA_TEXT, binding.webview.url) } startActivity(Intent.createChooser(intent, getString(R.string.share))) } catch (e: Exception) { @@ -171,6 +167,6 @@ open class WebViewActivity : BaseWebViewActivity() { } private fun openInBrowser() { - openInBrowser(webview.url) + binding.webview.url?.let { openInBrowser(it) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt new file mode 100644 index 0000000000..cd5986aad3 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt @@ -0,0 +1,56 @@ +package eu.kanade.tachiyomi.util + +import android.content.Context +import android.net.Uri +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.notification.NotificationReceiver +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.util.storage.getUriCompat +import eu.kanade.tachiyomi.util.system.createFileInCacheDir +import eu.kanade.tachiyomi.util.system.notificationBuilder +import eu.kanade.tachiyomi.util.system.notificationManager +import eu.kanade.tachiyomi.util.system.toast +import java.io.IOException + +class CrashLogUtil(private val context: Context) { + + private val notificationBuilder = context.notificationBuilder(Notifications.CHANNEL_CRASH_LOGS) { + setSmallIcon(R.drawable.ic_neko_notification) + } + + fun dumpLogs() { + try { + val file = context.createFileInCacheDir("neko_crash_logs.txt") + Runtime.getRuntime().exec("logcat *:E -d -f ${file.absolutePath}") + + showNotification(file.getUriCompat(context)) + } catch (e: IOException) { + context.toast("Failed to get logs") + } + } + + private fun showNotification(uri: Uri) { + context.notificationManager.cancel(Notifications.ID_CRASH_LOGS) + + with(notificationBuilder) { + setContentTitle(context.getString(R.string.crash_log_saved)) + + // Clear old actions if they exist + clearActions() + + addAction( + R.drawable.ic_bug_report_24dp, + context.getString(R.string.open_log), + NotificationReceiver.openErrorLogPendingActivity(context, uri) + ) + + addAction( + R.drawable.ic_share_24dp, + context.getString(R.string.share), + NotificationReceiver.shareCrashLogPendingBroadcast(context, uri, Notifications.ID_CRASH_LOGS) + ) + + context.notificationManager.notify(Notifications.ID_CRASH_LOGS, build()) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt new file mode 100644 index 0000000000..fc330117f2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt @@ -0,0 +1,162 @@ +package eu.kanade.tachiyomi.util + +import android.app.Activity +import android.view.View +import com.google.android.material.snackbar.BaseTransientBottomBar +import com.google.android.material.snackbar.Snackbar +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.MangaCategory +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.category.addtolibrary.SetCategoriesSheet +import eu.kanade.tachiyomi.util.view.snack +import java.util.Date + +fun Manga.shouldDownloadNewChapters(db: DatabaseHelper, prefs: PreferencesHelper): Boolean { + if (!favorite) return false + + // Boolean to determine if user wants to automatically download new chapters. + val downloadNew = prefs.downloadNew().get() + if (!downloadNew) return false + + val categoriesToDownload = prefs.downloadNewCategories().get().map(String::toInt) + if (categoriesToDownload.isEmpty()) return true + + // Get all categories, else default category (0) + val categoriesForManga = + db.getCategoriesForManga(this).executeAsBlocking() + .mapNotNull { it.id } + .takeUnless { it.isEmpty() } ?: listOf(0) + + return categoriesForManga.intersect(categoriesToDownload).isNotEmpty() +} + +fun Manga.moveCategories( + db: DatabaseHelper, + activity: Activity, + onMangaMoved: () -> Unit +) { + val categories = db.getCategories().executeAsBlocking() + val categoriesForManga = db.getCategoriesForManga(this).executeAsBlocking() + val ids = categoriesForManga.mapNotNull { it.id }.toTypedArray() + SetCategoriesSheet( + activity, + this, + categories.toMutableList(), + ids, + false + ) { + onMangaMoved() + }.show() +} + +fun List.moveCategories( + db: DatabaseHelper, + activity: Activity, + onMangaMoved: () -> Unit +) { + if (this.isEmpty()) return + val commonCategories = this + .map { db.getCategoriesForManga(it).executeAsBlocking() } + .reduce { set1: Iterable, set2 -> set1.intersect(set2).toMutableList() } + .mapNotNull { it.id } + .toTypedArray() + val categories = db.getCategories().executeAsBlocking() + SetCategoriesSheet( + activity, + this, + categories.toMutableList(), + commonCategories, + false + ) { + onMangaMoved() + }.show() +} + +fun Manga.addOrRemoveToFavorites( + db: DatabaseHelper, + preferences: PreferencesHelper, + view: View, + activity: Activity, + onMangaAdded: () -> Unit, + onMangaMoved: () -> Unit, + onMangaDeleted: () -> Unit +): Snackbar? { + if (!favorite) { + val categories = db.getCategories().executeAsBlocking() + val defaultCategoryId = preferences.defaultCategory() + val defaultCategory = categories.find { it.id == defaultCategoryId } + when { + defaultCategory != null -> { + favorite = true + date_added = Date().time + db.insertManga(this).executeAsBlocking() + val mc = MangaCategory.create(this, defaultCategory) + db.setMangaCategories(listOf(mc), listOf(this)) + onMangaMoved() + return view.snack(activity.getString(R.string.added_to_, defaultCategory.name)) { + setAction(R.string.change) { + moveCategories(db, activity, onMangaMoved) + } + } + } + defaultCategoryId == 0 || categories.isEmpty() -> { // 'Default' or no category + favorite = true + date_added = Date().time + db.insertManga(this).executeAsBlocking() + db.setMangaCategories(emptyList(), listOf(this)) + onMangaMoved() + return if (categories.isNotEmpty()) { + view.snack(activity.getString(R.string.added_to_, activity.getString(R.string.default_value))) { + setAction(R.string.change) { + moveCategories(db, activity, onMangaMoved) + } + } + } else { + view.snack(R.string.added_to_library) + } + } + else -> { + val categoriesForManga = db.getCategoriesForManga(this).executeAsBlocking() + val ids = categoriesForManga.mapNotNull { it.id }.toTypedArray() + + SetCategoriesSheet( + activity, + this, + categories.toMutableList(), + ids, + true + ) { + onMangaAdded() + }.show() + } + } + } else { + val lastAddedDate = date_added + favorite = false + date_added = 0 + db.insertManga(this).executeAsBlocking() + onMangaMoved() + return view.snack(view.context.getString(R.string.removed_from_library), Snackbar.LENGTH_INDEFINITE) { + setAction(R.string.undo) { + favorite = true + date_added = lastAddedDate + db.insertManga(this@addOrRemoveToFavorites).executeAsBlocking() + onMangaMoved() + } + addCallback( + object : BaseTransientBottomBar.BaseCallback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + super.onDismissed(transientBottomBar, event) + if (!favorite) { + onMangaDeleted() + } + } + } + ) + } + } + return null +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/PreferenceExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/PreferenceExtensions.kt index 0148d0b1cd..55aa6902e8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/PreferenceExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/PreferenceExtensions.kt @@ -1,6 +1,14 @@ package eu.kanade.tachiyomi.util import android.content.SharedPreferences +import android.widget.CompoundButton +import android.widget.RadioButton +import android.widget.RadioGroup +import android.widget.Spinner +import androidx.annotation.ArrayRes +import com.f2prateek.rx.preferences.Preference +import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose @@ -40,3 +48,79 @@ inline fun SharedPreferences.getItem(key: String, default: T): T { else -> throw IllegalArgumentException("Generic type not handled: ${T::class.java.name}") } } + +/** + * Binds a checkbox or switch view with a boolean preference. + */ +fun CompoundButton.bindToPreference(pref: Preference, block: (() -> Unit)? = null) { + isChecked = pref.getOrDefault() + setOnCheckedChangeListener { _, isChecked -> + pref.set(isChecked) + block?.invoke() + } +} + +/** + * Binds a checkbox or switch view with a boolean preference. + */ +fun CompoundButton.bindToPreference( + pref: com.tfcporciuncula.flow + .Preference, + block: ((Boolean) -> Unit)? = null +) { + isChecked = pref.get() + setOnCheckedChangeListener { _, isChecked -> + pref.set(isChecked) + block?.invoke(isChecked) + } +} + +/** + * Binds a radio group with a int preference. + */ +fun RadioGroup.bindToPreference(pref: Preference, block: (() -> Unit)? = null) { + (getChildAt(pref.getOrDefault()) as RadioButton).isChecked = true + setOnCheckedChangeListener { _, checkedId -> + val index = indexOfChild(findViewById(checkedId)) + pref.set(index) + block?.invoke() + } +} + +/** + * Binds a radio group with a int preference. + */ +fun RadioGroup.bindToPreference(pref: com.tfcporciuncula.flow.Preference, block: (() -> Unit)? = null) { + (getChildAt(pref.get()) as RadioButton).isChecked = true + setOnCheckedChangeListener { _, checkedId -> + val index = indexOfChild(findViewById(checkedId)) + pref.set(index) + block?.invoke() + } +} + +/** + * Binds a spinner to an int preference with an optional offset for the value. + */ +fun Spinner.bindToPreference( + pref: com.tfcporciuncula.flow.Preference, + offset: Int = 0 +) { + onItemSelectedListener = IgnoreFirstSpinnerListener { position -> + pref.set(position + offset) + } + setSelection(pref.get() - offset, false) +} + +/** + * Binds a spinner to an int preference. The position of the spinner item must + * correlate with the [intValues] resource item (in arrays.xml), which is a + * of int values that will be parsed here and applied to the preference. + */ +fun Spinner.bindToIntPreference(pref: com.tfcporciuncula.flow.Preference, @ArrayRes intValuesResource: Int) { + val intValues = resources.getStringArray(intValuesResource).map { it.toIntOrNull() } + onItemSelectedListener = IgnoreFirstSpinnerListener { position -> + pref.set(intValues[position] ?: 0) + } + setSelection(intValues.indexOf(pref.get()), false) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterRecognition.kt b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterRecognition.kt index ccaa005c3e..cf1045bbff 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterRecognition.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterRecognition.kt @@ -39,8 +39,9 @@ object ChapterRecognition { fun parseChapterNumber(chapter: SChapter, manga: SManga) { // If chapter number is known return. - if (chapter.chapter_number == -2f || chapter.chapter_number > -1f) + if (chapter.chapter_number == -2f || chapter.chapter_number > -1f) { return + } // Get chapter title with lower case var name = chapter.name.toLowerCase() @@ -66,8 +67,9 @@ object ChapterRecognition { } // Check base case ch.xx - if (updateChapter(basic.find(name), chapter)) + if (updateChapter(basic.find(name), chapter)) { return + } // Check one number occurrence. val occurrences: MutableList = arrayListOf() @@ -76,20 +78,23 @@ object ChapterRecognition { } if (occurrences.size == 1) { - if (updateChapter(occurrences[0], chapter)) + if (updateChapter(occurrences[0], chapter)) { return + } } // Remove manga title from chapter title. val nameWithoutManga = name.replace(manga.originalTitle.toLowerCase(), "").trim() // Check if first value is number after title remove. - if (updateChapter(withoutManga.find(nameWithoutManga), chapter)) + if (updateChapter(withoutManga.find(nameWithoutManga), chapter)) { return + } // Take the first number encountered. - if (updateChapter(occurrence.find(nameWithoutManga), chapter)) + if (updateChapter(occurrence.find(nameWithoutManga), chapter)) { return + } } /** @@ -117,18 +122,22 @@ object ChapterRecognition { * @return decimal/alpha float value */ private fun checkForDecimal(decimal: String?, alpha: String?): Float { - if (!decimal.isNullOrEmpty()) + if (!decimal.isNullOrEmpty()) { return decimal.toFloat() + } if (!alpha.isNullOrEmpty()) { - if (alpha.contains("extra")) + if (alpha.contains("extra")) { return .99f + } - if (alpha.contains("omake")) + if (alpha.contains("omake")) { return .98f + } - if (alpha.contains("special")) + if (alpha.contains("special")) { return .97f + } if (alpha[0] == '.') { // Take value after (.) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSourceSync.kt b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSourceSync.kt index ca452854fe..6ddc9b1b8a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSourceSync.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSourceSync.kt @@ -29,12 +29,11 @@ fun syncChaptersWithSource( manga: Manga, errorFromMerged: Boolean = false ): Pair, List> { - val downloadManager: DownloadManager = Injekt.get() val preferences: PreferencesHelper = Injekt.get() // Chapters from db. val dbChapters = db.getChapters(manga).executeAsBlocking().filterIfUsingCache(downloadManager, manga, preferences.useCacheSource()) - //no need to handle cache in dedupe because rawsource already has the correct chapters + // no need to handle cache in dedupe because rawsource already has the correct chapters val dedupedChapters = deduplicateChapters(rawSourceChapters, manga) val sourceChapters = dedupedChapters.mapIndexed { i, sChapter -> @@ -68,7 +67,6 @@ fun syncChaptersWithSource( if (dbChapter == null) { toAdd.add(sourceChapter) } else { - ChapterRecognition.parseChapterNumber(sourceChapter, manga) if (shouldUpdateDbChapter(dbChapter, sourceChapter)) { @@ -124,7 +122,7 @@ fun syncChaptersWithSource( // Recalculate update rate if unset and enough chapters are present if (manga.next_update == 0L && topChapters.size > 1) { - var delta = 0L; + var delta = 0L for (i in 0 until topChapters.size - 1) { delta += (topChapters[i].date_upload - topChapters[i + 1].date_upload) } @@ -183,7 +181,7 @@ fun syncChaptersWithSource( val topChapters = db.getChapters(manga).executeAsBlocking().filterIfUsingCache(downloadManager, manga, preferences.useCacheSource()).sortedByDescending { it.date_upload }.take(4) // Recalculate next update since chapters were changed if (topChapters.size > 1) { - var delta = 0L; + var delta = 0L for (i in 0 until topChapters.size - 1) { delta += (topChapters[i].date_upload - topChapters[i + 1].date_upload) } @@ -196,8 +194,9 @@ fun syncChaptersWithSource( val newestChapter = topChapters.getOrNull(0) val dateFetch = newestChapter?.date_upload ?: manga.last_update if (dateFetch == 0L) { - if (toAdd.isNotEmpty()) + if (toAdd.isNotEmpty()) { manga.last_update = Date().time + } } else manga.last_update = dateFetch db.updateLastUpdated(manga).executeAsBlocking() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterUtil.kt index 5b56f55d8f..6e29a03a9f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterUtil.kt @@ -10,14 +10,12 @@ import eu.kanade.tachiyomi.ui.manga.chapter.ChapterItem import eu.kanade.tachiyomi.util.system.contextCompatColor import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.dpToPxEnd +import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.timeSpanFromNow class ChapterUtil { companion object { - private val volumeRegex = Regex("""(vol|volume)\.? *([0-9]+)?""", RegexOption.IGNORE_CASE) - private val seasonRegex = Regex("""(Season |S)([0-9]+)?""") - fun relativeDate(chapter: Chapter): String? { return when (chapter.date_upload > 0) { true -> chapter.date_upload.timeSpanFromNow @@ -42,11 +40,16 @@ class ChapterUtil { if (chapter.bookmark) { val context = textView.context val drawable = VectorDrawableCompat.create( - textView.resources, R.drawable.ic_bookmark_24dp, context.theme + textView.resources, + R.drawable.ic_bookmark_24dp, + context.theme ) drawable?.setBounds(0, 0, textView.textSize.toInt(), textView.textSize.toInt()) textView.setCompoundDrawablesRelative( - drawable, null, null, null + drawable, + null, + null, + null ) textView.compoundDrawableTintList = ColorStateList.valueOf( bookmarkedColor(context) @@ -81,10 +84,39 @@ class ChapterUtil { } } + private fun readColor(context: Context): Int = context.contextCompatColor(R.color.read_chapter) + + private fun unreadColor(context: Context): Int = context.getResourceColor(android.R.attr.textColorPrimary) + + private fun bookmarkedColor(context: Context): Int = context.getResourceColor(android.R.attr.colorAccent) + + private val volumeRegex = Regex("""(vol|volume)\.? *([0-9]+)?""", RegexOption.IGNORE_CASE) + private val seasonRegex = Regex("""(Season |S)([0-9]+)?""") + + fun getGroupNumber(chapter: Chapter): Int? { + val groups = volumeRegex.find(chapter.name)?.groups + if (groups != null) return groups[2]?.value?.toIntOrNull() + val seasonGroups = seasonRegex.find(chapter.name)?.groups + if (seasonGroups != null) return seasonGroups[2]?.value?.toIntOrNull() + return null + } + + private fun getVolumeNumber(chapter: Chapter): Int? { + val groups = volumeRegex.find(chapter.name)?.groups + if (groups != null) return groups[2]?.value?.toIntOrNull() + return null + } + + private fun getSeasonNumber(chapter: Chapter): Int? { + val groups = seasonRegex.find(chapter.name)?.groups + if (groups != null) return groups[2]?.value?.toIntOrNull() + return null + } + fun hasMultipleVolumes(chapters: List): Boolean { val volumeSet = mutableSetOf() chapters.forEach { - val volNum = ChapterUtil.getVolumeNumber(it) + val volNum = getVolumeNumber(it) if (volNum != null) { volumeSet.add(volNum) if (volumeSet.size >= 2) return true @@ -96,7 +128,7 @@ class ChapterUtil { fun hasMultipleSeasons(chapters: List): Boolean { val volumeSet = mutableSetOf() chapters.forEach { - val volNum = ChapterUtil.getSeasonNumber(it) + val volNum = getSeasonNumber(it) if (volNum != null) { volumeSet.add(volNum) if (volumeSet.size >= 2) return true @@ -108,31 +140,5 @@ class ChapterUtil { fun hasTensOfChapters(chapters: List): Boolean { return chapters.size > 20 } - - fun getGroupNumber(chapter: ChapterItem): Int? { - val groups = volumeRegex.find(chapter.name)?.groups - if (groups != null) return groups[2]?.value?.toIntOrNull() - val seasonGroups = seasonRegex.find(chapter.name)?.groups - if (seasonGroups != null) return seasonGroups[2]?.value?.toIntOrNull() - return null - } - - private fun getVolumeNumber(chapter: Chapter): Int? { - val groups = volumeRegex.find(chapter.name)?.groups - if (groups != null) return groups[2]?.value?.toIntOrNull() - return null - } - - private fun getSeasonNumber(chapter: Chapter): Int? { - val groups = seasonRegex.find(chapter.name)?.groups - if (groups != null) return groups[2]?.value?.toIntOrNull() - return null - } - - private fun readColor(context: Context): Int = context.contextCompatColor(R.color.read_chapter) - - private fun unreadColor(context: Context): Int = context.contextCompatColor(R.color.unread_chapter) - - private fun bookmarkedColor(context: Context): Int = context.contextCompatColor(R.color.bookmarked_chapter) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/NoChaptersException.kt b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/NoChaptersException.kt new file mode 100644 index 0000000000..1801792481 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/NoChaptersException.kt @@ -0,0 +1,3 @@ +package eu.kanade.tachiyomi.util.chapter + +class NoChaptersException : Exception() diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/lang/EnumExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/EnumExtensions.kt new file mode 100644 index 0000000000..4c9432303f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/EnumExtensions.kt @@ -0,0 +1,7 @@ +package eu.kanade.tachiyomi.util.lang + +inline fun > T.next(): T { + val values = enumValues() + val nextOrdinal = (ordinal + 1) % values.size + return values[nextOrdinal] +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/lang/RxCoroutineBridge.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/RxCoroutineBridge.kt index ce760e03d5..1e4dce8063 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/lang/RxCoroutineBridge.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/RxCoroutineBridge.kt @@ -41,7 +41,7 @@ private suspend fun Observable.awaitOne(): T = suspendCancellableCoroutin } override fun onError(e: Throwable) { - /* + /* * Rx1 observable throws NoSuchElementException if cancellation happened before * element emission. To mitigate this we try to atomically resume continuation with exception: * if resume failed, then we know that continuation successfully cancelled itself diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt index e42b468a8e..8b7bdcaa34 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt @@ -1,16 +1,52 @@ package eu.kanade.tachiyomi.util.lang +import android.content.Context +import android.graphics.Typeface +import android.text.Spannable +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.BackgroundColorSpan +import android.text.style.ForegroundColorSpan +import android.text.style.RelativeSizeSpan +import android.text.style.StyleSpan +import android.text.style.SuperscriptSpan +import androidx.annotation.ColorInt +import androidx.annotation.StringRes +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.system.getResourceColor import kotlin.math.floor /** * Replaces the given string to have at most [count] characters using [replacement] at its end. * If [replacement] is longer than [count] an exception will be thrown when `length > count`. */ -fun String.chop(count: Int, replacement: String = "..."): String { - return if (length > count) +fun String.chop(count: Int, replacement: String = "…"): String { + return if (length > count) { take(count - replacement.length) + replacement - else + } else { this + } +} + +fun String.chopByWords(count: Int): String { + return if (length > count) { + val splitWords = split(" ") + val iterator = splitWords.iterator() + var newString = iterator.next() + return if (newString.length > count) { + chop(count) + } else { + var next = iterator.next() + while ("$newString $next".length <= count) { + newString = "$newString $next" + next = iterator.next() + } + newString + } + } else { + this + } } fun String.removeArticles(): String { @@ -35,8 +71,9 @@ fun String.trimOrNull(): String? { * If [replacement] is longer than [count] an exception will be thrown when `length > count`. */ fun String.truncateCenter(count: Int, replacement: String = "..."): String { - if (length <= count) + if (length <= count) { return this + } val pieceLength: Int = floor((count - replacement.length).div(2.0)).toInt() @@ -55,6 +92,59 @@ fun String.compareToCaseInsensitiveNaturalOrder(other: String): Int { return String.CASE_INSENSITIVE_ORDER.then(naturalOrder()).compare(this, other) } +fun CharSequence.tintText(@ColorInt color: Int): Spanned { + val s = SpannableString(this) + s.setSpan(ForegroundColorSpan(color), 0, this.length, 0) + return s +} + +fun String.highlightText(highlight: String, @ColorInt color: Int): Spanned { + val wordToSpan: Spannable = SpannableString(this) + if (highlight.isBlank()) return wordToSpan + indexesOf(highlight).forEach { + wordToSpan.setSpan(BackgroundColorSpan(color), it, it + highlight.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + return wordToSpan +} + +fun String.indexesOf(substr: String, ignoreCase: Boolean = true): List { + val list = mutableListOf() + if (substr.isBlank()) return list + + var i = -1 + while (true) { + i = indexOf(substr, i + 1, ignoreCase) + when (i) { + -1 -> return list + else -> list.add(i) + } + } +} + +fun String.withSubtitle(context: Context, @StringRes subtitleRes: Int) = + withSubtitle(context, context.getString(subtitleRes)) + +fun String.withSubtitle(context: Context, subtitle: String): Spanned { + val spannable = SpannableStringBuilder(this + "\n" + subtitle) + spannable.setSpan( + ForegroundColorSpan(context.getResourceColor(android.R.attr.textColorSecondary)), + this.length + 1, + spannable.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + return spannable +} + +fun String.addBetaTag(context: Context): Spanned { + val betaText = context.getString(R.string.beta) + val betaSpan = SpannableStringBuilder(this + betaText) + betaSpan.setSpan(SuperscriptSpan(), length, length + betaText.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + betaSpan.setSpan(RelativeSizeSpan(0.75f), length, length + betaText.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + betaSpan.setSpan(StyleSpan(Typeface.BOLD), length, length + betaText.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + betaSpan.setSpan(ForegroundColorSpan(context.getResourceColor(R.attr.colorAccent)), length, length + betaText.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + return betaSpan +} + private val uuidFormatLines = arrayOf(8, 13, 18, 23) private val uuidFormatDigits = arrayOf((0..7), (9..12), (14..17), (19..22), (24..35)) @@ -62,10 +152,10 @@ private val uuidFormatDigits = arrayOf((0..7), (9..12), (14..17), (19..22), (24. * Check if a string is in UUID format. */ fun String.isUUID() = - this.length == 36 - && uuidFormatLines.all { idx -> this[idx] == '-' } - && uuidFormatDigits.all { range -> - range.all { idx -> - this[idx].let { char -> char in '0'..'9' || char in 'a'..'f' || char in 'A'..'F' } + this.length == 36 && + uuidFormatLines.all { idx -> this[idx] == '-' } && + uuidFormatDigits.all { range -> + range.all { idx -> + this[idx].let { char -> char in '0'..'9' || char in 'a'..'f' || char in 'A'..'F' } + } } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/manga/MangaShortcutManager.kt b/app/src/main/java/eu/kanade/tachiyomi/util/manga/MangaShortcutManager.kt new file mode 100644 index 0000000000..0d16af37b0 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/manga/MangaShortcutManager.kt @@ -0,0 +1,134 @@ +package eu.kanade.tachiyomi.util.manga + +import android.content.Context +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Icon +import coil.Coil +import coil.request.ImageRequest +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.cache.CoverCache +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.ui.main.SearchActivity +import eu.kanade.tachiyomi.ui.recents.RecentsPresenter +import eu.kanade.tachiyomi.ui.source.browse.BrowseSourceController +import eu.kanade.tachiyomi.util.system.launchIO +import kotlinx.coroutines.GlobalScope +import timber.log.Timber +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import kotlin.math.min + +class MangaShortcutManager( + val preferences: PreferencesHelper = Injekt.get(), + val db: DatabaseHelper = Injekt.get(), + val coverCache: CoverCache = Injekt.get(), + val sourceManager: SourceManager = Injekt.get() +) { + + val context: Context = preferences.context + fun updateShortcuts() { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N_MR1) { + if (!preferences.appShortcuts()) { + val shortcutManager = context.getSystemService(ShortcutManager::class.java) + shortcutManager.removeAllDynamicShortcuts() + return + } + GlobalScope.launchIO { + val shortcutManager = context.getSystemService(ShortcutManager::class.java) + + val recentManga = RecentsPresenter.getRecentManga() + val recentSources = preferences.lastUsedSources().get().mapNotNull { + val splitS = it.split(":") + splitS.first().toLongOrNull()?.let { id -> + sourceManager.getMangadex() to splitS[1].toLong() + } + } + val recents = + (recentManga.take(shortcutManager.maxShortcutCountPerActivity) + recentSources) + .sortedByDescending { it.second } + .map { it.first } + .take(shortcutManager.maxShortcutCountPerActivity) + + val shortcuts = recents.mapNotNull { item -> + when (item) { + is Manga -> { + val request = ImageRequest.Builder(context).data(item).build() + val bitmap = ( + Coil.imageLoader(context) + .execute(request).drawable as? BitmapDrawable + )?.bitmap + + ShortcutInfo.Builder( + context, + "Manga-${item.id?.toString() ?: item.title}" + ) + .setShortLabel(item.title.takeUnless { it.isBlank() } ?: context.getString(R.string.manga)) + .setLongLabel(item.title.takeUnless { it.isBlank() } ?: context.getString(R.string.manga)) + .setIcon( + if (bitmap != null) if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + Icon.createWithAdaptiveBitmap(bitmap.toSquare()) + } else { + Icon.createWithBitmap(bitmap) + } + else Icon.createWithResource(context, R.drawable.ic_book_24dp) + ) + .setIntent( + SearchActivity.openMangaIntent(context, item.id, true) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + ) + .build() + } + is Source -> { + val bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.ic_tracker_mangadex_logo) + + ShortcutInfo.Builder(context, "Source-${item.id}") + .setShortLabel(item.name) + .setLongLabel(item.name) + .setIcon( + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + Icon.createWithAdaptiveBitmap(bitmap.toSquare()) + } else { + Icon.createWithBitmap(bitmap) + } + ) + .setIntent( + Intent( + context, + SearchActivity::class.java + ).setAction(MainActivity.SHORTCUT_SOURCE) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + .putExtra(BrowseSourceController.SOURCE_ID_KEY, item.id) + ) + .build() + } + else -> { + null + } + } + } + Timber.d("Shortcuts: ${shortcuts.joinToString(", ") { it.longLabel ?: "n/a" }}") + shortcutManager.dynamicShortcuts = shortcuts + } + } + } + + private fun Bitmap.toSquare(): Bitmap? { + val side = min(width, height) + + val xOffset = (width - side) / 2 + // Slight offset for the y, since a lil bit under the top is usually the focus of covers + val yOffset = ((height - side) / 2 * 0.25).toInt() + + return Bitmap.createBitmap(this, xOffset, yOffset, side, side) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt index b6a0346d71..ca2e0c1f4a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Environment +import android.os.StatFs import androidx.core.content.ContextCompat import androidx.core.os.EnvironmentCompat import com.hippo.unifile.UniFile @@ -30,6 +31,18 @@ object DiskUtil { return size } + /** + * Gets the available space for the disk that a file path points to, in bytes. + */ + fun getAvailableStorageSpace(f: UniFile): Long { + return try { + val stat = StatFs(f.uri.path) + stat.availableBlocksLong * stat.blockSizeLong + } catch (_: Exception) { + -1L + } + } + /** * Returns the root folders of all the available external storages. */ @@ -87,7 +100,7 @@ object DiskUtil { */ fun buildValidFilename(origName: String, suffix: String = ""): String { val name = origName.trim('.', ' ') - if (name.isNullOrEmpty()) { + if (name.isEmpty()) { return "(invalid)" } val sb = StringBuilder(name.length) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt b/app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt index a6442e6faa..9a8b24be8e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/storage/EpubFile.kt @@ -1,10 +1,15 @@ package eu.kanade.tachiyomi.util.storage +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga import org.jsoup.Jsoup import org.jsoup.nodes.Document import java.io.Closeable import java.io.File import java.io.InputStream +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale import java.util.zip.ZipEntry import java.util.zip.ZipFile @@ -18,6 +23,11 @@ class EpubFile(file: File) : Closeable { */ private val zip = ZipFile(file) + /** + * Path separator used by this epub. + */ + private val pathSeparator = getPathSeparator() + /** * Closes the underlying zip file. */ @@ -39,23 +49,73 @@ class EpubFile(file: File) : Closeable { return zip.getEntry(name) } + /** + * Fills manga metadata using this epub file's metadata. + */ + fun fillMangaMetadata(manga: SManga) { + val ref = getPackageHref() + val doc = getPackageDocument(ref) + + val creator = doc.getElementsByTag("dc:creator").first() + val description = doc.getElementsByTag("dc:description").first() + + manga.author = creator?.text() + manga.description = description?.text() + } + + /** + * Fills chapter metadata using this epub file's metadata. + */ + fun fillChapterMetadata(chapter: SChapter) { + val ref = getPackageHref() + val doc = getPackageDocument(ref) + + val title = doc.getElementsByTag("dc:title").first() + val publisher = doc.getElementsByTag("dc:publisher").first() + val creator = doc.getElementsByTag("dc:creator").first() + var date = doc.getElementsByTag("dc:date").first() + if (date == null) { + date = doc.select("meta[property=dcterms:modified]").first() + } + + if (title != null) { + chapter.name = title.text() + } + + if (publisher != null) { + chapter.scanlator = publisher.text() + } else if (creator != null) { + chapter.scanlator = creator.text() + } + + if (date != null) { + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()) + try { + val parsedDate = dateFormat.parse(date.text()) + if (parsedDate != null) { + chapter.date_upload = parsedDate.time + } + } catch (e: ParseException) { + // Empty + } + } + } + /** * Returns the path of all the images found in the epub file. */ fun getImagesFromPages(): List { - val allEntries = zip.entries().toList() val ref = getPackageHref() val doc = getPackageDocument(ref) val pages = getPagesFromDocument(doc) - val hrefs = getHrefMap(ref, allEntries.map { it.name }) - return getImagesFromPages(pages, hrefs) + return getImagesFromPages(pages, ref) } /** * Returns the path to the package document. */ private fun getPackageHref(): String { - val meta = zip.getEntry("META-INF/container.xml") + val meta = zip.getEntry(resolveZipPath("META-INF", "container.xml")) if (meta != null) { val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") } val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path") @@ -63,7 +123,7 @@ class EpubFile(file: File) : Closeable { return path } } - return "OEBPS/content.opf" + return resolveZipPath("OEBPS", "content.opf") } /** @@ -89,28 +149,67 @@ class EpubFile(file: File) : Closeable { /** * Returns all the images contained in every page from the epub. */ - private fun getImagesFromPages(pages: List, hrefs: Map): List { - return pages.map { page -> - val entry = zip.getEntry(hrefs[page]) + private fun getImagesFromPages(pages: List, packageHref: String): List { + val result = mutableListOf() + val basePath = getParentDirectory(packageHref) + pages.forEach { page -> + val entryPath = resolveZipPath(basePath, page) + val entry = zip.getEntry(entryPath) val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") } - document.getElementsByTag("img").mapNotNull { hrefs[it.attr("src")] } - }.flatten() + val imageBasePath = getParentDirectory(entryPath) + + document.allElements.forEach { + if (it.tagName() == "img") { + result.add(resolveZipPath(imageBasePath, it.attr("src"))) + } else if (it.tagName() == "image") { + result.add(resolveZipPath(imageBasePath, it.attr("xlink:href"))) + } + } + } + + return result } /** - * Returns a map with a relative url as key and abolute url as path. + * Returns the path separator used by the epub file. */ - private fun getHrefMap(packageHref: String, entries: List): Map { - val lastSlashPos = packageHref.lastIndexOf('/') - if (lastSlashPos < 0) { - return entries.associateBy { it } + private fun getPathSeparator(): String { + val meta = zip.getEntry("META-INF\\container.xml") + return if (meta != null) { + "\\" + } else { + "/" } - return entries.associateBy { entry -> - if (entry.isNotBlank() && entry.length > lastSlashPos) { - entry.substring(lastSlashPos + 1) - } else { - entry - } + } + + /** + * Resolves a zip path from base and relative components and a path separator. + */ + private fun resolveZipPath(basePath: String, relativePath: String): String { + if (relativePath.startsWith(pathSeparator)) { + // Path is absolute, so return as-is. + return relativePath + } + + var fixedBasePath = basePath.replace(pathSeparator, File.separator) + if (!fixedBasePath.startsWith(File.separator)) { + fixedBasePath = "${File.separator}$fixedBasePath" + } + + val fixedRelativePath = relativePath.replace(pathSeparator, File.separator) + val resolvedPath = File(fixedBasePath, fixedRelativePath).canonicalPath + return resolvedPath.replace(File.separator, pathSeparator).substring(1) + } + + /** + * Gets the parent directory of a path. + */ + private fun getParentDirectory(path: String): String { + val separatorIndex = path.lastIndexOf(pathSeparator) + return if (separatorIndex >= 0) { + path.substring(0, separatorIndex) + } else { + "" } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/storage/FileExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/storage/FileExtensions.kt index 53523c6d4c..8482bd8f6f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/storage/FileExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/storage/FileExtensions.kt @@ -13,7 +13,7 @@ import java.io.File * @param context context of application */ fun File.getUriCompat(context: Context): Uri { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", this) - else Uri.fromFile(this) + } else Uri.fromFile(this) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/BooleanExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/BooleanExtensions.kt new file mode 100644 index 0000000000..aae8f3caab --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/BooleanExtensions.kt @@ -0,0 +1,3 @@ +package eu.kanade.tachiyomi.util.system + +fun Boolean.toInt() = if (this) 1 else 0 diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt index 17a1b4834a..73a332b0d7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt @@ -154,6 +154,8 @@ val Float.dpToPxEnd: Float val Resources.isLTR get() = configuration.layoutDirection == View.LAYOUT_DIRECTION_LTR +fun Context.isTablet() = resources.getBoolean(R.bool.isTablet) + /** * Helper method to create a notification builder. * @@ -289,7 +291,7 @@ fun Context.openInBrowser(url: String, forceBrowser: Boolean): Boolean { ) .build() if (forceBrowser) { - val packages = getCustomTabsPackages().maxBy { it.preferredOrder } + val packages = getCustomTabsPackages().maxByOrNull { it.preferredOrder } val processName = packages?.activityInfo?.processName ?: return false intent.intent.`package` = processName } @@ -369,10 +371,10 @@ fun Context.iconicsDrawableLarge(icon: IIcon, size: Int = 24, color: Int = R.att return this.iconicsDrawable(icon, size, color, attributeColor) } +/** + * default tinted to actionbar + */ @SuppressLint("ResourceType") - /** - * default tinted to actionbar - */ fun Context.iconicsDrawableMedium(icon: IIcon, size: Int = 18, color: Int = R.attr.actionBarTintColor, attributeColor: Boolean = true): IconicsDrawable { return this.iconicsDrawable(icon, size, color, attributeColor) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/CoroutinesExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/CoroutinesExtensions.kt index c019f99898..08f335668f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/CoroutinesExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/CoroutinesExtensions.kt @@ -11,12 +11,20 @@ import kotlinx.coroutines.withContext fun launchUI(block: suspend CoroutineScope.() -> Unit): Job = GlobalScope.launch(Dispatchers.Main, CoroutineStart.DEFAULT, block) +fun launchIO(block: suspend CoroutineScope.() -> Unit): Job = + GlobalScope.launch(Dispatchers.IO, CoroutineStart.DEFAULT, block) + fun launchNow(block: suspend CoroutineScope.() -> Unit): Job = GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED, block) fun CoroutineScope.launchIO(block: suspend CoroutineScope.() -> Unit): Job = launch(Dispatchers.IO, block = block) +fun CoroutineScope.launchUI(block: suspend CoroutineScope.() -> Unit): Job = + launch(Dispatchers.Main, block = block) + suspend fun withUIContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block) suspend fun withIOContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.IO, block) + +suspend fun withDefContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Default, block) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/CrashLogUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/CrashLogUtil.kt index 5fc3207b93..568e6e7129 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/CrashLogUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/CrashLogUtil.kt @@ -45,4 +45,4 @@ class CrashLogUtil(private val context: Context) { context.notificationManager.notify(Notifications.ID_CRASH_LOGS, build()) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/DateExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/DateExtensions.kt index 7869a8bb6a..1dc1fe50bd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/DateExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/DateExtensions.kt @@ -1,6 +1,17 @@ package eu.kanade.tachiyomi.util.system +import android.content.Context import android.text.format.DateUtils +import eu.kanade.tachiyomi.R +import java.util.Locale val Long.timeSpanFromNow: String get() = DateUtils.getRelativeTimeSpanString(this).toString() + +fun Long.timeSpanFromNow(context: Context): String { + return if (this == 0L) { + context.getString(R.string.a_while_ago).lowercase(Locale.ROOT) + } else { + DateUtils.getRelativeTimeSpanString(this).toString() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt index caf0fd02dc..a9759e96cb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt @@ -1,15 +1,22 @@ package eu.kanade.tachiyomi.util.system import android.content.Context +import android.content.res.Configuration import android.graphics.Bitmap +import android.graphics.Canvas import android.graphics.Color +import android.graphics.Rect import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable +import androidx.annotation.ColorInt import eu.kanade.tachiyomi.R +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.io.InputStream import java.net.URLConnection import kotlin.math.abs +import kotlin.math.max object ImageUtil { @@ -37,8 +44,9 @@ object ImageUtil { stream.read(bytes, 0, bytes.size) } - if (length == -1) + if (length == -1) { return null + } if (bytes.compareWith(charByteArrayOf(0xFF, 0xD8, 0xFF))) { return ImageType.JPG @@ -58,11 +66,13 @@ object ImageUtil { } fun autoSetBackground(image: Bitmap?, alwaysUseWhite: Boolean, context: Context): Drawable { - val backgroundColor = if (alwaysUseWhite) Color.WHITE else + val backgroundColor = if (alwaysUseWhite) Color.WHITE else { context.getResourceColor(R.attr.readerBackground) + } if (image == null) return ColorDrawable(backgroundColor) - if (image.width < 50 || image.height < 50) + if (image.width < 50 || image.height < 50) { return ColorDrawable(backgroundColor) + } val top = 5 val bot = image.height - 5 val left = (image.width * 0.0275).toInt() @@ -88,15 +98,17 @@ object ImageUtil { !isWhite(image.getPixel(right, bot)) && pixelIsClose(image.getPixel(right, bot), image.getPixel(midX, bot)) && !isWhite(image.getPixel(midX, bot)) && pixelIsClose(image.getPixel(midX, bot), image.getPixel(left, bot)) && !isWhite(image.getPixel(left, bot)) && pixelIsClose(image.getPixel(left, bot), image.getPixel(left, top)) - ) + ) { return ColorDrawable(image.getPixel(left, top)) + } if (isWhite(image.getPixel(left, top)).toInt() + isWhite(image.getPixel(right, top)).toInt() + isWhite(image.getPixel(left, bot)).toInt() + isWhite(image.getPixel(right, bot)).toInt() > 2 - ) + ) { darkBG = false + } var blackPixel = when { topLeftIsDark -> image.getPixel(left, top) @@ -126,52 +138,61 @@ object ImageUtil { if (isWhite(pixel)) { whitePixelsStreak++ whitePixels++ - if (notOffset) + if (notOffset) { overallWhitePixels++ - if (whitePixelsStreak > 14) + } + if (whitePixelsStreak > 14) { whiteStrak = true - if (whitePixelsStreak > 6 && whitePixelsStreak >= index - 1) + } + if (whitePixelsStreak > 6 && whitePixelsStreak >= index - 1) { topWhiteStreak = whitePixelsStreak + } } else { whitePixelsStreak = 0 if (isDark(pixel) && isDark(pixelOff)) { blackPixels++ - if (notOffset) + if (notOffset) { overallBlackPixels++ + } blackPixelsStreak++ - if (blackPixelsStreak >= 14) + if (blackPixelsStreak >= 14) { blackStreak = true + } continue } } - if (blackPixelsStreak > 6 && blackPixelsStreak >= index - 1) + if (blackPixelsStreak > 6 && blackPixelsStreak >= index - 1) { topBlackStreak = blackPixelsStreak + } blackPixelsStreak = 0 } - if (blackPixelsStreak > 6) + if (blackPixelsStreak > 6) { botBlackStreak = blackPixelsStreak - else if (whitePixelsStreak > 6) + } else if (whitePixelsStreak > 6) { botWhiteStreak = whitePixelsStreak + } when { blackPixels > 22 -> { - if (x == right || x == right + offsetX) + if (x == right || x == right + offsetX) { blackPixel = when { topRightIsDark -> image.getPixel(right, top) botRightIsDark -> image.getPixel(right, bot) else -> blackPixel } + } darkBG = true overallWhitePixels = 0 break@outer } blackStreak -> { darkBG = true - if (x == right || x == right + offsetX) + if (x == right || x == right + offsetX) { blackPixel = when { topRightIsDark -> image.getPixel(right, top) botRightIsDark -> image.getPixel(right, bot) else -> blackPixel } + } if (blackPixels > 18) { overallWhitePixels = 0 break@outer @@ -183,50 +204,103 @@ object ImageUtil { val topIsBlackStreak = topBlackStreak > topWhiteStreak val bottomIsBlackStreak = botBlackStreak > botWhiteStreak - if (overallWhitePixels > 9 && overallWhitePixels > overallBlackPixels) + if (overallWhitePixels > 9 && overallWhitePixels > overallBlackPixels) { darkBG = false - if (topIsBlackStreak && bottomIsBlackStreak) + } + if (topIsBlackStreak && bottomIsBlackStreak) { darkBG = true + } + val isLandscape = context.resources.configuration?.orientation == Configuration.ORIENTATION_LANDSCAPE if (darkBG) { - return if (isWhite(image.getPixel(left, bot)) && isWhite(image.getPixel(right, bot))) GradientDrawable( + return if (!isLandscape && isWhite(image.getPixel(left, bot)) && isWhite(image.getPixel(right, bot))) GradientDrawable( GradientDrawable.Orientation.TOP_BOTTOM, intArrayOf(blackPixel, blackPixel, backgroundColor, backgroundColor) ) - else if (isWhite(image.getPixel(left, top)) && isWhite(image.getPixel(right, top))) GradientDrawable( + else if (!isLandscape && isWhite(image.getPixel(left, top)) && isWhite(image.getPixel(right, top))) GradientDrawable( GradientDrawable.Orientation.TOP_BOTTOM, intArrayOf(backgroundColor, backgroundColor, blackPixel, blackPixel) ) else ColorDrawable(blackPixel) } - if (topIsBlackStreak || ( - topLeftIsDark && topRightIsDark && - isDark(image.getPixel(left - offsetX, top)) && isDark(image.getPixel(right + offsetX, top)) && - (topMidIsDark || overallBlackPixels > 9) + if (!isLandscape && ( + topIsBlackStreak || ( + topLeftIsDark && topRightIsDark && + isDark(image.getPixel(left - offsetX, top)) && isDark(image.getPixel(right + offsetX, top)) && + (topMidIsDark || overallBlackPixels > 9) + ) ) - ) + ) { return GradientDrawable( GradientDrawable.Orientation.TOP_BOTTOM, intArrayOf(blackPixel, blackPixel, backgroundColor, backgroundColor) ) - else if (bottomIsBlackStreak || ( - botLeftIsDark && botRightIsDark && - isDark(image.getPixel(left - offsetX, bot)) && isDark(image.getPixel(right + offsetX, bot)) && - (isDark(image.getPixel(midX, bot)) || overallBlackPixels > 9) + } else if (!isLandscape && ( + bottomIsBlackStreak || ( + botLeftIsDark && botRightIsDark && + isDark(image.getPixel(left - offsetX, bot)) && isDark(image.getPixel(right + offsetX, bot)) && + (isDark(image.getPixel(midX, bot)) || overallBlackPixels > 9) + ) ) - ) + ) { return GradientDrawable( GradientDrawable.Orientation.TOP_BOTTOM, intArrayOf(backgroundColor, backgroundColor, blackPixel, blackPixel) ) + } return ColorDrawable(backgroundColor) } - fun Boolean.toInt() = if (this) 1 else 0 + fun mergeBitmaps( + imageBitmap: Bitmap, + imageBitmap2: Bitmap, + isLTR: Boolean, + @ColorInt background: Int = Color.WHITE, + progressCallback: ((Int) -> Unit)? = null + ): ByteArrayInputStream { + val height = imageBitmap.height + val width = imageBitmap.width + val height2 = imageBitmap2.height + val width2 = imageBitmap2.width + val maxHeight = max(height, height2) + val result = Bitmap.createBitmap(width + width2, max(height, height2), Bitmap.Config.ARGB_8888) + val canvas = Canvas(result) + canvas.drawColor(background) + val upperPart = Rect( + if (isLTR) 0 else width2, + (maxHeight - imageBitmap.height) / 2, + (if (isLTR) 0 else width2) + imageBitmap.width, + imageBitmap.height + (maxHeight - imageBitmap.height) / 2 + ) + canvas.drawBitmap(imageBitmap, imageBitmap.rect, upperPart, null) + progressCallback?.invoke(98) + val bottomPart = Rect( + if (!isLTR) 0 else width, + (maxHeight - imageBitmap2.height) / 2, + (if (!isLTR) 0 else width) + imageBitmap2.width, + imageBitmap2.height + (maxHeight - imageBitmap2.height) / 2 + ) + canvas.drawBitmap(imageBitmap2, imageBitmap2.rect, bottomPart, null) + progressCallback?.invoke(99) + + val output = ByteArrayOutputStream() + result.compress(Bitmap.CompressFormat.JPEG, 100, output) + progressCallback?.invoke(100) + return ByteArrayInputStream(output.toByteArray()) + } + + private val Bitmap.rect: Rect + get() = Rect(0, 0, width, height) + private fun isDark(color: Int): Boolean { return Color.red(color) < 40 && Color.blue(color) < 40 && Color.green(color) < 40 && Color.alpha(color) > 200 } + fun isDarkish(color: Int): Boolean { + return Color.red(color) < 80 && Color.blue(color) < 80 && Color.green(color) < 80 && + Color.alpha(color) > 150 + } + private fun pixelIsClose(color1: Int, color2: Int): Boolean { return abs(Color.red(color1) - Color.red(color2)) < 30 && abs(Color.green(color1) - Color.green(color2)) < 30 && diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/NotificationExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/NotificationExtensions.kt index 312d2d2064..1dbde71326 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/NotificationExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/NotificationExtensions.kt @@ -10,7 +10,6 @@ fun NotificationCompat.Builder.customize( smallIcon: Int, ongoing: Boolean = false ): NotificationCompat.Builder { - setContentTitle(title) setSmallIcon(smallIcon) color = context.contextCompatColor(R.color.neko_green_darker) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/RxCoroutineBridge.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/RxCoroutineBridge.kt new file mode 100644 index 0000000000..d67c87b278 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/RxCoroutineBridge.kt @@ -0,0 +1,83 @@ +package eu.kanade.tachiyomi.util.system + +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import rx.Emitter +import rx.Observable +import rx.Subscriber +import rx.Subscription +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/* + * Util functions for bridging RxJava and coroutines. Taken from TachiyomiEH/SY. + */ + +suspend fun Observable.awaitSingle(): T = single().awaitOne() + +private suspend fun Observable.awaitOne(): T = suspendCancellableCoroutine { cont -> + cont.unsubscribeOnCancellation( + subscribe( + object : Subscriber() { + override fun onStart() { + request(1) + } + + override fun onNext(t: T) { + cont.resume(t) + } + + override fun onCompleted() { + if (cont.isActive) cont.resumeWithException( + IllegalStateException( + "Should have invoked onNext" + ) + ) + } + + override fun onError(e: Throwable) { + // Rx1 observable throws NoSuchElementException if cancellation happened before + // element emission. To mitigate this we try to atomically resume continuation with exception: + // if resume failed, then we know that continuation successfully cancelled itself + val token = cont.tryResumeWithException(e) + if (token != null) { + cont.completeResume(token) + } + } + } + ) + ) +} + +internal fun CancellableContinuation.unsubscribeOnCancellation(sub: Subscription) = + invokeOnCancellation { sub.unsubscribe() } + +fun runAsObservable( + block: suspend () -> T, + backpressureMode: Emitter.BackpressureMode = Emitter.BackpressureMode.NONE +): Observable { + return Observable.create( + { emitter -> + val job = GlobalScope.launch(Dispatchers.Unconfined, start = CoroutineStart.ATOMIC) { + try { + emitter.onNext(block()) + emitter.onCompleted() + } catch (e: Throwable) { + // Ignore `CancellationException` as error, since it indicates "normal cancellation" + if (e !is CancellationException) { + emitter.onError(e) + } else { + emitter.onCompleted() + } + } + } + emitter.setCancellation { job.cancel() } + }, + backpressureMode + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/RxUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/RxUtil.kt index 80d9c0a649..bf8651af8d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/RxUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/RxUtil.kt @@ -17,8 +17,9 @@ suspend fun Single.await(subscribeOn: Scheduler? = null): T { } }, { - if (!continuation.isCancelled) + if (!continuation.isCancelled) { continuation.resumeWithException(it) + } } ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/ThemeUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ThemeUtil.kt index ec9403b921..4854401a31 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/ThemeUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ThemeUtil.kt @@ -1,24 +1,41 @@ package eu.kanade.tachiyomi.util.system +import android.app.Activity +import android.content.Context import android.graphics.Color import androidx.appcompat.app.AppCompatDelegate -import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper object ThemeUtil { - fun isBlueTheme(theme: Int): Boolean { - return theme == 4 || theme == 8 || theme == 7 + + /** Migration method */ + fun convertTheme(preferences: PreferencesHelper, theme: Int) { + preferences.nightMode().set( + when (theme) { + 0, 1 -> AppCompatDelegate.MODE_NIGHT_NO + 2, 3, 4 -> AppCompatDelegate.MODE_NIGHT_YES + else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + ) + preferences.lightTheme().set(Themes.PURE_WHITE) + preferences.darkTheme().set( + when (theme) { + 3 -> Themes.AMOLED + else -> Themes.DARK + } + ) } - fun isAMOLEDTheme(theme: Int): Boolean { - return theme == 3 || theme == 6 + fun isColoredTheme(theme: Themes): Boolean { + return return false } - fun theme(theme: Int): Int { - return when { - isAMOLEDTheme(theme) -> R.style.Theme_Tachiyomi_Amoled - isBlueTheme(theme) -> R.style.Theme_Tachiyomi_AllBlue - else -> R.style.Theme_Tachiyomi - } + fun isPitchBlack(context: Context, theme: Themes): Boolean { + return context.isInNightMode() && theme.darkBackground == Color.BLACK + } + + fun hasDarkActionBarInLight(context: Context, theme: Themes): Boolean { + return !context.isInNightMode() && isColoredTheme(theme) } fun readerBackgroundColor(theme: Int): Int { @@ -27,12 +44,28 @@ object ThemeUtil { else -> Color.WHITE } } +} + +fun Activity.setThemeAndNight(preferences: PreferencesHelper) { + if (preferences.nightMode().isNotSet()) { + ThemeUtil.convertTheme(preferences, preferences.oldTheme()) + } + AppCompatDelegate.setDefaultNightMode(preferences.nightMode().get()) + val theme = getPrefTheme(preferences) + setTheme(theme.styleRes) +} - fun nightMode(theme: Int): Int { - return when (theme) { - 1, 8 -> AppCompatDelegate.MODE_NIGHT_NO - 2, 3, 4 -> AppCompatDelegate.MODE_NIGHT_YES - else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM - } +fun Context.getPrefTheme(preferences: PreferencesHelper): Themes { + // Using a try catch in case I start to remove themes + return try { + ( + if ((applicationContext.isInNightMode() || preferences.nightMode().get() == AppCompatDelegate.MODE_NIGHT_YES) && + preferences.nightMode().get() != AppCompatDelegate.MODE_NIGHT_NO + ) preferences.darkTheme() else preferences.lightTheme() + ).get() + } catch (e: Exception) { + preferences.lightTheme().set(Themes.PURE_WHITE) + preferences.darkTheme().set(Themes.DARK) + Themes.PURE_WHITE } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/Themes.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/Themes.kt new file mode 100644 index 0000000000..1d0dcb5ac6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/Themes.kt @@ -0,0 +1,233 @@ +package eu.kanade.tachiyomi.util.system + +import android.graphics.Color +import androidx.annotation.ColorInt +import androidx.annotation.StringRes +import androidx.annotation.StyleRes +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.graphics.ColorUtils +import eu.kanade.tachiyomi.R +import kotlin.math.roundToInt + +@Suppress("unused") +enum class Themes(@StyleRes val styleRes: Int, val nightMode: Int, @StringRes val nameRes: Int) { + PURE_WHITE(R.style.Theme_Tachiyomi, AppCompatDelegate.MODE_NIGHT_NO, R.string.white_theme), + DARK(R.style.Theme_Tachiyomi, AppCompatDelegate.MODE_NIGHT_YES, R.string.dark), + AMOLED( + R.style.Theme_Tachiyomi_Amoled, + AppCompatDelegate.MODE_NIGHT_YES, + R.string.amoled_black + ), + SPRING( + R.style.Theme_Tachiyomi_MidnightDusk, + AppCompatDelegate.MODE_NIGHT_NO, + R.string.spring_blossom + ), + DUSK( + R.style.Theme_Tachiyomi_MidnightDusk, + AppCompatDelegate.MODE_NIGHT_YES, + R.string.midnight_dusk + ), + LIME( + R.style.Theme_Tachiyomi_FlatLime, + AppCompatDelegate.MODE_NIGHT_YES, + R.string.flat_lime + ), + BLACK_N_RED( + R.style.Theme_Tachiyomi_BlackAndRed, + AppCompatDelegate.MODE_NIGHT_YES, + R.string.black_and_red + ), + HOT_PINK( + R.style.Theme_Tachiyomi_HotPink, + AppCompatDelegate.MODE_NIGHT_YES, + R.string.hot_pink + ); + + fun getColors(mode: Int = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM): Colors { + return when (nightMode) { + AppCompatDelegate.MODE_NIGHT_YES -> darkColors() + AppCompatDelegate.MODE_NIGHT_NO -> lightColors() + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> when (mode) { + AppCompatDelegate.MODE_NIGHT_YES -> darkColors() + else -> lightColors() + } + else -> lightColors() + } + } + + private fun lightColors(): Colors { + return Colors( + lightPrimaryText, + lightSecondaryText, + lightBackground, + lightAccent, + lightAppBar, + lightAppBarText, + lightBottomBar, + lightInactiveTab, + lightActiveTab, + ) + } + + private fun darkColors(): Colors { + return Colors( + darkPrimaryText, + darkSecondaryText, + darkBackground, + darkAccent, + darkAppBar, + darkAppBarText, + darkBottomBar, + darkInactiveTab, + darkActiveTab, + ) + } + + /** Complies with textColorPrimary (probably night) */ + @ColorInt + val lightPrimaryText: Int = Color.parseColor( + when (styleRes) { + R.style.Theme_Tachiyomi_MidnightDusk -> "#DE240728" + else -> "#DE000000" + } + ) + + /** Complies with textColorPrimary (probably night) */ + @ColorInt + val darkPrimaryText: Int = Color.parseColor("#FFFFFFFF") + + /** Complies with textColorSecondary (primary with alpha) */ + @ColorInt + val lightSecondaryText: Int = ColorUtils.setAlphaComponent(lightPrimaryText, (0.54f * 255f).roundToInt()) + + /** Complies with textColorSecondary (primary with alpha) */ + @ColorInt + val darkSecondaryText: Int = ColorUtils.setAlphaComponent(darkPrimaryText, (0.54f * 255f).roundToInt()) + + /** Complies with colorBackground */ + @ColorInt + val lightBackground: Int = Color.parseColor( + when (styleRes) { + R.style.Theme_Tachiyomi_MidnightDusk -> "#f6f0f8" + else -> "#FAFAFA" + } + ) + + /** Complies with colorBackground (probably night) */ + @ColorInt + val darkBackground: Int = Color.parseColor( + when (styleRes) { + R.style.Theme_Tachiyomi_Amoled, R.style.Theme_Tachiyomi_BlackAndRed, R.style.Theme_Tachiyomi_HotPink -> "#000000" + R.style.Theme_Tachiyomi_MidnightDusk -> "#16151D" + R.style.Theme_Tachiyomi_FlatLime -> "#202125" + R.style.Theme_Tachiyomi -> "#292929" + else -> "#1C1C1D" + } + ) + + /** Complies with colorAccent */ + @ColorInt + val lightAccent: Int = Color.parseColor( + when (styleRes) { + R.style.Theme_Tachiyomi_MidnightDusk -> "#c43c97" + R.style.Theme_Tachiyomi -> "#101820" + else -> "#2979FF" + } + ) + + /** Complies with colorAccent (probably night) */ + @ColorInt + val darkAccent: Int = Color.parseColor( + when (styleRes) { + R.style.Theme_Tachiyomi_MidnightDusk -> "#F02475" + R.style.Theme_Tachiyomi_BlackAndRed -> "#AA2200" + R.style.Theme_Tachiyomi_HotPink -> "#FF3399" + R.style.Theme_Tachiyomi_FlatLime -> "#4AF88A" + R.style.Theme_Tachiyomi_Amoled, R.style.Theme_Tachiyomi -> "#20aa5e" + else -> "#3399FF" + } + ) + + /** Complies with colorSecondary */ + @ColorInt + val lightAppBar: Int = when (styleRes) { + else -> lightBackground + } + + /** Complies with colorSecondary (probably night) */ + @ColorInt + val darkAppBar: Int = when (styleRes) { + else -> darkBackground + } + + /** Complies with actionBarTintColor */ + @ColorInt + val lightAppBarText: Int = when (styleRes) { + R.style.Theme_Tachiyomi_MidnightDusk -> Color.parseColor("#DE4c0d4b") + else -> lightPrimaryText + } + + /** Complies with actionBarTintColor (probably night) */ + @ColorInt + val darkAppBarText: Int = when (styleRes) { + else -> darkPrimaryText + } + + /** Complies with colorPrimaryVariant */ + @ColorInt + val lightBottomBar: Int = Color.parseColor( + when (styleRes) { + R.style.Theme_Tachiyomi_MidnightDusk -> "#efe3f3" + else -> "#FFFFFF" + } + ) + + /** Complies with colorPrimaryVariant (probably night) */ + @ColorInt + val darkBottomBar: Int = Color.parseColor( + when (styleRes) { + R.style.Theme_Tachiyomi_Amoled, R.style.Theme_Tachiyomi_BlackAndRed, R.style.Theme_Tachiyomi_HotPink -> "#000000" + R.style.Theme_Tachiyomi -> "#292929" + R.style.Theme_Tachiyomi_MidnightDusk -> "#201F27" + R.style.Theme_Tachiyomi_FlatLime -> "#282A2E" + else -> "#212121" + } + ) + + /** Complies with tabBarIconInactive */ + @ColorInt + val lightInactiveTab: Int = when (styleRes) { + else -> Color.parseColor("#C2424242") + } + + /** Complies with tabBarIconInactive (probably night) */ + @ColorInt + val darkInactiveTab: Int = when (styleRes) { + else -> Color.parseColor("#C2FFFFFF") + } + + /** Complies with tabBarIconColor */ + @ColorInt + val lightActiveTab: Int = when (styleRes) { + else -> lightAccent + } + + /** Complies with tabBarIconColor (probably night) */ + @ColorInt + val darkActiveTab: Int = when (styleRes) { + else -> darkAccent + } + + data class Colors( + @ColorInt val primaryText: Int, + @ColorInt val secondaryText: Int, + @ColorInt val colorBackground: Int, + @ColorInt val colorAccent: Int, + @ColorInt val appBar: Int, + @ColorInt val appBarText: Int, + @ColorInt val bottomBar: Int, + @ColorInt val inactiveTab: Int, + @ColorInt val activeTab: Int, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewClientCompat.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewClientCompat.kt index d32cc11410..8d2eea7cf0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewClientCompat.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewClientCompat.kt @@ -61,8 +61,11 @@ abstract class WebViewClientCompat : WebViewClient() { error: WebResourceError ) { onReceivedErrorCompat( - view, error.errorCode, error.description?.toString(), - request.url.toString(), request.isForMainFrame + view, + error.errorCode, + error.description?.toString(), + request.url.toString(), + request.isForMainFrame ) } @@ -82,7 +85,9 @@ abstract class WebViewClientCompat : WebViewClient() { error: WebResourceResponse ) { onReceivedErrorCompat( - view, error.statusCode, error.reasonPhrase, + view, + error.statusCode, + error.reasonPhrase, request.url .toString(), request.isForMainFrame diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt index ed6cdbdbe9..9c5cc349e0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt @@ -1,8 +1,37 @@ package eu.kanade.tachiyomi.util.system +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.webkit.CookieManager import android.webkit.WebSettings import android.webkit.WebView +import timber.log.Timber +object WebViewUtil { + const val REQUESTED_WITH = "com.android.browser" + + const val MINIMUM_WEBVIEW_VERSION = 88 + + fun supportsWebView(context: Context): Boolean { + try { + // May throw android.webkit.WebViewFactory$MissingWebViewPackageException if WebView + // is not installed + CookieManager.getInstance() + } catch (e: Throwable) { + Timber.e(e) + return false + } + + return context.packageManager.hasSystemFeature(PackageManager.FEATURE_WEBVIEW) + } +} + +fun WebView.isOutdated(): Boolean { + return getWebViewMajorVersion() < WebViewUtil.MINIMUM_WEBVIEW_VERSION +} + +@SuppressLint("SetJavaScriptEnabled") fun WebView.setDefaultSettings() { with(settings) { javaScriptEnabled = true @@ -15,32 +44,25 @@ fun WebView.setDefaultSettings() { } } -private val WEBVIEW_UA_VERSION_REGEX by lazy { - Regex(""".*Chrome/(\d+)\..*""") -} - -private const val MINIMUM_WEBVIEW_VERSION = 86 - -fun WebView.isOutdated(): Boolean { - return getWebviewMajorVersion(this) < MINIMUM_WEBVIEW_VERSION +private fun WebView.getWebViewMajorVersion(): Int { + val uaRegexMatch = """.*Chrome/(\d+)\..*""".toRegex().matchEntire(getDefaultUserAgentString()) + return if (uaRegexMatch != null && uaRegexMatch.groupValues.size > 1) { + uaRegexMatch.groupValues[1].toInt() + } else { + 0 + } } // Based on https://stackoverflow.com/a/29218966 -private fun getWebviewMajorVersion(webview: WebView): Int { - val originalUA: String = webview.settings.userAgentString +private fun WebView.getDefaultUserAgentString(): String { + val originalUA: String = settings.userAgentString // Next call to getUserAgentString() will get us the default - webview.settings.userAgentString = null - - val uaRegexMatch = WEBVIEW_UA_VERSION_REGEX.matchEntire(webview.settings.userAgentString) - val webViewVersion: Int = if (uaRegexMatch != null && uaRegexMatch.groupValues.size > 1) { - uaRegexMatch.groupValues[1].toInt() - } else { - 0 - } + settings.userAgentString = null + val defaultUserAgentString = settings.userAgentString // Revert to original UA string - webview.settings.userAgentString = originalUA + settings.userAgentString = originalUA - return webViewVersion + return defaultUserAgentString } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/WindowInsetsExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/WindowInsetsExtensions.kt index 9c24f920bb..155fe06649 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/WindowInsetsExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/WindowInsetsExtensions.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.util.system import android.os.Build import android.view.WindowInsets +import androidx.annotation.RequiresApi fun WindowInsets.getBottomGestureInsets(): Int { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) mandatorySystemGestureInsets.bottom @@ -20,3 +21,6 @@ fun WindowInsets.hasSideInsets() = systemWindowInsetLeft > 0 || systemWindowInse fun WindowInsets.hasSideNavBar() = (systemWindowInsetLeft > 0 || systemWindowInsetRight > 0) && !isBottomTappable() && systemWindowInsetBottom == 0 + +@RequiresApi(Build.VERSION_CODES.R) +fun WindowInsets.isImeVisible() = isVisible(WindowInsets.Type.ime()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/view/ControllerExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/view/ControllerExtensions.kt index 0b0974c72f..50878cd60d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/view/ControllerExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/view/ControllerExtensions.kt @@ -1,88 +1,174 @@ package eu.kanade.tachiyomi.util.view +import android.Manifest import android.animation.ValueAnimator import android.content.Context +import android.content.Intent import android.content.pm.PackageManager +import android.os.Build +import android.os.Environment +import android.provider.Settings +import android.view.Gravity import android.view.View import android.view.ViewGroup import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.WindowInsets import android.view.inputmethod.InputMethodManager +import android.widget.FrameLayout import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat import androidx.core.math.MathUtils +import androidx.core.net.toUri +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.afollestad.materialdialogs.MaterialDialog import com.bluelinelabs.conductor.Controller import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeType import com.bluelinelabs.conductor.RouterTransaction import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.databinding.MainActivityBinding +import eu.kanade.tachiyomi.ui.base.MaterialFastScroll +import eu.kanade.tachiyomi.ui.base.controller.OneWayFadeChangeHandler import eu.kanade.tachiyomi.ui.main.BottomSheetController +import eu.kanade.tachiyomi.ui.main.FloatingSearchInterface +import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.manga.MangaDetailsController import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.getResourceColor -import kotlinx.android.synthetic.main.main_activity.* +import eu.kanade.tachiyomi.util.system.isTablet +import eu.kanade.tachiyomi.util.system.toast +import uy.kohesive.injekt.injectLazy import kotlin.math.abs import kotlin.random.Random fun Controller.setOnQueryTextChangeListener( searchView: SearchView, onlyOnSubmit: Boolean = false, + hideKbOnSubmit: Boolean = true, f: (text: String?) -> Boolean ) { - searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextChange(newText: String?): Boolean { - if (!onlyOnSubmit && router.backstack.lastOrNull() - ?.controller() == this@setOnQueryTextChangeListener - ) { - return f(newText) + searchView.setOnQueryTextListener( + object : SearchView.OnQueryTextListener { + override fun onQueryTextChange(newText: String?): Boolean { + if (!onlyOnSubmit && router.backstack.lastOrNull() + ?.controller == this@setOnQueryTextChangeListener + ) { + return f(newText) + } + return false } - return false - } - override fun onQueryTextSubmit(query: String?): Boolean { - if (router.backstack.lastOrNull()?.controller() == this@setOnQueryTextChangeListener) { - val imm = - activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager - ?: return f(query) - imm.hideSoftInputFromWindow(searchView.windowToken, 0) - return f(query) + override fun onQueryTextSubmit(query: String?): Boolean { + if (router.backstack.lastOrNull()?.controller == this@setOnQueryTextChangeListener) { + if (hideKbOnSubmit) { + val imm = + activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + ?: return f(query) + imm.hideSoftInputFromWindow(searchView.windowToken, 0) + } + return f(query) + } + return true } - return true } + ) +} + +fun Controller.removeQueryListener() { + val searchView = activityBinding?.cardToolbar?.menu?.findItem(R.id.action_search)?.actionView as? SearchView + val searchView2 = activityBinding?.toolbar?.menu?.findItem(R.id.action_search)?.actionView as? SearchView + searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?) = true + override fun onQueryTextChange(newText: String?) = true + }) + searchView2?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?) = true + override fun onQueryTextChange(newText: String?) = true }) } -fun Controller.liftAppbarWith(recycler: RecyclerView) { - view?.applyWindowInsetsForController() - recycler.setOnApplyWindowInsetsListener(RecyclerWindowInsetsListener) +fun Controller.liftAppbarWith(recycler: RecyclerView, padView: Boolean = false) { + if (padView) { + val attrsArray = intArrayOf(R.attr.actionBarSize) + val array = recycler.context.obtainStyledAttributes(attrsArray) + var appBarHeight = ( + if (toolbarHeight ?: 0 > 0) toolbarHeight!! + else array.getDimensionPixelSize(0, 0) + ) + array.recycle() + activityBinding!!.toolbar.post { + if (toolbarHeight!! > 0) { + appBarHeight = toolbarHeight!! + recycler.requestApplyInsets() + } + } + recycler.updatePaddingRelative( + top = activityBinding!!.toolbar.y.toInt() + appBarHeight + ) + recycler.applyBottomAnimatedInsets(setPadding = true) + recycler.doOnApplyWindowInsets { view, insets, _ -> + val headerHeight = insets.systemWindowInsetTop + appBarHeight + view.updatePaddingRelative( + top = headerHeight + ) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + view.updatePaddingRelative( + bottom = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + insets.getInsets(WindowInsets.Type.ime() or WindowInsets.Type.systemBars()).bottom + } else { + insets.systemWindowInsetBottom + } + ) + } + } + } else { + view?.applyWindowInsetsForController() + recycler.setOnApplyWindowInsetsListener(RecyclerWindowInsetsListener) + } var elevationAnim: ValueAnimator? = null var elevate = false - val elevateFunc: (Boolean) -> Unit = { el -> + val elevateFunc: (Boolean) -> Unit = f@{ el -> elevate = el elevationAnim?.cancel() + val floatingBar = + !(activityBinding?.toolbar?.isVisible == true || activityBinding?.tabsFrameLayout?.isVisible == true) + if (floatingBar) { + activityBinding?.appBar?.elevation = 0f + return@f + } elevationAnim = ValueAnimator.ofFloat( - activity?.appbar?.elevation ?: 0f, if (el) 15f else 0f + activityBinding?.appBar?.elevation ?: 0f, + if (el) 15f else 0f ) elevationAnim?.addUpdateListener { valueAnimator -> - activity?.appbar?.elevation = valueAnimator.animatedValue as Float + activityBinding?.appBar?.elevation = valueAnimator.animatedValue as Float } elevationAnim?.start() } + + val floatingBar = + !(activityBinding?.toolbar?.isVisible == true || activityBinding?.tabsFrameLayout?.isVisible == true) + if (floatingBar) { + activityBinding?.appBar?.elevation = 0f + } elevateFunc(recycler.canScrollVertically(-1)) - recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy) - if (router?.backstack?.lastOrNull() - ?.controller() == this@liftAppbarWith && activity != null - ) { - val notAtTop = recycler.canScrollVertically(-1) - if (notAtTop != elevate) elevateFunc(notAtTop) + recycler.addOnScrollListener( + object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + if (router?.backstack?.lastOrNull() + ?.controller == this@liftAppbarWith && activity != null + ) { + val notAtTop = recycler.canScrollVertically(-1) + if (notAtTop != elevate) elevateFunc(notAtTop) + } } } - }) + ) } fun Controller.scrollViewWith( @@ -92,25 +178,47 @@ fun Controller.scrollViewWith( swipeRefreshLayout: SwipeRefreshLayout? = null, afterInsets: ((WindowInsets) -> Unit)? = null, liftOnScroll: ((Boolean) -> Unit)? = null, - onLeavingController: (() -> Unit)? = null + onLeavingController: (() -> Unit)? = null, + onBottomNavUpdate: (() -> Unit)? = null, + includeTabView: Boolean = false ): ((Boolean) -> Unit) { var statusBarHeight = -1 - activity?.appbar?.y = 0f + val tabBarHeight = 48.dpToPx + activityBinding?.appBar?.y = 0f + activityBinding?.tabsFrameLayout?.elevation = 0f + val isTabletWithTabs = recycler.context.isTablet() && includeTabView + activityBinding?.tabShadow?.isVisible = isTabletWithTabs val attrsArray = intArrayOf(R.attr.actionBarSize) val array = recycler.context.obtainStyledAttributes(attrsArray) - var appBarHeight = if (activity!!.toolbar.height > 0) activity!!.toolbar.height - else array.getDimensionPixelSize(0, 0) + var appBarHeight = ( + if (toolbarHeight ?: 0 > 0) toolbarHeight!! + else array.getDimensionPixelSize(0, 0) + ) + if (includeTabView) tabBarHeight else 0 array.recycle() swipeRefreshLayout?.setDistanceToTriggerSync(150.dpToPx) - activity?.toolbar?.post { - if (activity!!.toolbar.height > 0) { - appBarHeight = activity!!.toolbar.height + activityBinding!!.toolbar.post { + if (toolbarHeight!! > 0) { + appBarHeight = toolbarHeight!! + if (includeTabView) tabBarHeight else 0 recycler.requestApplyInsets() } } + val updateViewsNearBottom = { + onBottomNavUpdate?.invoke() + activityBinding?.bottomView?.translationY = activityBinding?.bottomNav?.translationY ?: 0f + } + recycler.post { + updateViewsNearBottom() + } val randomTag = Random.nextLong() var lastY = 0f var fakeToolbarView: View? = null + var fakeBottomNavView: View? = null + if (!customPadding) { + recycler.updatePaddingRelative( + top = (activity?.window?.decorView?.rootWindowInsets?.systemWindowInsetTop ?: 0) + + appBarHeight + ) + } recycler.doOnApplyWindowInsets { view, insets, _ -> val headerHeight = insets.systemWindowInsetTop + appBarHeight if (!customPadding) view.updatePaddingRelative( @@ -118,128 +226,261 @@ fun Controller.scrollViewWith( bottom = if (padBottom) insets.systemWindowInsetBottom else view.paddingBottom ) swipeRefreshLayout?.setProgressViewOffset( - true, headerHeight + (-60).dpToPx, headerHeight + 10.dpToPx + true, + headerHeight + (-60).dpToPx, + headerHeight + 10.dpToPx ) statusBarHeight = insets.systemWindowInsetTop afterInsets?.invoke(insets) } var elevationAnim: ValueAnimator? = null var elevate = false - val elevateFunc: (Boolean) -> Unit = { el -> + var isInView = true + val preferences: PreferencesHelper by injectLazy() + val elevateFunc: (Boolean) -> Unit = f@{ el -> elevate = el if (liftOnScroll != null) { liftOnScroll.invoke(el) } else { elevationAnim?.cancel() + if (isTabletWithTabs && el) { + activityBinding?.tabShadow?.isVisible = true + } + val floatingBar = + (this as? FloatingSearchInterface)?.showFloatingBar() == true && !includeTabView + if (floatingBar) { + if (isTabletWithTabs) { + activityBinding?.tabShadow?.alpha = 0f + } else { + activityBinding?.appBar?.elevation = 0f + } + return@f + } elevationAnim = ValueAnimator.ofFloat( - activity?.appbar?.elevation ?: 0f, if (el) 15f else 0f + if (isTabletWithTabs) { + (activityBinding?.tabShadow?.alpha ?: 0f) * 100 + } else { + activityBinding?.appBar?.elevation ?: 0f + }, + if (el) 15f else 0f ) elevationAnim?.addUpdateListener { valueAnimator -> - activity?.appbar?.elevation = valueAnimator.animatedValue as Float + if (isTabletWithTabs) { + activityBinding?.tabShadow?.alpha = valueAnimator.animatedValue as Float / 100 + } else { + activityBinding?.appBar?.elevation = valueAnimator.animatedValue as Float + } } elevationAnim?.start() } } - addLifecycleListener(object : Controller.LifecycleListener() { - override fun onChangeStart( - controller: Controller, - changeHandler: ControllerChangeHandler, - changeType: ControllerChangeType - ) { - super.onChangeStart(controller, changeHandler, changeType) - if (changeType.isEnter) { - elevateFunc(elevate) - if (fakeToolbarView?.parent != null) { - val parent = fakeToolbarView?.parent as? ViewGroup ?: return - parent.removeView(fakeToolbarView) - fakeToolbarView = null - } - lastY = 0f - activity!!.toolbar.tag = randomTag - activity!!.toolbar.setOnClickListener { - if ((this@scrollViewWith as? BottomSheetController)?.sheetIsExpanded() != true) { - recycler.scrollToPosition(0) - } else { - (this@scrollViewWith as? BottomSheetController)?.toggleSheet() - } - } - } else { - if (!customPadding && lastY == 0f && router.backstack.lastOrNull() - ?.controller() is MangaDetailsController - ) { - val parent = recycler.parent as? ViewGroup ?: return - val v = View(activity) - fakeToolbarView = v - parent.addView(v, parent.indexOfChild(recycler) + 1) - val params = fakeToolbarView?.layoutParams - params?.height = recycler.paddingTop - params?.width = MATCH_PARENT - v.setBackgroundColor(v.context.getResourceColor(R.attr.colorSecondary)) - v.layoutParams = params - onLeavingController?.invoke() - } - elevationAnim?.cancel() - if (activity!!.toolbar.tag == randomTag) activity!!.toolbar.setOnClickListener(null) - } - } - }) - elevateFunc(recycler.canScrollVertically(-1)) - recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy) - if (router?.backstack?.lastOrNull() - ?.controller() == this@scrollViewWith && statusBarHeight > -1 && - activity != null && activity!!.appbar.height > 0 && - recycler.translationY == 0f + if ((this as? FloatingSearchInterface)?.showFloatingBar() == true && !includeTabView) { + activityBinding?.appBar?.elevation = 0f + } + addLifecycleListener( + object : Controller.LifecycleListener() { + override fun onChangeStart( + controller: Controller, + changeHandler: ControllerChangeHandler, + changeType: ControllerChangeType ) { - if (!recycler.canScrollVertically(-1)) { - val shortAnimationDuration = resources?.getInteger( - android.R.integer.config_shortAnimTime - ) ?: 0 - activity!!.appbar.animate().y(0f).setDuration(shortAnimationDuration.toLong()) - .start() + super.onChangeStart(controller, changeHandler, changeType) + isInView = changeType.isEnter + if (changeType.isEnter) { + activityBinding?.tabShadow?.isVisible = isTabletWithTabs + elevateFunc(elevate) + if (fakeToolbarView?.parent != null) { + val parent = fakeToolbarView?.parent as? ViewGroup ?: return + parent.removeView(fakeToolbarView) + fakeToolbarView = null + } + if (fakeBottomNavView?.parent != null) { + val parent = fakeBottomNavView?.parent as? ViewGroup ?: return + parent.removeView(fakeBottomNavView) + fakeBottomNavView = null + } lastY = 0f - if (elevate) elevateFunc(false) + activityBinding!!.toolbar.tag = randomTag + activityBinding!!.toolbar.setOnClickListener { + if ((this@scrollViewWith as? BottomSheetController)?.sheetIsExpanded() != true) { + recycler.smoothScrollToTop() + } else { + (this@scrollViewWith as? BottomSheetController)?.toggleSheet() + } + } } else { - activity!!.appbar.y -= dy - activity!!.appbar.y = MathUtils.clamp( - activity!!.appbar.y, -activity!!.appbar.height.toFloat(), 0f - ) - if (( - ( - activity!!.appbar.y <= -activity!!.appbar.height.toFloat() || - dy == 0 && activity!!.appbar.y == 0f - ) || dy == 0 - ) && !elevate - ) - elevateFunc(true) - lastY = activity!!.appbar.y + activityBinding?.tabShadow?.isVisible = false + if (!customPadding && lastY == 0f && ( + ( + this@scrollViewWith !is FloatingSearchInterface && router.backstack.lastOrNull() + ?.controller is MangaDetailsController + ) || includeTabView + ) + ) { + val parent = recycler.parent as? ViewGroup ?: return + val v = View(activity) + fakeToolbarView = v + parent.addView(v, parent.indexOfChild(recycler) + 1) + val params = fakeToolbarView?.layoutParams + params?.height = recycler.paddingTop + params?.width = MATCH_PARENT + v.setBackgroundColor(v.context.getResourceColor(R.attr.colorSecondary)) + v.layoutParams = params + onLeavingController?.invoke() + } + if (!customPadding && router.backstackSize == 2 && changeType == ControllerChangeType.PUSH_EXIT) { + val parent = recycler.parent as? ViewGroup ?: return + val bottomNav = activityBinding?.bottomNav ?: return + val v = View(activity) + fakeBottomNavView = v + parent.addView(v, parent.indexOfChild(recycler) + 1) + val params = fakeBottomNavView?.layoutParams + params?.height = recycler.paddingBottom + (params as? FrameLayout.LayoutParams)?.gravity = Gravity.BOTTOM + fakeBottomNavView?.translationY = bottomNav.translationY + params?.width = MATCH_PARENT + v.setBackgroundColor(v.context.getResourceColor(R.attr.colorPrimaryVariant)) + v.layoutParams = params + } + elevationAnim?.cancel() + if (activityBinding!!.toolbar.tag == randomTag) activityBinding!!.toolbar.setOnClickListener(null) } } } + ) + elevateFunc(recycler.canScrollVertically(-1)) - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - super.onScrollStateChanged(recyclerView, newState) - if (newState == RecyclerView.SCROLL_STATE_IDLE) { + recycler.post { + elevateFunc(recycler.canScrollVertically(-1)) + } + val isTablet = recycler.context.isTablet() + recycler.addOnScrollListener( + object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + if (recyclerView.tag == MaterialFastScroll.noUpdate) return if (router?.backstack?.lastOrNull() - ?.controller() == this@scrollViewWith && statusBarHeight > -1 && - activity != null && activity!!.appbar.height > 0 && + ?.controller == this@scrollViewWith && statusBarHeight > -1 && + activity != null && activityBinding!!.appBar.height > 0 && recycler.translationY == 0f ) { - val halfWay = abs((-activity!!.appbar.height.toFloat()) / 2) - val shortAnimationDuration = resources?.getInteger( - android.R.integer.config_shortAnimTime - ) ?: 0 - val closerToTop = abs(activity!!.appbar.y) - halfWay > 0 - val atTop = !recycler.canScrollVertically(-1) - lastY = if (closerToTop && !atTop) (-activity!!.appbar.height.toFloat()) else 0f - activity!!.appbar.animate().y(lastY).setDuration(shortAnimationDuration.toLong()).start() - if (recycler.canScrollVertically(-1) && !elevate) elevateFunc(true) - else if (!recycler.canScrollVertically(-1) && elevate) elevateFunc(false) + if (!recycler.canScrollVertically(-1)) { + val shortAnimationDuration = resources?.getInteger( + android.R.integer.config_shortAnimTime + ) ?: 0 + activityBinding!!.appBar.animate().y(0f) + .setDuration(shortAnimationDuration.toLong()) + .start() + if (router.backstackSize == 1 && isInView) { + activityBinding!!.bottomNav?.let { + val animator = it.animate()?.translationY(0f) + ?.setDuration(shortAnimationDuration.toLong()) + animator?.setUpdateListener { + updateViewsNearBottom() + } + animator?.start() + } + } + lastY = 0f + if (elevate) elevateFunc(false) + } else { + if (!isTablet) { + activityBinding!!.appBar.y -= dy + activityBinding!!.appBar.y = MathUtils.clamp( + activityBinding!!.appBar.y, + -activityBinding!!.appBar.height.toFloat(), + 0f + ) + activityBinding!!.bottomNav?.let { bottomNav -> + if (bottomNav.isVisible && isInView) { + if (preferences.hideBottomNavOnScroll().get()) { + bottomNav.translationY += dy + bottomNav.translationY = MathUtils.clamp( + bottomNav.translationY, + 0f, + bottomNav.height.toFloat() + ) + updateViewsNearBottom() + } else if (bottomNav.translationY != 0f) { + bottomNav.translationY = 0f + activityBinding!!.bottomView?.translationY = 0f + } + } + } + + if (!elevate && ( + dy == 0 || + ( + activityBinding!!.appBar.y <= -activityBinding!!.appBar.height.toFloat() || + dy == 0 && activityBinding!!.appBar.y == 0f + ) + ) + ) { + elevateFunc(true) + } + } else { + val notAtTop = recycler.canScrollVertically(-1) + if (notAtTop != elevate) elevateFunc(notAtTop) + } + lastY = activityBinding!!.appBar.y + } + } + } + + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + super.onScrollStateChanged(recyclerView, newState) + if (newState == RecyclerView.SCROLL_STATE_IDLE) { + if (isTablet) { + return + } + if (router?.backstack?.lastOrNull() + ?.controller == this@scrollViewWith && statusBarHeight > -1 && + activity != null && activityBinding!!.appBar.height > 0 && + recycler.translationY == 0f + ) { + val halfWay = activityBinding!!.appBar.height.toFloat() / 2 + val shortAnimationDuration = resources?.getInteger( + android.R.integer.config_shortAnimTime + ) ?: 0 + val closerToTop = abs(activityBinding!!.appBar.y) > halfWay + val halfWayBottom = (activityBinding!!.bottomNav?.height?.toFloat() ?: 0f) / 2 + val closerToBottom = activityBinding!!.bottomNav?.translationY ?: 0f > halfWayBottom + val atTop = !recycler.canScrollVertically(-1) + val closerToEdge = + if (activityBinding!!.bottomNav?.isVisible == true && + preferences.hideBottomNavOnScroll().get() + ) closerToBottom else closerToTop + lastY = + if (closerToEdge && !atTop) (-activityBinding!!.appBar.height.toFloat()) else 0f + activityBinding!!.appBar.animate().y(lastY) + .setDuration(shortAnimationDuration.toLong()).start() + if (activityBinding!!.bottomNav?.isVisible == true && + isInView && preferences.hideBottomNavOnScroll().get() + ) { + activityBinding!!.bottomNav?.let { + val lastBottomY = + if (closerToEdge && !atTop) it.height.toFloat() else 0f + val animator = it.animate()?.translationY(lastBottomY) + ?.setDuration(shortAnimationDuration.toLong()) + animator?.setUpdateListener { + updateViewsNearBottom() + } + animator?.start() + } + } + if (recycler.canScrollVertically(-1) && !elevate) elevateFunc(true) + else if (!recycler.canScrollVertically(-1) && elevate) elevateFunc(false) + } + } else if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { + val view = activity?.window?.currentFocus ?: return + val imm = + activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + ?: return + imm.hideSoftInputFromWindow(view.windowToken, 0) } } } - }) + ) return elevateFunc } @@ -252,8 +493,59 @@ fun Controller.requestPermissionsSafe(permissions: Array, requestCode: I } } +fun Controller.requestFilePermissionsSafe(requestCode: Int) { + val activity = activity ?: return + val permissions = mutableListOf(Manifest.permission.WRITE_EXTERNAL_STORAGE) + permissions.forEach { permission -> + if (ContextCompat.checkSelfPermission( + activity, + permission + ) != PackageManager.PERMISSION_GRANTED + ) { + requestPermissions(arrayOf(permission), requestCode) + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + !Environment.isExternalStorageManager() + ) { + MaterialDialog(activity) + .title(R.string.all_files_permission_required) + .message(R.string.external_storage_permission_notice) + .cancelOnTouchOutside(false) + .positiveButton(android.R.string.ok) { + val intent = Intent( + Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + "package:${activity.packageName}".toUri() + ) + try { + activity.startActivity(intent) + } catch (_: Exception) { + val intent2 = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) + activity.startActivity(intent2) + } + } + .negativeButton(android.R.string.cancel) + .show() + } +} + fun Controller.withFadeTransaction(): RouterTransaction { return RouterTransaction.with(this) .pushChangeHandler(OneWayFadeChangeHandler()) .popChangeHandler(OneWayFadeChangeHandler()) } + +fun Controller.openInBrowser(url: String) { + try { + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + startActivity(intent) + } catch (e: Throwable) { + activity?.toast(e.message) + } +} + +val Controller.activityBinding: MainActivityBinding? + get() = (activity as? MainActivity)?.binding + +val Controller.toolbarHeight: Int? + get() = (activity as? MainActivity)?.toolbarHeight diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/view/ImageViewExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/view/ImageViewExtensions.kt index 69335bc5b5..17780e7342 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/view/ImageViewExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/view/ImageViewExtensions.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.util.view import android.widget.ImageView import androidx.annotation.DrawableRes +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat /** @@ -17,3 +18,13 @@ fun ImageView.setVectorCompat(@DrawableRes drawable: Int, tint: Int? = null) { } setImageDrawable(vector) } + +fun ImageView.setAnimVectorCompat(@DrawableRes drawable: Int, tint: Int? = null) { + val vector = AnimatedVectorDrawableCompat.create(context, drawable) + if (tint != null) { + vector?.mutate() + vector?.setTint(tint) + } + setImageDrawable(vector) + vector?.start() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/view/MaterialDialogExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/view/MaterialDialogExtensions.kt index 8c3b2d5ab0..27c289b644 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/view/MaterialDialogExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/view/MaterialDialogExtensions.kt @@ -4,17 +4,9 @@ import com.afollestad.date.DatePicker import com.afollestad.materialdialogs.MaterialDialog import java.util.Calendar -/* - * Copyright (C) 2020 The Neko Manga Open Source Project - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - fun MaterialDialog.setDate(date: Long) { val datePicker = findViewById(com.afollestad.materialdialogs.datetime.R.id.datetimeDatePicker) ?: return val calendar = Calendar.getInstance() calendar.timeInMillis = date datePicker.setDate(calendar) -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt index 6f9fa23682..61df5f0997 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt @@ -8,6 +8,8 @@ import android.content.res.ColorStateList import android.graphics.Color import android.graphics.Point import android.os.Build +import android.view.Gravity +import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.ViewTreeObserver @@ -19,22 +21,36 @@ import androidx.annotation.ColorRes import androidx.annotation.IdRes import androidx.annotation.Px import androidx.appcompat.view.menu.MenuBuilder +import androidx.appcompat.widget.PopupMenu +import androidx.core.content.ContextCompat import androidx.core.graphics.ColorUtils +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsAnimationCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.forEach +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.LinearSmoothScroller +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.SmoothScroller import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import com.google.android.material.bottomnavigation.BottomNavigationItemView -import com.google.android.material.bottomnavigation.BottomNavigationMenuView -import com.google.android.material.bottomnavigation.BottomNavigationView -import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.button.MaterialButton +import com.google.android.material.navigation.NavigationBarItemView +import com.google.android.material.navigation.NavigationBarMenuView +import com.google.android.material.navigation.NavigationBarView import com.google.android.material.snackbar.Snackbar import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.util.lang.tintText import eu.kanade.tachiyomi.util.system.ThemeUtil import eu.kanade.tachiyomi.util.system.contextCompatColor +import eu.kanade.tachiyomi.util.system.getPrefTheme import eu.kanade.tachiyomi.util.system.getResourceColor -import eu.kanade.tachiyomi.util.system.isInNightMode +import eu.kanade.tachiyomi.util.system.pxToDp +import eu.kanade.tachiyomi.widget.AutofitRecyclerView import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import kotlin.math.max +import kotlin.math.pow +import kotlin.math.roundToInt /** * Returns coordinates of view. @@ -60,8 +76,8 @@ fun View.snack( if (f != null) { snack.f() } - val theme = Injekt.get().theme() - if (ThemeUtil.isAMOLEDTheme(theme) && context.isInNightMode()) { + val theme = context.getPrefTheme(Injekt.get()) + if (ThemeUtil.isPitchBlack(context, theme)) { val textView: TextView = snack.view.findViewById(com.google.android.material.R.id.snackbar_text) val button: Button? = @@ -87,30 +103,6 @@ fun Snackbar.getText(): CharSequence { return textView.text } -inline fun View.visible() { - visibility = View.VISIBLE -} - -inline fun View.invisible() { - visibility = View.INVISIBLE -} - -inline fun View.gone() { - visibility = View.GONE -} - -inline fun View.isVisible(): Boolean { - return visibility == View.VISIBLE -} - -inline fun View.visibleIf(show: Boolean) { - visibility = if (show) View.VISIBLE else View.GONE -} - -inline fun View.visInvisIf(show: Boolean) { - visibility = if (show) View.VISIBLE else View.INVISIBLE -} - inline val View.marginTop: Int get() = (layoutParams as? ViewGroup.MarginLayoutParams)?.topMargin ?: 0 @@ -131,6 +123,62 @@ object RecyclerWindowInsetsListener : View.OnApplyWindowInsetsListener { } } +fun View.applyBottomAnimatedInsets(bottomMargin: Int = 0, setPadding: Boolean = false) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + val setInsets: ((WindowInsets) -> Unit) = { insets -> + val bottom = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + insets.getInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.ime()).bottom + } else { + insets.systemWindowInsetBottom + } + if (setPadding) { + updatePaddingRelative(bottom = bottomMargin + bottom) + } else { + updateLayoutParams { + this.bottomMargin = bottom + bottomMargin + } + } + } + var handleInsets = true + doOnApplyWindowInsets { _, insets, _ -> + if (handleInsets) { + setInsets(insets) + } + } + + ViewCompat.setWindowInsetsAnimationCallback( + this, + object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) { + override fun onPrepare(animation: WindowInsetsAnimationCompat) { + handleInsets = false + super.onPrepare(animation) + } + + override fun onStart( + animation: WindowInsetsAnimationCompat, + bounds: WindowInsetsAnimationCompat.BoundsCompat + ): WindowInsetsAnimationCompat.BoundsCompat { + handleInsets = false + rootWindowInsets?.let { insets -> setInsets(insets) } + return super.onStart(animation, bounds) + } + + override fun onProgress( + insets: WindowInsetsCompat, + runningAnimations: List + ): WindowInsetsCompat { + insets.toWindowInsets()?.let { setInsets(it) } + return insets + } + + override fun onEnd(animation: WindowInsetsAnimationCompat) { + handleInsets = true + rootWindowInsets?.let { insets -> setInsets(insets) } + } + } + ) +} + object ControllerViewWindowInsetsListener : View.OnApplyWindowInsetsListener { override fun onApplyWindowInsets(v: View, insets: WindowInsets): WindowInsets { v.updateLayoutParams { @@ -165,50 +213,47 @@ fun View.doOnApplyWindowInsets(f: (View, WindowInsets, ViewPaddingState) -> Unit requestApplyInsetsWhenAttached() } +fun View.doOnApplyWindowInsetsCompat(f: (View, WindowInsetsCompat, ViewPaddingState) -> Unit) { + // Create a snapshot of the view's padding state + val paddingState = createStateForView(this) + ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets -> + f(v, insets, paddingState) + insets + } + requestApplyInsetsWhenAttached() +} + fun View.applyWindowInsetsForController() { setOnApplyWindowInsetsListener(ControllerViewWindowInsetsListener) requestApplyInsetsWhenAttached() } fun View.checkHeightThen(f: () -> Unit) { - viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - if (height > 0) { - viewTreeObserver.removeOnGlobalLayoutListener(this) - f() - } - } - }) -} - -fun View.applyWindowInsetsForRootController(bottomNav: View) { - viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - if (bottomNav.height > 0) { - viewTreeObserver.removeOnGlobalLayoutListener(this) - setOnApplyWindowInsetsListener { view, insets -> - view.updateLayoutParams { - bottomMargin = bottomNav.height - } - insets + viewTreeObserver.addOnGlobalLayoutListener( + object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + if (height > 0) { + viewTreeObserver.removeOnGlobalLayoutListener(this) + f() } - requestApplyInsetsWhenAttached() } } - }) + ) } fun View.requestApplyInsetsWhenAttached() { if (isAttachedToWindow) { requestApplyInsets() } else { - addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View) { - v.requestApplyInsets() - } + addOnAttachStateChangeListener( + object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + v.requestApplyInsets() + } - override fun onViewDetachedFromWindow(v: View) = Unit - }) + override fun onViewDetachedFromWindow(v: View) = Unit + } + ) } } @@ -254,36 +299,6 @@ inline fun View.updatePaddingRelative( setPaddingRelative(start, top, end, bottom) } -fun BottomSheetDialog.setEdgeToEdge( - activity: Activity, - contentView: View, - setTopMargin: Int = -1 -) { - window?.setBackgroundDrawable(null) - window?.navigationBarColor = activity.window.navigationBarColor - val isLight = (activity.window?.decorView?.systemUiVisibility ?: 0) and View - .SYSTEM_UI_FLAG_LIGHT_STATUS_BAR == View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isLight) - window?.decorView?.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR - window?.findViewById(com.google.android.material.R.id.container)?.fitsSystemWindows = - false - contentView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View - .SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - - val insets = activity.window.decorView.rootWindowInsets - (contentView.parent as View).translationX = ( - insets.systemWindowInsetLeft - insets - .systemWindowInsetRight - ).toFloat() / 2f - if (setTopMargin > 0) (contentView.parent as View).updateLayoutParams { - height = activity.window.decorView.height - insets.systemWindowInsetTop - setTopMargin - } - else if (setTopMargin == 0) contentView.updateLayoutParams { - topMargin = insets.systemWindowInsetTop - } - contentView.requestLayout() -} - fun setBottomEdge(view: View, activity: Activity) { val marginB = view.marginBottom view.updateLayoutParams { @@ -312,7 +327,92 @@ fun TextView.setTextColorRes(@ColorRes id: Int) { } @SuppressLint("RestrictedApi") -fun BottomNavigationView.getItemView(@IdRes id: Int): BottomNavigationItemView? { +fun NavigationBarView.getItemView(@IdRes id: Int): NavigationBarItemView? { val order = (menu as MenuBuilder).findItemIndex(id) - return (getChildAt(0) as BottomNavigationMenuView).getChildAt(order) as? BottomNavigationItemView + return (getChildAt(0) as NavigationBarMenuView).getChildAt(order) as? NavigationBarItemView +} + +fun RecyclerView.smoothScrollToTop() { + val linearLayoutManager = layoutManager as? LinearLayoutManager + if (linearLayoutManager != null) { + val smoothScroller: SmoothScroller = object : LinearSmoothScroller(context) { + override fun getVerticalSnapPreference(): Int { + return SNAP_TO_START + } + } + smoothScroller.targetPosition = 0 + val firstItemPos = linearLayoutManager.findFirstVisibleItemPosition() + if (firstItemPos > 15) { + scrollToPosition(15) + post { + linearLayoutManager.startSmoothScroll(smoothScroller) + } + } else { + linearLayoutManager.startSmoothScroll(smoothScroller) + } + } else { + scrollToPosition(0) + } +} + +fun View.rowsForValue(value: Int): Int { + return rowsForValue((value / 2f) - .5f) +} + +fun View.rowsForValue(value: Float): Int { + val size = 1.5f.pow(value) + val trueSize = AutofitRecyclerView.MULTIPLE * ((size * 100 / AutofitRecyclerView.MULTIPLE).roundToInt()) / 100f + val dpWidth = (measuredWidth.pxToDp / 100f).roundToInt() + return max(1, (dpWidth / trueSize).roundToInt()) +} + +var View.compatToolTipText: CharSequence? + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + tooltipText + } else { + "" + } + set(value) { + ViewCompat.setTooltipText(this, value) + } + +@SuppressLint("RestrictedApi") +inline fun View.popupMenu( + items: List>, + selectedItemId: Int? = null, + noinline onMenuItemClick: MenuItem.() -> Unit +): PopupMenu { + val popup = PopupMenu(context, this, Gravity.NO_GRAVITY) + items.forEach { (id, stringRes) -> + popup.menu.add(0, id, 0, stringRes) + } + + if (selectedItemId != null) { + val blendedAccent = ColorUtils.blendARGB( + context.getResourceColor(android.R.attr.colorAccent), + context.getResourceColor(android.R.attr.textColorPrimary), + 0.5f + ) + (popup.menu as? MenuBuilder)?.setOptionalIconsVisible(true) + val emptyIcon = ContextCompat.getDrawable(context, R.drawable.ic_blank_24dp) + popup.menu.forEach { item -> + item.icon = when (item.itemId) { + selectedItemId -> ContextCompat.getDrawable(context, R.drawable.ic_check_24dp)?.mutate()?.apply { + setTint(blendedAccent) + } + else -> emptyIcon + } + if (item.itemId == selectedItemId) { + item.title = item.title?.tintText(blendedAccent) + } + } + } + + popup.setOnMenuItemClickListener { + it.onMenuItemClick() + true + } + + popup.show() + return popup } diff --git a/app/src/main/java/eu/kanade/tachiyomi/v5/db/V5DbQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/v5/db/V5DbQueries.kt index c9aa9e2e1d..9b21fffb2e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/v5/db/V5DbQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/v5/db/V5DbQueries.kt @@ -35,4 +35,3 @@ class V5DbQueries { } } } - diff --git a/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationJob.kt b/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationJob.kt index 37b9717ddd..b4c64b6b5d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationJob.kt @@ -7,7 +7,7 @@ import androidx.work.Worker import androidx.work.WorkerParameters class V5MigrationJob(private val context: Context, workerParams: WorkerParameters) : - Worker(context, workerParams) { + Worker(context, workerParams) { override fun doWork(): Result { V5MigrationService.start(context) @@ -20,6 +20,5 @@ class V5MigrationJob(private val context: Context, workerParams: WorkerParameter fun doWorkNow() { WorkManager.getInstance().enqueue(OneTimeWorkRequestBuilder().build()) } - } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationNotifier.kt index bf4f2d2c6e..e0e602376a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationNotifier.kt @@ -1,33 +1,21 @@ package eu.kanade.tachiyomi.v5.job -import android.app.Notification import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.BitmapFactory -import android.graphics.drawable.BitmapDrawable import android.net.Uri import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat -import coil.Coil -import coil.request.CachePolicy -import coil.request.ImageRequest -import coil.transform.CircleCropTransformation import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications -import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.util.lang.chop -import eu.kanade.tachiyomi.util.system.notification import eu.kanade.tachiyomi.util.system.notificationBuilder import eu.kanade.tachiyomi.util.system.notificationManager -import uy.kohesive.injekt.injectLazy class V5MigrationNotifier(private val context: Context) { @@ -70,11 +58,11 @@ class V5MigrationNotifier(private val context: Context) { fun showProgressNotification(manga: SManga, current: Int, total: Int) { val title = manga.title context.notificationManager.notify( - Notifications.ID_V5_MIGRATION_PROGRESS, - progressNotificationBuilder - .setContentTitle(title) - .setProgress(total, current, false) - .build() + Notifications.ID_V5_MIGRATION_PROGRESS, + progressNotificationBuilder + .setContentTitle(title) + .setProgress(total, current, false) + .build() ) } @@ -88,11 +76,11 @@ class V5MigrationNotifier(private val context: Context) { fun showProgressNotification(chapter: SChapter, current: Int, total: Int) { val title = chapter.chapter_title context.notificationManager.notify( - Notifications.ID_V5_MIGRATION_PROGRESS, - progressNotificationBuilder - .setContentTitle(title) - .setProgress(total, current, false) - .build() + Notifications.ID_V5_MIGRATION_PROGRESS, + progressNotificationBuilder + .setContentTitle(title) + .setProgress(total, current, false) + .build() ) } @@ -107,23 +95,23 @@ class V5MigrationNotifier(private val context: Context) { return } context.notificationManager.notify( - Notifications.ID_V5_MIGRATION_ERROR, - context.notificationBuilder(Notifications.CHANNEL_V5_MIGRATION) { - setContentTitle(context.resources.getQuantityString(R.plurals.notification_update_failed, errors.size, errors.size)) - addAction( - R.drawable.nnf_ic_file_folder, - context.getString(R.string.view_all_errors), - NotificationReceiver.openErrorLogPendingActivity(context, uri!!) + Notifications.ID_V5_MIGRATION_ERROR, + context.notificationBuilder(Notifications.CHANNEL_V5_MIGRATION) { + setContentTitle(context.resources.getQuantityString(R.plurals.notification_update_failed, errors.size, errors.size)) + addAction( + R.drawable.nnf_ic_file_folder, + context.getString(R.string.view_all_errors), + NotificationReceiver.openErrorLogPendingActivity(context, uri!!) + ) + setStyle( + NotificationCompat.BigTextStyle().bigText( + errors.joinToString("\n") { + it.chop(TITLE_MAX_LEN) + } ) - setStyle( - NotificationCompat.BigTextStyle().bigText( - errors.joinToString("\n") { - it.chop(TITLE_MAX_LEN) - } - ) - ) - setSmallIcon(R.drawable.ic_neko_notification) - }.build() + ) + setSmallIcon(R.drawable.ic_neko_notification) + }.build() ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationService.kt b/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationService.kt index bf6c871cc7..9017fb467a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/v5/job/V5MigrationService.kt @@ -60,7 +60,8 @@ class V5MigrationService( notifier = V5MigrationNotifier(this) startForeground(Notifications.ID_V5_MIGRATION_PROGRESS, notifier.progressNotificationBuilder.build()) wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, "V5MigrationService:WakeLock" + PowerManager.PARTIAL_WAKE_LOCK, + "V5MigrationService:WakeLock" ) wakeLock.acquire(TimeUnit.MINUTES.toMillis(30)) } @@ -129,7 +130,7 @@ class V5MigrationService( if (isNumericId) { val newMangaId = V5DbQueries.getNewMangaId(dbV5.idDb, oldMangaId) if (newMangaId.isNotBlank()) { - manga.url = "/title/${newMangaId}" + manga.url = "/title/$newMangaId" manga.initialized = false manga.thumbnail_url = null db.insertManga(manga).executeAsBlocking() @@ -194,8 +195,8 @@ class V5MigrationService( if (failedUpdatesMangas.isNotEmpty() || failedUpdatesChapters.isNotEmpty()) { val errorFile = writeErrorFile(failedUpdatesErrors) notifier.showUpdateErrorNotification( - failedUpdatesMangas.map { it.key.title } - + failedUpdatesChapters.map { it.key.chapter_title }, + failedUpdatesMangas.map { it.key.title } + + failedUpdatesChapters.map { it.key.chapter_title }, errorFile.getUriCompat(this) ) } @@ -264,4 +265,3 @@ class V5MigrationService( } } } - diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/AutofitRecyclerView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/AutofitRecyclerView.kt index bc46cebf0b..65f5deb38c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/AutofitRecyclerView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/AutofitRecyclerView.kt @@ -2,9 +2,13 @@ package eu.kanade.tachiyomi.widget import android.content.Context import android.util.AttributeSet +import androidx.core.content.edit +import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager +import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.util.system.pxToDp import kotlin.math.max +import kotlin.math.pow import kotlin.math.roundToInt class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : @@ -15,8 +19,9 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att var columnWidth = -1f set(value) { field = value - if (measuredWidth > 0) + if (measuredWidth > 0) { setSpan(true) + } } var spanCount = 0 @@ -50,6 +55,33 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att setSpan() } + fun setGridSize(preferences: PreferencesHelper) { + // Migrate to float for grid size + if (preferences.gridSize().isNotSet()) { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val oldGridSize = prefs.getInt("grid_size", -1) + if (oldGridSize != -1) { + preferences.gridSize().set( + when (oldGridSize) { + 4 -> 3f + 3 -> 1.5f + 2 -> 1f + 1 -> 0f + 0 -> -.5f + else -> .5f + } + ) + prefs.edit { + remove("grid_size") + } + } + } + + val size = 1.5f.pow(preferences.gridSize().get()) + val trueSize = MULTIPLE * ((size * 100 / MULTIPLE).roundToInt()) / 100f + columnWidth = trueSize + } + private fun setSpan(force: Boolean = false) { if ((spanCount == 0 || force) && columnWidth > 0) { val dpWidth = (measuredWidth.pxToDp / 100f).roundToInt() @@ -57,4 +89,9 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att spanCount = count } } + + companion object { + private const val MULTIPLE_PERCENT = 0.25f + const val MULTIPLE = MULTIPLE_PERCENT * 100 + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/BaseTabbedScrollView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/BaseTabbedScrollView.kt new file mode 100644 index 0000000000..b434024bd9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/BaseTabbedScrollView.kt @@ -0,0 +1,48 @@ +package eu.kanade.tachiyomi.widget + +import android.content.Context +import android.util.AttributeSet +import androidx.core.widget.NestedScrollView +import androidx.viewbinding.ViewBinding +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.library.LibraryController +import eu.kanade.tachiyomi.ui.reader.ReaderActivity +import eu.kanade.tachiyomi.ui.recents.RecentsController +import eu.kanade.tachiyomi.util.view.RecyclerWindowInsetsListener +import uy.kohesive.injekt.injectLazy + +abstract class BaseTabbedScrollView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + NestedScrollView(context, attrs) { + + lateinit var binding: VB + private set + init { + clipToPadding = false + } + internal val preferences by injectLazy() + + abstract fun initGeneralPreferences() + abstract fun inflateBinding(): VB + + override fun onFinishInflate() { + super.onFinishInflate() + binding = inflateBinding() + setOnApplyWindowInsetsListener(RecyclerWindowInsetsListener) + initGeneralPreferences() + } +} + +abstract class BaseRecentsDisplayView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + BaseTabbedScrollView(context, attrs) { + var controller: RecentsController? = null +} + +abstract class BaseLibraryDisplayView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + BaseTabbedScrollView(context, attrs) { + var controller: LibraryController? = null +} + +abstract class BaseReaderSettingsView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + BaseTabbedScrollView(context, attrs) { + lateinit var activity: ReaderActivity +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/CustomLayoutPicker.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/CustomLayoutPicker.kt index 40f7b8ba8a..f153ffc2c8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/CustomLayoutPicker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/CustomLayoutPicker.kt @@ -14,10 +14,10 @@ class CustomLayoutPickerActivity : FilePickerActivity() { override fun getFragment(startPath: String?, mode: Int, allowMultiple: Boolean, allowCreateDir: Boolean): AbstractFilePickerFragment { - val fragment = CustomLayoutFilePickerFragment() - fragment.setArgs(startPath, mode, allowMultiple, allowCreateDir) - return fragment - } + val fragment = CustomLayoutFilePickerFragment() + fragment.setArgs(startPath, mode, allowMultiple, allowCreateDir) + return fragment + } } class CustomLayoutFilePickerFragment : FilePickerFragment() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/DialogCheckboxView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/DialogCheckboxView.kt deleted file mode 100644 index dd99d9bc5b..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/DialogCheckboxView.kt +++ /dev/null @@ -1,29 +0,0 @@ -package eu.kanade.tachiyomi.widget - -import android.content.Context -import android.util.AttributeSet -import android.widget.LinearLayout -import androidx.annotation.StringRes -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.util.view.inflate -import kotlinx.android.synthetic.main.common_dialog_with_checkbox.view.* - -class DialogCheckboxView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - LinearLayout(context, attrs) { - - init { - addView(inflate(R.layout.common_dialog_with_checkbox)) - } - - fun setDescription(@StringRes id: Int) { - description.text = context.getString(id) - } - - fun setOptionDescription(@StringRes id: Int) { - checkbox_option.text = context.getString(id) - } - - fun isChecked(): Boolean { - return checkbox_option.isChecked - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/E2EBottomSheetDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/E2EBottomSheetDialog.kt new file mode 100644 index 0000000000..938a801851 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/E2EBottomSheetDialog.kt @@ -0,0 +1,71 @@ +package eu.kanade.tachiyomi.widget + +import android.app.Activity +import android.os.Build +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.view.updateLayoutParams + +/** + * Edge to Edge BottomSheetDiolag that uses a custom theme and settings to extend pass the nav bar + */ +@Suppress("LeakingThis") +abstract class E2EBottomSheetDialog(activity: Activity) : + BottomSheetDialog(activity, R.style.BottomSheetDialogTheme) { + protected val binding: VB + + protected val sheetBehavior: BottomSheetBehavior<*> + protected open var recyclerView: RecyclerView? = null + + init { + binding = createBinding(activity.layoutInflater) + setContentView(binding.root) + + sheetBehavior = BottomSheetBehavior.from(binding.root.parent as ViewGroup) + + val contentView = binding.root + + window?.navigationBarColor = activity.window.navigationBarColor + val isLight = (activity.window?.decorView?.systemUiVisibility ?: 0) and View + .SYSTEM_UI_FLAG_LIGHT_STATUS_BAR == View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isLight) { + window?.decorView?.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR + } + val insets = activity.window.decorView.rootWindowInsets + (contentView.parent as View).background = null + contentView.post { + (contentView.parent as View).background = null + } + contentView.updateLayoutParams { + leftMargin = insets.systemWindowInsetLeft + rightMargin = insets.systemWindowInsetRight + } + contentView.requestLayout() + } + + override fun onStart() { + super.onStart() + recyclerView?.let { recyclerView -> + recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + super.onScrollStateChanged(recyclerView, newState) + if (newState == RecyclerView.SCROLL_STATE_IDLE || + newState == RecyclerView.SCROLL_STATE_SETTLING + ) { + sheetBehavior.isDraggable = true + } else { + sheetBehavior.isDraggable = !recyclerView.canScrollVertically(-1) + } + } + }) + } + } + + abstract fun createBinding(inflater: LayoutInflater): VB +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/EmptyView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/EmptyView.kt index 320ad10130..6ef77b2142 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/EmptyView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/EmptyView.kt @@ -2,28 +2,75 @@ package eu.kanade.tachiyomi.widget import android.content.Context import android.util.AttributeSet +import android.view.LayoutInflater import android.widget.RelativeLayout +import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.core.view.isVisible import com.google.android.material.button.MaterialButton import com.mikepenz.iconics.typeface.IIcon import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.CommonViewEmptyBinding +import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.iconicsDrawable -import eu.kanade.tachiyomi.util.view.gone -import eu.kanade.tachiyomi.util.view.visible -import kotlinx.android.synthetic.main.common_view_empty.view.* +import eu.kanade.tachiyomi.util.view.setVectorCompat class EmptyView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : RelativeLayout(context, attrs) { - init { - inflate(context, R.layout.common_view_empty, this) - } + private val binding: CommonViewEmptyBinding = + CommonViewEmptyBinding.inflate(LayoutInflater.from(context), this, true) /** * Hide the information view */ fun hide() { - this.gone() + this.isVisible = false + } + + /** + * Show the information view + * @param textResource text of information view + */ + fun show( + @DrawableRes drawable: Int, + @StringRes textResource: Int, + actions: List? = null, + ) { + show(drawable, context.getString(textResource), actions) + } + + /** + * Show the information view + * @param drawable icon of information view + * @param textResource text of information view + */ + fun show(@DrawableRes drawable: Int, message: String, actions: List? = null) { + binding.imageView.setVectorCompat( + drawable, + context.getResourceColor(android.R.attr.textColorHint) + ) + binding.textLabel.text = message + + binding.actionsContainer.removeAllViews() + if (!actions.isNullOrEmpty()) { + actions.forEach { + val button = ( + inflate( + context, + R.layout.material_text_button, + null + ) as MaterialButton + ).apply { + setText(it.resId) + setOnClickListener(it.listener) + } + + binding.actionsContainer.addView(button) + } + } + + this.isVisible = true } /** @@ -41,9 +88,10 @@ class EmptyView @JvmOverloads constructor(context: Context, attrs: AttributeSet? */ fun showMedium(icon: IIcon, message: String, actions: List? = null) { - image_view.setImageDrawable( + binding.imageView.setImageDrawable( context.iconicsDrawable( - icon, color = android.R.attr.textColorHint, + icon, + color = android.R.attr.textColorHint, size = 48 ) ) @@ -51,18 +99,20 @@ class EmptyView @JvmOverloads constructor(context: Context, attrs: AttributeSet? } fun show(icon: IIcon, message: String, actions: List? = null) { - image_view.setImageDrawable( + binding.imageView.setImageDrawable( context.iconicsDrawable( - icon, color = android.R.attr.textColorHint, size = 128 + icon, + color = android.R.attr.textColorHint, + size = 128 ) ) iconicsAfter(message, actions) } fun iconicsAfter(message: String, actions: List? = null) { - text_label.text = message + binding.textLabel.text = message - actions_container.removeAllViews() + binding.actionsContainer.removeAllViews() if (!actions.isNullOrEmpty()) { actions.forEach { val button = ( @@ -76,15 +126,14 @@ class EmptyView @JvmOverloads constructor(context: Context, attrs: AttributeSet? setOnClickListener(it.listener) } - actions_container.addView(button) + binding.actionsContainer.addView(button) } } - - this.visible() + this.isVisible = true } data class Action( @StringRes val resId: Int, - val listener: OnClickListener + val listener: OnClickListener, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/GifViewTarget.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/GifViewTarget.kt index c127a200b8..408688c115 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/GifViewTarget.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/GifViewTarget.kt @@ -4,20 +4,19 @@ import android.graphics.drawable.Drawable import android.view.View import android.view.ViewGroup import android.widget.ImageView +import androidx.core.view.isVisible import coil.target.ImageViewTarget -import eu.kanade.tachiyomi.util.view.gone -import eu.kanade.tachiyomi.util.view.visible class GifViewTarget(view: ImageView, private val progressBar: View?, private val decodeErrorLayout: ViewGroup?) : ImageViewTarget(view) { override fun onError(error: Drawable?) { - progressBar?.gone() - decodeErrorLayout?.visible() + progressBar?.isVisible = false + decodeErrorLayout?.isVisible = true } override fun onSuccess(result: Drawable) { - progressBar?.gone() - decodeErrorLayout?.gone() + progressBar?.isVisible = false + decodeErrorLayout?.isVisible = false super.onSuccess(result) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/MaterialSpinnerView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/MaterialSpinnerView.kt new file mode 100644 index 0000000000..2730879588 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/MaterialSpinnerView.kt @@ -0,0 +1,261 @@ +package eu.kanade.tachiyomi.widget + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.Gravity +import android.view.LayoutInflater +import android.view.MenuItem +import android.widget.FrameLayout +import androidx.annotation.ArrayRes +import androidx.annotation.StringRes +import androidx.appcompat.view.menu.MenuBuilder +import androidx.appcompat.widget.PopupMenu +import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils +import androidx.core.view.forEach +import androidx.core.view.get +import com.tfcporciuncula.flow.Preference +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.MaterialSpinnerViewBinding +import eu.kanade.tachiyomi.util.lang.tintText +import eu.kanade.tachiyomi.util.system.getResourceColor +import eu.kanade.tachiyomi.util.system.toast +import kotlin.math.max + +class MaterialSpinnerView @JvmOverloads constructor(context: Context, attrs: AttributeSet?) : + FrameLayout(context, attrs) { + + private var entries = emptyList() + var selectedPosition = 0 + private set + private var pref: Preference? = null + private var prefOffset = 0 + private var popup: PopupMenu? = null + var title: CharSequence + get() { + return binding.titleView.text + } + set(value) { + binding.titleView.text = value + } + + private val blendedAccent = ColorUtils.blendARGB( + context.getResourceColor(android.R.attr.colorAccent), + context.getResourceColor(android.R.attr.textColorPrimary), + 0.5f + ) + + var onItemSelectedListener: ((Int) -> Unit)? = null + set(value) { + field = value + if (value != null) { + popup = makeSettingsPopup() + setOnTouchListener(popup?.dragToOpenListener) + setOnClickListener { + popup?.show() + } + } + } + + private val binding = MaterialSpinnerViewBinding.inflate( + LayoutInflater.from(context), + this, + false + ) + + init { + addView(binding.root) + val a = context.obtainStyledAttributes(attrs, R.styleable.MaterialSpinnerView, 0, 0) + + val str = a.getString(R.styleable.MaterialSpinnerView_title) ?: "" + title = str + + val entries = (a.getTextArray(R.styleable.MaterialSpinnerView_android_entries) ?: emptyArray()).map { it.toString() } + this.entries = entries + + val maxLines = a.getInt(R.styleable.MaterialSpinnerView_android_maxLines, Int.MAX_VALUE) + binding.titleView.maxLines = maxLines + this.entries = entries + + binding.detailView.text = entries.firstOrNull().orEmpty() + + if (entries.isNotEmpty()) { + popup = makeSettingsPopup() + setOnTouchListener(popup?.dragToOpenListener) + setOnClickListener { + popup?.show() + } + } + + a.recycle() + } + + fun setEntries(entries: List) { + this.entries = entries + popup = makeSettingsPopup() + setOnTouchListener(popup?.dragToOpenListener) + setOnClickListener { + popup?.show() + } + } + + fun setSelection(selection: Int) { + popup?.menu?.get(selectedPosition)?.let { + it.icon = ContextCompat.getDrawable(context, R.drawable.ic_blank_24dp) + it.title = entries[selectedPosition] + } + selectedPosition = selection + popup?.menu?.get(selectedPosition)?.let { + it.icon = tintedCheck() + it.title = it.title?.tintText(blendedAccent) + } + binding.detailView.text = entries.getOrNull(selection).orEmpty() + } + + fun setDisabledState(@StringRes messageRes: Int = 0) { + alpha = 0.5f + popup = null + setOnTouchListener(null) + setOnClickListener { + if (messageRes != 0) { + context.toast(messageRes) + } + } + } + + fun bindToPreference(pref: Preference, offset: Int = 0, block: ((Int) -> Unit)? = null) { + setSelection(pref.get() - offset) + this.pref = pref + prefOffset = offset + popup = makeSettingsPopup(pref, prefOffset, block) + setOnTouchListener(popup?.dragToOpenListener) + setOnClickListener { + popup?.show() + } + } + + inline fun > bindToPreference(pref: Preference) { + val enumConstants = T::class.java.enumConstants + enumConstants?.indexOf(pref.get())?.let { setSelection(it) } + val popup = makeSettingsPopup(pref) + setOnTouchListener(popup.dragToOpenListener) + setOnClickListener { + popup.show() + } + } + + fun bindToIntPreference( + pref: Preference, + @ArrayRes intValuesResource: Int, + block: ((Int) -> Unit)? = null + ) { + this.pref = pref + prefOffset = 0 + val intValues = resources.getStringArray(intValuesResource).map { it.toIntOrNull() } + setSelection(max(0, intValues.indexOf(pref.get()))) + popup = makeSettingsPopup(pref, intValues, block) + setOnTouchListener(popup?.dragToOpenListener) + setOnClickListener { + popup?.show() + } + } + + inline fun > makeSettingsPopup(preference: Preference): PopupMenu { + val popup = popup() + + // Set a listener so we are notified if a menu item is clicked + popup.setOnMenuItemClickListener { menuItem -> + val pos = menuClicked(menuItem) + onItemSelectedListener?.invoke(pos) + true + } + // Set a listener so we are notified if a menu item is clicked + popup.setOnMenuItemClickListener { menuItem -> + val enumConstants = T::class.java.enumConstants + val pos = menuClicked(menuItem) + enumConstants?.get(pos)?.let { preference.set(it) } + true + } + return popup + } + + private fun makeSettingsPopup( + preference: Preference, + intValues: List, + block: ((Int) -> Unit)? = null + ): PopupMenu { + val popup = popup() + // Set a listener so we are notified if a menu item is clicked + popup.setOnMenuItemClickListener { menuItem -> + val pos = menuClicked(menuItem) + preference.set(intValues[pos] ?: 0) + block?.invoke(pos) + true + } + return popup + } + + private fun makeSettingsPopup( + preference: Preference, + offset: Int = 0, + block: ((Int) -> Unit)? = null + ): PopupMenu { + val popup = popup() + // Set a listener so we are notified if a menu item is clicked + popup.setOnMenuItemClickListener { menuItem -> + val pos = menuClicked(menuItem) + preference.set(pos + offset) + block?.invoke(pos) + true + } + return popup + } + + private fun makeSettingsPopup(): PopupMenu { + val popup = popup() + + // Set a listener so we are notified if a menu item is clicked + popup.setOnMenuItemClickListener { menuItem -> + val pos = menuClicked(menuItem) + onItemSelectedListener?.invoke(pos) + true + } + return popup + } + + fun menuClicked(menuItem: MenuItem): Int { + val pos = menuItem.itemId + setSelection(pos) + return pos + } + + @SuppressLint("RestrictedApi") + fun popup(): PopupMenu { + val popup = PopupMenu(context, this, Gravity.END) + entries.forEachIndexed { index, entry -> + popup.menu.add(0, index, 0, entry) + } + if (popup.menu is MenuBuilder) { + val m = popup.menu as MenuBuilder + m.setOptionalIconsVisible(true) + } + popup.menu.forEach { + it.icon = ContextCompat.getDrawable(context, R.drawable.ic_blank_24dp) + } + popup.menu.getItem(selectedPosition)?.let { menuItem -> + menuItem.icon = tintedCheck() + menuItem.title = + menuItem.title?.tintText(blendedAccent) + } + this.popup = popup + return popup + } + + private fun tintedCheck(): Drawable? { + return ContextCompat.getDrawable(context, R.drawable.ic_check_24dp)?.mutate()?.apply { + setTint(blendedAccent) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/MenuSheetItemView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/MenuSheetItemView.kt new file mode 100644 index 0000000000..6de84c68cd --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/MenuSheetItemView.kt @@ -0,0 +1,133 @@ +package eu.kanade.tachiyomi.widget + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.LinearLayout +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.view.isGone +import androidx.core.widget.TextViewCompat +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.MenuSheetItemBinding +import eu.kanade.tachiyomi.util.system.getResourceColor + +class MenuSheetItemView @JvmOverloads constructor(context: Context, attrs: AttributeSet?) : + LinearLayout(context, attrs) { + private val mText: String + private val mIconRes: Int + private val mEndIconRes: Int + private val mMaxLines: Int + + private var binding: MenuSheetItemBinding? = null + + init { + val a = context.obtainStyledAttributes(attrs, R.styleable.MenuSheetItemView, 0, 0) + + val str = a.getString(R.styleable.MenuSheetItemView_android_text) ?: "" + mText = str + + val d = a.getResourceId(R.styleable.MenuSheetItemView_icon, 0) + mIconRes = d + + val e = a.getResourceId(R.styleable.MenuSheetItemView_endIcon, 0) + mEndIconRes = e + + val m = a.getInt(R.styleable.MenuSheetItemView_android_maxLines, Int.MAX_VALUE) + mMaxLines = m + + a.recycle() + } + + override fun onFinishInflate() { + super.onFinishInflate() + binding = try { + MenuSheetItemBinding.bind(this) + } catch (e: Exception) { + MenuSheetItemBinding.inflate(LayoutInflater.from(context), this, true) + } + text = mText + setIcon(mIconRes) + setEndIcon(mEndIconRes) + binding?.itemTextView?.maxLines = mMaxLines + } + + var text: CharSequence? + get() = binding?.itemTextView?.text + set(value) { + binding?.itemTextView?.text = value + } + + var textSize: Float + get() = binding?.itemTextView?.textSize ?: 0f + set(value) { + binding?.itemTextView?.textSize = value + } + + fun setText(@StringRes res: Int) { + text = context.getString(res) + } + + fun selectWithEndIcon(@DrawableRes endDrawableRes: Int) { + isSelected = true + setEndIcon(endDrawableRes) + } + + override fun setSelected(selected: Boolean) { + super.setSelected(selected) + if (isSelected) { + setIconColor(context.getResourceColor(R.attr.colorAccent)) + setTextColor(context.getResourceColor(R.attr.colorAccent)) + } else { + setTextColor(context.getResourceColor(android.R.attr.textColorPrimary)) + setIconColor(context.getResourceColor(android.R.attr.textColorPrimary)) + setEndIcon(0) + } + } + + fun setTextColor(@ColorInt color: Int) { + binding?.itemTextView?.setTextColor(color) + } + + fun setIconColor(@ColorInt color: Int) = binding?.itemTextView?.let { + TextViewCompat.setCompoundDrawableTintList( + it, + ColorStateList.valueOf(color) + ) + } + + fun setIcon(@DrawableRes res: Int) { + binding?.itemTextView?.setCompoundDrawablesRelativeWithIntrinsicBounds( + res, + 0, + 0, + 0 + ) + } + + fun setIcon(drawable: Drawable?) { + binding?.itemTextView?.setCompoundDrawablesRelativeWithIntrinsicBounds( + drawable, + null, + null, + null, + ) + } + + fun getIcon(): Drawable? { + return binding?.itemTextView?.compoundDrawablesRelative?.getOrNull(0) + } + + fun setEndIcon(@DrawableRes res: Int) { + binding?.menuEndItem?.isGone = res == 0 + binding?.menuEndItem?.setImageResource(res) + } + + fun setEndIcon(drawable: Drawable?) { + binding?.menuEndItem?.isGone = drawable == null + binding?.menuEndItem?.setImageDrawable(drawable) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/NegativeSeekBar.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/NegativeSeekBar.kt index 7736d4ff4c..a0c8242b56 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/NegativeSeekBar.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/NegativeSeekBar.kt @@ -17,7 +17,9 @@ class NegativeSeekBar @JvmOverloads constructor(context: Context, attrs: Attribu init { val styledAttributes = context.obtainStyledAttributes( attrs, - R.styleable.NegativeSeekBar, 0, 0 + R.styleable.NegativeSeekBar, + 0, + 0 ) try { @@ -27,19 +29,21 @@ class NegativeSeekBar @JvmOverloads constructor(context: Context, attrs: Attribu styledAttributes.recycle() } - super.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { - override fun onProgressChanged(seekBar: SeekBar?, value: Int, fromUser: Boolean) { - listener?.onProgressChanged(seekBar, minValue + value, fromUser) - } + super.setOnSeekBarChangeListener( + object : OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, value: Int, fromUser: Boolean) { + listener?.onProgressChanged(seekBar, minValue + value, fromUser) + } - override fun onStartTrackingTouch(p0: SeekBar?) { - listener?.onStartTrackingTouch(p0) - } + override fun onStartTrackingTouch(p0: SeekBar?) { + listener?.onStartTrackingTouch(p0) + } - override fun onStopTrackingTouch(p0: SeekBar?) { - listener?.onStopTrackingTouch(p0) + override fun onStopTrackingTouch(p0: SeekBar?) { + listener?.onStopTrackingTouch(p0) + } } - }) + ) } override fun setProgress(progress: Int) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/RevealAnimationView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/RevealAnimationView.kt index 716e18c8b6..d9c3795776 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/RevealAnimationView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/RevealAnimationView.kt @@ -23,19 +23,25 @@ class RevealAnimationView @JvmOverloads constructor(context: Context, attrs: Att // Create the animation (the final radius is zero). val anim = ViewAnimationUtils.createCircularReveal( - this, centerX, centerY, initialRadius.toFloat(), 0f + this, + centerX, + centerY, + initialRadius.toFloat(), + 0f ) // Set duration of animation. anim.duration = 500 // make the view invisible when the animation is done - anim.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - super.onAnimationEnd(animation) - this@RevealAnimationView.visibility = View.INVISIBLE + anim.addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + super.onAnimationEnd(animation) + this@RevealAnimationView.visibility = View.INVISIBLE + } } - }) + ) anim.start() } @@ -56,7 +62,11 @@ class RevealAnimationView @JvmOverloads constructor(context: Context, attrs: Att // Create animation val anim = ViewAnimationUtils.createCircularReveal( - this, centerX, centerY, 0f, height.toFloat() + this, + centerX, + centerY, + 0f, + height.toFloat() ) // Set duration of animation diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleNavigationView.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleNavigationView.kt index bf48a009ad..b5d60d9283 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleNavigationView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/SimpleNavigationView.kt @@ -9,7 +9,6 @@ import android.widget.CheckBox import android.widget.CheckedTextView import android.widget.EditText import android.widget.RadioButton -import android.widget.Spinner import android.widget.TextView import androidx.appcompat.widget.TintTypedArray import androidx.core.view.ViewCompat @@ -25,7 +24,7 @@ import eu.kanade.tachiyomi.R as TR open class SimpleNavigationView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = 0 + defStyleAttr: Int = 0, ) : ScrimInsetsFrameLayout(context, attrs, defStyleAttr) { @@ -42,20 +41,24 @@ open class SimpleNavigationView @JvmOverloads constructor( init { // Custom attributes val a = TintTypedArray.obtainStyledAttributes( - context, attrs, - R.styleable.NavigationView, defStyleAttr, + context, + attrs, + R.styleable.NavigationView, + defStyleAttr, R.style.Widget_Design_NavigationView ) ViewCompat.setBackground( - this, a.getDrawable(R.styleable.NavigationView_android_background) + this, + a.getDrawable(R.styleable.NavigationView_android_background) ) if (a.hasValue(R.styleable.NavigationView_elevation)) { ViewCompat.setElevation( this, a.getDimensionPixelSize( - R.styleable.NavigationView_elevation, 0 + R.styleable.NavigationView_elevation, + 0 ).toFloat() ) } @@ -81,7 +84,8 @@ open class SimpleNavigationView @JvmOverloads constructor( override fun onMeasure(widthSpec: Int, heightSpec: Int) { val width = when (MeasureSpec.getMode(widthSpec)) { MeasureSpec.AT_MOST -> MeasureSpec.makeMeasureSpec( - min(MeasureSpec.getSize(widthSpec), maxWidth), MeasureSpec.EXACTLY + min(MeasureSpec.getSize(widthSpec), maxWidth), + MeasureSpec.EXACTLY ) MeasureSpec.UNSPECIFIED -> MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.EXACTLY) else -> widthSpec @@ -149,8 +153,7 @@ open class SimpleNavigationView @JvmOverloads constructor( class SpinnerHolder(parent: ViewGroup, listener: OnClickListener? = null) : ClickableHolder(parent.inflate(TR.layout.navigation_view_spinner), listener) { - val text: TextView = itemView.findViewById(TR.id.nav_view_item_text) - val spinner: Spinner = itemView.findViewById(TR.id.nav_view_item) + val spinnerView: MaterialSpinnerView = itemView.findViewById(TR.id.nav_view_item) } class EditTextHolder(parent: ViewGroup) : diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/TabbedBottomSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/TabbedBottomSheet.kt new file mode 100644 index 0000000000..57f159d6f1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/TabbedBottomSheet.kt @@ -0,0 +1,120 @@ +package eu.kanade.tachiyomi.widget + +import android.app.Activity +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.widget.NestedScrollView +import androidx.viewpager.widget.ViewPager +import com.google.android.material.tabs.TabLayout +import eu.kanade.tachiyomi.databinding.TabbedBottomSheetBinding +import eu.kanade.tachiyomi.util.system.dpToPx +import eu.kanade.tachiyomi.util.view.expand +import kotlin.math.max + +abstract class TabbedBottomSheetDialog(private val activity: Activity) : + E2EBottomSheetDialog(activity) { + + override fun createBinding(inflater: LayoutInflater) = + TabbedBottomSheetBinding.inflate(inflater) + + open var offset = -1 + + init { + val height = activity.window.decorView.rootWindowInsets.systemWindowInsetTop + binding.pager.maxHeight = activity.window.decorView.height - height - 125.dpToPx + + val adapter = TabbedSheetAdapter() + binding.pager.offscreenPageLimit = 2 + binding.pager.adapter = adapter + binding.tabs.setupWithViewPager(binding.pager) + } + + override fun onStart() { + super.onStart() + sheetBehavior.skipCollapsed = true + sheetBehavior.expand() + getTabViews().forEachIndexed { index, nestedScrollView -> + val view = nestedScrollView as? NestedScrollView + view?.isNestedScrollingEnabled = binding.pager.currentItem == index + view?.requestLayout() + } + binding.tabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab?) { + val view = getTabViews()[tab?.position ?: 0] as? NestedScrollView + view?.isNestedScrollingEnabled = true + view?.requestLayout() + } + + override fun onTabUnselected(tab: TabLayout.Tab?) { + val view = getTabViews()[tab?.position ?: 0] as? NestedScrollView + view?.isNestedScrollingEnabled = false + view?.requestLayout() + } + + override fun onTabReselected(tab: TabLayout.Tab?) { + val view = getTabViews()[tab?.position ?: 0] as? NestedScrollView + view?.isNestedScrollingEnabled = true + view?.requestLayout() + } + }) + } + + abstract fun getTabViews(): List + + abstract fun getTabTitles(): List + + private inner class TabbedSheetAdapter : ViewPagerAdapter() { + + override fun createView(container: ViewGroup, position: Int): View { + return getTabViews()[position] + } + + override fun getCount(): Int { + return getTabViews().size + } + + override fun getPageTitle(position: Int): CharSequence { + return activity.resources!!.getString(getTabTitles()[position]) + } + } +} + +class MeasuredViewPager @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + ViewPager(context, attrs) { + + var maxHeight = 0 + set(value) { + field = value + requestLayout() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + var heightSpec = heightMeasureSpec + super.onMeasure(widthMeasureSpec, heightSpec) + var height = 0 + val childWidthSpec = MeasureSpec.makeMeasureSpec( + max( + 0, + MeasureSpec.getSize(widthMeasureSpec) - + paddingLeft - paddingRight + ), + MeasureSpec.getMode(widthMeasureSpec) + ) + for (i in 0 until childCount) { + val child = getChildAt(i) + child.measure(childWidthSpec, MeasureSpec.UNSPECIFIED) + val h = child.measuredHeight + if (h > height) height = h + } + if (height != 0) { + heightSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) + } + if (maxHeight < height + (rootWindowInsets?.systemWindowInsetBottom ?: 0)) { + heightSpec = MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST) + } + super.onMeasure(widthMeasureSpec, heightSpec) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/IntListMatPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/IntListMatPreference.kt index f33b372aa3..3dcab32510 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/IntListMatPreference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/IntListMatPreference.kt @@ -24,22 +24,22 @@ class IntListMatPreference @JvmOverloads constructor( set(value) { entries = value.map { context.getString(it) } } private var defValue: Int = 0 var entries: List = emptyList() + var customSelectedValue: Int? = null + + override var customSummaryProvider: SummaryProvider? = SummaryProvider { + val index = entryValues.indexOf(prefs.getInt(key, defValue).getOrDefault()) + if (entries.isEmpty() || index == -1) "" + else entries[index] + } override fun onSetInitialValue(defaultValue: Any?) { super.onSetInitialValue(defaultValue) defValue = defaultValue as? Int ?: defValue } - override fun getSummary(): CharSequence { - if (customSummary != null) return customSummary!! - if (key == null) return super.getSummary() - val index = entryValues.indexOf(prefs.getInt(key, defValue).getOrDefault()) - return if (entries.isEmpty() || index == -1) "" - else entries[index] - } override fun dialog(): MaterialDialog { return super.dialog().apply { - val default = entryValues.indexOf(prefs.getInt(key, defValue).getOrDefault()) + val default = entryValues.indexOf(customSelectedValue ?: prefs.getInt(key, defValue).getOrDefault()) listItemsSingleChoice( items = entries, waitForPositiveButton = false, @@ -47,10 +47,11 @@ class IntListMatPreference @JvmOverloads constructor( ) { _, pos, _ -> val value = entryValues[pos] - if (key != null) + if (key != null) { prefs.getInt(key, defValue).set(value) + } callChangeListener(value) - this@IntListMatPreference.summary = this@IntListMatPreference.summary + notifyChanged() dismiss() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/ListMatPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/ListMatPreference.kt index dbe7c70df9..e03c5f4c64 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/ListMatPreference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/ListMatPreference.kt @@ -23,17 +23,17 @@ open class ListMatPreference @JvmOverloads constructor( var entriesRes: Array get() = emptyArray() set(value) { entries = value.map { context.getString(it) } } - protected var defValue: String = "" + private var defValue: String = "" var entries: List = emptyList() override fun onSetInitialValue(defaultValue: Any?) { super.onSetInitialValue(defaultValue) defValue = defaultValue as? String ?: defValue } - override fun getSummary(): CharSequence { - if (customSummary != null) return customSummary!! + + override var customSummaryProvider: SummaryProvider? = SummaryProvider { val index = entryValues.indexOf(prefs.getStringPref(key, defValue).getOrDefault()) - return if (entries.isEmpty() || index == -1) "" + if (entries.isEmpty() || index == -1) "" else entries[index] } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt index 6c3d780dc6..d22a8f8b38 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginDialogPreference.kt @@ -4,17 +4,16 @@ import android.app.Dialog import android.os.Bundle import android.view.View import androidx.annotation.StringRes +import androidx.core.view.isVisible import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.customview.customView +import com.afollestad.materialdialogs.customview.getCustomView import com.bluelinelabs.conductor.ControllerChangeHandler import com.bluelinelabs.conductor.ControllerChangeType import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.databinding.PrefAccountLoginBinding import eu.kanade.tachiyomi.ui.base.controller.DialogController -import kotlinx.android.synthetic.main.pref_account_login.view.* -import kotlinx.android.synthetic.main.pref_account_login.view.login -import kotlinx.android.synthetic.main.pref_site_login.view.* -import kotlinx.android.synthetic.main.pref_site_login.view.username_input import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -31,6 +30,7 @@ abstract class LoginDialogPreference( var v: View? = null private set + protected lateinit var binding: PrefAccountLoginBinding val preferences: PreferencesHelper by injectLazy() val scope = CoroutineScope(Job() + Dispatchers.Main) @@ -43,6 +43,7 @@ abstract class LoginDialogPreference( val dialog = MaterialDialog(activity!!).apply { customView(R.layout.pref_account_login, scrollable = false) } + binding = PrefAccountLoginBinding.bind(dialog.getCustomView()) onViewCreated(dialog.view) @@ -51,21 +52,16 @@ abstract class LoginDialogPreference( fun onViewCreated(view: View) { v = view.apply { - if (usernameLabelRes != null) { - username_input.hint = view.context.getString(usernameLabelRes) + binding.usernameInput.hint = view.context.getString(usernameLabelRes) } - login.setOnClickListener { + binding.login.setOnClickListener { checkLogin() } - two_factor_check?.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - two_factor_holder.visibility = View.VISIBLE - } else { - two_factor_holder.visibility = View.GONE - } + binding.twoFactorCheck.setOnCheckedChangeListener { _, isChecked -> + binding.twoFactorCheck.isVisible = isChecked } setCredentialsOnView(this) diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginPreference.kt index c6afc9ef1e..a7050f5633 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginPreference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/LoginPreference.kt @@ -4,10 +4,10 @@ import android.content.Context import android.content.res.ColorStateList import android.graphics.Color import android.util.AttributeSet +import android.widget.ImageView import androidx.preference.Preference import androidx.preference.PreferenceViewHolder import eu.kanade.tachiyomi.R -import kotlinx.android.synthetic.main.pref_widget_imageview.view.* class LoginPreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : Preference(context, attrs) { @@ -19,12 +19,14 @@ class LoginPreference @JvmOverloads constructor(context: Context, attrs: Attribu override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) - holder.itemView.image_view.setImageResource( - if (getPersistedString("").isNullOrEmpty()) android.R.color.transparent - else R.drawable.ic_done_24dp - ) - holder.itemView.image_view.imageTintList = - ColorStateList.valueOf(Color.parseColor("#FF4CAF50")) + (holder.findViewById(R.id.image_view) as? ImageView)?.let { imageView -> + imageView.setImageResource( + if (getPersistedString("").isNullOrEmpty()) android.R.color.transparent + else R.drawable.ic_done_24dp + ) + imageView.imageTintList = + ColorStateList.valueOf(Color.parseColor("#FF4CAF50")) + } } public override fun notifyChanged() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MangadexLoginDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MangadexLoginDialog.kt index e585433872..d5225379ae 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MangadexLoginDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MangadexLoginDialog.kt @@ -1,17 +1,17 @@ package eu.kanade.tachiyomi.widget.preference -import android.app.Activity import android.app.Dialog import android.os.Bundle import android.view.View import br.com.simplepass.loadingbutton.animatedDrawables.ProgressType import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.customview.customView +import com.afollestad.materialdialogs.customview.getCustomView import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.databinding.PrefAccountLoginBinding import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.util.system.toast -import kotlinx.android.synthetic.main.pref_site_login.view.* import kotlinx.coroutines.launch import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -20,7 +20,7 @@ class MangadexLoginDialog(bundle: Bundle? = null) : LoginDialogPreference(bundle val source: Source by lazy { Injekt.get().getMangadex() } - constructor(source: Source, activity: Activity? = null) : this( + constructor(source: Source) : this( Bundle().apply { putLong( "key", @@ -31,8 +31,9 @@ class MangadexLoginDialog(bundle: Bundle? = null) : LoginDialogPreference(bundle override fun onCreateDialog(savedViewState: Bundle?): Dialog { val dialog = MaterialDialog(activity!!).apply { - customView(R.layout.pref_site_login, scrollable = false) + customView(R.layout.pref_account_login, scrollable = false) } + binding = PrefAccountLoginBinding.bind(dialog.getCustomView()) onViewCreated(dialog.view) @@ -40,21 +41,19 @@ class MangadexLoginDialog(bundle: Bundle? = null) : LoginDialogPreference(bundle } override fun setCredentialsOnView(view: View) = with(view) { - dialog_title.text = context.getString(R.string.log_in_to_, source.name) - username.setText(preferences.sourceUsername(source)) - password.setText(preferences.sourcePassword(source)) + binding.dialogTitle.text = context.getString(R.string.log_in_to_, source.name) + binding.username.setText(preferences.sourceUsername(source)) + binding.password.setText(preferences.sourcePassword(source)) } override fun checkLogin() { - v?.apply { - - login.apply { + binding.login.apply { progressType = ProgressType.INDETERMINATE startAnimation() } - if (username.text.isNullOrBlank() || password.text.isNullOrBlank() || (two_factor_check.isChecked && two_factor_edit.text.isNullOrBlank())) { + if (binding.username.text.isNullOrBlank() || binding.password.text.isNullOrBlank() || (binding.twoFactorCheck.isChecked && binding.twoFactorEdit.text.isNullOrBlank())) { errorResult() context.toast(R.string.fields_cannot_be_blank) return @@ -66,16 +65,16 @@ class MangadexLoginDialog(bundle: Bundle? = null) : LoginDialogPreference(bundle scope.launch { try { val result = source.login( - username.text.toString(), - password.text.toString(), - two_factor_edit.text.toString() + binding.username.text.toString(), + binding.password.text.toString(), + binding.twoFactorEdit.text.toString() ) if (result) { dialog?.dismiss() preferences.setSourceCredentials( source, - username.text.toString(), - password.text.toString() + binding.username.text.toString(), + binding.password.text.toString() ) context.toast(R.string.successfully_logged_in) } else { @@ -93,8 +92,8 @@ class MangadexLoginDialog(bundle: Bundle? = null) : LoginDialogPreference(bundle v?.apply { dialog?.setCancelable(true) dialog?.setCanceledOnTouchOutside(true) - login.revertAnimation { - login.text = activity!!.getText(R.string.unknown_error) + binding.login.revertAnimation { + binding.login.text = activity!!.getText(R.string.unknown_error) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MangadexLogoutDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MangadexLogoutDialog.kt index 969bf1ccbf..626b5b1cdd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MangadexLogoutDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MangadexLogoutDialog.kt @@ -5,13 +5,12 @@ import android.os.Bundle import com.afollestad.materialdialogs.MaterialDialog import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.databinding.PrefAccountLoginBinding import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.util.system.launchNow import eu.kanade.tachiyomi.util.system.toast -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy @@ -20,6 +19,7 @@ class MangadexLogoutDialog(bundle: Bundle? = null) : DialogController(bundle) { val source: Source by lazy { Injekt.get().getMangadex() } + protected lateinit var binding: PrefAccountLoginBinding val preferences: PreferencesHelper by injectLazy() constructor(source: Source) : this(Bundle().apply { putLong("key", source.id) }) @@ -29,8 +29,7 @@ class MangadexLogoutDialog(bundle: Bundle? = null) : DialogController(bundle) { .title(R.string.logout) .positiveButton(R.string.logout) { launchNow { - - val loggedOut = withContext(Dispatchers.IO) { source.logout() } + val loggedOut = source.logout() if (loggedOut.loggedOut) { preferences.setSourceCredentials(source, "", "") diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MatPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MatPreference.kt index 209bc55ec7..3caaea1138 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MatPreference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MatPreference.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.widget.preference import android.app.Activity import android.content.Context import android.util.AttributeSet +import androidx.annotation.StringRes import androidx.preference.Preference import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.callbacks.onDismiss @@ -21,24 +22,58 @@ open class MatPreference @JvmOverloads constructor( val prefs: PreferencesHelper = Injekt.get() private var isShowing = false - var customSummary: String? = null + + @StringRes var dialogTitleRes: Int? = null override fun onClick() { - if (!isShowing) + if (!isShowing) { dialog().apply { onDismiss { this@MatPreference.isShowing = false } }.show() + } isShowing = true } - override fun getSummary(): CharSequence { - return customSummary ?: super.getSummary() + protected open var customSummaryProvider: SummaryProvider? = null + set(value) { + field = value + summaryProvider = customSummaryProvider + } + + override fun getSummary(): CharSequence? { + customSummaryProvider?.let { return it.provideSummary(this) } + return super.getSummary() + } + + override fun setSummary(summaryResId: Int) { + if (summaryResId == 0) { + summaryProvider = customSummaryProvider + return + } else { + customSummaryProvider = null + summaryProvider = null + } + super.setSummary(summaryResId) + } + + override fun setSummary(summary: CharSequence?) { + if (summary == null) { + summaryProvider = customSummaryProvider + return + } else { + customSummaryProvider = null + summaryProvider = null + } + super.setSummary(summary) } open fun dialog(): MaterialDialog { return MaterialDialog(activity ?: context).apply { - if (title != null) + if (dialogTitleRes != null) { + title(res = dialogTitleRes) + } else if (title != null) { title(text = title.toString()) + } negativeButton(android.R.string.cancel) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MultiListMatPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MultiListMatPreference.kt index cd0523ff41..fb044a4143 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MultiListMatPreference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/MultiListMatPreference.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.util.AttributeSet +import androidx.preference.Preference.SummaryProvider import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.list.checkItem import com.afollestad.materialdialogs.list.isItemChecked @@ -20,57 +21,81 @@ class MultiListMatPreference @JvmOverloads constructor( ListMatPreference(activity, context, attrs) { var allSelectionRes: Int? = null - var customSummaryRes: Int - get() = 0 + + /** All item is always selected and uncheckabele */ + var allIsAlwaysSelected = false set(value) { - customSummary = context.getString(value) + field = value + notifyChanged() } - var defSet: Set = emptySet() + /** All Item is moved to bottom of list if true */ + var showAllLast = false + set(value) { + field = value + notifyChanged() + } - override fun getSummary(): CharSequence { - if (customSummary != null) return customSummary!! - return prefs.getStringSet(key, defSet).getOrDefault().mapNotNull { - if (entryValues.indexOf(it) == -1) null - else entryValues.indexOf(it) + if (allSelectionRes != null) 1 else 0 - }.toIntArray().joinToString(",") { - entries[it] + var defValue: Set = emptySet() + + override fun onSetInitialValue(defaultValue: Any?) { + super.onSetInitialValue(defaultValue) + defValue = (defaultValue as? Collection<*>).orEmpty().mapNotNull { it as? String }.toSet() + } + + override var customSummaryProvider: SummaryProvider? = SummaryProvider { + var values = prefs.getStringSet(key, defValue).getOrDefault().mapNotNull { value -> + entryValues.indexOf(value).takeUnless { it == -1 } + }.toIntArray().sorted().map { entries[it] } + allSelectionRes?.let { allRes -> + when { + values.isEmpty() -> values = listOf(context.getString(allRes)) + allIsAlwaysSelected && !showAllLast -> + values = + listOf(context.getString(allRes)) + values + allIsAlwaysSelected -> values = values + context.getString(allRes) + } } + values.joinToString() } @SuppressLint("CheckResult") override fun MaterialDialog.setItems() { - val set = prefs.getStringSet(key, defSet).getOrDefault() + val set = prefs.getStringSet(key, defValue).getOrDefault() var default = set.mapNotNull { if (entryValues.indexOf(it) == -1) null - else entryValues.indexOf(it) + if (allSelectionRes != null) 1 else 0 + else entryValues.indexOf(it) + if (allSelectionRes != null && !showAllLast) 1 else 0 } .toIntArray() - if (allSelectionRes != null && default.isEmpty()) default = intArrayOf(0) - val items = if (allSelectionRes != null) - (listOf(context.getString(allSelectionRes!!)) + entries) else entries + val items = if (allSelectionRes != null) { + if (showAllLast) entries + listOf(context.getString(allSelectionRes!!)) + else listOf(context.getString(allSelectionRes!!)) + entries + } else entries + val allPos = if (showAllLast) items.size - 1 else 0 + if (allSelectionRes != null && default.isEmpty()) default = intArrayOf(allPos) + else if (allSelectionRes != null && allIsAlwaysSelected) default += allPos positiveButton(android.R.string.ok) { val pos = mutableListOf() for (i in items.indices) - if (!(allSelectionRes != null && i == 0) && isItemChecked(i)) pos.add(i) - var value = pos.map { - entryValues[it - if (allSelectionRes != null) 1 else 0] - }?.toSet() - if (allSelectionRes != null && isItemChecked(0)) value = emptySet() + if (!(allSelectionRes != null && i == allPos) && isItemChecked(i)) pos.add(i) + var value = pos.mapNotNull { + entryValues.getOrNull(it - if (allSelectionRes != null && !showAllLast) 1 else 0) + }.toSet() + if (allSelectionRes != null && !allIsAlwaysSelected && isItemChecked(0)) value = emptySet() prefs.getStringSet(key, emptySet()).set(value) callChangeListener(value) - this@MultiListMatPreference.summary = this@MultiListMatPreference.summary + notifyChanged() } listItemsMultiChoice( items = items, allowEmptySelection = true, - disabledIndices = if (allSelectionRes != null) intArrayOf(0) else null, + disabledIndices = if (allSelectionRes != null) intArrayOf(allPos) else null, waitForPositiveButton = false, initialSelection = default ) { _, pos, _ -> - if (allSelectionRes != null) { - if (pos.isEmpty()) checkItem(0) - else uncheckItem(0) + if (allSelectionRes != null && !allIsAlwaysSelected) { + if (pos.isEmpty()) checkItem(allPos) + else uncheckItem(allPos) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SiteLoginPreference.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SiteLoginPreference.kt index b0bf791341..9bb03f98a6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SiteLoginPreference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SiteLoginPreference.kt @@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.widget.preference import android.content.Context import android.util.AttributeSet -import android.view.View +import android.widget.ImageView import androidx.core.content.ContextCompat import androidx.preference.Preference import androidx.preference.PreferenceViewHolder @@ -12,7 +12,6 @@ import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.source.online.HttpSource -import kotlinx.android.synthetic.main.pref_item_source.view.* class SiteLoginPreference @JvmOverloads constructor( context: Context, @@ -21,7 +20,7 @@ class SiteLoginPreference @JvmOverloads constructor( ) : Preference(context, attrs) { init { - layoutResource = R.layout.pref_item_source + widgetLayoutResource = R.layout.pref_widget_imageview } private var onLoginClick: () -> Unit = {} @@ -31,23 +30,19 @@ class SiteLoginPreference @JvmOverloads constructor( holder.itemView.setOnClickListener { onLoginClick() } - val loginFrame = holder.itemView.login_frame - val color = if (source.isLogged()) - ContextCompat.getColor(context, R.color.material_green_500) - else - ContextCompat.getColor(context, R.color.material_blue_grey_300) - - holder.itemView.login - .setImageDrawable( + + val color = when (source.isLogged()) { + true -> ContextCompat.getColor(context, R.color.gold) + false -> ContextCompat.getColor(context, R.color.material_on_surface_disabled) + } + + (holder.findViewById(R.id.image_view) as? ImageView)?.let { imageView -> + imageView.setImageDrawable( IconicsDrawable(context, CommunityMaterial.Icon.cmd_account_circle).apply { sizeDp = 24 colorInt = color } ) - - loginFrame.visibility = View.VISIBLE - loginFrame.setOnClickListener { - onLoginClick() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SwitchPreferenceCategory.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SwitchPreferenceCategory.kt index 9843d67216..91acacf6a2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SwitchPreferenceCategory.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/SwitchPreferenceCategory.kt @@ -119,10 +119,11 @@ class SwitchPreferenceCategory @JvmOverloads constructor( override fun onSetInitialValue(restoreValue: Boolean, defaultValue: Any?) { setChecked( - if (restoreValue) + if (restoreValue) { getPersistedBoolean(mChecked) - else + } else { defaultValue as Boolean + } ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt index 3179650f5f..f99445b260 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt @@ -8,7 +8,6 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.util.system.toast -import kotlinx.android.synthetic.main.pref_account_login.view.* import kotlinx.coroutines.launch import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -25,19 +24,18 @@ class TrackLoginDialog(@StringRes usernameLabelRes: Int? = null, bundle: Bundle? override fun setCredentialsOnView(view: View) = with(view) { val serviceName = context.getString(service.nameRes()) - dialog_title.text = context.getString(R.string.log_in_to_, serviceName) - username.setText(service.getUsername()) - password.setText(service.getPassword()) + binding.dialogTitle.text = context.getString(R.string.log_in_to_, serviceName) + binding.username.setText(service.getUsername()) + binding.password.setText(service.getPassword()) } override fun checkLogin() { - v?.apply { - login.apply { + binding.login.apply { progressType = ProgressType.INDETERMINATE startAnimation() } - if (username.text.isNullOrBlank() || password.text.isNullOrBlank()) { + if (binding.username.text.isNullOrBlank() || binding.password.text.isNullOrBlank()) { errorResult() context.toast(R.string.username_must_not_be_blank) return @@ -45,8 +43,8 @@ class TrackLoginDialog(@StringRes usernameLabelRes: Int? = null, bundle: Bundle? dialog?.setCancelable(false) dialog?.setCanceledOnTouchOutside(false) - val user = username.text.toString() - val pass = password.text.toString() + val user = binding.username.text.toString() + val pass = binding.password.text.toString() scope.launch { try { val result = service.login(user, pass) @@ -68,8 +66,8 @@ class TrackLoginDialog(@StringRes usernameLabelRes: Int? = null, bundle: Bundle? v?.apply { dialog?.setCancelable(true) dialog?.setCanceledOnTouchOutside(true) - login.revertAnimation { - login.text = activity!!.getText(R.string.unknown_error) + binding.login.revertAnimation { + binding.login.text = activity!!.getText(R.string.unknown_error) } } } diff --git a/app/src/main/res/anim/enter_from_bottom.xml b/app/src/main/res/anim/enter_from_bottom.xml deleted file mode 100644 index c9bd492a55..0000000000 --- a/app/src/main/res/anim/enter_from_bottom.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/anim/exit_to_bottom.xml b/app/src/main/res/anim/exit_to_bottom.xml deleted file mode 100644 index d5d94c795e..0000000000 --- a/app/src/main/res/anim/exit_to_bottom.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/anim/fade_in_grow_from_top.xml b/app/src/main/res/anim/fade_in_grow_from_top.xml new file mode 100644 index 0000000000..a269d214e5 --- /dev/null +++ b/app/src/main/res/anim/fade_in_grow_from_top.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/fade_out_short.xml b/app/src/main/res/anim/fade_out_short.xml new file mode 100644 index 0000000000..d85100d8d1 --- /dev/null +++ b/app/src/main/res/anim/fade_out_short.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/color/accent_alpha.xml b/app/src/main/res/color/accent_alpha.xml new file mode 100644 index 0000000000..848a1163c9 --- /dev/null +++ b/app/src/main/res/color/accent_alpha.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/color/bottom_nav_item_selector.xml b/app/src/main/res/color/bottom_nav_item_selector.xml new file mode 100644 index 0000000000..2345ab46ea --- /dev/null +++ b/app/src/main/res/color/bottom_nav_item_selector.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/mtrl_btn_bg_selector.xml b/app/src/main/res/color/mtrl_btn_bg_selector.xml index 7a0b326301..d95768b695 100644 --- a/app/src/main/res/color/mtrl_btn_bg_selector.xml +++ b/app/src/main/res/color/mtrl_btn_bg_selector.xml @@ -1,5 +1,6 @@ - + \ No newline at end of file diff --git a/app/src/main/res/color/primary_button_text_color_selector.xml b/app/src/main/res/color/primary_button_text_color_selector.xml index 0770047cb9..265dc2cd08 100644 --- a/app/src/main/res/color/primary_button_text_color_selector.xml +++ b/app/src/main/res/color/primary_button_text_color_selector.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/color/secondary_alpha.xml b/app/src/main/res/color/secondary_alpha.xml new file mode 100644 index 0000000000..76ab945dd7 --- /dev/null +++ b/app/src/main/res/color/secondary_alpha.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/color/tabs_selector.xml b/app/src/main/res/color/tabs_selector.xml new file mode 100644 index 0000000000..34782dcb31 --- /dev/null +++ b/app/src/main/res/color/tabs_selector.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/color/tabs_selector_alt.xml b/app/src/main/res/color/tabs_selector_alt.xml new file mode 100644 index 0000000000..0346c464e3 --- /dev/null +++ b/app/src/main/res/color/tabs_selector_alt.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/color/tabs_selector_background.xml b/app/src/main/res/color/tabs_selector_background.xml new file mode 100644 index 0000000000..8d9eca6f09 --- /dev/null +++ b/app/src/main/res/color/tabs_selector_background.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/color/tint_color_secondary.xml b/app/src/main/res/color/tint_color_secondary.xml new file mode 100644 index 0000000000..8e20b70b39 --- /dev/null +++ b/app/src/main/res/color/tint_color_secondary.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/anim_browse_filled_to_outline.xml b/app/src/main/res/drawable/anim_browse_filled_to_outline.xml new file mode 100644 index 0000000000..93621baa40 --- /dev/null +++ b/app/src/main/res/drawable/anim_browse_filled_to_outline.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/anim_browse_outline_to_filled.xml b/app/src/main/res/drawable/anim_browse_outline_to_filled.xml new file mode 100644 index 0000000000..cc78390c25 --- /dev/null +++ b/app/src/main/res/drawable/anim_browse_outline_to_filled.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/anim_crop_to_free.xml b/app/src/main/res/drawable/anim_crop_to_free.xml new file mode 100644 index 0000000000..31ee38205e --- /dev/null +++ b/app/src/main/res/drawable/anim_crop_to_free.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/anim_dl_to_check_to_dl.xml b/app/src/main/res/drawable/anim_dl_to_check_to_dl.xml new file mode 100644 index 0000000000..ca39909a20 --- /dev/null +++ b/app/src/main/res/drawable/anim_dl_to_check_to_dl.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/anim_expand_less_to_more.xml b/app/src/main/res/drawable/anim_expand_less_to_more.xml new file mode 100644 index 0000000000..80edd325f5 --- /dev/null +++ b/app/src/main/res/drawable/anim_expand_less_to_more.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/anim_expand_more_to_less.xml b/app/src/main/res/drawable/anim_expand_more_to_less.xml new file mode 100644 index 0000000000..458d6039bd --- /dev/null +++ b/app/src/main/res/drawable/anim_expand_more_to_less.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/anim_free_to_crop.xml b/app/src/main/res/drawable/anim_free_to_crop.xml new file mode 100644 index 0000000000..3718b61a9f --- /dev/null +++ b/app/src/main/res/drawable/anim_free_to_crop.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/anim_incog_to_read.xml b/app/src/main/res/drawable/anim_incog_to_read.xml new file mode 100644 index 0000000000..645c2fb9e6 --- /dev/null +++ b/app/src/main/res/drawable/anim_incog_to_read.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/anim_lib_filled_to_outline.xml b/app/src/main/res/drawable/anim_lib_filled_to_outline.xml new file mode 100644 index 0000000000..f550431906 --- /dev/null +++ b/app/src/main/res/drawable/anim_lib_filled_to_outline.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/anim_lib_outline_to_filled.xml b/app/src/main/res/drawable/anim_lib_outline_to_filled.xml new file mode 100644 index 0000000000..f832e629fd --- /dev/null +++ b/app/src/main/res/drawable/anim_lib_outline_to_filled.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/anim_outline_to_filled.xml b/app/src/main/res/drawable/anim_outline_to_filled.xml new file mode 100644 index 0000000000..3503276655 --- /dev/null +++ b/app/src/main/res/drawable/anim_outline_to_filled.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/anim_read_to_incog.xml b/app/src/main/res/drawable/anim_read_to_incog.xml new file mode 100644 index 0000000000..c7125ec6cd --- /dev/null +++ b/app/src/main/res/drawable/anim_read_to_incog.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/anim_recents_filled_to_outline.xml b/app/src/main/res/drawable/anim_recents_filled_to_outline.xml new file mode 100644 index 0000000000..01ced2c62b --- /dev/null +++ b/app/src/main/res/drawable/anim_recents_filled_to_outline.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/anim_recents_outline_to_filled.xml b/app/src/main/res/drawable/anim_recents_outline_to_filled.xml new file mode 100644 index 0000000000..e7a1e0cbd6 --- /dev/null +++ b/app/src/main/res/drawable/anim_recents_outline_to_filled.xml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bottom_nav_item_selector.xml b/app/src/main/res/drawable/bottom_nav_item_selector.xml deleted file mode 100644 index 6da9355aa3..0000000000 --- a/app/src/main/res/drawable/bottom_nav_item_selector.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/button_bg_error.xml b/app/src/main/res/drawable/button_bg_error.xml deleted file mode 100644 index b0522539f1..0000000000 --- a/app/src/main/res/drawable/button_bg_error.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/button_text_state.xml b/app/src/main/res/drawable/button_text_state.xml index 481da7dfde..2ec49b4099 100644 --- a/app/src/main/res/drawable/button_text_state.xml +++ b/app/src/main/res/drawable/button_text_state.xml @@ -1,6 +1,6 @@ - + @@ -15,8 +15,8 @@ - - + + diff --git a/app/src/main/res/drawable/chapter_nav.xml b/app/src/main/res/drawable/chapter_nav.xml index a502258e3b..4a36479599 100644 --- a/app/src/main/res/drawable/chapter_nav.xml +++ b/app/src/main/res/drawable/chapter_nav.xml @@ -1,6 +1,6 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/filter_mock.webp b/app/src/main/res/drawable/filter_mock.webp deleted file mode 100644 index c65b390cb6d82fafe8cede665a0f4e096554f450..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 77344 zcmV(rK<>X%Nk&E(E&%{nMM6+kP&gnAE&%{=Rsx*?Dp&${0X|I}ibNuzp%`6e&?p4N zw1ha&&d#`hSbwd4Hv84@7w(_fKW0C``Y-th&3|{}cWz{OA48@E;(* zjejTp{r#W(hx>o9v6`G4-e<-g*8 zhyBp`{rV63Kj6Q4eaZVD|2O=n?tl6(upj2X*MHpqhyBz4DgX2R_t$^rf9U^||2O~l z|3|48)_=o)%l{YtKjZ`XKlFe0AKL%If9C(6|Ns8C;>YqI@PEsHsQ<72SO53@4~74t ze|`Ub{x|&}{jc($|NrLxf`2Rilm27-&-p+3zu~|C|JwVM|Ht|N^P~1p{=e;i|KIqZ zXCJHI@4r6(*$NcL2sZybyi)3VytWa9dW1(7a`2=;S7z+@LfLIUTk^o73PV@nPQvPr zix1%1S4CLn3ONAg5(Ta#Zo%*;Kx?xudkNf_BiWarKw79PB0#W4A~X8uVg0-}+(lIU zmFd86df>fGU7Z9CH7OSMwyBKtaQPYyIAJcx3Ya^^*e6~hLYIjHkSa9T)J z1Aqn7Np2WD-3d^JmJ|Bc*9-em zOZ3qb@Si12dyb--(ha9(2(HZ}u$`)*R4S4Fpxcw8u%l4mLhM84;+;I^YydKPjx_;d zN2$sI6b&d@6P4K;#uer)*w^<+;r}~zCjDlE=0SkJ5+QqmNtYWFFGD$IjWHdLnGR_> zAM6>16##o`h73;Hbp$X;nM_;5I^~pAqIL#zC*{Vr&65b^eU5A=fvYn{aEqVa={QlI zmH$8Nb6!6{4N7smVEdnDaLt+J?XbW>sW${C!1$2Sgh__J?m;vQ_S62mW4_`;&6vl4MG?GAwV6FAOD8aX36hQDE^~sh!mpfAEV45<8?S*w)E6q9(4 zd+SSxd2yC0tDn}J$!qlEjvgmI3`<~;>%UlBRmr10PgVmjh*ZEZKmk|>DN zJBk{!(b!4WN0eiU(x`=4C7{1Xzl;anUVHM*-WRu>h###obGzYt>hQ@KhAtKym#Q}u zKN_;kGY9$uw;@LV>!5D${(vUVrY%YPBA(7B1bzG>%gS{3=F z;g`-VgJ?=Y+IXb-=G9R~pXH2=GbYpRpS~QmB2%odb#N6Tit63(xrpD78 z@+n?{Z3HK)9+d!HKZ8BycL%`QaAZ(-cIZ2A5dCq0&G_*{fKM4|=1b{D7{W6^qQvZ_ zH}z{aOva5H0-R&odb7mTa4XLm2wV8L=?xv-FnpUQV>XJ!qta@B&b_q4rxVnwD+UA* z;uS9QSo!BhX;t;+hea(3bxY;b!@8AG7gwX-)D#AXe9q*5E&Mb)3;ufqq_N;R7w;-mt&3GLOe~VZiErShB`?ggxRC&Ik{-C#TOS|k*#Su{&;cD_E7;=}Q zH3@^ZA*vg9D21>%OP$l>mGUKi?ijzk7X&9YoG7HOEzTTp?soaXWwRs zVhx-SGuUxeS;iLD;}hPi4=7i6mE{=5(`2s6vysUiIwG$N6cERbp7E&iGqvlJs;@H9 zk?9Cs5Iu5&A3Sic>4XKX4f14Uq8Y>p|@32~AgSz*H+U9Gfqfuz0Kf@OMy!z_f>w^e=PUW$-Y0FeELI(3?ZZq0L& z`+Hj*sl>->%3M$atAwMKWINn(udER*$m7A%Spe}xQysbc9Fp*CN~&=u>;6VLnwqj+ zey6&#I>fEWOvflj6wG?MsF>qJQz07wCs;exq*h71Z3v;ke*x7Y5e_c2Cl90SLxfCD zkhxViV>%#W(Xs%)ZblzBENHc=9#qVQ@}L<|;T{Pb0=ySzw3sX*c_v$VcYn;O`R<{F zLsgbl^wQt+P&x!pYBB=-7)^Z+2H3lyiKi-UsY33)fqop|ZrC6+PhykS3q^iWu#nVm z-zS5$O)+bU@7fegth0p*-I;RvLHCky9g6jMd@r!A7g^8)j-@Oedd6x`v=W(>nsjDW zb}2iQEk%S~KW6d2m*g}WJ^RUAUUzt|;)uH!b@oJMjw%iZ`aHAZEA2Tjl7!%1%E(%m zFF_9BvAAWnG|n59U{-CjlV!Q0_}gSX^9{`aMNy5({3tC=ZV9B^$^IO=9`53!b!R_A~UHG#N&(1+u-)3V=qL6p;M#~85Oa-MxGo`-qV`@&YeGvoF{X?KOsd)#w@0*FEIHpuwZ7y- z0%^SY47Mt{j}Pnu+Gl8}T3GY}RUpN0K}ztS$wc%B?cLl$cs|TD0P@|PkVi#PWpiGS_r=3>ezU^b-4D`1R zQPH;fOMx9E>JBrc&R1m5Y7;F|p~FdZxx4=NWw*l^SnQJJ%h`h=?z2bJDH5=ad|IAL#=W z6)7k(flUipEPh$QC%XCcXkaBZO4H}gLknu7T&!d^AG)Kij=faI=D|e51mnQkX?(n74lG-_a&ukpAAZno;d{V)S^0QaMP@O zu^7W=%q7^wU}|!`AcA&igj8>pu`jR#k4gLf3zSrFNH?_Sf|@|_A#G(*9}joYc0ZXbwNCIg3260t-!MJ+tGx2$L@Rm3ghY$=03&6AfywL2;f~{E@!t+;u_gu7A?R~CJeKO+CR~;&e zjho8=FrK}+;Gf;RGNQ;q?UT~HE8TdbcECc2>bEVwe6}6CLq@(^VF-^^`%A}nt}QD; zx!5CsUfi(}nx>3~_J&(wG7F7fc0@9Gma%ez@vC7+zZWxd<2a=nbbv$1i3LdQQm2L@1tSyL{l&~U}5Rn;$n7Ut>0pXA2F$YX|i|YTE5Xq=(sNxIUf+PO$O5myw zdL!!zkp^O|PH1hlChRy064YSCK{h1-uiY#-^^Ed_a;%HH6y+U9o$#mk;UuZT=J1Bq zF1_@Y_@QwxsHH4uA0AG^RJOVa4>c4H`l+FaObF63GHoaQ{;s;kQTB~C%>$iHF(Ct| zuA!ap7`)Caep?aeIaxF0{N|tqF-)hbf=w6~9cbGi7t~?g`D@mwM&pCg;QOz70OVcWy#>OVrnX(K~+? zC}893;-w~bp2y2 zp*UrtN=PMZsSjLnQoD%n(C9-$3KGj$Q&syZ8j(vxb^cqfo+Qk#vvVUUvlb;6|LQLe z_n0Y^kK^ccF@OL6?#35k`@fsajg`-##4*47cIz^GzstZ+Xf3o3-Pr0+r-wj({kUtB zZXGV_S2%}nyQw*-GJBPM)nmYw+>-l^1TLe3GDO8%%=`tfFo~cwZ1uD``UOSzrKW^b zG12JmcRIEID=Dt3=Fq8SiZNbLTm*`I!tv6_Y@Fs%T@t0ao+b))0@ry@*F>sak^L1`1D8Jwb8G*R$;6DN4O zIi$9qC1qn6$HvXKh83OLm52FI2vRLFg>^ye&#@z9nQ66VtA>QHGHMjhNg$c6fM0S( z@bIbl_0{=1Y>O;VgNfpAZNn+!qP^@ps5M=^wn6~>@3SPte&2e$cBzMJx1{6~#93dH z<`JV$f4c1cVgA5y2xJAh`&ti8?PHAJasGz`9d>&!$5!8)V_zN z4=;fxSy0cA7QkAI^`E!e2W_=bX@>(3M5+)7e9@N5p5k79XjrjVUv*$pbx#LJUpbnu zzgZxC*`LsJ+>#kDDkjjWx@CLLdwGDYN9XF(qO#gtVU(9YzUY890Z|AmPiYif@%3H6 zbCVkmNEn&Z9F8>@aoq=Y{rqnulC35UaJnDXCP3#3b@~gL=+li>Cka$y=<^;GHbU!j zy>h^XDw*Hz=C=>0E8Ryh(f|;~y$F1x>%5{FD=5K$D20vXQkZ0vfyWJ7216dnpK}ai z5D3o0ufPAwU*G?|A=;*=`5p%n{z`Ok1*^1}utrckLwjhudF+ELc5k01JI8&=0xq9z zHzwCbV?;Do6DAsS$?^skFt9>kkyhBna@?M32901RIqix~KmyyM;pCN2GM^$H-?Thy zeqA#;+A?EL(H=P_4xBmxgpbZbFPJ|?x=*;aj`{hFW&Q5!ADpF#a*I9a)I8pQuGSv3`vVk`F8Ld>#KrP{U*G6Z8oMbqDI>HRen^SHn9^G}wbE2#Ei>jKl~i>}tB_7cPa4zVkmkgV&SU$QyB zz7*l>Wy9np)<1qYgb?)_;)CHE@gKm%qW^^pWy{pb)o!_-dl4u3X;<9d+ae!dWwM$e z5eH)H<6;uO)m@+bW;Ze&?7f>wgmc&1u{q+S$nwm(|YoBVH zM~{D>ED`JV$Y9~@aS!KAV|inw$G3ZyidzYlmt)l}^Xj2m_pgzHG&MqkpBK)y7fVLJ zno3s5YCHd2w|m_kV)1&AY;zWqDvK`g_1*-9>CgZU_{e4p>oYBN@?AGDmC`O?7Cvz8WW{iD*B1OQ>ZYLPzm<2r zt0y3Hr4u`e)K?m4p>Qb->FZ3G43#qHKU_c&EluH&>pMMtOeRw4r( zQ?b*w+;WDjYm*j3Ax^t#SI1&i!UK3E4A;6u`U0%0M={=@=#86u-K-K(@&f`{+qiBr zdwYv5m(k`q%^b#E=eNZmeylc2z3fzX3q#0~5j7^QxyvVtedJJJXLh1+88;vvL)xdc z#OfKnLT9+!=1 zpEqh=lHeT4l;DCQfEO_CysI#%?Wc75J?+0zjxb3c5h87Fib3hlZaN#WJs7Vah#+|>8V*}}b zq8!}rs3Wct=DWR{koS=?2L-F`-mfa2~%gfIXJ0V-6{go4G( zql(G=pAFyDC#p2cA(OwTIT&`r27&*UzZ~@~xqQJ<9~^V)^}qT^^t{=dyQNISj^4}R$+fb5y_tkWz*3mjk7>$CCF|Zgf$Q#eZ+G%fI zKLvz9sc2S-$?hG-qx!FuEuWtvz^ZS?(t6u|0#jmyF$@t)ObUTmP^y@1r!g}yw?5ME4qOA_ zDAJiL)D)BW2y^{GD3M1&*(5%sx6Z)$S4wMC9#+`897H0Vd09AN-)j&7u>nw6$}u`% ze3;#NnJUxVbW#&AwC^Q9A;vetS$EZRm{C=&b4{vPQ5@4K%iT+$k&AyihjYAN{QYse zcSwza&Fn8JQto3%G@7L1om3<>C5urHCAjwwpbz(KYxSYBVHcRW)9ym!oSFy@DzqOB zQtVCYc0YgN*bho(#%YRIz&k-JCAR#e|n~r4>h??vE>0@$!&@kTB~89 z4j>=46Zy9DA!{j3MFn$f;AP`pYVY`Ss;CldcPT}6-V1I!rP!$eB>qcY&%aE;O6OC+ z`C2Y2dP4VfExZCPjh}+c0fK-w+IDuu+x(|n&FjLT2?84 zS*XodhS>|CeHS&~!8CQ-9)ZRu3cvEv0=30$`^g7~FljXT2n8w-a_RF{rXBd3os}Uj zHnC15!UT`Pgps_e(3L%mVh$$abFBTjZKc*xyJ_aB7Ii_spxCNtUb}Z=CdqrCbs;@d z-QzxJwpczR7jO?H*%2t#Er#2_^FBk*strFUfphyVydYjGc?f>iVekFTRnX@iU~pAn z2B?B@#fQ<+y;Uge%Pe2?QEw!N76(BuhIq|}2$KNO=Q7}^sPY4j@iV8EPOqtfHAq72cpOw^ zPa**x5`6FC6s4PqVaH<|wHlFfcVB|kVpSyB$w|7~zhPhVJyd@S_KmsuNOUMWC0%7d z74);|7y^Km&m>zF8du#!fpeX%6EtWSvb_U`6(g;EKUYSp(|C7T>X=4x;eO=+%oWUc zmUqtiC~SWyX0G_|zh+N_+K)~PYdA%&3A8mb#Vfn=Wx3J}pgk7V3PSh`Y5-lMfK z6+Ecp+&jFuIHZn$F~2wf>0)n^pYN5;e5LIrAM3x{TtGzNvli5#y>AcC7>Zzlr@lyg zL|)=Im0~NArFFq7=buQic2TT6>b!hLRhhI0%mr5 zRElmatCUIydf3Ea*sMS89cr=U08j^Da|&`sbUp1+a;kNL^R@*f z)>7+Tyk^+p*;uqdC)$MS^?i8z22pw1;1YG-tm;Q4#Mdb*!Rx0hB4HSIh&*rl4;l7O z&Hpje%&DvTH`|UqNm%7~g6`ZnTB=fgkGG=I`KCeHc{7{Xqt!;x&IPmktnW$qMq^54 zT1&9@Xn7SBzKRM=G)#^B6z{& z+<4M7HzG#>p26NQSf91rp#yN~F4`2y3+SdbK{)i_Yur-tQ@3p*{)TE#GMvl-fOR`9l;)hJVGg0)=f>B|I`SRfcmE7);)vQojzj{*Rk4q4y!B!M3ZJESNSog*jlF~ll zsM3ZkweJRztzpg&-2gB(%tA>ZNlsw0ef5`Wd<62J+N!uTvWG;qrKD{wjtIGiGA@R4 zQHWzg{bnRYI!MJDdPHC{BJH>)Et8C1Vw1*%QlGKkkB~%$RQ7=TFJM36L0i$l#bd=T zXLq#V<(1YieDk^ZkKtqD*(ZQYt|)ErJU^CX`+5NwH9|O)GbCzJc%{Xpcdkmx@w^^x zESTwAtt-acV}O&O{{P!ZV3JmbzRZ-UTJmw?a~Kx0w=Ck`3{kqFaaHZ0LhR8vpxg!{ z^|C4KO7M6_Xd}xlmeoW_?y~j(dvi9H@|5R)%9N2=wx9d}1|{mEz(T+PTM6BjOUoD; zd*4UX!FK_fNs3ayPoTW$ga8C@2=*X7W~lk&ef4^Jx@i`=!`b%#MgUGZzB( zG(#+8o+^r5*1HTI0eC8l8Ze1l^dywfo=Kw_icY&6-`HgskE$$ikpwH~ZXClC9S6Uq zKMo`x*dQ-bs5t$iC^*ma@!p0}eIFv>JIB@sms>RPT04rV7tF>{Sve9q>}|TT0mJO4 zF$+obMU}Q02|(Gq&rDb(GHQz3j6M#IKM7(_3@+ao=~VB;G$tm8Vx?_zX7gWRc5uyrGZU#qDGs zd2@6sbY_m`k+E@PoMZVK2#1G*;fpU7__Mi=#q%b{3qM-ebjWf^?I5{@T8gp-jF$M7 zdC^^DCMuBacoItl2TJ@5Jf{8;oko4FS|0uJQ?!OHb3Kh$QG*X}Bhy}rk}>bw4`dQh z#Tc)3le4`;JwnCqnPDsomE5jJ6dBt3wB%NiQIxmqiv5~0ChC}YeoYnP0r61z9lO0O zXuag{AI^#D7xI|nacE;!wgxH)^U=CdaIeR{kqpSUlx zs#z@D`DiFVuUZ019uA9TnI%!F<@|CkFzLvN2HqC##S_16H>jvfurLdZ%~)I8 zvX9{0+zx57S*rWbB*WRn1fO9vcO5uYZKUA>=l>v7H2#{0A!NIj?{hN*SD6mtYkM}= z3Xy+&pvQm2Qa-zI$l1lek@@C9z9G*90X}&fb)}x_Ku1hgp*`L^)iY@QRzq^JoOlc7 ztah%2124g2BA|D6pfzIkm>%WDfGcTD+cXYs`p4Wk=&~x5%LSLWY}09>0BAr^t|rys z*PA(aU800c=_#1^3u;%WgjzZZeR#JE4JN2>#yoK!dx-d$hoe1dE(uqG19>O$FsPSu z&lhk|z{LWwK8jopC|y5k<`m=VJI)eiz0$oi?sQLX?9W>)iBU9WBdP^uE^>HU?Pq^P zj&zS9*>@#wH*$b8V5ON@O-*!Tut)f&&$k0p;;)?H{I?*&COcm<;-?dk4*hJVFee;^ zG3fbWmT=2*iWYQDZ^?QxAY03;M5##UhBy5;!-_0C6}6`0b-7h408XkKtsdl%C$|s-oSO zjD&ggo#wh0=3TyOt~W>$%D-X*@wTt>>=JsRBPhLjJ4bNj@?^YyWmvB33{n%Ls?&Im z6;rVIDVSFgLG$C|9;>1|n?(QMg}*)8%sHWy-ygFTR9fY2pug{N@a0#^(9AV6_?J3q zG|j7lpO00yjm@IZ*??r&vDCPIz*%Fz28BUT>xhFYSGY^3JyG9`v{!w^cDwj~hBdQX z{^}hWN;v;Rtd-ZS_$ligx5R%Fax2$K9b0v+4OrQBV8}%f8eWuqk}Pb^>fyJ+0pwLOePQBZ?(W*!%%T_2pzSM+JF@O_x%4oGIyRZwGLODIH?B= z=ZNKtpx1&mRQHBlpmTBP-kITH1!M)dHrMjCW%^$O`7gU5-`Oz`4q@^5>LIvB#d|ck-hbEmMqFyV~{xrZn-R)H@F*owGDZN^a_3_9+SBW@S z7YQ&}eFn-wf%c`vp)V!^ejj&IRL~lZkvPj9Fn(BD^&2TCtGFOm*vVZ1xT#k6thSl% zfLf?FF%j+0|Gd*@f7NO8av_g$CaQtgadbUB3RU@gHnF&Pa-i?;(; z&9=q4RgN2uY)1zv?fXV+_An%_Y$#e({{jBUtgyG7NOLHq%ie(!>a+ghV zdwovhb9q)Esk0kb1MlAmwO$rmSJS&DqqTSHT*n;@%9%dc)1(N~wV9IsAoYBA$f4!9 zrzY8|%HTF@7%pYED-JjF0E?yf-31Ae*BcBq=fZB5;tF0~7_ozECBooJRr-a~-2|u~ z;yuGs3Bti|jBzi~w7!RQZ>4@fg5lDJn!~PZ^JnkXX}Vu*3M|Umc+1Vb!TXIs5I91L zPvln?;E8FluBP+<5_uQ$OPyJuF$BWJX8Y9gyR~K6gCKLy`H+Kqc=Im#@ng25fP>GOiwj?{+or^^KRTPY})7z|$r zyk>}f$o;x~-s_3gZWTmpD#NV>nHD8Db>#DoQpFWTh^{y_&Ki*Tf`S@x+De~EVKNQpf(&+18g@-@vs!g= zWLR~`1Uk@<3dGN!G_nX`MM{H3dJ;iWws&i;WUOiH=BET(7Os^(#FxDFo~aTtMIChg z`3mZJPS7^*!Zge?V}wUz*|*>K>W2?)_0WVVv%lZBB|qY8Fl$MK$#Rrea}y0p6)Et5 z1LV{>BIcN;2)k^BM7$;kD;?wI$z=;rioEwTtC1!z+=cu$M?!Z9t|1wfV;waZ-xeiP z(dbZW_Os+KVOltk@I>H_=QMm~Kv?MYL2|k;D_cX10XdT9vAff$xYn8_jcP|X_a$^8 z^d1OAP~fWH=7PvSl3QZz1ecY<>1yN608A3T@W8M;NQVD~clAi$VNP4cfl7g!i@JH@ z?Odsj<~YCI;;DtR{l+<{GshYdC3yp7a-xa4rKC;z(g;2#Dm4o3c(b8UFMpb4fIbNg zd7WkX(B43tSV*T!{d{%bBwKz|QY#A^ct(pb?8Vvk^I$oF8QG*LNnc$3yIOhn%Gu&Q z&saHhgAs8q<4H$hh{w*!W7A))qdNPu1ThD8N8??lTsepESTssdn^W(3@`V`2xGzwN zbZzMLo00EJ7x?PD!gU`ko42zVE^ zd+uw%Sc4FqhjM4OS9d!U44 zeOt_-OBW-~{jamw+Ya;G2z37_{$j&3%Ju()VnHwj4V!wV#90@MQMGyITRuoeNA(=w z+{uCwdrOvTYX)8W4%GAUV*l#!hT}q75*{+R4$08lnSEfIZ~;tx;f`eK;a0vt7dAST z?m#Lz$0xdE0Cxz<&L?-jA0)tQKK8`j+rp*yMq~ioRyz1%jxoS~O22o6g-kE41O z%7k|Pmg}nPKUk_(Oocr)5tNo%YE)T+(CXa`Z5omQhdIdjw^e8xB*6NyQ|MOx0Q@1Y zeK{|)WV+jgrT9!#X;v#q=D;nD0fuxCN-rk*Ox5eYc1g)oO@`lS+9T?ZBA{ch*(R)H z>nRedk8B@`>nSIZeQX7MEpB~kvb)U*#lH6rRULvzbzp(6x)O2Ly+v{I7JsS-Mr{9+ zh``$Xguk^3YUDU`hg4cV?niw2e40x$pF8Ehe5T#f|?bh4&QFl*!B zVcejM3ppD#z@PW~17KwsXp|Hx3y3*Bpbh2PE1w_s*(3%D{uS4NAh0M>1U ztnEpxG{_S1%6|F(@w8?3*lmavZn?!Xg9de{IxOqH?f%pdKvfc4U-|_}ryd@}7#aEt z%PWpNeBGlhRf|iWDczi>9+ucR#N_6`I1JWRab>aWbEXAY_ApffR(Bht2o=yC1OL)E z_odUG+7>wl4*A@s^;JhU6E6Lsd&pIEbS3+NxutWFlj#)lYH4#QCXY$VI^Xi8qJOet zUWXk=kfv6m$BN~J;eO$9aeIx6zYw-#+wBa5y0ofi+a5T!oSZbl>$LS8J;8t?0y6rP zpJ!xqfs`0>my-H`i)@42|3?^W_Xn-}6VldGkN~SBb@YTT=TUMiY~8_rhtva>wZQtv zHf}3c>`nN!K6u9R|EB|(_x_yr#G+GZZJVQBOV@ZCeZ@cs5iKUA0O*(KLBNq;`CXX6 zofw2Y$oZHqKfP?{g+nEK)L@bg8|G1hhOEJU-HD=%pw(aa$ac?^{CYKVxb0ddALBUV z+HwLEaAIO}8z@=Y4|j$yB1ArW9ofFG%Iy^^Nd+yH!Id(Fx_l1*B?E}fX+ z{I>@%oBPfv6@sZ=yv{W7D{n(;79^8tt*3qVj1#`nM6NyC!=oRU72w0gERfc*R;=hQ zNfysyS0>>I;UUio_yR0I%EWm`l+<`{{v9W}enoX`tS?t$N#HrDKP-k9o76aD=TNQp z`Mf?Hu|dMI6LN*u7l)#eH7{BmSHFADC`0Gt@&`n;VP=O^rXE%Gt+n+)HLn@;5CUyt zHql?Do^If4bL(n_U~Og&bt{|%e9qe`dXY!E+~IkIMJT1WgwDnky^ZR!&qGT_y)Ke< z)mR4rCBq&tLEqcItL4$tC^{jrf9+u)!yeNkc@K%1GOlSwZ%>BH7~$`&$(7CzDmc^%Ha<;uhj!`evhNF|~jZ z%{JbYPZ5t6-Gsq*vkO}&%@%r7n8?7&*6b%73W}mX0x65iue+L{2LoG3BQun=S#E4C zg4LO|+hYIpAC3T#)vRN%s2r_0iFyA3qjnI|qLUIU{7Q}P{-O*zm8@tsVem6G3pkYq z(w`qyz_MQ;5?{rvWq!rOfe8BA6W@Ha17+^YAHEqAI@FQpV-cz6%m`Dh8f-)(I?WNc z(^?B%0xs{bpvn%{@m0koM}MWph7v;(2y#uMYdj(x@}q`A%62tK=RyyiNBC9L#S_X$ zMUj1Bo1-0HqeP^1l)de(8Lyp6{QL>((bNxg0AHaK@eQILmVTl%A>$NoC?;TC5A+v= zf*Vgf(q0MP5P-MffUfX6kU4i4Pk>re*NykX03hTq`+$v?&p(=;wf|uJ*HzIANYGgb zU>l==nN3q3RNz4~L;wPea-!XJ>FCQ!_ZM`(X}6HMb^|VbFQRk-DQ<0=b%#QMjix*F zlq=M_6J%FsGh>Y5qym;Nv2| z)Jyd8-mRBKi+2=ic9JBhRUQ-0*}2(xmL_=QW;sc}&hxw&ioyuCaFnn!7(N?6L8&C; zwx8dnktgavcDn)$`+}vfJ*~81_QrEacGcAMB?yV?OUJ#EZ4C47)L3*Xw}ML^6DDLi z!K=iWx5K>Y%ng%-@im5n7{Qk*6z=r-L!-Ih7Gdm&E&+kb2T1@gEy&_R{?f%MO{19} z{1)9Fv}urkTY-zAA_vq12t9*g5i8=v+7Xr9=g~zHCW;y7`?M#swl6jzEw&$wxf~Xyr8hD(>3+N^F1M%rE*uLPm zw0Oat$9gQ|3WEF+s>9P`T{_~HUR&pWHi)SpQnS8hqJ#sIL{p+=G z1jQB^c3$Z``rHRm>N>8qre(&7_ujQlEvRTu)qm~1)?d<`FWGnIWf`wcRi2-9wq9*h z#897TL>J%K2rX7FLCzYQNBzhdwiORc^KCKdXc(l!$l(kf8`kha(a|Nwx>#o2Uo~s z6l2tB3OkWt1nb`9s56WT{ZS04T8X;N zC%MhI^)eI@e#}yNm!@XCX}xnHzh~&M!yJ&bq9{;-&CeKZqiLzc1kgU07Naq1J{??b z(rW{2RWN=}7W7@U9f34J9WkNjB%9qs9P4TPcQrfRdGYBS+JrnOUBBPC?RO_W&!d6H zz(4aBmAtMQDLW$55_4VbZ2#_xcIpVRpxR*@=vF641ydgF+ia*vG4~HJ18qi^u^QC> zJIWNX*4bFFo{D4Ks0pEmMb00DY0-qSh#hNr6QG9%D}>`0AC&!Iza7TCy~H-iwLqF9 zD8g?zOIZyioZ6g!VODC-_cJF&(1uHX7EZ-Bci<*OwFh?*ciP%M`$Huv1df?jSx0LV zcTQzA(%1<4aU1q=F3mL#JtxaRN#fh0OL5%qJV6Nt$*Sv?ZpNn?R3p1$N40V8n0P%n#BpaGF?XiSx&Sb?Z?D$2K4 zKFO48iB`P+m3Q51rc{lahH*<88_~d600^*Su?OM|s(LS7jbpPm%HqdMN|Hv{NCD<& z_RX6exCmCv6M8F76|_$-+qmUi(eS-)$`jeK(DvXimY;BF90 zT?B_#A6@#xdbI32sV1I+{j1uk{{_<(LA5vt0%C#a_@ZRdXD_)}Sqt27w2~uHbnFgm zBvYicJ5WJb0{{|;Q7$X0ezX8^V$`I43(e&TkYy0QCGCZX?(fMkT%C=Br{eG;3vxXy!>oUH)7?3muSFy2A+Tu6XU}g9 zUF)^nP2(d><$_)H6Z;rWB-8OmL}pvKX|qtjm$IvkBa$KdxonFCCOWd7o{U_7SV8ay z*B$;TQtAfl6Jk*lx&fxbfoFS{V>oP?AlPvfhIO&E8BwX&C=H*|UVBiZ`sMBb%6C9h zm`uuc%+Q?)%zw5k(+xEmb~VGDo(A75f95h^*M0Nq^<_>eRZ$Kq*4h+}Os4?!zR0Mz zibFE7c+aZiEOwcqav1WKfh{uT}&|0j-y%o-0g{e&ui1i8J)ArTWS zPxLUIr!xU2S3w49jnZvhGrVBClj$ zCayrlL-*y=?hKH}*uxKQAfE6X1?lM*O7&KqI4z;H-939+EVcRL+#sub_j~ht%=Kqc zw6mz8N(L*!%dk^|H{+gV_+c1OT?HqxfyB{bs%n21O%%^~cGqtWPNjE%XsyFMylg9T z-M2{bFtCY-OuDg%^t$@KS4mL<5E|H=e-`SL-lFwcyruh!BhDqzjbNTWc$ z;I%7RqzWVycX+t00BxTbLNh#_qqS<8n`}~CS%eA7Wk?@PgOuD<@~$-*1*W?0Eh(B= zzS=c?!&UGDw`|gVc+z!D`%`Y=w&t(?;;Qh4-pUZe8TD+-tdFWj-)u!3-JRozuO?S* zGlExj>TtA%T9r7+m#5we^^8WVuVXbFQCxP-TzGs0aVBx2HEeE3u39wCbsAlKeEbm~5zQMt)p=nKJFaBK&2-0$&p@#PM#7IKjT+_` z8ET+&F{EW(9R6896!yoet^n^XfOTy!=#~qb*0}NFX*)O`au_sHOChZPI*RGQQbH^e z(Jn{m4_t;ouWoz&D7dv|=`k0ou{$F0;+?&i?$F=q0_HkEVjj7E5heC{7kv}zE41mi z$U644WLiP_l{z0$RDchf>YRyySMU0a*a8r25LebMu-g4TvS=YaDgJu(Mo>y_IY1eY zAc{FQ8_P3^ELBHVw$w^H0st&?D;K@i4GnEy<&_B)?qbW<**<{Z_J~? zpO64azMxLM_Z#L2EUcRb8SB{P)zzT{%*_k}gAjI@q?M&U&4kgSX+?$LAq3(!zeS`F zVki=j-UqAa);P7rcRftJHbtqM#TI1*XDp!}U_^9K^oxOQ4$vT_!Bh&wzC}?g?np9GOSP4ySix_F8ia zq?vE5`7O`sCTfYrT{4h(=Kx)Z1~PLF_=+O?pcvfPwyFSW`hEjE+puqv(C1-7@r*6t zHJ@-LG~uKSl^^3MQ^_B#0t?qeZMN59dK_vof1TOFPFx9|t>g=9p@tKle8rCT=I%gi3VQ&W}}W)-JVnJ+T?N6*oc~H>+yo zL4dNH?8OpetLu5I?nuYt-{O-c0iFZQT%E-rPAO*2iL-3&>O|Yp?X+kjnqAsQ;=kr?6@o1=8V4Od@H%r+l71)4iR=J5ye7fCS@ay_W9C!u68ijij9)l^}&fecoV7qjc#|A{bh=%qN;l@x6FV}G>Q_94|q73_CVZ| zjHxbcE>xJo;a)3E*b&28(&)xY723+CMVcgjt#0hM9SqYY$n5gy)CE|np7gXW=BC~s zyw;Tv49W^+l>kfc+n1n;qg5=E z?=L*PSMSl#w*ei|%S5Qw;B8{pKIA@)j)R)thkwfC7@FTl2v8mNHeMT7grV7(dkX44 zRkjQ8Ju+8~peGY!CAmSV@fcc{rXbqbz$@hxXikaH2Va*$dQi06vZGo@9A$RN zRUG}Ql^iRT%>P}y0cZTuAW+;J2Gb+KPGLqyVm4^$YR}5P@kH=>ZW^F(m_=S#Srt>bX)~q=O*Wn~ zzDO}r~I(92&p>+W6x+e$4VNC~U zs;!XT8(EX+)Fd%p!K03+ZVD8BKJQY+7X`vu4*z zZ(S*i1zj3@xM}Bh%{ul$h8w1PABg~)ghH>>`0n5iV_4-e#qTrbF%(m?X9{sMG=rgs z<5+378~gJ?3OGr)R$~}rMZ53fA;!@ixTqYu=K|m2#Uy}Vxts`3viB_@jZ4C3XrFE+ z$o`i*+_d++iXqWZX;8_pcM;-ND&^wisRf30;PkTz(Y^oo<_j;U!k9R9GEd+(Udx|> zvi;KEK{SbrY17O&cfV5uJXj=rpO#WZlfRJs7ZnnO#E8I1kQq8MaQ@XLZ^RO9f(ovO-ha7GJe3k~GXkwC5XA z-n1Dog5++|6L_TB>$a1JS?XxAuABm&UI+)M05?F$zY44*D@#R}_0W8UO#7`B-f$l^bbgf@7YBWSkb{R`9W~d~ zLg56)mhBj9eIv_blmqJ_Rh?%&c^k)K{LyZqNApx-Om)x7k~#6&%jCdICL=dr^G@p)@mj-k|*rPz&Px@MO zcGB)AST+|OOP$n@C{qz~U)y*!<9iJMtKGqQInIq)thXTv_$Gk*v?Yj&f_mhiW@#vE zl7t9GH-t#Px{8ha?nb37Hq?tm@`%5ln@;|wG?{^@pgdA1-TzP-^~mg#o|tc0PbN=@ zr??)jNfMJ%mA-U^Ky16}_NE)r;~|9=^1+!|7K~`cqi7K&$t-V%chg(IIeA@TJjWp3 zGIB`Kt2M{hAkwTTM3Mrl6Z6pPyjz7sx3Doy zLU)>-hX%1@9D-j-Ltr zy<#w&w$B&*JRIuQt>Ek`=esrWzXDL(J^8e(_(iiwd;0a_WkHlhb96g+jD;|JvSdgs zox$O|QrxKSo^5-Xk&&r$Ll(5OZ&^OmY2k8$5Mpa#%1mzPWU78AvbG$W8%Nz>_V__b ze<>Vog28;+S2-8919?FJO?q#caIg-K&@C*FikpQB0FATA$XDjex}4NP(a%@4pEKdI zE6bje7OxW}z?Hri^~v)4NuL-+vE zb1hw4qP@f=h3+%i(oURg+hT}qe=$U4a(Cm(umW6N!&x&|EB*uwsyWLW*W`|e{3~Nq z_lq@$Z+$YX!hL@BiwlR{1@MtUa8W2LeG~G`I{J>nb5|(q50cgRhIwPiT5IFAKEfQW zD!OuUz~eHuIN%&~cPrFGE(@NkGlS53Oz-r)N+j)@*DWw*?>YRx^!m*fC47DVNJVm{ zvRYEbW7Zpynew_NU}#GXbZhAyM&L=8jL~_*Vo$i1Q_mEY0;*PK2Sal*`oqO8_5OQo zE@Fq1Olm_7z)ntVXvZT6(LAEVgcssHp8}%3&)^jX!Vn489g#~!*yKiG*i8(xJ}%wlzn8@ZMXQuKL)q+sB?d^;lQ*laW! zBM^738b+z9i_lnFXgV`KQUY`9^~TN~n^PP?L3Fco{eH#V8Sv697FuXdiW;?&TR3+l znX-*ug&oXlUl*4qV$wDPu6LI&zAy;nneinJnFjc}O9#vd=F7I}`LrCj;wMWj0ml&V z@bt-;0{EP&L?SHf5+CM5c0zC5Nl6AAu<`&N9v4ajOO;z{k(kpfO3}bZ2xH8vUpcSz zEeJ{{qonUZIRnW~|D5hEpu{|#RgBCbR`Eb}gWoqxPErrf?^gr77~c`bc8nXvm>I1E zAkt23P#CB{ec+3D;62QY-k<3pAL;~&vjY=tsN5B^LP_8^p>eGscHu5d>l>7ew?EKz zP++kHyV+{-hi9$=`|JV)@q0xof#{3pJ}9Vq8o^uT1R64j={enOx2vSNW? z`iL%*Q4H)C-LT|3Fw26~w^J){h5!J)Z_a13vQw-kqVl}vhB`}JYOeKcKtZ8?8M6@@ z0?>g1nqK-BMKG4lM`Wkw|M5>FY6to9UkShvKrec_Y{aa|wdT*2A+I#uz<170Erwcwl@u`o7a(;uJV_(^kB z{hU3t#y(EPfhZfPtb5cXf$L-XHSwe0*DVfj2D;DQG}mQ)AP z3ldO&0|CpppcF(7YR^DJoQc9z(_n*b0sC$&I=&P-dC6j<7Wn^&=#^D|N92w;$H+e4 zOcQX`Q=+x~g4r81XicW)GxHm;skrC)SB!HZQ9_}7gWbNL!ZsSueNujZLssKWD;McY zf~OlP%vDi&0ZSIr&8x33^g6qjl2O#v<2w9Pusn!quJfaFUY7TjY-S0_M=RE{ZCcs4 z*Rf1HYp8Puj1M07&65zI*laO)ar7LNXXI29_{Em=&%o!M*c!2^efhVDAF+F55ivRu zV6EXbCq?|5f@#;{-Ay1V4lz}*6ilgw@Q1dPjxo`EXe{tcgl#LNO8{R(?vKywh)0z3 z)`-AnY&aqP!q;E)>WRV}h9|P@Mhot^wQ0I>qZR*uBAAkW?tiD@_20I+FfuKTdm;M1?3g zo|>SiYEfQAbWRC0Q}iMm@bX%DKO zKA9P`Wv5XE0<$wKG00xLAY2Rp;N~fMGCrPX(oz+9t}#$J<;TL+OghgS(Zk35x}9iO_$(}qNn*r z9VFw%__F_8V%GzpD_~rm|B(sgve_a@R=|ArnPGLCRQ&J1F)#n^xJe>7`n4L2D5s;! z=^GLUH+nz6p!3oBE~1nwy(f}Oh|c(;nZCBxc8|?D;@ddl$_Hh@w61fiVqkzOms9y4 z%MyW(gVB?=6pu*JuMM#n884&2xPYMn!L&3eammeP`r7tgyN%jRB{^rioo}xME%c=f zt2}Ymev)_7wKi|BtUgcm5uUBPS&}C%nCwL3_4EjOU62mHu8xy_;2Tb4jw@V)5tXD< z_o$cRr8F<0W2&-1Xg%=)}vgh5^Wcl+3~eWjPpG zvh_NQW`c0fAa1M&7zQlf-^tnc?MnKK6yb-yW<;H%ftl?2*Ds0yrx1z{eWhP=DUK<4}yDC%8%rYsJzJXzmFd zY8DZNG{c6<`9=AlnPG_$X)eYtQ2x{fC}S{Py`w^ARolA($D(^;50dy<3VYUYs+Ayd z1!41Nomp;OnKpt<2zK+<>uOFu#lT5I-&^LNk^S-{VS@(_Ffq-1=thQ!K-3MSX0b1D1&hR4Igzk_r$^@Bl4!Sd)^>TmqLsf;1p3G6= zO||#EH%b#{a1=9zIuHu&8*u2Zi#kz5IpcC@wA`Lj$Y_1%Emv2J*^xL|f@Jb|NoIZb z_r&ceZ+Wu(8^9%0ck4kMK7cHNQR4ljteAIZ^*zd)b`VUc6JIPnA*W;xSioM3E7CDru;QJG zITn;XKtCH$PBE0>VS}mUC6D^iFMeN%_=J$24<|jOU?acQ;ZDK#kRZCnCtuX6hYFC1 zt~Q>+*XeaC8F}S~x(~SX5f@oQg~3lF-APKh4%SIZw^N0;>-lQ+2106WGpKAR2Ut}L z3^i$?H9p-1NF~-+l)9$`?g3$xP$#fbcUa`Ym-48K(Uyr^BhVJi?6g&gAQPOshUVS~ zv-jat!`{aP&{LVNksJ0TQZ-!-*#mY1-d&V=3yIS#rp+d&-AFHEKRhvNE@`Bma~!T7 zCNyT&( zfMTA*+vzkOccFuw5jfBUm}6ZZUF4h{WW~N8mynPXFf+J$dE;HNo}}X$G6#z z&_^tmf(7uq$tu+V3RNm|3`hEM>3_T2Z}XOn%p$vaRNQ25P)FocF@rTu_V?y_dVCSo z-aEO3fXXSHY7zlZAzY|27ngKHNT+~XrJbo!fY-l_IZWr_g!UbFsa(k&cvbEoW>(?z zc}D&>?+d40{)xmVp_@&QC277K?$tcj#J^SaGyzfH12vEqHX-6Q((Z z6vrdVv6YhAah;Vjsh9{V_MS8v#6^?OnY3^}V}HW|%TzNWOsbnI>@^J4=@?^Wn}y2< z?^Y(nFf6ABmH@zvTX(6H$`=k?LwhuzJ>@m{iguvO$K5+IjX*Gn0xI%7^yp0*%L<}v zZpGBVwx2Yt#l%`S5XnlU4YR}#j3AzPB%?X}nP#^k3E_G*)a%cqxenD&=<6U;)7Szn zPct=QeM7*~a25W!P14ED`4!#vC~ML<1?PW|;=g&z4#OAW)6O$7ziHCxa0-Eu6!8(9dm>OKd z2(k#!5hit%KvSsD{(;!xaQc?!jI?i=0k{kt1Q6b5Sm>VUOF(wlv48#t&K(v)S1!GgtOovh-F~lV`LDS8c4x?0}txqvWX= zeuOw}56PHkT#=fg`-`exUg;a0;g>d}xI(sJ2!vE68HcwpMaKj^(v4XPTTR9hoX_oB zMq2LGc0{QyMBulx0`)R|z3~p>p%q~FkKO>b%|`@(hr+Iy^CZNz;liAjSOb3L6&Apv zL>VQX%)N3F8vDGjkPVwA{4HqjX zmEoroDrJj#U~KA663lN0@ple7+xSgoQZMu%B!-_kU*FK1S~s!{%Q^b7AcijaKtlK_ zB%1*yM>JQUS-yQ;?f!~R)<#b1H&U)R;Svkeu9@DPycWpxjuoj|yBk>dry_9;;FmO) zF&WQ_Zj(YC_FS|gB&fY8G^Ve5%0eSSL6Y$UU&B`#re;sOx<234 z@!U`Bc@TvR_6R_C;h1?VT!oxYN%-0J@O`b@IYH^(`7Plf)@*MC;pDxsJ~AA(DiY-9 ztsYBTOaL(FmlZ%@kp^S0C3y0O?OhUDpJ=?uVjGOOnPcQ{;&QHeAMYy&Q4vf`q7wDy zvwP}hEd_LA?W)nnhn*giJQAB)Dyy25a0_;>SD#z@TcNk1j6aX*p9w=s5VT2v0T+-(Nyt3G_HHy}TKnFXFcwGTDAv68 z6ZZ)5BpkjUj+adYKmtSd(Qct9eoGwGdaT21~*&lcla z;ax3H9BbByM-=9{MJvV+x75U{0fyGC#eU@a3AMVr;zE(+=>Yy@Y@4~9Uc2_az-Q#=I5Kd#OdS)kM*XbqB%x|1xUvPkHy()V@?{R*0ZvtL;xkc_fU z;RA!Q-eVEdu*ko`7S^2COhRvzvxl-XD^jCPLL+3=aBq2a#G(55wHQ97at5n1A>iuz zq{0lc5r`R-wHnW3Qj|eZ1U}!H7yg~78%}zz~Xqrg}f$fD- zjum~Xl=}fj+@CHQW|t4ngXC?6G7&i$iSQYkOzE*h)C$#$rZS3XDHpf`HvGU5Xi#tE z#*i8iyS)?Y?r+nr8zH&`7lpCY4)XkJIdW7}Y1T^=`6hn2&!y}@A2%3aLJzSLPxsbw z{A*ggKeajQ^doSg#epK~84j7<5Bk}W4w;ImgR%^WEogi{_ zWwejJ_wWh1*Kd{mJ%32NlXiZN{O$KM4M2EC6V-~|yPuoC=HpPP+!PN&sxcX~y-CvY$(zoL0FF5aUfFtUWZZ+HdSI_n0^hYhzdx$o& zI_BleZTqX%KsnVO_;r}#IW$%JvgDr}frR=Z_)Xf! z3!lGW@TlTyAETTgqaGl~O@WJNd>Y^%@kHD}O0Px1+e>C~bylQ1rq%VbLJH$DtQaXM zulL-CkwyVsHnV%4@^R`uHds0!HXoDCvdnM+1`VJ$DlQurMx_aaW4qn?x(0$<()W4k zdI})eQ>1Vk&r_+*0gaX(x;jJpj6UW(jxOprTHdh%ljTSudbXyI3XrNi3!9t+0Es|E z)DbF2r*%uamL{J!n09b9RAc(rNiIb@XzAfg2_Ug+PfU?8PV0|JvkvV?%M_NH+mD88 ze&rghim+nc)BSOepJQ|Ggsrp+t5%|2(}wE48P4MV*v^!!+Ra=(bi}O`Q9@X4prx8 zt+pY96#U~?^)#vt^m^ZBuApmTu=aC~KAn#mdx^DcgfVIPb{=m+v)P++*pcZtE9TJZ zu}b6r&Khn2-;mdoP37Ad@;wEZ11AOkz~wk)Enenhg<@RpeA)A_D}0`hRH;M?b6_2~ zQ5%_gbg>k)3P6u#e;)}y^bq_wR8~yqK+%>`@8xJ)A%9K%!4%&cfi^GMMH^ zgn=kgXSnBX6j0$0h=@a^blYVr)+@$~z<$cXRzvGtwizM}lg-gw>le{Wn!4S-%IWDB zO3z+=1ij6K_S<~cC3NVVhE^v(1S`#d>~#XW{BT4z3&jxPgVsu~HLW%?DHnO2(o(_g zSVY@_$DB~e*cWug>e5}k_r&n>W7=t+MsqnK%Q3JVL)l**K}Qi1Yo|wf78XH(OuK~8 zlr$WoASd6&;S_SY;)j%++|#5sBMy-@mJS^G;!R1g{R7W4Jsb>!{z4KpqC=3TJzlMo+c582J7 zAC-LDdLT-wBn38)=J@n4pfaoJAxV0ia2etUx0fB= zf=&glHjRocxj<(jtiQD=7KaF(@{D!l^yW4b?dtzpLi)YddPhi_V24$zjFu0m@3n`$ zFP!(#ifKAa4lWBx+;EQ$IDj&44k-0r^ILi_Z=#NTPvyi=>X*#Y18u|^ zWS7|46x2;n-LA4}(cbEz4Oc<_A@V|Y&Vg@SsK#IW-r9L0?}~0L`{jGKI2`Wva9;G& zF&r>u(2ZKZ`oh!fU;;&cspIc;Dwa5kwxwJU8VVIeQE9u-LY-xYD(PgrE}%yf|fDUv?Rr#00I>&6z)tDc z!m{5D>(xa3$RbL zHXC%TO`(;T&Z10N+Bn!@GD!glDQ#ExeT+kuPsq-Q4bcB36qk;>KJRps2{$=`g#}_M zpo0>h7a%sJ)54Vxf1R`p6CiF#r_x|g&asieaS~4W`-zhV1o|<~mIHrdxL}Mh@SQCa zASX>IeZnwKXDV`VBmfyNA%>K(eRidxgrF8e4eu->SH981(z|35~dcdVWQwFJ60mtf}ZgF z?OQ33a2kdv!j3nLZDpVCmzY&M>LJQS@$x(GQ-iCca&Uv+mHAX6kwyAn;=MwY6ufZv zwD=SLR4E@x`=5#2YAJb&;YY~}(+@SW+L;E^XqI1p4-Hr0LhC(6>niw*m9)K_Nbzlf z@mKzY2Q=Tjl4QrKZ_Ps>C0a4L#BFWxu|%t ze#;HJ20i(`d2!)6WY(h+GP97iHaTI`8tjuD=F{1eUtU3&8^yat|7lFv&^H*$e$V#U zoz{lymHX{E7cn`mi{4Y?z8_ILlmk#FiL_nYVY09L{agEQ4k4OOJQli*nOT$Xb45Sy z_>N4D?1R?gJCvD6H0)0ks!IF{GnKK1HoMi=gG!3bW30I*eM}ebhQ4S=?kmaxm^Nz5 z*|kC+?1hC2s^PS=YCI3vZbEW86T{WKEUlC!Ju`I9Ux_o^r0g9;X!HC)azj~{!d6K<6IxjEzF!lT$4`O^Qtk86J|e9pXV zAb#sr@mt54RjBD^DzMKj>PZPjWb8I#!I+?rmm`kKZ5}(lI%YIdqrP2gOib(yw46jP zJdgcAsxL3ZhVl8SA4LtocWq^?B5Wa*Kk)U>0Xl?pWtKs1ioSr! z?QSLQ)d6!;0FVOFHLw0|^uqisXDjzIj2-IJ-VT(NG@%7F6AE%0Hpo$9lwu{T092rD zF*vEc892MCrbvn|!~wiams`9_g#<8fW%EqRmt#csIABNAM*@iqQ7y%PNQZdBSi&i7 zRev~~AZC26g{G4JJD`=}UzGyQQCj&VYZ1!Tl-mfSm||aD{Fmx)9{f zQ7rM`B_Ri9&@iB!#zth}zYehEuoko#C?|c8(hbb5+cc-RtK5AB3Q!Ju^&TlCk2^AS zl|m*%EivhEK8uM(Zy5$GNxO5Io8NRFi-vjEc*(9y^QYYLmbQqalBl`+^>=Ies%!H* zfFb9IuAz6Yh)u@`5Cl|6m*m8kLur_a{|3>rOe~Tv+&;>wX&Y@Xg?!oMU06$CwLhX{ zKmD^h=rI<8ndFNu5Q3tybTZ6iU1wkQ5uIP8e{sBid2-&G5Bl+cP}KKzN(L`yPH;c< zGMsj?%ERERD=~po7^;%#BW3Fed^S6bUMw3T8%WjK2RQ_)nMh~O8t0epApJDm!#pm| zDwud`9_;sE5QYo`hh&LWd1=#e4NTL> z28*B{WVfgN7NwW)ED^Qf#4dmcwtP)z|e=0;(_w>otbzaOg*y!HsP=R~uP z1tna*UTT5Ll8ot{8!G26=9Ebj6ZiF4$B0Aty`+8ggN~yM5KS@xT5`kI*KClFbJFI% zU{TYXAH#1@_-I%z6SC>Uxg*LvEGO&!Lsh0^=Oz%kTEC=4HdMQdwMH;Aqx%DE8bI2H z=xQocVCzfb=5yYu|D>+ed(=1pcab&eDqqB!5pK2nwcyp>ByHjL2?jez;aR@%e88w{ z2FFgMD5LW6I6<}Jxa@ltS1ijqfiOsNXk-pAqe!a9tDnnwxrC2bO(E=Zhza79klpU9 z8`ZkRmkOQpbMFUN;))j(rN#&7J-SOAnT9go)s)PrBpFmX&1QU1^+9r>FJElgtPoAz zL{@PkjFeBG>#aWsw$C{2gD1g(mfNG*01}j>iuHrM5d-BRF?#dtn7W>2vnn&F+`pc# z*TI=g;yOc8ymSj%9Nk{n>!u?Cq6NKkN#PHP~;3@a7zmg_Qfk2g+t7Q|U7GsHo z+920*Wf(n8m@PMXojF3QDR6wrPdvRZMX#^U*+iQ_E?pi z?25(OFqgLu!z^Fdy(!QjkK#spHE*xuMN-1-k({bjpriJy;zOQn02dS z*w=JY1db+(2CL~t3#{!98p#hQlu5L@ah6gnI%5|}|61kbQ=%Ws>>HWSY~_Jf&@z(% zck2q#VKz+T=88EAxc=0ym$4gqv?T?pcaCEG7{0liJB^=RNmh41oUfk|F=6!3=q*I+ zu~M*9JFR-Rk?n{{rdVbfDnv>SUE|Yp`o=xKjJ@BGlni-e(h+phfa#|FY(KE)kj$+% z^e4b2Stl+6V%eZeeL+*8-rZF&DnJZu<)eFF+CUdUNcAyT5)<3MvrKZ12?T8l)DAY` zU)+kr{)p|ysn!YAeERc*HTI;%DggG%wB>M>$rt5kH*^2curvYZDPTrWU%?PYwO;qT z?3nZz$|oxKSz~m}`!w#ojI=ID2!v~`MpS?m^80zq(ZnGPKZsQu8%#i2 zrkV!Uq;W${CJEpM>sS^$!kte>ZA6$j;V)9u6(HGMS_lF2%@hKtc_>>BVYpwC$f1~) z9ETX&3x(@>2ksiRbD+wcn=GY5W-ck&`vdy3Hoal4Qfr-Q(C|5i?vhK4wB=){Xz7@} ze%MyIN1(;eCUl-P$BR#iAj1|~@F>u9&DV+#d>u~L{!>Av!&7qHg_50dn7g>3_ym$J zpsMjAL3+49A@fId>gVVw_k#Ant|(vVEMMi|WI*e=T*mvn2O6YIjjQb&QNF@PmQPyI zPATsCh;o+v$M!YO5MYrvYesG^-!q@eX_dk=xw>Zav8o00SRyl~&?xsSshy#pdh(wa zB{|^y0`?WP!J%Qr7LV%+WfT7#K~tGsTA6ieB_)%cc?kg?BNR-s7Q(iQZ8m?@U6(w0 zDpACYkMxF4J!?rsEL}0i|NMw!3?Em7dBTY|I3adFZMecp29+3L_U@424v=3A}&go;7&!hH@@?CByjKXOw`5M)xk z!%^7831zrx2tj(lj`bVUH#)&@%HVOfXlp>xJ0a1cD$&8DUYm?_)KKh|3AV8+UYD>@ zSWILKm`y~`3TFu^=f~o@JZpcz9>Ok|I{GRa3B9 z^pvmwhm5LP?~JqKNR&g)+;a7uOt5D0p`f_B9DWC5b~1uGIk_f@Yzh2BQ7F|1aN1k*XNYoc+8K0j?mM~2gW_P;+X6eqr& zCihLCHD;owq-OKe0KljT82tf^G;Rq4%>cA5oZjH6rR)`vZ0`Q(`qI(^O&39=*6@iZ zk_;oKs{F&@mI5n#nsFx41?mI6LO?zL!Me2NUp5`qi~QlWq{~*L&twE@182|gRcP;U z|7B0+?E7rFZ<6coBnxZ{-WL4Mg(Rc%*!1-f>SU@plD+cOvxss_5+t$Gx7tlyLvt6;xbmVKt?Pj%K1`M6|hZ z`f|edm3$SA7dz7A79B)E^2e)a;+>0b3Xs5F|H*LBFY33W94_Hunj&1a6y{p6%H2sPw4e z_#QYn zfi>~7c|F!cHckoN+NAE!BwO47H%s?ecAyj)l=p`D1o23I8QJ`+RM>xI9y|0s^WfK? zrP2S#fn|_BI82&{hp{=ILUYJjVrnMq8PpMEA_nrWZU#$ervZn$*vTaLVB3CMW{6%k zEKhL&3CSKd&R)fq39MpqJ2@j)^fz4)Wg$#FD^f(Q$NVu>tA(sGG;3v7HC{=W zX|fqIRB|U1Zel-(3D40{vA9SXvf2|pOu~82eGjZczX{W;_mGscS>N`SY>@dqlBKKR zjGIaI_y&fLB>3vY+j3IYs2id`QD53F4eEDAj{i9xh<*(LV!gC?tdj7@=r$?z`XsoO z6`!Nkoic5>j?j0+9r2?*u`Wl0q=L*STt)L?4?0wtLh6pVYdAg=`L_cyJwCY`8OkI>HTu9yYy%N71)oOlEhxq%c z!lVQ=xGKEG(MNwR@M>D=0QvE^W)wGte6m;vWAetsIm~b%$TQ$j5!nHJBuqJ8^YzWe z;&Zijy)n_EkgD|lln3K7mKZwHGx8-aig!MSjaW{Lmr4w5*7#aNFSp*5;d$|%;04Jk zshMcQEx21%84J94pa`|jb`+-c@n1wo_0hoMutDra0k{!#g=Wp)+p>4#t4^g785cY?cHr+$om z5_&ke#bE^z&^%9|*#dn%5FQ@DMnA*tBmE~d6JhXL9|+CBM{GV!8kw=h+HETpzIB+kBI_=iY&^MAt_0p0;- zXu6(P7-nGFyw1BukYOZso6ou^lF+Fc$ON$ndt!p>U5%rS&pz5YuVj!vPOBZc#qx)1rA{MJCHsKC2y1D9o{Z)3|uru#5 z>c%>RW!(lKj>-o~LY(g)a}ptcLlgL14rrQV%`MkYJZ?;8ssawk<0fmS6Azk1Q72Sti14Ee#USzDlJX5`qkXqk|4M{gL|EP zoqPi_Mi$JZ2pbcVh84>mOlyJ93ABTOjSw&(D*In@9;RvuM?gQeCvDvqq;;f?C*je2 zW#=*2#&+jJ2Vxb9OQT*VaP@~4DG-z2WR&FD<~{3S;yaC*J%}-F397a)ur!}r1zOGD z*sCcsY-}E^AVNJ~TJ!j6_QYz3$Dk0ewXob$uIz+$Vc`|DcHc~7#K%rBU#_! zJ_u6z+9MIM?cgNZMw^1=vE4NXbktd%VFVc@Y^+YbA%xg2*;cI1u%E;0fWX)`M&OC* zNx|4HY;I#VXe9)e+X*8+AFhU?@WmK)y$_m8qj(NRP6NAa$wE5Ea-IIlQ57UsQZ0}T zf1=gMh29da<`UbiZ6svCy}_eXr~-KH?1YQHAv(U1u|5z)s1Hl+`fnBv-s4pz;uTKE z7}8f>Rf7ulKel3P%-C>#6}b&U8%CyQG{Nz4cy%MLcQUY?7KqKkZ~7erU`ykXqCRoYYv$KEFsCY$nj%X zz&0s;#lQy_%R*@i)P?{NG*OE=1z4CGhfQ?*+EAHf92YFMqq6H_r_*J=mr>D>pZ&q5 z>AmeWjKi(DtV=kjGNq|7#M^ookTtJ<)856Y3sM`!)eCb5Xw5L(R`z89stf}TT8n+5 z&IZOqn#(uLUp+QhAS1QOC-i+Anee>K8sHsfL4qIM&R5z_aZ&)f;sFSglSo z?}2t1t7&=jAniaap`~WvwMNR(0u1*Baf20zQ1Hk#AW{+)-fSVMSN>l%nv|wMKieTG+v7-1jl+ zIeA-h*s_8N4QwfMy%xOei(%F%Zp~iV6g|H(?QWG`?yKH!w}<~DSh;c7rj~q&e=2_$ z4dI+>XE98HJ*#z1$ZpO#&FRx%em%jct+hLRn^$$^RCMwXL^`q-_&7JeOMwy$et@Pz zCqSLI{cftp?1A(e%V-~+nu>2{WZsW;qmV@Qgp;wUviKa*6WYkf$ojYR5j-^;`3(um z%mn9|`U53lfG}sP{A5MQp|M8cC7}?1n^0Fvx0je>tx0G858j{cuegarHx7rC9w=}d zB%m^S>dE-bSm9KBoG_tXfdIJ8$YxQSnwqEV=6hx;jpq_6Re>i06{qC5VE)cX{wYj^ zpcZ`2U0dF;^|DW^f_Y(&enC}6l<(U&tGxwDrG3q$WqExDj-+j7VGAIHLN~tpi?ROK z2i&0dG&Pxu1Cr^kkybX_B{B9q=B?p%i|q>$djO@#BDN=R-HQpwiwzz^7)m(%v za%^7S`U{Ad9Z_QDGWq?5J@TJ)xIZ}zPh!1@M~c#Ez8*VbFHiO%)ocIjiseOG$EZ1% zLZg~zhBhlFUjJ#*1u|bb4XiNj$oTaDD-konsB_d%Khuv!@X-*)PY?Bd47@UfAOMjF z;pb=kKQ+Cc)QV6+%};GWR?cSOZt@$iuuRo{EP4fUSfEqV$W!x2cGm~7T9QqM7GRTO zcwCkWHmp^he+=F7GEp`xg=XZe zc1PjdQo4UE3Fryos;QfuqB3xg#tAXu42w`PD?DDAh59A{G&4NKedT6ek zQ1%Ghj3xMq&N)>}*qUHXjmx>*b!@_QSJ#M5F)fK|H83%CpGp5|0#oe*+z#doBc>d3 zakd;%Yn@nY%iSt7ZC=1wVVngo>dFZVf|&Hz@T^bIc0iJ+6Y-BdcDud{hqjREBi)Z| z&}ec-p-b%a3Ks(AztdizgJj!x;5Le$rcF{;*aHw#T~YdfS;3ksaId~tLMS|AL=!oE ztR|!_7vG?MJ6bG)Ih)5Ez;#M;?EM6}H#I{z#zo&PFm_XIPEBX^W^RN6j8Rxt?kGLT zJ%V7jG3*E6tDlm8%K!ytzx7S`v?|^rpvaebBvhd+p^6I(D5I=edORow(8z=vj&w1L z8yV4)*;4)o8Hs$B_1hxX1i2%8ys)BcB6yyHq6GlJJ3x*i=(`bOjp7?E_X%jD#NXv} zGyk7Lib^w_yy@7pFXr-wEr$E}g>W!5C6=F-GKvxxbkd%L;SWj06hCGdIPBa@R2Vdy znz<_)3Y9}4mRm;taZm_{_?d<_6~W1XltIQh8!eGV2}WFd0oy4!JxvEJ->%D{afe%I zcA;f@H-43}rd?$32iGGPA5Sq`b+Vb-=Kj@UgjMLiLi!4}lV3C88zrg*22$^4C$Di^ zQijRJbdI7=D}hAIlSv)gO}3yRCD-m8vxnSlMR2BgF~)q{ntqO8`C0&@j$M(=5b%r?AcHPt+f*I-yN!rYFG0Oj#w^6*pA$V&$kV=E=F8fa!EG7Z= zk_r53?_))@saMbXm5$p^J^AP55I~{@j+srihB21D6yezV_IHnN0EYwfW~Uo`x1kx! zrbjrfpj{d<*M5#Lti}oOq;bz%qB`t1TBa{ffedm8lAAJVkGG@R#jLe$KGgc&eiSp# zM|;*3@r78mAV0%7guwB3>MAG`+(utrAK{)H+$i>2Sh$+5Txd6^7${DJQ(78LH*kou zsC7oyQD}D8W8NllG2=nVt+hY|@?=?w&GAU-*R%r4BN6zY@SD^N1Z-zbtOoWA>(-qv z?&V70|E%NduNNX@_hL+hUgB<|#dF+gLvYpLK$CDIL%shJ7nZ8Wf`%?a(&{TpfK6Pg zQ3kE-t-lQ23lf!*7JVq~=}QMfl1aDVw@jR!wO0Z*x$C8TFk5@Y74l4BI!1aAwGFNM0@xpABOc55TIHOcQT0S6Id50hU_%kz8BeeX|p z>t+({*w%;1mE6eIkoRUTX!D8Bv4|x54ne#ELk#|CE)^KCkbW_Q~bS6@G6i1aO2Re=mv2E2mF6=Yqu&zp8*HJcj4 zNC&XUa*3u!k%8}7T2fi#Rx(MNBKAdrjpRp!2w1)*edZ7cI%SnnRGTc(xZ508Bu$zog~}^;_70 zqLD>e;t&%{s}lWDLY~q*I!vIvjMm9!Tx|3!!IvgW?*jo-RDmu$rS8gE@Q}$8Chai7)XYdJO41p&8 zil$7CgUonn5&5NzbvktCs>u3NgA&BHydV9fZ)HiA<@~R@8rY>VQhm(XtFKgYoi>sR zePShIKwd?G0rxqUNI%~A-Qj`AJN@A2ZKgtLZfc|%G<=`7ZB(wm(roHnMr%NnR}=PV z^n0JeQS+{o`_fS<&#qNXR)$hihU1G;IjlQg0XO=SY8_SfkfNCWU;Mh&lZw&!SF1G`1m{@;1hmji@Rdi%8sKP=AX%MA`=GHOeDCX3+3~mVEeRw6RFOM#@R9uWyE!O)N-r0E&*-) z>`{TM8Vgo)0TR^V51~COY|wJ2nR`QIbg1X@O~cQ1He3T922Pbbb5H(biw7vj2`&1D z(EYh29k@<7T#4O!7s0y)ykhSk6ha{+=M5R6U8Z2illRxTr{;J9T*lT-7RM!uw_=yM zO42#EibTIAIu$G!rjTHC(t{1B@oPJXqS-Ym?;gUVPKqfke~Hy8LqR1ICL5(y z;nlXcIXWGs&LjRvwy-RYd%i{t^u%OJ4jwrH@LYbkZ)Es7>V`U@L{fMZDBd6+0lLf( z1X{L*8;}{so9L4t725jO-2m$NX4^iul-sGH<@7?=M$$gE|175$Hf%JlHYlB>`5|Pf z@I58QH(K!s&X9M8-bsCitu~ww98v0&7^CWx)kNYN?XS0AsCzS)_ns2!Q6vWCo@?6i zhZTp@XVLhz>TdpNrZU6fJj3m~f+1<3)Z>W!t<2=Id*>xE4|bOp+h0M1w9M>cy#4n{ zQS4-7*ZD;6FF%MW@m0)|!5)!z7F4`fWXo;&NICrVA{QBG%t*>rXV0W3;``Vc5YZwC z`&tr0DXe6T4#_mr0$7FYZASGi0sK(sTQLPb6W1uV=g|ZR5;l~HOXp@*D7^A!0pWQN z9g&v+r-A~w$(A+I(wTbA*3cJq%qYH7C6xq{k&wkj59N?y z=EBzuIgl4uYQEtvZ{b}(xzf{~Oj7peheiouA!mHTbvCyE0~d}cpi>X*X?>G~M}VTb zmv;Qla2I}dQrap20srU92On=1ng^_4H25Cquw~APmMP_gZ2-Hq#U=1o8J?oth^;Po@RgAJWz+t zVnr~f;c+tXpzLu@YKCw7JtSLlq}JG+@w#@4(Q6s2%QYDN=*)3<9@k?t!~XZ*Ize&l zw6;5>W3BzpTe;?6>wONKz*Nne$<7baMwa0M#iMIWs8&2ZhR6X?zE1ee5G-W=&0p$!E)rn2f#m`GK^KPgXT0O`w zT|K;~OfBr-btFPYee-=0chDH!g%I~&F~jj5VAV*E{(jUQHj zI)Nlr&PxDY`PiS0zH@iuJ5Sd+En3>1PK0O+IXoq!L!y zBqR)VMwydFO*TSt3&I^mL$no#)ofrJKaU*j)<3Ii=|xc^v$G2LLQz1u$HkO0ZjY6B z4Hx-%MdYcAeQ=a$HC1U7))Dc-MM>$g3!E*)+{sD>-Dy>efus02EPD%pJ)VEFk89_Z zscP*b3mA9uC+9JcgP5vrOhNUYQHwbX?tlbK_S^@wbg_q|1WWg1GON4R#8<@p(QQ2n z7sPdUiVrYKt1f~@%-I+xoccbg84`s@*`2?~jqeVnr<){VB(lqq(WrHd`G(UwZmYum#A~4 z#`&Tnq}6}C{?hZ7EU&Fs3$ zXgI0eoV8N=wSXhkk9q7PPQ=fglg$20$Lakz8~HaHj6*%1dRJ{TJ zby#pq?1AzlF?7bHxtTxcLkd`cw#TuA10Lvn9%?Qwb0re|I3b`&MX5dDa#g_o z(N24R{2u>c2|q(ui2-d(^HseJx#fNcvL^3PmtmAj&{3we4AwZq>1PUH#(I#i5*s7+ zpiB!`Ni^w&hP&poy9@276n4;Y&a6ITkZ1tX=9^QnJ0F;I6p>)@ciMV|qye226Vu95 z$7TjcWmz94nY%0kZtrgBLPyF>_I~CS zUmz}v{fjCQ1hLnoJV1>pYP?@ozVX$LTyJ6&kv?@ z>CDduccgk>!6^qN`el3$9Zf@|Y)=$514Q;3xOG0nCQDa%0XqLOzjuA)@;h>6`0r!#p`ODfD*Kn^=s$uScw@TZM8 zF&(5B)#lerU52ZU{ZcG0j4k-?J-fq#yTx4v?*(Njk_upX9$$2N?*|brARc0~xqXMg@^hILP*cnbt{_&}dq+^J{ zG%O!0533mfOX_HXKdcE?c-M=%#@yj@Fa{|;^vPyVkO=6C!3(-CKKxFwYUd(RhZ0Y`{6 zj*!|swho|Yb498P{8^2Z5$x}@YY;Vj=f>)lDZp&zh(9VqHbhHs&iFN*dU4Jwa|miO zYD4Us|4L|7{3&_KcuR(i%jM5cA;Ks^;T9$S-q?j0>}f?`{!(BJUlox0d|#HyZN8(R zy}WUHlC+^e*by9hFR`&uU1qWDWZgC!xHGW|*LcgZ-P>A+wl#0o1yn~+Y#-^mxh(us zZ6soXfJk4y%GapUe0Ar}Xp+|^R$=^ntY!bkp!HMtUV394SMQ=%InNiT_*r5GrW^Fk z)H8O9pKwji}-ZK|5lF zhBCj7k(@KsU={0g>9ZXz%vnE{I5fE4VtaQ+r;woQA?miA2&J;vq%{)5Xj^Vg%}gSDevJ)$(`xEi{3+!`459>D>Ra zkG*GtYHtB5<1=sSVSec!sbuMxG&iFeJf^qdlqH`&b%IjnZg%f}X!1{{RtbOObMTmt5-I3EIS zH&so1*vFrH3&g%-Lbd|))hR@BRZ$)I(#O~K0ll4Naf0*c=6Kw0Y+R7~-{rN`@V`J) zNF`y<*0<{C1)}{)sV%nd0Miry@;S#YJ=w@8;8@_vGK?#~2H$Kg40Dryo5<{Xbb4NU z$6+21#fBr|Q%y4I$I%)u?65*(5=*sVu~%kGxvdE{M0=I9^%MBi{EAw^pd?VqxZh@$Rx9_?z2zh`7g)$`YfdkyPJap5^j$a^PK2cY4P> z>omV}%pTf}#TyX3S#XyjtiLo6%L>gL-4U>2S6NZ;IjRpv4mYvS z{P@JvEzoT1m^$W-g{XBbMEjm`tIt#dikB9Sbd7em}HrCgO`B{7`)e^bj%-$bq0E*3RGP(MCXdifMsj!h%5nI-tYm#SA7g(7czqa%eIlgj-os!6pqYmfTnRm2ZmYXN`FarWRD4 zU133to3^128R!et678ta`WL&Tl){&Mf9;)#N37)BptfKG8B0JbM!hjdoWejEB~q=+ zHg1Wq#~o0%E4wwtUIIbGAC4OKur+n;dHID!jhZ1)r=Lo#-4kLsm1!&+yjOYJb%BMW zSYaTNF4oMXtbIy%TI7H4tS*({_@PK`Bw>LJ3mL?egJbrE-Zj3QT={B!#zMuKo(v0}gSHwd~_)6w5;#d+~t(TEBgPa)%FYX-hHFejGfZ@(d$8=E^Of-8@1rq%8lD$zeA1E)MlZD<<%fW1qSq~H-yTp*E7Oo+YD-z zDM5+;DR)|tq<;r<*>nkhS9taDTS2}LX1+o;_G2|PswiDx3aHN!gr_k0>TOEbq+f5j z6+za}4G*U}NBUi$?*oAf%K?by`4?eF&0;+pNK$f{fI1?XT{^Z{?B|@g+)`~mSC`6@ zw3t_!)tqi~S^%1I%5Mu~U!f?#46Qacm+es0WMQ|f<={$VNDTzbn`~s{?uA23B&C1dvDq zy1PHV(T4*C(sYjvj*?Wh6cQ-=G=kAFkucDo?Aj(DeH6~!vr~?Gve#ptR<>rlcziv9 zDFJn<-bn)d(AYTE^fQn$N1Ksli1Vb!?^wR#2k<|YC@rSS`LII7RYWQAZoxuT9e3@> z_hXJ+{e&kKG*Fg8%uM?JWGg>U9F{7HZqt-HDy)S*j4y}d2Elf#N~2$i8)XO}jdXL4 zf24>?8nUYuuYmrpdTF%aA^Gi(aC2cuT3pJCm07P3c82smUs`FgH)_m>wcn;{^7x;BnL~ zHT5TARkvt;^rGc=z)WAG_eLvi&YFQ+a<@*6IM~23QS0j3L14yqx~X??L_t_}d9gd| zWCW}=B5ahhX0z6$4PN5Po9svZ{=T$IvBi)DDAcy_9|4L)D}w_3oqxh!K0zUZ;Qx5Y z=0u+#R%s7S=8-0WL*_k4*D_lgLZ6cF>hsltKaU3;wsFhEBIs6pB%A7}I z_jB(tv9MB{t0<(i0%<;i-pir5mR|WlYkqzHCr4EU>N5?;$S#GxJ=1S+U-A>RIrS&a)-?Y@&Q-;&p1yD(Crg!ssNxwabsTzUs#C(Jw! z$!|Gr!W|D@%ydF=z#oq}w%%Iuk3|%LQ!6vtEc0=(-p<0XWYt|nGI5fX)?2s4%FTG~ z?Kq9ezG+rb3fRIS^oPaKFk3*z&z`JZD*#9_n{3%abga&31%xu|RQyl4M2&L5jcxh4 zh?d_AAQ~wqvA}FWd88Fy@pB{$R7t8LJNEP zlzr3~56`ajE4K!;f(K1K@*D68DDf_$#?=%Ys0}Mn0>q1yR9{|LGx*fWW>vNQj@PvM z&0#EmmW&{o=kB&!&`0Vt1&|V-w0R^xula>vdGW5U9;zY1gqEg1OOm$9cP=-OJ=!R2 zLsjFSwX{@eve+h&9|Gqtyeqq}1iTRzhuNBX|M`PFHvd9oKp6zIi=LfcZv+z#rrh8v zcmxy(1~k@}q9Z#C<2R%V%VP~*qlsQ9!zssK*i#&Ge4ybix)4u(b~PcfLVFs#&lMHl z1;r|-?P=?q22z`hqThz4Z&o!I(^*1y!g8VAn(`n`)+OcJJk?bIQ<4I&0PFj_i?65- z9NC)vF?UF}cG!FZjsl&qd#;eal5Xi-aU|Ea!%~oF#hxu`a4=q}Cfap9`xpiMMQibV zY`!RU4Sl5Xc7DXfqI3$#%XP0=n_9q>y$QkwE>4?w#${()I9h~(3PH4s9VM( zXM&4_`9A#85|BjwG^qfbEhgdh5UTwajM*({g#U(uOzXlmE!RY zfHt2?3gbo?qlfnEWEoW$6qcMoSDbo!}OC zH+3t1#Dmylgm$u)LGw{Jii|3hIVq*0TPS>#Gm%EZh(V1WW8_{~erv-HOYP;N-V|yM*h()0h#wB@v znI}y`jn2E9+zmC8o?D_|ybs1a?4ZBt!r)sXWxg{7lG=!iP@w=(JhZU6>)d2EIIeN4 zg+0w}tgc9TXScuBWLLNZ+?|)-mJgTaei- zn}VrXE_S~;mk|!)-49eeXiL?)auof>q-H7!R8t2EswcyhbTU;6#Sz85vq>D*9v=V) z#p%AH4fm>Y~|uUQ9a zjuV+{!3UMy--sO^3Ud{PzR6p8{Ibo{Te=Z`0zX2RIzwimRFbg8(K`5*PO-sC+nRug zDKeQ!Q6p_h9bF~Z(GUGi&z3q%XqH~ZdkdoSt9yUj7$sM_RYHoIfbS^9Aj)^J)R#H( zkl7|PUQMTsb5NxcS};J{o78B10IEI*oKhVh#JsiZrPxAjrj5qYx-%B6Il=6^$;agL zwv%vCzf6OT$A)oYpE{?|@Dah9kh9DE962jkK!6P<_3;UbKc5HLbNG{?45w{Tia{4$ z!gh!r<|Vd|(Wo{hMaOa$o$;EKAfP7^A7@f!_5F-VqBa4IBT*c!%nHHQZ<64(%7?5~ z2HnXt~~U{B2HdunT)BQ(<0yRTa;&%OqV? zovcdhz~R#@*jtZ|cWegGXzDZ@S?o`lWu(Y_MRe`t@^Yot1V~Zd^#$L{0+8vjm+7xJjLg{p!jm|^;d|+iuU8E@-C1>!*J#5Se6VLs!cT1p8 zF6DkEpfA#=Qs&1dPqe))H7NHnNZOz*X%D2=tteGv%)YhF$6AA&sh#Z9>ml)NN%GX( z$bRHUWGGOp*C}hET&Qq`cH+{ndMvwHXP{tVg3*#k#G~xD*{u1-mIqd!%AifgrR3Qz zH?5zfhDAlRQ}UIxNtp=)Xk~=Wuxl(Sy?9FdR@n#X|B=S#m^zT8nWw$2Sz-fKU%!A! zjoqvIMrOZLCdDk8Q!CHd>zq;I>9ZdreG6_ib0Y|1V$|Q}oLLY^_7@0VrDZ-C4+x*f z96;nND=mLFhcb$P?D1GgE*hIysyQ%ohGLMU{`s+Qi5WmyA0tb5Pz~~_BpQTuKrucG z24F!A00{H5aZ>kuy=>)uC|P={BY|57y-Rg^HyVD_M;|!sWxql<35Pb$rcYp*88Q=4 zm3A)PEakA7W(8$J4=>yDL|r3Fs7hNo%vMH4<(-u@<7!P{BXz|Oe-$w@?@&Zf6HK)y z)&=ds)qJV4iCzspRT}r?;==>KW<;R=#ikTW13r!$F1)ubXB#!l>gy&EnFiI1vYWu; zvVjkF7gjZZvEnD`TpdR5sz#^VDf8t;q*lkQ))1r4Ks-h5^SG0B5*q^%CH6jX_pb=& z&OSu+I7+74KF)nT$uHp|p>qyuv}mHMiqJMg{AwByVNj)F<{|>B?9sx`XwUF*Kg(9< zgh&NY{KDo{LfWoHyf(rd2OP=Uk%U-bx#6RE2i{0B`xv5i-MWNB$j;ZQbbBIudB}r# z5BhEUFO)<4ds=~VvD;u%s6N2U!Bh zB+KqT+_00$oksh`g@$jZV$1;?QUbx@(;^6~X^4HYqq>nvOG~6NG1&a3phf5d?e+IL z&;ll*kfI2cD>m`LsFG zp5!4B8f#Esu>`>6!73C61^-fO8ZKB~uPr8p1hgya^6ai;pcR;#%L;S(c59xaJ}!nK z6?@Ift@YT04`m>Efp9j&;4j>zT$$fFg89cB~Un(Hu{jlE*{u69!Cj+R{^0C_`+cT`|8{fVg1~zBA?- zfhIgjVz=)CYtYjq{@#lH3C+y`{K(I)aud~S)Tp7YJ%c-*{w7djwvpL-M^QEnCg3A^ ztq4hG9rNA?H2mVr1|mhW(?j0um6d}TFG+Br176-Dsi zG5yIzweR^?*s}u9mP$dW)B0w3>CwwdHbRK=(eh4Yp+&w7MO4SgTN2TS*{&auw^2z$BAEocVNDv2p%iC7#MGN7YqfQz(pYqFKQ0DuJ1pIMaEmx+~QLC!`gCr@16M2Ti;H!IKO>_hw0^ft(a3%{`jbDBVIU&PC97NXF7UJT}Qf$vN8m@9L;f4bT!C}`l z%_lw@d;n3W*-NQmfECL}q~Y7~o%#UX9OEo>ffY?tBmsy*g3ow5cuC7%c`zqTd%mOV z1H;*A+wjPz59E?VYdc@a`bs@}@?8-ZJa?7b&CoKxN@B1d+YQ3kb{?f}5<{h$dDV)< z-Vyw3GS2BScN}CE!o^+!$}~i8*|yXS5K=2cz;IcGN47V=GtBRMrGhqCVcz#7lL=9> zeb7I-52e3yyax%Y7dDk>f#2{dIylST`cI}w|DNTrHUJCI9F#V9zWHMLx2Hm>MjUB6 z6>1Ic^zZy?w9YN)G@27x`$DG}(ds-FejE~}gdeNoN+&M*ZeK&WrzQ{3AzBsR}TOd*Y`U0|V`31UK~I$v|x zlC&KsI=wV!`VZMgcXRN{UarHdC|8ZyAfYMXBjl!Xc%~mZtQzIq8!|PK3NO6XxgrqN zK{Gc!rMp8+<8$39N-VE?m;}hGFC2Qi*h6Hit*W z#h6F;$!6pBMq-<#C*Hc~0QsC*;yPaVn}$IatVa?{609DpXnzis7i8=TtZ_)tkHSx5 z#-Z4HYxFogsRkacs5G^6C-k6)M^Jav-tjAEl>lPhQE$gi!_A3zdz<@IlqO^#S*z73 z>kD<-1=kpP6C3xv&V`s3?y}+pUB@2L&58C&AZCd;|AqV^p_~vxkBctrFgP1>u9qqb z+SJDF9LflX8Go2q}cdU+KD@iGf=R6C2&jNMnT4B*@44p<$JPxrAMJ{JP9aOXdYteg%(A z+!Xl(J~+(Qd2iJI(tcSwmvOSHNE(HDEDQwp290%Jh}2be&nSd;BT=g}N8Y?;@wFA7 zZsf#**^Z5sE#T*Bb#Nd&jb5!JhFQ$0Vmh}P?-Pc$*bUBH>EYSwVHjV>%G-|m3!ban zhV_Ny18RrE_6w5USo~GKZG{KIo4&s)vjm7U#t6-AYZ&K)+38B~h)RwwO=7<~N@7f+ zJFrEch6e{Mqdn;@ug;)-mD0|s_G3M~1TuxSF==O}=P+%IR23GwtwOFW7>QjzSHOo? zl7M#?Dx=o53z#dOcHg=b4btV*^53a}1Naf0VEMDwP`&bb00oZ1UJGDigjLJm%RihR&8!12q^7RI>kDfQqZ!aF_8Z6)ev@X-5n zYa}$?^ftfevduz%*yDn@5f@p5wV`zvVVO(MTJyr3<9~S(%9?wMyjCN}tu4sbg*A1S zm$Gmcn`I`t9G~>X@OjT>a29td(3$bY4zbdgZS+9n={z(D5d@bZe!ue`bcFS{yFAiw zn!rIc#=MC;3Idbzoyf7E+@-L?j-N;~hug!kq!&mEb64LGvmhm?qHd~Q&+J<@n7Xu^&pSR%v}UwnEC(nCk)jy3fUpSG9EaIS6mU=N>S;_g$~}re zqQ#VHKjesVLo651qj;KRO-j>S>xeY^@6iT!q^r-LdXKzR$;ISx!# z=%`Lc++vJy0_1BFJotyf9LwEMQemaf0@GG>`apA+$m6rrte zoUN-yX3UrByNoA(>w&LtAbm7=LWDMF3Smz{2fqr$Oonu(j1idqPivbE<7L+!QFytN z_T_dZ?UMSI734H7VeJWSG?Ib>%Ez|Cy?t6hSg-k%gD^J!(Ar}FZdFBXu)Pg?n5?_K z+4##Cj(b6z_5%?7fv^J?B67ztxJLbaRuO(C>0G$l>?+5I_udGB4{R?rwir4=bAau5 zCCAW0ju{@r#`GiOGW2VYeoUo@umES|C;hpP%@&?HEqRy2XK4kG%?Pj_)#$x^eaG`Q z(|I+YpaKI*FbfZv6O@_(Ap8RQ(Jx|DmmlEIZfW^^wFq z;V>R9g%dy0>p%pXpxOlZW9IziOGg4@jV|u;>k3tS+a2kqsC%WNhIu8nIDZwF?|fg! z?`MFWj?U%v(@@S5K-e{nt0+wS2Oc8415r>;K|N1ISrKP453h5>koJn|+t@>(AJEA3 zsPN-!m}-EnX!Za`^#j-!&JoAdK@UUDy>8P-Cw)gtS21o(PzF<-X&m+`*MokcyzoeC z6@0?FT(W$+mXd+!Q*FVy{LH~`87iYuk#D?S>YL6?^7JWv5m<>)y<|D4xDdypGg5qB z2ic%)rTFQHs}?rMZQP7PU+Q4J(&}Y|uarbs3at)9$6IZEl?@&-;lkgL3&^|VFXMvZ z=LFaX|8 zG(}5P@UrL@{8I8d*}4MHp%0WQjQ27_5=GQzZhnv%?$lMHEq2pW?&DTFv4vC~j}PM} zwRkOl5&KrHlGp(a@o>_ur1YAVW(=PCj%Ivy7fu0X2lee#u~;B?hcVantm#=k$8N9JwOSBE2G?=)u6JTQSX+4At?CD53;@4$AVY?e-TttICyI?vOuy)})!*r`GX zA+`!_D;7K;T!8{#k8Va;^Tt{>6$+7D{92XMcF@ZK`~nu!3LSDnYVQ;I!QCynH2#$( z7TDjD21|Kd7^HGCb$vVm6q@~@q^1R2j)TUJ`)56 z?P42+cp#CPD((a$>!TXgbzaA}S&*Y}9t7__fZ6Ya#c`^+UO*{XfyNyYP}46nUL)mRxy6FonMHy;hx($Z+k%(fqjC0hRvy z&8aX-2J?Qpc=-Kh`?to+*X$U13FXSw;%f7m?&_?SNI#^RF}wqQ(7}`Mq!i7bFL&Ck zKsvK+mGJoqLN?Qipv%uZw|e)!@SQTUb&eBqm}Z(WES`WKR*dsZ_qX0B6T|wx zbuU7mrx1?@h^Zhdh*FX&VPHPzyQ9QwVj~9jX?o#_b)raR_PAn@^Ap&RUDZnJWWkBq znNwW(v3;{VHx^tq#<|Du2GfH!{H%GLFp|r%4zZ_C8r>zgAfFZ(Hl3D&#tVaw{N1lS zulyq=yFa*^q-HcNZ*j~aATxo~Eihwa1ahbo=?GPI)z<9nEM6-xOzTOkd5e|QzOZI- zKy7ubcIt|Y;di8C46qMXb5gfNMV9$7!ELbl1T*cg@)X5Qx>Ip#%D~RqENWl{S_05t zvcZFeY!CabeE;%72-Rmwhe9MKy@K^= zjVx1So@FJyIr0BNx)6f-(_^~oW~^O9<3zZE9A|PJWp?UhWLNL@o2St{C}@M7=Yag9 z$kVAwR)((K&*p2A-B9U+AJ?%U#&FiVf(S>&ijdkzU2u0skrkIb%F?i-C_BD|X36(N z_oKiE{VBM3lds4LUSCIX@7tBuXrJyjjX^|X7ymhDvj=WmoLlp4Il_*{eFIg{S zB%v#jb#vX$^!JGTD(S=uA%ux1Vf-Dz3$J<6ir3s1U0_Tgs$J#SC(0JWJ)Cz@G)ru9 zKEMxCLIO;zEUh38jBxZ1g|2w{x5Md@>KI@fv?BVDJ30@((AoHepGgZs5zDj*j;piH zz?^_{y)vmp)c$sxLg{wBdByAPx3h&n1sJIah9~xBh)H#pg9>bc$5$^8ag^L}c*tIy z^|7L6h7MAVV=Aur*^mVr6bw>zkPQ@f%-94dTeGQUk94~7@^leC078@lK!NeQGhvJ> ziGmhHNfGWBSh$#dunm78BcsWL<-}1ZCO5DsGV^K)P(T`z_Jygr56F8X>nJF?G_MstO-6pp$1uP3Ar; z=?tr1fX;Lk(0V~k5V*!b15g-JM8xj;1fB)ZSM>h4R(wHV5=6||5)k=YS$ibu%|0P# z!@GZVBd?dE!c?JY8z#?=K$VVcNTFXKx6)7B{{MvDV9h?>%Fqv8@ftCon}hr7&#CQ0 zY>@b$vbagW=dC|YMyt79SOIZwpE7sJ1}YNaE9?A$Sf}rXWQ82xLZ;qC0hR++M~yFf zzpBF`_XQIL;NB^}$*r$POyz5;U9Jdzqkh2cj2s`q6;k{rz*@~EdbjD*3;V!e(<#no z&Hhrx_u67X6HinTxiAP2kBE%vXox5MLo#m#Q;=RN?4Q?wLry!Y_Fs0hSpD?lED;7X zYITrJl0Nf5R2{bp_sZTIwya|VeN(hC&}XUnVwa}_1L|f=KzU<{&`qlUVq_Jk=`CRN zI(QC8Y6w{l>>lnRdvr{~Z)+{3Fc(>klV4YnI^!>x6H9UEt_P(4nD0oD>{&z^M430f z%{{!WU6W^zd zXG_l?lvEkO&)3e&smm0FD>_X6qydtG9KKyYS#@ZJ45dz=*g=W_W6Vr;JU4atp@*vW z1D4}L2qieQk=5?U)!bL?AHtI}a?^=1g^IN3Mgn+1sagC1ng;p|^lW0oV#zABR1$(J zF#&bzVslRXo|0GmwaToREwVY8n9T6W{K6ieL~u{^oW5`>9C&iARSVGR|Z`RfOx`pG<7jNu;1C``6WE(?c#kIPs=gm0!{gY97hn@zM(ewlj-N$ezp? zpe!VU0q0kx2;-DoKVvCCkhH#YEvFx)aEc1-;|)pXi9!jn>+H}{S0u+$Oa?AF_Y~GU zg}Y->@za$YpDjY?#Oy0c*gA3o-Zfr4b-SHOeWSo&ZE{4OeUV>!e=B@=nB6e&X@AH3 z0f%U+zfaOVD z=gKE=NSi$c(z3AAmkn5b4@VoaXHFv(3JC2G8T{T&{?I$8zp_PA{ux<{I^Lm^JzvF3 z4ja-WDFlWAA|aLTkwDh#lX3#bI2qG!_ArMH-7rqtd8aGK6Bo1LaGRbQl*ntMwjix< zEL;YIN}LII^Efd1r5!by*x{3Q{k?ryiD(Y+4_yr@l0ktwEbvXl)z2D(B#OJ1r5W${ zjcT4%?q|a`!6s@PKz69`q2LdFz-JRs)KP*^KRL7z4L0N~#>5pRS^#d8H2s0HAzOAm z)_yXJlblEHDS{j^6(3AFxn@<%1S8!3wgCvu0$_K#sVRC3i_ooR;=MrA))$|z#lChP=Yz%$b; z9UV%l&58bl@s$~&DQYuS!1U#??AA0j8;EvJ_v=-{e+)8sAhUd43T$QJC2OkqgpxGN z7hZ4Y3evnqY+Rw8V<>D$N|WB-ey^>-#7nDHD$xOPlX&l>|1BxDjC8_MeSSvxcotl& zz^L?6Jb^bKkssk+$q*M8pQh;9^N=fH3R=0y9niUlsbgk-6BD>z_-Dnw+)L%1xkTO4 zSU1LqZI&bAbna+-vQxklmAB~&Rfdj>7wvS{(n}F}D6pkiZF&%cyy)LoBWtz=9sl1T zX2B#1NSx6-K<6Hj%kUV{8mqH*t?_WOR$O7@i#{go#+j;aF8s7YU$wh=zSEP0TU|^{ zMoRVq*9lL`&v$3hl-BZ}&~wzS2-=}MM8_nquZ5k}PW*63rVdaLTadm|fVL1WqMb;d zO8c1AQ|<)_z`11NKWi;4y~UK%VxeEwwb1U%n9~6K)+e_{^P5D_ceS|M?%gS1$Rw(O z>AKwXIkm-`8$yrlwznmEpgOXcC`;w3rqa+=!5r^YDLnIN-)lZ{sz;6Hx};$Ae>1o^rNz zG+ZIH@s1V}F5T?H2=ey)a7mS#iS9fhm8ewG3&@5_m_RlQf`lMRZQeahKJA`#+Ey%rw9Nb$cLxf_aR_fa^s&g zDBOg^|CJ)Rud>DJ_?xbKR?39hwJ7{CPocvdD_y?X!idnwD z40wHP>Yg9pi~Mc!hd!}Sfjv=o_SNz`cVjm;DX55(*owTY>pv!t=bt)S1Y1a$K9y(bX)KcD8Fos3#pGiQ0sg462!P$|lnR#5wm7xN^KGLCugOQz2?G^LBeZ zBi{bNebNLB((@OP|MZw3l$#z#6Y?6WM$IrBWp^va>#TvZ?>cXxWaECXz~)$Tb4ib7 z_$rAdcsl8UWA0R3tg{H&pF&b^R^ONv6v5=VX16M5sOk=j`AaNWMMts2RPWT|YAT4b zXZ)%XiUei8)pxq#($-fUB=Jk3%u}0p>nvAWwFHss@S~+9%0_~moROBKMY4D<{Gv4V z#$?N#P)0}4Ey zm$qIK3`-dglVE1$&^AgI-Va{cqw3`0@}{=dNUf-l-e%BwRTBXrf=s!V0TT=W1f)*M z--C7Z@acoFhLBv3PW?Z~hu{LD#P#NMP}?;5Ce1iSm5Vye1czb=PWl%hdE8vFWy}Qv zOY*%ZderZXF|7L_r*{KyTL{zW1`1|h+YRmRadx0gKI(P~n*OT(*1x(;@;3sDxgVQl zm|bAhR(AxSBo4mM!(l0CvulU9&$p-xNf1Say_ zRDr2ZJv#Qte?DqvI(!TSrhtA;3ap1AnUzidc+oTg(a7k&iiMTBqL~0AssUMW0-*Hs6cT zPG^IXfH9=CAhLii!uu|C0)J`cS&WYDR8{0f*^08(l}Q^M*`d5+q-eT<>5&A^9T#?4 zLLY6=@BXa^k(aFHq$Gwg5>wNg(tePPMnBQd3fSJF90bKgV}#lAv;%k;Cm3Ak4w)9I zGTXR>m3~C)m}G}*bIgXqe+|m767C%(q;Er&XYSes6>&6|+nr?`$M9Zfgjb%WO&Q{L zaRkS$C9)Po%CnIJQ5h%+g&<71c|^1X9?LI~#OR%eBLndz=p}f>2Vo~Qx|q(l5i)!P zuVH-R$9U7(zfEwh7-j+Y@Oh~sA)&Nsx*b|MhcP#`2V1h9n+k)^OM#62e-IeZ?65;p zb$3awaki!bCvdL04cwK#7th&R_F5m%vL36+;J)eQt-nbufm74}B3KiwBkKy9IE<0# zL+X>2g6y43#eC0O7YAM|UG4+DB|d;RF`5p$KdiM|)2>E$Yu#Kb=qb98+X{pKLf4^njAmi7jdQMAjeSEnS2S2*A1hYr_aUNmMJ8R?t2kcudgVg}Q#zxV<{~^6yA?Du<$_s8~$jaKb`e zxiF$!ol01YCMdaHeX%!j&y%CBj#ydQzUheXs#+=G#QO4lF+i;c{0%pP+`@tmgSYk}h zbZ{EU;^p~*4*gg+(JCjWf>qV6AL?d&xim(szNeKbp33j@(4No4h=O#(kFu$BiwPz2 z%G9+%=d*)FwIF*=9bi73_!_#tEgnKJ>#JU?q)g(HAq0nosf zPOoDpGu{w`qtl+_z`*ff|K?E&YPwE5LxgNgp2+yS6tzH~AiKT`V>QBP=cAkpvG~7T zxqH}7H__C4-w%jMqP54fKtZWH_t{-f;y_{o9nN8To)Tt<6Po3U3b@j?oq$ATYZ=n* zsn7c4uiz$;T$U0yeEAIyiW$*({$J2%(5QYfVIz%XEVuQ?4bZq;_8IstZ-)a(cpt&m z($ifGzE-xKokviX4}r`8F#X`pM;Y|pq`juYrXIIqrss;cM)Ak?J)I&X-caO_i}6Tm zH64#G_@Quqh_iUPQ!FPW$;trO+`toiiQ&+Ovuwg(o?Vg7qAQ|1GaWzcF=7MIGMz~| z3|>>%!EY(I4RHK7>=FDt^O`YBqbPQlG~mLdRPhV-VP7h0HAHbuFu|Q~eTl6;s69Fv z96s{sbS7P#<~~5gfU70_d^TAxJtRmUH}b=3$cJzh$J9dHER@0%_|bf)Raq_y&$G;R zhRnE^6~vlrP=g0^!XpKmgCTV!4%QTlf?w8$?J6601f%E<$8+DX+gc{@qQAu7al{x( zG^2tXFh@3X8T|g!?y1%2&Jx6DBCBD(%1O%f{A`tE12oNwTY`t21N)u<8#U0BzzhUa z|E;!@6lMGz_DiPA$;16C7IV$=l8)V=RW})sVNeuSTcY3_!t4%tB46X8?BA)QX+EREB|8=cJYYmex`eQh)>$!0*bsTTxr}F0Nn& z;U`U3JezLK`Isu#asd;*8u`(tdr~^3Gd4GzlMh*c{_B)N-Z}&(<;p|-ldMz+L_i=P z1=dGmSxI6Bc+M;Z&+cmoBT!BZtgSAqu!cJc*8ZnYp0oao0lXk4Kp+BxE%+b%L(4M;MK`x2 z3$)S-*BB5CnS$W@+gHsc=5(SL(UdfJTC1mG3Yyp=_coO+V#c|N-eow=j+&(VCiu~p zHrA1-24o`I0dIOPcXg&w(rX{--8;jp$*R&qT8lZfmjmAVzUK+K z%XD)`%=iFMN;kCL+uZPd&^TZ{0?JQRjlXT^BEAXrMq`HTG9zS6dktUbv>)sZqu6En zyS|P*#m7qvIAK%gA?WA!ANdLHtNa=UMLUyV<{`mdA(f=U*V;v8K;m@3AZeD>17-6n zxiZ%hO^@dbFXaV?0Ja$Nz{a^A()PTU+Q-bB480<58XU?u-eQg(x6C149sPyq-&0D> zn3Aukx;&+-4K@1$EZ4VmqK)n7QsD;hDGO=FK!2#*Dedxm$k68JK{Je;1s6J=!cU7XDdI{*?6<8H&P| z0fhELRqo^*l)M#^h`cYtzKz2>_mDO~JJV`Fz)Fu2raEi}BaVE;d*PSd5O33oP{bUw z{J(4E+Goem88O2i+r0kcxb-#d^lF+E#*xBE359h|qJTivQiFYCrLrF2E3~}X6P-5- zvLrny-c{Msy_j&=z`$e@b7a<-`%iJzZrzM2nMXGXJ6Pe{0KFs9EB)*V1RyB!#x~Qr zt!p-3ei}Vo^RS*hO@4i*YnJl9PbHWM^rmN8WA2-omiiBZCvW^-@{*zCH^n;#iUII+ zZ=#*zQ)Cngz?ZI^8$b_uH|8>=v{ZS0w+aO0C6Bt19*urGkeAZ3`XKIWjSC>eY`WDQ zQ7{8=uPP!K7|$aSGJB)o8$h0Om($j~hw&S5;l_h-#i6d&F1f#`H-H8}4oFT}tWo6N zJ2r$(ogoYU;cm&#BrUVqu`D9BrXS8(p($(=RVSatAKk` ztjvqoacU)3Kk8#J0PGEK2jj1%`rP!YkUz#Jfci>g!&Lr#bP3mwB|*=X15mzsK+NNW zYSK)z8N8TxiJa;wF|18{ao&iw5a*vmua@*Z5aY=#1STC{S)IdEM882Yhk3q8J>2JP zZ7$@@@X+|KL0ikr8!N6c3S>0SLmR$Xsi%tFGXaK1IK%^d?%-Z-ywN~`0KeFNvQ#95 zzr6lhl4(KMm`@52rhqDBp+;NuLW6!cL>SS{yVQAaJSEhle_uG6+G^Hd=IT6 zU{Y6Mp}zLl`3;YvwqAlH^>ZN);QYTi!ydq8gWb1P7}mSTc<6?m#Jl}@T8xvfa#SGV zm!sn&gX0EBx(+L1u)rW7w|K7g-(oG~K$z}am#sfxx1Frj3r2ENrrhR2%M5ZWb8=Nd z^K3pcUq!)^#oe)oN^dF%6>lw&ocWKhap-Gy{9wxM&oJ{qQFbBL*6T0HU@O;VUwfn! z4<;J6=k}w?oN52e$-ce=fuV^aGrsj5v}(Lv8b#~=J|o55c)JRrs`jB0lZFH>j3?AL`HVaF{8rDL$& z^{CUgsB}9d520GT@swo}J7@Q7i7uI3aNG2iNM#@K8td?|vePPWew0z98Pv|@5|CY} z$k1M_ns#>%AfgtN9x71Asf5_J)F4e6^Vh1qe2*I6AnU=H3!5hKmi`nSli@vp@@RjZ zytpx&>}%RchJhcSfMRDu8{HNCS*tBZ&ZVl=klf}!6l$id_AF>fg4qHLCV{tfuolOB zlbF_Ih5)KXCgdLJQSxG0hrq<{aK|6!jLZkq_AEl(QW}Cv1&&3s0Bm3K)b0UNB8sPx zgacEC(#8a*#|TxA(mJ8HJ>vtd_V=^&{OO7(-`pw$IGEuLcsutUIqT4nrSZvrOexmB zI-f%(TeNykdWZEl6E$fxdzR-(0LSf9r}I1o^v{ok-|-dSPiG8kl7}h{+T#S_4t&+@ zMZ*z_lyB7Z|z*VCv+3A}L0Wl3O~z7O76lQume z1dfWUBTbKM)I1eRYcI`(s%3XO{^HoJi_de3{?m@P#z0|B6OG6*9}=^&3h`SJy6M9(Njgb7ks6|jhM_(UR6Arr6zSp z$>xcHI0H}KtOYjS2iT2C&nEHu_70?)T0j{Samb=!kU%@R#&^KiSOS}h7AADQX*UkN zfu*C}+y@ixyOsqbv_UKTf<(>^hgmL+!K&q0GDjQ^@iC0iUKEDmQUL`kUI|4E@7%?_ z=+qVvJbA1r7s9IhHFe8M;eAng=Yi+yvdg@Q7*uB!k&ftLj>+q6Bhhu*Wj1HvHwqqQk!ZXBeeuF%O&@`mW{KC zjEgTw<9qrE7dI06RETym*$KkoG|NF3f%RIW&eDM{bVB>YAszL%@FFd zIC5Z{fB`dBCGd|<6mQ^rfw1WHDr_x@LHJWE-(bTvl#&#yasMQCpGxC~BMka!E0ybP zpS1kPdTF^*a;RP$QJ#ZMxEph|_U7gy$4wZb!lVJBIHue|O+{j&i(Aq}kKp9hCL*BF zMHax9^;&-*0Fz0KeriH!(<<$ZCg$?{!h6$J|% za?xZ_S!w+6Yx<+0aJ6E3gH!G)80@m&0)cOi9+=g1Jbd|0>T%6+i3!St-M3UW3K^^eBQs;F-YK#5t4x?3Z!-t(5GDhp46B(hA}-~xm{O*#HFj;%P;`1zHDEgjw3Qtd1X`B0AwTx z@8x&uCi$o&Q|IrJX@Mnv7~zXeyg||9p>e>r{rc!uc~98I@K+p0`akyo>Hp5!W_>kG zufI3e#DumbfS1+ zd9oRqfNheIj&WFSSU73h?}Sp06Ii(4%Fkb}SFW9XTZf4FKhU)7xjg2%7@pbFSTjf{ z2kyqze&qLHB|iN308uC6cT=@DE}x8*avBdMJaByGNkyOwgFRGTcX4Y;70=nZ)EH5( zo1?p1!^)HyOgpCXw7eKZ;o&UGwCQn|c_~!L-eiBxk)>GBE(l-iaPqWAz4CuD{0+Fo zkt!t7azU;gSJA=HFpFbk_$6k0QEu3kRAkbmq-1%Db?nvJUv=TijhjZZb$Z@BcDm! zi&|Qpiq<9mx=|&dlYY7~SWRSbGDLZ!?sK;2fA$$i26hK>q65lppDmmLc(Jz94@+MO zeJDF*cdov^#NjMzg;-t3FiBAwZ-46qe5T*qn_T87enKsY7g$Om2FiZ)=@P(uavrDd zd(4^&eRNC1903o16P3%|I;`qauG2Y&g5)n~)}dBWYO3T}cu8wu=7Vnu?4J=RZ~z4n zp(re*2h0_lwEJQ2RM+>sJPd#!^V3_V zncOYJLi&Bn7mC~m2v`=fu)@aNb~`B#y0&Un3okh}_fNbuW=prf9p+p! zKE9vSX%rFl+1ht-#vKy%bx1*1!5veU6+$BzR-Vms9H@a_ANIc7kAGM{3iZk|J*tSR&4a6f zaeGJz_%Rt~J+#tlooGpI5WfQH?Uw9llG@VvJyT)AIaYr6fb+J75(2(0{A*-(Kz07! z?i8P!9JNTQisj)3>O5IyV*_>Sd0=N_3K=)#xI4*&fkl0a>FG@kGG#QI_yDd0A2$Dm z_3QE1X*8nc7v7s%_c(c%CAuLDT%Jv{;Q79Nvw9oIOH7$6SG_@-Q(ORngI~@?mKa7+ z1^wn0q!c5jP(clS@N#inht3f(;mJr{!FRf_CFYhaNim<9BtiVsdw^8ea-iBQ9a?w4 z+ICM1(>@-dJD4yb*PTq_i-J7sp(2_gGSPO)eGRCAss-)2J4XjI+9@fKKq`(BfOVY^ zT8T=ec@i4%3}Tdw()C{Ut-av`Td`W!9q{yS+P&8N9>Q^tc2>Xykf@t6$CJb&*2HjN z&^6rZ$$N(mVGk_7u(Iny_ry3F_k%KjI+>!) zZI4S8+Z#;{qsqz|a8uKxaky^*o5!R?N5)OVG`fKClZ!B#6OvrCedQwq2?~y4GHEkW zW)`M7yQO9GcncmA2OkhXeg1r}*T(0UGgYEQY8B_K$Op&4KXWU(g-Nc7b+bV4!sio) zAz9dP&9y0g8(OE*CKu5VaUXy_*Sv+j)jJ4Ji(dty6lsCAc^0=reas1rUhwiv>nB`( zkneJGLx$mjv?J;i7#%w-QfNu*a55Gmd#^t}o3vVHSggyq1N;Oxoc*X_`SSFVxad*s zkmCtx!HCV^pM(}xhN1w8En_O(Z`pDY8IyGTE<j(eSS`NB*=yxd= zr?YiBZ+%?15aJaQUZEdUbDHtf07Q3?dakx00S!7BIxZ46Ii|YaVLwC%5lR>CRjDDO z44afj|fH5 z;q*Z0F}R8Y)w3Pu0R}qz?5abt?v=ED=R6*R=Rxb3J>m7Y3@fUFBFmirUWIMT>xda-&J(2vF<^=|Xcs2SUo#2Kc6Bk3*0PD9f@QT`nYuj^ z-q{s3D~6jldG1I$9TfSDoCi)#Zt(t$Q;LLDkpp%Uh2q;yV{F04|6)`UT$Xu}-C(xe z#;dfo9e35BG7!s7HT}QyrN^Wuvrkeo0MOi1BU=R#1a7@l+^wIs+}u66h&K2bpqdy* zJI6x4iR){bZB7HFoC%X$K&z&eVOu{rxequpnE_qI_6sE1I%E?*2IbS&8HlvQ<-uv~< z*gaP$Qhtoqko1n>FpR8`Yru7smnI!LCbd?Q-OAMIPA`n_Wj4$~#E42>rzg-vC_|8# z+;WUlLb@)~G|$v+{hm73iR83cMPf8%RshA=o?KSM0~n-|f(uc`u|`plG;^~#sJAa&=#ZN6RQZT#_t2cA9>mr9JUZ= z6vR(fS{!MkX`)|~$uS~v$zk*lIQGB88_%5$W>yKaiH2kvizM~OeS0(cF--R9JWoXT zr?vp4-8Yauw0>*A1Kare!Fm||OF6Qy$^Z0Q1-cNY3KuJ%+%cWEo3!=hfZ(l#I+Wm~Y{Tq;Bui-v!{xlT?Pg4h4QM;>3Ms#5-;%Ll}JO>>9GQF;j zs0!#aq0y6%lbOc@TGzKw-#qiBB{~8nl#ycy8wK{4>=C2f-jh!GFz9V`4PEgjUN7rr z2PmKbq>sMB1=$cQ&bV<8=qT9wJWI(hgj*ze@EvgJLUjkMu|DJD?JOh?kaQlaUn2B9 z6%6I-vMG+@77sCx_1$7QL3t$o=yeWqXr=V=AY#frWTRjUc|KTkPzuuV4L&J*Aqx=`w?`@pc ztqtjGNk@Gf`QxBuDaf0Msf|+*m*hOrV7STQ$Gomi`|5)9`e7M$Gi;Yk`Fw8_dT&-} zf4qja0TvCXTz8k>SaqX1c*3)oSDrHYkX7G;*cwlqkq^9 z2BU9Es}$>YX4;=wh4Hk`?K1ZF4XEKY6zsL~m485P46gVmBIk&iIrptE()1-$7+RW7 zYDmR*Di{O68|=xflAF;7|LiuKQV)ou#RF1)dQA4CVIlp$rASX{DH2Xq?#{~0rU|vv z#>$a9Y>fyU7PhA<1q+CyM((49=E>8FrV%ZBV$Tnd8Uv@zLOp3@*-6Yy2A8y`0(3i} zs~S<03m+7^%blcWwW64E^IWQ+Z-0LO=N;s7#^-ohiuQ zmfz2)`SAD_?ddVuH-a^>;c#w*!*u3hAFWHs$@C+psVSD##fx_gdRK0ZA3_pHaw^-7 z5+!6Yd{gO5&V13(YJXn~^Uh6Iyn?(A8^r?WDTHQ1wf&o;yPEnCXCc(U{s)Ky_+W&v zLub;KnN_{t7vvMa@Lx?fY>1;uliGgJJn3koa!~}rW-3S{NXy4ZatCH4LxHUq$+5z#Qb!_Ao*o9<5yb38L9DB=kS{}65bDpUy=YDSm_x} zKvwrpZC^{84B(=3W_Z0Dxzr8sG(YIb4;+2=5hTL7sTx$6v$0zfmx4PvE#;1AqiJir zHn{d@0d_Kh5(ZMy4RMZFMjl!`aQfE14-obumXz#xFr+uZmCCG=(4V<_%!Oxgl+o_| z{^9{rHn;75i$fy-g>(X8m!Fm|ha-tn7N1ch5f>jvkV~gI4?{+Yw>75(pQlvdq*ls- z0Jpl%r+oxz(_Eb9E>FJeMrL|C$4-X;=I}z6ft^T1kM*+>gi15x>H;qrB%KwIhzfS4 zK02$#igx9V;Ji?W%ZHyMiNo8&+6?-!oi3@1Lqa=8gT9VYIaPyzPv<6Ur&W({>v$>c zEb$J#e?Va+?&>>%_A?w%O-6REm>bW!(StDz*Og<#OX;A!ruT9>avuCH8VEvh38yLK zKx`0v6cHlo8~vC9b>m%Jah#@Cnyd!xa20<}3FeZ{Cg`L`Xu{&!Q5r8xNWe?6v#5e* z7W+@ZC-4CF!BXmh$5KCkdqYyproLE(uqu@zavnz&*oE{tn3`6o&N33tg=s`5pWGP^ zS6&>~?Zc3}T|Tw<130`5wEN4MV;nhCe{z%`v0gBtehA!PvGU?&CQ|)hlg=C_@`hUH zBIfniL|<7DMlP)wwG^dhL=`rQZ?->;mtCGrS4>672Hi(|Xy-BEUcOxENn{$|F%9Sd zQbjv$UD-R7J(PDww>lxoc!>>hZz2lPy0f)MU|x#2lTm{w=`0V4U5;apUyMA+ITNhu z72qJW$51OlZC>7aoe~M?#v}{Nub5EMkn0i4ynuH?xV7XYhllUQY9NJiK0vO%Rr@a~ zr-+<|ka<|lhlVy?Au;EmZu&6UP0oi?rF2}HkR|GLxo?~kCf!-UzH zug@R7Dz|kK1zya~aSHtWY#N$H54g5Z{6PHgRW7pL@@=DjYL$7h$rXYhX>q}6XaI`k ztAn>KTOVkWggEuLYYFxWV7uAR^}b&h&)n@c2szx(unZ9>%I>l3chL(jW2srQ#u`64&U(XKgJ;LN!Db7jg^#b{%P#bw zlNGZpdNqnqYfBj?aAQny_1REq5xCv$7sfJ>9BZ-fDRE*eL@!Ee5Ut{U=K=(Xd854f zBaK9lE_L1fKoeJAJJed>7GMCHzdTVn{C7hj7jIFSE)5-w#TY>0B_uPv%Kf&JwliZn z=5V#az+MdjT6b@irgT)Ex?ZgeMOPj9Usbh$j@6tP4%*08%ewV%=_m~je5;SHpbAIq zP*X`K-d^;q7XxZl3v?O!p_pW5@cGvi5|ewpn`pxF+*9(rD*13evJZoTE6?L98 zdIJ;z(t~I?I@KAQm@<@e%oy%vhM48w>aIvpAvX}1-LWfY zoZwzcvXg^aqP%xl6wk;(AWG*!w?rn@zI>y0+RlOH5(-dfY%S%^U-tJ>@@~#0LxXN;KK^ zLjeW5kH~=eCRt5O>7cOV#-vFZNhl(%(|aiQ>1B70lC1c zBLNI_KHh}Re82OW7=3!;n>uzzv))YDpf#y+JdUGugba9X87UXhos(Pk;K=7Qkf<}> zL24qW=iv( zSW0E=@1N`HK3a@&^07D9@jVj!&Zaohv7U_OY_1wo_xruM_A-bS1u&FA&%|usH$TO9 zzGHetfqNX;z$$!W;5h@~ohiM7+1rEGCVBm+tR7*#0Sfrg7+Ix14TNz3l23xhZ=W+T z`pNnXu@C`y1O;6K*wY0m`N^-m>sink)11$y<%4QF*i(kAIu{XoVBij}e}CH9s|Jo%_YYBbmm|c1By z;7aRHO+Q5vv4e_t#Sybub%V$!%|B!w1o+0s=P?Y0owuXbbe>-BuLNIcmKlfdU@l5k zYrHIWk?9hQbL{zf-D!?s+p@6i`Rgu<&(pj1t>%^Uvz3m4K3;6wVEN>oIlkB)di%cU1 ztbxoUj0Ft|Y=_|UegIsAN@1&B; zDGcHymToka+5n-l>=kL1)s=n|(1X+>~QD@MIfzon-Te!&yXj|L&6!q}PxMt}ejoUdQeZmjdN{-K2#%A2yO1- zGnoJcv~M0|m|P-au)-PXmhDf6e;}lTHqP>dhtw5nkI|k%X{IOKg^vhV(wA5GW#<>w zjv(#QjSx=RbhUd7nD-`?Us7`sBIGp0Xt{rm04+3zP4)hWstR5$trs9VzBpxXPnt$K zcfnRJikbKaC3NnXbUFOd8O=Y4RzS=L6IoOqgo}31zQq4gQ0B=>1PdBLwWCV+IxeE> zBAq^!z2KBw(6w?{iX!a=!s@S`NO-Op&UHSSH_2)#@JQOjP>E12V7_9`|J{_Dl75u*U3Zam;M)}dF3aw}$6vU{zYi>dkd;qZ*;X2Qp z09-R`JwOprjKMCiolOfGIp>2T9A%e;8>EE$s+VDI=)0bcE0XdItO7uhr!!FWF=hK|V+Sw6x3=ENBidrgKtY5(6 z948_f=mU59zjpBxiRt=icgD^BmAzX)}w-WAbN`d%y#@>fXCIpUIOW?c8^NzqJE4B z;1RIfnnT!yxz4xJiH#g%hh*{DO89kMehcR3%+vX_B$0gys)I>A{i&f3M``{H%6H_d zR-Xl3%R?K1X+=C+zQvK)aJHG~DR}YHKeoLvWOlORVmQL}`R2cXVW+-I9!JoMmrVW# z@bNU&L+n9}zEqpxY$z*=T#DZQIe2J$H8B*9u((e=Y3!mehW?NBCJ4Anf$xh#DeI}X zS6*$Fn7Sd8l#?deDcNy_jW?&&ffGb)%_PVwAi5{c(7jyUx~OFK^|==Ir@c_y^t8B$ zrqgHL;(vs1B#L5XnJa!dFXn<$ZqGj{w`O|}0dR!!m8AJi&%dhEl`E2d=kAuKo7)V5 ztn9I!QX*iL1V!E0f-bVY+AJ5;ccvYoaIgK5^!2~RL;RZF68u_|ZnF!5^h%ZGKCg{6 zAqwj_?qx@Ct$<`_4M9+V8u_sxZwfqlyiBRMcdC3?do|SB`R?iRBYq>dbZc@|J&)TU zmHr%0#>D35N8BLCY5BQC&a$FZGH7X0oP1^csr9LP9N8TFWRtuMGmVYPL8uBkD03&* z#Jcq~55G)F@uwZZ>yC#vZMQSH0rBWpF!?ABQQhs!;?omORcg3l31c|1woc@gY+j>U znzdP7}B4}pVFd9xEeN{EYZXiI9kSbkuaz67r~SWv7DwkBT|^t zWt_+qnt&VmwOYYzCPxA>on&Cppx1PWu5}47gPYt~=*`=n)3qb&JM-nZX@Mg9@(KNl z@5vXK}qXyvh7r>-bL~n~ngE$Hl4MyL;LdV9p_;;E|T$RY+6n-&hb^60j+KC;J_2Z z6KMwf=*-A;1Sn4g5s}z5VhrOH7<)+f@O+rl_fD16rH^;A}>ePt+T6i9%>kbAIuc&_hGless^W||*?JFtxjGahRI9U0q+fV1FeK7?66 z_b2SUh)KQfj3~|TL578`SWE=xGhTe2nz_;01{$68dS0Sy(tm(M!Wo7M)wm1YenjQC%;;b^01aeub#q_p zm4=q~AaVdFWJTA3;6It_vpqnwPjp!EG%M#`PUU*0fz8&LKXUs(n_`ksasE0AP- zvS`7KlezGX8n_q&VzO`7>CkOFAfYlE4U&BOT4Y%mSvMygFJu_BUEAHUs$1f^)Q&fG z+yzq|pqMB&HD5=u?v*j|5Yz01+j3A%?ylF&&s zFT{sMTRENgt7lgan#HrR4#E0?02ay$A9KAP8=*2(o4Y47K*L($0pd5ePj;mqHs;6u zX*tZ(a8hBjjQuZ1`eO^Z@@VJQsPny0_h2&EJn{YPh~w-$9S-h6YTa|MR;0$>KhOKD z<~@Cb1Z&O?A&L?;#T-y#TCTg$RAB&=#yzj5qs2RX%M$%FWD-O@o1Eaz==6ZBCzVyi7>FvAMYPsMiPtx?}l=vVF7i^3MsU?13iWRg^e`HDo z!-#s(2&m)*$kHn2Ta$UK#;Ps59WfGK0qtut?t5}bS1}R>+Klj%1IXoc_!QKRxk9|I zbJjjBy$dgQ2su%4xi}RL3u3!5`)%=4!{4~8L+w>FH)aNHQrw{odu$zL&xtW-IkfcCPA|w7 zE5k#RSo^Jex%M}uF~#o6Qvk|LKxI{ps0>91wcjVBW)TBHSAxpEE}U`c&~H24oqtv& z2WPtS>z?F=Ra1nY)#%$-ea$9O07yArg}L>f*m=b)1e~!v zN_C!YMt&pS6bS|t?(U{v-!o)%7_pDZs~N4r(2r2X-zugO3pl+%R@)S181ov8Bp0ZnO>g6Okp2>bnwQQriZaKOvD~ zCVMw!9c3VXEuATX+0LL4BaS?;GKGidXPy(_qezkQX@vvI6wAfV!0gitNvU5K^tQg7;QQx`@Vgc3a|3XA?K za|_sv+`ZaM##BhVHjEIcae~*xsnuUb3W?qZCpVy3Fkk_AxA_NX$1)5<bgofNAi`@Q|-t}>mig2J;rkqgB1eG^dCY0S#Z zbK*104@88qXjCiL%wUoZhCr&Z(Fq&Dbe6CaF381Ip+^%h9GU*o_wyYM87 z;zYSQ5)(wCheICczG6ZTlt?(BxefcqDGo}soD^_#cn5apggh@q*k07FW!^e!4FJ#|3r%CKAejNX^^{=f0)z8_!RsC>&ovTT|S+rt+~^}V-{ z#%8t%&ij>JeX+!?y))5D@~NB#E8Wz-uca#!u?YN{IN-b=9uAvUVmY}l%p19F-~Gee z<|b6AWBFlM)gxMT$|XJbpZ-r+rzt^~vH)rky>5BOG$twZ8}i2jntEh& zGkIS;Cv1pUN)D*uDtVuW;Ds8ll4g3{%0d1>T2R4!H`a z0Bs&%yciUGLUbk;0v~B-RqhY9G2Gsit%6pdIoIxe2iu_B6RR4_ATzx~1}`+F3nfmy#-w(7LcOn=%^ z4NJ2*QtFq)fb@}OXb*k7d&RKjop9aS#S&#c4cc-U&?R)rc^e$VlQUi znBZmP0sw!B|{IVYWj&+2bWjPI#>yNaUA*-o&taMJj$|c%m^a5nS*cipS6PpVQ$!C(JqRA>e=;n zh-E-ldZ$-BL3)#t%Pb%kMLRQ8T1KC9+`^%noj}QT`}H&g!EmhEcuC>?9Y05D(I0Mj?(uVk zxfl|oR8RAweqU!IAnQ20x^t&%e>$D(qR{vET^XlscbwU~Ye$uqLLf3^SA@i~ zKUn0-ea||TWwNs*j&dGtnIl`y8y#A2Hq^+vuokxC)-gioSM=u~Ndq1&Sb(#5w~uj_ zsH4V_&O{+>GP9(X?8|W{iUO2R^@79(_ znAj*Rbeiykv2(9cLA(eq8!w>YuxAeRob?bl>8Op1jj_f2!&m3$SPNn4-7+=|MQE;N zVt_z&7Iol#?Vs-w2Ld4|K5(ruV-1K`vsoNnV>QMeTr5$)6oJ)4i7t<0Ao^}S$ZIPG zBCKMHv;2^up`()EKg~5`=gq-yq<0yg>N`}zGdDtym7$aS4uTyJDK7OuNOVvd~-t7_>x#--% zPfKp6fjJkUe8*Tp<1nkLd-J(_N7rTTa714ZJEHbEJlpBW_9|W~ekXR)*&lP(SyI{G zr(x1JYv8uwfcbmmwIb2Z0@e))xp=r&#sB~JEv-hgqtUTZHj!Z{mkD_)=a?mQMIr(L zJOlzzPv8z~PWW03N~1ml&Gb-RvK+@^mJU1lK?qy!TWgO){|Yqb!%)AsG-bn0ue-LK z8Gs(DPXwaaB3y7k%qKnC=LeTg*5Eb;v~7YOpoe8#Z|ZH@oX+d66t8m5@N;u3crVYe zep|5E5ulAI+q8@!qooRvo-Wp}e&a3+_fj~wJC?RA{8VB~D+D}9Yl91D z05sO*Dq{CjUMqlACIkjcy0CXao755G0;$k(OfmX z@2g`0KP|x0c#j2S?+;$Q^3?S6e+;xx@Vd1i_rt==QjHbi_7e+^o9U{0fh)FX%pZam zj}6!u)$o5UQw;{O^lAinj8R62lm?}p->0ljy}M4pR>Ix)*<-$1a}7vQ!&gUA`E8Y8 zw?2!kwt7gumv5%xx4j~^3slX41w!QtUEIhBN=Qo9nlXGl%n0sm&3E9oGpYEz{Wt!l3b{` zmh)8=KkoNt%dLwPR!qj| z-xa8}N=Eo&daf|l()IwpMTt9dsJ@3XS5<$c4-^!{Jhdgz`B$|DuXCbdAe$EL@AO8y z$ByYP5J_JsHoa~mtQid4Thnqz8>sL8tkbEz4tUG1>OTY7tQHa^x&qP^M#{WTKQ**K zvaps`_GXLXtNU*!<6*4Qm8@t$LMPxzZU3aQD#PaP8{;!*-$2u_Lcg}+q)ZA~L(Kmc zB{y|oKbFNa;{wAbDk?VAfpc;O9aANj@J9c#s1%@og()!baq;A#tKijqlgs~r=Q$WU zaC1#}3O_pqmb0C0Q7;25BC54c+D6mdtvSTO%+?EBWXh*wc>%m*_J!WL#eL>bstlf^ zdW$jA`E~hi-mn(}GlINsOq-MpQ^8eA>Sd61b$Fmbd?g_*?Zc);!_q{7+f?9WO6v(A z56Q*6vZTS45t&CnijTd?NmVh4&H0v^saAztKiOAEIfO?}od=>$(CPQcI=R6S+eLMm*1$Bj3B zNO&*c_lUad??;s4f)ZGOMp9O;U)6xgZkWT~2RVy~a{ZBa+xGv@SDWyb)a&8EF|Q2J zLT(tHnacFPQU2q(Z(jhney+HtjA4X_CSob}{ULLWS`2GA+ciCl6{k#~DFZ{b%uX2> z(rR9fp`^N}|dcebCkVSCLEwONp~AWcitzjpeQj?VVh^a!` zgjsJ%G$-1*x2v>EoGJhGn!B1~)ooA-y+3$_d60$#`!p-Jgt`?{*LNhOaFfq3;)1g7 zVnC({pXMNoJ5}=kOX#Pl>?HSET`O**A66&ywo(E}7l zJJTNAFE66}U#*C?=^hA2-s)2JGs9G$`BE3giC+C#~PvUrp7UVgXd zXvs4(Ff1|caN1asM$jQAlwYi#e~r=DlbrhXO9>mT3aIGVFQIsHf(O?*+INk)I+Po{ z7t38^24ZfN#5RFWj6mVje|oEDJTxz3j!U9hZ`1#41%g#U_vmN&Pni%fThOx2Ma^n= zPovrli&vBJ-cDa#EcLHoT{JTSM|mHfVre}3E#he{KqP`?{o#VFkGN{S+&4JV#n`^a zHhQX-uVqUZ1x!CSjw&zR;eK6zt+IIV zte-X`f@0~xX_StSZr)gaPH96>pTn2%tWgh43XElgucYyWbkJE(M#@{)~ZB-(J zD1SkgfGMmW>_HoqaOBC1OCuQqV`R&solA-RK7u|X2 z{4%{M1YwJQqfnw7ak+sze4R{@ZvyACBm1snaW*iHlw+_xIyHHjy1WMz@SZf8tzT}} z^8tdGc!LW_8~M&?xfZb49ZdZeZ(EVv(AwDwN!}>foB#3v-NGPFpIYN7Nuv4@E3p-? z)Tp;(7^zl|j~8*}U|!f28dp7jalS>ewy~_730o!HYEORq%mZ~}EMw*_R+7+}k%NsT zF3Kt;FSQq{78!w`n1n+xyJ^TuG^_72e+WAX^zt*C>?65`u5(U`3m9O}#N@R(DYWdC zz0AML-LJ z?6iACN;DAc|4~x~UM+<%c52O|5wV9!9cMA~e~!LWf8qeDk98fN34R6{QJOisYt>Nx z%|MPQNhyE2?UHJ}$42vXD2f?WDWLlHg3%SZS&c-O4eNg>cT%nq(kAnfa8-I8p#~j7 zxH&(exN*G}fuG*HT-7+Io%BtvCgU$kLd$Hzw(udmgtcKx};=FCL(*6_U?%MmXlpDVS< z(r$qIiJ{UWV;?cvRQl2bAiw4N1MG;Dm2<*K!d?($iP93YJt$?)#LB3`^Wr``>dSA} zrx%i{zkPRfwyuf=CEcIlrc9(E0tdckeo^)rO|!ZH#pKu(*s*A*W~zO@4nZ-xfs2plxK+ZseVXFbY!eQ) zB2t7e>TMN?1o>Lb0Us8jxHTDx?5R}8$!&%s60KFN?ZX7@k#cs092hK^L?Fzz}IB&F+vorpUl# zbb#8FzOKbW4-)BW-l;X^DQ=S$-!;4vuJ!QPP$3vnE5eSt_Panc>-A7-_=if2Mx|?) z0eLw*Aj%D$+ZO~MaS+|xazWDtcp%Cw4WfKeS;pGK(JMj+n8;F8E}SAzSpC~Ck!+NS zqkPN9c)Dg2=O@z3*<@843_A$vYMnKwqpUmB@V9@Pr)H^~r^48L?RjdJ50Q;f!)?^$ z_*YLgphigTTr)Xkmv}U8fny$=bO!pQ%aKraB{;P6E>%qa4IKDq2$~J-oZeMO`-pt& z6ntt@EAEE5pH7yK@oB^qLLt&Vht3j~>hX4ND`#Umpyezsb4L9=CB}zQVu8iH4<`<2 zG**We((`k{8iNiix129Qkb^^~_T8r`=H?S<|G?k*Wpx|P`kTUG`O>WMIzm0#&vKUy*{_{W%~yb0^O`tcSA6mGA7$w7kmZ|7jPvOju}AnB`ot?-sj4Dd5W??79UY^Tx0U!*BS&P-i0?(`4qd;fv=|_h z?&TK@tWL7YmihnBHpZIRgro3sQ`=|C>V z`b?{EC2i?C_`P7!#9xxQ@b5HKIpP{z!4$uFfW@06KwXG&e*XDv;L;&&;V{+;9Wu{x z)JcG0DvwJny7dc;xEY!4H+qq(v>BtU6(hTm?kd51p9ut(;Y2zX>Ubn`e6r2IF*fq2 zTNyN-xS}L!h)vhflKthrgIJ$!dV9P$bjaRV53^1vs|xG>ATnhv#NY~_dyIl}`|zZK zCe`62%hH(*T$RdyU7nk^v$Og>5UqGnbz=3!D?-UEzZpX1R6?^1z2@M6g0Wb%s$YL`HO8Yg=BpU3=Je;T&vwqA z6(FK`Cwbx>CR}P$4XORX(L!m)HyVBO$?SJz*o)Lrj<`a>*^ONuB%;@k<@7BxR27XVh{dr_C4cII~)^*3VJt%wr+Op>AlX`^EOG;4kFphP^WyG;51C zJG9BxP({&7VTZ+h8=;a)!c{`GHBDmR5H`|zyQ8(7bKdG^G(k*V#*f9gC;&b$#Pt=& z;NVKiM_B>wy+k4u%4A-CqgH8SjlLU{Mbp)`>%3a<-kngvozFtfH4$BD>Wn77(|}w* zsQxSum^8%rzU_bYNscHl6DYH_J_&QhVnUAamRZFYBvU3FC75=m1B|Vx)oeMCRK`;h z6`eG72jC;|3Sx}K)kMocF+hF6c(c~MrSCJ(!{2IZ5e>VLX3YGeV`0iAb8b9&>4~-; z+px54U9B&}8nN7tSxK_k8_+y=q`lU$(j!cTJqe=LJjd2bL`-A6;qCP|m_jz{T2z5h5UZy4#<#vA3oZ6EY!fM1~1M06NS;E_~}J<&V|FKp=qkjc=CwYDgw z!|8uZF7;{uwT=%nk?{YJ(w{Y4Z}m^x763DUW-{-+dM#uCa1*w|Q@rG>b_`}|qr5^B ztb3~$$hv4VfrXkzT^H5E_$|JjfWQj93l;9I+)54P#LmT;nS88yptsBvqoc(!->(U2r-KmIe@;FYwcXz6D`R(4ZNHJ6f}Q*Cl_9T^ zGjrzWo9Xr*E7y2K`LbEkQnaJrW$HIgMk7j&Y%nmTWHjxuZ9MRp({f&u{Gy_2U+2MS z88#pA1^g2zwJ-bz`*jXPD$W|CQnxPJMWO{6Xay^8pXzxfW$6hR`rWlpv!ZP4l{kc= zjmDZ7s7DpP7rw{PE6i7@z&0rjIg#b`%4ax^b_HyDU_?V4gdfLiQxVQLZH9tFfl1rZ ze)*s+Y@^-4v3(8a;-MN=9ihoMn-&eM(u2sNNQRg6OJS z&@p{#zt1ME9iF?>=P!T6f)LWcWU|%pOdRZ83P87U^$sfJbIfz&?`-o3qL%B>K>P+ zjuuNk)VJU|tI#M^eRn|1^G|7^k=5l7*rBqPQ%=ifOsYgA^QqY$wj-R~POTmQr<4Lq zgbQU{f~-oLfp&}7l`{Nj-e)0AV#C9oFJarfTvM7qUvHVOZ;OqdypWMWd$!GY9p3FF z=sV_V!?0$==avfPT;k&8L`UE#>c*Bi_Y2k{6&s6PRj@()3Qtf&?OPdKYc8#9m*-eLg)Vq+$a4t8poATlG1Nj=KDal#BcZ z*>#`w2((}SZc_0*yY!pY6!UNgkPyDjzp?66DnEVRZZ|WxDO>1oKmF037ryRtClOt+ zroYyZS343}^;-3hx=YZ-WP(N2A~ULpLJ%!YgFVIOYUT;aZedG^FDd^%qv@yddE(LY#`na5l-cg(Jqkw5q_E)m!CIs8T%i_%Wg$I&?fvajG!>swmS3B zAue2nIFTX@PdJ&oOuPUVg?=9|1P=D)N8M<~-~*376E{J!$J@DE7mIQ}UAiSSGL+K? zh%%zPHF6_$SslP%q7y?OcMK4eE%&=iW+E{iz-9qfXCVQOVLc4sKhvHbaZf$F5gb** zt%~`OwLb7&Cgp|8i!p75&p@+Zlf)H0Yw5tPZF*^5@5li6mg>|Q{S3t$`D6upSRT!C z+0-h&e*H?>tuJd5=O<;8s<}R7Qo(&>V5T4Milt)l#g#yM)nz7MlR*k+^}=-OYOxx~ zXR`~zzfE}f@{j^^?RI?Om6*qUvjK&o@QZw6igo%uVQ7zR!cspsPlM1tmbY(C8+k(w zYJVYqc}B5%65!7>X=yv{e7Jmc5`YyVngk$fEG6yRn%a-{?v-rL0^F66A(q+%CK~F; zm=MRnTpue7xr{Q#pr1m!3Rsg;))Lv~Au=4oA=2#3^_`ONLU`5mZ7&>So2cHy^$ATe zYd?PU!ZNibH|g)l0h1rbJD8Qbrrvf1V_vYTy;nSp*_SW{if0%)U9GdyNpy9>W@DEX zLYKXWFdKx#CogA~->}|$VE2;)J)-n}R+Y1`7Yt#2t#V>(72oiD2pOHKhlWt5h2_L8 zQOl(~(BSF=w;jB6Zi8ncSE6k-6*fj4j`Cum(*VHdv?6Mn2~pl1t5_r6>;lNGX5l(( z>1ioPxG0shJ8R}b=qQV{8_A-%5^uYrHkVq2bP?XGI)KnHp7QzyEn=Q8ERHw>S~3#m z!|5X8N`qKMWu6CbJcB{p=uE_qi>)2FTFC-v=@YOzrv}HZ%~ZIcD0TAb>v}4nt$|t&%G=Mk+S87Q;t!2A(lI^FH~yU46Q@4nhRL zZp)?b@swuox8?RaE)Cl8W=dC_5j%Zi9C+u&0elN(Q-4(~X5n;zzVOEyCFUl*hP*pM z!NV?lHis2007-jvb?6!MyFC-1rDju@zcioV?lw&q+W3&Q>V)Y%Lf}MK?B?VKS^)M4 zt8MmY=+FhxGAHD3mhO?acgU(R2D9kyQ)pGPIYhInRIu%@R6uX6w#U@d-H#LucL5fKuT7jHU<>Iub1 zQgvoh0_b#pmoBY_<bU(1o|DO&L6}$td!E|09K-u%)?w9;8kpBmc%_irwl!D*| zGZg{(v}w!CZ_=FILHv#^UkoKI-TL2`?On_NoB?S zMIW!e9igLEAO?2NGFsR}OA@ifI5$z%NWjBv!Piw`F#qd~YXd7fo0HtvTixh{!g8D1 z=NML9I;az-FcPw0Ci>ETpNBvAvn{5k^mKc}2`6S7hgfa$8NRsPP~RsuJCB6~D-9!s zSyOE6s`M6t`~UU==ZH+o!##^IDw}$S*0V{4c~cml)y#$N{+)DI6!Q-VWGsqT+GKt{mKNJJw`AiRPx&F3I4o zLMN;YA2F-yuqaT{B2F}%jwiwSNoi{I@`iT*jl}T4hXugwE0e1JX#Xv=LPlyxL9j4uRIuvH)H*&Nix=hLXHs}4QqLuD~u zf7UNLGOT9hZ5|?u!<%}&iPUpd*;BsbeQ^XZmNGb&1BQ0U4n_ikoKs0iqv6?VIE^PQ zlm*2c7NT3bxkQZyq7)bsRwesoDbwnUej$8A%E1-8glLzgn9zwLGsZoGen82MQ@AFl zFz9C8{E{ub5wEBeTZ4X1lD)0ibA-@?;;FY0W%os3ngz65W8&Mh{|rF2>rYV_5`6jK zlp=E-5m=E-WC}>JZjIwH>CJNPAIvo~vjhzOcamnLT{s}i-li~0t=dr;B{*_4u3iCZ zbB$q1hpNr(8M)SX=P3`Jhz>n3Wx;ESDiTZL)hp8C|aX zn`^K%)4_e{7XIz%8S%n=#+5YytEwp{!R%SBmS#tnCusMDkP~gY5|PKdXe^R*rEcqb z6nyNzE&ML08bgUuSVg#^oy{aPnQ}idAIJ1Kis>DNXH=X}aubS^>qeOoE6zj34O56d zfI;q*I2`9>ifm(Y6&@aaAG|@)1&}0j-tt$$DpMSWPJ&WN?1{}y##dV&&y9x?fUQ|~ zo!yB6$>h80c^r84EErhc5_k$a!NA5Md(Mv{iXs!Jb?(XiuZ(hXTWQ#$?*;{E#X{y( z?P75sq*IU$+JXv%et!Rcl3jQS(HO35y9BA=5sNIMAH1Ws3+PE`TbODabc$D@FRi!k=Z7 z&-fB|HaJMJhB3*ONK2RJBSYF|>xE$dZiwvtLz7tTf{o=cSNdiCJtGW;bg+g2{TcXl zK9(lvM$IR1PxEr72M*FoA~Gm|V8f8_IA4f0PBjKyw9-qq-Z_==9{tP)o3!`O%;JFN z^RX4(KFE7=#=)cTD$hH{Cg>uasB-t%Jf<63f%HSK4nI;tuC=?#z-*18s_)h@sjKRdj)X%x6$FsyZQ=oyJK#VdF4k|%AD;-A?Qo7YB~S*qk(Y|Y*3|I2w4 zF6rf_L(Pn&4NGG;4Mv!MDS-EAU6X(!Ew+UXsPEJD+pPhXx#Y2R4n6Qk@8Lr(2n=t+ zvZU#uiAC;4@l0KDQa4%y^caH*_#hWVhy|G$g&W<^zAf@RW(`Q|gU4S)RrceM2(+K% zC%m9Mv9TLUa-cft~72Q_BeniplE!EAOt!bS?MiSvjzX2*vl<`!Z@`lEQ6Z7F| z)28$x2G*7mH?10zvW@>AO@+iy1HxEh(m_>heNsmo(@50P-EEkS))!yMs;9@ZYO)h~ zsR{*C2BeO$0hF8m-bo}#m#=vTRx0puCs%;=@RzE-O-4zOv~HiY)1!3HH?h6HbO|CM z8eyQGlqIm2#2g=^CfjUAMY*GlG{GhvKYYNUe`mcuWf-eKTa{X1X3D?56(bp1tf0EA zwCh4D`yl_|S|Pr_G(mia;wkfIbrlGsj=XLnlzDm-2|>y)^R7xAReA#rfO7vmhud=P z?a!QWy3Fo~1YxfMgX^>>C(AIslR7btv=2g?(`;wJFEcIrVM*Z;O7Ci8lm2tQWeRuF9J2UyKjGTBWpez0x-1u9c2Wg~YwlkX0)K8@Qne_)5v4}BTt;XD zy?rq{E7(B|4?+3$$<&T4!iYH+eFkB5zT5Ms1y+(t_>xI4Q5$>YsFX4(Qehh|`5&_X zYy9-}4TOhV2WkM(BGzm8(12DKQi7Zg_A->|%=VW*+-O1pwk;M60u|97pp?JdSXgU4 z$~Lh`c`^`x(I4i}{`Mr$q;5XS$f3g`CLhj3Gw7=S`JTb(TQS4=O2^Tu%qh3D{Yw$`;KYPC|x8N&WVyZFc{-o@%_=e^$%^a1~F*9H9 z++aOFIyvw1!pFSj@QAZVz;6{ZX#S_gaC`rrC4q(M(!&pTZvt#y1=3O%t70?NK9vAv zan4?rL{gMYTPJC}=88ev2wJ+sacfQ$k${irxEhO;q{xYprWy9q`#ri++CbU|sC@)Y zEcoR45Bi0*qNX`oFg7=qSzEnSy%~Op8fN;}& zkEWEYp5Zdzf9}1St)3$n3?t&ZzZOQS!Ii6mMZ zi^jO8iIS5HP0jY$PIM+wq;Ows(!lIFxLc0?DE0 z{i+Ed5W5Eu3cz03B&R<>b1oMl{rl3jn(6Qc2RIWrKb>hYfTSgHAHD%YL#T5UWmhmX z{4`aja=>B=+Tf1OYOS+w_XSog!%-EB1x71u2OyGLT=VfrR1juP$R$Bbo;J#ks1;xsa%@;FCn3?Vk40Vb;WeK?!l4OZR{1dqS@QIk& zDnoR?D2gGl(u-4t#p{H)p27(r378g0k4`i-gE#L$vm7*a@S~h zwu9&JKsvku&z|;rX>kx(XhoM(`mAq_wUes^Kmfb3aJ-kCl2NE2m48W|nvAtaYppdG z3Fnsigz1szC@wZmshYQC#zh-iqnvZ*mRWqFs^t+ZrVK!rw7Ouc=;sf%F5ljK=JA-| zCW**mm(FXSf~k>$3Arl^0e95@bU8><1@3*H8;;Ow9(fgI>~RbB(Z^I2WjO`&H8%tfk_>1-}xhL{F#jz1<$-wAYJL!-CE+% zTW2USE+w2R4+lo#GR#U&Ubb7*j;>X0u_yMjAYqUup@Mgwok-kyEqGL7HTn}g7$EzF zAA}y2{^svKn!NaWfENIPyNKl(KwP>9I6z;&04slq&Ng|Ep%7Ze0N-dZdS)hxg$x!-rSZ9XlAQaq2NT}e`MwT;En3NV7 zu`rlEJm4=TtgNYyxxmhLtJ!>Dd@x{daYs#m22*7sP9Qw~hp)YLxU|G!CGm!qN}KPC z?gX&d_%{S^_g~WmV0W3LOWcWYH#U&yRs;OJ(JISI{-uI9o1But26tC!Q02vUH&ob1 zlummFe$Hz&+NI9QA-;nm;^Wnd{USKP2Udeh&y$fA=as400IXb zdD}1#W$F#v_+@%Rx_dwN@|ZR|h3UXXh^a|}^bbGB@7KIN_7SuJCInUJ1`GYEp5ppO z+oL4jJu$|xvtxja!fML7umc#9d#r{Wp6VWs4=Nzb-D{je z49qbsDWwfb%Fo$DK?hbe8`b(W+7Izzm}^SK`iA;J=7?f2g!QpkKLJ$1)VJT{uU|{3 zipqXLNXQ<=ylh#@^=|%x0^fGU(aA}}Wi84B460$(f$DwTtis#0jQ~5Eqx5?AHErIM z!c6wSL};3^HJP@RtYS_wqSMc|&tWX;Zl%uv2$q8=QtRTu;xpon?3NfP)ME#H9w#Wh zHm5fc(3A>RlHY4Qf0}q?@Ip9_6Q~H(3ga}sP&c3)HmF%`W!~S#Wm=$fvV}AsFhaY6 z+yBXnG15f}bses#^yM|UCP*tV-yA?~R*SGD@OX@PA2yLnQ!y3#`p}BH&si4+s&k#_H_yXJ;A5?o?!|pIy8}n7n=`G({5WBOS|9a2d#P|1?$iJ z=gj1aGC`C_~}5_kZmUyTYV zIRkmN;&7T6th*wU7T~tFVZ=G&aTTebZBZ9~>C@OUiQRVfJdKu3yP8>P?%2W>!_%gh z05CcFd9Ec3r>cnab0rcEM}W*ioO__~0>UBljWbJQiT2{?J-7uQ&rsASUFVOM=zV@n z!Um0AB+VlI3|^KKksvQd_J&qdWlrabp+t&7C7XueYzHvfeDAylB~eMqQM;+mYN?Wpsd5tuz; zR*8*I!N37%Cq(IlD9m!1oPSnGVEp;dF%?P8^T|zCM%F6sR4L yH~aPE=p;f6fb-l323NcbX)}DK=PbVmm<~ + + diff --git a/app/src/main/res/drawable/ic_arrow_back_24dp.xml b/app/src/main/res/drawable/ic_arrow_back_24dp.xml new file mode 100644 index 0000000000..71d5bbd292 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_backup_24dp.xml b/app/src/main/res/drawable/ic_backup_24dp.xml new file mode 100644 index 0000000000..086281669f --- /dev/null +++ b/app/src/main/res/drawable/ic_backup_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_blank_28dp.xml b/app/src/main/res/drawable/ic_blank_28dp.xml new file mode 100644 index 0000000000..728b4afdc0 --- /dev/null +++ b/app/src/main/res/drawable/ic_blank_28dp.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_book_24dp.xml b/app/src/main/res/drawable/ic_book_24dp.xml index 811d5ac4bd..e6712dfcb6 100644 --- a/app/src/main/res/drawable/ic_book_24dp.xml +++ b/app/src/main/res/drawable/ic_book_24dp.xml @@ -1,9 +1,10 @@ + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0" + android:tint="?attr/colorAccent"> - + android:pathData="M18,2H6c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM6,4h5v8l-2.5,-1.5L6,12V4z" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_book_open_variant_24dp.xml b/app/src/main/res/drawable/ic_book_open_variant_24dp.xml new file mode 100644 index 0000000000..f463d84c28 --- /dev/null +++ b/app/src/main/res/drawable/ic_book_open_variant_24dp.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_bookmark_24dp.xml b/app/src/main/res/drawable/ic_bookmark_24dp.xml index 38953e0efa..b70b21beaf 100644 --- a/app/src/main/res/drawable/ic_bookmark_24dp.xml +++ b/app/src/main/res/drawable/ic_bookmark_24dp.xml @@ -1,9 +1,9 @@ + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:tint="?attr/actionBarTintColor" + android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable/ic_broken_image_24dp.xml b/app/src/main/res/drawable/ic_broken_image_24dp.xml new file mode 100644 index 0000000000..35506713be --- /dev/null +++ b/app/src/main/res/drawable/ic_broken_image_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_browse_24dp.xml b/app/src/main/res/drawable/ic_browse_24dp.xml index 68aa814795..21f4f417a2 100644 --- a/app/src/main/res/drawable/ic_browse_24dp.xml +++ b/app/src/main/res/drawable/ic_browse_24dp.xml @@ -1,8 +1,9 @@ + android:width="24dp" + android:height="24dp" + android:tint="?attr/actionBarTintColor" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable/ic_browse_off_24dp.xml b/app/src/main/res/drawable/ic_browse_off_24dp.xml new file mode 100644 index 0000000000..b252ebeb0c --- /dev/null +++ b/app/src/main/res/drawable/ic_browse_off_24dp.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_browse_selector_24dp.xml b/app/src/main/res/drawable/ic_browse_selector_24dp.xml index ae581550df..56907a97ca 100644 --- a/app/src/main/res/drawable/ic_browse_selector_24dp.xml +++ b/app/src/main/res/drawable/ic_browse_selector_24dp.xml @@ -1,5 +1,16 @@ - - - - \ No newline at end of file + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_calendar_text_outline_24dp.xml b/app/src/main/res/drawable/ic_calendar_text_outline_24dp.xml new file mode 100644 index 0000000000..29c8952454 --- /dev/null +++ b/app/src/main/res/drawable/ic_calendar_text_outline_24dp.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_close_circle_24dp.xml b/app/src/main/res/drawable/ic_close_circle_24dp.xml new file mode 100644 index 0000000000..d87d11ba5c --- /dev/null +++ b/app/src/main/res/drawable/ic_close_circle_24dp.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_eye_down_24dp.xml b/app/src/main/res/drawable/ic_eye_down_24dp.xml new file mode 100644 index 0000000000..222e960e5b --- /dev/null +++ b/app/src/main/res/drawable/ic_eye_down_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_eye_off_down_24dp.xml b/app/src/main/res/drawable/ic_eye_off_down_24dp.xml new file mode 100644 index 0000000000..45cf6a161c --- /dev/null +++ b/app/src/main/res/drawable/ic_eye_off_down_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_eye_off_range_24dp.xml b/app/src/main/res/drawable/ic_eye_off_range_24dp.xml new file mode 100644 index 0000000000..7f0152976d --- /dev/null +++ b/app/src/main/res/drawable/ic_eye_off_range_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_eye_off_up_24dp.xml b/app/src/main/res/drawable/ic_eye_off_up_24dp.xml new file mode 100644 index 0000000000..749d474b5c --- /dev/null +++ b/app/src/main/res/drawable/ic_eye_off_up_24dp.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_eye_range_24dp.xml b/app/src/main/res/drawable/ic_eye_range_24dp.xml new file mode 100644 index 0000000000..30d5d72ec8 --- /dev/null +++ b/app/src/main/res/drawable/ic_eye_range_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_eye_remove_outline_24dp.xml b/app/src/main/res/drawable/ic_eye_remove_outline_24dp.xml new file mode 100644 index 0000000000..4c8f7946e4 --- /dev/null +++ b/app/src/main/res/drawable/ic_eye_remove_outline_24dp.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_eye_up_24dp.xml b/app/src/main/res/drawable/ic_eye_up_24dp.xml new file mode 100644 index 0000000000..74ddfd567a --- /dev/null +++ b/app/src/main/res/drawable/ic_eye_up_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_glasses_24dp.xml b/app/src/main/res/drawable/ic_glasses_24dp.xml new file mode 100644 index 0000000000..738e332366 --- /dev/null +++ b/app/src/main/res/drawable/ic_glasses_24dp.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_heart_off_24dp.xml b/app/src/main/res/drawable/ic_heart_off_24dp.xml new file mode 100644 index 0000000000..6787d1a069 --- /dev/null +++ b/app/src/main/res/drawable/ic_heart_off_24dp.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_help_outline_24dp.xml b/app/src/main/res/drawable/ic_help_outline_24dp.xml new file mode 100644 index 0000000000..302ec7cd53 --- /dev/null +++ b/app/src/main/res/drawable/ic_help_outline_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_history_off_24dp.xml b/app/src/main/res/drawable/ic_history_off_24dp.xml new file mode 100644 index 0000000000..ece916dcc9 --- /dev/null +++ b/app/src/main/res/drawable/ic_history_off_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_incognito_24dp.xml b/app/src/main/res/drawable/ic_incognito_24dp.xml new file mode 100644 index 0000000000..d923bfcd35 --- /dev/null +++ b/app/src/main/res/drawable/ic_incognito_24dp.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_incognito_circle_24dp.xml b/app/src/main/res/drawable/ic_incognito_circle_24dp.xml new file mode 100644 index 0000000000..fe7a9861c3 --- /dev/null +++ b/app/src/main/res/drawable/ic_incognito_circle_24dp.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_language_24dp.xml b/app/src/main/res/drawable/ic_language_24dp.xml new file mode 100644 index 0000000000..6d7cba52fd --- /dev/null +++ b/app/src/main/res/drawable/ic_language_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_libary_filled_24dp.xml b/app/src/main/res/drawable/ic_libary_filled_24dp.xml new file mode 100644 index 0000000000..8c6eac2992 --- /dev/null +++ b/app/src/main/res/drawable/ic_libary_filled_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_library_24dp.xml b/app/src/main/res/drawable/ic_library_24dp.xml deleted file mode 100644 index a4e951051c..0000000000 --- a/app/src/main/res/drawable/ic_library_24dp.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_library_selector_24dp.xml b/app/src/main/res/drawable/ic_library_selector_24dp.xml index 25ca378800..d9f5d2884e 100644 --- a/app/src/main/res/drawable/ic_library_selector_24dp.xml +++ b/app/src/main/res/drawable/ic_library_selector_24dp.xml @@ -1,5 +1,16 @@ - - - - \ No newline at end of file + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_lock_24dp.xml b/app/src/main/res/drawable/ic_lock_24dp.xml new file mode 100644 index 0000000000..146c066b5f --- /dev/null +++ b/app/src/main/res/drawable/ic_lock_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_more_vert_24dp.xml b/app/src/main/res/drawable/ic_more_vert_24dp.xml index 0ef23a5676..a988f72688 100644 --- a/app/src/main/res/drawable/ic_more_vert_24dp.xml +++ b/app/src/main/res/drawable/ic_more_vert_24dp.xml @@ -2,7 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportHeight="24.0" - android:viewportWidth="24.0"> + android:viewportWidth="24.0" + android:tint="?actionBarTintColor"> diff --git a/app/src/main/res/drawable/ic_no_settings_24dp.xml b/app/src/main/res/drawable/ic_no_settings_24dp.xml deleted file mode 100644 index 71acd27e8e..0000000000 --- a/app/src/main/res/drawable/ic_no_settings_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_outline_photo_24dp.xml b/app/src/main/res/drawable/ic_outline_photo_24dp.xml new file mode 100644 index 0000000000..f2524d59ed --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_photo_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_save_24dp.xml b/app/src/main/res/drawable/ic_outline_save_24dp.xml new file mode 100644 index 0000000000..dbc1b91f4d --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_save_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_settings_24dp.xml b/app/src/main/res/drawable/ic_outline_settings_24dp.xml new file mode 100644 index 0000000000..21fc727076 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_settings_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_share_24dp.xml b/app/src/main/res/drawable/ic_outline_share_24dp.xml new file mode 100644 index 0000000000..4cd6541066 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_share_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_page_next_outline_24dp.xml b/app/src/main/res/drawable/ic_page_next_outline_24dp.xml new file mode 100644 index 0000000000..c9afa7ced6 --- /dev/null +++ b/app/src/main/res/drawable/ic_page_next_outline_24dp.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_page_previous_outline_24dp.xml b/app/src/main/res/drawable/ic_page_previous_outline_24dp.xml new file mode 100644 index 0000000000..ef908121b6 --- /dev/null +++ b/app/src/main/res/drawable/ic_page_previous_outline_24dp.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_pin_24dp.xml b/app/src/main/res/drawable/ic_pin_24dp.xml index fe0e5245a2..1ac6a56c96 100644 --- a/app/src/main/res/drawable/ic_pin_24dp.xml +++ b/app/src/main/res/drawable/ic_pin_24dp.xml @@ -1,6 +1,7 @@ diff --git a/app/src/main/res/drawable/ic_star_24dp.xml b/app/src/main/res/drawable/ic_plus_24dp.xml similarity index 50% rename from app/src/main/res/drawable/ic_star_24dp.xml rename to app/src/main/res/drawable/ic_plus_24dp.xml index eacf681ceb..fe500883a4 100644 --- a/app/src/main/res/drawable/ic_star_24dp.xml +++ b/app/src/main/res/drawable/ic_plus_24dp.xml @@ -1,8 +1,8 @@ - + - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_reader_continuous_vertical_24dp.xml b/app/src/main/res/drawable/ic_reader_continuous_vertical_24dp.xml new file mode 100644 index 0000000000..7997bba7bb --- /dev/null +++ b/app/src/main/res/drawable/ic_reader_continuous_vertical_24dp.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_reader_default_24dp.xml b/app/src/main/res/drawable/ic_reader_default_24dp.xml new file mode 100644 index 0000000000..3ec2d7598e --- /dev/null +++ b/app/src/main/res/drawable/ic_reader_default_24dp.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_reader_ltr_24dp.xml b/app/src/main/res/drawable/ic_reader_ltr_24dp.xml new file mode 100644 index 0000000000..2c44474e90 --- /dev/null +++ b/app/src/main/res/drawable/ic_reader_ltr_24dp.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_reader_rtl_24dp.xml b/app/src/main/res/drawable/ic_reader_rtl_24dp.xml new file mode 100644 index 0000000000..bce2932fcf --- /dev/null +++ b/app/src/main/res/drawable/ic_reader_rtl_24dp.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_reader_vertical_24dp.xml b/app/src/main/res/drawable/ic_reader_vertical_24dp.xml new file mode 100644 index 0000000000..def93b2c21 --- /dev/null +++ b/app/src/main/res/drawable/ic_reader_vertical_24dp.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_reader_webtoon_24dp.xml b/app/src/main/res/drawable/ic_reader_webtoon_24dp.xml new file mode 100644 index 0000000000..b7683822de --- /dev/null +++ b/app/src/main/res/drawable/ic_reader_webtoon_24dp.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_recent_read_selector_24dp.xml b/app/src/main/res/drawable/ic_recent_read_selector_24dp.xml deleted file mode 100644 index 0683bf9008..0000000000 --- a/app/src/main/res/drawable/ic_recent_read_selector_24dp.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_recents_filled_24dp.xml b/app/src/main/res/drawable/ic_recents_filled_24dp.xml new file mode 100644 index 0000000000..92d144ebe6 --- /dev/null +++ b/app/src/main/res/drawable/ic_recents_filled_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_recents_outline_24dp.xml b/app/src/main/res/drawable/ic_recents_outline_24dp.xml new file mode 100644 index 0000000000..9c9a1d9588 --- /dev/null +++ b/app/src/main/res/drawable/ic_recents_outline_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_recents_selector_24dp.xml b/app/src/main/res/drawable/ic_recents_selector_24dp.xml new file mode 100644 index 0000000000..c2d2620440 --- /dev/null +++ b/app/src/main/res/drawable/ic_recents_selector_24dp.xml @@ -0,0 +1,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_rounded_tooltip_24dp.xml b/app/src/main/res/drawable/ic_rounded_tooltip_24dp.xml new file mode 100644 index 0000000000..36d0b4d437 --- /dev/null +++ b/app/src/main/res/drawable/ic_rounded_tooltip_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_save_all_outline_24dp.xml b/app/src/main/res/drawable/ic_save_all_outline_24dp.xml new file mode 100644 index 0000000000..6345f96d26 --- /dev/null +++ b/app/src/main/res/drawable/ic_save_all_outline_24dp.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_screen_lock_landscape_24dp.xml b/app/src/main/res/drawable/ic_screen_lock_landscape_24dp.xml new file mode 100644 index 0000000000..f78d9a5d43 --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_lock_landscape_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_screen_lock_portrait_24dp.xml b/app/src/main/res/drawable/ic_screen_lock_portrait_24dp.xml new file mode 100644 index 0000000000..718ca70baf --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_lock_portrait_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_screen_rotation_24dp.xml b/app/src/main/res/drawable/ic_screen_rotation_24dp.xml new file mode 100644 index 0000000000..3243d5a75d --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_rotation_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_off_24dp.xml b/app/src/main/res/drawable/ic_search_off_24dp.xml new file mode 100644 index 0000000000..1f3c9247b1 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_off_24dp.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_security_24dp.xml b/app/src/main/res/drawable/ic_security_24dp.xml new file mode 100644 index 0000000000..0cde5e26b7 --- /dev/null +++ b/app/src/main/res/drawable/ic_security_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_select_all_24dp.xml b/app/src/main/res/drawable/ic_select_all_24dp.xml new file mode 100644 index 0000000000..23d025e46d --- /dev/null +++ b/app/src/main/res/drawable/ic_select_all_24dp.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_select_none_24dp.xml b/app/src/main/res/drawable/ic_select_none_24dp.xml new file mode 100644 index 0000000000..04894a7259 --- /dev/null +++ b/app/src/main/res/drawable/ic_select_none_24dp.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_share_all_outline_24dp.xml b/app/src/main/res/drawable/ic_share_all_outline_24dp.xml new file mode 100644 index 0000000000..e668612eb0 --- /dev/null +++ b/app/src/main/res/drawable/ic_share_all_outline_24dp.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_single_page_24dp.xml b/app/src/main/res/drawable/ic_single_page_24dp.xml new file mode 100644 index 0000000000..c41f46a0f7 --- /dev/null +++ b/app/src/main/res/drawable/ic_single_page_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_skip_next_24.xml b/app/src/main/res/drawable/ic_skip_next_24.xml new file mode 100644 index 0000000000..757a516afe --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_next_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_skip_previous_24.xml b/app/src/main/res/drawable/ic_skip_previous_24.xml new file mode 100644 index 0000000000..ab5a2f8b28 --- /dev/null +++ b/app/src/main/res/drawable/ic_skip_previous_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_stay_current_landscape_24dp.xml b/app/src/main/res/drawable/ic_stay_current_landscape_24dp.xml new file mode 100644 index 0000000000..ac185cda4b --- /dev/null +++ b/app/src/main/res/drawable/ic_stay_current_landscape_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stay_current_portrait_24dp.xml b/app/src/main/res/drawable/ic_stay_current_portrait_24dp.xml new file mode 100644 index 0000000000..23e9f4f263 --- /dev/null +++ b/app/src/main/res/drawable/ic_stay_current_portrait_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_update_24dp.xml b/app/src/main/res/drawable/ic_update_24dp.xml deleted file mode 100644 index 5e6ebfe987..0000000000 --- a/app/src/main/res/drawable/ic_update_24dp.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_view_comments_24p.xml b/app/src/main/res/drawable/ic_view_comments_24p.xml new file mode 100644 index 0000000000..2b229b9a2d --- /dev/null +++ b/app/src/main/res/drawable/ic_view_comments_24p.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/oval.xml b/app/src/main/res/drawable/oval.xml new file mode 100644 index 0000000000..41bc9ee16a --- /dev/null +++ b/app/src/main/res/drawable/oval.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/oval_ripple.xml b/app/src/main/res/drawable/oval_ripple.xml index 4f48db0d13..83003bbb21 100644 --- a/app/src/main/res/drawable/oval_ripple.xml +++ b/app/src/main/res/drawable/oval_ripple.xml @@ -1,9 +1,9 @@ + android:color="?colorAccent"> - + @@ -24,7 +24,7 @@ - + diff --git a/app/src/main/res/drawable/reader_toolbar_ripple.xml b/app/src/main/res/drawable/reader_toolbar_ripple.xml new file mode 100644 index 0000000000..407c8ed029 --- /dev/null +++ b/app/src/main/res/drawable/reader_toolbar_ripple.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rect_ripple.xml b/app/src/main/res/drawable/rect_ripple.xml new file mode 100644 index 0000000000..0b72b6ee74 --- /dev/null +++ b/app/src/main/res/drawable/rect_ripple.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/round_textview_background.xml b/app/src/main/res/drawable/round_textview_background.xml deleted file mode 100644 index 75a5ee898c..0000000000 --- a/app/src/main/res/drawable/round_textview_background.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/round_textview_border.xml b/app/src/main/res/drawable/round_textview_border.xml index a92032e0e8..0c3f0cfb44 100644 --- a/app/src/main/res/drawable/round_textview_border.xml +++ b/app/src/main/res/drawable/round_textview_border.xml @@ -15,8 +15,17 @@ - - + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_preview_rect.xml b/app/src/main/res/drawable/rounded_preview_rect.xml new file mode 100644 index 0000000000..8ff3de7822 --- /dev/null +++ b/app/src/main/res/drawable/rounded_preview_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sc_glasses_48dp.xml b/app/src/main/res/drawable/sc_glasses_48dp.xml deleted file mode 100644 index c90cfd147d..0000000000 --- a/app/src/main/res/drawable/sc_glasses_48dp.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/sc_update_48dp.xml b/app/src/main/res/drawable/sc_update_48dp.xml deleted file mode 100644 index e1db340055..0000000000 --- a/app/src/main/res/drawable/sc_update_48dp.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_gradient_start_shadow.xml b/app/src/main/res/drawable/shape_gradient_start_shadow.xml new file mode 100644 index 0000000000..ea7f1f9ca4 --- /dev/null +++ b/app/src/main/res/drawable/shape_gradient_start_shadow.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/tab_highlight_indicator.xml b/app/src/main/res/drawable/tab_highlight_indicator.xml new file mode 100644 index 0000000000..e4cda3a296 --- /dev/null +++ b/app/src/main/res/drawable/tab_highlight_indicator.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/tab_indicator.xml b/app/src/main/res/drawable/tab_indicator.xml new file mode 100644 index 0000000000..6105fbdcac --- /dev/null +++ b/app/src/main/res/drawable/tab_indicator.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/theme_selected_border.xml b/app/src/main/res/drawable/theme_selected_border.xml new file mode 100644 index 0000000000..4c08f4da8d --- /dev/null +++ b/app/src/main/res/drawable/theme_selected_border.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/thumb_drawable.xml b/app/src/main/res/drawable/thumb_drawable.xml index cd7125aca5..0c8831e7cf 100644 --- a/app/src/main/res/drawable/thumb_drawable.xml +++ b/app/src/main/res/drawable/thumb_drawable.xml @@ -3,7 +3,7 @@ - + diff --git a/app/src/main/res/drawable/unread_angled_badge.xml b/app/src/main/res/drawable/unread_angled_badge.xml index b505c96dd7..ca6b9d7904 100644 --- a/app/src/main/res/drawable/unread_angled_badge.xml +++ b/app/src/main/res/drawable/unread_angled_badge.xml @@ -8,8 +8,8 @@ tools:background="@color/material_red_500"> + android:strokeColor="?attr/unreadBadgeColor" /> \ No newline at end of file diff --git a/app/src/main/res/layout-land/reader_color_filter_sheet.xml b/app/src/main/res/layout-land/reader_color_filter_sheet.xml deleted file mode 100644 index 6df89dc7c6..0000000000 --- a/app/src/main/res/layout-land/reader_color_filter_sheet.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout-sw600dp/main_activity.xml b/app/src/main/res/layout-sw600dp/main_activity.xml new file mode 100644 index 0000000000..22a3b95ad3 --- /dev/null +++ b/app/src/main/res/layout-sw600dp/main_activity.xml @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/add_category_item.xml b/app/src/main/res/layout/add_category_item.xml new file mode 100644 index 0000000000..52dd83492a --- /dev/null +++ b/app/src/main/res/layout/add_category_item.xml @@ -0,0 +1,12 @@ + + diff --git a/app/src/main/res/layout/bottom_menu_sheet.xml b/app/src/main/res/layout/bottom_menu_sheet.xml index 6b6e4a37d9..fdac7831c4 100644 --- a/app/src/main/res/layout/bottom_menu_sheet.xml +++ b/app/src/main/res/layout/bottom_menu_sheet.xml @@ -2,30 +2,24 @@ - - - + @@ -58,5 +52,15 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:text="Title Text" /> + + diff --git a/app/src/main/res/layout/browse_source_controller.xml b/app/src/main/res/layout/browse_source_controller.xml index 33b79f1885..88f9f2d554 100644 --- a/app/src/main/res/layout/browse_source_controller.xml +++ b/app/src/main/res/layout/browse_source_controller.xml @@ -1,9 +1,9 @@ @@ -28,24 +28,23 @@ android:visibility="gone" /> - + app:icon="@drawable/ic_filter_list_24dp" /> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" /> + diff --git a/app/src/main/res/layout/categories_item.xml b/app/src/main/res/layout/categories_item.xml index 6e2c68c99a..1f73be7a5c 100644 --- a/app/src/main/res/layout/categories_item.xml +++ b/app/src/main/res/layout/categories_item.xml @@ -1,5 +1,6 @@ - + app:srcCompat="@drawable/ic_drag_handle_24dp" /> + app:layout_constraintStart_toEndOf="@id/reorder" + app:layout_constraintEnd_toStartOf="@id/title" + app:layout_constraintTop_toTopOf="parent" + /> @@ -48,16 +50,18 @@ android:layout_height="match_parent" android:background="@null" android:imeOptions="actionDone" - android:inputType="none" + android:inputType="textCapSentences" + android:hint="@string/category" android:maxLines="1" android:singleLine="true" - android:textColor="@color/textColorPrimary" + android:textColor="?android:attr/textColorPrimary" android:textSize="16sp" app:layout_constraintEnd_toEndOf="@id/title" app:layout_constraintTop_toTopOf="@id/title" app:layout_constraintBottom_toBottomOf="@id/title" app:layout_constraintStart_toStartOf="@id/title" - tools:text="Title" /> + tools:text="Title" + android:autofillHints="" /> diff --git a/app/src/main/res/layout/changelog_header_layout.xml b/app/src/main/res/layout/changelog_header_layout.xml deleted file mode 100644 index 65d97ab364..0000000000 --- a/app/src/main/res/layout/changelog_header_layout.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/layout/changelog_row_layout.xml b/app/src/main/res/layout/changelog_row_layout.xml deleted file mode 100644 index b4eefb7ca9..0000000000 --- a/app/src/main/res/layout/changelog_row_layout.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/chapter_filter_layout.xml b/app/src/main/res/layout/chapter_filter_layout.xml index e20c8b69c8..b9768f2f44 100644 --- a/app/src/main/res/layout/chapter_filter_layout.xml +++ b/app/src/main/res/layout/chapter_filter_layout.xml @@ -1,5 +1,5 @@ - @@ -52,4 +52,4 @@ android:layout_marginStart="12dp" android:layout_marginEnd="12dp" android:text="@string/show_bookmarked_chapters" /> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/chapter_header_item.xml b/app/src/main/res/layout/chapter_header_item.xml new file mode 100644 index 0000000000..8adbd85bee --- /dev/null +++ b/app/src/main/res/layout/chapter_header_item.xml @@ -0,0 +1,59 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chapter_sort_bottom_sheet.xml b/app/src/main/res/layout/chapter_sort_bottom_sheet.xml index 98b4522859..0d551b9e70 100644 --- a/app/src/main/res/layout/chapter_sort_bottom_sheet.xml +++ b/app/src/main/res/layout/chapter_sort_bottom_sheet.xml @@ -66,7 +66,8 @@ android:text="@string/oldest_first" /> - @@ -134,7 +135,7 @@ android:alpha="0.25" android:contentDescription="@string/drag_handle" android:src="@drawable/draggable_pill" - android:tint="?android:attr/textColorPrimary" /> + app:tint="?android:attr/textColorPrimary" /> + app:tint="@color/gray_button" /> \ No newline at end of file diff --git a/app/src/main/res/layout/chapters_item.xml b/app/src/main/res/layout/chapters_item.xml index 4347cb8c26..9ab33feb9c 100644 --- a/app/src/main/res/layout/chapters_item.xml +++ b/app/src/main/res/layout/chapters_item.xml @@ -12,7 +12,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:visibility="gone" - android:background="@color/neko_green_complementary" + android:background="?attr/colorAccent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -22,7 +22,7 @@ android:id="@+id/read" android:layout_width="24dp" android:layout_height="24dp" - android:tint="@color/md_white_1000" + app:tint="?attr/colorOnAccent" android:layout_gravity="end|center" android:layout_marginEnd="21dp" android:src="@drawable/ic_read_24dp" /> @@ -34,7 +34,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:visibility="gone" - android:background="@color/neko_green_darker" + android:background="?attr/colorAccent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -46,7 +46,7 @@ android:layout_height="24dp" android:layout_gravity="start|center" android:layout_marginStart="21dp" - android:tint="@color/md_white_1000" + app:tint="?attr/colorOnAccent" android:src="@drawable/ic_bookmark_24dp" /> @@ -111,6 +111,7 @@ - - - - - - - diff --git a/app/src/main/res/layout/common_view_empty.xml b/app/src/main/res/layout/common_view_empty.xml index ced4a760dd..bc324bf7de 100644 --- a/app/src/main/res/layout/common_view_empty.xml +++ b/app/src/main/res/layout/common_view_empty.xml @@ -1,16 +1,27 @@ - + android:orientation="vertical"> + android:orientation="vertical" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/text_label" /> - + diff --git a/app/src/main/res/layout/display_bottom_sheet.xml b/app/src/main/res/layout/display_bottom_sheet.xml deleted file mode 100644 index cf470dc61a..0000000000 --- a/app/src/main/res/layout/display_bottom_sheet.xml +++ /dev/null @@ -1,238 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/download_bottom_sheet.xml b/app/src/main/res/layout/download_bottom_sheet.xml index ef719aba8c..6c9402399b 100644 --- a/app/src/main/res/layout/download_bottom_sheet.xml +++ b/app/src/main/res/layout/download_bottom_sheet.xml @@ -7,7 +7,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/bottom_sheet_rounded_background" - android:backgroundTint="?android:attr/colorBackground" + android:backgroundTint="?colorPrimaryVariant" android:orientation="vertical" app:behavior_peekHeight="48sp" app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> @@ -56,7 +56,7 @@ tools:text="Downloads" /> - - + + + \ No newline at end of file diff --git a/app/src/main/res/layout/download_button.xml b/app/src/main/res/layout/download_button.xml index 49f3b6e6ed..052aa5e094 100644 --- a/app/src/main/res/layout/download_button.xml +++ b/app/src/main/res/layout/download_button.xml @@ -1,6 +1,7 @@ @@ -56,7 +56,7 @@ android:layout_gravity="start" android:contentDescription="@string/reorder" android:scaleType="center" - android:tint="?android:attr/textColorPrimary" + app:tint="?android:attr/textColorPrimary" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" @@ -88,7 +88,7 @@ android:ellipsize="end" android:maxLines="1" android:textAppearance="@style/TextAppearance.Regular.Caption" - app:layout_constraintEnd_toStartOf="@+id/migration_menu" + app:layout_constraintEnd_toStartOf="@+id/download_menu" android:layout_marginEnd="16dp" app:layout_constraintStart_toStartOf="@+id/title" app:layout_constraintTop_toBottomOf="@+id/title" @@ -101,7 +101,7 @@ android:layout_height="wrap_content" android:layout_marginBottom="8dp" app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@+id/migration_menu" + app:layout_constraintEnd_toStartOf="@+id/download_menu" app:layout_constraintStart_toEndOf="@+id/reorder" android:layout_marginEnd="16dp" app:layout_constraintTop_toBottomOf="@+id/chapter_title" /> @@ -115,12 +115,12 @@ android:maxLines="1" app:layout_constraintBottom_toBottomOf="@+id/title" android:layout_marginEnd="16dp" - app:layout_constraintEnd_toStartOf="@+id/migration_menu" + app:layout_constraintEnd_toStartOf="@+id/download_menu" app:layout_constraintTop_toTopOf="@+id/title" tools:text="(0/10)" /> + + + + app:tint="@color/gray_button" /> @@ -76,6 +87,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="10dp" + android:maxLines="2" android:text="@string/group_library_by" android:textColor="?android:attr/textColorPrimary" app:icon="@drawable/ic_label_outline_24dp" @@ -86,6 +98,7 @@ style="@style/Theme.Widget.Button.TextButton" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:maxLines="2" android:text="@string/expand_all_categories" android:textColor="?android:attr/textColorPrimary" app:icon="@drawable/ic_expand_more_24dp" @@ -98,24 +111,13 @@ android:layout_height="wrap_content" android:orientation="horizontal"> - - diff --git a/app/src/main/res/layout/filter_buttons.xml b/app/src/main/res/layout/filter_tag_group.xml similarity index 100% rename from app/src/main/res/layout/filter_buttons.xml rename to app/src/main/res/layout/filter_tag_group.xml diff --git a/app/src/main/res/layout/in_library_badge.xml b/app/src/main/res/layout/in_library_badge.xml new file mode 100644 index 0000000000..bc866f5c94 --- /dev/null +++ b/app/src/main/res/layout/in_library_badge.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/library_badges_layout.xml b/app/src/main/res/layout/library_badges_layout.xml new file mode 100644 index 0000000000..143d6cf7ad --- /dev/null +++ b/app/src/main/res/layout/library_badges_layout.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/library_category_header_item.xml b/app/src/main/res/layout/library_category_header_item.xml index 9fd5fa999c..c737bc86a9 100644 --- a/app/src/main/res/layout/library_category_header_item.xml +++ b/app/src/main/res/layout/library_category_header_item.xml @@ -40,7 +40,7 @@ android:layout_marginStart="8dp" android:contentDescription="@string/select_all" android:src="@drawable/ic_expand_less_24dp" - android:tint="?android:textColorPrimary" + app:tint="?android:textColorPrimary" app:layout_constraintBottom_toBottomOf="@+id/category_title" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@+id/category_title" /> @@ -127,7 +127,7 @@ android:focusable="true" android:padding="4dp" android:src="@drawable/ic_refresh_24dp" - android:tint="?android:textColorPrimary" + app:tint="?android:textColorPrimary" android:tooltipText="@string/update" app:layout_constraintBottom_toBottomOf="@id/category_title" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout/library_category_layout.xml b/app/src/main/res/layout/library_category_layout.xml new file mode 100644 index 0000000000..a0b022ea8c --- /dev/null +++ b/app/src/main/res/layout/library_category_layout.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/library_list_controller.xml b/app/src/main/res/layout/library_controller.xml similarity index 71% rename from app/src/main/res/layout/library_list_controller.xml rename to app/src/main/res/layout/library_controller.xml index 24264c93b9..46b08fe304 100644 --- a/app/src/main/res/layout/library_list_controller.xml +++ b/app/src/main/res/layout/library_controller.xml @@ -42,7 +42,8 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> - + - + android:layout_gravity="center|top"> + + + + android:layout_gravity="top|center"> - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml index 7435daafb5..30ed7ed49b 100644 --- a/app/src/main/res/layout/main_activity.xml +++ b/app/src/main/res/layout/main_activity.xml @@ -21,7 +21,7 @@ - - - - + android:maxLines="1" + android:textColor="?actionBarTintColor" + android:textSize="20sp" + tools:drawableEnd="@drawable/ic_arrow_drop_down_24dp" + tools:drawableStart="@drawable/ic_blank_24dp" + tools:text="Title Text" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + android:layout_marginEnd="16dp" + android:hint="@string/title" + app:boxStrokeColor="?colorAccent" + app:endIconMode="clear_text" + app:hintEnabled="false" + app:hintTextColor="?colorAccent"> + + + + - - - + android:layout_height="match_parent" + android:orientation="horizontal"> + + - + + + + + - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/manga_grid_item.xml b/app/src/main/res/layout/manga_grid_item.xml index 42eeed2c82..f644b1ec17 100644 --- a/app/src/main/res/layout/manga_grid_item.xml +++ b/app/src/main/res/layout/manga_grid_item.xml @@ -61,7 +61,7 @@ android:contentDescription="@string/start_reading" android:padding="6dp" android:src="@drawable/ic_start_reading_24dp" - android:tint="@android:color/white" /> + app:tint="@android:color/white" /> @@ -109,6 +109,7 @@ @@ -417,9 +417,9 @@ android:layout_marginEnd="16dp" android:visibility="gone" app:atg_backgroundColor="@android:color/transparent" - app:atg_borderColor="@color/colorAccent" + app:atg_borderColor="?attr/colorAccent" app:atg_borderStrokeWidth="1dp" - app:atg_textColor="@color/colorAccent" + app:atg_textColor="?attr/colorAccent" app:atg_textSize="14sp" app:layout_constrainedHeight="true" app:layout_constraintBottom_toTopOf="@id/less_button" diff --git a/app/src/main/res/layout/manga_list_item.xml b/app/src/main/res/layout/manga_list_item.xml index 33fe59f003..d71ff84d7e 100644 --- a/app/src/main/res/layout/manga_list_item.xml +++ b/app/src/main/res/layout/manga_list_item.xml @@ -8,6 +8,16 @@ android:id="@+id/constraint_layout" android:background="@drawable/list_item_selector"> + + + - + app:layout_constraintTop_toBottomOf="@id/bottom_list_line" /> diff --git a/app/src/main/res/layout/material_spinner_view.xml b/app/src/main/res/layout/material_spinner_view.xml new file mode 100644 index 0000000000..1d0033c680 --- /dev/null +++ b/app/src/main/res/layout/material_spinner_view.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/material_text_button.xml b/app/src/main/res/layout/material_text_button.xml index 906deae66b..89f1a31d2e 100644 --- a/app/src/main/res/layout/material_text_button.xml +++ b/app/src/main/res/layout/material_text_button.xml @@ -1,5 +1,6 @@ - + android:orientation="horizontal" + tools:icon="@drawable/ic_share_24dp" + tools:endIcon="@drawable/ic_arrow_downward_24dp" + tools:text="@string/share"> + android:tint="@color/md_white_1000_54" /> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/navigation_view_group.xml b/app/src/main/res/layout/navigation_view_group.xml index 91fb1ee088..fad2bbc345 100644 --- a/app/src/main/res/layout/navigation_view_group.xml +++ b/app/src/main/res/layout/navigation_view_group.xml @@ -1,30 +1,33 @@ - + android:paddingEnd="?attr/listPreferredItemPaddingEnd"> + android:textAllCaps="true" + android:textSize="15sp" + android:textAppearance="@style/Neko.Headline6.Lower" + android:textColor="?android:attr/textColorPrimary" + tools:text="Header" /> + android:layout_height="wrap_content" + tools:src="@drawable/ic_expand_more_24dp" + app:tint="?android:attr/textColorPrimary" /> diff --git a/app/src/main/res/layout/navigation_view_spinner.xml b/app/src/main/res/layout/navigation_view_spinner.xml index b3e8809a30..852e5d8795 100644 --- a/app/src/main/res/layout/navigation_view_spinner.xml +++ b/app/src/main/res/layout/navigation_view_spinner.xml @@ -1,6 +1,6 @@ - - - - + tools:entries="@array/viewers_selector" + tools:title="Filter: "/> - + diff --git a/app/src/main/res/layout/pref_account_login.xml b/app/src/main/res/layout/pref_account_login.xml index a6341c3f75..3ae60145c0 100644 --- a/app/src/main/res/layout/pref_account_login.xml +++ b/app/src/main/res/layout/pref_account_login.xml @@ -18,11 +18,13 @@ + android:hint="@string/username" + app:boxStrokeColor="@color/colorAccent" + app:hintTextColor="@color/colorAccent"> + app:boxStrokeColor="?colorAccent" + app:endIconMode="password_toggle" + app:hintTextColor="?colorAccent"> + + + + + + - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/pref_widget_imageview.xml b/app/src/main/res/layout/pref_widget_imageview.xml index 78381c96cc..320d56e047 100644 --- a/app/src/main/res/layout/pref_widget_imageview.xml +++ b/app/src/main/res/layout/pref_widget_imageview.xml @@ -1,7 +1,7 @@ - - \ No newline at end of file + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_height="wrap_content" + tools:src="@drawable/circle_progress" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="wrap_content" /> \ No newline at end of file diff --git a/app/src/main/res/layout/reader_activity.xml b/app/src/main/res/layout/reader_activity.xml index dd86c8046e..6c653420be 100644 --- a/app/src/main/res/layout/reader_activity.xml +++ b/app/src/main/res/layout/reader_activity.xml @@ -1,7 +1,9 @@ @@ -25,7 +27,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="bottom|center_horizontal" - android:layout_marginBottom="8dp" android:padding="4dp" android:textStyle="bold" /> @@ -35,31 +36,57 @@ android:layout_height="match_parent" android:visibility="gone" /> + + + android:visibility="gone" > + android:layout_height="wrap_content"> + app:titleTextColor="?actionBarTintColor" + app:subtitleTextColor="@color/tint_color_secondary" + android:background="@drawable/reader_toolbar_ripple" /> - + + + - + + + @@ -53,13 +53,13 @@ android:ellipsize="end" android:maxLines="1" app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@id/bookmark_layout" + app:layout_constraintEnd_toStartOf="@id/bookmark_button" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/chapter_scanlator" + app:layout_constraintTop_toBottomOf="@+id/chapter_subtitle" tools:text="English" /> + app:tint="?android:attr/textColorHint" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/reader_chapters_sheet.xml b/app/src/main/res/layout/reader_chapters_sheet.xml index 9570141866..e311d4b8f1 100644 --- a/app/src/main/res/layout/reader_chapters_sheet.xml +++ b/app/src/main/res/layout/reader_chapters_sheet.xml @@ -6,9 +6,8 @@ android:layout_width="match_parent" android:layout_height="300dp" android:background="@drawable/bottom_sheet_rounded_background" - android:backgroundTint="?colorSecondary" android:orientation="vertical" - app:behavior_peekHeight="?attr/actionBarSize" + app:behavior_peekHeight="60dp" app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> + app:layout_constraintTop_toTopOf="parent" + app:srcCompat="@drawable/ic_format_list_numbered_24dp" + app:tint="?actionBarTintColor" /> + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@id/webview_button" + app:layout_constraintHorizontal_chainStyle="spread" + app:layout_constraintStart_toEndOf="@id/chapters_button" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintWidth_max="wrap" + app:srcCompat="@drawable/ic_view_comments_24p" + app:tint="?actionBarTintColor" /> + android:tooltipText="@string/open_in_webview" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@id/reading_mode" + app:layout_constraintHorizontal_chainStyle="spread" + app:layout_constraintStart_toEndOf="@id/comments_button" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintWidth_max="wrap" + app:srcCompat="@drawable/ic_open_in_webview_24dp" + app:tint="?actionBarTintColor" /> + + android:tooltipText="@string/rotation" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@id/crop_borders_sheet_button" + app:layout_constraintHorizontal_chainStyle="spread" + app:layout_constraintStart_toEndOf="@id/reading_mode" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintWidth_max="wrap" + app:srcCompat="@drawable/ic_screen_rotation_24dp" + app:tint="?actionBarTintColor" + tools:visibility="visible" /> + + + + + + - + android:background="@drawable/bottom_sheet_rounded_background" + android:clipToPadding="false"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:paddingStart="@dimen/material_component_dialogs_padding_around_content_area" + android:paddingTop="0dp" + android:paddingEnd="@dimen/material_component_dialogs_padding_around_content_area"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/reader_color_filter_sheet.xml b/app/src/main/res/layout/reader_color_filter_sheet.xml deleted file mode 100644 index f6c40f06d4..0000000000 --- a/app/src/main/res/layout/reader_color_filter_sheet.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/reader_general_layout.xml b/app/src/main/res/layout/reader_general_layout.xml new file mode 100644 index 0000000000..df511b46ec --- /dev/null +++ b/app/src/main/res/layout/reader_general_layout.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/reader_nav.xml b/app/src/main/res/layout/reader_nav.xml index d03657a54a..546de349d1 100644 --- a/app/src/main/res/layout/reader_nav.xml +++ b/app/src/main/res/layout/reader_nav.xml @@ -3,63 +3,69 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/reader_nav" + android:background="@drawable/chapter_nav" android:layout_width="match_parent" android:layout_height="wrap_content" android:paddingStart="4dp" android:paddingEnd="4dp" android:layout_gravity="top" - android:paddingBottom="12dp" + android:paddingBottom="6dp" + android:paddingTop="6dp" android:clickable="true" android:focusable="true" android:gravity="center" - android:orientation="horizontal" - app:layout_anchor="@id/chapters_bottom_sheet" - app:layout_anchorGravity="top"> + android:orientation="horizontal"> + + + app:tint="?actionBarTintColor" + app:srcCompat="@drawable/ic_skip_previous_24" /> + android:layout_height="match_parent"> + tools:text="1-2" /> @@ -70,12 +76,20 @@ android:layout_width="40dp" android:layout_height="40dp" android:layout_gravity="center" - android:layout_marginStart="8dp" - android:layout_marginEnd="50dp" - android:background="@drawable/chapter_nav" - android:contentDescription="@string/spen_next_chapter" + android:layout_marginEnd="56dp" + android:background="?selectableItemBackgroundBorderless" + android:contentDescription="@string/next_chapter" android:padding="@dimen/material_layout_keylines_screen_edge_margin" - android:tint="@color/textColorPrimary" - app:srcCompat="@drawable/ic_arrow_forward_24dp" /> + app:tint="?actionBarTintColor" + app:srcCompat="@drawable/ic_skip_next_24" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/reader_paged_layout.xml b/app/src/main/res/layout/reader_paged_layout.xml new file mode 100644 index 0000000000..90321be092 --- /dev/null +++ b/app/src/main/res/layout/reader_paged_layout.xml @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/reader_settings_sheet.xml b/app/src/main/res/layout/reader_settings_sheet.xml deleted file mode 100644 index 86888697ce..0000000000 --- a/app/src/main/res/layout/reader_settings_sheet.xml +++ /dev/null @@ -1,328 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/recent_chapters_controller.xml b/app/src/main/res/layout/recent_chapters_controller.xml deleted file mode 100644 index 6f90c617b5..0000000000 --- a/app/src/main/res/layout/recent_chapters_controller.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/recent_chapters_item.xml b/app/src/main/res/layout/recent_chapters_item.xml deleted file mode 100644 index ab8683af7b..0000000000 --- a/app/src/main/res/layout/recent_chapters_item.xml +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/recent_manga_item.xml b/app/src/main/res/layout/recent_manga_item.xml index 33b048a0b2..941f59040b 100644 --- a/app/src/main/res/layout/recent_manga_item.xml +++ b/app/src/main/res/layout/recent_manga_item.xml @@ -25,7 +25,7 @@ android:layout_gravity="end|center" android:layout_marginEnd="21dp" android:src="@drawable/ic_eye_24dp" - android:tint="@color/md_white_1000" /> + app:tint="@color/md_white_1000" /> @@ -98,7 +101,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" - android:layout_marginBottom="14dp" android:ellipsize="end" android:maxLines="1" android:singleLine="true" @@ -109,7 +111,7 @@ app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toStartOf="@+id/title" app:layout_constraintTop_toBottomOf="@+id/title" - app:layout_constraintVertical_bias="0.0" + app:layout_constraintBottom_toTopOf="@id/body" tools:text="Manga title" /> + + - + app:layout_constraintTop_toBottomOf="@id/subtitle"> + + + + + + app:constraint_referenced_ids="space,card_layout" /> - - + app:layout_constraintTop_toBottomOf="@id/bottom_line" /> \ No newline at end of file diff --git a/app/src/main/res/layout/recently_read_controller.xml b/app/src/main/res/layout/recently_read_controller.xml deleted file mode 100644 index ff1df88842..0000000000 --- a/app/src/main/res/layout/recently_read_controller.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/recently_read_item.xml b/app/src/main/res/layout/recently_read_item.xml deleted file mode 100644 index 3da82ecec4..0000000000 --- a/app/src/main/res/layout/recently_read_item.xml +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - - - - - - - - - - - - -