From 4c088448c35df1d7bbbdb61c51d4b7af5d31841f Mon Sep 17 00:00:00 2001 From: Kudomaga <63206378+HapeLee@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:28:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(reader):=20=E8=88=B9=E6=96=B0=E7=9A=84?= =?UTF-8?q?=E5=9F=BA=E4=BA=8E=20Compose=20=E5=AE=9E=E7=8E=B0=E7=9A=84?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E8=8F=9C=E5=8D=95=E7=95=8C=E9=9D=A2=E4=B8=8E?= =?UTF-8?q?=E5=90=84=E9=A1=B9=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 初始版本 * refactor(reader): MVI architecture + menu settings overhaul MVI/UDF Architecture: - Add ReadBookContract: UiState, Intent, Effect, Sheet, Dialog, ConfigUpdate - Refactor ReadBookViewModel with onIntent() entry point - Add ReadBookScreen/ReadBookRouteScreen stateless composables - Add ReadBookColorTheme for reader-specific theme override - Add ReadConfigContract for read config screen MVI ReadStyleSheet Decomposition: - Split ReadStyleSheet into SystemMenuPage, TextTitleSheet, HeaderFooterPage, TitleBarIconSheet, GlobalThemePage - Each page owns its tab state, color picker, and config mutations Data Layer (DataStore-backed Repositories): - Add ReadSettingsRepository with ReadPreferences flow - Add MangaSettingsRepository with MangaPreferences flow - Add ReadAloudSettingsRepository for TTS settings - Add ReadStyleRepository (file I/O for read style configs) - Add ReadBookStyleConfigRepository (repository boundary) - Add ReadStyleResolver for background/theme resolution - Remove legacy ReadTipConfig (merged into ReadBookConfig) - Register all new repositories in Koin appModule Menu Settings: - Add unified border settings (width, color, colorNight) for both top and bottom bars with drawWithCache + drawPath - Add ColorModePill with light/dark toggle and '+' icon for no-color state in TinySettingItems - Add TinyClearColorModeSettingItem for border color config - Add menu color customization (bg, accent, container) with day/night variants and seed color mode - Add bottom bar layout settings (corner radius, margins) - Add custom icon support for menu buttons and title bar icons - Add title bar icon position setting Config Pipeline: - Consolidate all ReadBookConfig mutations into typed ConfigUpdate sealed interface with legacy UP_CONFIG code mapping - Add resolvedMenuBorderColor (auto day/night switching) - Add 20 new PreferKey constants for menu settings - Update manga config dialogs to use MangaSettingsRepository - Update ReadConfigViewModel with DataStore flow observation * feat(reader): add liquid glass menu bars and floating bottom bar - Integrate com.kyant.backdrop 2.0.0 for liquid glass blur/vibrancy effects on reading menu top bar and bottom bar (Android 13+) - Add floating bottom bar toggle replacing manual margin sliders - Extract ReadMenuConfig data class for proper MVI state management of all menu configuration properties - Enable edge-to-edge in BaseComposeActivity, remove manual navigation bar color management across base classes and controller - Fix fullScreen() to use setDecorFitsSystemWindows(false) * Refactor series button rendering * 实现液态玻璃 * 优化液态玻璃 * @ fix(reader): fix MVI behavior issues and improve sheet lifecycle Behavior fixes: - Fix AddSourceAsNewBook empty implementation — wire intent to addToBookshelf - Fix content edit loading stuck on null chapter/content — use onFinally - Fix cannot save empty content — remove isEmpty guard, use title as loaded proxy - Fix bookmark not opening — use openReadMenuRoute instead of dead activeSheet path - Fix chapter source sheet persists — animate dismiss via show param before removal - Fix chapter source reopens to TOC — reset showToc in initData - Fix SaveChapterContent saves to wrong chapter — carry chapterIndex in intent - Fix chapter source search runs after dismiss — cancel searchJob in dispose - Fix "go to background" button in ReadAloud — use CloseReadBook intent Architecture improvements: - ReadMenuConfig uses ImmutableMap for Compose stability - Fix stale remember in ReadBookMenuBar title bar icons - Move icon picker file IO from composable to ViewModel - Route launcher results through MVI intents instead of direct calls - Fix ChangeChapterSource effect collector key — use viewModel not Unit - Make ReadAloudConfigSheet UDF-compliant — stateless with intents - Remove main menu buttons from AutoRead and ReadAloud sheets @ * @ refactor(reader): use Material Icons and ConfirmDismissButtonsRow - TitleBarIconSheet: replace drawable resources with Icons library, use ConfirmDismissButtonsRow for save/cancel buttons - SystemMenuPage MenuCustomIconSheet: same icon and button treatment - ReadBookMenuBar: update ToolButtonDef/TitleBarIconDef to use ImageVector instead of drawable resource Int - Use AutoMirrored variants for List and HelpOutline icons @ * feat: 优化液态玻璃 * feat: 优化液态玻璃 * fix(AppLogSheet): 从本地文件读取日志,替代内存列表 * @ feat(reader): highlight rules, theme refactor & MVI improvements - Add HighlightRule entity, DAO, and config/edit sheets - Add ThemeConfigStore, remove OldThemeConfig - Refactor ReadBookContract/ViewModel/Screen for deeper MVI - Add CharStyle for text rendering, refactor TextLine/TextChapterLayout - Add SectionTitle, TinySettingItems UI components - Remove legacy XML layouts (regex_color_config) - Update database schema to v90 - Various sheet and widget refinements @ * @ fix: address PR review feedback - ReadStyleRepository: fix copy-paste bug (bgStrNight→bgStr) + add bgTypeNight import - BgImageSpan: replace Bitmap.createScaledBitmap with BitmapShader matrix scaling - HighlightRule: use UUID.randomUUID() instead of System.currentTimeMillis() - ChangeSourceSearchUseCase: guard against empty chapters list @ * @fix: remove residual regexColorRules references after merge - ChangeSourceSearchUseCase: adapt filter lambda to new 3-param signature - TextChapterLayout: remove globalRegexResult field, preApplyRegexColorRules call, and the entire regexColorRules fullTextBuilder block @ * fix: 图标间距 --- .claude/settings.local.json | 8 +- .../io.legado.app.data.AppDatabase/90.json | 2415 ++++++++++++++++ app/src/main/AndroidManifest.xml | 16 - app/src/main/java/io/legado/app/App.kt | 12 +- .../main/java/io/legado/app/api/ShortCuts.kt | 5 +- .../java/io/legado/app/base/BaseActivity.kt | 8 +- .../app/base/BaseBottomSheetDialogFragment.kt | 5 +- .../io/legado/app/base/BaseComposeActivity.kt | 10 +- .../java/io/legado/app/constant/PreferKey.kt | 44 + .../java/io/legado/app/data/AppDatabase.kt | 11 +- .../legado/app/data/dao/HighlightRuleDao.kt | 41 + .../legado/app/data/entities/HighlightRule.kt | 94 + .../repository/MangaSettingsRepository.kt | 182 ++ .../repository/ReadAloudSettingsRepository.kt | 121 + .../ReadBookStyleConfigRepository.kt | 61 + .../data/repository/ReadSettingsRepository.kt | 537 ++++ .../data/repository/ReadStyleRepository.kt | 255 ++ .../app/data/repository/SettingsRepository.kt | 11 + .../io/legado/app/di/appDatabaseModule.kt | 1 + .../main/java/io/legado/app/di/appModule.kt | 19 +- .../usecase/ChangeSourceSearchUseCase.kt | 276 ++ .../usecase/GetChapterContentUseCase.kt | 48 + .../java/io/legado/app/help/DefaultData.kt | 8 +- .../java/io/legado/app/help/JsExtensions.kt | 6 +- .../io/legado/app/help/config/AppConfig.kt | 187 +- .../legado/app/help/config/ReadBookConfig.kt | 1024 ++++--- .../app/help/config/ReadStyleResolver.kt | 141 + .../legado/app/help/config/ReadTipConfig.kt | 120 - ...{OldThemeConfig.kt => ThemeConfigStore.kt} | 56 +- .../java/io/legado/app/help/storage/Backup.kt | 8 +- .../io/legado/app/help/storage/Restore.kt | 12 +- .../app/lib/prefs/ThemeModePreference.kt | 4 +- .../java/io/legado/app/model/BookCover.kt | 3 - .../app/receiver/MediaButtonReceiver.kt | 4 +- .../app/receiver/NetworkChangedListener.kt | 14 +- .../app/service/BaseReadAloudService.kt | 7 +- .../app/ui/animation/InteractiveHighlight.kt | 2 + .../app/ui/association/ImportThemeDialog.kt | 6 +- .../ui/association/ImportThemeViewModel.kt | 14 +- .../ChangeBookSourceComposeViewModel.kt | 256 +- .../changesource/ChangeBookSourceDialog.kt | 6 +- .../ChangeChapterSourceAdapter.kt | 164 -- .../ChangeChapterSourceContract.kt | 86 + .../changesource/ChangeChapterSourceDialog.kt | 420 --- .../ChangeChapterSourceViewModel.kt | 407 ++- .../changesource/ChangeChapterTocAdapter.kt | 74 - .../app/ui/book/info/BookInfoActivity.kt | 10 + .../app/ui/book/info/BookInfoContract.kt | 2 + .../app/ui/book/info/BookInfoRouteScreen.kt | 12 +- .../legado/app/ui/book/info/BookInfoSheets.kt | 2 +- .../app/ui/book/info/BookInfoViewModel.kt | 7 +- .../app/ui/book/manga/ReadMangaActivity.kt | 33 +- .../app/ui/book/manga/ReadMangaViewModel.kt | 89 +- .../config/MangaClickActionConfigDialog.kt | 54 +- .../manga/config/MangaColorFilterDialog.kt | 12 +- .../manga/config/MangaFooterSettingDialog.kt | 11 +- .../app/ui/book/read/BaseReadBookActivity.kt | 432 --- .../app/ui/book/read/ContentEditDialog.kt | 176 -- .../ui/book/read/EffectiveReplacesDialog.kt | 116 - .../io/legado/app/ui/book/read/MangaMenu.kt | 17 - .../app/ui/book/read/ReadBookActivity.kt | 2037 -------------- .../ui/book/read/ReadBookColorPickerIds.kt | 17 + .../app/ui/book/read/ReadBookColorTheme.kt | 298 ++ .../app/ui/book/read/ReadBookContract.kt | 1018 +++++++ .../app/ui/book/read/ReadBookController.kt | 1049 +++++++ .../app/ui/book/read/ReadBookMenuBar.kt | 2310 +++++++++++++++ .../app/ui/book/read/ReadBookRouteScreen.kt | 473 ++++ .../legado/app/ui/book/read/ReadBookScreen.kt | 414 +++ .../app/ui/book/read/ReadBookSearchBar.kt | 246 ++ .../app/ui/book/read/ReadBookViewModel.kt | 2471 ++++++++++++++++- .../io/legado/app/ui/book/read/ReadMenu.kt | 948 ------- .../io/legado/app/ui/book/read/SearchMenu.kt | 208 -- .../legado/app/ui/book/read/TextActionMenu.kt | 17 +- .../app/ui/book/read/config/AutoReadDialog.kt | 100 - .../app/ui/book/read/config/BgAdapter.kt | 48 - .../app/ui/book/read/config/BgImageSpan.kt | 161 ++ .../ui/book/read/config/BgTextConfigDialog.kt | 368 --- .../read/config/ClickActionConfigDialog.kt | 150 - .../ui/book/read/config/DashUnderlineSpan.kt | 64 + .../book/read/config/DoubleUnderlineSpan.kt | 66 + .../ui/book/read/config/FontConfigDialog.kt | 206 -- .../ui/book/read/config/FontSelectDialog.kt | 258 -- .../ui/book/read/config/HighlightRuleStore.kt | 356 +++ .../ui/book/read/config/HighlightStyleSpan.kt | 23 + .../ui/book/read/config/HttpTtsEditDialog.kt | 135 - .../book/read/config/HttpTtsEditViewModel.kt | 76 - .../ui/book/read/config/InfoConfigDialog.kt | 284 -- .../ui/book/read/config/MoreConfigDialog.kt | 188 -- .../book/read/config/PaddingConfigDialog.kt | 119 - .../app/ui/book/read/config/PageKeyDialog.kt | 72 - .../book/read/config/ReadAloudConfigDialog.kt | 211 -- .../ui/book/read/config/ReadAloudDialog.kt | 261 -- .../ui/book/read/config/ReadStyleDialog.kt | 246 -- .../read/config/RegexColorConfigDialog.kt | 204 -- .../ui/book/read/config/ShadowSetDialog.kt | 66 - .../ui/book/read/config/SolidUnderlineSpan.kt | 62 + .../ui/book/read/config/SpeakEngineDialog.kt | 299 -- .../book/read/config/SpeakEngineViewModel.kt | 23 - .../app/ui/book/read/config/SvgPathParser.kt | 410 +++ .../ui/book/read/config/SvgUnderlineSpan.kt | 75 + .../ui/book/read/config/TipConfigDialog.kt | 283 -- .../read/config/ToolButtonConfigDialog.kt | 221 -- .../book/read/config/UnderlineConfigDialog.kt | 107 - .../ui/book/read/config/WaveUnderlineSpan.kt | 81 + .../legado/app/ui/book/read/page/AutoPager.kt | 2 + .../app/ui/book/read/page/ContentTextView.kt | 52 +- .../legado/app/ui/book/read/page/PageView.kt | 121 +- .../legado/app/ui/book/read/page/ReadView.kt | 23 +- .../ui/book/read/page/entities/TextLine.kt | 467 +++- .../page/entities/column/TextBaseColumn.kt | 10 + .../read/page/entities/column/TextColumn.kt | 62 +- .../page/entities/column/TextHtmlColumn.kt | 13 +- .../ui/book/read/page/provider/CharStyle.kt | 20 + .../read/page/provider/TextChapterLayout.kt | 259 +- .../app/ui/book/read/sheet/AutoReadSheet.kt | 172 ++ .../ui/book/read/sheet/BgTextConfigSheet.kt | 285 ++ .../read/sheet/ChangeChapterSourceSheet.kt | 322 +++ .../ui/book/read/sheet/CharsetConfigSheet.kt | 67 + .../book/read/sheet/ClickActionConfigSheet.kt | 248 ++ .../ui/book/read/sheet/ContentEditSheet.kt | 115 + .../app/ui/book/read/sheet/DictSheet.kt | 170 ++ .../app/ui/book/read/sheet/DownloadSheet.kt | 75 + .../book/read/sheet/EffectiveReplacesSheet.kt | 125 + .../app/ui/book/read/sheet/FontSelectSheet.kt | 54 + .../app/ui/book/read/sheet/GlobalThemePage.kt | 436 +++ .../ui/book/read/sheet/HeaderFooterPage.kt | 410 +++ .../read/sheet/HighlightRuleConfigSheet.kt | 195 ++ .../book/read/sheet/HighlightRuleEditSheet.kt | 513 ++++ .../app/ui/book/read/sheet/MoreConfigSheet.kt | 382 +++ .../ui/book/read/sheet/PaddingConfigSheet.kt | 205 ++ .../ui/book/read/sheet/PageAnimConfigSheet.kt | 45 + .../ui/book/read/sheet/PageKeyConfigSheet.kt | 140 + .../app/ui/book/read/sheet/PhotoSheet.kt | 105 + .../book/read/sheet/ReadAloudConfigSheet.kt | 186 ++ .../app/ui/book/read/sheet/ReadAloudSheet.kt | 239 ++ .../app/ui/book/read/sheet/ReadStyleSheet.kt | 161 ++ .../app/ui/book/read/sheet/ShadowSetSheet.kt | 98 + .../book/read/sheet/SimulatedReadingSheet.kt | 153 + .../app/ui/book/read/sheet/SystemMenuPage.kt | 720 +++++ .../app/ui/book/read/sheet/TextTitleSheet.kt | 532 ++++ .../ui/book/read/sheet/TitleBarIconSheet.kt | 318 +++ .../book/read/sheet/ToolButtonConfigSheet.kt | 22 + .../book/read/sheet/UnderlineConfigSheet.kt | 161 ++ .../io/legado/app/ui/book/toc/TocViewModel.kt | 43 +- .../coverConfig/CoverConfigViewModel.kt | 7 +- .../app/ui/config/otherConfig/OtherConfig.kt | 20 - .../config/otherConfig/OtherConfigScreen.kt | 19 +- .../otherConfig/OtherConfigViewModel.kt | 47 +- .../app/ui/config/readConfig/PageKeySheet.kt | 53 +- .../config/readConfig/ReadConfigContract.kt | 77 + .../ui/config/readConfig/ReadConfigScreen.kt | 208 +- .../config/readConfig/ReadConfigViewModel.kt | 253 +- .../app/ui/config/themeConfig/ThemeConfig.kt | 9 + .../config/themeConfig/ThemeConfigScreen.kt | 109 +- .../io/legado/app/ui/main/MainActivity.kt | 73 +- .../java/io/legado/app/ui/main/MainIntent.kt | 19 + .../io/legado/app/ui/main/MainNavGraph.kt | 100 + .../java/io/legado/app/ui/main/MainNavKey.kt | 9 + .../io/legado/app/ui/main/MainNavigator.kt | 12 +- .../java/io/legado/app/ui/theme/AppTheme.kt | 27 +- .../io/legado/app/ui/theme/LegadoTheme.kt | 7 +- .../io/legado/app/ui/theme/ThemeComponents.kt | 2 +- .../io/legado/app/ui/theme/ThemeOverride.kt | 18 +- .../ui/widget/components/AccentColorButton.kt | 45 + .../ui/widget/components/FontSelectGrid.kt | 187 ++ .../ui/widget/components/FontSelectSheet.kt | 84 + .../app/ui/widget/components/IconSwitch.kt | 36 + .../app/ui/widget/components/SectionTitle.kt | 22 + .../app/ui/widget/components/ValueStepper.kt | 13 +- .../widget/components/alert/AppAlertDialog.kt | 2 +- .../components/bookmark/BookmarkEditSheet.kt | 112 +- .../button/series/MediumAnimatedButton.kt | 184 +- .../button/series/MediumOutlinedButton.kt | 83 +- .../button/series/MediumPlainButton.kt | 123 +- .../button/series/MediumToggleButton.kt | 66 +- .../button/series/MediumTonalButton.kt | 79 +- .../button/series/SeriesIconButton.kt | 315 +++ .../button/series/SmallAnimatedButton.kt | 193 +- .../button/series/SmallOutlinedButton.kt | 92 +- .../button/series/SmallPlainButton.kt | 142 +- .../button/series/SmallToggleButton.kt | 88 +- .../button/series/SmallTonalButton.kt | 99 +- .../components/dialog/ColorPickerSheet.kt | 2 +- .../ui/widget/components/log/AppLogSheet.kt | 140 +- .../settingItem/TinySettingItems.kt | 739 +++++ .../ui/widget/components/tabRow/CardTabRow.kt | 76 + .../widget/components/topbar/TopBarButton.kt | 15 +- .../io/legado/app/utils/ActivityExtensions.kt | 39 +- .../io/legado/app/utils/ContextExtensions.kt | 32 +- .../io/legado/app/utils/FragmentExtensions.kt | 17 +- .../main/res/layout-land/view_read_menu.xml | 223 -- .../main/res/layout/activity_book_read.xml | 61 - .../layout/dialog_chapter_change_source.xml | 119 - .../main/res/layout/dialog_content_edit.xml | 58 - .../main/res/layout/dialog_font_select.xml | 83 - .../main/res/layout/dialog_http_tts_edit.xml | 157 -- app/src/main/res/layout/dialog_page_key.xml | 88 - app/src/main/res/layout/dialog_read_aloud.xml | 393 --- .../res/layout/dialog_regex_color_config.xml | 51 - app/src/main/res/layout/dialog_shadow_set.xml | 37 - app/src/main/res/layout/item_bg_image.xml | 36 - app/src/main/res/layout/item_font.xml | 24 - .../main/res/layout/item_regex_color_rule.xml | 75 - app/src/main/res/layout/view_read_menu.xml | 191 -- app/src/main/res/layout/view_search_menu.xml | 157 -- app/src/main/res/values-zh-rCN/strings.xml | 111 +- app/src/main/res/values/ids.xml | 7 +- app/src/main/res/values/strings.xml | 107 + gradle/libs.versions.toml | 2 +- 209 files changed, 25994 insertions(+), 12717 deletions(-) create mode 100644 app/schemas/io.legado.app.data.AppDatabase/90.json create mode 100644 app/src/main/java/io/legado/app/data/dao/HighlightRuleDao.kt create mode 100644 app/src/main/java/io/legado/app/data/entities/HighlightRule.kt create mode 100644 app/src/main/java/io/legado/app/data/repository/MangaSettingsRepository.kt create mode 100644 app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt create mode 100644 app/src/main/java/io/legado/app/data/repository/ReadBookStyleConfigRepository.kt create mode 100644 app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt create mode 100644 app/src/main/java/io/legado/app/data/repository/ReadStyleRepository.kt create mode 100644 app/src/main/java/io/legado/app/domain/usecase/ChangeSourceSearchUseCase.kt create mode 100644 app/src/main/java/io/legado/app/domain/usecase/GetChapterContentUseCase.kt create mode 100644 app/src/main/java/io/legado/app/help/config/ReadStyleResolver.kt delete mode 100644 app/src/main/java/io/legado/app/help/config/ReadTipConfig.kt rename app/src/main/java/io/legado/app/help/config/{OldThemeConfig.kt => ThemeConfigStore.kt} (84%) delete mode 100644 app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceAdapter.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceContract.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceDialog.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterTocAdapter.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/BaseReadBookActivity.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/ContentEditDialog.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/EffectiveReplacesDialog.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/ReadBookColorPickerIds.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/ReadBookColorTheme.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/ReadBookSearchBar.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/SearchMenu.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/AutoReadDialog.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/BgAdapter.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/BgImageSpan.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/BgTextConfigDialog.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/ClickActionConfigDialog.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/DashUnderlineSpan.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/DoubleUnderlineSpan.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/FontConfigDialog.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/FontSelectDialog.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/HighlightRuleStore.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/HighlightStyleSpan.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/HttpTtsEditDialog.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/HttpTtsEditViewModel.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/InfoConfigDialog.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/MoreConfigDialog.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/PaddingConfigDialog.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/PageKeyDialog.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudConfigDialog.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudDialog.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/ReadStyleDialog.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/RegexColorConfigDialog.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/ShadowSetDialog.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/SolidUnderlineSpan.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/SpeakEngineDialog.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/SpeakEngineViewModel.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/SvgPathParser.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/SvgUnderlineSpan.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/TipConfigDialog.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/ToolButtonConfigDialog.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/UnderlineConfigDialog.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/WaveUnderlineSpan.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/page/provider/CharStyle.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/AutoReadSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/BgTextConfigSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/ChangeChapterSourceSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/CharsetConfigSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/ClickActionConfigSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/ContentEditSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/DictSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/DownloadSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/EffectiveReplacesSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/FontSelectSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/GlobalThemePage.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/HeaderFooterPage.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleConfigSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleEditSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/MoreConfigSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/PaddingConfigSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/PageAnimConfigSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/PageKeyConfigSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/PhotoSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudConfigSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/ReadStyleSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/ShadowSetSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/SimulatedReadingSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/SystemMenuPage.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/TextTitleSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/TitleBarIconSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/ToolButtonConfigSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/sheet/UnderlineConfigSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigContract.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/AccentColorButton.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/FontSelectGrid.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/FontSelectSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/SectionTitle.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/button/series/SeriesIconButton.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/settingItem/TinySettingItems.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/tabRow/CardTabRow.kt delete mode 100644 app/src/main/res/layout-land/view_read_menu.xml delete mode 100644 app/src/main/res/layout/activity_book_read.xml delete mode 100644 app/src/main/res/layout/dialog_chapter_change_source.xml delete mode 100644 app/src/main/res/layout/dialog_content_edit.xml delete mode 100644 app/src/main/res/layout/dialog_font_select.xml delete mode 100644 app/src/main/res/layout/dialog_http_tts_edit.xml delete mode 100644 app/src/main/res/layout/dialog_page_key.xml delete mode 100644 app/src/main/res/layout/dialog_read_aloud.xml delete mode 100644 app/src/main/res/layout/dialog_regex_color_config.xml delete mode 100644 app/src/main/res/layout/dialog_shadow_set.xml delete mode 100644 app/src/main/res/layout/item_bg_image.xml delete mode 100644 app/src/main/res/layout/item_font.xml delete mode 100644 app/src/main/res/layout/item_regex_color_rule.xml delete mode 100644 app/src/main/res/layout/view_read_menu.xml delete mode 100644 app/src/main/res/layout/view_search_menu.xml diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 113812f8b..1e7cb12e8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -109,10 +109,10 @@ "Bash(node *)", "Bash(Select-String -Pattern \"BUILD|error|Error\")", "Bash(Select-Object -First 10)", - "Bash(git push *)", - "Bash(gh run *)", - "WebFetch(domain:ktor.io)", - "WebFetch(domain:api.ktor.io)" + "Bash(Select-Object -Last 10)", + "Bash(dir /s /b \"%USERPROFILE%\\\\.gradle\\\\caches\\\\*kyant*\")", + "Bash(Select-String -Pattern \"\\(BUILD|error:|FAILED\\)\")", + "PowerShell(grep *)" ] } } diff --git a/app/schemas/io.legado.app.data.AppDatabase/90.json b/app/schemas/io.legado.app.data.AppDatabase/90.json new file mode 100644 index 000000000..b9b87d417 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/90.json @@ -0,0 +1,2415 @@ +{ + "formatVersion": 1, + "database": { + "version": 90, + "identityHash": "cfd034a0567781ef4e9a7e8e3d695a9c", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `remark` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, `syncTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'loc_book'" + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT" + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT" + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT" + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT" + }, + { + "fieldPath": "remark", + "columnName": "remark", + "affinity": "TEXT" + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT" + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT" + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT" + }, + { + "fieldPath": "syncTime", + "columnName": "syncTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "bookUrl" + ] + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": false, + "columnNames": [ + "name", + "author" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + }, + { + "name": "index_books_durChapterTime", + "unique": false, + "columnNames": [ + "durChapterTime" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_books_durChapterTime` ON `${TABLE_NAME}` (`durChapterTime`)" + } + ] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `cover` TEXT, `order` INTEGER NOT NULL, `enableRefresh` INTEGER NOT NULL DEFAULT 1, `show` INTEGER NOT NULL DEFAULT 1, `bookSort` INTEGER NOT NULL DEFAULT -1, `isPrivate` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cover", + "columnName": "cover", + "affinity": "TEXT" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enableRefresh", + "columnName": "enableRefresh", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "bookSort", + "columnName": "bookSort", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "isPrivate", + "columnName": "isPrivate", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + } + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceUrl` TEXT NOT NULL, `bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL DEFAULT 0, `enabled` INTEGER NOT NULL DEFAULT 1, `enabledExplore` INTEGER NOT NULL DEFAULT 1, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `coverDecodeJs` TEXT, `bookSourceComment` TEXT, `variableComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `exploreScreen` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, `ruleReview` TEXT, `eventListener` INTEGER NOT NULL DEFAULT 0, `customButton` INTEGER NOT NULL DEFAULT 0, `homepageModules` TEXT, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT" + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT" + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "jsLib", + "columnName": "jsLib", + "affinity": "TEXT" + }, + { + "fieldPath": "enabledCookieJar", + "columnName": "enabledCookieJar", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT" + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT" + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT" + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT" + }, + { + "fieldPath": "coverDecodeJs", + "columnName": "coverDecodeJs", + "affinity": "TEXT" + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT" + }, + { + "fieldPath": "variableComment", + "columnName": "variableComment", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondTime", + "columnName": "respondTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "exploreScreen", + "columnName": "exploreScreen", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT" + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleReview", + "columnName": "ruleReview", + "affinity": "TEXT" + }, + { + "fieldPath": "eventListener", + "columnName": "eventListener", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "customButton", + "columnName": "customButton", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "homepageModules", + "columnName": "homepageModules", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "bookSourceUrl" + ] + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `isVolume` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `isVip` INTEGER NOT NULL, `isPay` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `wordCount` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, `reviewImg` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isVolume", + "columnName": "isVolume", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isVip", + "columnName": "isVip", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPay", + "columnName": "isPay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT" + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT" + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER" + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER" + }, + { + "fieldPath": "startFragmentId", + "columnName": "startFragmentId", + "affinity": "TEXT" + }, + { + "fieldPath": "endFragmentId", + "columnName": "endFragmentId", + "affinity": "TEXT" + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT" + }, + { + "fieldPath": "reviewImg", + "columnName": "reviewImg", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "url", + "bookUrl" + ] + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL DEFAULT '', `group` TEXT, `pattern` TEXT NOT NULL DEFAULT '', `replacement` TEXT NOT NULL DEFAULT '', `scope` TEXT, `scopeTitle` INTEGER NOT NULL DEFAULT 0, `scopeContent` INTEGER NOT NULL DEFAULT 1, `excludeScope` TEXT, `isEnabled` INTEGER NOT NULL DEFAULT 1, `isRegex` INTEGER NOT NULL DEFAULT 1, `timeoutMillisecond` INTEGER NOT NULL DEFAULT 3000, `sortOrder` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT" + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT" + }, + { + "fieldPath": "scopeTitle", + "columnName": "scopeTitle", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "scopeContent", + "columnName": "scopeContent", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "excludeScope", + "columnName": "excludeScope", + "affinity": "TEXT" + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "timeoutMillisecond", + "columnName": "timeoutMillisecond", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "3000" + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, `chapterWordCountText` TEXT, `chapterWordCount` INTEGER NOT NULL DEFAULT -1, `respondTime` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT" + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT" + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT" + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT" + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterWordCountText", + "columnName": "chapterWordCountText", + "affinity": "TEXT" + }, + { + "fieldPath": "chapterWordCount", + "columnName": "chapterWordCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "respondTime", + "columnName": "respondTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "bookUrl" + ] + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "word" + ] + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "url" + ] + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `variableComment` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `coverDecodeJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL DEFAULT 0, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `contentWhitelist` TEXT, `contentBlacklist` TEXT, `shouldOverrideUrlLoading` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL DEFAULT 1, `loadWithBaseUrl` INTEGER NOT NULL DEFAULT 1, `injectJs` TEXT, `preloadJs` TEXT, `startHtml` TEXT, `startStyle` TEXT, `startJs` TEXT, `showWebLog` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, `customOrder` INTEGER NOT NULL DEFAULT 0, `type` INTEGER NOT NULL DEFAULT 0, `preload` INTEGER NOT NULL DEFAULT 0, `cacheFirst` INTEGER NOT NULL DEFAULT 0, `searchUrl` TEXT, `redirectPolicy` TEXT NOT NULL DEFAULT 'ASK_CROSS_ORIGIN', PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceComment", + "columnName": "sourceComment", + "affinity": "TEXT" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variableComment", + "columnName": "variableComment", + "affinity": "TEXT" + }, + { + "fieldPath": "jsLib", + "columnName": "jsLib", + "affinity": "TEXT" + }, + { + "fieldPath": "enabledCookieJar", + "columnName": "enabledCookieJar", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT" + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT" + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT" + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT" + }, + { + "fieldPath": "coverDecodeJs", + "columnName": "coverDecodeJs", + "affinity": "TEXT" + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT" + }, + { + "fieldPath": "contentWhitelist", + "columnName": "contentWhitelist", + "affinity": "TEXT" + }, + { + "fieldPath": "contentBlacklist", + "columnName": "contentBlacklist", + "affinity": "TEXT" + }, + { + "fieldPath": "shouldOverrideUrlLoading", + "columnName": "shouldOverrideUrlLoading", + "affinity": "TEXT" + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT" + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "injectJs", + "columnName": "injectJs", + "affinity": "TEXT" + }, + { + "fieldPath": "preloadJs", + "columnName": "preloadJs", + "affinity": "TEXT" + }, + { + "fieldPath": "startHtml", + "columnName": "startHtml", + "affinity": "TEXT" + }, + { + "fieldPath": "startStyle", + "columnName": "startStyle", + "affinity": "TEXT" + }, + { + "fieldPath": "startJs", + "columnName": "startJs", + "affinity": "TEXT" + }, + { + "fieldPath": "showWebLog", + "columnName": "showWebLog", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "preload", + "columnName": "preload", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "cacheFirst", + "columnName": "cacheFirst", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "redirectPolicy", + "columnName": "redirectPolicy", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ASK_CROSS_ORIGIN'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceUrl" + ] + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "time" + ] + }, + "indices": [ + { + "name": "index_bookmarks_bookName_bookAuthor", + "unique": false, + "columnNames": [ + "bookName", + "bookAuthor" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" + } + ] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `read` INTEGER NOT NULL, `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`, `sort`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT" + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'默认分组'" + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "durPos", + "columnName": "durPos", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "origin", + "link", + "sort" + ] + } + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `title` TEXT, `readTime` INTEGER, `read` INTEGER NOT NULL, `origin` TEXT NOT NULL DEFAULT '', `sort` TEXT NOT NULL DEFAULT '', `image` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, `pubDate` TEXT, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "durPos", + "columnName": "durPos", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "record" + ] + }, + "indices": [ + { + "name": "index_rssReadRecords_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssReadRecords_origin` ON `${TABLE_NAME}` (`origin`)" + } + ] + }, + { + "tableName": "readRecordDetail", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `date` TEXT NOT NULL, `readTime` INTEGER NOT NULL DEFAULT 0, `readWords` INTEGER NOT NULL DEFAULT 0, `firstReadTime` INTEGER NOT NULL DEFAULT 0, `lastReadTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`, `date`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "readWords", + "columnName": "readWords", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "firstReadTime", + "columnName": "firstReadTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastReadTime", + "columnName": "lastReadTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "bookName", + "bookAuthor", + "date" + ] + } + }, + { + "tableName": "readRecordSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `startTime` INTEGER NOT NULL, `endTime` INTEGER NOT NULL, `words` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTime", + "columnName": "endTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "words", + "columnName": "words", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT" + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'默认分组'" + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "durPos", + "columnName": "durPos", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "origin", + "link" + ] + } + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `example` TEXT, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "example", + "columnName": "example", + "affinity": "TEXT" + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `readTime` INTEGER NOT NULL DEFAULT 0, `lastRead` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastRead", + "columnName": "lastRead", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "bookName", + "bookAuthor" + ] + } + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `contentType` TEXT, `concurrentRate` TEXT DEFAULT '0', `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `loginCheckJs` TEXT, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT" + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT", + "defaultValue": "'0'" + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT" + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT" + }, + { + "fieldPath": "jsLib", + "columnName": "jsLib", + "affinity": "TEXT" + }, + { + "fieldPath": "enabledCookieJar", + "columnName": "enabledCookieJar", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "dictRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `urlRule` TEXT NOT NULL, `showRule` TEXT NOT NULL, `enabled` INTEGER NOT NULL DEFAULT 1, `sortNumber` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "urlRule", + "columnName": "urlRule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "showRule", + "columnName": "showRule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "sortNumber", + "columnName": "sortNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "keyboardAssists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` INTEGER NOT NULL DEFAULT 0, `key` TEXT NOT NULL DEFAULT '', `value` TEXT NOT NULL DEFAULT '', `serialNo` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`type`, `key`))", + "fields": [ + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "serialNo", + "columnName": "serialNo", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "type", + "key" + ] + } + }, + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `config` TEXT, `sortNumber` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT" + }, + { + "fieldPath": "sortNumber", + "columnName": "sortNumber", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "search_content_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookName` TEXT DEFAULT '', `bookAuthor` TEXT DEFAULT '', `query` TEXT NOT NULL, `time` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_search_content_history_bookName_bookAuthor_query", + "unique": true, + "columnNames": [ + "bookName", + "bookAuthor", + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_content_history_bookName_bookAuthor_query` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`, `query`)" + } + ] + }, + { + "tableName": "homepage_modules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sourceUrl` TEXT NOT NULL, `moduleKey` TEXT NOT NULL, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `args` TEXT, `layoutConfig` TEXT, `url` TEXT, `isEnabled` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `customSetId` TEXT, `isUserCreated` INTEGER NOT NULL, `customTitle` TEXT, `customSetTitle` TEXT, `sourceJsonHash` TEXT, `syncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "moduleKey", + "columnName": "moduleKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "args", + "columnName": "args", + "affinity": "TEXT" + }, + { + "fieldPath": "layoutConfig", + "columnName": "layoutConfig", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customSetId", + "columnName": "customSetId", + "affinity": "TEXT" + }, + { + "fieldPath": "isUserCreated", + "columnName": "isUserCreated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customTitle", + "columnName": "customTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "customSetTitle", + "columnName": "customSetTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceJsonHash", + "columnName": "sourceJsonHash", + "affinity": "TEXT" + }, + { + "fieldPath": "syncedAt", + "columnName": "syncedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "homepage_custom_sets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "highlightRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `pattern` TEXT NOT NULL, `sampleText` TEXT NOT NULL, `targetScope` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `position` INTEGER NOT NULL, `textColor` INTEGER, `bgColor` INTEGER, `underlineMode` INTEGER NOT NULL, `underlineColor` INTEGER, `underlineWidth` REAL NOT NULL, `underlineOffset` REAL NOT NULL, `underlineSvgPath` TEXT, `bgImage` TEXT, `bgImageFit` INTEGER NOT NULL, `bgImageScale` REAL NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sampleText", + "columnName": "sampleText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetScope", + "columnName": "targetScope", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "textColor", + "columnName": "textColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "bgColor", + "columnName": "bgColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "underlineMode", + "columnName": "underlineMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "underlineColor", + "columnName": "underlineColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "underlineWidth", + "columnName": "underlineWidth", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "underlineOffset", + "columnName": "underlineOffset", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "underlineSvgPath", + "columnName": "underlineSvgPath", + "affinity": "TEXT" + }, + { + "fieldPath": "bgImage", + "columnName": "bgImage", + "affinity": "TEXT" + }, + { + "fieldPath": "bgImageFit", + "columnName": "bgImageFit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bgImageScale", + "columnName": "bgImageScale", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "views": [ + { + "viewName": "book_sources_part", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS select bookSourceUrl, bookSourceName, bookSourceGroup, customOrder, enabled, enabledExplore, \n (loginUrl is not null and trim(loginUrl) <> '') hasLoginUrl, lastUpdateTime, respondTime, weight, \n (exploreUrl is not null and trim(exploreUrl) <> '') hasExploreUrl \n from book_sources" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cfd034a0567781ef4e9a7e8e3d695a9c')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9c6502674..5895e8ac0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -139,22 +139,6 @@ android:enableOnBackInvokedCallback="true" android:exported="false" android:windowSoftInputMode="adjustPan|stateHidden" /> - - - - - - - - - { @@ -189,6 +190,11 @@ class App : Application(), ImageLoaderFactory { // oldConfig = Configuration(newConfig) // } + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + TextLine.trimCaches(level) + } + /** * 尝试在安装了GMS的设备上(GMS或者MicroG)使用GMS内置的Conscrypt * 作为首选JCE提供程序,而使Okhttp在低版本Android上 diff --git a/app/src/main/java/io/legado/app/api/ShortCuts.kt b/app/src/main/java/io/legado/app/api/ShortCuts.kt index 0f9f6b80c..c664ed1c4 100644 --- a/app/src/main/java/io/legado/app/api/ShortCuts.kt +++ b/app/src/main/java/io/legado/app/api/ShortCuts.kt @@ -7,7 +7,6 @@ import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import io.legado.app.R import io.legado.app.receiver.SharedReceiverActivity -import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.main.MainActivity object ShortCuts { @@ -34,7 +33,9 @@ object ShortCuts { val bookShelfIntent = MainActivity.createHomeIntent(context).apply { action = Intent.ACTION_VIEW } - val readBookIntent = buildIntent(context) + val readBookIntent = MainActivity.createReadBookIntent(context).apply { + action = Intent.ACTION_VIEW + } return ShortcutInfoCompat.Builder(context, "lastRead") .setShortLabel(context.getString(R.string.last_read)) .setLongLabel(context.getString(R.string.last_read)) diff --git a/app/src/main/java/io/legado/app/base/BaseActivity.kt b/app/src/main/java/io/legado/app/base/BaseActivity.kt index bc03e5d5d..c71b07b88 100644 --- a/app/src/main/java/io/legado/app/base/BaseActivity.kt +++ b/app/src/main/java/io/legado/app/base/BaseActivity.kt @@ -27,7 +27,7 @@ import io.legado.app.constant.EventBus import io.legado.app.constant.PreferKey import io.legado.app.constant.Theme import io.legado.app.help.config.AppConfig -import io.legado.app.help.config.OldThemeConfig +import io.legado.app.help.config.ThemeConfigStore import io.legado.app.lib.theme.primaryColor import io.legado.app.utils.applyOpenTint import io.legado.app.utils.applyTint @@ -36,7 +36,6 @@ import io.legado.app.utils.fullScreen import io.legado.app.utils.getPrefString import io.legado.app.utils.hideSoftInput import io.legado.app.utils.observeEvent -import io.legado.app.utils.setNavigationBarColorAuto import io.legado.app.utils.setStatusBarColorAuto import io.legado.app.utils.themeColor import io.legado.app.utils.toastOnUi @@ -101,7 +100,6 @@ abstract class BaseActivity( else{ setupSystemBar() } - window.setNavigationBarColorAuto(themeColor(com.google.android.material.R.attr.colorSurface)) //setupSystemBar() setContentView(binding.root) upBackgroundImage() @@ -231,7 +229,7 @@ abstract class BaseActivity( open fun upBackgroundImage() { if (imageBg) { try { - OldThemeConfig.getBgImage(this, windowManager.windowSize)?.let { + ThemeConfigStore.getBgImage(this, windowManager.windowSize)?.let { window.decorView.background = it.toDrawable(resources) } } catch (e: OutOfMemoryError) { @@ -261,4 +259,4 @@ abstract class BaseActivity( currentFocus?.hideSoftInput() super.finish() } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/base/BaseBottomSheetDialogFragment.kt b/app/src/main/java/io/legado/app/base/BaseBottomSheetDialogFragment.kt index 14114ce63..5cda24938 100644 --- a/app/src/main/java/io/legado/app/base/BaseBottomSheetDialogFragment.kt +++ b/app/src/main/java/io/legado/app/base/BaseBottomSheetDialogFragment.kt @@ -12,8 +12,6 @@ import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment import io.legado.app.constant.AppLog import io.legado.app.help.coroutine.Coroutine -import io.legado.app.utils.setNavigationBarColorAuto -import io.legado.app.utils.themeColor import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlin.coroutines.CoroutineContext @@ -30,7 +28,6 @@ abstract class BaseBottomSheetDialogFragment( override fun onStart() { super.onStart() - dialog?.window?.setNavigationBarColorAuto(requireContext().themeColor(com.google.android.material.R.attr.colorSurfaceContainer)) if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { val screenHeight = resources.displayMetrics.heightPixels (dialog as? BottomSheetDialog)?.behavior?.apply { @@ -70,4 +67,4 @@ abstract class BaseBottomSheetDialogFragment( open fun observeLiveBus() { } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/base/BaseComposeActivity.kt b/app/src/main/java/io/legado/app/base/BaseComposeActivity.kt index 020db124d..ca350f425 100644 --- a/app/src/main/java/io/legado/app/base/BaseComposeActivity.kt +++ b/app/src/main/java/io/legado/app/base/BaseComposeActivity.kt @@ -1,7 +1,9 @@ package io.legado.app.base import android.os.Bundle +import android.os.Build import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.Composable import androidx.core.graphics.drawable.toDrawable @@ -9,7 +11,7 @@ import androidx.core.view.WindowCompat import io.legado.app.constant.EventBus import io.legado.app.constant.Theme import io.legado.app.help.config.AppConfig -import io.legado.app.help.config.OldThemeConfig +import io.legado.app.help.config.ThemeConfigStore import io.legado.app.ui.theme.AppTheme import io.legado.app.utils.disableAutoFill import io.legado.app.utils.fullScreen @@ -34,6 +36,10 @@ abstract class BaseComposeActivity( AppContextWrapper.applyLocaleAndFont(this) super.onCreate(savedInstanceState) + enableEdgeToEdge() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } setupSystemBar() // Compose 入口 @@ -66,7 +72,7 @@ abstract class BaseComposeActivity( open fun upBackgroundImage() { try { - OldThemeConfig.getBgImage(this, windowManager.windowSize)?.let { + ThemeConfigStore.getBgImage(this, windowManager.windowSize)?.let { window.setBackgroundDrawable(it.toDrawable(resources)) } } catch (_: Exception) {} diff --git a/app/src/main/java/io/legado/app/constant/PreferKey.kt b/app/src/main/java/io/legado/app/constant/PreferKey.kt index 53c5a5b5f..d824dfc04 100644 --- a/app/src/main/java/io/legado/app/constant/PreferKey.kt +++ b/app/src/main/java/io/legado/app/constant/PreferKey.kt @@ -103,6 +103,10 @@ object PreferKey { const val showBrightnessView = "showBrightnessView" const val useUnderline = "useUnderline" const val regexColorRules = "regexColorRules" + const val highlightRuleItems = "highlightRuleItems" + const val highlightRuleDialog = "highlightRuleDialog" + const val highlightRuleBookTitle = "highlightRuleBookTitle" + const val highlightRuleBracketNote = "highlightRuleBracketNote" const val adaptSpecialStyle = "adaptSpecialStyle" const val autoClearExpired = "autoClearExpired" const val autoChangeSource = "autoChangeSource" @@ -288,6 +292,35 @@ object PreferKey { const val isPredictiveBackEnabled = "isPredictiveBackEnabled" const val replaceSortMode = "desc" const val readBarStyle = "readBarStyle" + const val readMenuBgColor = "readMenuBgColor" + const val readMenuAccentColor = "readMenuAccentColor" + const val readMenuContainerColor = "readMenuContainerColor" + const val readMenuBgColorNight = "readMenuBgColorNight" + const val readMenuAccentColorNight = "readMenuAccentColorNight" + const val readMenuContainerColorNight = "readMenuContainerColorNight" + const val readMenuColorMode = "readMenuColorMode" + const val readMenuIconShowText = "readMenuIconShowText" + const val readMenuIconStyle = "readMenuIconStyle" + const val readMenuIconItemsPerRow = "readMenuIconItemsPerRow" + const val readMenuIconRowCount = "readMenuIconRowCount" + const val readMenuBottomCornerRadius = "readMenuBottomCornerRadius" + const val readMenuFloatingBottomBar = "readMenuFloatingBottomBar" + const val readMenuTopBarBlurMode = "readMenuTopBarBlurMode" + const val readMenuBottomBarBlurMode = "readMenuBottomBarBlurMode" + const val readMenuTopBarLiquidGlassButtons = "readMenuTopBarLiquidGlassButtons" + const val readMenuBottomBarLiquidGlassButtons = "readMenuBottomBarLiquidGlassButtons" + const val readMenuTopBarBlurStyle = "readMenuTopBarBlurStyle" + const val readMenuBottomBarBlurStyle = "readMenuBottomBarBlurStyle" + const val readMenuBlurRadius = "readMenuBlurRadius" + const val readMenuBlurAlpha = "readMenuBlurAlpha" + const val readMenuLensRadius = "readMenuLensRadius" + const val readMenuBorderWidth = "readMenuBorderWidth" + const val readMenuBorderColor = "readMenuBorderColor" + const val readMenuBorderColorNight = "readMenuBorderColorNight" + const val readMenuCustomIcons = "readMenuCustomIcons" + const val titleBarCustomIcons = "titleBarCustomIcons" + const val titleBarIconPosition = "titleBarIconPosition" + const val showTitleBarIcons = "showTitleBarIcons" const val disableReturnKey = "disableReturnKey" const val selectText = "selectText" //我在干什么 @@ -351,3 +384,14 @@ object PreferKey { const val eyeProtectionStartTime = "eyeProtectionStartTime" const val eyeProtectionEndTime = "eyeProtectionEndTime" } + +object ReadMenuBlurMode { + const val None = 0 + const val LiquidGlass = 1 + const val Haze = 2 +} + +object ReadMenuBlurStyle { + const val Solid = 0 + const val Progressive = 1 +} diff --git a/app/src/main/java/io/legado/app/data/AppDatabase.kt b/app/src/main/java/io/legado/app/data/AppDatabase.kt index 2b06f261f..a360eab47 100644 --- a/app/src/main/java/io/legado/app/data/AppDatabase.kt +++ b/app/src/main/java/io/legado/app/data/AppDatabase.kt @@ -15,6 +15,7 @@ import io.legado.app.data.dao.BookmarkDao import io.legado.app.data.dao.CacheDao import io.legado.app.data.dao.CookieDao import io.legado.app.data.dao.DictRuleDao +import io.legado.app.data.dao.HighlightRuleDao import io.legado.app.data.dao.HomepageCustomSetDao import io.legado.app.data.dao.HomepageModuleDao import io.legado.app.data.dao.HttpTTSDao @@ -41,6 +42,7 @@ import io.legado.app.data.entities.Cache import io.legado.app.data.entities.Cookie import io.legado.app.data.entities.DictRule import io.legado.app.data.entities.HomepageCustomSet +import io.legado.app.data.entities.HighlightRule import io.legado.app.data.entities.HomepageModule import io.legado.app.data.entities.HttpTTS import io.legado.app.data.entities.KeyboardAssist @@ -73,7 +75,7 @@ val appDb by lazy { } @Database( - version = 89, + version = 90, exportSchema = true, entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class, ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class, @@ -81,7 +83,8 @@ val appDb by lazy { RssReadRecord::class, ReadRecordDetail::class, ReadRecordSession::class, RssStar::class, TxtTocRule::class, ReadRecord::class, HttpTTS::class, Cache::class, RuleSub::class, DictRule::class, KeyboardAssist::class, Server::class, - SearchContentHistory::class, HomepageModule::class, HomepageCustomSet::class], + SearchContentHistory::class, HomepageModule::class, HomepageCustomSet::class, + HighlightRule::class], views = [BookSourcePart::class], autoMigrations = [ AutoMigration(from = 43, to = 44), @@ -129,7 +132,8 @@ val appDb by lazy { AutoMigration(from = 85, to = 86), AutoMigration(from = 86, to = 87), AutoMigration(from = 87, to = 88), - AutoMigration(from = 88, to = 89) + AutoMigration(from = 88, to = 89), + AutoMigration(from = 89, to = 90) ] ) abstract class AppDatabase : RoomDatabase() { @@ -158,6 +162,7 @@ abstract class AppDatabase : RoomDatabase() { abstract val searchContentHistoryDao: SearchContentHistoryDao abstract val homepageModuleDao: HomepageModuleDao abstract val homepageCustomSetDao: HomepageCustomSetDao + abstract val highlightRuleDao: HighlightRuleDao companion object { diff --git a/app/src/main/java/io/legado/app/data/dao/HighlightRuleDao.kt b/app/src/main/java/io/legado/app/data/dao/HighlightRuleDao.kt new file mode 100644 index 000000000..431d49eea --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/HighlightRuleDao.kt @@ -0,0 +1,41 @@ +package io.legado.app.data.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import io.legado.app.data.entities.HighlightRule + +@Dao +interface HighlightRuleDao { + + @Query("SELECT * FROM highlightRules ORDER BY position ASC") + fun getAll(): List + + @Query("SELECT * FROM highlightRules WHERE enabled = 1 AND pattern != '' ORDER BY position ASC") + fun getEnabled(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(rules: List) + + @Update + fun update(rule: HighlightRule) + + @Delete + fun delete(rule: HighlightRule) + + @Query("DELETE FROM highlightRules") + fun deleteAll() + + @Query("SELECT COUNT(*) FROM highlightRules") + fun count(): Int + + @Transaction + fun replaceAll(rules: List) { + deleteAll() + insertAll(rules) + } +} diff --git a/app/src/main/java/io/legado/app/data/entities/HighlightRule.kt b/app/src/main/java/io/legado/app/data/entities/HighlightRule.kt new file mode 100644 index 000000000..a804e429a --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/HighlightRule.kt @@ -0,0 +1,94 @@ +package io.legado.app.data.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.util.UUID + +@Entity(tableName = "highlightRules") +data class HighlightRule( + @PrimaryKey + var id: String = UUID.randomUUID().toString(), + var name: String = "", + var pattern: String = "", + var sampleText: String = "", + var targetScope: Int = TARGET_ALL, + var enabled: Boolean = true, + var position: Int = 0, + var textColor: Int? = null, + var bgColor: Int? = null, + var underlineMode: Int = 0, + var underlineColor: Int? = null, + var underlineWidth: Float = 1f, + var underlineOffset: Float = 2f, + var underlineSvgPath: String? = null, + var bgImage: String? = null, + var bgImageFit: Int = 0, + var bgImageScale: Float = 1f, +) { + + fun styleSummary(): String { + val parts = ArrayList(4) + parts.add(targetScopeLabel()) + textColor?.let { + parts.add("字色 ${it.toHexColor()}") + } + bgColor?.let { + parts.add("背景色 ${it.toHexColor()}") + } + if (underlineMode != 0) { + parts.add( + when (underlineMode) { + 1 -> "实线下划线" + 2 -> "虚线下划线" + 3 -> "波浪下划线" + 4 -> "双下划线" + 5 -> "自定义SVG" + else -> "下划线" + } + underlineColor?.let { " ${it.toHexColor()}" }.orEmpty() + ) + } + if (!bgImage.isNullOrBlank()) { + parts.add( + when (bgImageFit) { + 1 -> "背景图(拉伸)" + 2 -> "背景图(裁剪)" + else -> "背景图(平铺)" + } + ) + } + if (parts.isEmpty()) { + parts.add("无样式") + } + return parts.joinToString(" / ") + } + + fun targetScopeLabel(): String { + return when (targetScope) { + TARGET_TITLE -> "作用于标题" + TARGET_BODY -> "作用于正文" + else -> "作用于全部" + } + } + + fun displayPattern(): String { + return pattern.ifBlank { ".*" } + } + + fun normalizedSampleText(): String { + return sampleText.ifBlank { + "她轻声说:“今晚就出发。”\n最近在重读《百年孤独》(纪念版),节奏依然很稳。" + } + } + + fun copyWithNewId(): HighlightRule { + return copy(id = UUID.randomUUID().toString()) + } + + companion object { + const val TARGET_ALL = 0 + const val TARGET_TITLE = 1 + const val TARGET_BODY = 2 + + fun Int.toHexColor(): String = String.format("#%08X", this) + } +} diff --git a/app/src/main/java/io/legado/app/data/repository/MangaSettingsRepository.kt b/app/src/main/java/io/legado/app/data/repository/MangaSettingsRepository.kt new file mode 100644 index 000000000..a7008ca32 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/MangaSettingsRepository.kt @@ -0,0 +1,182 @@ +package io.legado.app.data.repository + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import io.legado.app.constant.PreferKey +import io.legado.app.ui.book.manga.config.MangaScrollMode +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import java.io.IOException + +data class MangaPreferences( + val showMangaUi: Boolean = true, + val disableMangaScale: Boolean = true, + val disableMangaScrollAnimation: Boolean = false, + val disableMangaCrossFade: Boolean = false, + val disableClickScroll: Boolean = false, + val mangaPreDownloadNum: Int = 10, + val mangaAutoPageSpeed: Int = 3, + val mangaFooterConfig: String = "", + val mangaScrollMode: Int = MangaScrollMode.WEBTOON, + val mangaLongClick: Boolean = true, + val mangaBackground: Int = 0xFF000000.toInt(), + val mangaColorFilter: String = "", + val hideMangaTitle: Boolean = false, + val enableMangaEInk: Boolean = false, + val mangaEInkThreshold: Int = 150, + val enableMangaGray: Boolean = false, + val webtoonSidePaddingDp: Int = 0, + val mangaVolumeKeyPage: Boolean = false, + val reverseVolumeKeyPage: Boolean = false, + val clickActionTL: Int = -1, + val clickActionTC: Int = -1, + val clickActionTR: Int = 1, + val clickActionML: Int = 2, + val clickActionMC: Int = 0, + val clickActionMR: Int = 1, + val clickActionBL: Int = 2, + val clickActionBC: Int = 1, + val clickActionBR: Int = 1 +) + +class MangaSettingsRepository( + private val context: Context, + private val settingsRepository: SettingsRepository +) { + + val preferences: Flow = context.dataStore.data + .catch { exception -> + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + .map { preferences -> + preferences.toMangaPreferences() + } + + suspend fun setShowMangaUi(value: Boolean) = + settingsRepository.putBoolean(PreferKey.showMangaUi, value) + + suspend fun setMangaPreDownloadNum(value: Int) = + settingsRepository.putInt(PreferKey.mangaPreDownloadNum, value) + + suspend fun setMangaAutoPageSpeed(value: Int) = + settingsRepository.putInt(PreferKey.mangaAutoPageSpeed, value) + + suspend fun setDisableClickScroll(value: Boolean) = + settingsRepository.putBoolean(PreferKey.disableClickScroll, value) + + suspend fun setDisableMangaScrollAnimation(value: Boolean) = + settingsRepository.putBoolean(PreferKey.disableMangaScrollAnimation, value) + + suspend fun setDisableMangaCrossFade(value: Boolean) = + settingsRepository.putBoolean(PreferKey.disableMangaCrossFade, value) + + suspend fun setDisableMangaScale(value: Boolean) = + settingsRepository.putBoolean(PreferKey.disableMangaScale, value) + + suspend fun setEnableMangaEInk(value: Boolean) = + settingsRepository.putBoolean(PreferKey.enableMangaEInk, value) + + suspend fun setMangaEInkThreshold(value: Int) = + settingsRepository.putInt(PreferKey.mangaEInkThreshold, value) + + suspend fun setEnableMangaGray(value: Boolean) = + settingsRepository.putBoolean(PreferKey.enableMangaGray, value) + + suspend fun setMangaAutoColorFilter(value: String) = + settingsRepository.putString(PreferKey.mangaColorFilter, value) + + suspend fun setMangaBackground(value: Int) = + settingsRepository.putInt(PreferKey.mangaBackground, value) + + suspend fun setMangaLongClick(value: Boolean) = + settingsRepository.putBoolean(PreferKey.mangaLongClick, value) + + suspend fun setMangaVolumeKeyPage(value: Boolean) = + settingsRepository.putBoolean(PreferKey.mangaVolumeKeyPage, value) + + suspend fun setReverseVolumeKeyPage(value: Boolean) = + settingsRepository.putBoolean(PreferKey.reverseVolumeKeyPage, value) + + suspend fun setHideMangaTitle(value: Boolean) = + settingsRepository.putBoolean(PreferKey.hideMangaTitle, value) + + suspend fun setMangaFooterConfig(value: String) = + settingsRepository.putString(PreferKey.mangaFooterConfig, value) + + suspend fun setMangaClickAction(key: String, value: Int) = + settingsRepository.putInt(key, value) + + private fun Preferences.toMangaPreferences(): MangaPreferences { + return MangaPreferences( + showMangaUi = this[Keys.ShowMangaUi] ?: true, + disableMangaScale = this[Keys.DisableMangaScale] ?: true, + disableMangaScrollAnimation = this[Keys.DisableMangaScrollAnimation] ?: false, + disableMangaCrossFade = this[Keys.DisableMangaCrossFade] ?: false, + disableClickScroll = this[Keys.DisableClickScroll] ?: false, + mangaPreDownloadNum = this[Keys.MangaPreDownloadNum] ?: 10, + mangaAutoPageSpeed = this[Keys.MangaAutoPageSpeed] ?: 3, + mangaFooterConfig = this[Keys.MangaFooterConfig] ?: "", + mangaScrollMode = this[Keys.MangaScrollMode] ?: MangaScrollMode.WEBTOON, + mangaLongClick = this[Keys.MangaLongClick] ?: true, + mangaBackground = this[Keys.MangaBackground] ?: 0xFF000000.toInt(), + mangaColorFilter = this[Keys.MangaColorFilter] ?: "", + hideMangaTitle = this[Keys.HideMangaTitle] ?: false, + enableMangaEInk = this[Keys.EnableMangaEInk] ?: false, + mangaEInkThreshold = this[Keys.MangaEInkThreshold] ?: 150, + enableMangaGray = this[Keys.EnableMangaGray] ?: false, + webtoonSidePaddingDp = this[Keys.WebtoonSidePaddingDp] ?: 0, + mangaVolumeKeyPage = this[Keys.MangaVolumeKeyPage] ?: false, + reverseVolumeKeyPage = this[Keys.ReverseVolumeKeyPage] ?: false, + clickActionTL = this[Keys.ClickActionTL] ?: -1, + clickActionTC = this[Keys.ClickActionTC] ?: -1, + clickActionTR = this[Keys.ClickActionTR] ?: 1, + clickActionML = this[Keys.ClickActionML] ?: 2, + clickActionMC = this[Keys.ClickActionMC] ?: 0, + clickActionMR = this[Keys.ClickActionMR] ?: 1, + clickActionBL = this[Keys.ClickActionBL] ?: 2, + clickActionBC = this[Keys.ClickActionBC] ?: 1, + clickActionBR = this[Keys.ClickActionBR] ?: 1 + ) + } + + private object Keys { + val ShowMangaUi = booleanPreferencesKey(PreferKey.showMangaUi) + val DisableMangaScale = booleanPreferencesKey(PreferKey.disableMangaScale) + val DisableMangaScrollAnimation = + booleanPreferencesKey(PreferKey.disableMangaScrollAnimation) + val DisableMangaCrossFade = booleanPreferencesKey(PreferKey.disableMangaCrossFade) + val DisableClickScroll = booleanPreferencesKey(PreferKey.disableClickScroll) + val MangaPreDownloadNum = intPreferencesKey(PreferKey.mangaPreDownloadNum) + val MangaAutoPageSpeed = intPreferencesKey(PreferKey.mangaAutoPageSpeed) + val MangaFooterConfig = stringPreferencesKey(PreferKey.mangaFooterConfig) + val MangaScrollMode = intPreferencesKey(PreferKey.mangaScrollMode) + val MangaLongClick = booleanPreferencesKey(PreferKey.mangaLongClick) + val MangaBackground = intPreferencesKey(PreferKey.mangaBackground) + val MangaColorFilter = stringPreferencesKey(PreferKey.mangaColorFilter) + val HideMangaTitle = booleanPreferencesKey(PreferKey.hideMangaTitle) + val EnableMangaEInk = booleanPreferencesKey(PreferKey.enableMangaEInk) + val MangaEInkThreshold = intPreferencesKey(PreferKey.mangaEInkThreshold) + val EnableMangaGray = booleanPreferencesKey(PreferKey.enableMangaGray) + val WebtoonSidePaddingDp = intPreferencesKey(PreferKey.webtoonSidePaddingDp) + val MangaVolumeKeyPage = booleanPreferencesKey(PreferKey.mangaVolumeKeyPage) + val ReverseVolumeKeyPage = booleanPreferencesKey(PreferKey.reverseVolumeKeyPage) + val ClickActionTL = intPreferencesKey(PreferKey.mangaClickActionTL) + val ClickActionTC = intPreferencesKey(PreferKey.mangaClickActionTC) + val ClickActionTR = intPreferencesKey(PreferKey.mangaClickActionTR) + val ClickActionML = intPreferencesKey(PreferKey.mangaClickActionML) + val ClickActionMC = intPreferencesKey(PreferKey.mangaClickActionMC) + val ClickActionMR = intPreferencesKey(PreferKey.mangaClickActionMR) + val ClickActionBL = intPreferencesKey(PreferKey.mangaClickActionBL) + val ClickActionBC = intPreferencesKey(PreferKey.mangaClickActionBC) + val ClickActionBR = intPreferencesKey(PreferKey.mangaClickActionBR) + } +} diff --git a/app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt b/app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt new file mode 100644 index 000000000..eadc16aeb --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt @@ -0,0 +1,121 @@ +package io.legado.app.data.repository + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.emptyPreferences +import io.legado.app.constant.PreferKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import java.io.IOException + +data class ReadAloudPreferences( + val ignoreAudioFocus: Boolean = false, + val mediaButtonOnExit: Boolean = true, + val readAloudByMediaButton: Boolean = false, + val pauseReadAloudWhilePhoneCalls: Boolean = false, + val readAloudWakeLock: Boolean = false, + val mediaButtonPerNext: Boolean = false, + val readAloudByPage: Boolean = false, + val systemMediaControlCompatibilityChange: Boolean = true, + val streamReadAloudAudio: Boolean = false, + val ttsTimer: Int = 0, + val ttsFollowSys: Boolean = true, + val ttsSpeechRate: Int = 5, +) + +class ReadAloudSettingsRepository( + private val context: Context, + private val settingsRepository: SettingsRepository +) { + + val preferences: Flow = context.dataStore.data + .catch { exception -> + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + .map { preferences -> + preferences.toReadAloudPreferences() + } + + suspend fun setIgnoreAudioFocus(value: Boolean) = + settingsRepository.putBoolean(PreferKey.ignoreAudioFocus, value) + + suspend fun setMediaButtonOnExit(value: Boolean) = + settingsRepository.putBoolean(PreferKey.mediaButtonOnExit, value) + + suspend fun setReadAloudByMediaButton(value: Boolean) = + settingsRepository.putBoolean(PreferKey.readAloudByMediaButton, value) + + suspend fun setPauseReadAloudWhilePhoneCalls(value: Boolean) = + settingsRepository.putBoolean(PreferKey.pauseReadAloudWhilePhoneCalls, value) + + suspend fun setReadAloudWakeLock(value: Boolean) = + settingsRepository.putBoolean(PreferKey.readAloudWakeLock, value) + + suspend fun setMediaButtonPerNext(value: Boolean) = + settingsRepository.putBoolean(KEY_MEDIA_BUTTON_PER_NEXT, value) + + suspend fun setReadAloudByPage(value: Boolean) = + settingsRepository.putBoolean(PreferKey.readAloudByPage, value) + + suspend fun setSystemMediaControlCompatibilityChange(value: Boolean) = + settingsRepository.putBoolean(PreferKey.systemMediaControlCompatibilityChange, value) + + suspend fun setStreamReadAloudAudio(value: Boolean) = + settingsRepository.putBoolean(PreferKey.streamReadAloudAudio, value) + + suspend fun setTtsTimer(value: Int) = + settingsRepository.putInt(PreferKey.ttsTimer, value.coerceIn(0, 180)) + + suspend fun setTtsFollowSys(value: Boolean) = + settingsRepository.putBoolean(PreferKey.ttsFollowSys, value) + + suspend fun setTtsSpeechRate(value: Int) = + settingsRepository.putInt(PreferKey.ttsSpeechRate, value.coerceIn(0, 80)) + + private fun Preferences.toReadAloudPreferences(): ReadAloudPreferences { + return ReadAloudPreferences( + ignoreAudioFocus = this[Keys.IgnoreAudioFocus] ?: false, + mediaButtonOnExit = this[Keys.MediaButtonOnExit] ?: true, + readAloudByMediaButton = this[Keys.ReadAloudByMediaButton] ?: false, + pauseReadAloudWhilePhoneCalls = this[Keys.PauseReadAloudWhilePhoneCalls] ?: false, + readAloudWakeLock = this[Keys.ReadAloudWakeLock] ?: false, + mediaButtonPerNext = this[Keys.MediaButtonPerNext] ?: false, + readAloudByPage = this[Keys.ReadAloudByPage] ?: false, + systemMediaControlCompatibilityChange = + this[Keys.SystemMediaControlCompatibilityChange] ?: true, + streamReadAloudAudio = this[Keys.StreamReadAloudAudio] ?: false, + ttsTimer = this[Keys.TtsTimer] ?: 0, + ttsFollowSys = this[Keys.TtsFollowSys] ?: true, + ttsSpeechRate = this[Keys.TtsSpeechRate] ?: 5, + ) + } + + private object Keys { + val IgnoreAudioFocus = booleanPreferencesKey(PreferKey.ignoreAudioFocus) + val MediaButtonOnExit = booleanPreferencesKey(PreferKey.mediaButtonOnExit) + val ReadAloudByMediaButton = booleanPreferencesKey(PreferKey.readAloudByMediaButton) + val PauseReadAloudWhilePhoneCalls = + booleanPreferencesKey(PreferKey.pauseReadAloudWhilePhoneCalls) + val ReadAloudWakeLock = booleanPreferencesKey(PreferKey.readAloudWakeLock) + val MediaButtonPerNext = booleanPreferencesKey(KEY_MEDIA_BUTTON_PER_NEXT) + val ReadAloudByPage = booleanPreferencesKey(PreferKey.readAloudByPage) + val SystemMediaControlCompatibilityChange = + booleanPreferencesKey(PreferKey.systemMediaControlCompatibilityChange) + val StreamReadAloudAudio = booleanPreferencesKey(PreferKey.streamReadAloudAudio) + val TtsTimer = androidx.datastore.preferences.core.intPreferencesKey(PreferKey.ttsTimer) + val TtsFollowSys = booleanPreferencesKey(PreferKey.ttsFollowSys) + val TtsSpeechRate = androidx.datastore.preferences.core.intPreferencesKey( + PreferKey.ttsSpeechRate + ) + } + + companion object { + const val KEY_MEDIA_BUTTON_PER_NEXT = "mediaButtonPerNext" + } +} diff --git a/app/src/main/java/io/legado/app/data/repository/ReadBookStyleConfigRepository.kt b/app/src/main/java/io/legado/app/data/repository/ReadBookStyleConfigRepository.kt new file mode 100644 index 000000000..6458f65e6 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/ReadBookStyleConfigRepository.kt @@ -0,0 +1,61 @@ +package io.legado.app.data.repository + +import io.legado.app.help.config.ReadBookConfig +import java.io.InputStream + +/** + * Repository boundary for the legacy read style config file. + * + * The underlying model is still [ReadBookConfig.Config] because each read style + * keeps a full layout/background/text configuration. This wrapper centralizes + * file mutations so UI and ViewModel code do not call persistence helpers directly. + */ +class ReadBookStyleConfigRepository { + + fun save() { + ReadBookConfig.save() + } + + fun addStyle(): Int { + ReadBookConfig.configList.add(ReadBookConfig.Config()) + save() + return ReadBookConfig.configList.lastIndex + } + + fun deleteCurrentStyle(): Boolean { + val deleted = ReadBookConfig.deleteDur() + if (deleted) { + save() + } + return deleted + } + + fun importCurrentStyle(bytes: ByteArray) { + ReadBookConfig.durConfig = ReadBookConfig.import(bytes) + save() + } + + fun exportCurrentStyle(): ByteArray { + return ReadBookConfig.export() + } + + fun saveBackgroundImage(inputStream: InputStream, displayName: String?): String { + return ReadBookConfig.saveBackgroundImage(inputStream, displayName) + } + + fun setCurrentBackgroundImage(path: String) { + ReadBookConfig.durConfig.setCurBg(2, path) + save() + } + + fun setCurrentBackgroundImageForMode(path: String, isNight: Boolean) { + if (isNight) { + ReadBookConfig.durConfig.bgTypeNight = 2 + ReadBookConfig.durConfig.bgStrNight = path + } else { + ReadBookConfig.durConfig.bgType = 2 + ReadBookConfig.durConfig.bgStr = path + } + save() + } +} diff --git a/app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt b/app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt new file mode 100644 index 000000000..bdfc1ca3b --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt @@ -0,0 +1,537 @@ +package io.legado.app.data.repository + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import io.legado.app.constant.PreferKey +import io.legado.app.constant.ReadMenuBlurMode +import io.legado.app.constant.ReadMenuBlurStyle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import java.io.IOException + +data class ReadPreferences( + val screenOrientation: String = "0", + val keepLight: String = "0", + val hideStatusBar: Boolean = false, + val hideNavigationBar: Boolean = false, + val paddingDisplayCutouts: Boolean = false, + val titleBarMode: String = "1", + val menuAlpha: Int = 100, + val readBodyToLh: Boolean = true, + val defaultSourceChangeAll: Boolean = true, + val textFullJustify: Boolean = true, + val textBottomJustify: Boolean = true, + val adaptSpecialStyle: Boolean = true, + val useZhLayout: Boolean = false, + val showBrightnessView: Boolean = true, + val useUnderline: Boolean = false, + val readSliderMode: String = "0", + val doubleHorizontalPage: String = "0", + val progressBarBehavior: String = "page", + val mouseWheelPage: Boolean = true, + val volumeKeyPage: Boolean = true, + val volumeKeyPageOnPlay: Boolean = true, + val keyPageOnLongPress: Boolean = false, + val pageTouchSlop: Int = 0, + val sliderVibrator: Boolean = false, + val selectVibrator: Boolean = false, + val autoChangeSource: Boolean = true, + val selectText: Boolean = true, + val noAnimScrollPage: Boolean = false, + val clickImgWay: String = "2", + val optimizeRender: Boolean = false, + val disableReturnKey: Boolean = false, + val expandTextMenu: Boolean = false, + val showReadTitleAddition: Boolean = true, + val autoReadSpeed: Int = 10, + val prevKeys: String = "", + val nextKeys: String = "", + val tocUiUseReplace: Boolean = false, + val tocCountWords: Boolean = true, + val readStyleSelect: Int = 0, + val comicStyleSelect: Int = 0, + val shareLayout: Boolean = false, + val readBarStyleFollowPage: Boolean = false, + val readBarStyle: Int = 0, + val clickActionTL: Int = 2, + val clickActionTC: Int = 2, + val clickActionTR: Int = 1, + val clickActionML: Int = 2, + val clickActionMC: Int = 0, + val clickActionMR: Int = 1, + val clickActionBL: Int = 2, + val clickActionBC: Int = 1, + val clickActionBR: Int = 1, + val fontFolder: String = "", + val readMenuBgColor: Int = 0, + val readMenuAccentColor: Int = 0, + val readMenuContainerColor: Int = 0, + val readMenuBgColorNight: Int = 0, + val readMenuAccentColorNight: Int = 0, + val readMenuContainerColorNight: Int = 0, + val readMenuColorMode: Int = 1, + val readMenuIconShowText: Boolean = true, + val readMenuIconStyle: Int = 0, + val readMenuIconItemsPerRow: Int = 5, + val readMenuIconRowCount: Int = 1, + val readMenuBottomCornerRadius: Int = 0, + val readMenuFloatingBottomBar: Boolean = false, + val readMenuTopBarBlurMode: Int = ReadMenuBlurMode.None, + val readMenuBottomBarBlurMode: Int = ReadMenuBlurMode.None, + val readMenuTopBarLiquidGlassButtons: Boolean = false, + val readMenuBottomBarLiquidGlassButtons: Boolean = false, + val readMenuTopBarBlurStyle: Int = ReadMenuBlurStyle.Progressive, + val readMenuBottomBarBlurStyle: Int = ReadMenuBlurStyle.Solid, + val readMenuBlurRadius: Int = 24, + val readMenuBlurAlpha: Int = 60, + val readMenuLensRadius: Float = 24f, + val readMenuBorderWidth: Int = 0, + val readMenuBorderColor: Int = 0, + val readMenuBorderColorNight: Int = 0, + val readMenuCustomIcons: String = "", + val titleBarCustomIcons: String = "", + val titleBarIconPosition: Int = 0, + val showTitleBarIcons: Boolean = true, + val chineseConverterType: Int = 0, +) + +class ReadSettingsRepository( + private val context: Context, + private val settingsRepository: SettingsRepository +) { + + val preferences: Flow = context.dataStore.data + .catch { exception -> + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + .map { preferences -> + preferences.toReadPreferences() + } + + suspend fun setScreenOrientation(value: String) = + settingsRepository.putString(PreferKey.screenOrientation, value) + + suspend fun setKeepLight(value: String) = + settingsRepository.putString(PreferKey.keepLight, value) + + suspend fun setHideStatusBar(value: Boolean) = + settingsRepository.putBoolean(PreferKey.hideStatusBar, value) + + suspend fun setHideNavigationBar(value: Boolean) = + settingsRepository.putBoolean(PreferKey.hideNavigationBar, value) + + suspend fun setPaddingDisplayCutouts(value: Boolean) = + settingsRepository.putBoolean(PreferKey.paddingDisplayCutouts, value) + + suspend fun setTitleBarMode(value: String) = + settingsRepository.putString(PreferKey.titleBarMode, value) + + suspend fun setMenuAlpha(value: Int) = + settingsRepository.putInt(PreferKey.menuAlpha, value) + + suspend fun setReadBodyToLh(value: Boolean) = + settingsRepository.putBoolean(PreferKey.readBodyToLh, value) + + suspend fun setDefaultSourceChangeAll(value: Boolean) = + settingsRepository.putBoolean(PreferKey.defaultSourceChangeAll, value) + + suspend fun setTextFullJustify(value: Boolean) = + settingsRepository.putBoolean(PreferKey.textFullJustify, value) + + suspend fun setTextBottomJustify(value: Boolean) = + settingsRepository.putBoolean(PreferKey.textBottomJustify, value) + + suspend fun setAdaptSpecialStyle(value: Boolean) = + settingsRepository.putBoolean(PreferKey.adaptSpecialStyle, value) + + suspend fun setUseZhLayout(value: Boolean) = + settingsRepository.putBoolean(PreferKey.useZhLayout, value) + + suspend fun setShowBrightnessView(value: Boolean) = + settingsRepository.putBoolean(PreferKey.showBrightnessView, value) + + suspend fun setUseUnderline(value: Boolean) = + settingsRepository.putBoolean(PreferKey.useUnderline, value) + + suspend fun setReadSliderMode(value: String) = + settingsRepository.putString(PreferKey.readSliderMode, value) + + suspend fun setDoubleHorizontalPage(value: String) = + settingsRepository.putString(PreferKey.doublePageHorizontal, value) + + suspend fun setProgressBarBehavior(value: String) = + settingsRepository.putString(PreferKey.progressBarBehavior, value) + + suspend fun setMouseWheelPage(value: Boolean) = + settingsRepository.putBoolean(PreferKey.mouseWheelPage, value) + + suspend fun setVolumeKeyPage(value: Boolean) = + settingsRepository.putBoolean(PreferKey.volumeKeyPage, value) + + suspend fun setVolumeKeyPageOnPlay(value: Boolean) = + settingsRepository.putBoolean(PreferKey.volumeKeyPageOnPlay, value) + + suspend fun setKeyPageOnLongPress(value: Boolean) = + settingsRepository.putBoolean(PreferKey.keyPageOnLongPress, value) + + suspend fun setPageTouchSlop(value: Int) = + settingsRepository.putInt(PreferKey.pageTouchSlop, value) + + suspend fun setSliderVibrator(value: Boolean) = + settingsRepository.putBoolean(PreferKey.sliderVibrator, value) + + suspend fun setSelectVibrator(value: Boolean) = + settingsRepository.putBoolean(PreferKey.selectVibrator, value) + + suspend fun setAutoChangeSource(value: Boolean) = + settingsRepository.putBoolean(PreferKey.autoChangeSource, value) + + suspend fun setSelectText(value: Boolean) = + settingsRepository.putBoolean(PreferKey.selectText, value) + + suspend fun setNoAnimScrollPage(value: Boolean) = + settingsRepository.putBoolean(PreferKey.noAnimScrollPage, value) + + suspend fun setClickImgWay(value: String) = + settingsRepository.putString(PreferKey.clickImgWay, value) + + suspend fun setOptimizeRender(value: Boolean) = + settingsRepository.putBoolean(PreferKey.optimizeRender, value) + + suspend fun setDisableReturnKey(value: Boolean) = + settingsRepository.putBoolean(PreferKey.disableReturnKey, value) + + suspend fun setExpandTextMenu(value: Boolean) = + settingsRepository.putBoolean(PreferKey.expandTextMenu, value) + + suspend fun setShowReadTitleAddition(value: Boolean) = + settingsRepository.putBoolean(PreferKey.showReadTitleAddition, value) + + suspend fun setAutoReadSpeed(value: Int) = + settingsRepository.putInt(PreferKey.autoReadSpeed, value) + + suspend fun setPageKeys(prevKeys: String, nextKeys: String) { + settingsRepository.putStrings( + mapOf( + PreferKey.prevKeys to prevKeys, + PreferKey.nextKeys to nextKeys + ) + ) + } + + suspend fun setTocUiUseReplace(value: Boolean) = + settingsRepository.putBoolean(PreferKey.tocUiUseReplace, value) + + suspend fun setTocCountWords(value: Boolean) = + settingsRepository.putBoolean(PreferKey.tocCountWords, value) + + suspend fun setReadStyleSelect(value: Int) = + settingsRepository.putInt(PreferKey.readStyleSelect, value) + + suspend fun setComicStyleSelect(value: Int) = + settingsRepository.putInt(PreferKey.comicStyleSelect, value) + + suspend fun setShareLayout(value: Boolean) = + settingsRepository.putBoolean(PreferKey.shareLayout, value) + + suspend fun setReadBarStyleFollowPage(value: Boolean) = + settingsRepository.putBoolean(PreferKey.readBarStyleFollowPage, value) + + suspend fun setReadBarStyle(value: Int) = + settingsRepository.putInt(PreferKey.readBarStyle, value.coerceIn(0, 2)) + + suspend fun setClickAction(key: String, value: Int) = + settingsRepository.putInt(key, value) + + suspend fun setFontFolder(value: String) = + settingsRepository.putString(PreferKey.fontFolder, value) + + suspend fun setReadMenuBgColor(value: Int) = + settingsRepository.putInt(PreferKey.readMenuBgColor, value) + + suspend fun setReadMenuAccentColor(value: Int) = + settingsRepository.putInt(PreferKey.readMenuAccentColor, value) + + suspend fun setReadMenuContainerColor(value: Int) = + settingsRepository.putInt(PreferKey.readMenuContainerColor, value) + + suspend fun setReadMenuBgColorNight(value: Int) = + settingsRepository.putInt(PreferKey.readMenuBgColorNight, value) + + suspend fun setReadMenuAccentColorNight(value: Int) = + settingsRepository.putInt(PreferKey.readMenuAccentColorNight, value) + + suspend fun setReadMenuContainerColorNight(value: Int) = + settingsRepository.putInt(PreferKey.readMenuContainerColorNight, value) + + suspend fun setReadMenuColorMode(value: Int) = + settingsRepository.putInt(PreferKey.readMenuColorMode, value.coerceIn(0, 1)) + + suspend fun setReadMenuIconShowText(value: Boolean) = + settingsRepository.putBoolean(PreferKey.readMenuIconShowText, value) + + suspend fun setReadMenuIconStyle(value: Int) = + settingsRepository.putInt(PreferKey.readMenuIconStyle, value.coerceIn(0, 2)) + + suspend fun setReadMenuIconItemsPerRow(value: Int) = + settingsRepository.putInt(PreferKey.readMenuIconItemsPerRow, value.coerceIn(2, 8)) + + suspend fun setReadMenuIconRowCount(value: Int) = + settingsRepository.putInt(PreferKey.readMenuIconRowCount, value.coerceIn(1, 2)) + + suspend fun setReadMenuBottomCornerRadius(value: Int) = + settingsRepository.putInt(PreferKey.readMenuBottomCornerRadius, value.coerceIn(0, 32)) + + suspend fun setReadMenuFloatingBottomBar(value: Boolean) = + settingsRepository.putBoolean(PreferKey.readMenuFloatingBottomBar, value) + + suspend fun setReadMenuTopBarBlurMode(value: Int) = + settingsRepository.putInt(PreferKey.readMenuTopBarBlurMode, value.coerceIn(0, 2)) + + suspend fun setReadMenuBottomBarBlurMode(value: Int) = + settingsRepository.putInt(PreferKey.readMenuBottomBarBlurMode, value.coerceIn(0, 2)) + + suspend fun setReadMenuTopBarLiquidGlassButtons(value: Boolean) = + settingsRepository.putBoolean(PreferKey.readMenuTopBarLiquidGlassButtons, value) + + suspend fun setReadMenuBottomBarLiquidGlassButtons(value: Boolean) = + settingsRepository.putBoolean(PreferKey.readMenuBottomBarLiquidGlassButtons, value) + + suspend fun setReadMenuTopBarBlurStyle(value: Int) = + settingsRepository.putInt(PreferKey.readMenuTopBarBlurStyle, value.coerceIn(0, 1)) + + suspend fun setReadMenuBottomBarBlurStyle(value: Int) = + settingsRepository.putInt(PreferKey.readMenuBottomBarBlurStyle, value.coerceIn(0, 1)) + + suspend fun setReadMenuBlurRadius(value: Int) = + settingsRepository.putInt(PreferKey.readMenuBlurRadius, value.coerceIn(0, 32)) + + suspend fun setReadMenuBlurAlpha(value: Int) = + settingsRepository.putInt(PreferKey.readMenuBlurAlpha, value.coerceIn(0, 100)) + + suspend fun setReadMenuLensRadius(value: Float) = + settingsRepository.putFloat(PreferKey.readMenuLensRadius, value.coerceIn(0f, 48f)) + + suspend fun setReadMenuBorderWidth(value: Int) = + settingsRepository.putInt(PreferKey.readMenuBorderWidth, value.coerceIn(0, 4)) + + suspend fun setReadMenuBorderColor(value: Int) = + settingsRepository.putInt(PreferKey.readMenuBorderColor, value) + + suspend fun setReadMenuBorderColorNight(value: Int) = + settingsRepository.putInt(PreferKey.readMenuBorderColorNight, value) + + suspend fun setReadMenuCustomIcons(value: String) = + settingsRepository.putString(PreferKey.readMenuCustomIcons, value) + + suspend fun setTitleBarCustomIcons(value: String) = + settingsRepository.putString(PreferKey.titleBarCustomIcons, value) + + suspend fun setTitleBarIconPosition(value: Int) = + settingsRepository.putInt(PreferKey.titleBarIconPosition, value.coerceIn(0, 3)) + + suspend fun setShowTitleBarIcons(value: Boolean) = + settingsRepository.putBoolean(PreferKey.showTitleBarIcons, value) + + suspend fun setChineseConverterType(value: Int) = + settingsRepository.putInt(PreferKey.chineseConverterType, value) + + suspend fun setStyleSelect(isComic: Boolean, value: Int) { + if (isComic) { + setComicStyleSelect(value) + } else { + setReadStyleSelect(value) + } + } + + private fun Preferences.toReadPreferences(): ReadPreferences { + val readStyleSelect = this[Keys.ReadStyleSelect] ?: 0 + return ReadPreferences( + screenOrientation = this[Keys.ScreenOrientation] ?: "0", + keepLight = this[Keys.KeepLight] ?: "0", + hideStatusBar = this[Keys.HideStatusBar] ?: false, + hideNavigationBar = this[Keys.HideNavigationBar] ?: false, + paddingDisplayCutouts = this[Keys.PaddingDisplayCutouts] ?: false, + titleBarMode = this[Keys.TitleBarMode] ?: "1", + menuAlpha = this[Keys.MenuAlpha] ?: 100, + readBodyToLh = this[Keys.ReadBodyToLh] ?: true, + defaultSourceChangeAll = this[Keys.DefaultSourceChangeAll] ?: true, + textFullJustify = this[Keys.TextFullJustify] ?: true, + textBottomJustify = this[Keys.TextBottomJustify] ?: true, + adaptSpecialStyle = this[Keys.AdaptSpecialStyle] ?: true, + useZhLayout = this[Keys.UseZhLayout] ?: false, + showBrightnessView = this[Keys.ShowBrightnessView] ?: true, + useUnderline = this[Keys.UseUnderline] ?: false, + readSliderMode = this[Keys.ReadSliderMode] ?: "0", + doubleHorizontalPage = this[Keys.DoubleHorizontalPage] ?: "0", + progressBarBehavior = this[Keys.ProgressBarBehavior] ?: "page", + mouseWheelPage = this[Keys.MouseWheelPage] ?: true, + volumeKeyPage = this[Keys.VolumeKeyPage] ?: true, + volumeKeyPageOnPlay = this[Keys.VolumeKeyPageOnPlay] ?: true, + keyPageOnLongPress = this[Keys.KeyPageOnLongPress] ?: false, + pageTouchSlop = this[Keys.PageTouchSlop] ?: 0, + sliderVibrator = this[Keys.SliderVibrator] ?: false, + selectVibrator = this[Keys.SelectVibrator] ?: false, + autoChangeSource = this[Keys.AutoChangeSource] ?: true, + selectText = this[Keys.SelectText] ?: true, + noAnimScrollPage = this[Keys.NoAnimScrollPage] ?: false, + clickImgWay = this[Keys.ClickImgWay] ?: "2", + optimizeRender = this[Keys.OptimizeRender] ?: false, + disableReturnKey = this[Keys.DisableReturnKey] ?: false, + expandTextMenu = this[Keys.ExpandTextMenu] ?: false, + showReadTitleAddition = this[Keys.ShowReadTitleAddition] ?: true, + autoReadSpeed = this[Keys.AutoReadSpeed] ?: 10, + prevKeys = this[Keys.PrevKeys] ?: "", + nextKeys = this[Keys.NextKeys] ?: "", + tocUiUseReplace = this[Keys.TocUiUseReplace] ?: false, + tocCountWords = this[Keys.TocCountWords] ?: true, + readStyleSelect = readStyleSelect, + comicStyleSelect = this[Keys.ComicStyleSelect] ?: readStyleSelect, + shareLayout = this[Keys.ShareLayout] ?: false, + readBarStyleFollowPage = this[Keys.ReadBarStyleFollowPage] ?: false, + readBarStyle = this[Keys.ReadBarStyle] ?: 0, + clickActionTL = this[Keys.ClickActionTL] ?: 2, + clickActionTC = this[Keys.ClickActionTC] ?: 2, + clickActionTR = this[Keys.ClickActionTR] ?: 1, + clickActionML = this[Keys.ClickActionML] ?: 2, + clickActionMC = this[Keys.ClickActionMC] ?: 0, + clickActionMR = this[Keys.ClickActionMR] ?: 1, + clickActionBL = this[Keys.ClickActionBL] ?: 2, + clickActionBC = this[Keys.ClickActionBC] ?: 1, + clickActionBR = this[Keys.ClickActionBR] ?: 1, + fontFolder = this[Keys.FontFolder] ?: "", + readMenuBgColor = this[Keys.ReadMenuBgColor] ?: 0, + readMenuAccentColor = this[Keys.ReadMenuAccentColor] ?: 0, + readMenuContainerColor = this[Keys.ReadMenuContainerColor] ?: 0, + readMenuBgColorNight = this[Keys.ReadMenuBgColorNight] ?: 0, + readMenuAccentColorNight = this[Keys.ReadMenuAccentColorNight] ?: 0, + readMenuContainerColorNight = this[Keys.ReadMenuContainerColorNight] ?: 0, + readMenuColorMode = this[Keys.ReadMenuColorMode] ?: 1, + readMenuIconShowText = this[Keys.ReadMenuIconShowText] ?: true, + readMenuIconStyle = this[Keys.ReadMenuIconStyle] ?: 0, + readMenuIconItemsPerRow = this[Keys.ReadMenuIconItemsPerRow] ?: 5, + readMenuIconRowCount = this[Keys.ReadMenuIconRowCount] ?: 1, + readMenuBottomCornerRadius = this[Keys.ReadMenuBottomCornerRadius] ?: 0, + readMenuFloatingBottomBar = this[Keys.ReadMenuFloatingBottomBar] ?: false, + readMenuTopBarBlurMode = this[Keys.ReadMenuTopBarBlurMode] ?: ReadMenuBlurMode.None, + readMenuBottomBarBlurMode = this[Keys.ReadMenuBottomBarBlurMode] + ?: ReadMenuBlurMode.None, + readMenuTopBarLiquidGlassButtons = this[Keys.ReadMenuTopBarLiquidGlassButtons] ?: false, + readMenuBottomBarLiquidGlassButtons = this[Keys.ReadMenuBottomBarLiquidGlassButtons] + ?: false, + readMenuTopBarBlurStyle = this[Keys.ReadMenuTopBarBlurStyle] + ?: ReadMenuBlurStyle.Progressive, + readMenuBottomBarBlurStyle = this[Keys.ReadMenuBottomBarBlurStyle] + ?: ReadMenuBlurStyle.Solid, + readMenuBlurRadius = this[Keys.ReadMenuBlurRadius] ?: 24, + readMenuBlurAlpha = this[Keys.ReadMenuBlurAlpha] ?: 60, + readMenuLensRadius = this[Keys.ReadMenuLensRadius] ?: 24f, + readMenuBorderWidth = this[Keys.ReadMenuBorderWidth] ?: 0, + readMenuBorderColor = this[Keys.ReadMenuBorderColor] ?: 0, + readMenuBorderColorNight = this[Keys.ReadMenuBorderColorNight] ?: 0, + readMenuCustomIcons = this[Keys.ReadMenuCustomIcons] ?: "", + titleBarCustomIcons = this[Keys.TitleBarCustomIcons] ?: "", + titleBarIconPosition = this[Keys.TitleBarIconPosition] ?: 0, + showTitleBarIcons = this[Keys.ShowTitleBarIcons] ?: true, + chineseConverterType = this[Keys.ChineseConverterType] ?: 0, + ) + } + + private object Keys { + val ScreenOrientation = stringPreferencesKey(PreferKey.screenOrientation) + val KeepLight = stringPreferencesKey(PreferKey.keepLight) + val HideStatusBar = booleanPreferencesKey(PreferKey.hideStatusBar) + val HideNavigationBar = booleanPreferencesKey(PreferKey.hideNavigationBar) + val PaddingDisplayCutouts = booleanPreferencesKey(PreferKey.paddingDisplayCutouts) + val TitleBarMode = stringPreferencesKey(PreferKey.titleBarMode) + val MenuAlpha = intPreferencesKey(PreferKey.menuAlpha) + val ReadBodyToLh = booleanPreferencesKey(PreferKey.readBodyToLh) + val DefaultSourceChangeAll = booleanPreferencesKey(PreferKey.defaultSourceChangeAll) + val TextFullJustify = booleanPreferencesKey(PreferKey.textFullJustify) + val TextBottomJustify = booleanPreferencesKey(PreferKey.textBottomJustify) + val AdaptSpecialStyle = booleanPreferencesKey(PreferKey.adaptSpecialStyle) + val UseZhLayout = booleanPreferencesKey(PreferKey.useZhLayout) + val ShowBrightnessView = booleanPreferencesKey(PreferKey.showBrightnessView) + val UseUnderline = booleanPreferencesKey(PreferKey.useUnderline) + val ReadSliderMode = stringPreferencesKey(PreferKey.readSliderMode) + val DoubleHorizontalPage = stringPreferencesKey(PreferKey.doublePageHorizontal) + val ProgressBarBehavior = stringPreferencesKey(PreferKey.progressBarBehavior) + val MouseWheelPage = booleanPreferencesKey(PreferKey.mouseWheelPage) + val VolumeKeyPage = booleanPreferencesKey(PreferKey.volumeKeyPage) + val VolumeKeyPageOnPlay = booleanPreferencesKey(PreferKey.volumeKeyPageOnPlay) + val KeyPageOnLongPress = booleanPreferencesKey(PreferKey.keyPageOnLongPress) + val PageTouchSlop = intPreferencesKey(PreferKey.pageTouchSlop) + val SliderVibrator = booleanPreferencesKey(PreferKey.sliderVibrator) + val SelectVibrator = booleanPreferencesKey(PreferKey.selectVibrator) + val AutoChangeSource = booleanPreferencesKey(PreferKey.autoChangeSource) + val SelectText = booleanPreferencesKey(PreferKey.selectText) + val NoAnimScrollPage = booleanPreferencesKey(PreferKey.noAnimScrollPage) + val ClickImgWay = stringPreferencesKey(PreferKey.clickImgWay) + val OptimizeRender = booleanPreferencesKey(PreferKey.optimizeRender) + val DisableReturnKey = booleanPreferencesKey(PreferKey.disableReturnKey) + val ExpandTextMenu = booleanPreferencesKey(PreferKey.expandTextMenu) + val ShowReadTitleAddition = booleanPreferencesKey(PreferKey.showReadTitleAddition) + val AutoReadSpeed = intPreferencesKey(PreferKey.autoReadSpeed) + val PrevKeys = stringPreferencesKey(PreferKey.prevKeys) + val NextKeys = stringPreferencesKey(PreferKey.nextKeys) + val TocUiUseReplace = booleanPreferencesKey(PreferKey.tocUiUseReplace) + val TocCountWords = booleanPreferencesKey(PreferKey.tocCountWords) + val ReadStyleSelect = intPreferencesKey(PreferKey.readStyleSelect) + val ComicStyleSelect = intPreferencesKey(PreferKey.comicStyleSelect) + val ShareLayout = booleanPreferencesKey(PreferKey.shareLayout) + val ReadBarStyleFollowPage = booleanPreferencesKey(PreferKey.readBarStyleFollowPage) + val ReadBarStyle = intPreferencesKey(PreferKey.readBarStyle) + val ClickActionTL = intPreferencesKey(PreferKey.clickActionTL) + val ClickActionTC = intPreferencesKey(PreferKey.clickActionTC) + val ClickActionTR = intPreferencesKey(PreferKey.clickActionTR) + val ClickActionML = intPreferencesKey(PreferKey.clickActionML) + val ClickActionMC = intPreferencesKey(PreferKey.clickActionMC) + val ClickActionMR = intPreferencesKey(PreferKey.clickActionMR) + val ClickActionBL = intPreferencesKey(PreferKey.clickActionBL) + val ClickActionBC = intPreferencesKey(PreferKey.clickActionBC) + val ClickActionBR = intPreferencesKey(PreferKey.clickActionBR) + val FontFolder = stringPreferencesKey(PreferKey.fontFolder) + val ReadMenuBgColor = intPreferencesKey(PreferKey.readMenuBgColor) + val ReadMenuAccentColor = intPreferencesKey(PreferKey.readMenuAccentColor) + val ReadMenuContainerColor = intPreferencesKey(PreferKey.readMenuContainerColor) + val ReadMenuBgColorNight = intPreferencesKey(PreferKey.readMenuBgColorNight) + val ReadMenuAccentColorNight = intPreferencesKey(PreferKey.readMenuAccentColorNight) + val ReadMenuContainerColorNight = intPreferencesKey(PreferKey.readMenuContainerColorNight) + val ReadMenuColorMode = intPreferencesKey(PreferKey.readMenuColorMode) + val ReadMenuIconShowText = booleanPreferencesKey(PreferKey.readMenuIconShowText) + val ReadMenuIconStyle = intPreferencesKey(PreferKey.readMenuIconStyle) + val ReadMenuIconItemsPerRow = intPreferencesKey(PreferKey.readMenuIconItemsPerRow) + val ReadMenuIconRowCount = intPreferencesKey(PreferKey.readMenuIconRowCount) + val ReadMenuBottomCornerRadius = intPreferencesKey(PreferKey.readMenuBottomCornerRadius) + val ReadMenuFloatingBottomBar = booleanPreferencesKey(PreferKey.readMenuFloatingBottomBar) + val ReadMenuTopBarBlurMode = intPreferencesKey(PreferKey.readMenuTopBarBlurMode) + val ReadMenuBottomBarBlurMode = intPreferencesKey(PreferKey.readMenuBottomBarBlurMode) + val ReadMenuTopBarLiquidGlassButtons = + booleanPreferencesKey(PreferKey.readMenuTopBarLiquidGlassButtons) + val ReadMenuBottomBarLiquidGlassButtons = + booleanPreferencesKey(PreferKey.readMenuBottomBarLiquidGlassButtons) + val ReadMenuTopBarBlurStyle = intPreferencesKey(PreferKey.readMenuTopBarBlurStyle) + val ReadMenuBottomBarBlurStyle = intPreferencesKey(PreferKey.readMenuBottomBarBlurStyle) + val ReadMenuBlurRadius = intPreferencesKey(PreferKey.readMenuBlurRadius) + val ReadMenuBlurAlpha = intPreferencesKey(PreferKey.readMenuBlurAlpha) + val ReadMenuLensRadius = floatPreferencesKey(PreferKey.readMenuLensRadius) + val ReadMenuBorderWidth = intPreferencesKey(PreferKey.readMenuBorderWidth) + val ReadMenuBorderColor = intPreferencesKey(PreferKey.readMenuBorderColor) + val ReadMenuBorderColorNight = intPreferencesKey(PreferKey.readMenuBorderColorNight) + val ReadMenuCustomIcons = stringPreferencesKey(PreferKey.readMenuCustomIcons) + val TitleBarCustomIcons = stringPreferencesKey(PreferKey.titleBarCustomIcons) + val TitleBarIconPosition = intPreferencesKey(PreferKey.titleBarIconPosition) + val ShowTitleBarIcons = booleanPreferencesKey(PreferKey.showTitleBarIcons) + val ChineseConverterType = intPreferencesKey(PreferKey.chineseConverterType) + } +} diff --git a/app/src/main/java/io/legado/app/data/repository/ReadStyleRepository.kt b/app/src/main/java/io/legado/app/data/repository/ReadStyleRepository.kt new file mode 100644 index 000000000..565c03f13 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/ReadStyleRepository.kt @@ -0,0 +1,255 @@ +package io.legado.app.data.repository + +import androidx.core.graphics.toColorInt +import io.legado.app.constant.AppLog +import io.legado.app.help.DefaultData +import io.legado.app.help.config.ReadBookConfig +import io.legado.app.help.config.ReadStyleResolver +import io.legado.app.utils.FileUtils +import io.legado.app.utils.GSON +import io.legado.app.utils.compress.ZipUtils +import io.legado.app.utils.createFolderReplace +import io.legado.app.utils.externalCache +import io.legado.app.utils.externalFiles +import io.legado.app.utils.fromJsonArray +import io.legado.app.utils.fromJsonObject +import io.legado.app.utils.getFile +import io.legado.app.utils.printOnDebug +import splitties.init.appCtx +import java.io.File +import java.io.InputStream + +class ReadStyleRepository { + + val configFilePath: String = + FileUtils.getPath(appCtx.filesDir, ReadBookConfig.configFileName) + val shareConfigFilePath: String = + FileUtils.getPath(appCtx.filesDir, ReadBookConfig.shareConfigFileName) + + fun readConfigs(): List { + val configFile = File(configFilePath) + if (configFile.exists()) { + try { + return GSON.fromJsonArray(configFile.readText()).getOrThrow() + } catch (e: Exception) { + AppLog.put("读取排版配置文件出错", e) + } + } + return DefaultData.readConfigs + } + + fun readShareConfig(fallbackConfig: ReadBookConfig.Config): ReadBookConfig.Config { + val configFile = File(shareConfigFilePath) + if (configFile.exists()) { + try { + return GSON.fromJsonObject(configFile.readText()).getOrThrow() + } catch (e: Exception) { + e.printOnDebug() + } + } + return fallbackConfig + } + + fun save( + configs: List, + shareConfig: ReadBookConfig.Config + ) { + GSON.toJson(configs).let { + FileUtils.delete(configFilePath) + FileUtils.createFileIfNotExist(configFilePath).writeText(it) + } + GSON.toJson(shareConfig).let { + FileUtils.delete(shareConfigFilePath) + FileUtils.createFileIfNotExist(shareConfigFilePath).writeText(it) + } + } + + fun getAllPicBgStr(configs: List): ArrayList { + val list = arrayListOf() + configs.forEach { + if (it.bgType == 2) { + list.add(it.bgStr) + } + if (it.bgTypeNight == 2) { + list.add(it.bgStrNight) + } + if (it.bgTypeEInk == 2) { + list.add(it.bgStrEInk) + } + } + return list + } + + fun clearBgAndCache(configs: List) { + val bgs = hashSetOf() + configs.forEach { config -> + repeat(3) { + config.getBgPath(it)?.let { path -> + bgs.add(path) + } + } + } + appCtx.externalFiles.getFile("bg").listFiles()?.forEach { + if (!bgs.contains(it.absolutePath)) { + it.delete() + } + } + FileUtils.delete(appCtx.externalCache.getFile("readConfig")) + FileUtils.delete(FileUtils.getPath(appCtx.externalCache, "readConfig.zip")) + } + + fun saveBackgroundImage(inputStream: InputStream, displayName: String?): String { + val bgDir = appCtx.externalFiles.getFile("bg") + bgDir.mkdirs() + val safeName = displayName + ?.let { File(it).name } + ?.takeIf { it.isNotBlank() } + ?: "read_bg.jpg" + val baseName = File(safeName).nameWithoutExtension.ifBlank { "read_bg" } + val extension = File(safeName).extension.ifBlank { "jpg" } + val bgFile = File(bgDir, "${baseName}_${System.currentTimeMillis()}.$extension") + if (!FileUtils.writeInputStream(bgFile, inputStream)) { + error("save read background image failed") + } + return bgFile.absolutePath + } + + fun export(config: ReadBookConfig.Config): ByteArray { + val exportDir = appCtx.externalCache.getFile("readConfigExport") + exportDir.createFolderReplace() + val exportConfig = config.copy( + regexColorRules = ArrayList(config.regexColorRules.map { it.copy() }) + ) + val exportFiles = arrayListOf() + + addBackgroundFile(exportDir, exportConfig, 0, exportFiles) + addBackgroundFile(exportDir, exportConfig, 1, exportFiles) + addBackgroundFile(exportDir, exportConfig, 2, exportFiles) + exportConfig.textFont = addAssetFile(exportDir, exportConfig.textFont, exportFiles) + exportConfig.titleFont = addAssetFile(exportDir, exportConfig.titleFont, exportFiles) + + val configFile = exportDir.getFile(ReadBookConfig.configFileName) + configFile.writeText(GSON.toJson(exportConfig)) + exportFiles.add(configFile) + + val zipFile = appCtx.externalCache.getFile("readConfig.zip") + FileUtils.delete(zipFile) + ZipUtils.zipFiles(exportFiles, zipFile) + return zipFile.readBytes() + } + + fun import(byteArray: ByteArray): ReadBookConfig.Config { + val configZipPath = FileUtils.getPath(appCtx.externalCache, "readConfig.zip") + FileUtils.delete(configZipPath) + val zipFile = FileUtils.createFileIfNotExist(configZipPath) + zipFile.writeBytes(byteArray) + val configDir = appCtx.externalCache.getFile("readConfig") + configDir.createFolderReplace() + ZipUtils.unZipToPath(zipFile, configDir) + val configFile = configDir.getFile(ReadBookConfig.configFileName) + val config: ReadBookConfig.Config = + GSON.fromJsonObject(configFile.readText()).getOrThrow() + + config.textFont = importFont(configDir, config.textFont) + config.titleFont = importFont(configDir, config.titleFont) + + if (config.bgType == 2) { + val bgName = FileUtils.getName(config.bgStr) + config.bgStr = bgName + val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName) + if (!FileUtils.exist(bgPath)) { + val bgFile = configDir.getFile(bgName) + if (bgFile.exists()) { + bgFile.copyTo(File(bgPath)) + } + } + config.bgStr = bgPath + } else if (config.bgTypeNight == 0) { + config.bgStrNight.toColorInt() + } + if (config.bgTypeNight == 2) { + val bgName = FileUtils.getName(config.bgStrNight) + config.bgStrNight = bgName + val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName) + if (!FileUtils.exist(bgPath)) { + val bgFile = configDir.getFile(bgName) + if (bgFile.exists()) { + bgFile.copyTo(File(bgPath)) + } + } + config.bgStrNight = bgPath + } + if (config.bgTypeEInk == 2) { + val bgName = FileUtils.getName(config.bgStrEInk) + config.bgStrEInk = bgName + val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName) + if (!FileUtils.exist(bgPath)) { + val bgFile = configDir.getFile(bgName) + if (bgFile.exists()) { + bgFile.copyTo(File(bgPath)) + } + } + config.bgStrEInk = bgPath + } else if (config.bgTypeEInk == 0) { + config.bgStrEInk.toColorInt() + } + config.curTextColor() + config.curTextAccentColor() + config.curTextShadowColor() + return config + } + + private fun addBackgroundFile( + exportDir: File, + config: ReadBookConfig.Config, + bgIndex: Int, + exportFiles: MutableList + ) { + val sourcePath = ReadStyleResolver.backgroundPath(config, bgIndex) ?: return + val exportedName = addAssetFile(exportDir, sourcePath, exportFiles) + if (exportedName.isBlank()) { + return + } + when (bgIndex) { + 0 -> config.bgStr = exportedName + 1 -> config.bgStrNight = exportedName + 2 -> config.bgStrEInk = exportedName + } + } + + private fun addAssetFile( + exportDir: File, + sourcePath: String, + exportFiles: MutableList + ): String { + if (sourcePath.isBlank()) { + return "" + } + val source = File(sourcePath) + if (!source.exists() || !source.isFile) { + return "" + } + val target = exportDir.getFile(source.name) + source.copyTo(target, overwrite = true) + if (exportFiles.none { it.absolutePath == target.absolutePath }) { + exportFiles.add(target) + } + return target.name + } + + private fun importFont(configDir: File, fontName: String): String { + if (fontName.isEmpty()) { + return "" + } + val fontPath = FileUtils.getPath(appCtx.externalFiles, "font", fontName) + val fontFile = configDir.getFile(fontName) + return if (fontFile.exists()) { + if (!FileUtils.exist(fontPath)) { + fontFile.copyTo(File(fontPath)) + } + fontPath + } else { + "" + } + } +} diff --git a/app/src/main/java/io/legado/app/data/repository/SettingsRepository.kt b/app/src/main/java/io/legado/app/data/repository/SettingsRepository.kt index f7a95a757..3b55c5fee 100644 --- a/app/src/main/java/io/legado/app/data/repository/SettingsRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/SettingsRepository.kt @@ -84,6 +84,17 @@ class SettingsRepository(private val context: Context) { suspend fun putString(key: String, value: String) = updatePreference(stringPreferencesKey(key), value) + suspend fun putStrings(values: Map) { + dataStore.edit { preferences -> + values.forEach { (key, value) -> + preferences[stringPreferencesKey(key)] = value + } + } + values.forEach { (key, value) -> + syncToSharedPrefs(key, value) + } + } + // Int 类型的快捷访问 fun getInt(key: String, defaultValue: Int = 0): Flow = getPreference(intPreferencesKey(key), defaultValue) diff --git a/app/src/main/java/io/legado/app/di/appDatabaseModule.kt b/app/src/main/java/io/legado/app/di/appDatabaseModule.kt index a99a74df3..09fe510bf 100644 --- a/app/src/main/java/io/legado/app/di/appDatabaseModule.kt +++ b/app/src/main/java/io/legado/app/di/appDatabaseModule.kt @@ -38,4 +38,5 @@ val appDatabaseModule = module { factory { get().serverDao } factory { get().homepageModuleDao } factory { get().homepageCustomSetDao } + factory { get().highlightRuleDao } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index 49486a793..ffee68a2f 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/app/src/main/java/io/legado/app/di/appModule.kt @@ -25,7 +25,11 @@ import io.legado.app.data.repository.ExploreRepositoryImpl import io.legado.app.data.repository.HomepageModulesRepository import io.legado.app.data.repository.LlmTranslateRepositoryImpl import io.legado.app.data.repository.LocalBookRepository +import io.legado.app.data.repository.MangaSettingsRepository +import io.legado.app.data.repository.ReadBookStyleConfigRepository +import io.legado.app.data.repository.ReadAloudSettingsRepository import io.legado.app.data.repository.ReadRecordRepository +import io.legado.app.data.repository.ReadSettingsRepository import io.legado.app.data.repository.RemoteBookRepository import io.legado.app.data.repository.RssRepository import io.legado.app.data.repository.SearchContentRepository @@ -57,11 +61,13 @@ import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase import io.legado.app.domain.usecase.BatchCacheDownloadUseCase import io.legado.app.domain.usecase.CacheBookChaptersUseCase import io.legado.app.domain.usecase.ChangeBookSourceUseCase +import io.legado.app.domain.usecase.ChangeSourceSearchUseCase import io.legado.app.domain.usecase.ClearBookCacheUseCase import io.legado.app.domain.usecase.DeleteBooksUseCase import io.legado.app.domain.usecase.ExploreBooksUseCase import io.legado.app.domain.usecase.ExploreKindUiUseCase import io.legado.app.domain.usecase.ExportBookshelfUseCase +import io.legado.app.domain.usecase.GetChapterContentUseCase import io.legado.app.domain.usecase.GetReadingProgressUseCase import io.legado.app.domain.usecase.ImportBookshelfUseCase import io.legado.app.domain.usecase.RefreshTocUseCase @@ -85,6 +91,7 @@ import io.legado.app.ui.book.cache.manage.BookCacheManageViewModel import io.legado.app.ui.book.changecover.ChangeCoverViewModel import io.legado.app.ui.book.changesource.ChangeBookSourceComposeViewModel import io.legado.app.ui.book.changesource.ChangeBookSourceViewModel +import io.legado.app.ui.book.changesource.ChangeChapterSourceViewModel import io.legado.app.ui.book.explore.ExploreShowViewModel import io.legado.app.ui.book.group.GroupViewModel import io.legado.app.ui.book.import.local.ImportBookViewModel @@ -147,6 +154,10 @@ val appModule = module { singleOf(::SearchContentRepository) singleOf(::RemoteBookRepository) singleOf(::SettingsRepository) + singleOf(::ReadSettingsRepository) + singleOf(::ReadAloudSettingsRepository) + singleOf(::ReadBookStyleConfigRepository) + singleOf(::MangaSettingsRepository) singleOf(::LocalPreferencesRepository) singleOf(::ExploreBooksUseCase) singleOf(::ExploreKindUiUseCase) @@ -194,6 +205,8 @@ val appModule = module { single { get() } single { get() } singleOf(::SearchBooksUseCase) + singleOf(::ChangeSourceSearchUseCase) + singleOf(::GetChapterContentUseCase) single { LlmTranslateRepositoryImpl() } single { DictionaryRepositoryImpl() } singleOf(::TranslateChapterUseCase) @@ -252,12 +265,16 @@ val appModule = module { application = get(), getReadingProgressUseCase = get(), uploadReadingProgressUseCase = get(), - translateChapterUseCase = get() + translateChapterUseCase = get(), + readSettingsRepository = get(), + readBookStyleConfigRepository = get(), + readAloudSettingsRepository = get() ) } viewModelOf(::ChangeCoverViewModel) viewModelOf(::ChangeBookSourceComposeViewModel) viewModelOf(::ChangeBookSourceViewModel) + viewModelOf(::ChangeChapterSourceViewModel) viewModelOf(::ExploreViewModel) viewModelOf(::RssViewModel) viewModelOf(::SearchViewModel) diff --git a/app/src/main/java/io/legado/app/domain/usecase/ChangeSourceSearchUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/ChangeSourceSearchUseCase.kt new file mode 100644 index 000000000..e1126c76b --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/usecase/ChangeSourceSearchUseCase.kt @@ -0,0 +1,276 @@ +package io.legado.app.domain.usecase + +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.SearchBook +import io.legado.app.domain.gateway.BookSearchGateway +import io.legado.app.help.book.BookHelp +import io.legado.app.help.book.ContentProcessor +import io.legado.app.help.book.primaryStr +import io.legado.app.help.book.releaseHtmlData +import io.legado.app.help.config.AppConfig +import io.legado.app.help.source.SourceHelp +import io.legado.app.model.webBook.WebBook +import io.legado.app.ui.book.changesource.ObservableSourceConfig +import io.legado.app.ui.config.otherConfig.OtherConfig +import io.legado.app.utils.internString +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withTimeout +import java.util.concurrent.ConcurrentHashMap + +sealed interface ChangeSourceSearchEvent { + data object Started : ChangeSourceSearchEvent + data class Progress( + val processedSources: Int, + val totalSources: Int, + val resultCount: Int, + val sourceName: String, + ) : ChangeSourceSearchEvent + + data class Result(val searchBook: SearchBook) : ChangeSourceSearchEvent + data class Finished(val isEmpty: Boolean) : ChangeSourceSearchEvent +} + +class ChangeSourceSearchUseCase( + private val gateway: BookSearchGateway, +) { + private val threadCount = OtherConfig.threadCount + private val contentProcessor by lazy { + // ContentProcessor needs the old book - will be set before search + null as ContentProcessor? + } + + // Shared state for TOC cache + private val tocMap = ConcurrentHashMap>() + private val bookMap = ConcurrentHashMap() + private var tocMapChapterCount = 0 + + fun search( + name: String, + author: String, + scope: io.legado.app.ui.book.search.SearchScope, + oldBook: Book, + fromReadBookActivity: Boolean, + ): Flow = flow { + val contentProcessor = ContentProcessor.get(oldBook) + val bookSourceParts = scope.getBookSourceParts() + if (bookSourceParts.isEmpty()) { + throw io.legado.app.exception.NoStackTraceException("启用书源为空") + } + + tocMap.clear() + bookMap.clear() + tocMapChapterCount = 0 + + emit(ChangeSourceSearchEvent.Started) + + var processedSources = 0 + val totalSources = bookSourceParts.size + + for (bs in bookSourceParts) { + currentCoroutineContext().ensureActive() + val source = bs.getBookSource() ?: continue + try { + withTimeout(60000L) { + searchSource( + source, name, author, oldBook, fromReadBookActivity, + contentProcessor + ) + }.forEach { searchBook -> + emit(ChangeSourceSearchEvent.Result(searchBook)) + } + } catch (_: Throwable) { + currentCoroutineContext().ensureActive() + } + processedSources++ + emit( + ChangeSourceSearchEvent.Progress( + processedSources = processedSources, + totalSources = totalSources, + resultCount = 0, + sourceName = source.bookSourceName, + ) + ) + } + + emit(ChangeSourceSearchEvent.Finished(isEmpty = true)) + }.flowOn(Dispatchers.IO) + + private suspend fun searchSource( + source: BookSource, + name: String, + author: String, + oldBook: Book, + fromReadBookActivity: Boolean, + contentProcessor: ContentProcessor, + ): List { + val checkAuthor = AppConfig.changeSourceCheckAuthor + val loadInfo = AppConfig.changeSourceLoadInfo + val loadToc = AppConfig.changeSourceLoadToc + val loadWordCount = AppConfig.changeSourceLoadWordCount + + val resultBooks = WebBook.searchBookAwait( + source, name, + filter = { fName, fAuthor, _ -> + fName == name && (!checkAuthor || fAuthor.contains(author)) + } + ) + + val processedBooks = mutableListOf() + for (searchBook in resultBooks) { + currentCoroutineContext().ensureActive() + when { + loadInfo || loadToc || loadWordCount -> { + val book = searchBook.toBook() + try { + loadBookInfo( + source, + book, + loadToc, + loadWordCount, + oldBook, + fromReadBookActivity, + contentProcessor + ) + val processedSearchBook = book.toSearchBook() + processedBooks.add(processedSearchBook) + } catch (e: Throwable) { + if (e is CancellationException) throw e + processedBooks.add(searchBook) + } + } + + else -> { + processedBooks.add(searchBook) + } + } + } + return processedBooks + } + + private suspend fun loadBookInfo( + source: BookSource, + book: Book, + loadToc: Boolean, + loadWordCount: Boolean, + oldBook: Book, + fromReadBookActivity: Boolean, + contentProcessor: ContentProcessor, + ) { + if (book.tocUrl.isEmpty()) { + WebBook.getBookInfoAwait(source, book) + } + if (loadToc || loadWordCount) { + loadBookToc( + source, + book, + loadWordCount, + oldBook, + fromReadBookActivity, + contentProcessor + ) + } + } + + private suspend fun loadBookToc( + source: BookSource, + book: Book, + loadWordCount: Boolean, + oldBook: Book, + fromReadBookActivity: Boolean, + contentProcessor: ContentProcessor, + ) { + val chapters = WebBook.getChapterListAwait(source, book).getOrThrow() + for (chapter in chapters) { + chapter.internString() + } + if (tocMapChapterCount < 30000) { + tocMapChapterCount += chapters.size + tocMap[book.primaryStr()] = chapters + } + bookMap[book.primaryStr()] = book + book.releaseHtmlData() + if (loadWordCount) { + loadBookWordCount( + source, + book, + chapters, + oldBook, + fromReadBookActivity, + contentProcessor + ) + } + } + + private suspend fun loadBookWordCount( + source: BookSource, + book: Book, + chapters: List, + oldBook: Book, + fromReadBookActivity: Boolean, + contentProcessor: ContentProcessor, + ) { + if (chapters.isEmpty()) return + val chapterIndex = if (fromReadBookActivity) { + BookHelp.getDurChapter(oldBook, chapters) + } else { + chapters.lastIndex + } + if (chapterIndex !in chapters.indices) return + val bookChapter = chapters[chapterIndex] + var title = bookChapter.title.trim() + if (title.length > 20) { + title = title.substring(0, 20) + "…" + } + val startTime = System.currentTimeMillis() + try { + val nextChapterUrl = chapters.getOrNull(chapterIndex + 1)?.url + var content = WebBook.getContentAwait(source, book, bookChapter, nextChapterUrl, false) + content = contentProcessor.getContent(oldBook, bookChapter, content, false).toString() + val len = content.length + val endTime = System.currentTimeMillis() + book.toSearchBook().apply { + chapterWordCountText = "[${chapterIndex + 1}] ${title}\n字数:${len}" + chapterWordCount = len + respondTime = (endTime - startTime).toInt() + } + } catch (t: Throwable) { + if (t is CancellationException) throw t + val endTime = System.currentTimeMillis() + book.toSearchBook().apply { + chapterWordCountText = + "[${chapterIndex + 1}] ${title}\n获取字数失败:${t.localizedMessage}" + chapterWordCount = -1 + respondTime = (endTime - startTime).toInt() + } + } + } + + // Source management + fun topSource(searchBook: SearchBook) { + ObservableSourceConfig.setBookScore(searchBook, 1) + } + + fun bottomSource(searchBook: SearchBook) { + ObservableSourceConfig.setBookScore(searchBook, 0) + } + + fun disableSource(searchBook: SearchBook) { + io.legado.app.data.appDb.bookSourceDao.getBookSource(searchBook.origin)?.let { source -> + source.enabled = false + io.legado.app.data.appDb.bookSourceDao.update(source) + } + } + + fun deleteSource(searchBook: SearchBook) { + SourceHelp.deleteBookSource(searchBook.origin) + io.legado.app.data.appDb.searchBookDao.delete(searchBook) + } +} diff --git a/app/src/main/java/io/legado/app/domain/usecase/GetChapterContentUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/GetChapterContentUseCase.kt new file mode 100644 index 000000000..eaf2d34d9 --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/usecase/GetChapterContentUseCase.kt @@ -0,0 +1,48 @@ +package io.legado.app.domain.usecase + +import io.legado.app.data.dao.BookChapterDao +import io.legado.app.data.dao.BookSourceDao +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookSource +import io.legado.app.exception.NoStackTraceException +import io.legado.app.model.webBook.WebBook + +class GetChapterContentUseCase( + private val bookSourceDao: BookSourceDao, + private val bookChapterDao: BookChapterDao, +) { + + /** + * Get TOC for a book. If tocUrl is empty, fetches book info first. + */ + suspend fun getToc(book: Book): Pair, BookSource> { + val source = bookSourceDao.getBookSource(book.origin) + ?: throw NoStackTraceException("书源不存在") + if (book.tocUrl.isEmpty()) { + WebBook.getBookInfoAwait(source, book) + } + val toc = WebBook.getChapterListAwait(source, book).getOrThrow() + return Pair(toc, source) + } + + /** + * Get content for a specific chapter. + */ + suspend fun getContent( + book: Book, + chapter: BookChapter, + nextChapterUrl: String?, + ): String { + val bookSource = bookSourceDao.getBookSource(book.origin) + ?: throw NoStackTraceException("书源不存在") + return WebBook.getContentAwait(bookSource, book, chapter, nextChapterUrl, false) + } + + /** + * Find the chapter index in a new TOC matching the current chapter. + */ + fun getDurChapterIndex(chapterIndex: Int, chapterTitle: String, toc: List): Int { + return io.legado.app.help.book.BookHelp.getDurChapter(chapterIndex, chapterTitle, toc) + } +} diff --git a/app/src/main/java/io/legado/app/help/DefaultData.kt b/app/src/main/java/io/legado/app/help/DefaultData.kt index 0d70c5993..6a4ca6889 100644 --- a/app/src/main/java/io/legado/app/help/DefaultData.kt +++ b/app/src/main/java/io/legado/app/help/DefaultData.kt @@ -8,7 +8,7 @@ import io.legado.app.data.entities.KeyboardAssist import io.legado.app.data.entities.RssSource import io.legado.app.data.entities.TxtTocRule import io.legado.app.help.config.LocalConfig -import io.legado.app.help.config.OldThemeConfig +import io.legado.app.help.config.ThemeConfigStore import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.model.BookCover @@ -70,12 +70,12 @@ object DefaultData { GSON.fromJsonArray(json).getOrNull() ?: emptyList() } - val themeConfigs: List by lazy { + val themeConfigs: List by lazy { val json = String( - appCtx.assets.open("defaultData${File.separator}${OldThemeConfig.configFileName}") + appCtx.assets.open("defaultData${File.separator}${ThemeConfigStore.configFileName}") .readBytes() ) - GSON.fromJsonArray(json).getOrNull() ?: emptyList() + GSON.fromJsonArray(json).getOrNull() ?: emptyList() } val rssSources: List by lazy { diff --git a/app/src/main/java/io/legado/app/help/JsExtensions.kt b/app/src/main/java/io/legado/app/help/JsExtensions.kt index 60831624d..04d476a73 100644 --- a/app/src/main/java/io/legado/app/help/JsExtensions.kt +++ b/app/src/main/java/io/legado/app/help/JsExtensions.kt @@ -15,7 +15,7 @@ import io.legado.app.constant.AppPattern import io.legado.app.data.entities.BaseSource import io.legado.app.exception.NoStackTraceException import io.legado.app.help.config.AppConfig -import io.legado.app.help.config.OldThemeConfig +import io.legado.app.help.config.ThemeConfigStore import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.http.BackstageWebView import io.legado.app.help.http.CookieManager.cookieJarHeader @@ -1204,12 +1204,12 @@ interface JsExtensions : JsEncodeUtils { */ @JavascriptInterface fun getThemeConfig(): String { - val themeConfig = OldThemeConfig.getDurConfig(appCtx) + val themeConfig = ThemeConfigStore.getDurConfig(appCtx) return GSON.toJson(themeConfig) } fun getThemeConfigMap(): Map { - return OldThemeConfig.getDurConfig(appCtx).toMap() + return ThemeConfigStore.getDurConfig(appCtx).toMap() } } diff --git a/app/src/main/java/io/legado/app/help/config/AppConfig.kt b/app/src/main/java/io/legado/app/help/config/AppConfig.kt index 944b5f8cd..f28dd06d8 100644 --- a/app/src/main/java/io/legado/app/help/config/AppConfig.kt +++ b/app/src/main/java/io/legado/app/help/config/AppConfig.kt @@ -5,6 +5,7 @@ import android.os.Build import io.legado.app.BuildConfig import io.legado.app.constant.PreferKey import io.legado.app.data.appDb +import io.legado.app.data.repository.ReadPreferences import io.legado.app.ui.book.manga.config.MangaScrollMode import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory import io.legado.app.utils.getPrefBoolean @@ -64,8 +65,134 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { var adaptSpecialStyle = appCtx.getPrefBoolean(PreferKey.adaptSpecialStyle, true) var useUnderline = appCtx.getPrefBoolean(PreferKey.useUnderline, false) + private var screenOrientationValue = appCtx.getPrefString(PreferKey.screenOrientation) ?: "0" + private var noAnimScrollPageValue = appCtx.getPrefBoolean(PreferKey.noAnimScrollPage, false) + private var tocUiUseReplaceValue = appCtx.getPrefBoolean(PreferKey.tocUiUseReplace) + private var tocCountWordsValue = appCtx.getPrefBoolean(PreferKey.tocCountWords, true) + private var autoChangeSourceValue = appCtx.getPrefBoolean(PreferKey.autoChangeSource, true) + private var clickImgWayValue = appCtx.getPrefString(PreferKey.clickImgWay, "2") ?: "2" + private var doublePageHorizontalValue = appCtx.getPrefString(PreferKey.doublePageHorizontal, "0") ?: "0" + private var progressBarBehaviorValue = appCtx.getPrefString(PreferKey.progressBarBehavior, "page") ?: "page" + private var keyPageOnLongPressValue = appCtx.getPrefBoolean(PreferKey.keyPageOnLongPress, false) + private var volumeKeyPageValue = appCtx.getPrefBoolean(PreferKey.volumeKeyPage, true) + private var volumeKeyPageOnPlayValue = appCtx.getPrefBoolean(PreferKey.volumeKeyPageOnPlay, true) + private var mouseWheelPageValue = appCtx.getPrefBoolean(PreferKey.mouseWheelPage, true) + private var paddingDisplayCutoutsValue = appCtx.getPrefBoolean(PreferKey.paddingDisplayCutouts, false) + private var pageTouchSlopValue = appCtx.getPrefInt(PreferKey.pageTouchSlop, 0) + private var showReadTitleBarAdditionValue = + appCtx.getPrefBoolean(PreferKey.showReadTitleAddition, true) + private var titleBarModeValue = appCtx.getPrefString(PreferKey.titleBarMode, "1") ?: "1" + private var menuAlphaValue = appCtx.getPrefInt(PreferKey.menuAlpha, 100) + private var readSliderModeValue = appCtx.getPrefString(PreferKey.readSliderMode, "0") ?: "0" + private var readBarStyleFollowPageValue = + appCtx.getPrefBoolean(PreferKey.readBarStyleFollowPage, false) + private var readBarStyleValue = appCtx.getPrefInt(PreferKey.readBarStyle, 0) + private var defaultSourceChangeAllValue = + appCtx.getPrefBoolean(PreferKey.defaultSourceChangeAll, true) + private var sliderVibratorValue = appCtx.getPrefBoolean(PreferKey.sliderVibrator, false) + private var selectVibratorValue = appCtx.getPrefBoolean(PreferKey.selectVibrator, false) + + fun syncReadPreferences(preferences: ReadPreferences) { + optimizeRender = CanvasRecorderFactory.isSupport && preferences.optimizeRender + adaptSpecialStyle = preferences.adaptSpecialStyle + useUnderline = preferences.useUnderline + clickActionTL = preferences.clickActionTL + clickActionTC = preferences.clickActionTC + clickActionTR = preferences.clickActionTR + clickActionML = preferences.clickActionML + clickActionMC = preferences.clickActionMC + clickActionMR = preferences.clickActionMR + clickActionBL = preferences.clickActionBL + clickActionBC = preferences.clickActionBC + clickActionBR = preferences.clickActionBR + screenOrientationValue = preferences.screenOrientation + noAnimScrollPageValue = preferences.noAnimScrollPage + tocUiUseReplaceValue = preferences.tocUiUseReplace + tocCountWordsValue = preferences.tocCountWords + autoChangeSourceValue = preferences.autoChangeSource + clickImgWayValue = preferences.clickImgWay + doublePageHorizontalValue = preferences.doubleHorizontalPage + progressBarBehaviorValue = preferences.progressBarBehavior + keyPageOnLongPressValue = preferences.keyPageOnLongPress + volumeKeyPageValue = preferences.volumeKeyPage + volumeKeyPageOnPlayValue = preferences.volumeKeyPageOnPlay + mouseWheelPageValue = preferences.mouseWheelPage + paddingDisplayCutoutsValue = preferences.paddingDisplayCutouts + pageTouchSlopValue = preferences.pageTouchSlop + showReadTitleBarAdditionValue = preferences.showReadTitleAddition + titleBarModeValue = preferences.titleBarMode + menuAlphaValue = preferences.menuAlpha + readSliderModeValue = preferences.readSliderMode + readBarStyleFollowPageValue = preferences.readBarStyleFollowPage + readBarStyleValue = preferences.readBarStyle + defaultSourceChangeAllValue = preferences.defaultSourceChangeAll + sliderVibratorValue = preferences.sliderVibrator + selectVibratorValue = preferences.selectVibrator + } + + fun updateReadBarStyleCache(value: Int) { + readBarStyleValue = value.coerceIn(0, 2) + } + + private fun syncReadPreferenceFromSharedPreferences(key: String?) { + when (key) { + PreferKey.optimizeRender -> optimizeRender = CanvasRecorderFactory.isSupport && + appCtx.getPrefBoolean(PreferKey.optimizeRender, false) + PreferKey.adaptSpecialStyle -> adaptSpecialStyle = + appCtx.getPrefBoolean(PreferKey.adaptSpecialStyle, true) + PreferKey.useUnderline -> useUnderline = + appCtx.getPrefBoolean(PreferKey.useUnderline, false) + PreferKey.screenOrientation -> screenOrientationValue = + appCtx.getPrefString(PreferKey.screenOrientation) ?: "0" + PreferKey.noAnimScrollPage -> noAnimScrollPageValue = + appCtx.getPrefBoolean(PreferKey.noAnimScrollPage, false) + PreferKey.tocUiUseReplace -> tocUiUseReplaceValue = + appCtx.getPrefBoolean(PreferKey.tocUiUseReplace) + PreferKey.tocCountWords -> tocCountWordsValue = + appCtx.getPrefBoolean(PreferKey.tocCountWords, true) + PreferKey.autoChangeSource -> autoChangeSourceValue = + appCtx.getPrefBoolean(PreferKey.autoChangeSource, true) + PreferKey.clickImgWay -> clickImgWayValue = + appCtx.getPrefString(PreferKey.clickImgWay, "2") ?: "2" + PreferKey.doublePageHorizontal -> doublePageHorizontalValue = + appCtx.getPrefString(PreferKey.doublePageHorizontal, "0") ?: "0" + PreferKey.progressBarBehavior -> progressBarBehaviorValue = + appCtx.getPrefString(PreferKey.progressBarBehavior, "page") ?: "page" + PreferKey.keyPageOnLongPress -> keyPageOnLongPressValue = + appCtx.getPrefBoolean(PreferKey.keyPageOnLongPress, false) + PreferKey.volumeKeyPage -> volumeKeyPageValue = + appCtx.getPrefBoolean(PreferKey.volumeKeyPage, true) + PreferKey.volumeKeyPageOnPlay -> volumeKeyPageOnPlayValue = + appCtx.getPrefBoolean(PreferKey.volumeKeyPageOnPlay, true) + PreferKey.mouseWheelPage -> mouseWheelPageValue = + appCtx.getPrefBoolean(PreferKey.mouseWheelPage, true) + PreferKey.paddingDisplayCutouts -> paddingDisplayCutoutsValue = + appCtx.getPrefBoolean(PreferKey.paddingDisplayCutouts, false) + PreferKey.pageTouchSlop -> pageTouchSlopValue = + appCtx.getPrefInt(PreferKey.pageTouchSlop, 0) + PreferKey.showReadTitleAddition -> showReadTitleBarAdditionValue = + appCtx.getPrefBoolean(PreferKey.showReadTitleAddition, true) + PreferKey.titleBarMode -> titleBarModeValue = + appCtx.getPrefString(PreferKey.titleBarMode, "1") ?: "1" + PreferKey.menuAlpha -> menuAlphaValue = appCtx.getPrefInt(PreferKey.menuAlpha, 100) + PreferKey.readSliderMode -> readSliderModeValue = + appCtx.getPrefString(PreferKey.readSliderMode, "0") ?: "0" + PreferKey.readBarStyleFollowPage -> readBarStyleFollowPageValue = + appCtx.getPrefBoolean(PreferKey.readBarStyleFollowPage, false) + PreferKey.readBarStyle -> readBarStyleValue = + appCtx.getPrefInt(PreferKey.readBarStyle, 0) + PreferKey.defaultSourceChangeAll -> defaultSourceChangeAllValue = + appCtx.getPrefBoolean(PreferKey.defaultSourceChangeAll, true) + PreferKey.sliderVibrator -> sliderVibratorValue = + appCtx.getPrefBoolean(PreferKey.sliderVibrator, false) + PreferKey.selectVibrator -> selectVibratorValue = + appCtx.getPrefBoolean(PreferKey.selectVibrator, false) + } + } + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + syncReadPreferenceFromSharedPreferences(key) when (key) { PreferKey.adaptSpecialStyle -> adaptSpecialStyle = @@ -230,7 +357,7 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { // get() = appCtx.getPrefBoolean(PreferKey.immNavigationBar, true) val screenOrientation: String? - get() = appCtx.getPrefString(PreferKey.screenOrientation) + get() = screenOrientationValue var bookGroupStyle: Int get() = appCtx.getPrefInt(PreferKey.bookGroupStyle, 0) @@ -351,7 +478,7 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { } val noAnimScrollPage: Boolean - get() = appCtx.getPrefBoolean(PreferKey.noAnimScrollPage, false) + get() = noAnimScrollPageValue const val defaultSpeechRate = 5 @@ -468,14 +595,16 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { } var tocUiUseReplace: Boolean - get() = appCtx.getPrefBoolean(PreferKey.tocUiUseReplace) + get() = tocUiUseReplaceValue set(value) { + tocUiUseReplaceValue = value appCtx.putPrefBoolean(PreferKey.tocUiUseReplace, value) } var tocCountWords: Boolean - get() = appCtx.getPrefBoolean(PreferKey.tocCountWords, true) + get() = tocCountWordsValue set(value) { + tocCountWordsValue = value appCtx.putPrefBoolean(PreferKey.tocCountWords, value) } @@ -486,7 +615,7 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { } val autoChangeSource: Boolean - get() = appCtx.getPrefBoolean(PreferKey.autoChangeSource, true) + get() = autoChangeSourceValue var changeSourceLoadInfo: Boolean get() = appCtx.getPrefBoolean(PreferKey.changeSourceLoadInfo) @@ -545,7 +674,7 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { } val clickImgWay: String? - get() = appCtx.getPrefString(PreferKey.clickImgWay) + get() = clickImgWayValue var preDownloadNum get() = appCtx.getPrefInt(PreferKey.preDownloadNum, 10) set(value) { @@ -590,25 +719,25 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { val streamReadAloudAudio get() = appCtx.getPrefBoolean(PreferKey.streamReadAloudAudio, false) val doublePageHorizontal: String? - get() = appCtx.getPrefString(PreferKey.doublePageHorizontal) + get() = doublePageHorizontalValue val progressBarBehavior: String? - get() = appCtx.getPrefString(PreferKey.progressBarBehavior, "page") + get() = progressBarBehaviorValue val keyPageOnLongPress - get() = appCtx.getPrefBoolean(PreferKey.keyPageOnLongPress, false) + get() = keyPageOnLongPressValue val volumeKeyPage - get() = appCtx.getPrefBoolean(PreferKey.volumeKeyPage, true) + get() = volumeKeyPageValue val volumeKeyPageOnPlay - get() = appCtx.getPrefBoolean(PreferKey.volumeKeyPageOnPlay, true) + get() = volumeKeyPageOnPlayValue val mouseWheelPage - get() = appCtx.getPrefBoolean(PreferKey.mouseWheelPage, true) + get() = mouseWheelPageValue val paddingDisplayCutouts - get() = appCtx.getPrefBoolean(PreferKey.paddingDisplayCutouts, false) + get() = paddingDisplayCutoutsValue var searchScope: String get() = appCtx.getPrefString("searchScope") ?: "" @@ -623,8 +752,9 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { } var pageTouchSlop: Int - get() = appCtx.getPrefInt(PreferKey.pageTouchSlop, 0) + get() = pageTouchSlopValue set(value) { + pageTouchSlopValue = value appCtx.putPrefInt(PreferKey.pageTouchSlop, value) } @@ -666,21 +796,24 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { } var showReadTitleBarAddition: Boolean - get() = appCtx.getPrefBoolean(PreferKey.showReadTitleAddition, true) + get() = showReadTitleBarAdditionValue set(value) { + showReadTitleBarAdditionValue = value appCtx.putPrefBoolean(PreferKey.showReadTitleAddition, value) } var readBarStyleFollowPage: Boolean - get() = appCtx.getPrefBoolean(PreferKey.readBarStyleFollowPage, false) + get() = readBarStyleFollowPageValue set(value) { + readBarStyleFollowPageValue = value appCtx.putPrefBoolean(PreferKey.readBarStyleFollowPage, value) } var readBarStyle: Int - get() = appCtx.getPrefInt(PreferKey.readBarStyle, 0) + get() = readBarStyleValue set(value) { - appCtx.putPrefInt(PreferKey.readBarStyle, value) + readBarStyleValue = value.coerceIn(0, 2) + appCtx.putPrefInt(PreferKey.readBarStyle, readBarStyleValue) } var sourceEditMaxLine: Int @@ -757,8 +890,9 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { } var titleBarMode - get() = appCtx.getPrefString(PreferKey.titleBarMode, "1") + get() = titleBarModeValue set(value) { + titleBarModeValue = value appCtx.putPrefString(PreferKey.titleBarMode, value) } @@ -903,14 +1037,16 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { appCtx.putPrefBoolean(PreferKey.enableBlur, value) } var menuAlpha: Int - get() = appCtx.getPrefInt(PreferKey.menuAlpha, 100) + get() = menuAlphaValue set(value) { + menuAlphaValue = value appCtx.putPrefInt(PreferKey.menuAlpha, value) } var readSliderMode - get() = appCtx.getPrefString(PreferKey.readSliderMode, "0") + get() = readSliderModeValue set(value) { + readSliderModeValue = value appCtx.putPrefString(PreferKey.readSliderMode, value) } @@ -945,20 +1081,23 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { } var defaultSourceChangeAll: Boolean - get() = appCtx.getPrefBoolean(PreferKey.defaultSourceChangeAll, true) + get() = defaultSourceChangeAllValue set(value) { + defaultSourceChangeAllValue = value appCtx.putPrefBoolean(PreferKey.defaultSourceChangeAll, value) } var sliderVibrator: Boolean - get() = appCtx.getPrefBoolean(PreferKey.sliderVibrator, false) + get() = sliderVibratorValue set(value) { + sliderVibratorValue = value appCtx.putPrefBoolean(PreferKey.sliderVibrator, value) } var selectVibrator: Boolean - get() = appCtx.getPrefBoolean(PreferKey.selectVibrator, false) + get() = selectVibratorValue set(value) { + selectVibratorValue = value appCtx.putPrefBoolean(PreferKey.selectVibrator, value) } diff --git a/app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt b/app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt index 2aad1d834..69493b433 100644 --- a/app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt +++ b/app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt @@ -1,40 +1,32 @@ package io.legado.app.help.config -import android.graphics.Color +import android.content.Context import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import androidx.annotation.Keep -import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.toColorInt -import com.google.android.material.color.MaterialColors -import io.legado.app.constant.AppLog +import io.legado.app.R import io.legado.app.constant.PageAnim import io.legado.app.constant.PreferKey +import io.legado.app.constant.ReadMenuBlurMode +import io.legado.app.constant.ReadMenuBlurStyle +import io.legado.app.data.repository.ReadPreferences +import io.legado.app.data.repository.ReadStyleRepository import io.legado.app.help.DefaultData import io.legado.app.help.coroutine.Coroutine -import io.legado.app.utils.BitmapUtils -import io.legado.app.utils.FileUtils import io.legado.app.utils.GSON -import io.legado.app.utils.compress.ZipUtils -import io.legado.app.utils.createFolderReplace -import io.legado.app.utils.externalCache -import io.legado.app.utils.externalFiles -import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonObject -import io.legado.app.utils.getFile import io.legado.app.utils.getMeanColor import io.legado.app.utils.getPrefBoolean +import io.legado.app.utils.getPrefFloat import io.legado.app.utils.getPrefInt import io.legado.app.utils.getPrefString import io.legado.app.utils.hexString -import io.legado.app.utils.printOnDebug import io.legado.app.utils.putPrefBoolean import io.legado.app.utils.putPrefInt -import io.legado.app.utils.putPrefString -import io.legado.app.utils.resizeAndRecycle import splitties.init.appCtx -import java.io.File +import java.io.InputStream /** * 阅读界面配置 @@ -42,10 +34,33 @@ import java.io.File @Suppress("ConstPropertyName") @Keep object ReadBookConfig { + private val readStyleRepository = ReadStyleRepository() + + // region Tip position constants + const val tipNone = 0 + const val tipChapterTitle = 1 + const val tipTime = 2 + const val tipBattery = 3 + const val tipBatteryPercentage = 10 + const val tipPage = 4 + const val tipTotalProgress = 5 + const val tipPageAndTotal = 6 + const val tipBookName = 7 + const val tipTimeBattery = 8 + const val tipTimeBatteryPercentage = 9 + const val tipTotalProgress1 = 11 + const val tipChapterTitleArrow = 12 + const val tipBatteryInside = 13 + const val tipBatteryIcon = 14 + const val tipBatteryClassic = 15 + const val tipTimeBatteryClassic = 16 + const val tipChapterTitleArrowClassic = 17 + // endregion + const val configFileName = "readConfig.json" const val shareConfigFileName = "shareReadConfig.json" - val configFilePath = FileUtils.getPath(appCtx.filesDir, configFileName) - val shareConfigFilePath = FileUtils.getPath(appCtx.filesDir, shareConfigFileName) + val configFilePath: String get() = readStyleRepository.configFilePath + val shareConfigFilePath: String get() = readStyleRepository.shareConfigFilePath val configList: ArrayList = arrayListOf() lateinit var shareConfig: Config var durConfig @@ -63,7 +78,7 @@ object ReadBookConfig { val textColor: Int get() = durConfig.curTextColor() val textAccentColor: Int get() = durConfig.curTextAccentColor() val textShadowColor: Int get() = durConfig.curTextShadowColor() - val menuColor: Int get() = durConfig.curMenuAc() + val menuColor: Int get() = readMenuAccentColor init { initConfigs() initShareConfig() @@ -78,34 +93,14 @@ object ReadBookConfig { } fun initConfigs() { - val configFile = File(configFilePath) - var configs: List? = null - if (configFile.exists()) { - try { - val json = configFile.readText() - configs = GSON.fromJsonArray(json).getOrThrow() - } catch (e: Exception) { - AppLog.put("读取排版配置文件出错", e) - } - } - (configs ?: DefaultData.readConfigs).let { + readStyleRepository.readConfigs().let { configList.clear() configList.addAll(it) } } fun initShareConfig() { - val configFile = File(shareConfigFilePath) - var c: Config? = null - if (configFile.exists()) { - try { - val json = configFile.readText() - c = GSON.fromJsonObject(json).getOrThrow() - } catch (e: Exception) { - e.printOnDebug() - } - } - shareConfig = c ?: configList.getOrNull(5) ?: Config() + shareConfig = readStyleRepository.readShareConfig(configList.getOrNull(5) ?: Config()) } fun upBg(width: Int, height: Int) { @@ -121,32 +116,13 @@ object ReadBookConfig { fun save() { Coroutine.async { synchronized(this) { - GSON.toJson(configList).let { - FileUtils.delete(configFilePath) - FileUtils.createFileIfNotExist(configFilePath).writeText(it) - } - GSON.toJson(shareConfig).let { - FileUtils.delete(shareConfigFilePath) - FileUtils.createFileIfNotExist(shareConfigFilePath).writeText(it) - } + readStyleRepository.save(configList, shareConfig) } } } fun getAllPicBgStr(): ArrayList { - val list = arrayListOf() - configList.forEach { - if (it.bgType == 2) { - list.add(it.bgStr) - } - if (it.bgTypeNight == 2) { - list.add(it.bgStrNight) - } - if (it.bgTypeEInk == 2) { - list.add(it.bgStrEInk) - } - } - return list + return readStyleRepository.getAllPicBgStr(configList) } fun deleteDur(): Boolean { @@ -165,22 +141,7 @@ object ReadBookConfig { } fun clearBgAndCache() { - val bgs = hashSetOf() - configList.forEach { config -> - repeat(3) { - config.getBgPath(it)?.let { path -> - bgs.add(path) - } - } - } - appCtx.externalFiles.getFile("bg").listFiles()?.forEach { - if (!bgs.contains(it.absolutePath)) { - it.delete() - } - } - FileUtils.delete(appCtx.externalCache.getFile("readConfig")) - val configZipPath = FileUtils.getPath(appCtx.externalCache, "readConfig.zip") - FileUtils.delete(configZipPath) + readStyleRepository.clearBgAndCache(configList) } private fun resetAll() { @@ -191,13 +152,22 @@ object ReadBookConfig { } } - //配置写入读取 - var readBodyToLh = appCtx.getPrefBoolean(PreferKey.readBodyToLh, true) - var autoReadSpeed = appCtx.getPrefInt(PreferKey.autoReadSpeed, 10) + // Runtime compatibility snapshot. New write/read flows should use ReadSettingsRepository. + private var readBodyToLhValue = appCtx.getPrefBoolean(PreferKey.readBodyToLh, true) + var readBodyToLh: Boolean + get() = readBodyToLhValue set(value) { - field = value + readBodyToLhValue = value + } + + private var autoReadSpeedValue = appCtx.getPrefInt(PreferKey.autoReadSpeed, 10) + var autoReadSpeed: Int + get() = autoReadSpeedValue + set(value) { + autoReadSpeedValue = value appCtx.putPrefInt(PreferKey.autoReadSpeed, value) } + var styleSelect: Int get() = if (isComic) comicStyleSelect else readStyleSelect set(value) { @@ -207,27 +177,331 @@ object ReadBookConfig { readStyleSelect = value } } - var readStyleSelect = appCtx.getPrefInt(PreferKey.readStyleSelect) + + private var readStyleSelectValue = appCtx.getPrefInt(PreferKey.readStyleSelect) + var readStyleSelect: Int + get() = readStyleSelectValue set(value) { - field = value + readStyleSelectValue = value if (appCtx.getPrefInt(PreferKey.readStyleSelect) != value) { appCtx.putPrefInt(PreferKey.readStyleSelect, value) } } - var comicStyleSelect = appCtx.getPrefInt(PreferKey.comicStyleSelect, readStyleSelect) + + private var comicStyleSelectValue = appCtx.getPrefInt(PreferKey.comicStyleSelect, readStyleSelect) + var comicStyleSelect: Int + get() = comicStyleSelectValue set(value) { - field = value + comicStyleSelectValue = value if (appCtx.getPrefInt(PreferKey.comicStyleSelect) != value) { appCtx.putPrefInt(PreferKey.comicStyleSelect, value) } } - var shareLayout = appCtx.getPrefBoolean(PreferKey.shareLayout) + + private var shareLayoutValue = appCtx.getPrefBoolean(PreferKey.shareLayout) + var shareLayout: Boolean + get() = shareLayoutValue set(value) { - field = value + shareLayoutValue = value if (appCtx.getPrefBoolean(PreferKey.shareLayout) != value) { appCtx.putPrefBoolean(PreferKey.shareLayout, value) } } + + private var textFullJustifyValue = appCtx.getPrefBoolean(PreferKey.textFullJustify, true) + private var textBottomJustifyValue = appCtx.getPrefBoolean(PreferKey.textBottomJustify, true) + private var hideStatusBarValue = appCtx.getPrefBoolean(PreferKey.hideStatusBar) + private var hideNavigationBarValue = appCtx.getPrefBoolean(PreferKey.hideNavigationBar) + private var useZhLayoutValue = appCtx.getPrefBoolean(PreferKey.useZhLayout) + private var readMenuBgColorValue = appCtx.getPrefInt(PreferKey.readMenuBgColor) + private var readMenuAccentColorValue = appCtx.getPrefInt(PreferKey.readMenuAccentColor) + private var readMenuContainerColorValue = appCtx.getPrefInt(PreferKey.readMenuContainerColor) + private var readMenuBgColorNightValue = appCtx.getPrefInt(PreferKey.readMenuBgColorNight) + private var readMenuAccentColorNightValue = appCtx.getPrefInt(PreferKey.readMenuAccentColorNight) + private var readMenuContainerColorNightValue = appCtx.getPrefInt(PreferKey.readMenuContainerColorNight) + private var readMenuColorModeValue = appCtx.getPrefInt(PreferKey.readMenuColorMode, 1) + private var readMenuIconShowTextValue = appCtx.getPrefBoolean(PreferKey.readMenuIconShowText, true) + private var readMenuIconStyleValue = appCtx.getPrefInt(PreferKey.readMenuIconStyle) + private var readMenuIconItemsPerRowValue = appCtx.getPrefInt(PreferKey.readMenuIconItemsPerRow, 5) + private var readMenuIconRowCountValue = appCtx.getPrefInt(PreferKey.readMenuIconRowCount, 1) + private var readMenuBottomCornerRadiusValue = appCtx.getPrefInt(PreferKey.readMenuBottomCornerRadius) + private var readMenuFloatingBottomBarValue = appCtx.getPrefBoolean(PreferKey.readMenuFloatingBottomBar) + private var readMenuTopBarBlurModeValue = appCtx.getPrefInt( + PreferKey.readMenuTopBarBlurMode, + ReadMenuBlurMode.None + ) + private var readMenuBottomBarBlurModeValue = appCtx.getPrefInt( + PreferKey.readMenuBottomBarBlurMode, + ReadMenuBlurMode.None + ) + private var readMenuTopBarLiquidGlassButtonsValue = appCtx.getPrefBoolean( + PreferKey.readMenuTopBarLiquidGlassButtons + ) + private var readMenuBottomBarLiquidGlassButtonsValue = appCtx.getPrefBoolean( + PreferKey.readMenuBottomBarLiquidGlassButtons + ) + private var readMenuTopBarBlurStyleValue = appCtx.getPrefInt( + PreferKey.readMenuTopBarBlurStyle, + ReadMenuBlurStyle.Progressive + ) + private var readMenuBottomBarBlurStyleValue = appCtx.getPrefInt( + PreferKey.readMenuBottomBarBlurStyle, + ReadMenuBlurStyle.Solid + ) + private var readMenuBlurRadiusValue = appCtx.getPrefInt(PreferKey.readMenuBlurRadius, 24) + private var readMenuBlurAlphaValue = appCtx.getPrefInt(PreferKey.readMenuBlurAlpha, 60) + private var readMenuLensRadiusValue = appCtx.getPrefFloat(PreferKey.readMenuLensRadius, 24f) + private var readMenuBorderWidthValue = appCtx.getPrefInt(PreferKey.readMenuBorderWidth) + private var readMenuBorderColorValue = appCtx.getPrefInt(PreferKey.readMenuBorderColor) + private var readMenuBorderColorNightValue = appCtx.getPrefInt(PreferKey.readMenuBorderColorNight) + private var readMenuCustomIconsValue = + parseReadMenuCustomIcons(appCtx.getPrefString(PreferKey.readMenuCustomIcons)) + private var titleBarCustomIconsValue = + parseReadMenuCustomIcons(appCtx.getPrefString(PreferKey.titleBarCustomIcons)) + private var titleBarIconPositionValue = appCtx.getPrefInt(PreferKey.titleBarIconPosition) + private var showTitleBarIconsValue = appCtx.getPrefBoolean(PreferKey.showTitleBarIcons, true) + + fun syncPreferences(preferences: ReadPreferences) { + readBodyToLhValue = preferences.readBodyToLh + autoReadSpeedValue = preferences.autoReadSpeed + readStyleSelectValue = preferences.readStyleSelect + comicStyleSelectValue = preferences.comicStyleSelect + shareLayoutValue = preferences.shareLayout + textFullJustifyValue = preferences.textFullJustify + textBottomJustifyValue = preferences.textBottomJustify + hideStatusBarValue = preferences.hideStatusBar + hideNavigationBarValue = preferences.hideNavigationBar + useZhLayoutValue = preferences.useZhLayout + readMenuBgColorValue = preferences.readMenuBgColor + readMenuAccentColorValue = preferences.readMenuAccentColor + readMenuContainerColorValue = preferences.readMenuContainerColor + readMenuBgColorNightValue = preferences.readMenuBgColorNight + readMenuAccentColorNightValue = preferences.readMenuAccentColorNight + readMenuContainerColorNightValue = preferences.readMenuContainerColorNight + readMenuColorModeValue = preferences.readMenuColorMode + readMenuIconShowTextValue = preferences.readMenuIconShowText + readMenuIconStyleValue = preferences.readMenuIconStyle + readMenuIconItemsPerRowValue = preferences.readMenuIconItemsPerRow + readMenuIconRowCountValue = preferences.readMenuIconRowCount + readMenuBottomCornerRadiusValue = preferences.readMenuBottomCornerRadius + readMenuFloatingBottomBarValue = preferences.readMenuFloatingBottomBar + readMenuTopBarBlurModeValue = preferences.readMenuTopBarBlurMode + readMenuBottomBarBlurModeValue = preferences.readMenuBottomBarBlurMode + readMenuTopBarLiquidGlassButtonsValue = preferences.readMenuTopBarLiquidGlassButtons + readMenuBottomBarLiquidGlassButtonsValue = preferences.readMenuBottomBarLiquidGlassButtons + readMenuTopBarBlurStyleValue = preferences.readMenuTopBarBlurStyle + readMenuBottomBarBlurStyleValue = preferences.readMenuBottomBarBlurStyle + readMenuBlurRadiusValue = preferences.readMenuBlurRadius + readMenuBlurAlphaValue = preferences.readMenuBlurAlpha + readMenuLensRadiusValue = preferences.readMenuLensRadius + readMenuBorderWidthValue = preferences.readMenuBorderWidth + readMenuBorderColorValue = preferences.readMenuBorderColor + readMenuBorderColorNightValue = preferences.readMenuBorderColorNight + readMenuCustomIconsValue = parseReadMenuCustomIcons(preferences.readMenuCustomIcons) + titleBarCustomIconsValue = parseReadMenuCustomIcons(preferences.titleBarCustomIcons) + titleBarIconPositionValue = preferences.titleBarIconPosition + showTitleBarIconsValue = preferences.showTitleBarIcons + } + + var readMenuBgColor: Int + get() = readMenuBgColorValue.takeIf { it != 0 } ?: durConfig.menuBgColor(isNight = false) + set(value) { + readMenuBgColorValue = value + } + + var readMenuAccentColor: Int + get() = readMenuAccentColorValue.takeIf { it != 0 } ?: durConfig.menuAccentColor(isNight = false) + set(value) { + readMenuAccentColorValue = value + } + + var readMenuContainerColor: Int + get() = readMenuContainerColorValue.takeIf { it != 0 } ?: readMenuBgColor + set(value) { + readMenuContainerColorValue = value + } + + var readMenuBgColorNight: Int + get() = readMenuBgColorNightValue.takeIf { it != 0 } ?: durConfig.menuBgColor(isNight = true) + set(value) { + readMenuBgColorNightValue = value + } + + var readMenuAccentColorNight: Int + get() = readMenuAccentColorNightValue.takeIf { it != 0 } ?: durConfig.menuAccentColor(isNight = true) + set(value) { + readMenuAccentColorNightValue = value + } + + var readMenuContainerColorNight: Int + get() = readMenuContainerColorNightValue.takeIf { it != 0 } ?: readMenuBgColorNight + set(value) { + readMenuContainerColorNightValue = value + } + + val resolvedMenuBgColor: Int + get() = if (ReadStyleResolver.isNightTheme()) readMenuBgColorNight else readMenuBgColor + + val resolvedMenuAccentColor: Int + get() = if (ReadStyleResolver.isNightTheme()) readMenuAccentColorNight else readMenuAccentColor + + val resolvedMenuContainerColor: Int + get() = if (ReadStyleResolver.isNightTheme()) readMenuContainerColorNight else readMenuContainerColor + + var readMenuColorMode: Int + get() = readMenuColorModeValue.coerceIn(0, 1) + set(value) { + readMenuColorModeValue = value.coerceIn(0, 1) + } + + var readMenuIconShowText: Boolean + get() = readMenuIconShowTextValue + set(value) { + readMenuIconShowTextValue = value + } + + var readMenuIconStyle: Int + get() = readMenuIconStyleValue.coerceIn(0, 2) + set(value) { + readMenuIconStyleValue = value.coerceIn(0, 2) + } + + var readMenuIconItemsPerRow: Int + get() = readMenuIconItemsPerRowValue.coerceIn(2, 8) + set(value) { + readMenuIconItemsPerRowValue = value.coerceIn(2, 8) + } + + var readMenuIconRowCount: Int + get() = readMenuIconRowCountValue.coerceIn(1, 2) + set(value) { + readMenuIconRowCountValue = value.coerceIn(1, 2) + } + + var readMenuBottomCornerRadius: Int + get() = readMenuBottomCornerRadiusValue.coerceIn(0, 32) + set(value) { + readMenuBottomCornerRadiusValue = value.coerceIn(0, 32) + } + + var readMenuFloatingBottomBar: Boolean + get() = readMenuFloatingBottomBarValue + set(value) { + readMenuFloatingBottomBarValue = value + } + + var readMenuTopBarBlurMode: Int + get() = readMenuTopBarBlurModeValue.coerceIn(0, 2) + set(value) { + readMenuTopBarBlurModeValue = value.coerceIn(0, 2) + } + + var readMenuBottomBarBlurMode: Int + get() = readMenuBottomBarBlurModeValue.coerceIn(0, 2) + set(value) { + readMenuBottomBarBlurModeValue = value.coerceIn(0, 2) + } + + var readMenuTopBarLiquidGlassButtons: Boolean + get() = readMenuTopBarLiquidGlassButtonsValue + set(value) { + readMenuTopBarLiquidGlassButtonsValue = value + } + + var readMenuBottomBarLiquidGlassButtons: Boolean + get() = readMenuBottomBarLiquidGlassButtonsValue + set(value) { + readMenuBottomBarLiquidGlassButtonsValue = value + } + + var readMenuTopBarBlurStyle: Int + get() = readMenuTopBarBlurStyleValue.coerceIn(0, 1) + set(value) { + readMenuTopBarBlurStyleValue = value.coerceIn(0, 1) + } + + var readMenuBottomBarBlurStyle: Int + get() = readMenuBottomBarBlurStyleValue.coerceIn(0, 1) + set(value) { + readMenuBottomBarBlurStyleValue = value.coerceIn(0, 1) + } + + var readMenuBlurRadius: Int + get() = readMenuBlurRadiusValue.coerceIn(0, 32) + set(value) { + readMenuBlurRadiusValue = value.coerceIn(0, 32) + } + + var readMenuBlurAlpha: Int + get() = readMenuBlurAlphaValue.coerceIn(0, 100) + set(value) { + readMenuBlurAlphaValue = value.coerceIn(0, 100) + } + + var readMenuLensRadius: Float + get() = readMenuLensRadiusValue.coerceIn(0f, 48f) + set(value) { + readMenuLensRadiusValue = value.coerceIn(0f, 48f) + } + + var readMenuBorderWidth: Int + get() = readMenuBorderWidthValue.coerceIn(0, 4) + set(value) { + readMenuBorderWidthValue = value.coerceIn(0, 4) + } + + var readMenuBorderColor: Int + get() = readMenuBorderColorValue + set(value) { + readMenuBorderColorValue = value + } + + var readMenuBorderColorNight: Int + get() = readMenuBorderColorNightValue + set(value) { + readMenuBorderColorNightValue = value + } + + val resolvedMenuBorderColor: Int + get() = if (ReadStyleResolver.isNightTheme()) readMenuBorderColorNight else readMenuBorderColor + + var readMenuCustomIcons: Map + get() = readMenuCustomIconsValue + set(value) { + readMenuCustomIconsValue = value.filterValues { it.isNotBlank() } + } + + var titleBarCustomIcons: Map + get() = titleBarCustomIconsValue + set(value) { + titleBarCustomIconsValue = value.filterValues { it.isNotBlank() } + } + + // 0=top-left, 1=top-right, 2=bottom-left, 3=bottom-right + var titleBarIconPosition: Int + get() = titleBarIconPositionValue.coerceIn(0, 3) + set(value) { + titleBarIconPositionValue = value.coerceIn(0, 3) + } + + var showTitleBarIcons: Boolean + get() = showTitleBarIconsValue + set(value) { + showTitleBarIconsValue = value + } + + fun encodeReadMenuCustomIcons(value: Map): String { + return GSON.toJson(value.filterValues { it.isNotBlank() }) + } + + private fun parseReadMenuCustomIcons(value: String?): Map { + if (value.isNullOrBlank()) { + return emptyMap() + } + return GSON.fromJsonObject>(value).getOrNull() + ?.filterValues { it.isNotBlank() } + ?: emptyMap() + } val regexColorRules: ArrayList get() = durConfig.regexColorRules @@ -238,15 +512,27 @@ object ReadBookConfig { /** * 两端对齐 */ - val textFullJustify get() = appCtx.getPrefBoolean(PreferKey.textFullJustify, true) + val textFullJustify get() = textFullJustifyValue /** * 底部对齐 */ - val textBottomJustify get() = appCtx.getPrefBoolean(PreferKey.textBottomJustify, true) - var hideStatusBar = appCtx.getPrefBoolean(PreferKey.hideStatusBar) - var hideNavigationBar = appCtx.getPrefBoolean(PreferKey.hideNavigationBar) - var useZhLayout = appCtx.getPrefBoolean(PreferKey.useZhLayout) + val textBottomJustify get() = textBottomJustifyValue + var hideStatusBar: Boolean + get() = hideStatusBarValue + set(value) { + hideStatusBarValue = value + } + var hideNavigationBar: Boolean + get() = hideNavigationBarValue + set(value) { + hideNavigationBarValue = value + } + var useZhLayout: Boolean + get() = useZhLayoutValue + set(value) { + useZhLayoutValue = value + } val config get() = if (shareLayout) shareConfig else durConfig @@ -576,15 +862,15 @@ object ReadBookConfig { } var menuBgColor: Int - get() = config.curMenuBg() + get() = readMenuBgColor set(value) { - config.setMenuCurBg(value) + readMenuBgColor = value } var menuAcColor: Int - get() = config.curMenuAc() + get() = readMenuAccentColor set(value) { - config.setMenuCurAc(value) + readMenuAccentColor = value } var shadowColor: Int @@ -593,6 +879,101 @@ object ReadBookConfig { config.setCurShadColor(value) } + // region Tip / Header / Footer + + var tipHeaderLeft: Int + get() = config.tipHeaderLeft + set(value) { + config.tipHeaderLeft = value + } + + var tipHeaderMiddle: Int + get() = config.tipHeaderMiddle + set(value) { + config.tipHeaderMiddle = value + } + + var tipHeaderRight: Int + get() = config.tipHeaderRight + set(value) { + config.tipHeaderRight = value + } + + var tipFooterLeft: Int + get() = config.tipFooterLeft + set(value) { + config.tipFooterLeft = value + } + + var tipFooterMiddle: Int + get() = config.tipFooterMiddle + set(value) { + config.tipFooterMiddle = value + } + + var tipFooterRight: Int + get() = config.tipFooterRight + set(value) { + config.tipFooterRight = value + } + + var headerMode: Int + get() = config.headerMode + set(value) { + config.headerMode = value + } + + var footerMode: Int + get() = config.footerMode + set(value) { + config.footerMode = value + } + + var tipHeaderColor: Int + get() = config.tipHeaderColor + set(value) { + config.tipHeaderColor = value + } + + var tipFooterColor: Int + get() = config.tipFooterColor + set(value) { + config.tipFooterColor = value + } + + var tipDividerColor: Int + get() = config.tipDividerColor + set(value) { + config.tipDividerColor = value + } + + val tipValues = arrayOf( + tipNone, tipBookName, tipChapterTitle, tipChapterTitleArrow, tipChapterTitleArrowClassic, + tipTime, tipBattery, tipBatteryClassic, tipBatteryInside, tipBatteryIcon, tipBatteryPercentage, + tipPage, tipTotalProgress, tipTotalProgress1, tipPageAndTotal, tipTimeBattery, + tipTimeBatteryClassic, tipTimeBatteryPercentage + ) + val tipNames get() = appCtx.resources.getStringArray(R.array.read_tip).toList() + val tipColorNames get() = appCtx.resources.getStringArray(R.array.tip_color).toList() + val tipDividerColorNames get() = appCtx.resources.getStringArray(R.array.tip_divider_color).toList() + + fun getHeaderModes(context: Context): LinkedHashMap { + return linkedMapOf( + Pair(0, context.getString(R.string.hide_when_status_bar_show)), + Pair(1, context.getString(R.string.show)), + Pair(2, context.getString(R.string.hide)) + ) + } + + fun getFooterModes(context: Context): LinkedHashMap { + return linkedMapOf( + Pair(0, context.getString(R.string.show)), + Pair(1, context.getString(R.string.hide)) + ) + } + + // endregion + fun getExportConfig(): Config { val exportConfig = durConfig.copy(regexColorRules = ArrayList(durConfig.regexColorRules.map { it.copy() })) if (shareLayout) { @@ -661,76 +1042,16 @@ object ReadBookConfig { return exportConfig } + fun export(): ByteArray { + return readStyleRepository.export(getExportConfig()) + } + fun import(byteArray: ByteArray): Config { - val configZipPath = FileUtils.getPath(appCtx.externalCache, "readConfig.zip") - FileUtils.delete(configZipPath) - val zipFile = FileUtils.createFileIfNotExist(configZipPath) - zipFile.writeBytes(byteArray) - val configDir = appCtx.externalCache.getFile("readConfig") - configDir.createFolderReplace() - ZipUtils.unZipToPath(zipFile, configDir) - val configFile = configDir.getFile(configFileName) - val config: Config = GSON.fromJsonObject(configFile.readText()).getOrThrow() - if (config.textFont.isNotEmpty()) { - val fontName = config.textFont - val fontPath = - FileUtils.getPath(appCtx.externalFiles, "font", fontName) - val fontFile = configDir.getFile(fontName) - if (fontFile.exists()) { - if (!FileUtils.exist(fontPath)) { - fontFile.copyTo(File(fontPath)) - } - config.textFont = fontPath - } else { - config.textFont = "" - } - } - if (config.titleFont.isNotEmpty()) { - val fontName = config.titleFont - val fontPath = - FileUtils.getPath(appCtx.externalFiles, "font", fontName) - val fontFile = configDir.getFile(fontName) - if (fontFile.exists()) { - if (!FileUtils.exist(fontPath)) { - fontFile.copyTo(File(fontPath)) - } - config.titleFont = fontPath - } else { - config.titleFont = "" - } - } - if (config.bgType == 2) { - val bgName = FileUtils.getName(config.bgStr) - config.bgStr = bgName - val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName) - if (!FileUtils.exist(bgPath)) { - val bgFile = configDir.getFile(bgName) - if (bgFile.exists()) { - bgFile.copyTo(File(bgPath)) - } - } - config.bgStrNight = bgPath - } else if (config.bgTypeNight == 0) { - config.bgStrNight.toColorInt() - } - if (config.bgTypeEInk == 2) { - val bgName = FileUtils.getName(config.bgStrEInk) - config.bgStrEInk = bgName - val bgPath = FileUtils.getPath(appCtx.externalFiles, "bg", bgName) - if (!FileUtils.exist(bgPath)) { - val bgFile = configDir.getFile(bgName) - if (bgFile.exists()) { - bgFile.copyTo(File(bgPath)) - } - } - config.bgStrEInk = bgPath - } else if (config.bgTypeEInk == 0) { - config.bgStrEInk.toColorInt() - } - config.curTextColor() - config.curTextAccentColor() - config.curTextShadowColor() - return config + return readStyleRepository.import(byteArray) + } + + fun saveBackgroundImage(inputStream: InputStream, displayName: String?): String { + return readStyleRepository.saveBackgroundImage(inputStream, displayName) } @Keep @@ -738,9 +1059,13 @@ object ReadBookConfig { var name: String = "", var bgStr: String = "#EEEEEE",//白天背景 var bgStrNight: String = "#000000",//夜间背景 + @Transient var menuBgColor: String = "#EEEFE3", + @Transient var menuAcColor: String = "#EEEFE3", + @Transient var menuBgColorNight: String = "#BFCBAD", + @Transient var menuAcColorNight: String = "#586249", var bgStrEInk: String = "#FFFFFF",//EInk背景 var bgAlpha: Int = 100,//背景透明度 @@ -812,17 +1137,31 @@ object ReadBookConfig { var footerPaddingTop: Int = 6, var showHeaderLine: Boolean = false, var showFooterLine: Boolean = true, - var tipHeaderLeft: Int = ReadTipConfig.time, - var tipHeaderMiddle: Int = ReadTipConfig.none, - var tipHeaderRight: Int = ReadTipConfig.battery, - var tipFooterLeft: Int = ReadTipConfig.chapterTitle, - var tipFooterMiddle: Int = ReadTipConfig.none, - var tipFooterRight: Int = ReadTipConfig.pageAndTotal, + var tipHeaderLeft: Int = tipTime, + var tipHeaderMiddle: Int = tipNone, + var tipHeaderRight: Int = tipBattery, + var tipFooterLeft: Int = tipChapterTitle, + var tipFooterMiddle: Int = tipNone, + var tipFooterRight: Int = tipPageAndTotal, var tipHeaderColor: Int = 0, var tipFooterColor: Int = 0, var tipDividerColor: Int = -1, var headerMode: Int = 0, var footerMode: Int = 0, + @Transient + var menuIconShowText: Boolean = true, + @Transient + var menuIconStyle: Int = 0, + @Transient + var menuIconItemsPerRow: Int = 5, + @Transient + var menuIconRowCount: Int = 1, + @Transient + var menuBottomCornerRadius: Int = 0, + @Transient + var menuBottomHorizontalMargin: Int = 0, + @Transient + var menuBottomBottomMargin: Int = 0, var regexColorRules: ArrayList = arrayListOf() ) { @@ -946,30 +1285,59 @@ object ReadBookConfig { ) fun getBgPath(bgIndex: Int): String? { - val bgType = when (bgIndex) { - 0 -> bgType - 1 -> bgTypeNight - 2 -> bgTypeEInk - else -> error("unknown bgIndex: $bgIndex") - } - if (bgType != 2) { - return null - } - val bgStr = when (bgIndex) { - 0 -> bgStr - 1 -> bgStrNight - 2 -> bgStrEInk - else -> error("unknown bgIndex: $bgIndex") - } - val path = if (bgStr.contains(File.separator)) { - bgStr - } else { - FileUtils.getPath(appCtx.externalFiles, "bg", bgStr) - } - return path + return ReadStyleResolver.backgroundPath(this, bgIndex) } - private fun initColorInt() { + private inline fun updateCurrentMode( + eInk: () -> Unit, + night: () -> Unit, + day: () -> Unit + ) { + when (ReadStyleResolver.currentMode()) { + ReadStyleResolver.ReadStyleMode.EInk -> eInk() + ReadStyleResolver.ReadStyleMode.Night -> night() + ReadStyleResolver.ReadStyleMode.Day -> day() + } + } + + private inline fun currentModeValue( + eInk: () -> T, + night: () -> T, + day: () -> T + ): T { + return when (ReadStyleResolver.currentMode()) { + ReadStyleResolver.ReadStyleMode.EInk -> eInk() + ReadStyleResolver.ReadStyleMode.Night -> night() + ReadStyleResolver.ReadStyleMode.Day -> day() + } + } + + private inline fun updateNightTheme( + night: () -> Unit, + day: () -> Unit + ) { + if (ReadStyleResolver.isNightTheme()) { + night() + } else { + day() + } + } + + private inline fun nightThemeValue( + night: () -> T, + day: () -> T + ): T { + return if (ReadStyleResolver.isNightTheme()) { + night() + } else { + day() + } + } + + private fun ensureColorInts() { + if (initColorInt) { + return + } textColorIntEInk = textColorEInk.toColorInt() textColorIntNight = textColorNight.toColorInt() textColorInt = textColor.toColorInt() @@ -984,7 +1352,10 @@ object ReadBookConfig { initColorInt = true } - private fun initAccentColorInt() { + private fun ensureAccentColorInts() { + if (initAccentColorInt) { + return + } textAccentColorIntEInk = textAccentColorEInk.toColorInt() textAccentColorIntNight = textAccentColorNight.toColorInt() textAccentColorInt = textAccentColor.toColorInt() @@ -992,250 +1363,207 @@ object ReadBookConfig { } fun setCurTextAccentColor(color: Int) { - when { - AppConfig.isEInkMode -> { + updateCurrentMode( + eInk = { textAccentColorEInk = "#${color.hexString}" textAccentColorIntEInk = color - } - - AppConfig.isNightTheme -> { + }, + night = { textAccentColorNight = "#${color.hexString}" textAccentColorIntNight = color - } - - else -> { + }, + day = { textAccentColor = "#${color.hexString}" textAccentColorInt = color } - } + ) } fun curTextAccentColor(): Int { - if (!initAccentColorInt) { - initAccentColorInt() - } - return when { - AppConfig.isEInkMode -> textAccentColorIntEInk - AppConfig.isNightTheme -> textAccentColorIntNight - else -> textAccentColorInt - } + ensureAccentColorInts() + return currentModeValue( + eInk = { textAccentColorIntEInk }, + night = { textAccentColorIntNight }, + day = { textAccentColorInt } + ) } fun setCurShadColor(color: Int){ - when { - AppConfig.isNightTheme -> { + updateNightTheme( + night = { shadowColorN = "#${color.hexString}" shadowColorNightInt = color - } - else -> { + }, + day = { shadowColor = "#${color.hexString}" shadowColorInt = color } - } + ) } fun setCurTextColor(color: Int) { - when { - AppConfig.isEInkMode -> { + updateCurrentMode( + eInk = { textColorEInk = "#${color.hexString}" textColorIntEInk = color - } - - AppConfig.isNightTheme -> { + }, + night = { textColorNight = "#${color.hexString}" textColorIntNight = color - } - - else -> { + }, + day = { textColor = "#${color.hexString}" textColorInt = color } - } + ) } fun curTextColor(): Int { - if (!initColorInt) { - initColorInt() - } - return when { - AppConfig.isEInkMode -> textColorIntEInk - AppConfig.isNightTheme -> textColorIntNight - else -> textColorInt - } + ensureColorInts() + return currentModeValue( + eInk = { textColorIntEInk }, + night = { textColorIntNight }, + day = { textColorInt } + ) } fun curTextShadowColor(): Int { - if (!initColorInt) { - initColorInt() - } - return when { - AppConfig.isNightTheme -> shadowColorNightInt - else -> shadowColorInt - } + ensureColorInts() + return nightThemeValue( + night = { shadowColorNightInt }, + day = { shadowColorInt } + ) } fun setCurStatusIconDark(isDark: Boolean) { - when { - AppConfig.isEInkMode -> darkStatusIconEInk = isDark - AppConfig.isNightTheme -> darkStatusIconNight = isDark - else -> darkStatusIcon = isDark - } + updateCurrentMode( + eInk = { darkStatusIconEInk = isDark }, + night = { darkStatusIconNight = isDark }, + day = { darkStatusIcon = isDark } + ) } fun curStatusIconDark(): Boolean { - return when { - AppConfig.isEInkMode -> darkStatusIconEInk - AppConfig.isNightTheme -> darkStatusIconNight - else -> darkStatusIcon - } + return currentModeValue( + eInk = { darkStatusIconEInk }, + night = { darkStatusIconNight }, + day = { darkStatusIcon } + ) } fun setCurPageAnim(@PageAnim.Anim anim: Int) { - when { - AppConfig.isEInkMode -> pageAnimEInk = anim - else -> pageAnim = anim - } + updateCurrentMode( + eInk = { pageAnimEInk = anim }, + night = { pageAnim = anim }, + day = { pageAnim = anim } + ) } fun curPageAnim(): Int { - return when { - AppConfig.isEInkMode -> pageAnimEInk - else -> pageAnim - } + return currentModeValue( + eInk = { pageAnimEInk }, + night = { pageAnim }, + day = { pageAnim } + ) } + // Public getters for mode-specific values (for ReadBookStyleConfig) + fun getDarkStatusIcon(): Boolean = darkStatusIcon + fun getDarkStatusIconNight(): Boolean = darkStatusIconNight + fun getDarkStatusIconEInk(): Boolean = darkStatusIconEInk + fun getTextColor(): String = textColor + fun getTextColorNight(): String = textColorNight + fun getTextColorEInk(): String = textColorEInk + fun getPageAnim(): Int = pageAnim + fun getPageAnimEInk(): Int = pageAnimEInk + fun setCurBg(bgType: Int, bg: String) { - when { - AppConfig.isEInkMode -> { - bgTypeEInk = bgType - bgStrEInk = bg - } - - AppConfig.isNightTheme -> { - bgTypeNight = bgType - bgStrNight = bg - } - - else -> { - this.bgType = bgType - bgStr = bg - } - } + ReadStyleResolver.setCurrentBackground(this, bgType, bg) } fun curBgStr(): String { - return when { - AppConfig.isEInkMode -> bgStrEInk - AppConfig.isNightTheme -> bgStrNight - else -> bgStr - } + return ReadStyleResolver.currentBackground(this).value } fun curMenuBg(): Int { - return when { - AppConfig.isNightTheme -> menuBgColorNightInt - else -> menuBgColorInt - } + ensureColorInts() + return nightThemeValue( + night = { menuBgColorNightInt }, + day = { menuBgColorInt } + ) + } + + fun menuBgColor(isNight: Boolean): Int { + ensureColorInts() + return if (isNight) menuBgColorNightInt else menuBgColorInt } fun setMenuCurBg(bg: Int) { - when { - AppConfig.isNightTheme -> { + updateNightTheme( + night = { menuBgColorNight = "#${bg.hexString}" menuBgColorNightInt = bg - } - - else -> { + }, + day = { menuBgColor = "#${bg.hexString}" menuBgColorInt = bg } - } + ) } fun curMenuAc(): Int { - return when { - AppConfig.isNightTheme -> menuAcColorNightInt - else -> menuAcColorInt - } + ensureColorInts() + return nightThemeValue( + night = { menuAcColorNightInt }, + day = { menuAcColorInt } + ) + } + + fun menuAccentColor(isNight: Boolean): Int { + ensureColorInts() + return if (isNight) menuAcColorNightInt else menuAcColorInt } fun setMenuCurAc(bg: Int) { - when { - AppConfig.isNightTheme -> { + updateNightTheme( + night = { menuAcColorNight = "#${bg.hexString}" menuAcColorNightInt = bg - } - - else -> { + }, + day = { menuAcColor = "#${bg.hexString}" menuAcColorInt = bg } - } + ) } fun curUnderlineColor(): Int { - return when { - AppConfig.isNightTheme -> underlineColorNightInt - else -> underlineColorInt - } + ensureColorInts() + return nightThemeValue( + night = { underlineColorNightInt }, + day = { underlineColorInt } + ) } fun setUnderlineColor(bg: Int) { - when { - AppConfig.isNightTheme -> { + updateNightTheme( + night = { underlineColorNight = "#${bg.hexString}" underlineColorNightInt = bg - } - - else -> { + }, + day = { underlineColor = "#${bg.hexString}" underlineColorInt = bg } - } + ) } fun curBgType(): Int { - return when { - AppConfig.isEInkMode -> bgTypeEInk - AppConfig.isNightTheme -> bgTypeNight - else -> bgType - } + return ReadStyleResolver.currentBackground(this).type } fun curBgDrawable(width: Int, height: Int): Drawable { - if (width == 0 || height == 0) { - val backgroundColor = MaterialColors.getColor(appCtx, com.google.android.material.R.attr.colorSurface, Color.WHITE) - return backgroundColor.toDrawable() - } - - var bgDrawable: Drawable? = null - val resources = appCtx.resources - try { - bgDrawable = when (curBgType()) { - 0 -> curBgStr().toColorInt().toDrawable() - 1 -> { - val path = "bg" + File.separator + curBgStr() - val bitmap = BitmapUtils.decodeAssetsBitmap(appCtx, path, width, height) - bitmap?.resizeAndRecycle(width, height)?.toDrawable(resources) - } - else -> { - val path = curBgStr().let { - if (it.contains(File.separator)) it - else FileUtils.getPath(appCtx.externalFiles, "bg", curBgStr()) - } - val bitmap = BitmapUtils.decodeBitmap(path, width, height) - bitmap?.resizeAndRecycle(width, height)?.toDrawable(resources) - } - } - } catch (e: OutOfMemoryError) { - e.printOnDebug() - } catch (e: Exception) { - e.printOnDebug() - } - - // fallback 使用 MD3 的 colorSurface 作为背景色 - val fallbackColor = MaterialColors.getColor(appCtx, com.google.android.material.R.attr.colorSurface, Color.WHITE) - return bgDrawable ?: fallbackColor.toDrawable() + return ReadStyleResolver.currentBackgroundDrawable(this, width, height) } } } diff --git a/app/src/main/java/io/legado/app/help/config/ReadStyleResolver.kt b/app/src/main/java/io/legado/app/help/config/ReadStyleResolver.kt new file mode 100644 index 000000000..353767cde --- /dev/null +++ b/app/src/main/java/io/legado/app/help/config/ReadStyleResolver.kt @@ -0,0 +1,141 @@ +package io.legado.app.help.config + +import android.graphics.Color +import android.graphics.drawable.Drawable +import androidx.core.graphics.drawable.toDrawable +import androidx.core.graphics.toColorInt +import com.google.android.material.color.MaterialColors +import io.legado.app.utils.BitmapUtils +import io.legado.app.utils.FileUtils +import io.legado.app.utils.externalFiles +import io.legado.app.utils.printOnDebug +import io.legado.app.utils.resizeAndRecycle +import splitties.init.appCtx +import java.io.File + +object ReadStyleResolver { + + enum class ReadStyleMode { + Day, + Night, + EInk + } + + data class ReadBackground( + val type: Int, + val value: String + ) + + fun currentMode(): ReadStyleMode { + return when { + AppConfig.isEInkMode -> ReadStyleMode.EInk + AppConfig.isNightTheme -> ReadStyleMode.Night + else -> ReadStyleMode.Day + } + } + + fun isNightTheme(): Boolean { + return AppConfig.isNightTheme + } + + fun setCurrentBackground( + config: ReadBookConfig.Config, + bgType: Int, + bg: String + ) { + when (currentMode()) { + ReadStyleMode.EInk -> { + config.bgTypeEInk = bgType + config.bgStrEInk = bg + } + + ReadStyleMode.Night -> { + config.bgTypeNight = bgType + config.bgStrNight = bg + } + + ReadStyleMode.Day -> { + config.bgType = bgType + config.bgStr = bg + } + } + } + + fun currentBackground(config: ReadBookConfig.Config): ReadBackground { + return when (currentMode()) { + ReadStyleMode.EInk -> ReadBackground(config.bgTypeEInk, config.bgStrEInk) + ReadStyleMode.Night -> ReadBackground(config.bgTypeNight, config.bgStrNight) + ReadStyleMode.Day -> ReadBackground(config.bgType, config.bgStr) + } + } + + fun backgroundPath(config: ReadBookConfig.Config, bgIndex: Int): String? { + val bgType = when (bgIndex) { + 0 -> config.bgType + 1 -> config.bgTypeNight + 2 -> config.bgTypeEInk + else -> error("unknown bgIndex: $bgIndex") + } + if (bgType != 2) { + return null + } + val bgStr = when (bgIndex) { + 0 -> config.bgStr + 1 -> config.bgStrNight + 2 -> config.bgStrEInk + else -> error("unknown bgIndex: $bgIndex") + } + return if (bgStr.contains(File.separator)) { + bgStr + } else { + FileUtils.getPath(appCtx.externalFiles, "bg", bgStr) + } + } + + fun currentBackgroundDrawable( + config: ReadBookConfig.Config, + width: Int, + height: Int + ): Drawable { + if (width == 0 || height == 0) { + return fallbackBackground() + } + + var bgDrawable: Drawable? = null + val resources = appCtx.resources + val background = currentBackground(config) + try { + bgDrawable = when (background.type) { + 0 -> background.value.toColorInt().toDrawable() + 1 -> { + val path = "bg" + File.separator + background.value + val bitmap = BitmapUtils.decodeAssetsBitmap(appCtx, path, width, height) + bitmap?.resizeAndRecycle(width, height)?.toDrawable(resources) + } + else -> { + val path = background.value.let { + if (it.contains(File.separator)) it + else FileUtils.getPath(appCtx.externalFiles, "bg", background.value) + } + val bitmap = BitmapUtils.decodeBitmap(path, width, height) + bitmap?.resizeAndRecycle(width, height)?.toDrawable(resources) + } + } + } catch (e: OutOfMemoryError) { + e.printOnDebug() + } catch (e: Exception) { + e.printOnDebug() + } + + return bgDrawable ?: fallbackBackground() + } + + private fun fallbackBackground(): Drawable { + val fallbackColor = MaterialColors.getColor( + appCtx, + com.google.android.material.R.attr.colorSurface, + Color.WHITE + ) + return fallbackColor.toDrawable() + } +} diff --git a/app/src/main/java/io/legado/app/help/config/ReadTipConfig.kt b/app/src/main/java/io/legado/app/help/config/ReadTipConfig.kt deleted file mode 100644 index f1c847ad4..000000000 --- a/app/src/main/java/io/legado/app/help/config/ReadTipConfig.kt +++ /dev/null @@ -1,120 +0,0 @@ -package io.legado.app.help.config - -import android.content.Context -import io.legado.app.R -import splitties.init.appCtx - -@Suppress("ConstPropertyName") -object ReadTipConfig { - - const val none = 0 - const val chapterTitle = 1 - const val time = 2 - const val battery = 3 - const val batteryPercentage = 10 - const val page = 4 - const val totalProgress = 5 - const val pageAndTotal = 6 - const val bookName = 7 - const val timeBattery = 8 - const val timeBatteryPercentage = 9 - const val totalProgress1 = 11 - const val chapterTitleArrow = 12 - const val batteryInside = 13 - const val batteryIcon = 14 - const val batteryClassic = 15 - const val timeBatteryClassic = 16 - const val chapterTitleArrowClassic = 17 - - - val tipValues = arrayOf( - none, bookName, chapterTitle, chapterTitleArrow, chapterTitleArrowClassic, time, battery, batteryClassic, batteryInside, batteryIcon, batteryPercentage, page, - totalProgress, totalProgress1, pageAndTotal, timeBattery, timeBatteryClassic, timeBatteryPercentage - ) - val tipNames get() = appCtx.resources.getStringArray(R.array.read_tip).toList() - - val tipColorNames get() = appCtx.resources.getStringArray(R.array.tip_color).toList() - val tipDividerColorNames - get() = appCtx.resources.getStringArray(R.array.tip_divider_color).toList() - - var tipHeaderLeft: Int - get() = ReadBookConfig.config.tipHeaderLeft - set(value) { - ReadBookConfig.config.tipHeaderLeft = value - } - - var tipHeaderMiddle: Int - get() = ReadBookConfig.config.tipHeaderMiddle - set(value) { - ReadBookConfig.config.tipHeaderMiddle = value - } - - var tipHeaderRight: Int - get() = ReadBookConfig.config.tipHeaderRight - set(value) { - ReadBookConfig.config.tipHeaderRight = value - } - - var tipFooterLeft: Int - get() = ReadBookConfig.config.tipFooterLeft - set(value) { - ReadBookConfig.config.tipFooterLeft = value - } - - var tipFooterMiddle: Int - get() = ReadBookConfig.config.tipFooterMiddle - set(value) { - ReadBookConfig.config.tipFooterMiddle = value - } - - var tipFooterRight: Int - get() = ReadBookConfig.config.tipFooterRight - set(value) { - ReadBookConfig.config.tipFooterRight = value - } - - var headerMode: Int - get() = ReadBookConfig.config.headerMode - set(value) { - ReadBookConfig.config.headerMode = value - } - - var footerMode: Int - get() = ReadBookConfig.config.footerMode - set(value) { - ReadBookConfig.config.footerMode = value - } - - var tipHeaderColor: Int - get() = ReadBookConfig.config.tipHeaderColor - set(value) { - ReadBookConfig.config.tipHeaderColor = value - } - - var tipFooterColor: Int - get() = ReadBookConfig.config.tipFooterColor - set(value) { - ReadBookConfig.config.tipFooterColor = value - } - - var tipDividerColor: Int - get() = ReadBookConfig.config.tipDividerColor - set(value) { - ReadBookConfig.config.tipDividerColor = value - } - - fun getHeaderModes(context: Context): LinkedHashMap { - return linkedMapOf( - Pair(0, context.getString(R.string.hide_when_status_bar_show)), - Pair(1, context.getString(R.string.show)), - Pair(2, context.getString(R.string.hide)) - ) - } - - fun getFooterModes(context: Context): LinkedHashMap { - return linkedMapOf( - Pair(0, context.getString(R.string.show)), - Pair(1, context.getString(R.string.hide)) - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/help/config/OldThemeConfig.kt b/app/src/main/java/io/legado/app/help/config/ThemeConfigStore.kt similarity index 84% rename from app/src/main/java/io/legado/app/help/config/OldThemeConfig.kt rename to app/src/main/java/io/legado/app/help/config/ThemeConfigStore.kt index ceb8e501b..a14125db5 100644 --- a/app/src/main/java/io/legado/app/help/config/OldThemeConfig.kt +++ b/app/src/main/java/io/legado/app/help/config/ThemeConfigStore.kt @@ -4,15 +4,13 @@ import android.content.Context import android.graphics.Bitmap import android.util.DisplayMetrics import androidx.annotation.Keep -import androidx.appcompat.app.AppCompatDelegate import androidx.core.graphics.toColorInt +import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.R -import io.legado.app.constant.AppLog import io.legado.app.constant.EventBus import io.legado.app.constant.PreferKey import io.legado.app.constant.Theme import io.legado.app.help.DefaultData -import io.legado.app.model.BookCover import io.legado.app.utils.BitmapUtils import io.legado.app.utils.FileUtils import io.legado.app.utils.GSON @@ -26,13 +24,12 @@ import io.legado.app.utils.getPrefString import io.legado.app.utils.hexString import io.legado.app.utils.postEvent import io.legado.app.utils.printOnDebug -import io.legado.app.utils.putPrefInt import io.legado.app.utils.stackBlur import splitties.init.appCtx import java.io.File @Keep -object OldThemeConfig { +object ThemeConfigStore { const val configFileName = "themeConfig.json" val configFilePath = FileUtils.getPath(appCtx.filesDir, configFileName) @@ -46,13 +43,8 @@ object OldThemeConfig { else -> Theme.Light } - fun isDarkTheme(): Boolean { - return getTheme() == Theme.Dark - } - fun applyDayNight(context: Context) { initNightMode() - BookCover.upDefaultCover() postEvent(EventBus.RECREATE, "") postEvent(EventBus.UP_CONFIG, arrayListOf(2)) } @@ -62,17 +54,7 @@ object OldThemeConfig { } private fun initNightMode() { - when (appCtx.getPrefString(PreferKey.themeMode, "0")) { - "1" -> { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) - } - "2" -> { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) - } - else -> { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) - } - } + ThemeConfig.initNightMode() } fun getBgImage(context: Context, metrics: DisplayMetrics): Bitmap? { @@ -110,11 +92,6 @@ object OldThemeConfig { FileUtils.createFileIfNotExist(configFilePath).writeText(json) } - fun delConfig(index: Int) { - configList.removeAt(index) - save() - } - fun addConfig(json: String): Boolean { GSON.fromJsonObject(json.trim { it < ' ' }).getOrNull() ?.let { @@ -165,33 +142,8 @@ object OldThemeConfig { return null } - fun applyConfig(context: Context, config: Config) { - try { - val primary = config.primaryColor.toColorInt() - if (config.isNightTheme) { - context.putPrefInt(PreferKey.cNPrimary, primary) - } else { - context.putPrefInt(PreferKey.cPrimary, primary) - } - AppConfig.isNightTheme = config.isNightTheme - applyDayNight(context) - } catch (e: Exception) { - AppLog.put("设置主题出错\n$e", e, true) - } - } - - fun saveDayTheme(context: Context, name: String) { - val config = getDayTheme(context, name) - addConfig(config) - } - - fun saveNightTheme(context: Context, name: String) { - val config = getNightTheme(context, name) - addConfig(config) - } - /** - * 更新主题 + * 清理无用背景图片 */ fun clearBg() { val bgImagePath = appCtx.getPrefString(PreferKey.bgImage) diff --git a/app/src/main/java/io/legado/app/help/storage/Backup.kt b/app/src/main/java/io/legado/app/help/storage/Backup.kt index f310e050c..9f274b552 100644 --- a/app/src/main/java/io/legado/app/help/storage/Backup.kt +++ b/app/src/main/java/io/legado/app/help/storage/Backup.kt @@ -13,7 +13,7 @@ import io.legado.app.help.AppWebDav import io.legado.app.help.DirectLinkUpload import io.legado.app.help.config.AppConfig import io.legado.app.help.config.LocalConfig -import io.legado.app.help.config.OldThemeConfig +import io.legado.app.help.config.ThemeConfigStore import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.model.BookCover @@ -77,7 +77,7 @@ object Backup { DirectLinkUpload.ruleFileName, ReadBookConfig.configFileName, ReadBookConfig.shareConfigFileName, - OldThemeConfig.configFileName, + ThemeConfigStore.configFileName, BookCover.configFileName, "config.xml" ) @@ -164,8 +164,8 @@ object Backup { FileUtils.createFileIfNotExist(backupPath + File.separator + ReadBookConfig.shareConfigFileName) .writeText(it) } - GSON.toJson(OldThemeConfig.configList).let { - FileUtils.createFileIfNotExist(backupPath + File.separator + OldThemeConfig.configFileName) + GSON.toJson(ThemeConfigStore.configList).let { + FileUtils.createFileIfNotExist(backupPath + File.separator + ThemeConfigStore.configFileName) .writeText(it) } DirectLinkUpload.getConfig()?.let { diff --git a/app/src/main/java/io/legado/app/help/storage/Restore.kt b/app/src/main/java/io/legado/app/help/storage/Restore.kt index c8f794253..e9970933f 100644 --- a/app/src/main/java/io/legado/app/help/storage/Restore.kt +++ b/app/src/main/java/io/legado/app/help/storage/Restore.kt @@ -34,7 +34,7 @@ import io.legado.app.help.LauncherIconHelp import io.legado.app.help.book.isLocal import io.legado.app.help.book.upType import io.legado.app.help.config.LocalConfig -import io.legado.app.help.config.OldThemeConfig +import io.legado.app.help.config.ThemeConfigStore import io.legado.app.help.config.ReadBookConfig import io.legado.app.model.BookCover import io.legado.app.model.localBook.LocalBook @@ -275,12 +275,12 @@ object Restore : KoinComponent { } //恢复主题配置 if (!BackupConfig.ignoreThemeConfig) { - File(path, OldThemeConfig.configFileName).takeIf { + File(path, ThemeConfigStore.configFileName).takeIf { it.exists() }?.runCatching { - FileUtils.delete(OldThemeConfig.configFilePath) - copyTo(File(OldThemeConfig.configFilePath)) - OldThemeConfig.upConfig() + FileUtils.delete(ThemeConfigStore.configFilePath) + copyTo(File(ThemeConfigStore.configFilePath)) + ThemeConfigStore.upConfig() }?.onFailure { AppLog.put("恢复主题出错\n${it.localizedMessage}", it) } @@ -341,7 +341,7 @@ object Restore : KoinComponent { if (!BuildConfig.DEBUG) { LauncherIconHelp.changeIcon(appCtx.getPrefString(PreferKey.launcherIcon)) } - OldThemeConfig.applyDayNight(appCtx) + ThemeConfigStore.applyDayNight(appCtx) } } diff --git a/app/src/main/java/io/legado/app/lib/prefs/ThemeModePreference.kt b/app/src/main/java/io/legado/app/lib/prefs/ThemeModePreference.kt index d6fd9777b..bba878ea4 100644 --- a/app/src/main/java/io/legado/app/lib/prefs/ThemeModePreference.kt +++ b/app/src/main/java/io/legado/app/lib/prefs/ThemeModePreference.kt @@ -7,7 +7,7 @@ import android.util.AttributeSet import androidx.preference.PreferenceViewHolder import com.google.android.material.button.MaterialButtonToggleGroup import io.legado.app.R -import io.legado.app.help.config.OldThemeConfig +import io.legado.app.help.config.ThemeConfigStore class ThemeModePreference(context: Context, attrs: AttributeSet) : Preference(context, attrs) { @@ -59,7 +59,7 @@ class ThemeModePreference(context: Context, attrs: AttributeSet) : Preference(co persistString(newValue) callChangeListener(newValue) Handler(Looper.getMainLooper()).postDelayed({ - OldThemeConfig.applyDayNight(context) + ThemeConfigStore.applyDayNight(context) }, 300) } } diff --git a/app/src/main/java/io/legado/app/model/BookCover.kt b/app/src/main/java/io/legado/app/model/BookCover.kt index 2a4f51180..f245d2a48 100644 --- a/app/src/main/java/io/legado/app/model/BookCover.kt +++ b/app/src/main/java/io/legado/app/model/BookCover.kt @@ -63,9 +63,6 @@ object BookCover { }.getOrDefault(appCtx.resources.getDrawable(R.drawable.image_cover_default, null)) } - // 兼容旧代码,空实现 - fun upDefaultCover() {} - fun getRandomDefaultPath( seed: Any? = null, isNight: Boolean = AppConfig.isNightTheme diff --git a/app/src/main/java/io/legado/app/receiver/MediaButtonReceiver.kt b/app/src/main/java/io/legado/app/receiver/MediaButtonReceiver.kt index d7548a19a..07c9540cf 100644 --- a/app/src/main/java/io/legado/app/receiver/MediaButtonReceiver.kt +++ b/app/src/main/java/io/legado/app/receiver/MediaButtonReceiver.kt @@ -14,7 +14,7 @@ import io.legado.app.model.ReadBook import io.legado.app.service.AudioPlayService import io.legado.app.service.BaseReadAloudService import io.legado.app.ui.book.audio.AudioPlayActivity -import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.main.MainActivity import io.legado.app.utils.LogUtils import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.postEvent @@ -94,7 +94,7 @@ class MediaButtonReceiver : BroadcastReceiver() { // break } - LifecycleHelp.isExistActivity(ReadBookActivity::class.java) -> + MainActivity.hasActiveReadBookRoute -> postEvent(EventBus.MEDIA_BUTTON, true) LifecycleHelp.isExistActivity(AudioPlayActivity::class.java) -> diff --git a/app/src/main/java/io/legado/app/receiver/NetworkChangedListener.kt b/app/src/main/java/io/legado/app/receiver/NetworkChangedListener.kt index a81ca2471..89f5cb842 100644 --- a/app/src/main/java/io/legado/app/receiver/NetworkChangedListener.kt +++ b/app/src/main/java/io/legado/app/receiver/NetworkChangedListener.kt @@ -17,12 +17,10 @@ import splitties.systemservices.connectivityManager class NetworkChangedListener(private val context: Context) { var onNetworkChanged: (() -> Unit)? = null + private var registered = false private val receiver: NetworkChangedReceiver? by lazy { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - NetworkChangedReceiver() - } - return@lazy null + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) NetworkChangedReceiver() else null } private val networkCallback: ConnectivityManager.NetworkCallback? by lazy { @@ -38,25 +36,31 @@ class NetworkChangedListener(private val context: Context) { @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") fun register() { + if (registered) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { networkCallback?.let { connectivityManager.registerDefaultNetworkCallback(it) + registered = true } } else { receiver?.let { context.registerReceiver(it, it.filter) + registered = true } } } fun unRegister() { + if (!registered) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { networkCallback?.let { connectivityManager.unregisterNetworkCallback(it) + registered = false } } else { receiver?.let { context.unregisterReceiver(it) + registered = false } } } @@ -74,4 +78,4 @@ class NetworkChangedListener(private val context: Context) { } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/service/BaseReadAloudService.kt b/app/src/main/java/io/legado/app/service/BaseReadAloudService.kt index b3f40ff1c..6a1516ee8 100644 --- a/app/src/main/java/io/legado/app/service/BaseReadAloudService.kt +++ b/app/src/main/java/io/legado/app/service/BaseReadAloudService.kt @@ -43,8 +43,8 @@ import io.legado.app.lib.permission.PermissionsCompat import io.legado.app.model.ReadAloud import io.legado.app.model.ReadBook import io.legado.app.receiver.MediaButtonReceiver -import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.book.read.page.entities.TextChapter +import io.legado.app.ui.main.MainActivity import io.legado.app.utils.LogUtils import io.legado.app.utils.activityPendingIntent import io.legado.app.utils.getPrefBoolean @@ -639,7 +639,10 @@ abstract class BaseReadAloudService : BaseService(), .setContentTitle(nTitle) .setContentText(nSubtitle) .setContentIntent( - activityPendingIntent("activity") + activityPendingIntent( + MainActivity.createReadBookIntent(this, readAloud = true), + "activity" + ) ) .setVibrate(null) .setSound(null) diff --git a/app/src/main/java/io/legado/app/ui/animation/InteractiveHighlight.kt b/app/src/main/java/io/legado/app/ui/animation/InteractiveHighlight.kt index aea50ca1e..f155e315a 100644 --- a/app/src/main/java/io/legado/app/ui/animation/InteractiveHighlight.kt +++ b/app/src/main/java/io/legado/app/ui/animation/InteractiveHighlight.kt @@ -35,6 +35,8 @@ class InteractiveHighlight( Animatable(Offset.Zero, Offset.VectorConverter, Offset.VisibilityThreshold) private var startPosition = Offset.Zero + val pressProgress: Float get() = pressProgressAnimation.value + val offset: Offset get() = positionAnimation.value @Language("AGSL") private val shader = RuntimeShader( diff --git a/app/src/main/java/io/legado/app/ui/association/ImportThemeDialog.kt b/app/src/main/java/io/legado/app/ui/association/ImportThemeDialog.kt index d98eae3b9..09655ce23 100644 --- a/app/src/main/java/io/legado/app/ui/association/ImportThemeDialog.kt +++ b/app/src/main/java/io/legado/app/ui/association/ImportThemeDialog.kt @@ -14,7 +14,7 @@ import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter import io.legado.app.databinding.DialogRecyclerViewBinding import io.legado.app.databinding.ItemSourceImportBinding -import io.legado.app.help.config.OldThemeConfig +import io.legado.app.help.config.ThemeConfigStore //import io.legado.app.lib.theme.primaryColor import io.legado.app.ui.widget.dialog.CodeDialog import io.legado.app.ui.widget.dialog.WaitDialog @@ -126,7 +126,7 @@ class ImportThemeDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_recycl } inner class SourcesAdapter(context: Context) : - RecyclerAdapter(context) { + RecyclerAdapter(context) { override fun getViewBinding(parent: ViewGroup): ItemSourceImportBinding { return ItemSourceImportBinding.inflate(inflater, parent, false) @@ -135,7 +135,7 @@ class ImportThemeDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_recycl override fun convert( holder: ItemViewHolder, binding: ItemSourceImportBinding, - item: OldThemeConfig.Config, + item: ThemeConfigStore.Config, payloads: MutableList ) { binding.apply { diff --git a/app/src/main/java/io/legado/app/ui/association/ImportThemeViewModel.kt b/app/src/main/java/io/legado/app/ui/association/ImportThemeViewModel.kt index 83f9aa612..3d140958f 100644 --- a/app/src/main/java/io/legado/app/ui/association/ImportThemeViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/association/ImportThemeViewModel.kt @@ -8,7 +8,7 @@ import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog import io.legado.app.exception.NoStackTraceException -import io.legado.app.help.config.OldThemeConfig +import io.legado.app.help.config.ThemeConfigStore import io.legado.app.help.http.decompressed import io.legado.app.help.http.newCallResponseBody import io.legado.app.help.http.okHttpClient @@ -28,8 +28,8 @@ class ImportThemeViewModel(app: Application) : BaseViewModel(app) { val errorLiveData = MutableLiveData() val successLiveData = MutableLiveData() - val allSources = arrayListOf() - val checkSources = arrayListOf() + val allSources = arrayListOf() + val checkSources = arrayListOf() val selectStatus = arrayListOf() val isSelectAll: Boolean @@ -57,7 +57,7 @@ class ImportThemeViewModel(app: Application) : BaseViewModel(app) { execute { selectStatus.forEachIndexed { index, b -> if (b) { - OldThemeConfig.addConfig(allSources[index]) + ThemeConfigStore.addConfig(allSources[index]) } } }.onFinally { @@ -79,12 +79,12 @@ class ImportThemeViewModel(app: Application) : BaseViewModel(app) { private suspend fun importSourceAwait(text: String) { when { text.isJsonObject() -> { - GSON.fromJsonObject(text).getOrThrow().let { + GSON.fromJsonObject(text).getOrThrow().let { allSources.add(it) } } - text.isJsonArray() -> GSON.fromJsonArray(text).getOrThrow() + text.isJsonArray() -> GSON.fromJsonArray(text).getOrThrow() .let { items -> allSources.addAll(items) } @@ -114,7 +114,7 @@ class ImportThemeViewModel(app: Application) : BaseViewModel(app) { private fun comparisonSource() { execute { allSources.forEach { config -> - val source = OldThemeConfig.configList.find { + val source = ThemeConfigStore.configList.find { it.themeName == config.themeName } checkSources.add(source) diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt index 881a5f20a..7e63cad79 100644 --- a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt @@ -1,25 +1,35 @@ package io.legado.app.ui.book.changesource -import android.app.Application -import io.legado.app.data.appDb +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.SearchBook import io.legado.app.data.repository.SearchRepository +import io.legado.app.domain.usecase.ChangeSourceSearchEvent +import io.legado.app.domain.usecase.ChangeSourceSearchUseCase +import io.legado.app.domain.usecase.GetChapterContentUseCase +import io.legado.app.help.book.primaryStr import io.legado.app.ui.book.search.SearchScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch class ChangeBookSourceComposeViewModel( - application: Application, + private val changeSourceSearchUseCase: ChangeSourceSearchUseCase, + private val getChapterContentUseCase: GetChapterContentUseCase, private val searchRepository: SearchRepository, -) : ChangeBookSourceViewModel(application) { +) : ViewModel() { + // Public state for the sheet val enabledGroups = searchRepository.enabledGroups val enabledSources = searchRepository.enabledSources - val searchScope = SearchScope(ChangeSourceConfig.searchScope) + private val searchScope = SearchScope(ChangeSourceConfig.searchScope) data class ScopeUiState( val isAll: Boolean, @@ -38,18 +48,164 @@ class ChangeBookSourceComposeViewModel( ) val scopeUiState = _scopeUiState.asStateFlow() - val checkAuthor: Boolean - get() = ChangeSourceConfig.checkAuthor + private val _isSearching = MutableStateFlow(false) + val isSearching = _isSearching.asStateFlow() - val loadInfo: Boolean - get() = ChangeSourceConfig.loadInfo + private val _changeSourceProgress = MutableStateFlow(0 to "") + val changeSourceProgress = _changeSourceProgress.asStateFlow() - val loadToc: Boolean - get() = ChangeSourceConfig.loadToc + private val _searchDataFlow = MutableStateFlow>(emptyList()) + val searchDataFlow: StateFlow> = _searchDataFlow.asStateFlow() - val loadWordCount: Boolean - get() = ChangeSourceConfig.loadWordCount + val totalSourceCount: Int + get() = searchResults.size + fun getBookFromMap(key: String): Book? = bookMap[key]?.toBook() + + // Options + val checkAuthor: Boolean get() = ChangeSourceConfig.checkAuthor + val loadInfo: Boolean get() = ChangeSourceConfig.loadInfo + val loadToc: Boolean get() = ChangeSourceConfig.loadToc + val loadWordCount: Boolean get() = ChangeSourceConfig.loadWordCount + + // Internal state + private var searchJob: Job? = null + private var oldBook: Book? = null + private var screenKey: String = "" + private val searchResults = mutableListOf() + private val bookMap = mutableMapOf() + private val tocMap = mutableMapOf>() + + init { + viewModelScope.launch { + searchRepository.enabledGroups.collect { /* handled by sheet */ } + } + } + + fun initData(name: String, author: String, book: Book, fromReadBookActivity: Boolean) { + this.oldBook = book + if (searchJob?.isActive != true) { + startSearch() + } + } + + fun startSearch() { + val book = oldBook ?: return + stopSearch() + searchResults.clear() + bookMap.clear() + tocMap.clear() + _searchDataFlow.value = emptyList() + + searchJob = viewModelScope.launch { + changeSourceSearchUseCase.search( + name = book.name, + author = book.author, + scope = SearchScope(ChangeSourceConfig.searchScope), + oldBook = book, + fromReadBookActivity = false, + ).collect { event -> + when (event) { + is ChangeSourceSearchEvent.Started -> { + _isSearching.value = true + } + + is ChangeSourceSearchEvent.Progress -> { + _changeSourceProgress.value = event.processedSources to event.sourceName + } + + is ChangeSourceSearchEvent.Result -> { + searchResults.add(event.searchBook) + bookMap[event.searchBook.primaryStr()] = event.searchBook + filterResults() + } + + is ChangeSourceSearchEvent.Finished -> { + _isSearching.value = false + } + } + } + } + } + + fun startSearch(origin: String) { + // Reload a single source + viewModelScope.launch { + changeSourceSearchUseCase.topSource( + searchResults.find { it.origin == origin } ?: return@launch + ) + startSearch() + } + } + + fun stopSearch() { + searchJob?.cancel() + searchJob = null + _isSearching.value = false + } + + fun screen(key: String?) { + screenKey = key?.trim() ?: "" + filterResults() + } + + fun startOrStopSearch() { + if (searchJob?.isActive == true) { + stopSearch() + } else { + startSearch() + } + } + + fun pause() { + // No-op for now + } + + fun resume() { + // No-op for now + } + + private fun filterResults() { + val filtered = if (screenKey.isEmpty()) { + searchResults.toList() + } else { + searchResults.filter { + it.name.contains(screenKey) || it.originName.contains(screenKey) + } + } + val sorted = filtered.sortedWith( + compareByDescending { ObservableSourceConfig.getBookScore(it) } + .thenByDescending { io.legado.app.help.config.SourceConfig.getSourceScore(it.origin) } + .thenBy { it.originOrder } + ) + _searchDataFlow.value = sorted + } + + fun getToc( + book: Book, + onSuccess: (toc: List, source: BookSource) -> Unit, + onError: (e: Throwable) -> Unit, + ) { + viewModelScope.launch { + try { + val cachedToc = tocMap[book.primaryStr()] + if (cachedToc != null) { + val source = io.legado.app.data.appDb.bookSourceDao.getBookSource(book.origin) + if (source != null) { + onSuccess(cachedToc, source) + return@launch + } + } + val (toc, source) = getChapterContentUseCase.getToc(book) + tocMap[book.primaryStr()] = toc + onSuccess(toc, source) + } catch (e: Exception) { + onError(e) + } + } + } + + // Options fun onCheckAuthorChange(enabled: Boolean) { if (ChangeSourceConfig.checkAuthor == enabled) return ChangeSourceConfig.checkAuthor = enabled @@ -70,21 +226,67 @@ class ChangeBookSourceComposeViewModel( if (ChangeSourceConfig.loadWordCount == enabled) return ChangeSourceConfig.loadWordCount = enabled if (enabled) { - onLoadWordCountChecked(true) + startSearch() } else { refresh() } } - fun bookScoreFlow(searchBook: SearchBook): StateFlow { - return ObservableSourceConfig.bookScoreFlow(searchBook) + fun refresh() { + searchResults.clear() + bookMap.clear() + startSearch() } + // Source actions + fun topSource(searchBook: SearchBook) { + changeSourceSearchUseCase.topSource(searchBook) + refresh() + } + + fun bottomSource(searchBook: SearchBook) { + changeSourceSearchUseCase.bottomSource(searchBook) + refresh() + } + + fun disableSource(searchBook: SearchBook) { + changeSourceSearchUseCase.disableSource(searchBook) + searchResults.remove(searchBook) + filterResults() + } + + fun del(searchBook: SearchBook) { + changeSourceSearchUseCase.deleteSource(searchBook) + searchResults.remove(searchBook) + filterResults() + } + + fun autoChangeSource( + bookType: Int?, + onSuccess: (book: Book, toc: List, source: BookSource) -> Unit, + ) { + viewModelScope.launch { + val found = searchResults.firstOrNull { it.type == bookType } + if (found != null) { + try { + val (toc, source) = getChapterContentUseCase.getToc(found.toBook()) + onSuccess(found.toBook(), toc, source) + } catch (_: Exception) { + } + } + } + } + + // Score + fun bookScoreFlow(searchBook: SearchBook) = ObservableSourceConfig.bookScoreFlow(searchBook) + fun onBookScoreClick(searchBook: SearchBook) { val currentScore = ObservableSourceConfig.getBookScore(searchBook) - setBookScore(searchBook, if (currentScore > 0) 0 else 1) + changeSourceSearchUseCase.topSource(searchBook) + ObservableSourceConfig.setBookScore(searchBook, if (currentScore > 0) 0 else 1) } + // Scope fun selectAllScope() { searchScope.update("") saveScope() @@ -110,17 +312,15 @@ class ChangeBookSourceComposeViewModel( } else { mutableSetOf() } - if (selectedUrls.contains(source.bookSourceUrl)) { selectedUrls.remove(source.bookSourceUrl) } else { selectedUrls.add(source.bookSourceUrl) } - if (selectedUrls.isEmpty()) { searchScope.update("") } else { - val selectedSources = appDb.bookSourceDao.allEnabledPart.filter { + val selectedSources = io.legado.app.data.appDb.bookSourceDao.allEnabledPart.filter { selectedUrls.contains(it.bookSourceUrl) } searchScope.updateSources(selectedSources) @@ -130,14 +330,12 @@ class ChangeBookSourceComposeViewModel( private fun saveScope() { ChangeSourceConfig.searchScope = searchScope.toString() - _scopeUiState.update { - ScopeUiState( - isAll = searchScope.isAll(), - isSource = searchScope.isSource(), - displayNames = searchScope.displayNames, - sourceUrls = searchScope.sourceUrls - ) - } + _scopeUiState.value = ScopeUiState( + isAll = searchScope.isAll(), + isSource = searchScope.isSource(), + displayNames = searchScope.displayNames, + sourceUrls = searchScope.sourceUrls + ) refresh() } } diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceDialog.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceDialog.kt index b65125cd6..b50d3d875 100644 --- a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceDialog.kt +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceDialog.kt @@ -32,10 +32,10 @@ import io.legado.app.databinding.DialogBookChangeSourceBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.alert import io.legado.app.model.ReadBook -import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.book.search.SearchScope import io.legado.app.ui.book.source.edit.BookSourceEditActivity import io.legado.app.ui.book.source.manage.BookSourceActivity +import io.legado.app.ui.main.MainActivity import io.legado.app.ui.widget.dialog.WaitDialog import io.legado.app.ui.widget.recycler.VerticalDivider import io.legado.app.utils.StartActivityContract @@ -103,7 +103,7 @@ class ChangeBookSourceDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_b override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { //binding.toolBar.setBackgroundColor(primaryColor) - viewModel.initData(arguments, callBack?.oldBook, activity is ReadBookActivity) + viewModel.initData(arguments, callBack?.oldBook, MainActivity.hasActiveReadBookRoute) showTitle() initMenu() initRecyclerView() @@ -547,4 +547,4 @@ class ChangeBookSourceDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_b fun addToBookshelf(book: Book, toc: List) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceAdapter.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceAdapter.kt deleted file mode 100644 index e7a68609f..000000000 --- a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceAdapter.kt +++ /dev/null @@ -1,164 +0,0 @@ -package io.legado.app.ui.book.changesource - -import android.content.Context -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.widget.PopupMenu -import androidx.recyclerview.widget.DiffUtil -import io.legado.app.R -import io.legado.app.base.adapter.DiffRecyclerAdapter -import io.legado.app.base.adapter.ItemViewHolder -import io.legado.app.data.entities.SearchBook -import io.legado.app.databinding.ItemChangeSourceBinding -import io.legado.app.help.config.AppConfig -import io.legado.app.utils.gone -import io.legado.app.utils.invisible -import io.legado.app.utils.visible -import splitties.views.onLongClick - - -class ChangeChapterSourceAdapter( - context: Context, - val viewModel: ChangeChapterSourceViewModel, - val callBack: CallBack -) : DiffRecyclerAdapter(context) { - - override val diffItemCallback = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean { - return oldItem.bookUrl == newItem.bookUrl - } - - override fun areContentsTheSame(oldItem: SearchBook, newItem: SearchBook): Boolean { - return oldItem.originName == newItem.originName - && oldItem.getDisplayLastChapterTitle() == newItem.getDisplayLastChapterTitle() - } - - } - - override fun getViewBinding(parent: ViewGroup): ItemChangeSourceBinding { - return ItemChangeSourceBinding.inflate(inflater, parent, false) - } - - override fun convert( - holder: ItemViewHolder, - binding: ItemChangeSourceBinding, - item: SearchBook, - payloads: MutableList - ) { - binding.apply { - if (payloads.isEmpty()) { - tvOrigin.text = item.originName - tvAuthor.text = item.author - tvLast.text = item.getDisplayLastChapterTitle() - tvCurrentChapterWordCount.text = item.chapterWordCountText - tvRespondTime.text = context.getString(R.string.respondTime, item.respondTime) - if (callBack.oldBookUrl == item.bookUrl) { - ivChecked.visible() - } else { - ivChecked.invisible() - } - } else { - for (i in payloads.indices) { - val bundle = payloads[i] as Bundle - bundle.keySet().forEach { - when (it) { - "name" -> tvOrigin.text = item.originName - "latest" -> tvLast.text = item.getDisplayLastChapterTitle() - "upCurSource" -> if (callBack.oldBookUrl == item.bookUrl) { - ivChecked.visible() - } else { - ivChecked.invisible() - } - } - } - } - } - val score = callBack.getBookScore(item) - if (score > 0) { - // 已置顶 - binding.ivGood.setImageResource(R.drawable.ic_praise_filled) - } else { - // 未置顶 - binding.ivGood.setImageResource(R.drawable.ic_praise) - } - - - if (AppConfig.changeSourceLoadWordCount && !item.chapterWordCountText.isNullOrBlank()) { - tvCurrentChapterWordCount.visible() - } else { - tvCurrentChapterWordCount.gone() - } - - if (AppConfig.changeSourceLoadWordCount && item.respondTime >= 0) { - tvRespondTime.visible() - } else { - tvRespondTime.gone() - } - } - } - - override fun registerListener(holder: ItemViewHolder, binding: ItemChangeSourceBinding) { - binding.ivGood.setOnClickListener { - val item = getItem(holder.layoutPosition) ?: return@setOnClickListener - val score = callBack.getBookScore(item) - if (score > 0) { - // 已置顶 -> 取消置顶 - binding.ivGood.setImageResource(R.drawable.ic_praise) - callBack.setBookScore(item, 0) - } else { - // 未置顶 -> 设置置顶 - binding.ivGood.setImageResource(R.drawable.ic_praise_filled) - callBack.setBookScore(item, 1) - } - } - holder.itemView.setOnClickListener { - getItem(holder.layoutPosition)?.let { - callBack.openToc(it) - } - } - holder.itemView.onLongClick { - showMenu(holder.itemView, getItem(holder.layoutPosition)) - } - } - - private fun showMenu(view: View, searchBook: SearchBook?) { - searchBook ?: return - val popupMenu = PopupMenu(context, view) - popupMenu.inflate(R.menu.change_source_item) - popupMenu.setOnMenuItemClickListener { - when (it.itemId) { - R.id.menu_top_source -> { - callBack.topSource(searchBook) - } - R.id.menu_bottom_source -> { - callBack.bottomSource(searchBook) - } - R.id.menu_edit_source -> { - callBack.editSource(searchBook) - } - R.id.menu_disable_source -> { - callBack.disableSource(searchBook) - } - R.id.menu_delete_source -> { - callBack.deleteSource(searchBook) - updateItems(0, itemCount, listOf()) - } - } - true - } - popupMenu.show() - } - - interface CallBack { - val oldBookUrl: String? - fun openToc(searchBook: SearchBook) - fun topSource(searchBook: SearchBook) - fun bottomSource(searchBook: SearchBook) - fun editSource(searchBook: SearchBook) - fun disableSource(searchBook: SearchBook) - fun deleteSource(searchBook: SearchBook) - fun setBookScore(searchBook: SearchBook, score: Int) - fun getBookScore(searchBook: SearchBook): Int - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceContract.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceContract.kt new file mode 100644 index 000000000..36b8c6272 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceContract.kt @@ -0,0 +1,86 @@ +package io.legado.app.ui.book.changesource + +import androidx.compose.runtime.Immutable +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookSourcePart +import io.legado.app.data.entities.SearchBook +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +data class ChangeChapterSourceUiState( + val searchQuery: String = "", + val isSearching: Boolean = false, + val searchProgress: Pair = 0 to "", + val totalSourceCount: Int = 0, + val searchResults: ImmutableList = persistentListOf(), + // Options + val checkAuthor: Boolean = ChangeSourceConfig.checkAuthor, + val loadInfo: Boolean = ChangeSourceConfig.loadInfo, + val loadToc: Boolean = ChangeSourceConfig.loadToc, + val loadWordCount: Boolean = ChangeSourceConfig.loadWordCount, + // TOC view + val showToc: Boolean = false, + val selectedSourceName: String = "", + val tocItems: ImmutableList = persistentListOf(), + val isLoadingToc: Boolean = false, + // Scope filter + val scopeState: ScopeUiState = ScopeUiState( + isAll = true, + isSource = false, + displayNames = emptyList(), + sourceUrls = emptyList() + ), + val enabledGroups: ImmutableList = persistentListOf(), + val enabledSources: ImmutableList = persistentListOf(), + // Source book map (for score flow) + val bookMap: Map = emptyMap(), +) + +@Immutable +data class ScopeUiState( + val isAll: Boolean, + val isSource: Boolean, + val displayNames: List, + val sourceUrls: List +) + +sealed interface ChangeChapterSourceIntent { + // Search + data class UpdateQuery(val query: String) : ChangeChapterSourceIntent + data object StartStopSearch : ChangeChapterSourceIntent + data object Refresh : ChangeChapterSourceIntent + + // Source selection + data class SelectSource(val searchBook: SearchBook) : ChangeChapterSourceIntent + data object BackFromToc : ChangeChapterSourceIntent + + // Chapter selection + data class SelectChapter(val chapter: BookChapter) : ChangeChapterSourceIntent + + // Options menu + data class SetCheckAuthor(val enabled: Boolean) : ChangeChapterSourceIntent + data class SetLoadInfo(val enabled: Boolean) : ChangeChapterSourceIntent + data class SetLoadToc(val enabled: Boolean) : ChangeChapterSourceIntent + data class SetLoadWordCount(val enabled: Boolean) : ChangeChapterSourceIntent + + // Source actions + data class TopSource(val searchBook: SearchBook) : ChangeChapterSourceIntent + data class BottomSource(val searchBook: SearchBook) : ChangeChapterSourceIntent + data class DisableSource(val searchBook: SearchBook) : ChangeChapterSourceIntent + data class DeleteSource(val searchBook: SearchBook) : ChangeChapterSourceIntent + + // Scope filter + data object ShowFilterSheet : ChangeChapterSourceIntent + data object DismissFilterSheet : ChangeChapterSourceIntent + data object SelectAllScope : ChangeChapterSourceIntent + data class ToggleScopeGroup(val groupName: String) : ChangeChapterSourceIntent + data class ToggleScopeSource(val source: BookSourcePart) : ChangeChapterSourceIntent + data object ApplyScope : ChangeChapterSourceIntent +} + +sealed interface ChangeChapterSourceEffect { + data class ReplaceContent(val content: String) : ChangeChapterSourceEffect + data class ShowToast(val message: String) : ChangeChapterSourceEffect + data object Dismiss : ChangeChapterSourceEffect +} diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceDialog.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceDialog.kt deleted file mode 100644 index 2411d58bd..000000000 --- a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceDialog.kt +++ /dev/null @@ -1,420 +0,0 @@ -package io.legado.app.ui.book.changesource - -//import io.legado.app.lib.theme.primaryColor -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import androidx.activity.addCallback -import androidx.appcompat.widget.SearchView -import androidx.appcompat.widget.Toolbar -import androidx.core.os.bundleOf -import androidx.core.view.isVisible -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle.State.STARTED -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import io.legado.app.R -import io.legado.app.base.BaseDialogFragment -import io.legado.app.constant.AppLog -import io.legado.app.constant.EventBus -import io.legado.app.data.appDb -import io.legado.app.data.entities.Book -import io.legado.app.data.entities.BookChapter -import io.legado.app.data.entities.BookSource -import io.legado.app.data.entities.SearchBook -import io.legado.app.databinding.DialogChapterChangeSourceBinding -import io.legado.app.help.book.BookHelp -import io.legado.app.help.config.AppConfig -import io.legado.app.lib.dialogs.alert -import io.legado.app.ui.book.read.ReadBookActivity -import io.legado.app.ui.book.search.SearchScope -import io.legado.app.ui.book.source.edit.BookSourceEditActivity -import io.legado.app.ui.book.source.manage.BookSourceActivity -import io.legado.app.ui.widget.recycler.VerticalDivider -import io.legado.app.utils.StartActivityContract -import io.legado.app.utils.applyTint -import io.legado.app.utils.dpToPx -import io.legado.app.utils.gone -import io.legado.app.utils.observeEvent -import io.legado.app.utils.setLayout -import io.legado.app.utils.startActivity -import io.legado.app.utils.toastOnUi -import io.legado.app.utils.transaction -import io.legado.app.utils.viewbindingdelegate.viewBinding -import io.legado.app.utils.visible -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.conflate -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch - - -class ChangeChapterSourceDialog() : BaseDialogFragment(R.layout.dialog_chapter_change_source), - Toolbar.OnMenuItemClickListener, - ChangeChapterSourceAdapter.CallBack, - ChangeChapterTocAdapter.Callback { - - constructor(name: String, author: String, chapterIndex: Int, chapterTitle: String) : this() { - arguments = Bundle().apply { - putString("name", name) - putString("author", author) - putInt("chapterIndex", chapterIndex) - putString("chapterTitle", chapterTitle) - } - } - - private val binding by viewBinding(DialogChapterChangeSourceBinding::bind) - private val groups = linkedSetOf() - private val callBack: CallBack? get() = activity as? CallBack - private val viewModel: ChangeChapterSourceViewModel by viewModels() - private val editSourceResult = - registerForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) { - viewModel.startSearch() - } - private val searchBookAdapter by lazy { - ChangeChapterSourceAdapter(requireContext(), viewModel, this) - } - private val tocAdapter by lazy { - ChangeChapterTocAdapter(requireContext(), this) - } - private val contentSuccess: (content: String) -> Unit = { - binding.loadingToc.gone() - callBack?.replaceContent(it) - dismissAllowingStateLoss() - } - private var searchBook: SearchBook? = null - private val searchFinishCallback: (isEmpty: Boolean) -> Unit = { - if (it) { - val searchScope = SearchScope(ChangeSourceConfig.searchScope) - val group = searchScope.display - if (!searchScope.isAll()) { - lifecycleScope.launch { - context?.alert("搜索结果为空") { - setMessage("${group}分组搜索结果为空,是否切换到全部分组") - noButton() - yesButton { - ChangeSourceConfig.searchScope = "" - upGroupMenu() - viewModel.startSearch() - } - } - } - } - } - } - - override fun onStart() { - super.onStart() - setLayout(1f, ViewGroup.LayoutParams.MATCH_PARENT) - } - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - //binding.toolBar.setBackgroundColor(primaryColor) - viewModel.initData(arguments, callBack?.oldBook, activity is ReadBookActivity) - showTitle() - initMenu() - initView() - initRecyclerView() - initSearchView() - initBottomBar() - initLiveData() - viewModel.searchFinishCallback = searchFinishCallback - activity?.onBackPressedDispatcher?.addCallback(this) { - if (binding.clToc.isVisible) { - binding.clToc.gone() - binding.recyclerView.visible() - return@addCallback - } - dismissAllowingStateLoss() - } - } - - override fun onDestroy() { - super.onDestroy() - viewModel.searchFinishCallback = null - } - - private fun showTitle() { - binding.toolBar.title = viewModel.chapterTitle - } - - private fun initMenu() { - binding.toolBar.inflateMenu(R.menu.change_source) - //binding.toolBar.menu.applyTint(requireContext()) - binding.toolBar.setOnMenuItemClickListener(this) - binding.toolBar.menu.findItem(R.id.menu_check_author) - ?.isChecked = AppConfig.changeSourceCheckAuthor - binding.toolBar.menu.findItem(R.id.menu_load_info) - ?.isChecked = AppConfig.changeSourceLoadInfo - binding.toolBar.menu.findItem(R.id.menu_load_toc) - ?.isChecked = AppConfig.changeSourceLoadToc - binding.toolBar.menu.findItem(R.id.menu_load_word_count) - ?.isChecked = AppConfig.changeSourceLoadWordCount - } - - private fun initView() { - binding.ivHideToc.setOnClickListener { - binding.clToc.gone() - binding.recyclerView.visible() - } - //binding.flHideToc.elevation = requireContext().elevation - } - - private fun initRecyclerView() { - binding.recyclerView.addItemDecoration(VerticalDivider(requireContext())) - binding.recyclerView.adapter = searchBookAdapter - searchBookAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { - override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - if (positionStart == 0) { - binding.recyclerView.scrollToPosition(0) - } - } - - override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { - if (toPosition == 0) { - binding.recyclerView.scrollToPosition(0) - } - } - }) - binding.recyclerViewToc.adapter = tocAdapter - } - - private fun initSearchView() { - val searchView = binding.toolBar.menu.findItem(R.id.menu_screen).actionView as SearchView - searchView.setOnCloseListener { - showTitle() - false - } - searchView.setOnSearchClickListener { - binding.toolBar.title = "" - binding.toolBar.subtitle = "" - } - searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String?): Boolean { - return false - } - - override fun onQueryTextChange(newText: String?): Boolean { - viewModel.screen(newText) - return false - } - - }) - } - - private fun initBottomBar() { - binding.tvDur.text = callBack?.oldBook?.originName - binding.tvDur.setOnClickListener { - scrollToDurSource() - } - binding.ivTop.setOnClickListener { - binding.recyclerView.scrollToPosition(0) - } - binding.ivBottom.setOnClickListener { - binding.recyclerView.scrollToPosition(searchBookAdapter.itemCount - 1) - } - } - - private fun initLiveData() { - viewModel.searchStateData.observe(viewLifecycleOwner) { - binding.refreshProgressBar.isVisible = it - if (it) { - startStopMenuItem?.let { item -> - item.setIcon(R.drawable.ic_stop_black_24dp) - item.setTitle(R.string.stop) - } - } else { - startStopMenuItem?.let { item -> - item.setIcon(R.drawable.ic_refresh) - item.setTitle(R.string.refresh) - } - } - binding.toolBar.menu.applyTint(requireContext()) - } - lifecycleScope.launch { - lifecycle.currentStateFlow.first { it.isAtLeast(STARTED) } - viewModel.searchDataFlow.conflate().collect { - searchBookAdapter.setItems(it) - delay(1000) - - } - } - lifecycleScope.launch { - appDb.bookSourceDao.flowEnabledGroups().conflate().collect { - groups.clear() - groups.addAll(it) - upGroupMenu() - } - } - } - - private val startStopMenuItem: MenuItem? - get() = binding.toolBar.menu.findItem(R.id.menu_start_stop) - - override fun onMenuItemClick(item: MenuItem?): Boolean { - when (item?.itemId) { - R.id.menu_check_author -> { - AppConfig.changeSourceCheckAuthor = !item.isChecked - item.isChecked = !item.isChecked - viewModel.refresh() - } - - R.id.menu_load_info -> { - AppConfig.changeSourceLoadInfo = !item.isChecked - item.isChecked = !item.isChecked - } - - R.id.menu_load_toc -> { - AppConfig.changeSourceLoadToc = !item.isChecked - item.isChecked = !item.isChecked - } - - R.id.menu_load_word_count -> { - AppConfig.changeSourceLoadWordCount = !item.isChecked - item.isChecked = !item.isChecked - viewModel.onLoadWordCountChecked(item.isChecked) - } - - R.id.menu_start_stop -> viewModel.startOrStopSearch() - R.id.menu_source_manage -> startActivity() - else -> if (item?.groupId == R.id.source_group && !item.isChecked) { - item.isChecked = true - if (item.title.toString() == getString(R.string.all_source)) { - ChangeSourceConfig.searchScope = "" - } else { - ChangeSourceConfig.searchScope = item.title.toString() - } - lifecycleScope.launch(IO) { - viewModel.stopSearch() - if (viewModel.refresh()) { - viewModel.startSearch() - } - } - } - } - return false - } - - private fun scrollToDurSource() { - searchBookAdapter.getItems().forEachIndexed { index, searchBook -> - if (searchBook.bookUrl == oldBookUrl) { - (binding.recyclerView.layoutManager as LinearLayoutManager) - .scrollToPositionWithOffset(index, 60.dpToPx()) - return - } - } - } - - override fun openToc(searchBook: SearchBook) { - this.searchBook = searchBook - tocAdapter.setItems(null) - binding.recyclerView.gone() - binding.clToc.visible() - binding.loadingToc.visible() - val book = searchBook.toBook() - viewModel.getToc(book, { toc: List, _: BookSource -> - tocAdapter.durChapterIndex = - BookHelp.getDurChapter(viewModel.chapterIndex, viewModel.chapterTitle, toc) - binding.loadingToc.gone() - tocAdapter.setItems(toc) - binding.recyclerViewToc.scrollToPosition(tocAdapter.durChapterIndex - 5) - }, { - binding.clToc.gone() - AppLog.put("单章换源获取目录出错\n$it", it, true) - }) - } - - override val oldBookUrl: String? - get() = callBack?.oldBook?.bookUrl - - override fun topSource(searchBook: SearchBook) { - viewModel.topSource(searchBook) - } - - override fun bottomSource(searchBook: SearchBook) { - viewModel.bottomSource(searchBook) - } - - override fun editSource(searchBook: SearchBook) { - editSourceResult.launch { - putExtra("sourceUrl", searchBook.origin) - } - } - - override fun disableSource(searchBook: SearchBook) { - viewModel.disableSource(searchBook) - } - - override fun deleteSource(searchBook: SearchBook) { - viewModel.del(searchBook) - if (oldBookUrl == searchBook.bookUrl) { - viewModel.autoChangeSource(callBack?.oldBook?.type) { book, toc, source -> - callBack?.changeTo(source, book, toc) - } - } - } - - override fun setBookScore(searchBook: SearchBook, score: Int) { - viewModel.setBookScore(searchBook, score) - } - - override fun getBookScore(searchBook: SearchBook): Int { - return viewModel.getBookScore(searchBook) - } - - override fun clickChapter(bookChapter: BookChapter, nextChapterUrl: String?) { - searchBook?.let { - binding.loadingToc.visible() - viewModel.getContent(it.toBook(), bookChapter, nextChapterUrl, contentSuccess) { msg -> - binding.loadingToc.gone() - binding.clToc.gone() - toastOnUi(msg) - } - } - } - - /** - * 更新分组菜单 - */ - private fun upGroupMenu() { - binding.toolBar.menu.findItem(R.id.menu_group)?.subMenu?.transaction { menu -> - val searchScope = SearchScope(ChangeSourceConfig.searchScope) - val selectedGroup = searchScope.displayNames.firstOrNull() ?: "" - menu.removeGroup(R.id.source_group) - val allItem = menu.add(R.id.source_group, Menu.NONE, Menu.NONE, R.string.all_source) - var hasSelectedGroup = false - groups.forEach { group -> - menu.add(R.id.source_group, Menu.NONE, Menu.NONE, group)?.let { - if (group == selectedGroup) { - it.isChecked = true - hasSelectedGroup = true - } - } - } - menu.setGroupCheckable(R.id.source_group, true, true) - if (!hasSelectedGroup) { - allItem.isChecked = true - } - } - } - - override fun observeLiveBus() { - observeEvent(EventBus.SOURCE_CHANGED) { - searchBookAdapter.notifyItemRangeChanged( - 0, - searchBookAdapter.itemCount, - bundleOf(Pair("upCurSource", oldBookUrl)) - ) - } - } - - interface CallBack { - val oldBook: Book? - fun changeTo(source: BookSource, book: Book, toc: List) - fun replaceContent(content: String) - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceViewModel.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceViewModel.kt index c97d0406d..20eaf4d8c 100644 --- a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceViewModel.kt @@ -1,46 +1,389 @@ package io.legado.app.ui.book.changesource -import android.app.Application -import android.os.Bundle -import io.legado.app.data.appDb +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter -import io.legado.app.exception.NoStackTraceException -import io.legado.app.model.webBook.WebBook +import io.legado.app.data.entities.SearchBook +import io.legado.app.data.repository.SearchRepository +import io.legado.app.domain.usecase.ChangeSourceSearchEvent +import io.legado.app.domain.usecase.ChangeSourceSearchUseCase +import io.legado.app.domain.usecase.GetChapterContentUseCase +import io.legado.app.ui.book.search.SearchScope +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch -@Suppress("MemberVisibilityCanBePrivate") -class ChangeChapterSourceViewModel(application: Application) : - ChangeBookSourceViewModel(application) { +class ChangeChapterSourceViewModel( + private val changeSourceSearchUseCase: ChangeSourceSearchUseCase, + private val getChapterContentUseCase: GetChapterContentUseCase, + private val searchRepository: SearchRepository, +) : ViewModel() { - var chapterIndex: Int = 0 - var chapterTitle: String = "" + private val _uiState = MutableStateFlow(ChangeChapterSourceUiState()) + val uiState = _uiState.asStateFlow() - override fun initData(arguments: Bundle?, book: Book?, fromReadBookActivity: Boolean) { - super.initData(arguments, book, fromReadBookActivity) - arguments?.let { bundle -> - bundle.getString("chapterTitle")?.let { - chapterTitle = it + private val _effects = MutableSharedFlow(extraBufferCapacity = 16) + val effects = _effects.asSharedFlow() + + // Internal state + private var searchJob: Job? = null + private var oldBook: Book? = null + private var chapterIndex: Int = 0 + private var chapterTitle: String = "" + private var screenKey: String = "" + private val searchResults = mutableListOf() + private val bookMap = mutableMapOf() + + // Scope state + private val searchScope = SearchScope(ChangeSourceConfig.searchScope) + + init { + // Load initial scope state + _uiState.update { + it.copy( + scopeState = ScopeUiState( + isAll = searchScope.isAll(), + isSource = searchScope.isSource(), + displayNames = searchScope.displayNames, + sourceUrls = searchScope.sourceUrls + ) + ) + } + // Collect enabled groups and sources + viewModelScope.launch { + searchRepository.enabledGroups.collect { groups -> + _uiState.update { it.copy(enabledGroups = groups.toImmutableList()) } + } + } + viewModelScope.launch { + searchRepository.enabledSources.collect { sources -> + _uiState.update { it.copy(enabledSources = sources.toImmutableList()) } } - chapterIndex = bundle.getInt("chapterIndex") } } - fun getContent( - book: Book, - chapter: BookChapter, - nextChapterUrl: String?, - success: (content: String) -> Unit, - error: (msg: String) -> Unit - ) { - execute { - val bookSource = appDb.bookSourceDao.getBookSource(book.origin) - ?: throw NoStackTraceException("书源不存在") - WebBook.getContentAwait(bookSource, book, chapter, nextChapterUrl, false) - }.onSuccess { - success.invoke(it) - }.onError { - error.invoke(it.localizedMessage ?: "获取正文出错") + fun initData(book: Book, chapterIndex: Int, chapterTitle: String) { + this.oldBook = book + this.chapterIndex = chapterIndex + this.chapterTitle = chapterTitle + _uiState.update { + it.copy( + showToc = false, + tocItems = persistentListOf(), + isLoadingToc = false, + selectedSourceName = "", + ) + } + startSearch() + } + + fun onIntent(intent: ChangeChapterSourceIntent) { + when (intent) { + is ChangeChapterSourceIntent.UpdateQuery -> { + screenKey = intent.query.trim() + _uiState.update { it.copy(searchQuery = intent.query) } + filterResults() + } + + is ChangeChapterSourceIntent.StartStopSearch -> { + if (searchJob?.isActive == true) { + stopSearch() + } else { + startSearch() + } + } + + is ChangeChapterSourceIntent.Refresh -> { + startSearch() + } + + is ChangeChapterSourceIntent.SelectSource -> { + selectSource(intent.searchBook) + } + + is ChangeChapterSourceIntent.BackFromToc -> { + _uiState.update { + it.copy( + showToc = false, + tocItems = persistentListOf(), + isLoadingToc = false + ) + } + } + + is ChangeChapterSourceIntent.SelectChapter -> { + selectChapter(intent.chapter) + } + // Options + is ChangeChapterSourceIntent.SetCheckAuthor -> { + ChangeSourceConfig.checkAuthor = intent.enabled + _uiState.update { it.copy(checkAuthor = intent.enabled) } + refreshResults() + } + + is ChangeChapterSourceIntent.SetLoadInfo -> { + ChangeSourceConfig.loadInfo = intent.enabled + _uiState.update { it.copy(loadInfo = intent.enabled) } + } + + is ChangeChapterSourceIntent.SetLoadToc -> { + ChangeSourceConfig.loadToc = intent.enabled + _uiState.update { it.copy(loadToc = intent.enabled) } + } + + is ChangeChapterSourceIntent.SetLoadWordCount -> { + ChangeSourceConfig.loadWordCount = intent.enabled + _uiState.update { it.copy(loadWordCount = intent.enabled) } + if (intent.enabled) { + startSearch() + } else { + refreshResults() + } + } + // Source actions + is ChangeChapterSourceIntent.TopSource -> { + changeSourceSearchUseCase.topSource(intent.searchBook) + refreshResults() + } + + is ChangeChapterSourceIntent.BottomSource -> { + changeSourceSearchUseCase.bottomSource(intent.searchBook) + refreshResults() + } + + is ChangeChapterSourceIntent.DisableSource -> { + changeSourceSearchUseCase.disableSource(intent.searchBook) + searchResults.remove(intent.searchBook) + filterResults() + } + + is ChangeChapterSourceIntent.DeleteSource -> { + changeSourceSearchUseCase.deleteSource(intent.searchBook) + searchResults.remove(intent.searchBook) + filterResults() + } + // Scope + is ChangeChapterSourceIntent.ShowFilterSheet -> { + // Handled by UI + } + + is ChangeChapterSourceIntent.DismissFilterSheet -> { + // Handled by UI + } + + is ChangeChapterSourceIntent.SelectAllScope -> { + searchScope.update("") + saveScope() + } + + is ChangeChapterSourceIntent.ToggleScopeGroup -> { + if (searchScope.isSource()) { + searchScope.update("") + } + val selected = searchScope.displayNames.toMutableSet() + if (selected.contains(intent.groupName)) { + selected.remove(intent.groupName) + } else { + selected.add(intent.groupName) + } + searchScope.update(selected.toList()) + saveScope() + } + + is ChangeChapterSourceIntent.ToggleScopeSource -> { + val selectedUrls = if (searchScope.isSource()) { + searchScope.sourceUrls.toMutableSet() + } else { + mutableSetOf() + } + if (selectedUrls.contains(intent.source.bookSourceUrl)) { + selectedUrls.remove(intent.source.bookSourceUrl) + } else { + selectedUrls.add(intent.source.bookSourceUrl) + } + if (selectedUrls.isEmpty()) { + searchScope.update("") + } else { + val selectedSources = + io.legado.app.data.appDb.bookSourceDao.allEnabledPart.filter { + selectedUrls.contains(it.bookSourceUrl) + } + searchScope.updateSources(selectedSources) + } + saveScope() + } + + is ChangeChapterSourceIntent.ApplyScope -> { + startSearch() + } } } -} \ No newline at end of file + private fun startSearch() { + val book = oldBook ?: return + stopSearch() + searchResults.clear() + bookMap.clear() + filterResults() + + searchJob = viewModelScope.launch { + changeSourceSearchUseCase.search( + name = book.name, + author = book.author, + scope = SearchScope(ChangeSourceConfig.searchScope), + oldBook = book, + fromReadBookActivity = true, + ).collect { event -> + when (event) { + is ChangeSourceSearchEvent.Started -> { + _uiState.update { it.copy(isSearching = true) } + } + + is ChangeSourceSearchEvent.Progress -> { + _uiState.update { + it.copy( + searchProgress = event.processedSources to event.sourceName, + totalSourceCount = event.totalSources, + ) + } + } + + is ChangeSourceSearchEvent.Result -> { + searchResults.add(event.searchBook) + bookMap[event.searchBook.primaryStr()] = event.searchBook + filterResults() + } + + is ChangeSourceSearchEvent.Finished -> { + _uiState.update { it.copy(isSearching = false) } + } + } + } + } + } + + private fun stopSearch() { + searchJob?.cancel() + searchJob = null + _uiState.update { it.copy(isSearching = false) } + } + + fun dispose() { + stopSearch() + } + + private fun refreshResults() { + searchResults.clear() + bookMap.clear() + startSearch() + } + + private fun filterResults() { + val filtered = if (screenKey.isEmpty()) { + searchResults.toList() + } else { + searchResults.filter { + it.name.contains(screenKey) || it.originName.contains(screenKey) + } + } + // Sort by score + val sorted = filtered.sortedWith( + compareByDescending { ObservableSourceConfig.getBookScore(it) } + .thenByDescending { io.legado.app.help.config.SourceConfig.getSourceScore(it.origin) } + .thenBy { it.originOrder } + ) + _uiState.update { + it.copy( + searchResults = sorted.toImmutableList(), + bookMap = bookMap.toMap() + ) + } + } + + private fun selectSource(searchBook: SearchBook) { + val book = searchBook.toBook() + _uiState.update { + it.copy( + showToc = true, + selectedSourceName = searchBook.originName, + isLoadingToc = true + ) + } + viewModelScope.launch { + try { + val (toc, _) = getChapterContentUseCase.getToc(book) + _uiState.update { + it.copy( + tocItems = toc.toImmutableList(), + isLoadingToc = false + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + showToc = false, + isLoadingToc = false + ) + } + _effects.tryEmit(ChangeChapterSourceEffect.ShowToast("获取目录失败")) + } + } + } + + private fun selectChapter(chapter: BookChapter) { + val book = oldBook ?: return + val selectedSearchBook = _uiState.value.searchResults.find { + it.originName == _uiState.value.selectedSourceName + } ?: return + + _uiState.update { it.copy(isLoadingToc = true) } + viewModelScope.launch { + try { + val searchBook = selectedSearchBook.toBook() + val toc = _uiState.value.tocItems + val nextChapterUrl = toc.getOrNull(chapter.index + 1)?.url + val content = + getChapterContentUseCase.getContent(searchBook, chapter, nextChapterUrl) + _uiState.update { it.copy(isLoadingToc = false) } + _effects.tryEmit(ChangeChapterSourceEffect.ReplaceContent(content)) + _effects.tryEmit(ChangeChapterSourceEffect.Dismiss) + } catch (e: Exception) { + _uiState.update { it.copy(isLoadingToc = false) } + _effects.tryEmit( + ChangeChapterSourceEffect.ShowToast( + e.localizedMessage ?: "获取正文出错" + ) + ) + } + } + } + + private fun saveScope() { + ChangeSourceConfig.searchScope = searchScope.toString() + _uiState.update { + it.copy( + scopeState = ScopeUiState( + isAll = searchScope.isAll(), + isSource = searchScope.isSource(), + displayNames = searchScope.displayNames, + sourceUrls = searchScope.sourceUrls + ) + ) + } + refreshResults() + } + + fun bookScoreFlow(searchBook: SearchBook) = ObservableSourceConfig.bookScoreFlow(searchBook) + + fun onBookScoreClick(searchBook: SearchBook) { + val currentScore = ObservableSourceConfig.getBookScore(searchBook) + ObservableSourceConfig.setBookScore(searchBook, if (currentScore > 0) 0 else 1) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterTocAdapter.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterTocAdapter.kt deleted file mode 100644 index 2394a75c7..000000000 --- a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterTocAdapter.kt +++ /dev/null @@ -1,74 +0,0 @@ -package io.legado.app.ui.book.changesource - -import android.content.Context -import android.view.ViewGroup -import io.legado.app.R -import io.legado.app.base.adapter.ItemViewHolder -import io.legado.app.base.adapter.RecyclerAdapter -import io.legado.app.data.entities.BookChapter -import io.legado.app.databinding.ItemChapterListBinding -import io.legado.app.lib.theme.ThemeUtils -//import io.legado.app.lib.theme.accentColor -import io.legado.app.utils.getCompatColor -import io.legado.app.utils.gone -import io.legado.app.utils.themeColor -import io.legado.app.utils.visible - -class ChangeChapterTocAdapter(context: Context, val callback: Callback) : - RecyclerAdapter(context) { - - var durChapterIndex = 0 - - override fun getViewBinding(parent: ViewGroup): ItemChapterListBinding { - return ItemChapterListBinding.inflate(inflater, parent, false) - } - - override fun convert( - holder: ItemViewHolder, - binding: ItemChapterListBinding, - item: BookChapter, - payloads: MutableList - ) { - binding.run { - val isDur = durChapterIndex == item.index - ivVolume.gone() - ivLocked.gone() - if (isDur) { - tvChapterName.setTextColor(context.themeColor(androidx.appcompat.R.attr.colorPrimary)) - } else { - tvChapterName.setTextColor(context.themeColor(com.google.android.material.R.attr.colorOnSurface)) - } - - tvChapterName.text = item.title - if (item.isVolume) { - //卷名,如第一卷 突出显示 - tvChapterItem.setBackgroundColor(context.getCompatColor(R.color.btn_bg_press)) - } else { - //普通章节 保持不变 - tvChapterItem.background = - ThemeUtils.resolveDrawable(context, android.R.attr.selectableItemBackground) - } - if (!item.tag.isNullOrEmpty() && !item.isVolume) { - //卷名不显示tag(更新时间规则) - tvTag.text = item.tag - tvTag.visible() - } else { - tvTag.gone() - } - ivChecked.setImageResource(R.drawable.ic_check) - ivChecked.visible(isDur) - } - } - - override fun registerListener(holder: ItemViewHolder, binding: ItemChapterListBinding) { - holder.itemView.setOnClickListener { - getItem(holder.layoutPosition)?.let { - callback.clickChapter(it, getItem(holder.layoutPosition + 1)?.url) - } - } - } - - interface Callback { - fun clickChapter(bookChapter: BookChapter, nextChapterUrl: String?) - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt index 02bc3e58f..70ee5b591 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt @@ -29,6 +29,16 @@ class BookInfoActivity : BaseComposeActivity(), VariableDialog.Callback { onOpenSearch = { keyword -> startActivity(MainActivity.createSearchIntent(this, key = keyword)) }, + onOpenReader = { bookUrl, inBookshelf, chapterChanged -> + startActivity( + MainActivity.createReadBookIntent( + context = this, + bookUrl = bookUrl, + inBookshelf = inBookshelf, + chapterChanged = chapterChanged, + ) + ) + }, onNavigateToBookInfo = { name, author, bookUrl, origin, coverPath -> startActivity { putExtra("bookUrl", bookUrl) diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt index 3dc77d10e..ab4374158 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt @@ -10,6 +10,8 @@ import io.legado.app.domain.usecase.ChangeSourceMigrationOptions import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +const val READER_RESULT_DELETED = 100 + data class BookInfoUiState( val book: Book? = null, val chapterList: List = emptyList(), diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt index 1e7235e0e..1a3fb5fc2 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt @@ -22,7 +22,6 @@ import io.legado.app.model.SourceCallBack import io.legado.app.ui.book.audio.AudioPlayActivity import io.legado.app.ui.book.info.edit.BookInfoEditActivity import io.legado.app.ui.book.manga.ReadMangaActivity -import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.book.source.edit.BookSourceEditActivity import io.legado.app.ui.book.toc.TocActivityResult import io.legado.app.ui.config.otherConfig.OtherConfig @@ -48,6 +47,7 @@ fun BookInfoRouteScreen( onBack: () -> Unit, onFinish: (resultCode: Int?, afterTransition: Boolean) -> Unit, onOpenSearch: (String) -> Unit, + onOpenReader: (bookUrl: String, inBookshelf: Boolean, chapterChanged: Boolean) -> Unit = { _, _, _ -> }, onNavigateToBookInfo: (name: String?, author: String?, bookUrl: String, origin: String?, coverPath: String?) -> Unit = { _, _, _, _, _ -> }, onNavigateToExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit = { _, _, _ -> }, sharedTransitionScope: SharedTransitionScope? = null, @@ -123,7 +123,15 @@ fun BookInfoRouteScreen( ReadMangaActivity::class.java } - else -> ReadBookActivity::class.java + else -> null + } + if (cls == null) { + onOpenReader( + effect.book.bookUrl, + effect.inBookshelf, + effect.chapterChanged, + ) + return@collectLatest } readBookResult.launch( Intent(activity, cls).apply { diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt index abb7898b2..9799488d5 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt @@ -576,7 +576,7 @@ fun ChangeSourceSheet( val performAction: (SearchBook, Boolean) -> Unit = { searchBook, replace -> loadingAction = true - val book = viewModel.bookMap[searchBook.primaryStr()] ?: searchBook.toBook() + val book = viewModel.getBookFromMap(searchBook.primaryStr()) ?: searchBook.toBook() viewModel.getToc( book, onSuccess = { toc, source -> diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt index f30da80b1..589d1eddd 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt @@ -11,7 +11,6 @@ import coil.ImageLoader import coil.request.SuccessResult import io.legado.app.R import io.legado.app.base.BaseViewModel - import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern import io.legado.app.constant.BookType @@ -56,12 +55,13 @@ import io.legado.app.ui.main.MainIntent import io.legado.app.ui.widget.components.image.cover.buildCoverImageRequest import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.GSON -import io.legado.app.utils.fromJsonArray import io.legado.app.utils.ImageSaveUtils import io.legado.app.utils.UrlUtil +import io.legado.app.utils.fromJsonArray import io.legado.app.utils.postEvent import io.legado.app.utils.splitNotBlank import io.legado.app.utils.toastOnUi +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job @@ -76,7 +76,6 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.collections.immutable.toImmutableList import java.io.ByteArrayOutputStream class BookInfoViewModel( @@ -346,7 +345,7 @@ class BookInfoViewModel( syncUiState() } - io.legado.app.ui.book.read.ReadBookActivity.RESULT_DELETED -> { + READER_RESULT_DELETED -> { emitEffect(BookInfoEffect.Finish(resultCode = RESULT_OK)) } } diff --git a/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt b/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt index b06abe6bb..e3199940a 100644 --- a/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt @@ -56,6 +56,7 @@ import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext import io.legado.app.receiver.NetworkChangedListener import io.legado.app.ui.book.changesource.ChangeBookSourceDialog import io.legado.app.ui.book.info.BookInfoActivity +import io.legado.app.ui.book.info.READER_RESULT_DELETED import io.legado.app.ui.book.manga.config.MangaAutoReadDialog import io.legado.app.ui.book.manga.config.MangaClickActionConfigDialog import io.legado.app.ui.book.manga.config.MangaColorFilterConfig @@ -73,7 +74,6 @@ import io.legado.app.ui.book.manga.recyclerview.WebtoonFrame import io.legado.app.ui.book.read.EyeProtectionRefreshScheduler import io.legado.app.ui.book.read.MangaMenu import io.legado.app.ui.book.read.observeEyeProtectionEvents -import io.legado.app.ui.book.read.ReadBookActivity.Companion.RESULT_DELETED import io.legado.app.ui.book.source.edit.BookSourceEditActivity import io.legado.app.ui.book.toc.TocActivityResult import io.legado.app.ui.browser.WebViewActivity @@ -185,7 +185,7 @@ class ReadMangaActivity : VMBaseActivity? = null @@ -378,6 +380,91 @@ class ReadMangaViewModel( } } + fun setMangaPreDownloadNum(value: Int) { + AppConfig.mangaPreDownloadNum = value + viewModelScope.launch { mangaSettingsRepository.setMangaPreDownloadNum(value) } + } + + fun setMangaBackground(value: Int) { + AppConfig.mangaBackground = value + viewModelScope.launch { mangaSettingsRepository.setMangaBackground(value) } + } + + fun setDisableClickScroll(value: Boolean) { + AppConfig.disableClickScroll = value + viewModelScope.launch { mangaSettingsRepository.setDisableClickScroll(value) } + } + + fun setDisableMangaScrollAnimation(value: Boolean) { + AppConfig.disableMangaScrollAnimation = value + viewModelScope.launch { mangaSettingsRepository.setDisableMangaScrollAnimation(value) } + } + + fun setDisableMangaCrossFade(value: Boolean) { + AppConfig.disableMangaCrossFade = value + viewModelScope.launch { mangaSettingsRepository.setDisableMangaCrossFade(value) } + } + + fun setDisableMangaScale(value: Boolean) { + AppConfig.disableMangaScale = value + viewModelScope.launch { mangaSettingsRepository.setDisableMangaScale(value) } + } + + fun setEInkMode(enabled: Boolean, threshold: Int) { + AppConfig.enableMangaEInk = enabled + AppConfig.enableMangaGray = false + AppConfig.mangaEInkThreshold = threshold + viewModelScope.launch { + mangaSettingsRepository.setEnableMangaEInk(enabled) + mangaSettingsRepository.setEnableMangaGray(false) + mangaSettingsRepository.setMangaEInkThreshold(threshold) + } + } + + fun setGrayMode(enabled: Boolean) { + AppConfig.enableMangaGray = enabled + AppConfig.enableMangaEInk = false + viewModelScope.launch { + mangaSettingsRepository.setEnableMangaGray(enabled) + mangaSettingsRepository.setEnableMangaEInk(false) + } + } + + fun setMangaAutoPageSpeed(value: Int) { + AppConfig.mangaAutoPageSpeed = value + viewModelScope.launch { mangaSettingsRepository.setMangaAutoPageSpeed(value) } + } + + fun setMangaLongClick(value: Boolean) { + AppConfig.mangaLongClick = value + viewModelScope.launch { mangaSettingsRepository.setMangaLongClick(value) } + } + + fun setMangaVolumeKeyPage(value: Boolean) { + AppConfig.MangaVolumeKeyPage = value + viewModelScope.launch { mangaSettingsRepository.setMangaVolumeKeyPage(value) } + } + + fun setReverseVolumeKeyPage(value: Boolean) { + AppConfig.reverseVolumeKeyPage = value + viewModelScope.launch { mangaSettingsRepository.setReverseVolumeKeyPage(value) } + } + + fun setHideMangaTitle(value: Boolean) { + AppConfig.hideMangaTitle = value + viewModelScope.launch { mangaSettingsRepository.setHideMangaTitle(value) } + } + + fun setMangaColorFilter(value: String) { + AppConfig.mangaColorFilter = value + viewModelScope.launch { mangaSettingsRepository.setMangaAutoColorFilter(value) } + } + + fun setMangaFooterConfig(value: String) { + AppConfig.mangaFooterConfig = value + viewModelScope.launch { mangaSettingsRepository.setMangaFooterConfig(value) } + } + override fun onCleared() { super.onCleared() changeSourceCoroutine?.cancel() diff --git a/app/src/main/java/io/legado/app/ui/book/manga/config/MangaClickActionConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/manga/config/MangaClickActionConfigDialog.kt index ca9f3f13d..ba4cba78e 100644 --- a/app/src/main/java/io/legado/app/ui/book/manga/config/MangaClickActionConfigDialog.kt +++ b/app/src/main/java/io/legado/app/ui/book/manga/config/MangaClickActionConfigDialog.kt @@ -8,15 +8,17 @@ import android.widget.TextView import io.legado.app.R import io.legado.app.base.BaseOverlayDialogFragment import io.legado.app.constant.PreferKey +import io.legado.app.data.repository.MangaSettingsRepository import io.legado.app.databinding.DialogClickActionConfigBinding import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.selector import io.legado.app.utils.getCompatColor -import io.legado.app.utils.putPrefInt import io.legado.app.utils.viewbindingdelegate.viewBinding +import org.koin.android.ext.android.inject class MangaClickActionConfigDialog : BaseOverlayDialogFragment(R.layout.dialog_click_action_config) { private val binding by viewBinding(DialogClickActionConfigBinding::bind) + private val mangaSettingsRepository by inject() private val actions by lazy { linkedMapOf( @@ -66,68 +68,85 @@ class MangaClickActionConfigDialog : BaseOverlayDialogFragment(R.layout.dialog_c binding.tvTopLeft.setOnClickListener { selectAction { action -> - putPrefInt(PreferKey.mangaClickActionTL, action) + setClickAction(PreferKey.mangaClickActionTL, action) (it as? TextView)?.text = actions[action] } } binding.tvTopCenter.setOnClickListener { selectAction { action -> - putPrefInt(PreferKey.mangaClickActionTC, action) + setClickAction(PreferKey.mangaClickActionTC, action) (it as? TextView)?.text = actions[action] } } binding.tvTopRight.setOnClickListener { selectAction { action -> - putPrefInt(PreferKey.mangaClickActionTR, action) + setClickAction(PreferKey.mangaClickActionTR, action) (it as? TextView)?.text = actions[action] } } binding.tvMiddleLeft.setOnClickListener { selectAction { action -> - putPrefInt(PreferKey.mangaClickActionML, action) + setClickAction(PreferKey.mangaClickActionML, action) (it as? TextView)?.text = actions[action] } } binding.tvMiddleCenter.setOnClickListener { selectAction { action -> - putPrefInt(PreferKey.mangaClickActionMC, action) + setClickAction(PreferKey.mangaClickActionMC, action) (it as? TextView)?.text = actions[action] } } binding.tvMiddleRight.setOnClickListener { selectAction { action -> - putPrefInt(PreferKey.mangaClickActionMR, action) + setClickAction(PreferKey.mangaClickActionMR, action) (it as? TextView)?.text = actions[action] } } binding.tvBottomLeft.setOnClickListener { selectAction { action -> - putPrefInt(PreferKey.mangaClickActionBL, action) + setClickAction(PreferKey.mangaClickActionBL, action) (it as? TextView)?.text = actions[action] } } binding.tvBottomCenter.setOnClickListener { selectAction { action -> - putPrefInt(PreferKey.mangaClickActionBC, action) + setClickAction(PreferKey.mangaClickActionBC, action) (it as? TextView)?.text = actions[action] } } binding.tvBottomRight.setOnClickListener { selectAction { action -> - putPrefInt(PreferKey.mangaClickActionBR, action) + setClickAction(PreferKey.mangaClickActionBR, action) (it as? TextView)?.text = actions[action] } } } + private fun setClickAction(key: String, action: Int) { + when (key) { + PreferKey.mangaClickActionTL -> AppConfig.mangaClickActionTL = action + PreferKey.mangaClickActionTC -> AppConfig.mangaClickActionTC = action + PreferKey.mangaClickActionTR -> AppConfig.mangaClickActionTR = action + PreferKey.mangaClickActionML -> AppConfig.mangaClickActionML = action + PreferKey.mangaClickActionMC -> AppConfig.mangaClickActionMC = action + PreferKey.mangaClickActionMR -> AppConfig.mangaClickActionMR = action + PreferKey.mangaClickActionBL -> AppConfig.mangaClickActionBL = action + PreferKey.mangaClickActionBC -> AppConfig.mangaClickActionBC = action + PreferKey.mangaClickActionBR -> AppConfig.mangaClickActionBR = action + } + execute { + mangaSettingsRepository.setMangaClickAction(key, action) + } + } + private fun selectAction(success: (action: Int) -> Unit) { context?.selector( getString(R.string.select_action), @@ -138,7 +157,20 @@ class MangaClickActionConfigDialog : BaseOverlayDialogFragment(R.layout.dialog_c } override fun onDestroy() { + if (!hasMenuClickArea()) { + AppConfig.detectMangaClickArea() + execute { + mangaSettingsRepository.setMangaClickAction(PreferKey.mangaClickActionMC, 0) + } + } super.onDestroy() - AppConfig.detectMangaClickArea() + } + + private fun hasMenuClickArea(): Boolean { + return AppConfig.mangaClickActionTL * AppConfig.mangaClickActionTC * + AppConfig.mangaClickActionTR * AppConfig.mangaClickActionML * + AppConfig.mangaClickActionMC * AppConfig.mangaClickActionMR * + AppConfig.mangaClickActionBL * AppConfig.mangaClickActionBC * + AppConfig.mangaClickActionBR == 0 } } diff --git a/app/src/main/java/io/legado/app/ui/book/manga/config/MangaColorFilterDialog.kt b/app/src/main/java/io/legado/app/ui/book/manga/config/MangaColorFilterDialog.kt index 60fcd7804..331bc97fd 100644 --- a/app/src/main/java/io/legado/app/ui/book/manga/config/MangaColorFilterDialog.kt +++ b/app/src/main/java/io/legado/app/ui/book/manga/config/MangaColorFilterDialog.kt @@ -7,6 +7,7 @@ import android.view.WindowManager import androidx.core.view.isVisible import io.legado.app.R import io.legado.app.base.BaseBottomSheetDialogFragment +import io.legado.app.data.repository.MangaSettingsRepository import io.legado.app.databinding.DialogMangaColorFilterBinding import io.legado.app.help.config.AppConfig import io.legado.app.utils.GSON @@ -14,9 +15,11 @@ import io.legado.app.utils.fromJsonObject import io.legado.app.utils.invisible import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible +import org.koin.android.ext.android.inject class MangaColorFilterDialog : BaseBottomSheetDialogFragment(R.layout.dialog_manga_color_filter) { private val binding by viewBinding(DialogMangaColorFilterBinding::bind) + private val mangaSettingsRepository by inject() private val mConfig = GSON.fromJsonObject(AppConfig.mangaColorFilter).getOrNull() ?: MangaColorFilterConfig() @@ -114,8 +117,13 @@ class MangaColorFilterDialog : BaseBottomSheetDialogFragment(R.layout.dialog_man override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) - AppConfig.mangaColorFilter = mConfig.toJson() + val colorFilter = mConfig.toJson() + AppConfig.mangaColorFilter = colorFilter AppConfig.mangaEInkThreshold = mMangaEInkThreshold + execute { + mangaSettingsRepository.setMangaAutoColorFilter(colorFilter) + mangaSettingsRepository.setMangaEInkThreshold(mMangaEInkThreshold) + } } interface Callback { @@ -124,4 +132,4 @@ class MangaColorFilterDialog : BaseBottomSheetDialogFragment(R.layout.dialog_man fun updateGrayMode(enabled: Boolean) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/book/manga/config/MangaFooterSettingDialog.kt b/app/src/main/java/io/legado/app/ui/book/manga/config/MangaFooterSettingDialog.kt index b386a03e7..1f234ea2d 100644 --- a/app/src/main/java/io/legado/app/ui/book/manga/config/MangaFooterSettingDialog.kt +++ b/app/src/main/java/io/legado/app/ui/book/manga/config/MangaFooterSettingDialog.kt @@ -11,6 +11,7 @@ import com.jaredrummler.android.colorpicker.ColorPickerDialog import io.legado.app.R import io.legado.app.base.BaseBottomSheetDialogFragment import io.legado.app.constant.EventBus +import io.legado.app.data.repository.MangaSettingsRepository import io.legado.app.databinding.DialogMangaFooterSettingBinding import io.legado.app.help.config.AppConfig import io.legado.app.ui.book.manga.entities.MangaFooterConfig @@ -20,6 +21,7 @@ import io.legado.app.utils.fromJsonObject import io.legado.app.utils.postEvent import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding +import org.koin.android.ext.android.inject class MangaFooterSettingDialog : BaseBottomSheetDialogFragment(R.layout.dialog_manga_footer_setting) { @@ -37,6 +39,7 @@ class MangaFooterSettingDialog : var callback: Callback? = null private val binding by viewBinding(DialogMangaFooterSettingBinding::bind) + private val mangaSettingsRepository by inject() override fun onStart() { super.onStart() @@ -255,7 +258,11 @@ class MangaFooterSettingDialog : override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) - AppConfig.mangaFooterConfig = GSON.toJson(config) + val footerConfig = GSON.toJson(config) + AppConfig.mangaFooterConfig = footerConfig + execute { + mangaSettingsRepository.setMangaFooterConfig(footerConfig) + } } private fun updateChapterText() { @@ -311,4 +318,4 @@ class MangaFooterSettingDialog : fun onMangaLongClickChanged(checked: Boolean) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/BaseReadBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/read/BaseReadBookActivity.kt deleted file mode 100644 index 0dddbe257..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/BaseReadBookActivity.kt +++ /dev/null @@ -1,432 +0,0 @@ -package io.legado.app.ui.book.read - -import android.annotation.SuppressLint -import android.content.pm.ActivityInfo -import android.os.Build -import android.os.Bundle -import android.view.KeyEvent -import android.view.View -import android.view.WindowInsets -import android.view.WindowManager -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.doOnAttach -import androidx.core.view.isVisible -import androidx.core.view.updateLayoutParams -import androidx.lifecycle.lifecycleScope -import com.google.android.material.datepicker.MaterialDatePicker -import io.legado.app.R -import io.legado.app.base.VMBaseActivity -import io.legado.app.constant.AppConst.charsets -import io.legado.app.constant.PreferKey -import io.legado.app.databinding.ActivityBookReadBinding -import io.legado.app.databinding.DialogDownloadChoiceBinding -import io.legado.app.databinding.DialogEditTextBinding -import io.legado.app.databinding.DialogSimulatedReadingBinding -import io.legado.app.help.config.AppConfig -import io.legado.app.help.config.LocalConfig -import io.legado.app.help.config.ReadBookConfig -import io.legado.app.lib.dialogs.alert -import io.legado.app.lib.dialogs.selector -import io.legado.app.model.CacheBook -import io.legado.app.model.ReadBook -import io.legado.app.ui.book.read.config.BgTextConfigDialog -import io.legado.app.ui.book.read.config.ClickActionConfigDialog -import io.legado.app.ui.book.read.config.FontConfigDialog -import io.legado.app.ui.book.read.config.FontSelectDialog -import io.legado.app.ui.book.read.config.InfoConfigDialog -import io.legado.app.ui.book.read.config.PaddingConfigDialog -import io.legado.app.ui.book.read.config.PageKeyDialog -import io.legado.app.ui.book.read.config.ShadowSetDialog -import io.legado.app.ui.book.read.config.UnderlineConfigDialog -import io.legado.app.ui.file.HandleFileContract -import io.legado.app.utils.ColorUtils -import io.legado.app.utils.FileDoc -import io.legado.app.utils.find -import io.legado.app.utils.getPrefString -import io.legado.app.utils.gone -import io.legado.app.utils.isTv -import io.legado.app.utils.setLightStatusBar -import io.legado.app.utils.setNavigationBarColorAuto -import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat -import kotlinx.coroutines.launch -import io.legado.app.utils.showDialogFragment -import io.legado.app.utils.themeColor -import io.legado.app.utils.viewbindingdelegate.viewBinding -import org.koin.androidx.viewmodel.ext.android.viewModel -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId -import java.time.format.DateTimeFormatter - -/** - * 阅读界面 - */ -abstract class BaseReadBookActivity : - VMBaseActivity(imageBg = false) { - - override val binding by viewBinding(ActivityBookReadBinding::inflate) - override val viewModel by viewModel() - protected val menuLayoutIsVisible - get() = bottomDialog > 0 || binding.readMenu.isVisible || binding.searchMenu.bottomMenuVisible - - var bottomDialog = 0 - set(value) { - if (field != value) { - field = value - onBottomDialogChange() - } - } - private val selectBookFolderResult = registerForActivityResult(HandleFileContract()) { - it.uri?.let { uri -> - ReadBook.book?.let { book -> - FileDoc.fromUri(uri, true).find(book.originName)?.let { doc -> - book.bookUrl = doc.uri.toString() - book.save() - viewModel.loadChapterList(book) - } ?: ReadBook.upMsg("找不到文件") - } - } ?: ReadBook.upMsg("没有权限访问") - } - - override fun onCreate(savedInstanceState: Bundle?) { - ReadBook.msg = null - setOrientation() - upLayoutInDisplayCutoutMode() - super.onCreate(savedInstanceState) - binding.navigationBar.doOnAttach { - binding.navigationBar.setOnApplyWindowInsetsListenerCompat { view, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - view.updateLayoutParams { - height = insets.bottom - } - windowInsets - } - } - viewModel.permissionDenialLiveData.observe(this) { - selectBookFolderResult.launch { - mode = HandleFileContract.DIR_SYS - title = "选择书籍所在文件夹" - } - } - if (!LocalConfig.readHelpVersionIsLast) { - if (isTv) { - showCustomPageKeyConfig() - } else { - showClickRegionalConfig() - } - } - } - - private fun onBottomDialogChange() { - when (bottomDialog) { - 0 -> onMenuHide() - 1 -> onMenuShow() - } - } - - open fun onMenuShow() { - - } - - open fun onMenuHide() { - - } - - fun showInfoConfig() { - showDialogFragment() - } - - fun showFont() { - showDialogFragment() - } - - fun showPaddingConfig() { - showDialogFragment() - } - - fun showShadowSet() { - showDialogFragment() - } - - fun showFontSelect() { - showDialogFragment() - } - - fun showUnderlineConfig() { - showDialogFragment() - } - - fun showBgTextConfig() { - showDialogFragment() - } - - fun showClickRegionalConfig() { - showDialogFragment() - } - - private fun showCustomPageKeyConfig() { - PageKeyDialog(this).show() - } - - /** - * 屏幕方向 - */ - @SuppressLint("SourceLockedOrientationActivity") - fun setOrientation() { - when (AppConfig.screenOrientation) { - "0" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED - "1" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - "2" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - "3" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR - "4" -> requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT - } - } - - /** - * 更新状态栏,导航栏 - */ - fun upSystemUiVisibility( - isInMultiWindow: Boolean, - toolBarHide: Boolean = true, - ) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - window.insetsController?.run { - if (toolBarHide && ReadBookConfig.hideNavigationBar) { - hide(WindowInsets.Type.navigationBars()) - } else { - show(WindowInsets.Type.navigationBars()) - } - if (toolBarHide && ReadBookConfig.hideStatusBar) { - hide(WindowInsets.Type.statusBars()) - } else { - show(WindowInsets.Type.statusBars()) - } - } - } - upSystemUiVisibilityO(isInMultiWindow, toolBarHide) - if (toolBarHide) { - setLightStatusBar(ReadBookConfig.durConfig.curStatusIconDark()) - } else { - val statusBarColor = - if (AppConfig.readBarStyleFollowPage - && ReadBookConfig.durConfig.curBgType() == 0 - ) { - ReadBookConfig.bgMeanColor - } else { - ReadBookConfig.bgMeanColor - } - setLightStatusBar(ColorUtils.isColorLight(statusBarColor)) - } - } - - @Suppress("DEPRECATION") - private fun upSystemUiVisibilityO( - isInMultiWindow: Boolean, - toolBarHide: Boolean = true - ) { - var flag = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE - or View.SYSTEM_UI_FLAG_IMMERSIVE - or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY) - if (!isInMultiWindow) { - flag = flag or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - } - if (ReadBookConfig.hideNavigationBar) { - flag = flag or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - if (toolBarHide) { - flag = flag or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - } - } - if (ReadBookConfig.hideStatusBar && toolBarHide) { - flag = flag or View.SYSTEM_UI_FLAG_FULLSCREEN - } - window.decorView.systemUiVisibility = flag - } - - fun upNavigationBarColor() { - upNavigationBar() - val navColor = when { - binding.readMenu.isVisible -> themeColor(com.google.android.material.R.attr.colorSurfaceContainer) - binding.searchMenu.bottomMenuVisible -> themeColor(com.google.android.material.R.attr.colorSurface) - bottomDialog > 0 -> themeColor(com.google.android.material.R.attr.colorSurface) - else -> ReadBookConfig.bgMeanColor - } - window.setNavigationBarColorAuto(navColor) - binding.navigationBar.setBackgroundColor(navColor) - } - - @SuppressLint("RtlHardcoded") - private fun upNavigationBar() { - binding.navigationBar.gone(!menuLayoutIsVisible) - } - - /** - * 保持亮屏 - */ - fun keepScreenOn(on: Boolean) { - val isScreenOn = - (window.attributes.flags and WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) != 0 - if (on == isScreenOn) return - if (on) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - } - - /** - * 适配刘海 - */ - private fun upLayoutInDisplayCutoutMode() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - window.attributes = window.attributes.apply { - layoutInDisplayCutoutMode = if (ReadBookConfig.readBodyToLh) { - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES - } else { - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER - } - } - } - } - - @SuppressLint("InflateParams", "SetTextI18n") - fun showDownloadDialog() { - ReadBook.book?.let { book -> - alert(titleResource = R.string.offline_cache) { - val alertBinding = DialogDownloadChoiceBinding.inflate(layoutInflater).apply { - editStart.setText((book.durChapterIndex + 1).toString()) - editEnd.setText(book.totalChapterNum.toString()) - } - customView { alertBinding.root } - okButton { - alertBinding.run { - val start = editStart.text!!.toString().let { - if (it.isEmpty()) 0 else it.toInt() - } - val end = editEnd.text!!.toString().let { - if (it.isEmpty()) book.totalChapterNum else it.toInt() - } - lifecycleScope.launch { - CacheBook.start(this@BaseReadBookActivity, book, start - 1, end - 1) - } - } - } - cancelButton() - } - } - } - - fun showSimulatedReading() { - val book = ReadBook.book ?: return - val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") - - val alertBinding = DialogSimulatedReadingBinding.inflate(layoutInflater).apply { - srEnabled.isChecked = book.getReadSimulating() - editStart.setText(book.getStartChapter().toString()) - editNum.setText(book.getDailyChapters().toString()) - - // 安全地设置初始日期 - val safeDate = book.getStartDate() ?: LocalDate.now() - startDate.setText(safeDate.format(dateFormatter)) - - // 让 EditText 不可直接编辑,只能通过选择器 - startDate.isFocusable = false - startDate.isCursorVisible = false - - startDate.setOnClickListener { - val currentDate = try { - LocalDate.parse(startDate.text.toString(), dateFormatter) - } catch (e: Exception) { - LocalDate.now() - } - val initialSelection = currentDate - .atStartOfDay(ZoneId.systemDefault()) - .toInstant() - .toEpochMilli() - val picker = MaterialDatePicker.Builder.datePicker() - .setTitleText("选择开始日期") - .setSelection(initialSelection) - .build() - picker.addOnPositiveButtonClickListener { selection -> - val date = Instant.ofEpochMilli(selection) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - startDate.setText(date.format(dateFormatter)) - } - picker.show((root.context as AppCompatActivity).supportFragmentManager, "md3_date_picker") - } - } - alert(titleResource = R.string.simulated_reading) { - customView { alertBinding.root } - okButton { - alertBinding.run { - val start = editStart.text.toString().toIntOrNull() ?: 0 - val num = editNum.text.toString().toIntOrNull() ?: book.totalChapterNum - val enabled = srEnabled.isChecked - - val date = try { - LocalDate.parse(startDate.text.toString(), dateFormatter) - } catch (e: Exception) { - LocalDate.now() - } - - book.setStartDate(date) - book.setDailyChapters(num) - book.setStartChapter(start) - book.setReadSimulating(enabled) - book.save() - ReadBook.clearTextChapter() - viewModel.initData(intent) - } - } - cancelButton() - } - } - - fun showCharsetConfig() { - alert(R.string.set_charset) { - val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { - editView.hint = "charset" - editView.setFilterValues(charsets) - editView.setText(ReadBook.book?.charset) - } - customView { alertBinding.root } - okButton { - alertBinding.editView.text?.toString()?.let { - ReadBook.setCharset(it) - } - } - cancelButton() - } - } - - fun showPageAnimConfig(success: () -> Unit) { - val items = arrayListOf() - items.add(getString(R.string.btn_default_s)) - items.add(getString(R.string.page_anim_cover)) - items.add(getString(R.string.page_anim_slide)) - items.add(getString(R.string.page_anim_simulation)) - items.add(getString(R.string.page_anim_scroll)) - items.add(getString(R.string.page_anim_none)) - selector(R.string.page_anim, items) { _, i -> - ReadBook.book?.setPageAnim(i - 1) - success() - } - } - - fun isPrevKey(keyCode: Int): Boolean { - if (keyCode == KeyEvent.KEYCODE_UNKNOWN) { - return false - } - val prevKeysStr = getPrefString(PreferKey.prevKeys) - return prevKeysStr?.split(",")?.contains(keyCode.toString()) ?: false - } - - fun isNextKey(keyCode: Int): Boolean { - if (keyCode == KeyEvent.KEYCODE_UNKNOWN) { - return false - } - val nextKeysStr = getPrefString(PreferKey.nextKeys) - return nextKeysStr?.split(",")?.contains(keyCode.toString()) ?: false - } -} diff --git a/app/src/main/java/io/legado/app/ui/book/read/ContentEditDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/ContentEditDialog.kt deleted file mode 100644 index 66817fe36..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/ContentEditDialog.kt +++ /dev/null @@ -1,176 +0,0 @@ -package io.legado.app.ui.book.read - -import android.app.Application -import android.content.DialogInterface -import android.os.Bundle -import android.view.View -import androidx.fragment.app.viewModels -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.lifecycleScope -import io.legado.app.R -import io.legado.app.base.BaseBottomSheetDialogFragment -import io.legado.app.base.BaseViewModel -import io.legado.app.data.appDb -import io.legado.app.data.entities.BookChapter -import io.legado.app.databinding.DialogContentEditBinding -import io.legado.app.databinding.DialogEditTextBinding -import io.legado.app.help.book.BookHelp -import io.legado.app.help.book.ContentProcessor -import io.legado.app.help.book.isLocal -import io.legado.app.help.book.isLocalTxt -import io.legado.app.help.coroutine.Coroutine -import io.legado.app.lib.dialogs.alert -import io.legado.app.model.ReadBook -import io.legado.app.model.webBook.WebBook -import io.legado.app.utils.gone -import io.legado.app.utils.sendToClip -import io.legado.app.utils.viewbindingdelegate.viewBinding -import io.legado.app.utils.visible -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * 内容编辑 - */ -class ContentEditDialog : BaseBottomSheetDialogFragment(R.layout.dialog_content_edit) { - - val binding by viewBinding(DialogContentEditBinding::bind) - val viewModel by viewModels() - - private val targetOffset: Int - get() = arguments?.getInt("start_position", -1).takeIf { it != -1 } - ?: ReadBook.durChapterPos - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - binding.toolBar.title = ReadBook.curTextChapter?.title - val book = ReadBook.book - if (book?.isLocalTxt == true) { - binding.cbSaveToSource.visible() - } - initMenu() - binding.toolBar.setOnClickListener { - lifecycleScope.launch { - val book1 = ReadBook.book ?: return@launch - val chapter = withContext(IO) { - appDb.bookChapterDao.getChapter(book1.bookUrl, ReadBook.durChapterIndex) - } ?: return@launch - editTitle(chapter) - } - } - viewModel.loadStateLiveData.observe(viewLifecycleOwner) { - if (it) { - binding.rlLoading.visible() - } else { - binding.rlLoading.gone() - } - } - viewModel.initContent { - binding.contentView.setText(it) - binding.contentView.post { - val layout = binding.contentView.layout ?: return@post - val targetY = binding.contentView.top + - layout.getLineTop( - layout.getLineForOffset(targetOffset) - ) - - binding.scrollView.smoothScrollTo(0, targetY) - //highlightSelectedTextTwice() - } - } - - } - - private fun initMenu() { - binding.toolBar.inflateMenu(R.menu.content_edit) - //binding.toolBar.menu.applyTint(requireContext()) - binding.toolBar.setOnMenuItemClickListener { - when (it.itemId) { - R.id.menu_save -> { - save() - dismiss() - } - R.id.menu_reset -> viewModel.initContent(true) { content -> - binding.contentView.setText(content) - ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false) - } - R.id.menu_copy_all -> requireContext() - .sendToClip("${binding.toolBar.title}\n${binding.contentView.text}") - } - return@setOnMenuItemClickListener true - } - } - - private fun editTitle(chapter: BookChapter) { - alert { - setTitle(R.string.edit) - val alertBinding = DialogEditTextBinding.inflate(layoutInflater) - alertBinding.editView.setText(chapter.title) - setCustomView(alertBinding.root) - okButton { - chapter.title = alertBinding.editView.text.toString() - lifecycleScope.launch { - withContext(IO) { - appDb.bookChapterDao.update(chapter) - } - binding.toolBar.title = chapter.getDisplayTitle() - ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false) - } - } - } - } - - override fun onCancel(dialog: DialogInterface) { - super.onCancel(dialog) - save() - } - - private fun save() { - val content = binding.contentView.text?.toString() ?: return - val saveToSource = binding.cbSaveToSource.isChecked - Coroutine.async { - val book = ReadBook.book ?: return@async - val chapter = appDb.bookChapterDao - .getChapter(book.bookUrl, ReadBook.durChapterIndex) - ?: return@async - BookHelp.saveText(book, chapter, content, saveToSource) - ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false) - } - } - - class ContentEditViewModel(application: Application) : BaseViewModel(application) { - val loadStateLiveData = MutableLiveData() - var content: String? = null - - fun initContent(reset: Boolean = false, success: (String) -> Unit) { - execute { - val book = ReadBook.book ?: return@execute null - val chapter = appDb.bookChapterDao - .getChapter(book.bookUrl, ReadBook.durChapterIndex) - ?: return@execute null - if (reset) { - content = null - BookHelp.delContent(book, chapter) - if (!book.isLocal) ReadBook.bookSource?.let { bookSource -> - WebBook.getContentAwait(bookSource, book, chapter) - } - } - return@execute content ?: let { - val contentProcessor = ContentProcessor.get(book.name, book.origin) - val content = BookHelp.getContent(book, chapter) ?: return@let null - contentProcessor.getContent(book, chapter, content, includeTitle = false) - .toString() - } - }.onStart { - loadStateLiveData.postValue(true) - }.onSuccess { - content = it - success.invoke(it ?: "") - }.onFinally { - loadStateLiveData.postValue(false) - } - } - - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/EffectiveReplacesDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/EffectiveReplacesDialog.kt deleted file mode 100644 index 5df512a0b..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/EffectiveReplacesDialog.kt +++ /dev/null @@ -1,116 +0,0 @@ -package io.legado.app.ui.book.read - -import android.content.Context -import android.content.DialogInterface -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.activityViewModels -import androidx.recyclerview.widget.LinearLayoutManager -import io.legado.app.R -import io.legado.app.base.BaseDialogFragment -import io.legado.app.base.adapter.ItemViewHolder -import io.legado.app.base.adapter.RecyclerAdapter -import io.legado.app.data.entities.ReplaceRule -import io.legado.app.databinding.DialogRecyclerViewBinding -import io.legado.app.databinding.Item1lineTextBinding -import io.legado.app.help.config.AppConfig -import io.legado.app.lib.dialogs.alert -//import io.legado.app.lib.theme.primaryColor -import io.legado.app.model.ReadBook -import io.legado.app.ui.replace.ReplaceEditRoute -import io.legado.app.ui.replace.ReplaceRuleActivity -import io.legado.app.utils.setLayout -import io.legado.app.utils.viewbindingdelegate.viewBinding - -/** - * 起效的替换规则 - */ -class EffectiveReplacesDialog : BaseDialogFragment(R.layout.dialog_recycler_view) { - - private val binding by viewBinding(DialogRecyclerViewBinding::bind) - private val viewModel by activityViewModels() - private val adapter by lazy { ReplaceAdapter(requireContext()) } - private val chineseConvert by lazy { ReplaceRule(0, "繁简转换") } - - private var isEdit = false - - private val editActivity = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == AppCompatActivity.RESULT_OK) { - isEdit = true - } - } - - override fun onStart() { - super.onStart() - setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT) - } - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - binding.run { - //toolBar.setBackgroundColor(primaryColor) - toolBar.setTitle(R.string.effective_replaces) - recyclerView.layoutManager = LinearLayoutManager(requireContext()) - recyclerView.adapter = adapter - } - val effectiveReplaceRules = ReadBook.curTextChapter?.effectiveReplaceRules ?: emptyList() - if (AppConfig.chineseConverterType > 0) { - adapter.setItems(effectiveReplaceRules + chineseConvert) - } else { - adapter.setItems(effectiveReplaceRules) - } - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - if (isEdit) { - viewModel.replaceRuleChanged() - } - } - - private fun showChineseConvertAlert() { - alert(titleResource = R.string.chinese_converter) { - items(resources.getStringArray(R.array.chinese_mode).toList()) { _, i -> - if (AppConfig.chineseConverterType != i) { - AppConfig.chineseConverterType = i - isEdit = true - } - } - } - } - - private inner class ReplaceAdapter(context: Context) : - RecyclerAdapter(context) { - - override fun getViewBinding(parent: ViewGroup): Item1lineTextBinding { - return Item1lineTextBinding.inflate(inflater, parent, false) - } - - override fun registerListener(holder: ItemViewHolder, binding: Item1lineTextBinding) { - binding.root.setOnClickListener { - getItem(holder.layoutPosition)?.let { item -> - if (item == chineseConvert) { - showChineseConvertAlert() - return@let - } - val editRoute = ReplaceEditRoute(id = item.id, pattern = item.pattern) - ReplaceRuleActivity.startIntent(requireContext(), editRoute) - } - } - } - - override fun convert( - holder: ItemViewHolder, - binding: Item1lineTextBinding, - item: ReplaceRule, - payloads: MutableList - ) { - binding.textView.text = item.name - } - - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/MangaMenu.kt b/app/src/main/java/io/legado/app/ui/book/read/MangaMenu.kt index 1cf616045..e4979ee98 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/MangaMenu.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/MangaMenu.kt @@ -20,13 +20,11 @@ import io.legado.app.lib.dialogs.alert import io.legado.app.model.ReadBook import io.legado.app.model.ReadManga import io.legado.app.ui.browser.WebViewActivity -import io.legado.app.utils.ConstraintModify import io.legado.app.utils.activity import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.gone import io.legado.app.utils.invisible import io.legado.app.utils.loadAnimation -import io.legado.app.utils.modifyBegin import io.legado.app.utils.openUrl import io.legado.app.utils.startActivity import io.legado.app.utils.visible @@ -125,27 +123,12 @@ class MangaMenu @JvmOverloads constructor( } else { titleBarAddition.gone() } - upBrightnessVwPos() /** * 确保视图不被导航栏遮挡 */ bottomView.applyNavigationBarPadding() } - private fun upBrightnessVwPos() { - if (AppConfig.brightnessVwPos) { - binding.root.modifyBegin() - .clear(R.id.ll_brightness, ConstraintModify.Anchor.LEFT) - .rightToRightOf(R.id.ll_brightness, R.id.vw_menu_root) - .commit() - } else { - binding.root.modifyBegin() - .clear(R.id.ll_brightness, ConstraintModify.Anchor.RIGHT) - .leftToLeftOf(R.id.ll_brightness, R.id.vw_menu_root) - .commit() - } - } - private fun initAnimation() { menuTopIn.setAnimationListener(menuInListener) menuTopOut.setAnimationListener(menuOutListener) diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt deleted file mode 100644 index 7a2c8390c..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt +++ /dev/null @@ -1,2037 +0,0 @@ -package io.legado.app.ui.book.read - -//import io.legado.app.lib.theme.accentColor -import android.annotation.SuppressLint -import android.content.Intent -import android.content.res.Configuration -import android.os.Bundle -import android.os.Looper -import android.view.Gravity -import android.view.HapticFeedbackConstants -import android.view.InputDevice -import android.view.KeyEvent -import android.view.Menu -import android.view.MenuItem -import android.view.MotionEvent -import android.view.View -import androidx.activity.addCallback -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.widget.PopupMenu -import androidx.core.view.HapticFeedbackConstantsCompat -import androidx.core.view.get -import androidx.core.view.size -import androidx.lifecycle.lifecycleScope -import com.jaredrummler.android.colorpicker.ColorPickerDialogListener -import com.script.rhino.runScriptWithContext -import io.legado.app.BuildConfig -import io.legado.app.R -import io.legado.app.constant.AppLog -import io.legado.app.constant.BookType -import io.legado.app.constant.EventBus -import io.legado.app.constant.PreferKey -import io.legado.app.constant.Status -import io.legado.app.data.appDb -import io.legado.app.data.entities.Book -import io.legado.app.data.entities.BookChapter -import io.legado.app.data.entities.BookProgress -import io.legado.app.data.entities.BookSource -import io.legado.app.exception.NoStackTraceException -import io.legado.app.help.IntentData -import io.legado.app.help.TTS -import io.legado.app.help.book.BookHelp -import io.legado.app.help.book.ContentProcessor -import io.legado.app.help.book.isAudio -import io.legado.app.help.book.isEpub -import io.legado.app.help.book.isLocal -import io.legado.app.help.book.isLocalTxt -import io.legado.app.help.book.isMobi -import io.legado.app.help.book.removeType -import io.legado.app.help.config.AppConfig -import io.legado.app.help.config.ReadBookConfig -import io.legado.app.help.config.ReadTipConfig -import io.legado.app.help.coroutine.Coroutine -import io.legado.app.help.source.getSourceType -import io.legado.app.help.storage.Backup -import io.legado.app.lib.dialogs.SelectItem -import io.legado.app.lib.dialogs.alert -import io.legado.app.lib.dialogs.selector -import io.legado.app.model.ReadAloud -import io.legado.app.model.ReadBook -import io.legado.app.model.SourceCallBack -import io.legado.app.model.analyzeRule.AnalyzeRule -import io.legado.app.model.translation.TranslationChapterStatus -import io.legado.app.model.translation.TranslationManager -import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter -import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext -import io.legado.app.model.analyzeRule.AnalyzeUrl.Companion.paramPattern -import io.legado.app.model.localBook.EpubFile -import io.legado.app.model.localBook.MobiFile -import io.legado.app.receiver.NetworkChangedListener -import io.legado.app.receiver.TimeBatteryReceiver -import io.legado.app.service.BaseReadAloudService -import io.legado.app.ui.about.AppLogDialog -import io.legado.app.ui.book.bookmark.BookmarkDialog -import io.legado.app.ui.book.changesource.ChangeBookSourceDialog -import io.legado.app.ui.book.changesource.ChangeChapterSourceDialog -import io.legado.app.ui.book.info.BookInfoActivity -import io.legado.app.ui.book.read.config.AutoReadDialog -import io.legado.app.ui.book.read.config.BgTextConfigDialog.Companion.BG_COLOR -import io.legado.app.ui.book.read.config.FontConfigDialog -import io.legado.app.ui.book.read.config.FontConfigDialog.Companion.S_COLOR -import io.legado.app.ui.book.read.config.FontConfigDialog.Companion.TEXT_ACCENT_COLOR -import io.legado.app.ui.book.read.config.FontConfigDialog.Companion.TEXT_COLOR -import io.legado.app.ui.book.read.config.FontSelectDialog -import io.legado.app.ui.book.read.config.ReadAloudDialog -import io.legado.app.ui.book.read.config.ReadStyleDialog -import io.legado.app.ui.book.read.config.RegexColorConfigDialog -import io.legado.app.ui.book.read.config.RegexColorConfigDialog.Companion.REGEX_RULE_COLOR -import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.A_COLOR -import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.B_COLOR -import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TIP_DIVIDER_COLOR -import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TIP_FOOTER_COLOR -import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TIP_HEADER_COLOR -import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TITLE_COLOR -import io.legado.app.ui.book.read.config.ToolButtonConfigDialog -import io.legado.app.ui.book.read.config.UnderlineConfigDialog.Companion.U_COLOR -import io.legado.app.ui.book.read.page.ContentTextView -import io.legado.app.ui.book.read.page.ReadView -import io.legado.app.ui.book.read.page.entities.PageDirection -import io.legado.app.ui.book.read.page.entities.TextPage -import io.legado.app.ui.book.read.page.provider.ChapterProvider -import io.legado.app.ui.book.read.page.provider.LayoutProgressListener -import io.legado.app.ui.book.read.page.provider.TextChapterLayout -import io.legado.app.ui.book.searchContent.SearchContentActivity -import io.legado.app.ui.book.searchContent.SearchResult -import io.legado.app.ui.book.source.edit.BookSourceEditActivity -import io.legado.app.ui.book.toc.TocActivityResult -import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity -import io.legado.app.ui.browser.WebViewActivity -import io.legado.app.ui.dict.DictDialog -import io.legado.app.ui.login.SourceLoginActivity -import io.legado.app.ui.login.SourceLoginJsExtensions -import io.legado.app.ui.replace.ReplaceEditRoute -import io.legado.app.ui.replace.ReplaceRuleActivity -import io.legado.app.ui.widget.PopupAction -import io.legado.app.ui.widget.dialog.PhotoDialog -import io.legado.app.utils.Debounce -import io.legado.app.utils.GSON -import io.legado.app.utils.LogUtils -import io.legado.app.utils.NetworkUtils -import io.legado.app.utils.StartActivityContract -import io.legado.app.utils.applyOpenTint -import io.legado.app.utils.buildMainHandler -import io.legado.app.utils.dismissDialogFragment -import io.legado.app.utils.fromJsonObject -import io.legado.app.utils.getPrefBoolean -import io.legado.app.utils.getPrefString -import io.legado.app.utils.hexString -import io.legado.app.utils.iconItemOnLongClick -import io.legado.app.utils.invisible -import io.legado.app.utils.isAbsUrl -import io.legado.app.utils.isTrue -import io.legado.app.utils.navigationBarGravity -import io.legado.app.utils.observeEvent -import io.legado.app.utils.observeEventSticky -import io.legado.app.utils.postEvent -import io.legado.app.utils.showDialogFragment -import io.legado.app.utils.showHelp -import io.legado.app.utils.startActivity -import io.legado.app.utils.startActivityForBook -import io.legado.app.utils.sysScreenOffTime -import io.legado.app.utils.throttle -import io.legado.app.utils.toastOnUi -import io.legado.app.utils.longToastOnUi -import io.legado.app.utils.visible -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.Dispatchers.Main -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * 阅读界面 - */ -class ReadBookActivity : BaseReadBookActivity(), - View.OnTouchListener, - ReadView.CallBack, - TextActionMenu.CallBack, - ContentTextView.CallBack, - PopupMenu.OnMenuItemClickListener, - ReadMenu.CallBack, - SearchMenu.CallBack, - ReadAloudDialog.CallBack, - ChangeBookSourceDialog.CallBack, - ChangeChapterSourceDialog.CallBack, - ReadBook.CallBack, - AutoReadDialog.CallBack, - ToolButtonConfigDialog.CallBack, - ColorPickerDialogListener, - FontConfigDialog.CallBack, - FontSelectDialog.CallBack, - LayoutProgressListener { - - private val tocActivity = - registerForActivityResult(TocActivityResult()) { - it?.let { - viewModel.openChapter(it.first, it.second) - } - } - private val sourceEditActivity = - registerForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) { - if (it.resultCode == RESULT_OK) { - viewModel.upBookSource { - upMenuView() - } - } - } - private val replaceActivity = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - viewModel.replaceRuleChanged() - } - } - - private val txtTocRuleLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - result.data?.getStringExtra("tocRegex")?.let { rule -> - ReadBook.book?.let { - it.tocUrl = rule - loadChapterList(it) - } - } - } - } - - private val searchContentActivity = - registerForActivityResult(StartActivityContract(SearchContentActivity::class.java)) { - val data = it.data ?: return@registerForActivityResult - val key = data.getLongExtra("key", System.currentTimeMillis()) - val index = data.getIntExtra("index", 0) - val searchResult = IntentData.get("searchResult$key") - val searchResultList = IntentData.get>("searchResultList$key") - if (searchResult != null && searchResultList != null) { - viewModel.searchContentQuery = searchResult.query - binding.searchMenu.upSearchResultList(searchResultList) - isShowingSearchResult = true - viewModel.searchResultIndex = index - binding.searchMenu.updateSearchResultIndex(index) - binding.searchMenu.selectedSearchResult?.let { currentResult -> - ReadBook.saveCurrentBookProgress() //退出全文搜索恢复此时进度 - skipToSearch(currentResult) - showActionMenu() - } - } - } - private val bookInfoActivity = - registerForActivityResult(StartActivityContract(BookInfoActivity::class.java)) { - if (it.resultCode == RESULT_OK) { - setResult(RESULT_DELETED) - finish() - } else { - ReadBook.loadOrUpContent() - } - } - private var menu: Menu? = null - private var backupJob: Job? = null - private var tts: TTS? = null - val textActionMenu: TextActionMenu by lazy { - TextActionMenu(this, this) - } - private val popupAction: PopupAction by lazy { - PopupAction(this) - } - - // 当前使用的字体路径(给 FontDialog 用) - override val curFontPath: String - get() = ReadBookConfig.textFont - - // 当选择了字体时被调用 - override fun selectFont(path: String) { - // path 为空表示恢复系统默认字体 - ReadBookConfig.textFont = path - // 通知阅读界面刷新字体 - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5, 2)) - } - - override val isInitFinish: Boolean get() = viewModel.isInitFinish - override val isScroll: Boolean get() = binding.readView.isScroll - private val isAutoPage get() = binding.readView.isAutoPage - override var isShowingSearchResult = false - override var isSelectingSearchResult = false - set(value) { - field = value && isShowingSearchResult - } - private val timeBatteryReceiver = TimeBatteryReceiver() - private var screenTimeOut: Long = 0 - private var loadStates: Boolean = false - override val pageFactory get() = binding.readView.pageFactory - override val pageDelegate get() = binding.readView.pageDelegate - override val headerHeight: Int get() = binding.readView.curPage.headerHeight - override val imgBgPaddingStart: Int get() = binding.readView.curPage.imgBgPaddingStart - private val nextPageDebounce by lazy { Debounce { keyPage(PageDirection.NEXT) } } - private val prevPageDebounce by lazy { Debounce { keyPage(PageDirection.PREV) } } - private var bookChanged = false - private var pageChanged = false - private val handler by lazy { buildMainHandler() } - private val screenOffRunnable by lazy { Runnable { keepScreenOn(false) } } - private val eyeProtectionScheduler by lazy { - EyeProtectionRefreshScheduler(handler) { binding.eyeProtectionOverlay.refresh() } - } - private val executor = ReadBook.executor - private val upSeekBarThrottle = throttle(200) { - runOnUiThread { - upSeekBarProgress() - binding.readMenu.upSeekBar() - } - } - - //恢复跳转前进度对话框的交互结果 - private var confirmRestoreProcess: Boolean? = null - private val networkChangedListener by lazy { - NetworkChangedListener(this) - } - private var justInitData: Boolean = false - private var syncDialog: AlertDialog? = null - - @SuppressLint("ClickableViewAccessibility") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - upScreenTimeOut() - ReadBook.register(this) - binding.cursorLeft.setOnTouchListener(this) - binding.cursorRight.setOnTouchListener(this) - binding.eyeProtectionOverlay.refresh() - - onBackPressedDispatcher.addCallback(this) { - if (isShowingSearchResult) { - exitSearchMenu() - restoreLastBookProcess() - return@addCallback - } - //拦截返回供恢复阅读进度 - if (ReadBook.lastBookProgress != null && confirmRestoreProcess != false) { - restoreLastBookProcess() - return@addCallback - } - if (BaseReadAloudService.isPlay()) { - ReadAloud.pause(this@ReadBookActivity) - toastOnUi(R.string.read_aloud_pause) - return@addCallback - } - if (isAutoPage) { - autoPageStop() - return@addCallback - } - if (getPrefBoolean("disableReturnKey") && !menuLayoutIsVisible) { - return@addCallback - } - if (savedInstanceState != null || !ReadBook.inBookshelf) { - ReadBook.commitReadSession() - supportFinishAfterTransition() - } else { - ReadBook.commitReadSession() - callBackBookEnd() - supportFinishAfterTransition() - } - //TODO: 有关测量相关问题 - //孩子们,我的水平不够,只能这样修复测量问题了 - } - } - - override fun onPostCreate(savedInstanceState: Bundle?) { - super.onPostCreate(savedInstanceState) - viewModel.initReadBookConfig(intent) - Looper.myQueue().addIdleHandler { - viewModel.initData(intent) - false - } - justInitData = true - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - viewModel.initData(intent) - } - - override fun onWindowFocusChanged(hasFocus: Boolean) { - super.onWindowFocusChanged(hasFocus) - upSystemUiVisibility() - if (hasFocus) { - binding.readMenu.upBrightnessState() - } else if (!menuLayoutIsVisible) { - ReadBook.cancelPreDownloadTask() - } - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - //upSystemUiVisibility() - binding.readView.upStyle() - recreate() - } - - - override fun onTopResumedActivityChanged(isTopResumedActivity: Boolean) { - if (!isTopResumedActivity) { - ReadBook.cancelPreDownloadTask() - } - } - - @SuppressLint("UnspecifiedRegisterReceiverFlag") - override fun onResume() { - super.onResume() - ReadBook.readStartTime = System.currentTimeMillis() - ReadBook.initReadTime() - ReadBook.startAutoSaveSession() - if (bookChanged) { - bookChanged = false - ReadBook.callBack = this - viewModel.initData(intent) - justInitData = true - } else { - //web端阅读时,app处于阅读界面,本地记录会覆盖web保存的进度,在此处恢复 - ReadBook.webBookProgress?.let { - ReadBook.setProgress(it) - ReadBook.webBookProgress = null - } - } - upSystemUiVisibility() - registerReceiver(timeBatteryReceiver, timeBatteryReceiver.filter) - binding.readView.upTime() - screenOffTimerStart() - binding.eyeProtectionOverlay.refresh() - eyeProtectionScheduler.schedule() - // 网络监听,当从无网切换到网络环境时同步进度(注意注册的同时就会收到监听,因此界面激活时无需重复执行同步操作) - networkChangedListener.register() - networkChangedListener.onNetworkChanged = { - // 当网络是可用状态且无需初始化时同步进度(初始化中已有同步进度逻辑) - if (AppConfig.syncBookProgressPlus && NetworkUtils.isAvailable() && !justInitData) { - ReadBook.syncProgress({ progress -> sureNewProgress(progress) }) - } - } - } - - override fun onPause() { - super.onPause() - autoPageStop() - eyeProtectionScheduler.cancel() - backupJob?.cancel() - ReadBook.saveRead() - ReadBook.stopAutoSaveSession() - ReadBook.commitReadSession() - ReadBook.cancelPreDownloadTask() - unregisterReceiver(timeBatteryReceiver) - upSystemUiVisibility() - if (!BuildConfig.DEBUG) { - if (AppConfig.syncBookProgressPlus) { - ReadBook.syncProgress() - } else { - ReadBook.uploadProgress() - } - Backup.autoBack(this) - } - justInitData = false - networkChangedListener.unRegister() - } - - override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.book_read, menu) - menu.iconItemOnLongClick(R.id.menu_change_source) { - PopupMenu(this, it).apply { - inflate(R.menu.book_read_change_source) - this.menu.applyOpenTint(this@ReadBookActivity) - setOnMenuItemClickListener(this@ReadBookActivity) - }.show() - } - menu.iconItemOnLongClick(R.id.menu_refresh) { - PopupMenu(this, it).apply { - inflate(R.menu.book_read_refresh) - this.menu.applyOpenTint(this@ReadBookActivity) - setOnMenuItemClickListener(this@ReadBookActivity) - }.show() - } - binding.readMenu.refreshMenuColorFilter() - return super.onCompatCreateOptionsMenu(menu) - } - - override fun onPrepareOptionsMenu(menu: Menu): Boolean { - this.menu = menu - upMenu() - return super.onPrepareOptionsMenu(menu) - } - - override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { - menu.findItem(R.id.menu_same_title_removed)?.isChecked = - ReadBook.curTextChapter?.sameTitleRemoved == true - return super.onMenuOpened(featureId, menu) - } - - /** - * 更新菜单 - */ - private fun upMenu() { - val menu = menu ?: return - val book = ReadBook.book ?: return - val onLine = !book.isLocal - for (i in 0 until menu.size) { - val item = menu[i] - when (item.groupId) { - R.id.menu_group_on_line -> item.isVisible = onLine - R.id.menu_group_local -> item.isVisible = !onLine - R.id.menu_group_text -> item.isVisible = book.isLocalTxt - R.id.menu_group_epub -> item.isVisible = book.isEpub - else -> when (item.itemId) { - R.id.menu_enable_replace -> item.isChecked = book.getUseReplaceRule() - R.id.menu_re_segment -> item.isChecked = book.getReSegment() -// R.id.menu_enable_review -> { -// item.isVisible = BuildConfig.DEBUG -// item.isChecked = AppConfig.enableReview -// } - - R.id.menu_reverse_content -> item.isVisible = onLine - R.id.menu_del_ruby_tag -> item.isChecked = book.getDelTag(Book.rubyTag) - R.id.menu_del_h_tag -> item.isChecked = book.getDelTag(Book.hTag) - } - } - } - lifecycleScope.launch { - menu.findItem(R.id.menu_get_progress)?.isVisible = withContext(IO) { - viewModel.isReadingProgressSyncConfigured() - } - menu.findItem(R.id.menu_cover_progress)?.isVisible = withContext(IO) { - viewModel.isReadingProgressSyncConfigured() - } - } - } - - private fun defaultChangeSource() { - if (AppConfig.defaultSourceChangeAll) - { - binding.readMenu.runMenuOut() - ReadBook.book?.let { - showDialogFragment(ChangeBookSourceDialog(it.name, it.author)) - } - } else { - lifecycleScope.launch { - val book = ReadBook.book ?: return@launch - val chapter = - appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) - ?: return@launch - binding.readMenu.runMenuOut() - showDialogFragment( - ChangeChapterSourceDialog(book.name, book.author, chapter.index, chapter.title) - ) - } - } - } - - /** - * 菜单 - */ - override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.menu_change_source -> { defaultChangeSource() } - - R.id.menu_book_change_source -> { - binding.readMenu.runMenuOut() - ReadBook.book?.let { - showDialogFragment(ChangeBookSourceDialog(it.name, it.author)) - } - } - - R.id.menu_chapter_change_source -> lifecycleScope.launch { - val book = ReadBook.book ?: return@launch - val chapter = - appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) - ?: return@launch - binding.readMenu.runMenuOut() - showDialogFragment( - ChangeChapterSourceDialog(book.name, book.author, chapter.index, chapter.title) - ) - } - - R.id.menu_refresh, - R.id.menu_refresh_dur -> { - if (ReadBook.bookSource == null) { - upContent() - } else { - ReadBook.book?.let { - ReadBook.curTextChapter = null - binding.readView.upContent() - viewModel.refreshContentDur(it) - } - } - } - - R.id.menu_refresh_after -> { - if (ReadBook.bookSource == null) { - upContent() - } else { - ReadBook.book?.let { - ReadBook.clearTextChapter() - binding.readView.upContent() - viewModel.refreshContentAfter(it) - } - } - } - - R.id.menu_refresh_all -> { - if (ReadBook.bookSource == null) { - upContent() - } else { - ReadBook.book?.let { - refreshContentAll(it) - } - } - } - - R.id.menu_setting_replace -> openReplaceRule() - R.id.menu_download -> showDownloadDialog() - R.id.menu_add_bookmark -> addBookmark() - R.id.menu_simulated_reading -> showSimulatedReading() - R.id.menu_edit_content -> { - binding.readMenu.runMenuOut() - showDialogFragment(ContentEditDialog()) - } - R.id.menu_update_toc -> ReadBook.book?.let { - if (it.isEpub) { - BookHelp.clearCache(it) - EpubFile.clear() - } - if (it.isMobi) { - MobiFile.clear() - } - loadChapterList(it) - } - R.id.menu_enable_replace -> { - changeReplaceRuleState() - } - R.id.menu_re_segment -> ReadBook.book?.let { - it.setReSegment(!it.getReSegment()) - item.isChecked = it.getReSegment() - ReadBook.loadContent(false) - } - - R.id.menu_tool_button -> { - binding.readMenu.runMenuOut() - showDialogFragment(ToolButtonConfigDialog()) - } - -// R.id.menu_enable_review -> { -// AppConfig.enableReview = !AppConfig.enableReview -// item.isChecked = AppConfig.enableReview -// ReadBook.loadContent(false) -// } - - R.id.menu_del_ruby_tag -> ReadBook.book?.let { - item.isChecked = !item.isChecked - if (item.isChecked) { - it.addDelTag(Book.rubyTag) - } else { - it.removeDelTag(Book.rubyTag) - } - refreshContentAll(it) - } - - R.id.menu_del_h_tag -> ReadBook.book?.let { - item.isChecked = !item.isChecked - if (item.isChecked) { - it.addDelTag(Book.hTag) - } else { - it.removeDelTag(Book.hTag) - } - refreshContentAll(it) - } - - R.id.menu_page_anim -> showPageAnimConfig { - binding.readView.upPageAnim() - ReadBook.loadContent(false) - } - - R.id.menu_log -> showDialogFragment() - R.id.menu_toc_regex -> { - val intent = Intent(this, TxtTocRuleActivity::class.java) - intent.putExtra("tocRegex", ReadBook.book?.tocUrl) - txtTocRuleLauncher.launch(intent) - } - - R.id.menu_reverse_content -> ReadBook.book?.let { - viewModel.reverseContent(it) - } - - R.id.menu_set_charset -> showCharsetConfig() - R.id.menu_image_style -> { - val imgStyles = - arrayListOf( - Book.imgStyleDefault, Book.imgStyleFull, Book.imgStyleText, - Book.imgStyleSingle - ) - selector( - R.string.image_style, - imgStyles - ) { _, index -> - val imageStyle = imgStyles[index] - ReadBook.book?.setImageStyle(imageStyle) - if (imageStyle == Book.imgStyleSingle) { - ReadBook.book?.setPageAnim(0) // 切换图片样式single后,自动切换为覆盖 - binding.readView.upPageAnim() - } - ReadBook.loadContent(false) - } - } - - R.id.menu_get_progress -> ReadBook.book?.let { - viewModel.syncBookProgress(it) { progress -> - sureSyncProgress(progress) - } - } - - R.id.menu_cover_progress -> ReadBook.book?.let { - ReadBook.uploadProgress(true) { toastOnUi(R.string.upload_book_success) } - } - - R.id.menu_same_title_removed -> { - ReadBook.book?.let { - val contentProcessor = ContentProcessor.get(it) - val textChapter = ReadBook.curTextChapter - if (textChapter != null - && !textChapter.sameTitleRemoved - && !contentProcessor.removeSameTitleCache.contains( - textChapter.chapter.getFileName("nr") - ) - ) { - toastOnUi("未找到可移除的重复标题") - } - } - viewModel.reverseRemoveSameTitle() - } - - R.id.menu_effective_replaces -> showDialogFragment() - - R.id.menu_help -> showHelp() - } - return super.onCompatOptionsItemSelected(item) - } - - private fun refreshContentAll(book: Book) { - ReadBook.clearTextChapter() - binding.readView.upContent() - viewModel.refreshContentAll(book) - } - - override fun onTranslationClick() { - val book = ReadBook.book ?: return - book.setTranslationMode(!book.getTranslationMode()) - book.save() - binding.readMenu.updateTranslationButton(book.getTranslationMode()) - ReadBook.loadContent(false) - } - - override fun onTranslationLongClick() { - val book = ReadBook.book ?: return - val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) ?: return - - if (TranslationManager.hasTranslatedCache(book, chapter)) { - alert(title = getString(R.string.retranslate_chapter), message = getString(R.string.retranslate_confirm)) { - positiveButton(getString(R.string.ok)) { retranslateCurrentChapter() } - negativeButton(getString(R.string.cancel)) - }.show() - } - } - - fun retranslateCurrentChapter() { - val book = ReadBook.book ?: return - lifecycleScope.launch { - TranslationManager.deleteTranslationCache(book, appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) ?: return@launch) - book.setTranslationMode(true) - book.save() - ReadBook.loadContent(false) - } - } - - override fun onMenuItemClick(item: MenuItem): Boolean { - return onCompatOptionsItemSelected(item) - } - - /** - * 按键拦截,显示菜单 - */ - override fun dispatchKeyEvent(event: KeyEvent): Boolean { - val keyCode = event.keyCode - val action = event.action - val isDown = action == 0 - - if (keyCode == KeyEvent.KEYCODE_MENU) { - if (isDown && !binding.readMenu.canShowMenu) { - binding.readMenu.runMenuIn() - return true - } - if (!isDown && !binding.readMenu.canShowMenu) { - binding.readMenu.canShowMenu = true - return true - } - } - return super.dispatchKeyEvent(event) - } - - /** - * 鼠标滚轮事件 - */ - override fun onGenericMotionEvent(event: MotionEvent): Boolean { - if (0 != (event.source and InputDevice.SOURCE_CLASS_POINTER)) { - if (event.action == MotionEvent.ACTION_SCROLL) { - val axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL) - LogUtils.d("onGenericMotionEvent", "axisValue = $axisValue") - // 获得垂直坐标上的滚动方向 - if (axisValue < 0.0f) { // 滚轮向下滚 - mouseWheelPage(PageDirection.NEXT) - } else { // 滚轮向上滚 - mouseWheelPage(PageDirection.PREV) - } - return true - } - } - // 手柄摇杆控制翻页 - if (0 != (event.source and InputDevice.SOURCE_CLASS_JOYSTICK)) { - if (event.action == MotionEvent.ACTION_MOVE) { - // 左摇杆上下移动控制翻页 - val yAxis = event.getAxisValue(MotionEvent.AXIS_Y) - if (Math.abs(yAxis) > 0.5f) { // 死区设置 - if (yAxis > 0) { // 摇杆向下 - handleKeyPage(PageDirection.NEXT, false) - } else { // 摇杆向上 - handleKeyPage(PageDirection.PREV, false) - } - return true - } - } - } - return super.onGenericMotionEvent(event) - } - - /** - * 按键事件 - */ - override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { - if (menuLayoutIsVisible) { - return super.onKeyDown(keyCode, event) - } - val longPress = event.repeatCount > 0 - when { - isPrevKey(keyCode) -> { - handleKeyPage(PageDirection.PREV, longPress) - return true - } - - isNextKey(keyCode) -> { - handleKeyPage(PageDirection.NEXT, longPress) - return true - } - } - when (keyCode) { - KeyEvent.KEYCODE_VOLUME_UP -> if (volumeKeyPage(PageDirection.PREV, longPress)) { - return true - } - - KeyEvent.KEYCODE_VOLUME_DOWN -> if (volumeKeyPage(PageDirection.NEXT, longPress)) { - return true - } - - KeyEvent.KEYCODE_PAGE_UP -> { - handleKeyPage(PageDirection.PREV, longPress) - return true - } - - KeyEvent.KEYCODE_PAGE_DOWN -> { - handleKeyPage(PageDirection.NEXT, longPress) - return true - } - - KeyEvent.KEYCODE_SPACE -> { - handleKeyPage(PageDirection.NEXT, longPress) - return true - } - // 手柄方向键控制翻页 - KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_LEFT -> { - handleKeyPage(PageDirection.PREV, longPress) - return true - } - - KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT -> { - handleKeyPage(PageDirection.NEXT, longPress) - return true - } - } - - return super.onKeyDown(keyCode, event) - } - - /** - * 松开按键事件 - */ - override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { - when (keyCode) { - KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN -> { - if (volumeKeyPage(PageDirection.NONE, false)) { - return true - } - } - - } - return super.onKeyUp(keyCode, event) - } - - /** - * view触摸,文字选择 - */ - @SuppressLint("ClickableViewAccessibility") - override fun onTouch(v: View, event: MotionEvent): Boolean = binding.run { - if (!binding.readView.isTextSelected) { - return false - } - when (event.action) { - MotionEvent.ACTION_DOWN -> textActionMenu.dismiss() - MotionEvent.ACTION_MOVE -> { - when (v.id) { - R.id.cursor_left -> if (!readView.curPage.getReverseStartCursor()) { - readView.curPage.selectStartMove( - event.rawX + cursorLeft.width, - event.rawY - cursorLeft.height - ) - } else { - readView.curPage.selectEndMove( - event.rawX - cursorRight.width, - event.rawY - cursorRight.height - ) - } - - R.id.cursor_right -> if (readView.curPage.getReverseEndCursor()) { - readView.curPage.selectStartMove( - event.rawX + cursorLeft.width, - event.rawY - cursorLeft.height - ) - } else { - readView.curPage.selectEndMove( - event.rawX - cursorRight.width, - event.rawY - cursorRight.height - ) - } - } - } - - MotionEvent.ACTION_UP -> { - readView.curPage.resetReverseCursor() - showTextActionMenu() - } - } - return true - } - - /** - * 更新文字选择开始位置 - */ - override fun upSelectedStart(x: Float, y: Float, top: Float): Unit = binding.run { - cursorLeft.x = x - cursorLeft.width - cursorLeft.y = y - cursorLeft.visible(true) - textMenuPosition.x = x - textMenuPosition.y = top - - if (AppConfig.selectVibrator) - root.performHapticFeedback(HapticFeedbackConstantsCompat.TEXT_HANDLE_MOVE) - } - - /** - * 更新文字选择结束位置 - */ - override fun upSelectedEnd(x: Float, y: Float): Unit = binding.run { - cursorRight.x = x - cursorRight.y = y - cursorRight.visible(true) - if (AppConfig.selectVibrator) - root.performHapticFeedback(HapticFeedbackConstantsCompat.TEXT_HANDLE_MOVE) - } - - /** - * 取消文字选择 - */ - override fun onCancelSelect() = binding.run { - cursorLeft.invisible() - cursorRight.invisible() - textActionMenu.dismiss() - } - - override fun onLongScreenshotTouchEvent(event: MotionEvent): Boolean { - return binding.readView.onTouchEvent(event) - } - - /** - * 显示文本操作菜单 - */ - override fun showTextActionMenu() { - val navigationBarHeight = - if (!ReadBookConfig.hideNavigationBar && navigationBarGravity == Gravity.BOTTOM) - binding.navigationBar.height else 0 - textActionMenu.show( - binding.textMenuPosition, - binding.root.height + navigationBarHeight, - binding.textMenuPosition.x.toInt(), - binding.textMenuPosition.y.toInt(), - binding.cursorLeft.y.toInt() + binding.cursorLeft.height, - binding.cursorRight.x.toInt(), - binding.cursorRight.y.toInt() + binding.cursorRight.height - ) - } - - /** - * 当前选择的文本 - */ - override val selectedText: String get() = binding.readView.getSelectText() - - /** - * 文本选择菜单操作 - */ - override fun onMenuItemSelected(itemId: Int): Boolean { - when (itemId) { - R.id.menu_aloud -> when (AppConfig.contentSelectSpeakMod) { - 1 -> lifecycleScope.launch { - binding.readView.aloudStartSelect() - } - - else -> speak(binding.readView.getSelectText()) - } - - R.id.menu_bookmark -> binding.readView.curPage.let { - val bookmark = it.createBookmark() - if (bookmark == null) { - toastOnUi(R.string.create_bookmark_error) - } else { - showDialogFragment(BookmarkDialog(bookmark)) - } - return true - } - - R.id.menu_edit -> { - val startPos = ReadBook.durChapterPos - showDialogFragment { - putInt("start_position", startPos) - putString("selected_text", binding.readView.getSelectText()) - } - return true - } - - R.id.menu_replace -> { - val scopes = arrayListOf() - ReadBook.book?.name?.let { scopes.add(it) } - ReadBook.bookSource?.bookSourceUrl?.let { scopes.add(it) } - - val text = selectedText.lineSequence().map { it.trim() }.joinToString("\n") - - val editRoute = ReplaceEditRoute( - id = -1, - pattern = text, - scope = scopes.joinToString(";"), - isScopeTitle = false, - isScopeContent = true - ) - - replaceActivity.launch( - ReplaceRuleActivity.startIntent( - context = this, - editRoute = editRoute - ) - ) - return true - } - - R.id.menu_search_content -> { - viewModel.searchContentQuery = selectedText - openSearchActivity(selectedText) - return true - } - - R.id.menu_dict -> { - showDialogFragment(DictDialog(selectedText)) - return true - } - } - return false - } - - private fun onEditTextAction(selectedText: String, startPos: Int) { - val bundle = Bundle().apply { - putString("selected_text", selectedText) - putInt("start_position", startPos) - } - - val dialog = ContentEditDialog().apply { - arguments = bundle - } - - dialog.show(supportFragmentManager, "ContentEditDialog") - } - - /** - * 文本选择菜单操作完成 - */ - override fun onMenuActionFinally() = binding.run { - textActionMenu.dismiss() - readView.cancelSelect() - } - - private fun speak(text: String) { - if (tts == null) { - tts = TTS() - } - tts?.speak(text) - } - - /** - * 鼠标滚轮翻页 - */ - private fun mouseWheelPage(direction: PageDirection) { - if (menuLayoutIsVisible || !AppConfig.mouseWheelPage) { - return - } - keyPageDebounce(direction, mouseWheel = true, longPress = false) - } - - /** - * 音量键翻页 - */ - private fun volumeKeyPage(direction: PageDirection, longPress: Boolean): Boolean { - if (!AppConfig.volumeKeyPage) { - return false - } - if (!AppConfig.volumeKeyPageOnPlay && BaseReadAloudService.isPlay()) { - return false - } - handleKeyPage(direction, longPress) - return true - } - - private fun handleKeyPage(direction: PageDirection, longPress: Boolean) { - if (AppConfig.keyPageOnLongPress || direction == PageDirection.NONE) { - keyPage(direction) - } else { - keyPageDebounce(direction, longPress = longPress) - } - } - - private fun keyPageDebounce( - direction: PageDirection, - mouseWheel: Boolean = false, - longPress: Boolean - ) { - if (longPress) { - return - } - nextPageDebounce.apply { - wait = if (mouseWheel) 200L else 600L - leading = !mouseWheel - trailing = mouseWheel - } - prevPageDebounce.apply { - wait = if (mouseWheel) 200L else 600L - leading = !mouseWheel - trailing = mouseWheel - } - when (direction) { - PageDirection.NEXT -> nextPageDebounce.invoke() - PageDirection.PREV -> prevPageDebounce.invoke() - else -> {} - } - } - - private fun keyPage(direction: PageDirection) { - binding.readView.cancelSelect() - binding.readView.pageDelegate?.isCancel = false - binding.readView.pageDelegate?.keyTurnPage(direction) - } - - override fun upMenuView() { - handler.post { - upMenu() - binding.readMenu.upBookView() - } - } - - override fun loadChapterList(book: Book) { - ReadBook.upMsg(getString(R.string.toc_updateing)) - viewModel.loadChapterList(book) - } - - /** - * 内容加载完成 - */ - override fun contentLoadFinish() { - if (intent.getBooleanExtra("readAloud", false)) { - intent.removeExtra("readAloud") - ReadBook.readAloud() - } - loadStates = true - } - - /** - * 更新内容 - */ - override fun upContent( - relativePosition: Int, - resetPageOffset: Boolean, - success: (() -> Unit)? - ) { - lifecycleScope.launch { - binding.readView.upContent(relativePosition, resetPageOffset) - if (relativePosition == 0) { - upSeekBarProgress() - } - loadStates = false - success?.invoke() - } - } - - override suspend fun upContentAwait( - relativePosition: Int, - resetPageOffset: Boolean, - success: (() -> Unit)? - ) = withContext(Main.immediate) { - binding.readView.upContent(relativePosition, resetPageOffset) - if (relativePosition == 0) { - upSeekBarProgress() - } - loadStates = false - } - - override fun upPageAnim(upRecorder: Boolean) { - lifecycleScope.launch { - binding.readView.upPageAnim(upRecorder) - } - } - - override fun notifyBookChanged() { - bookChanged = true - if (!ReadBook.inBookshelf) { - viewModel.removeFromBookshelf { super.finish() } - } - } - - override fun cancelSelect() { - runOnUiThread { - binding.readView.cancelSelect() - } - } - - /** - * 页面改变 - */ - override fun pageChanged() { - pageChanged = true - binding.readView.onPageChange() - handler.post { - upSeekBarProgress() - } - executor.execute { - startBackupJob() - } - } - - /** - * 更新进度条位置 - */ - private fun upSeekBarProgress() { - val progress = when (AppConfig.progressBarBehavior) { - "page" -> ReadBook.durPageIndex - else /* chapter */ -> ReadBook.durChapterIndex - } - if (progress >= 0) { - binding.readMenu.setSeekPage(progress) - } - } - - /** - * 显示菜单 - */ - override fun showMenuBar() { - binding.readMenu.runMenuIn() - } - - override val oldBook: Book? - get() = ReadBook.book - - override fun changeTo(source: BookSource, book: Book, toc: List) { - if (!book.isAudio) { - viewModel.changeTo(book, toc) - } else { - ReadAloud.stop(this) - lifecycleScope.launch { - withContext(IO) { - ReadBook.book?.migrateTo(book, toc) - book.removeType(BookType.updateError) - ReadBook.book?.delete() - appDb.bookDao.insert(book) - } - startActivityForBook(book) - finish() - } - } - } - - override fun replaceContent(content: String) { - ReadBook.book?.let { - viewModel.saveContent(it, content) - } - } - - override fun showActionMenu() { - when { - BaseReadAloudService.isRun -> showReadAloudDialog() - isAutoPage -> showDialogFragment() - isShowingSearchResult -> binding.searchMenu.runMenuIn() - else -> binding.readMenu.runMenuIn() - } - } - - /** - * 显示朗读菜单 - */ - override fun showReadAloudDialog() { - showDialogFragment() - } - - /** - * 自动翻页 - */ - override fun autoPage() { - ReadAloud.stop(this) - if (isAutoPage) { - autoPageStop() - } else { - binding.readView.autoPager.start() - binding.readMenu.setAutoPage(true) - screenTimeOut = -1L - screenOffTimerStart() - } - } - - override fun autoPageStop() { - if (isAutoPage) { - binding.readView.autoPager.stop() - binding.readMenu.setAutoPage(false) - dismissDialogFragment() - upScreenTimeOut() - } - } - - override fun openSourceEditActivity() { - ReadBook.bookSource?.let { - sourceEditActivity.launch { - putExtra("sourceUrl", it.bookSourceUrl) - } - } - } - - override fun openBookInfoActivity() { - ReadBook.book?.let { - bookInfoActivity.launch { - putExtra("name", it.name) - putExtra("author", it.author) - putExtra("bookUrl", it.bookUrl) - } - } - } - - /** - * 替换 - */ - override fun openReplaceRule() { - replaceActivity.launch(Intent(this, ReplaceRuleActivity::class.java)) - } - - /** - * 打开目录 - */ - override fun openChapterList() { - ReadBook.book?.let { - tocActivity.launch(it.bookUrl) - } - } - - /** - * 打开搜索界面 - */ - override fun openSearchActivity(searchWord: String?) { - val book = ReadBook.book ?: return - searchContentActivity.launch { - putExtra("bookUrl", book.bookUrl) - putExtra("searchWord", searchWord) - putExtra("searchResultIndex", viewModel.searchResultIndex) - viewModel.searchResultList?.first()?.let { - if (it.query == viewModel.searchContentQuery) { - IntentData.put("searchResultList", viewModel.searchResultList) - } - } - } - } - - /** - * 禁用书源 - */ - override fun disableSource() { - viewModel.disableSource() - } - - /** - * 显示阅读样式配置 - */ -// override fun showReadStyle() { -// showDialogFragment() -// } -// -// /** -// * 显示更多设置 -// */ -// override fun showMoreSetting() { -// showDialogFragment() -// } -// - override fun showSearchSetting() { - showDialogFragment() - } - - override fun showReadStyle() { - showDialogFragment() - } - - /** - * 更新状态栏,导航栏 - */ - override fun upSystemUiVisibility() { - upSystemUiVisibility(isInMultiWindow, !menuLayoutIsVisible) - upNavigationBarColor() - } - - // 退出全文搜索 - override fun exitSearchMenu() { - if (isShowingSearchResult) { - isShowingSearchResult = false - binding.searchMenu.invalidate() - binding.searchMenu.invisible() - ReadBook.clearSearchResult() - binding.readView.cancelSelect(true) - } - } - - /* 恢复到 全文搜索/进度条跳转前的位置 */ - private fun restoreLastBookProcess() { - if (confirmRestoreProcess == true) { - ReadBook.restoreLastBookProgress() - } else if (confirmRestoreProcess == null) { - alert(R.string.draw) { - setMessage(R.string.restore_last_book_process) - yesButton { - confirmRestoreProcess = true - ReadBook.restoreLastBookProgress() //恢复启动全文搜索前的进度 - } - noButton { - ReadBook.lastBookProgress = null - confirmRestoreProcess = false - } - onCancelled { - ReadBook.lastBookProgress = null - confirmRestoreProcess = false - } - } - } - } - - override fun showLogin() { - ReadBook.bookSource?.let { - startActivity { - putExtra("bookType", BookType.text) - } - } - } - - override fun payAction() { - val book = ReadBook.book ?: return - if (book.isLocal) return - val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) - if (chapter == null) { - toastOnUi("no chapter") - return - } - alert(R.string.chapter_pay) { - setMessage(chapter.title) - yesButton { - Coroutine.async(lifecycleScope) { - val source = - ReadBook.bookSource ?: throw NoStackTraceException("no book source") - val payAction = source.getContentRule().payAction - if (payAction.isNullOrBlank()) { - throw NoStackTraceException("no pay action") - } - val analyzeRule = AnalyzeRule(book, source) - analyzeRule.setCoroutineContext(coroutineContext) - analyzeRule.setBaseUrl(chapter.url) - analyzeRule.setChapter(chapter) - analyzeRule.evalJS(payAction).toString() - }.onSuccess(IO) { - if (it.isAbsUrl()) { - startActivity { - val bookSource = ReadBook.bookSource - putExtra("title", getString(R.string.chapter_pay)) - putExtra("url", it) - putExtra("sourceOrigin", bookSource?.bookSourceUrl) - putExtra("sourceName", bookSource?.bookSourceName) - putExtra("sourceType", bookSource?.getSourceType()) - } - } else if (it.isTrue()) { - //购买成功后刷新目录 - ReadBook.book?.let { - ReadBook.curTextChapter = null - BookHelp.delContent(book, chapter) - loadChapterList(book) - } - } - }.onError { - AppLog.put("执行购买操作出错\n${it.localizedMessage}", it, true) - } - } - noButton() - } - } - - /** - * 朗读按钮 - */ - override fun onClickReadAloud() { - autoPageStop() - when { - !BaseReadAloudService.isRun -> { - ReadAloud.upReadAloudClass() - val scrollPageAnim = ReadBook.pageAnim() == 3 - if (scrollPageAnim) { - val pos = binding.readView.getReadAloudPos() - if (pos != null) { - val (index, line) = pos - if (ReadBook.durChapterIndex != index) { - ReadBook.openChapter(index, line.chapterPosition, false) { - ReadBook.readAloud(startPos = line.pagePosition) - } - } else { - ReadBook.durChapterPos = line.chapterPosition - ReadBook.readAloud(startPos = line.pagePosition) - } - } else { - ReadBook.readAloud() - } - } else { - ReadBook.readAloud() - } - } - - BaseReadAloudService.pause -> { - val scrollPageAnim = ReadBook.pageAnim() == 3 - if (scrollPageAnim && pageChanged) { - pageChanged = false - val pos = binding.readView.getReadAloudPos() - if (pos != null) { - val (index, line) = pos - if (ReadBook.durChapterIndex != index) { - ReadBook.openChapter(index, line.chapterPosition, false) { - ReadBook.readAloud(startPos = line.pagePosition) - } - } else { - ReadBook.durChapterPos = line.chapterPosition - ReadBook.readAloud(startPos = line.pagePosition) - } - } else { - ReadBook.readAloud() - } - } else { - ReadAloud.resume(this) - } - } - - else -> ReadAloud.pause(this) - } - } - - override fun showHelp() { - showHelp("readMenuHelp") - } - - /** - * 点击图片 - */ - override fun oldClickImg(src: String): Boolean { - val urlMatcher = paramPattern.matcher(src) - if (urlMatcher.find()) { - val urlOptionStr = src.substring(urlMatcher.end()) - val urlOptionMap = GSON.fromJsonObject>(urlOptionStr).getOrNull() - val click = urlOptionMap?.get("click") - if (click != null) { - Coroutine.async(lifecycleScope, IO) { - val source = ReadBook.bookSource ?: return@async - val java = SourceLoginJsExtensions(this@ReadBookActivity, source, BookType.text) - val book = ReadBook.book ?: return@async - val chapter = - appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) - ?: throw Exception("no find chapter") - runScriptWithContext { - source.evalJS(click) { - put("java", java) - put("book", book) - put("chapter", chapter) - put("result", src) - } - } - }.onError { - AppLog.put("执行图片链接click键值出错\n${it.localizedMessage}", it, true) - } - return true - } - val jsStr = urlOptionMap?.get("js") ?: return false - Coroutine.async(lifecycleScope, IO) { - val source = ReadBook.bookSource ?: return@async - val book = ReadBook.book ?: return@async - val chapter = - appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) - ?: throw Exception("no find chapter") - val urlNoOption = src.take(urlMatcher.start()) - AnalyzeRule(book, source).apply { - setCoroutineContext(coroutineContext) - setBaseUrl(chapter.url) - setChapter(chapter) - evalJS(jsStr, urlNoOption) - } - }.onError { - AppLog.put("执行图片链接js键值出错\n${it.localizedMessage}", it, true) - } - return true - } - return false - } - - override fun clickImg(click: String, src: String) { - Coroutine.async(lifecycleScope, IO) { - val source = ReadBook.bookSource ?: return@async - val java = SourceLoginJsExtensions(this@ReadBookActivity, source, BookType.text) - val book = ReadBook.book ?: return@async - val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) - ?: throw Exception("no find chapter") - runScriptWithContext { - source.evalJS(click) { - put("java", java) - put("book", book) - put("chapter", chapter) - put("result", src) - } - } - }.onError { - AppLog.put("执行图片链接click键值出错\n${it.localizedMessage}", it, true) - } - } - - /** - * 长按图片 - */ - @SuppressLint("RtlHardcoded") - override fun onImageLongPress(x: Float, y: Float, src: String) { - binding.root.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - popupAction.setItems( - listOf( - SelectItem(getString(R.string.show), "show"), - SelectItem(getString(R.string.refresh), "refresh"), - SelectItem("保存到相册", "save"), - SelectItem(getString(R.string.menu), "menu"), - ) - ) - popupAction.onActionClick = { - when (it) { - "show" -> showDialogFragment(PhotoDialog(src)) - "refresh" -> viewModel.refreshImage(src) - "save" -> { - viewModel.saveImage(src) - } - "menu" -> showActionMenu() - } - popupAction.dismiss() - } - val navigationBarHeight = - if (!ReadBookConfig.hideNavigationBar && navigationBarGravity == Gravity.BOTTOM) - binding.navigationBar.height else 0 - popupAction.showAtLocation( - binding.readView, Gravity.BOTTOM or Gravity.LEFT, x.toInt(), - binding.root.height + navigationBarHeight - y.toInt() - ) - } - - /** - * colorSelectDialog - */ - override fun onColorSelected(dialogId: Int, color: Int) = ReadBookConfig.durConfig.run { - when (dialogId) { - S_COLOR -> { - setCurShadColor(color) - postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6, 9, 11)) - } - - TEXT_COLOR -> { - setCurTextColor(color) - postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6, 9, 11)) - if (AppConfig.readBarStyleFollowPage) { - postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) - } - } - - TEXT_ACCENT_COLOR -> { - setCurTextAccentColor(color) - postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6, 9, 11)) - if (AppConfig.readBarStyleFollowPage) { - postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) - } - } - - BG_COLOR -> { - setCurBg(0, "#${color.hexString}") - postEvent(EventBus.UP_CONFIG, arrayListOf(1)) - if (AppConfig.readBarStyleFollowPage) { - postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) - } - } - - TIP_HEADER_COLOR -> { - ReadTipConfig.tipHeaderColor = color - postEvent(EventBus.TIP_COLOR, "") - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - - TIP_FOOTER_COLOR -> { - ReadTipConfig.tipFooterColor = color - postEvent(EventBus.TIP_COLOR, "") - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - - TIP_DIVIDER_COLOR -> { - ReadTipConfig.tipDividerColor = color - postEvent(EventBus.TIP_COLOR, "") - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - - TITLE_COLOR -> { - ReadBookConfig.titleColor = color - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - - REGEX_RULE_COLOR -> { - val pos = RegexColorConfigDialog.pendingColorPosition - if (pos in ReadBookConfig.regexColorRules.indices) { - ReadBookConfig.regexColorRules[pos].color = color - ReadBookConfig.saveRegexColorRules() - TextChapterLayout.invalidateRegexCache() - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - val fontConfigDialog = supportFragmentManager.findFragmentByTag("FontConfigDialog") - (fontConfigDialog?.childFragmentManager?.findFragmentByTag("regexColorConfig") as? RegexColorConfigDialog) - ?.onColorSelected(color) - } - - B_COLOR -> { - setMenuCurBg(color) - postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) - } - - A_COLOR -> { - setMenuCurAc(color) - postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) - } - - U_COLOR -> { - setUnderlineColor(color) - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - postEvent(EventBus.UP_CONFIG, arrayListOf(6, 9, 11)) - } - } - } - - /** - * colorSelectDialog - */ - override fun onDialogDismissed(dialogId: Int) = Unit - - private fun sureSyncProgress(progress: BookProgress) { - alert(R.string.get_book_progress) { - setMessage(R.string.current_progress_exceeds_cloud) - okButton { - ReadBook.setProgress(progress) - } - noButton() - } - } - - /* 进度条跳转到指定章节 */ - override fun skipToChapter(index: Int) { - ReadBook.saveCurrentBookProgress() //退出章节跳转恢复此时进度 - viewModel.openChapter(index) - } - - /* 全文搜索跳转 */ - override fun navigateToSearch(searchResult: SearchResult, index: Int) { - viewModel.searchResultIndex = index - skipToSearch(searchResult) - } - - override fun onMenuShow() { - binding.readView.autoPager.pause() - } - - override fun onMenuHide() { - binding.readView.autoPager.resume() - } - - override fun refresh() { - recreate() - } - - override fun onLayoutPageCompleted(index: Int, page: TextPage) { - upSeekBarThrottle.invoke() - binding.readView.onLayoutPageCompleted(index, page) - } - - /* 全文搜索跳转 */ - private fun skipToSearch(searchResult: SearchResult) { - if (searchResult.chapterIndex != ReadBook.durChapterIndex) { - viewModel.openChapter(searchResult.chapterIndex) { - jumpToPosition(searchResult) - } - } else { - jumpToPosition(searchResult) - } - } - - private fun jumpToPosition(searchResult: SearchResult) { - val curTextChapter = ReadBook.curTextChapter ?: return - binding.searchMenu.updateSearchInfo() - val (pageIndex, lineIndex, charIndex, addLine, charIndex2) = - viewModel.searchResultPositions(curTextChapter, searchResult) - ReadBook.skipToPage(pageIndex) { - isSelectingSearchResult = true - binding.readView.curPage.selectStartMoveIndex(0, lineIndex, charIndex) - when (addLine) { - 0 -> binding.readView.curPage.selectEndMoveIndex( - 0, - lineIndex, - charIndex + viewModel.searchContentQuery.length - 1 - ) - - 1 -> binding.readView.curPage.selectEndMoveIndex( - 0, lineIndex + 1, charIndex2 - ) - //consider change page, jump to scroll position - -1 -> binding.readView.curPage.selectEndMoveIndex(1, 0, charIndex2) - } - binding.readView.isTextSelected = true - isSelectingSearchResult = false - } - } - - override fun addBookmark() { - val book = ReadBook.book - val page = ReadBook.curTextChapter?.getPage(ReadBook.durPageIndex) - if (book != null && page != null) { - val bookmark = book.createBookMark().apply { - chapterIndex = ReadBook.durChapterIndex - chapterPos = ReadBook.durChapterPos - chapterName = page.title - bookText = page.text.replace(Regex("[袮꧁]"), "").trim() - } - showDialogFragment(BookmarkDialog(bookmark)) - } - } - - override fun changeReplaceRuleState() { - binding.readMenu.runMenuOut() - ReadBook.book?.let { - it.setUseReplaceRule(!it.getUseReplaceRule()) - ReadBook.saveRead() - menu?.findItem(R.id.menu_enable_replace)?.isChecked = it.getUseReplaceRule() - binding.readMenu.changeReplace(it.getUseReplaceRule()) - viewModel.replaceRuleChanged() - } - } - - private fun startBackupJob() { - backupJob?.cancel() - backupJob = lifecycleScope.launch(IO) { - delay(300000) - ReadBook.book?.let { - viewModel.uploadBookProgress(it) - ensureActive() - Backup.autoBack(this@ReadBookActivity) - } - } - } - - override fun sureNewProgress(progress: BookProgress) { - syncDialog?.dismiss() - syncDialog = alert(R.string.get_book_progress) { - setMessage(R.string.cloud_progress_exceeds_current) - okButton { - ReadBook.setProgress(progress) - } - noButton() - } - } - - private fun callBackBookEnd() { - SourceCallBack.callBackBook( - SourceCallBack.END_READ, - ReadBook.bookSource, - ReadBook.book, - ReadBook.curTextChapter?.chapter - ) - } - - override fun onDestroy() { - super.onDestroy() - tts?.clearTts() - textActionMenu.dismiss() - popupAction.dismiss() - binding.readView.onDestroy() - ReadBook.unregister(this) - if (!ReadBook.inBookshelf && !isChangingConfigurations) { - viewModel.removeFromBookshelf(null) - } - if (!BuildConfig.DEBUG) { - Backup.autoBack(this) - } - } - - override fun observeLiveBus() = binding.run { - observeEvent(EventBus.TIME_CHANGED) { readView.upTime() } - observeEvent(EventBus.BATTERY_CHANGED) { readView.upBattery(it) } - observeEvent(EventBus.MEDIA_BUTTON) { - if (it) { - onClickReadAloud() - } else { - ReadBook.readAloud(!BaseReadAloudService.pause) - } - } - observeEvent>(EventBus.UP_CONFIG) { - it.forEach { value -> - when (value) { - 0 -> upSystemUiVisibility() - 1 -> readView.upBg() - 2 -> { - readView.upStyle() - binding.readMenu.reset() - } - 3 -> readView.upBgAlpha() - 4 -> readView.upPageSlopSquare() - 5 -> if (isInitFinish) ReadBook.loadContent(resetPageOffset = false) - 6 -> readView.upContent(resetPageOffset = false) - 8 -> ChapterProvider.upStyle() - 9 -> readView.invalidateTextPage() - 10 -> ChapterProvider.upLayout() - 11 -> readView.submitRenderTask() - } - } - } - observeEvent(EventBus.ALOUD_STATE) { - if (it == Status.STOP || it == Status.PAUSE) { - ReadBook.curTextChapter?.let { textChapter -> - val page = textChapter.getPageByReadPos(ReadBook.durChapterPos) - if (page != null) { - page.removePageAloudSpan() - readView.upContent(resetPageOffset = false) - } - } - } - } - observeEventSticky(EventBus.TTS_PROGRESS) { chapterStart -> - lifecycleScope.launch(IO) { - if (BaseReadAloudService.isPlay()) { - ReadBook.curTextChapter?.let { textChapter -> - val pageIndex = ReadBook.durPageIndex - val aloudSpanStart = chapterStart - textChapter.getReadLength(pageIndex) - textChapter.getPage(pageIndex) - ?.upPageAloudSpan(aloudSpanStart) - upContent() - } - } - } - } - observeEvent(PreferKey.keepLight) { - upScreenTimeOut() - } - observeEvent(PreferKey.textSelectAble) { - readView.curPage.upSelectAble(it) - } - observeEvent(PreferKey.showBrightnessView) { - readMenu.upBrightnessState() - } - observeEvent>(EventBus.SEARCH_RESULT) { - viewModel.searchResultList = it - } - observeEvent(EventBus.UPDATE_READ_ACTION_BAR) { - readMenu.reset() - } - observeEvent(EventBus.UP_SEEK_BAR) { - readMenu.upSeekBar() - } - observeEvent(EventBus.REFRESH_BOOK_CONTENT) { - ReadBook.book?.let { - ReadBook.curTextChapter = null - binding.readView.upContent() - viewModel.refreshContentDur(it) - } - } - observeEyeProtectionEvents( - onRefresh = { binding.eyeProtectionOverlay.refresh() }, - scheduler = eyeProtectionScheduler - ) - } - - private fun upScreenTimeOut() { - val keepLightPrefer = getPrefString(PreferKey.keepLight)?.toInt() ?: 0 - screenTimeOut = keepLightPrefer * 1000L - screenOffTimerStart() - } - - /** - * 重置黑屏时间 - */ - override fun screenOffTimerStart() { - handler.post { - if (screenTimeOut < 0) { - keepScreenOn(true) - return@post - } - val t = screenTimeOut - sysScreenOffTime - if (t > 0) { - keepScreenOn(true) - handler.removeCallbacks(screenOffRunnable) - handler.postDelayed(screenOffRunnable, screenTimeOut) - } else { - keepScreenOn(false) - } - } - } - - override fun addToBookshelf(book: Book, toc: List) { - viewModel.addToBookshelf(book, toc) { - toastOnUi("已添加到书架") - } - } - - companion object { - const val RESULT_DELETED = 100 - } - -} diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookColorPickerIds.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookColorPickerIds.kt new file mode 100644 index 000000000..e246634e2 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookColorPickerIds.kt @@ -0,0 +1,17 @@ +package io.legado.app.ui.book.read + +object ReadBookColorPickerIds { + const val SHADOW_COLOR = 123 + const val TEXT_COLOR = 121 + const val TEXT_ACCENT_COLOR = 125 + const val BG_COLOR = 122 + const val HIGHLIGHT_RULE_COLOR = 7900 + const val TITLE_COLOR = 7896 + const val TIP_HEADER_COLOR = 7897 + const val TIP_DIVIDER_COLOR = 7898 + const val TIP_FOOTER_COLOR = 7899 + const val MENU_BG_COLOR = 114 + const val MENU_ACCENT_COLOR = 514 + const val UNDERLINE_COLOR = 810 + var pendingHighlightRulePosition = -1 +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookColorTheme.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookColorTheme.kt new file mode 100644 index 000000000..182abe8a7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookColorTheme.kt @@ -0,0 +1,298 @@ +package io.legado.app.ui.book.read + +import android.graphics.drawable.ColorDrawable +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.core.graphics.toColorInt +import io.legado.app.data.repository.ReadPreferences +import io.legado.app.help.config.ReadBookConfig +import io.legado.app.help.config.ReadStyleResolver +import io.legado.app.ui.config.themeConfig.ThemeConfig +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.ProvideThemeOverride +import io.legado.app.ui.theme.ThemeOverrideState +import io.legado.app.ui.theme.ThemeResolver +import io.legado.app.ui.theme.buildThemeOverrideState +import io.legado.app.ui.theme.extractSeedColor +import io.legado.app.ui.theme.toSafeBitmap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun ReadBookColorTheme( + styleConfig: ReadBookStyleConfig, + preferences: ReadPreferences, + content: @Composable () -> Unit, +) { + ProvideThemeOverride( + theme = rememberReadBookColorTheme( + styleConfig = styleConfig, + preferences = preferences, + ), + content = content + ) +} + +@Composable +private fun rememberReadBookColorTheme( + styleConfig: ReadBookStyleConfig, + preferences: ReadPreferences, +): ThemeOverrideState? { + val isAppDark = LegadoTheme.isDark + return when (preferences.readBarStyle) { + 1 -> rememberReadBackgroundTheme(styleConfig, isAppDark) + 2 -> rememberCustomReadMenuTheme( + styleConfig = styleConfig, + preferences = preferences, + isAppDark = isAppDark, + ) + else -> null + } +} + +@Composable +private fun rememberReadBackgroundTheme( + styleConfig: ReadBookStyleConfig, + isAppDark: Boolean, +): ThemeOverrideState? { + val background = remember(styleConfig, isAppDark) { + runCatching { ReadStyleResolver.currentBackground(ReadBookConfig.durConfig) }.getOrNull() + } ?: return null + var seedColor by remember(background) { mutableStateOf(null) } + + LaunchedEffect(background, styleConfig, isAppDark) { + seedColor = when (background.type) { + 0 -> background.value.toColorOrNull() + else -> extractCurrentReadBackgroundSeed() + ?: ReadBookConfig.bgMeanColor.takeIf { it != 0 }?.let(::Color) + } + } + + val sourceColor = seedColor ?: return null + val surfaceColor = when (background.type) { + 0 -> background.value.toColorOrNull() + else -> ReadBookConfig.bgMeanColor.takeIf { it != 0 }?.let(::Color) + } + return rememberReadThemeOverride( + seedColor = sourceColor, + backgroundColor = surfaceColor, + containerColor = null, + fallbackDark = isAppDark, + ) +} + +@Composable +private fun rememberCustomReadMenuTheme( + styleConfig: ReadBookStyleConfig, + preferences: ReadPreferences, + isAppDark: Boolean, +): ThemeOverrideState { + val menuBackgroundColor = remember( + styleConfig, + preferences.readMenuBgColor, + preferences.readMenuBgColorNight, + isAppDark, + ) { + Color(preferences.readMenuBackgroundColor(isAppDark)) + } + val accentColor = remember( + styleConfig, + preferences.readMenuAccentColor, + preferences.readMenuAccentColorNight, + isAppDark, + ) { + Color(preferences.readMenuAccentColor(isAppDark)) + } + val menuContainerColor = remember( + styleConfig, + preferences.readMenuContainerColor, + preferences.readMenuContainerColorNight, + preferences.readMenuBgColor, + preferences.readMenuBgColorNight, + isAppDark, + ) { + Color(preferences.readMenuContainerColor(isAppDark)) + } + val useSeedOnly = preferences.readMenuColorMode == 0 + if (!useSeedOnly) { + return rememberCustomReadMenuThemeOverride( + accentColor = accentColor, + menuBackgroundColor = menuBackgroundColor, + menuContainerColor = menuContainerColor, + isDark = isAppDark, + ) + } + + return rememberReadThemeOverride( + seedColor = accentColor, + backgroundColor = null, + containerColor = null, + fallbackDark = isAppDark, + deriveDarkFromColor = false, + ) ?: buildReadThemeOverride( + seedColor = accentColor, + backgroundColor = null, + containerColor = null, + isDark = isAppDark, + ) +} + +@Composable +private fun rememberCustomReadMenuThemeOverride( + accentColor: Color, + menuBackgroundColor: Color, + menuContainerColor: Color, + isDark: Boolean, +): ThemeOverrideState { + return remember(accentColor, menuBackgroundColor, menuContainerColor, isDark) { + buildReadThemeOverride( + seedColor = accentColor, + backgroundColor = null, + containerColor = null, + isDark = isDark, + ).let { base -> + base.copy( + colorScheme = base.colorScheme.withCustomReadMenuColors( + accentColor = accentColor, + menuBackgroundColor = menuBackgroundColor, + menuContainerColor = menuContainerColor, + ) + ) + } + } +} + +@Composable +private fun rememberReadThemeOverride( + seedColor: Color, + backgroundColor: Color?, + containerColor: Color?, + fallbackDark: Boolean = LegadoTheme.isDark, + deriveDarkFromColor: Boolean = true, +): ThemeOverrideState? { + val isDark = remember(backgroundColor, containerColor, fallbackDark, deriveDarkFromColor) { + if (deriveDarkFromColor) { + (containerColor ?: backgroundColor)?.let { it.luminance() < 0.5f } ?: fallbackDark + } else { + fallbackDark + } + } + return remember(seedColor, backgroundColor, containerColor, isDark) { + buildReadThemeOverride( + seedColor = seedColor, + backgroundColor = backgroundColor, + containerColor = containerColor, + isDark = isDark, + ) + } +} + +private fun buildReadThemeOverride( + seedColor: Color, + backgroundColor: Color?, + containerColor: Color?, + isDark: Boolean, +): ThemeOverrideState { + val colorSpec = ThemeResolver.resolveColorSpecFromMaterialVersion(ThemeConfig.materialVersion) + val base = buildThemeOverrideState( + seedColor = seedColor, + isDark = isDark, + paletteStyle = ThemeResolver.resolvePaletteStyle(ThemeConfig.paletteStyle), + colorSpec = colorSpec, + usePureBlack = false, + ) + return base.copy( + colorScheme = base.colorScheme.withReadSurfaceColors( + backgroundColor = backgroundColor, + containerColor = containerColor + ) + ) +} + +private fun ColorScheme.withReadSurfaceColors( + backgroundColor: Color?, + containerColor: Color?, +): ColorScheme { + val resolvedBackground = backgroundColor ?: background + val resolvedContainer = containerColor ?: surfaceContainer + return copy( + background = resolvedBackground, + surface = resolvedBackground, + surfaceDim = resolvedBackground, + surfaceBright = resolvedBackground, + surfaceContainerLowest = resolvedBackground, + surfaceContainer = resolvedContainer, + ) +} + +private fun ColorScheme.withCustomReadMenuColors( + accentColor: Color, + menuBackgroundColor: Color, + menuContainerColor: Color, +): ColorScheme { + return copy( + primary = accentColor, + onPrimary = accentColor.contrastContentColor(), + surfaceTint = accentColor, + surfaceContainerHigh = menuBackgroundColor, + surfaceContainerLow = menuContainerColor, + ) +} + +private fun Color.contrastContentColor(): Color { + return if (luminance() > 0.5f) Color.Black else Color.White +} + +private fun ReadPreferences.readMenuBackgroundColor(isDark: Boolean): Int { + return if (isDark) { + readMenuBgColorNight.takeIf { it != 0 } ?: ReadBookConfig.durConfig.menuBgColor(isNight = true) + } else { + readMenuBgColor.takeIf { it != 0 } ?: ReadBookConfig.durConfig.menuBgColor(isNight = false) + } +} + +private fun ReadPreferences.readMenuAccentColor(isDark: Boolean): Int { + return if (isDark) { + readMenuAccentColorNight.takeIf { it != 0 } + ?: ReadBookConfig.durConfig.menuAccentColor(isNight = true) + } else { + readMenuAccentColor.takeIf { it != 0 } + ?: ReadBookConfig.durConfig.menuAccentColor(isNight = false) + } +} + +private fun ReadPreferences.readMenuContainerColor(isDark: Boolean): Int { + return if (isDark) { + readMenuContainerColorNight.takeIf { it != 0 } ?: readMenuBackgroundColor(isDark = true) + } else { + readMenuContainerColor.takeIf { it != 0 } ?: readMenuBackgroundColor(isDark = false) + } +} + +private suspend fun extractCurrentReadBackgroundSeed(): Color? { + return withContext(Dispatchers.Default) { + runCatching { + val drawable = ReadStyleResolver.currentBackgroundDrawable( + config = ReadBookConfig.durConfig, + width = 128, + height = 128 + ) + if (drawable is ColorDrawable) { + Color(drawable.color) + } else { + Color(drawable.toSafeBitmap(128).extractSeedColor()) + } + }.getOrNull() + } +} + +private fun String.toColorOrNull(): Color? { + return runCatching { Color(toColorInt()) }.getOrNull() +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt new file mode 100644 index 000000000..a99d6a7c2 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt @@ -0,0 +1,1018 @@ +package io.legado.app.ui.book.read + +import android.net.Uri +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import io.legado.app.constant.ReadMenuBlurMode +import io.legado.app.constant.ReadMenuBlurStyle +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookProgress +import io.legado.app.data.entities.BookSource +import io.legado.app.ui.book.read.page.entities.TextChapter +import io.legado.app.ui.book.read.page.entities.TextPage +import io.legado.app.ui.book.searchContent.SearchResult +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf + +@Stable +data class ReadBookMenuState( + val visible: Boolean = false, + val routeStack: ImmutableList = persistentListOf(ReadBookMenuRoute.Main), +) { + val currentRoute: ReadBookMenuRoute + get() = routeStack.lastOrNull() ?: ReadBookMenuRoute.Main + + val canNavigateBack: Boolean + get() = routeStack.size > 1 +} + +@Immutable +sealed interface ReadBookMenuRoute { + data object Main : ReadBookMenuRoute + data object ReadStyle : ReadBookMenuRoute + data object TextTitle : ReadBookMenuRoute + data object ReadAloud : ReadBookMenuRoute + data object AutoRead : ReadBookMenuRoute + data object PaddingConfig : ReadBookMenuRoute + data class Bookmark(val bookmark: io.legado.app.data.entities.Bookmark) : ReadBookMenuRoute +} + +@Stable +data class ReadBookStyleConfig( + val styleSelect: Int = 0, + val styleName: String = "文字", + val bgAlpha: Float = 1f, + // Day mode + val bgType: Int = 0, + val bgStr: String = "#EEEEEE", + val darkStatusIcon: Boolean = true, + // Night mode + val bgTypeNight: Int = 0, + val bgStrNight: String = "#000000", + val darkStatusIconNight: Boolean = false, + // E-Ink mode + val bgTypeEInk: Int = 0, + val bgStrEInk: String = "#FFFFFF", + val darkStatusIconEInk: Boolean = true, + // Text + val textSize: Int = 20, + val textColor: String = "#3E3D3B", + val textColorNight: String = "#CCCCCC", + val textColorEInk: String = "#000000", + // Page anim + val pageAnim: Int = 0, + val pageAnimEInk: Int = 4, + // Layout + val shareLayout: Boolean = false, + // Config list for style selector + val configCount: Int = 1, +) { + // Computed properties for background mode + val isDayBgImage: Boolean get() = bgType != 0 + val isNightBgImage: Boolean get() = bgTypeNight != 0 +} + +@Stable +data class ReadBookUiState( + val book: Book? = null, + val bookSource: BookSource? = null, + val bookName: String = "", + val chapterName: String = "", + val chapterUrl: String = "", + val chapterSize: Int = 0, + val durChapterIndex: Int = 0, + val durChapterPos: Int = 0, + val durPageIndex: Int = 0, + val isLocalBook: Boolean = true, + val msg: String? = null, + val isInitFinish: Boolean = false, + // Search + val searchMenuVisible: Boolean = false, + val isShowingSearchResult: Boolean = false, + val searchContentQuery: String = "", + val searchResultList: ImmutableList = persistentListOf(), + val searchResultIndex: Int = 0, + // Read aloud / auto page + val isReadAloudRunning: Boolean = false, + val isReadAloudPaused: Boolean = false, + val isAutoPage: Boolean = false, + // Seek bar + val seekProgress: Int = 0, + val seekMax: Int = 0, + // Replace rules + val replaceRuleEnabled: Boolean = false, + val effectiveReplaceCount: Int = 0, + // Translation + val translationMode: Boolean = false, + // Chapter info + val curTextChapter: TextChapter? = null, + // Time / battery (from EventBus) + val time: String = "", + val battery: Int = 0, + val menuState: ReadBookMenuState = ReadBookMenuState(), + // Active sheet / dialog + val activeSheet: ReadBookSheet? = null, + val activeDialog: ReadBookDialog? = null, + // Menu state (for overflow menu) + val isLocalTxt: Boolean = false, + val isEpub: Boolean = false, + val useReplaceRule: Boolean = false, + val reSegment: Boolean = false, + val delRubyTag: Boolean = false, + val delHTag: Boolean = false, + val sameTitleRemoved: Boolean = false, + val isReadingProgressSyncConfigured: Boolean = false, + // Content edit + val contentEditLoading: Boolean = false, + val contentEditText: String = "", + val contentEditTitle: String = "", + val contentEditIsLocalTxt: Boolean = false, + val contentEditSaveToSource: Boolean = false, + val ttsEngineItems: ImmutableList = persistentListOf(), + val selectedTtsEngine: String? = null, + val preDownloadNum: Int = 10, + val audioCacheCleanTime: Int = 10, + // Read aloud config + val readAloudIgnoreAudioFocus: Boolean = false, + val readAloudPauseOnPhoneCall: Boolean = false, + val readAloudWakeLock: Boolean = false, + val readAloudMediaButtonPerNext: Boolean = false, + val readAloudByPage: Boolean = false, + val readAloudSystemMediaCompat: Boolean = true, + val readAloudStreamAudio: Boolean = false, + val readAloudTtsFollowSys: Boolean = false, + val readAloudTtsSpeechRate: Int = 10, + val readAloudTtsTimer: Int = 0, + // Style config (reactive state for ReadBookConfig) + val styleConfig: ReadBookStyleConfig = ReadBookStyleConfig(), + // Menu config (from ReadBookConfig via repository) + val menuConfig: ReadMenuConfig = ReadMenuConfig(), +) { + val menuVisible: Boolean + get() = menuState.visible +} + +@Stable +data class ReadMenuConfig( + val titleBarIconPosition: Int = 0, + val showTitleBarIcons: Boolean = true, + val readMenuFloatingBottomBar: Boolean = false, + val readMenuBottomCornerRadius: Int = 0, + val readMenuIconItemsPerRow: Int = 5, + val readMenuIconRowCount: Int = 1, + val readMenuBorderWidth: Int = 0, + val readMenuBorderColor: Int = 0, + val readMenuBorderColorNight: Int = 0, + val readMenuBlurAlpha: Int = 60, + val readMenuBlurRadius: Int = 24, + val readMenuLensRadius: Float = 24f, + val readMenuTopBarBlurMode: Int = ReadMenuBlurMode.None, + val readMenuBottomBarBlurMode: Int = ReadMenuBlurMode.None, + val readMenuTopBarLiquidGlassButtons: Boolean = false, + val readMenuBottomBarLiquidGlassButtons: Boolean = false, + val readMenuTopBarBlurStyle: Int = ReadMenuBlurStyle.Progressive, + val readMenuBottomBarBlurStyle: Int = ReadMenuBlurStyle.Solid, + val readMenuIconStyle: Int = 0, + val readMenuIconShowText: Boolean = true, + val titleBarCustomIcons: ImmutableMap = persistentMapOf(), + val readMenuCustomIcons: ImmutableMap = persistentMapOf(), + val titleBarButtons: ImmutableList = persistentListOf(), + val bottomBarButtons: ImmutableList = persistentListOf(), +) + +@Immutable +data class ReadBookTtsEngineItem( + val title: String, + val value: String?, +) + +@Immutable +data class ReadBookButtonConfigItem( + val id: String, + val enabled: Boolean, +) + +internal val ReadBookButtonIds = listOf( + "search", + "auto_page", + "catalog", + "read_aloud", + "setting", + "addBookmark", + "theme", + "prev_chapter", + "next_chapter", + "replace", + "replace_badge", + "translate", +) + +sealed interface ReadBookIntent { + // Initialization + data class InitData(val intent: android.content.Intent) : ReadBookIntent + data class InitReadBookConfig(val intent: android.content.Intent) : ReadBookIntent + + // Navigation + data object NextPage : ReadBookIntent + data object PrevPage : ReadBookIntent + data object NextChapter : ReadBookIntent + data object PrevChapter : ReadBookIntent + data class OpenChapter(val index: Int, val pos: Int = 0) : ReadBookIntent + data class SkipToPage(val pageIndex: Int) : ReadBookIntent + + // Menu + data object ToggleMenu : ReadBookIntent + data object ShowMenu : ReadBookIntent + data object HideMenu : ReadBookIntent + data class OpenReadMenuRoute(val route: ReadBookMenuRoute) : ReadBookIntent + data object ReadMenuBack : ReadBookIntent + + // Search + data class OpenSearch(val word: String?) : ReadBookIntent + data object ExitSearch : ReadBookIntent + data object ShowSearchMenu : ReadBookIntent + data object HideSearchMenu : ReadBookIntent + data class SetSearchResults(val results: List, val index: Int, val query: String? = null) : ReadBookIntent + data class SetSearchResultIndex(val index: Int) : ReadBookIntent + data class SetShowingSearchResult(val value: Boolean) : ReadBookIntent + data class NavigateToSearchResult(val result: SearchResult, val index: Int) : ReadBookIntent + + // Read aloud + data object ToggleReadAloud : ReadBookIntent + + // Auto page + data object ToggleAutoPage : ReadBookIntent + data object StopAutoPage : ReadBookIntent + + // Content operations + data object RefreshCurrentChapter : ReadBookIntent + data object RefreshAllChapters : ReadBookIntent + data object RefreshContentAfter : ReadBookIntent + data class ChangeReplaceRule(val enabled: Boolean) : ReadBookIntent + data object ToggleTranslation : ReadBookIntent + + // Change source + data class ChangeSource(val book: Book, val toc: List) : ReadBookIntent + data class AddSourceAsNewBook(val book: Book, val toc: List) : ReadBookIntent + + // Activity result intents + data class OpenChapterResult(val index: Int, val chapterPos: Int) : ReadBookIntent + data object SourceEditResult : ReadBookIntent + data object ReplaceRuleResult : ReadBookIntent + data class BookInfoResult(val bookDeleted: Boolean) : ReadBookIntent + data class FontFolderSelected(val uri: Uri) : ReadBookIntent + + // Progress sync + data class SureNewProgress(val progress: BookProgress) : ReadBookIntent + data class SureSyncProgress(val progress: BookProgress) : ReadBookIntent + + // Bookmark + data object AddBookmark : ReadBookIntent + data class SaveBookmark(val bookmark: io.legado.app.data.entities.Bookmark) : ReadBookIntent + data class DeleteBookmark(val bookmark: io.legado.app.data.entities.Bookmark) : ReadBookIntent + + // Text selection + data object CancelSelect : ReadBookIntent + + // System UI + data object UpSystemUiVisibility : ReadBookIntent + data object UpContent : ReadBookIntent + + // Brightness + data class SetBrightness(val value: Int) : ReadBookIntent + data object ToggleBrightnessAuto : ReadBookIntent + + // Seek bar jump + data class SeekToChapter(val index: Int) : ReadBookIntent + + // Sheet / Dialog + data class ShowSheet(val sheet: ReadBookSheet) : ReadBookIntent + data object DismissSheet : ReadBookIntent + data class SetActiveSheet(val sheet: ReadBookSheet?) : ReadBookIntent + data class ShowDialog(val dialog: ReadBookDialog) : ReadBookIntent + data object DismissDialog : ReadBookIntent + + // Source actions + data object ShowLogin : ReadBookIntent + data object PayAction : ReadBookIntent + data object ConfirmPayAction : ReadBookIntent + data object DisableSource : ReadBookIntent + data object OpenSourceEdit : ReadBookIntent + data class OpenSourceEditByUrl(val sourceUrl: String) : ReadBookIntent + data object OpenBookInfo : ReadBookIntent + data object OpenChapterList : ReadBookIntent + + // Content edit + data object LoadContentEdit : ReadBookIntent + data class SaveContentEdit(val content: String, val saveToSource: Boolean) : ReadBookIntent + data object ResetContentEdit : ReadBookIntent + data class SetContentEditText(val text: String) : ReadBookIntent + data class SetContentEditSaveToSource(val value: Boolean) : ReadBookIntent + + // Tools + data class RefreshImage(val src: String) : ReadBookIntent + data class SaveImage(val src: String) : ReadBookIntent + data object ReverseContent : ReadBookIntent + data object ReverseRemoveSameTitle : ReadBookIntent + data object RetranslateCurrentChapter : ReadBookIntent + + // Menu actions (moved from Activity) + data object MenuUpdateToc : ReadBookIntent + data object MenuCoverProgress : ReadBookIntent + data object MenuSameTitleRemoved : ReadBookIntent + data class MenuImageStyle(val style: String) : ReadBookIntent + data object MenuGetProgress : ReadBookIntent + data object MenuChangeSource : ReadBookIntent + data object MenuBookChangeSource : ReadBookIntent + data object MenuChapterChangeSource : ReadBookIntent + data object MenuSettingReplace : ReadBookIntent + data object MenuTocRegex : ReadBookIntent + data class TocRegexResult(val tocRegex: String) : ReadBookIntent + data object MenuRefreshDur : ReadBookIntent + data object MenuRefreshAfter : ReadBookIntent + data object MenuRefreshAll : ReadBookIntent + data object MenuEnableReplace : ReadBookIntent + data object MenuReSegment : ReadBookIntent + data object MenuDelRubyTag : ReadBookIntent + data object MenuDelHTag : ReadBookIntent + data object MenuReverseContent : ReadBookIntent + + // Page anim config (selector dialog, needs Activity context) + data object ShowPageAnimConfig : ReadBookIntent + + // Replace editor (needs Activity context for ActivityResult) + data class OpenReplaceEditor(val id: Long, val pattern: String?) : ReadBookIntent + data object ReplaceRuleChanged : ReadBookIntent + + // Font folder picker (needs Activity context for ActivityResult) + data object OpenFontFolderPicker : ReadBookIntent + + // Read style SAF actions + data object OpenReadStyleImagePicker : ReadBookIntent + data class OpenReadStyleImagePickerForMode(val isNight: Boolean) : ReadBookIntent + data object OpenReadStyleImport : ReadBookIntent + data object OpenReadStyleExport : ReadBookIntent + data class ReadStyleImageSelected(val uri: Uri) : ReadBookIntent + data class ReadStyleImageSelectedForMode(val uri: Uri, val isNight: Boolean) : ReadBookIntent + data class ReadStyleConfigImportSelected(val uri: Uri) : ReadBookIntent + data class ReadStyleConfigExportSelected(val uri: Uri) : ReadBookIntent + data object SaveReadStyleConfig : ReadBookIntent + data object AddReadStyleConfig : ReadBookIntent + data object DeleteCurrentReadStyleConfig : ReadBookIntent + + // Bookshelf + data object RemoveFromBookshelf : ReadBookIntent + + // Config update (triggers ReadView upBg/upStyle etc.) + data class OnConfigUpdated(val actions: Set) : ReadBookIntent + + // Typed config mutation — single entry point for all ReadBookConfig changes + data class UpdateConfig(val update: ConfigUpdate) : ReadBookIntent + + // Icon picker — file IO handled by ViewModel + data class SaveMenuCustomIcon(val id: String, val uri: Uri) : ReadBookIntent + data class SaveTitleBarCustomIcon(val id: String, val uri: Uri) : ReadBookIntent + data class OpenMenuCustomIconPicker(val id: String) : ReadBookIntent + data class OpenTitleBarCustomIconPicker(val id: String) : ReadBookIntent + data class SaveMenuButtonConfig(val items: List) : ReadBookIntent + data class SaveTitleBarButtonConfig(val items: List) : ReadBookIntent + + // BgTextConfig (needs Activity for DialogFragment) + data class OpenBgTextConfig(val index: Int) : ReadBookIntent + + // Day/night toggle + data object ToggleDayNight : ReadBookIntent + + // Default font picker (needs Activity for AlertDialog) + // Text action menu (moved from Activity) + data class TextActionAloud(val text: String) : ReadBookIntent + data class TextActionBookmark(val text: String) : ReadBookIntent + data class TextActionReplace(val text: String) : ReadBookIntent + data class TextActionSearchContent(val text: String) : ReadBookIntent + data class TextActionDict(val text: String) : ReadBookIntent + + // Screen / selection config + data object KeepLightChanged : ReadBookIntent + data class TextSelectAbleChanged(val enabled: Boolean) : ReadBookIntent + + // Media / TTS + data class MediaButtonPressed(val play: Boolean) : ReadBookIntent + data class TtsProgress(val chapterStart: Int) : ReadBookIntent + + // Dialog callback bridge + data object ReadAloudAction : ReadBookIntent + + // Read aloud config (needs Activity for DialogFragment) + data object ShowReadAloudConfig : ReadBookIntent + data object SelectSpeakEngine : ReadBookIntent + data object OpenPreDownloadNumPicker : ReadBookIntent + data object OpenCacheCleanTimePicker : ReadBookIntent + data class ApplySpeakEngine(val value: String?) : ReadBookIntent + data class ApplyPreDownloadNum(val value: Int) : ReadBookIntent + data class ApplyAudioCacheCleanTime(val value: Int) : ReadBookIntent + data class SetReadAloudIgnoreAudioFocus(val value: Boolean) : ReadBookIntent + data class SetReadAloudPauseOnPhoneCall(val value: Boolean) : ReadBookIntent + data class SetReadAloudWakeLock(val value: Boolean) : ReadBookIntent + data class SetReadAloudMediaButtonPerNext(val value: Boolean) : ReadBookIntent + data class SetReadAloudByPage(val value: Boolean) : ReadBookIntent + data class SetReadAloudSystemMediaCompat(val value: Boolean) : ReadBookIntent + data class SetReadAloudStreamAudio(val value: Boolean) : ReadBookIntent + data object ReadAloudPrevParagraph : ReadBookIntent + data object ReadAloudTogglePause : ReadBookIntent + data object ReadAloudStop : ReadBookIntent + data object ReadAloudNextParagraph : ReadBookIntent + data object ReadAloudPrevChapter : ReadBookIntent + data object ReadAloudNextChapter : ReadBookIntent + data class SetReadAloudTtsTimer(val value: Int) : ReadBookIntent + data class SetReadAloudTtsFollowSys(val value: Boolean) : ReadBookIntent + data class SetReadAloudTtsSpeechRate(val value: Int) : ReadBookIntent + data object OpenSystemTtsSettings : ReadBookIntent + data object ClearTtsCache : ReadBookIntent + data class SelectFont(val path: String) : ReadBookIntent + data class SelectSystemTypeface(val index: Int) : ReadBookIntent + data class ColorSelected(val dialogId: Int, val color: Int) : ReadBookIntent + + // Simulated reading apply (clear chapter cache + reinit) + data object ApplySimulatedReading : ReadBookIntent + + // Page anim changed (reload content + update view) + data object PageAnimChanged : ReadBookIntent + + // Download chapters + data class DownloadChapters(val start: Int, val end: Int) : ReadBookIntent + + // Save chapter content (from chapter source change) + data class SaveChapterContent(val content: String, val chapterIndex: Int) : ReadBookIntent + + // Lifecycle (from route DisposableEffect) + data object OnResume : ReadBookIntent + data object OnPause : ReadBookIntent + data object OnDispose : ReadBookIntent + data object CloseReadBook : ReadBookIntent + data object OpenBooksDirPicker : ReadBookIntent + data class BooksDirSelected(val uri: Uri) : ReadBookIntent +} + +sealed interface ReadBookEffect { + // Toast + data class ShowToast(val message: String) : ReadBookEffect + data class LongToast(val message: String) : ReadBookEffect + data class TtsCacheCleared(val message: String) : ReadBookEffect + + // Navigation / lifecycle + data object Finish : ReadBookEffect + data object Recreate : ReadBookEffect + + // ReadView operations (require Activity/View reference) + data class UpdateReadViewConfig(val actions: Set) : ReadBookEffect + data class UpContent(val relativePosition: Int, val resetPageOffset: Boolean) : ReadBookEffect + data class UpPageAnim(val upRecorder: Boolean) : ReadBookEffect + data object UpTime : ReadBookEffect + data class UpBattery(val level: Int) : ReadBookEffect + data object UpAloudState : ReadBookEffect + data object UpSeekBar : ReadBookEffect + data object UpMenuView : ReadBookEffect + data object PageChanged : ReadBookEffect + data object ContentLoadFinish : ReadBookEffect + data class LayoutPageCompleted(val index: Int, val page: TextPage) : ReadBookEffect + data object RefreshBookContent : ReadBookEffect + + // Menu / UI actions + data object AddBookmark : ReadBookEffect + data object CancelSelect : ReadBookEffect + data object UpSystemUiVisibility : ReadBookEffect + data class SetBrightness(val value: Int) : ReadBookEffect + data object ToggleBrightnessAuto : ReadBookEffect + + // Read aloud / auto page + data object ToggleReadAloud : ReadBookEffect + data object ToggleAutoPage : ReadBookEffect + data object StopAutoPage : ReadBookEffect + + // Search + data class OpenSearchActivity(val word: String?, val bookUrl: String) : ReadBookEffect + data class NavigateToSearchResult(val result: SearchResult) : ReadBookEffect + data object ExitSearch : ReadBookEffect + + // Source actions + data class ShowLogin(val sourceUrl: String) : ReadBookEffect + data class OpenSourceEdit(val sourceUrl: String) : ReadBookEffect + data class OpenBookInfo(val name: String, val author: String, val bookUrl: String) : ReadBookEffect + data class OpenChapterList(val bookUrl: String) : ReadBookEffect + data class OpenWebView( + val title: String, + val url: String, + val sourceOrigin: String?, + val sourceName: String?, + val sourceType: Int?, + ) : ReadBookEffect + + // Menu actions that need Activity + data object MenuChangeSource : ReadBookEffect + data object MenuBookChangeSource : ReadBookEffect + data object MenuChapterChangeSource : ReadBookEffect + data object MenuSettingReplace : ReadBookEffect + data class MenuTocRegex(val tocRegex: String?) : ReadBookEffect + data class MenuImageStyleChanged(val style: String) : ReadBookEffect + data class SyncBookProgress(val book: Book) : ReadBookEffect + + // Text action menu (needs Activity for View operations) + data object TextActionAloudSelect : ReadBookEffect + data class TextActionSpeak(val text: String) : ReadBookEffect + data class TextActionReplace(val text: String, val bookName: String?, val bookSourceUrl: String?) : ReadBookEffect + + // Screen / selection + data object UpScreenTimeOut : ReadBookEffect + data class UpTextSelectAble(val enabled: Boolean) : ReadBookEffect + + // TTS + data class UpTtsAloudSpan(val chapterStart: Int) : ReadBookEffect + + // Dialogs (Activity-driven) + data object ShowConfirmSkipToChapter : ReadBookEffect + // Replace editor (needs Activity context for ActivityResult) + data class OpenReplaceEditor(val id: Long, val pattern: String?) : ReadBookEffect + + // Font folder picker + data object OpenFontFolderPicker : ReadBookEffect + + // Read style SAF actions + data object OpenReadStyleImagePicker : ReadBookEffect + data class OpenReadStyleImagePickerForMode(val isNight: Boolean) : ReadBookEffect + data object OpenReadStyleImport : ReadBookEffect + data object OpenReadStyleExport : ReadBookEffect + data class OpenMenuCustomIconPicker(val id: String) : ReadBookEffect + data class OpenTitleBarCustomIconPicker(val id: String) : ReadBookEffect + data object OpenSystemTtsSettings : ReadBookEffect + + // Day/night toggle + data object ToggleDayNight : ReadBookEffect + + // Page anim changed — Activity calls readView.upPageAnim() + ReadBook.loadContent(false) + data object PageAnimChanged : ReadBookEffect + + // Download chapters — Activity calls CacheBook.start() + data class DownloadChapters(val start: Int, val end: Int) : ReadBookEffect + + // Lifecycle — route-level Activity operations + data object RegisterTimeBatteryReceiver : ReadBookEffect + data object UnregisterTimeBatteryReceiver : ReadBookEffect + data object RegisterNetworkListener : ReadBookEffect + data object UnregisterNetworkListener : ReadBookEffect + data object OpenBooksDirPicker : ReadBookEffect + data object BackupNow : ReadBookEffect +} + +@Immutable +sealed interface ReadBookSheet { + data object PageAnim : ReadBookSheet + data object Download : ReadBookSheet + data object Charset : ReadBookSheet + data object SimulatedReading : ReadBookSheet + data object ToolButtonConfig : ReadBookSheet + data object TitleBarIconConfig : ReadBookSheet + data object EffectiveReplaces : ReadBookSheet + data object ContentEdit : ReadBookSheet + data object AppLog : ReadBookSheet + data class ChangeChapterSource(val chapterIndex: Int, val chapterTitle: String) : ReadBookSheet + data object ChangeBookSource : ReadBookSheet + data object ShadowSet : ReadBookSheet + data object UnderlineConfig : ReadBookSheet + data object FontSelect : ReadBookSheet + data object HighlightRuleConfig : ReadBookSheet + data object MoreConfig : ReadBookSheet + data object BgTextConfig : ReadBookSheet + data object ReadAloudConfig : ReadBookSheet + data object SpeakEngineConfig : ReadBookSheet + data object PreDownloadConfig : ReadBookSheet + data object AudioCacheCleanConfig : ReadBookSheet + data object ClickActionConfig : ReadBookSheet + data object PageKeyConfig : ReadBookSheet + data object InfoConfig : ReadBookSheet + data class Dict(val word: String) : ReadBookSheet + data class Bookmark( + val bookmark: io.legado.app.data.entities.Bookmark, + val editPos: Int = -1, + ) : ReadBookSheet + + data class Photo( + val src: String, + val sourceOrigin: String? = null, + ) : ReadBookSheet +} + +@Immutable +sealed interface ReadBookDialog { + data class ConfirmRestoreProgress(val progress: BookProgress) : ReadBookDialog + data class SureSyncProgress(val progress: BookProgress) : ReadBookDialog + data object ConfirmSkipToChapter : ReadBookDialog + data class ConfirmChapterPay(val chapterTitle: String) : ReadBookDialog +} + +/** + * Typed config update actions — replaces magic integer codes. + * Each action represents a specific UI update operation. + */ +@Immutable +sealed interface ConfigUpdateAction { + data object UpdateSystemUi : ConfigUpdateAction + data object UpdateBackground : ConfigUpdateAction + data object UpdateStyle : ConfigUpdateAction + data object UpdateBackgroundAlpha : ConfigUpdateAction + data object UpdatePageSlopSquare : ConfigUpdateAction + data object ReloadContent : ConfigUpdateAction + data object UpdateContent : ConfigUpdateAction + data object UpdateChapterStyle : ConfigUpdateAction + data object InvalidateTextPage : ConfigUpdateAction + data object UpdateLayout : ConfigUpdateAction + data object SubmitRenderTask : ConfigUpdateAction +} + +/** + * Typed config mutations — replaces direct `ReadBookConfig.xxx = value` + `postEvent(UP_CONFIG, ...)`. + * Each variant carries [actions] that describe which UI updates are needed. + */ +@Immutable +sealed interface ConfigUpdate { + val actions: Set + + // --- Text style --- + data class TextSize(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent) + } + data class LetterSpacing(val value: Float) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent) + } + data class LineSpacing(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent) + } + data class ParagraphSpacing(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent) + } + data class ParagraphIndent(val value: String) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent) + } + data class TextItalic(val value: Boolean) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent) + } + data class TextBold(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.InvalidateTextPage, ConfigUpdateAction.UpdateContent) + } + data class TextColor(val color: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.InvalidateTextPage) + } + data class TextAccentColor(val color: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.InvalidateTextPage) + } + + // --- Title style --- + data class TitleMode(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.ReloadContent) + } + data class TitleBold(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.InvalidateTextPage, ConfigUpdateAction.UpdateContent) + } + data class TitleSegScaling(val value: Float) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent) + } + data class TitleLineSpacingExtra(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent) + } + data class TitleLineSpacingSub(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent) + } + data class TitleSize(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent) + } + data class TitleTopSpacing(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent) + } + data class TitleBottomSpacing(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent) + } + data class TitleColor(val color: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.InvalidateTextPage) + } + + // --- Header / footer tips --- + data class HeaderMode(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle) + } + data class FooterMode(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle) + } + data class TipHeaderLeft(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent) + } + data class TipHeaderMiddle(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent) + } + data class TipHeaderRight(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent) + } + data class TipFooterLeft(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent) + } + data class TipFooterMiddle(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent) + } + data class TipFooterRight(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent) + } + data class HeaderFontSize(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle) + } + data class TipHeaderColor(val color: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle) + } + data class TipFooterColor(val color: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle) + } + data class TipDividerColor(val color: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle) + } + + // --- Layout / style --- + data class StyleSelect(val index: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent) + } + data class ShareLayout(val value: Boolean) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent) + } + data class PageAnim(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateBackground) + } + + // --- Menu colors --- + data class MenuBgColor(val color: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent) + } + data class MenuAccentColor(val color: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent) + } + data class MenuContainerColor(val color: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent) + } + data class MenuBgColorNight(val color: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent) + } + data class MenuAccentColorNight(val color: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent) + } + data class MenuContainerColorNight(val color: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent) + } + data class MenuColorMode(val value: Int) : ConfigUpdate { + override val actions = emptySet() + } + data class ReadBarStyle(val value: Int) : ConfigUpdate { + override val actions = emptySet() + } + + // --- Menu bar border --- + data class BorderWidth(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent) + } + data class BorderColor(val color: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent) + } + data class BorderColorNight(val color: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent) + } + + // --- Shadow --- + data class TextShadow(val value: Boolean) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent) + } + data class ShadowRadius(val value: Float) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent) + } + data class ShadowDx(val value: Float) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent) + } + data class ShadowDy(val value: Float) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent) + } + data class ShadowColor(val color: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.InvalidateTextPage) + } + + // --- Underline --- + data class Underline(val value: Boolean) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateContent, ConfigUpdateAction.InvalidateTextPage, ConfigUpdateAction.SubmitRenderTask) + } + data class DottedLine(val value: Boolean) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateContent, ConfigUpdateAction.InvalidateTextPage, ConfigUpdateAction.SubmitRenderTask) + } + data class UnderlineExtend(val value: Boolean) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateContent, ConfigUpdateAction.InvalidateTextPage, ConfigUpdateAction.SubmitRenderTask) + } + data class UnderlineHeight(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.InvalidateTextPage, ConfigUpdateAction.UpdateContent) + } + data class UnderlinePadding(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.InvalidateTextPage, ConfigUpdateAction.UpdateContent) + } + data class DottedBase(val value: Float) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateContent, ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.UpdateLayout) + } + data class DottedRatio(val value: Float) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateContent, ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.UpdateLayout) + } + data class UnderlineColor(val color: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle) + } + + // --- Body padding --- + data class PaddingTop(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateLayout, ConfigUpdateAction.ReloadContent) + } + data class PaddingBottom(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateLayout, ConfigUpdateAction.ReloadContent) + } + data class PaddingLeft(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateLayout, ConfigUpdateAction.ReloadContent) + } + data class PaddingRight(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateLayout, ConfigUpdateAction.ReloadContent) + } + + // --- Header padding --- + data class HeaderPaddingTop(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle) + } + data class HeaderPaddingBottom(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle) + } + data class HeaderPaddingLeft(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle) + } + data class HeaderPaddingRight(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle) + } + data class ShowHeaderLine(val value: Boolean) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle) + } + + // --- Footer padding --- + data class FooterPaddingTop(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle) + } + data class FooterPaddingBottom(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle) + } + data class FooterPaddingLeft(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle) + } + data class FooterPaddingRight(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle) + } + data class ShowFooterLine(val value: Boolean) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle) + } + + // --- Background / display --- + data class BgStr(val value: String) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateBackgroundAlpha, ConfigUpdateAction.ReloadContent) + } + data class BgStrNight(val value: String) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateBackgroundAlpha, ConfigUpdateAction.ReloadContent) + } + data class BgStrEInk(val value: String) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateBackgroundAlpha, ConfigUpdateAction.ReloadContent) + } + data class BgType(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateBackgroundAlpha, ConfigUpdateAction.ReloadContent) + } + data class BgTypeNight(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateBackgroundAlpha, ConfigUpdateAction.ReloadContent) + } + data class BgTypeEInk(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateBackgroundAlpha, ConfigUpdateAction.ReloadContent) + } + data class BgAlpha(val value: Int) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateBackgroundAlpha) + } + data class StatusIconDark(val value: Boolean) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.ReloadContent) + } + data class MenuIconShowText(val value: Boolean) : ConfigUpdate { + override val actions = emptySet() + } + data class MenuIconStyle(val value: Int) : ConfigUpdate { + override val actions = emptySet() + } + data class MenuIconItemsPerRow(val value: Int) : ConfigUpdate { + override val actions = emptySet() + } + data class MenuIconRowCount(val value: Int) : ConfigUpdate { + override val actions = emptySet() + } + data class MenuBottomCornerRadius(val value: Int) : ConfigUpdate { + override val actions = emptySet() + } + data class FloatingBottomBar(val value: Boolean) : ConfigUpdate { + override val actions = emptySet() + } + data class MenuTopBarBlurMode(val value: Int) : ConfigUpdate { + override val actions = emptySet() + } + data class MenuBottomBarBlurMode(val value: Int) : ConfigUpdate { + override val actions = emptySet() + } + data class MenuTopBarLiquidGlassButtons(val value: Boolean) : ConfigUpdate { + override val actions = emptySet() + } + data class MenuBottomBarLiquidGlassButtons(val value: Boolean) : ConfigUpdate { + override val actions = emptySet() + } + data class MenuTopBarBlurSelection(val mode: Int, val style: Int) : ConfigUpdate { + override val actions = emptySet() + } + data class MenuBottomBarBlurStyle(val value: Int) : ConfigUpdate { + override val actions = emptySet() + } + data class MenuBlurRadius(val value: Int) : ConfigUpdate { + override val actions = emptySet() + } + data class MenuBlurAlpha(val value: Int) : ConfigUpdate { + override val actions = emptySet() + } + data class MenuLensRadius(val value: Float) : ConfigUpdate { + override val actions = emptySet() + } + data class MenuCustomIcon(val id: String, val path: String) : ConfigUpdate { + override val actions = emptySet() + } + data class TitleBarCustomIcon(val id: String, val path: String) : ConfigUpdate { + override val actions = emptySet() + } + data class TitleBarIconPosition(val value: Int) : ConfigUpdate { + override val actions = emptySet() + } + data class ShowTitleBarIcons(val value: Boolean) : ConfigUpdate { + override val actions = emptySet() + } + + // --- System UI (also updates AppConfig) --- + data class HideStatusBar(val value: Boolean) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateSystemUi, ConfigUpdateAction.UpdateStyle) + } + data class HideNavigationBar(val value: Boolean) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateSystemUi, ConfigUpdateAction.UpdateStyle) + } + + // --- Display toggles --- + data class PaddingDisplayCutouts(val value: Boolean) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateStyle) + } + data class TitleBarMode(val value: String) : ConfigUpdate { + override val actions = emptySet() + } + data class TextFullJustify(val value: Boolean) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.ReloadContent) + } + data class TextBottomJustify(val value: Boolean) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.ReloadContent) + } + data class AdaptSpecialStyle(val value: Boolean) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.ReloadContent) + } + data class UseZhLayout(val value: Boolean) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.ReloadContent) + } + data class ShowBrightnessView(val value: Boolean) : ConfigUpdate { + override val actions = emptySet() + } + data class UseUnderlineGlobal(val value: Boolean) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.ReloadContent) + } + data class ReadSliderMode(val value: String) : ConfigUpdate { + override val actions = emptySet() + } + data class DoubleHorizontalPage(val value: String) : ConfigUpdate { + override val actions = emptySet() + } + data class ProgressBarBehavior(val value: String) : ConfigUpdate { + override val actions = emptySet() + } + data class NoAnimScrollPage(val value: Boolean) : ConfigUpdate { + override val actions = emptySet() + } + data class ShowReadTitleAddition(val value: Boolean) : ConfigUpdate { + override val actions = emptySet() + } + + // --- Highlight rules --- + data class HighlightRules(val rules: List) : ConfigUpdate { + override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent) + } + + // --- Auto read --- + data class AutoReadSpeed(val value: Int) : ConfigUpdate { + override val actions = emptySet() + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt new file mode 100644 index 000000000..1b4635ba7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt @@ -0,0 +1,1049 @@ +package io.legado.app.ui.book.read + +import android.annotation.SuppressLint +import android.content.Intent +import android.content.pm.ActivityInfo +import android.os.Build +import android.view.Gravity +import android.view.HapticFeedbackConstants +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.WindowInsets +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.HapticFeedbackConstantsCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.doOnAttach +import androidx.core.view.updateLayoutParams +import androidx.lifecycle.lifecycleScope +import com.script.rhino.runScriptWithContext +import io.legado.app.R +import io.legado.app.constant.AppLog +import io.legado.app.constant.BookType +import io.legado.app.data.appDb +import io.legado.app.data.entities.BookProgress +import io.legado.app.help.TTS +import io.legado.app.help.config.AppConfig +import io.legado.app.help.config.ReadBookConfig +import io.legado.app.help.storage.Backup +import io.legado.app.lib.dialogs.SelectItem +import io.legado.app.model.CacheBook +import io.legado.app.model.ReadAloud +import io.legado.app.model.ReadBook +import io.legado.app.model.analyzeRule.AnalyzeRule +import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter +import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext +import io.legado.app.model.analyzeRule.AnalyzeUrl.Companion.paramPattern +import io.legado.app.receiver.NetworkChangedListener +import io.legado.app.receiver.TimeBatteryReceiver +import io.legado.app.service.BaseReadAloudService +import io.legado.app.ui.book.read.page.ContentTextView +import io.legado.app.ui.book.read.page.ReadView +import io.legado.app.ui.book.read.page.entities.PageDirection +import io.legado.app.ui.book.read.page.provider.ChapterProvider +import io.legado.app.ui.book.read.page.provider.TextPageFactory +import io.legado.app.ui.book.searchContent.SearchResult +import io.legado.app.ui.login.SourceLoginJsExtensions +import io.legado.app.ui.widget.PopupAction +import io.legado.app.utils.Debounce +import io.legado.app.utils.GSON +import io.legado.app.utils.buildMainHandler +import io.legado.app.utils.fromJsonObject +import io.legado.app.utils.invisible +import io.legado.app.utils.longToastOnUi +import io.legado.app.utils.navigationBarGravity +import io.legado.app.utils.setLightStatusBar +import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat +import io.legado.app.utils.sysScreenOffTime +import io.legado.app.utils.throttle +import io.legado.app.utils.toastOnUi +import io.legado.app.utils.visible +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.launch + +/** + * Encapsulates all the reader logic that used to be in ReadBookActivity. + * This allows ReadBookRouteScreen to be hosted in any Activity (ReadBookActivity or MainActivity). + */ +class ReadBookController( + val activity: AppCompatActivity, + val viewModel: ReadBookViewModel, +) : ReadBookRouteHost, + ReadBookInputHandler, + ReadView.CallBack, + ContentTextView.CallBack, + TextActionMenu.CallBack { + + var refs: ReadBookViewRefs? = null + + // Fallback handler for effects not yet migrated to controller + var onUnhandledEffect: (ReadBookEffect) -> Unit = {} + var onClose: (() -> Unit)? = null + + // Page state — moved from Activity + var pageChanged: Boolean = false + private set + + fun resetPageChanged() { + pageChanged = false + } + + // Callbacks to Activity for operations that require Activity-level state + var onScreenOffTimerStart: (() -> Unit)? = null + var onStartContentLoadFinish: (() -> Unit)? = null + + // Phase 4: callbacks for Activity-dependent effects + var onToggleReadAloud: (() -> Unit)? = null + var onToggleAutoPage: (() -> Unit)? = null + var onStopAutoPage: (() -> Unit)? = null + + private var tts: TTS? = null + private val timeBatteryReceiver = TimeBatteryReceiver() + private var timeBatteryReceiverRegistered = false + private val networkChangedListener by lazy { NetworkChangedListener(activity) } + private val handler by lazy { buildMainHandler() } + private val screenOffRunnable by lazy { Runnable { keepScreenOn(false) } } + private val textActionMenu by lazy { + TextActionMenu( + context = activity, + callBack = this, + expandTextMenu = { viewModel.readPreferences.value.expandTextMenu } + ) + } + private val popupAction by lazy { PopupAction(activity) } + private var screenTimeOut: Long = 0 + // justInitData moved to ViewModel (set on InitData intent) + + val isAutoPage: Boolean get() = refs?.readView?.isAutoPage == true + + private fun speak(text: String) { + if (tts == null) { + tts = TTS() + } + tts?.speak(text) + } + + fun clearTts() { + tts?.clearTts() + tts = null + textActionMenu.dismiss() + popupAction.dismiss() + refs?.readView?.onDestroy() + networkChangedListener.unRegister() + unregisterTimeBatteryReceiver() + } + + // Phase 5: Key handling / page turn + var bottomDialogCount: Int = 0 + + private val menuLayoutIsVisible: Boolean + get() = bottomDialogCount > 0 || + viewModel.uiState.value.menuVisible || + viewModel.uiState.value.searchMenuVisible + + private val nextPageDebounce by lazy { Debounce { keyPage(PageDirection.NEXT) } } + private val prevPageDebounce by lazy { Debounce { keyPage(PageDirection.PREV) } } + + private val upSeekBarThrottle = throttle(200) { + onUnhandledEffect(ReadBookEffect.UpSeekBar) + } + + fun onRefsReady(newRefs: ReadBookViewRefs) { + if (refs === newRefs) return + refs = newRefs + newRefs.readView.autoPager.onStop = { + viewModel.setAutoPage(false) + } + newRefs.navigationBar.doOnAttach { + newRefs.navigationBar.setOnApplyWindowInsetsListenerCompat { view, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.updateLayoutParams { + height = insets.bottom + } + windowInsets + } + } + newRefs.readView.upTime() + } + + fun onRouteInitialized() { + upScreenTimeOut() + } + + /** + * View/Window-only resume — business logic handled by ViewModel via OnResume intent. + */ + fun onResume() { + upSystemUiVisibility() + refs?.readView?.upTime() + screenOffTimerStart() + } + + /** + * View/Window-only pause — business logic handled by ViewModel via OnPause intent. + */ + fun onPause() { + upSystemUiVisibility() + } + + override val isInMultiWindowModeCompat: Boolean + get() = activity.isInMultiWindowMode + + override fun closeReadBook() { + onClose?.invoke() ?: activity.finish() + } + + @SuppressLint("WrongConstant") + override fun upSystemUiVisibility(isInMultiWindow: Boolean, toolBarHide: Boolean) { + val window = activity.window + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.insetsController?.run { + if (toolBarHide && ReadBookConfig.hideNavigationBar) { + hide(WindowInsets.Type.navigationBars()) + } else { + show(WindowInsets.Type.navigationBars()) + } + if (toolBarHide && ReadBookConfig.hideStatusBar) { + hide(WindowInsets.Type.statusBars()) + } else { + show(WindowInsets.Type.statusBars()) + } + } + } + + // Legacy flags + var flag = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_IMMERSIVE + or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY) + if (!isInMultiWindow) { + flag = flag or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + } + if (ReadBookConfig.hideNavigationBar) { + flag = flag or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + if (toolBarHide) { + flag = flag or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + } + } + if (ReadBookConfig.hideStatusBar && toolBarHide) { + flag = flag or View.SYSTEM_UI_FLAG_FULLSCREEN + } + window.decorView.systemUiVisibility = flag + + if (toolBarHide) { + activity.setLightStatusBar(ReadBookConfig.durConfig.curStatusIconDark()) + } + } + + // ── ReadView.CallBack ───────────────────────────────────────────── + + override val isInitFinish: Boolean get() = viewModel.uiState.value.isInitFinish + + override fun showActionMenu() { + val state = viewModel.uiState.value + when { + BaseReadAloudService.isRun -> viewModel.onIntent( + ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.ReadAloud) + ) + + isAutoPage -> viewModel.onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.AutoRead)) + state.isShowingSearchResult -> viewModel.onIntent(ReadBookIntent.ShowSearchMenu) + else -> viewModel.onIntent(ReadBookIntent.ShowMenu) + } + } + + override fun screenOffTimerStart() { + onScreenOffTimerStart?.invoke() ?: screenOffTimerStartInternal() + } + + override fun showTextActionMenu() { + val r = refs ?: return + val navigationBarHeight = + if (!ReadBookConfig.hideNavigationBar && activity.navigationBarGravity == Gravity.BOTTOM) { + r.navigationBar.height + } else { + 0 + } + textActionMenu.upMenu() + textActionMenu.show( + r.textMenuPosition, + r.root.height + navigationBarHeight, + r.textMenuPosition.x.toInt(), + r.textMenuPosition.y.toInt(), + r.cursorLeft.y.toInt() + r.cursorLeft.height, + r.cursorRight.x.toInt(), + r.cursorRight.y.toInt() + r.cursorRight.height + ) + } + + override fun autoPageStop() { + viewModel.onIntent(ReadBookIntent.StopAutoPage) + } + + override fun openChapterList() { + viewModel.onIntent(ReadBookIntent.OpenChapterList) + } + + override fun openContentEdit() { + viewModel.onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ContentEdit)) + } + + override fun addBookmark() { + val book = ReadBook.book + val page = ReadBook.curTextChapter?.getPage(ReadBook.durPageIndex) + if (book != null && page != null) { + val bookmark = book.createBookMark().apply { + chapterIndex = ReadBook.durChapterIndex + chapterPos = ReadBook.durChapterPos + chapterName = page.title + bookText = page.text.replace(Regex("[袮꧁]"), "").trim() + } + viewModel.onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.Bookmark(bookmark))) + } + } + + override fun changeReplaceRuleState() { + viewModel.onIntent(ReadBookIntent.MenuEnableReplace) + } + + override fun openSearchActivity(searchWord: String?) { + viewModel.onIntent(ReadBookIntent.OpenSearch(searchWord)) + } + + override fun upSystemUiVisibility() { + val state = viewModel.uiState.value + upSystemUiVisibility(isInMultiWindowModeCompat, !state.menuVisible) + } + + override fun sureNewProgress(progress: BookProgress) { + viewModel.onIntent(ReadBookIntent.SureNewProgress(progress)) + } + + // ── ContentTextView.CallBack ────────────────────────────────────── + + override val headerHeight: Int get() = refs?.readView?.curPage?.headerHeight ?: 0 + override val imgBgPaddingStart: Int get() = refs?.readView?.curPage?.imgBgPaddingStart ?: 0 + override val pageFactory: TextPageFactory + get() = refs?.readView?.pageFactory ?: error("ReadView not ready") + override val pageDelegate get() = refs?.readView?.pageDelegate + override val isScroll: Boolean get() = refs?.readView?.isScroll ?: false + override var isSelectingSearchResult = false + override fun upSelectedStart(x: Float, y: Float, top: Float) { + val r = refs ?: return + r.cursorLeft.x = x - r.cursorLeft.width + r.cursorLeft.y = y + r.cursorLeft.visible(true) + r.textMenuPosition.x = x + r.textMenuPosition.y = top + + if (AppConfig.selectVibrator) { + r.root.performHapticFeedback(HapticFeedbackConstantsCompat.TEXT_HANDLE_MOVE) + } + } + + override fun upSelectedEnd(x: Float, y: Float) { + val r = refs ?: return + r.cursorRight.x = x + r.cursorRight.y = y + r.cursorRight.visible(true) + if (AppConfig.selectVibrator) { + r.root.performHapticFeedback(HapticFeedbackConstantsCompat.TEXT_HANDLE_MOVE) + } + } + + override fun onImageLongPress(x: Float, y: Float, src: String) { + val r = refs ?: return + r.root.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + popupAction.setItems( + listOf( + SelectItem(activity.getString(R.string.show), "show"), + SelectItem(activity.getString(R.string.refresh), "refresh"), + SelectItem("保存到相册", "save"), + SelectItem(activity.getString(R.string.menu), "menu"), + ) + ) + popupAction.onActionClick = { + when (it) { + "show" -> viewModel.onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.Photo(src))) + "refresh" -> viewModel.refreshImage(src) + "save" -> viewModel.saveImage(src) + "menu" -> showActionMenu() + } + popupAction.dismiss() + } + val navigationBarHeight = + if (!ReadBookConfig.hideNavigationBar && activity.navigationBarGravity == Gravity.BOTTOM) { + r.navigationBar.height + } else { + 0 + } + popupAction.showAtLocation( + r.readView, + Gravity.BOTTOM or Gravity.LEFT, + x.toInt(), + r.root.height + navigationBarHeight - y.toInt() + ) + } + + override fun onCancelSelect() { + refs?.cursorLeft?.invisible() + refs?.cursorRight?.invisible() + textActionMenu.dismiss() + } + + override fun onLongScreenshotTouchEvent(event: MotionEvent): Boolean = + refs?.readView?.onTouchEvent(event) ?: false + + override fun oldClickImg(src: String): Boolean { + val urlMatcher = paramPattern.matcher(src) + if (urlMatcher.find()) { + val urlOptionStr = src.substring(urlMatcher.end()) + val urlOptionMap = GSON.fromJsonObject>(urlOptionStr).getOrNull() + val click = urlOptionMap?.get("click") + if (click != null) { + activity.lifecycleScope.launch(IO) { + try { + val source = ReadBook.bookSource ?: return@launch + val java = SourceLoginJsExtensions(activity, source, BookType.text) + val book = ReadBook.book ?: return@launch + val chapter = appDb.bookChapterDao.getChapter( + book.bookUrl, + ReadBook.durChapterIndex + ) ?: throw Exception("no find chapter") + runScriptWithContext { + source.evalJS(click) { + put("java", java) + put("book", book) + put("chapter", chapter) + put("result", src) + } + } + } catch (e: Throwable) { + AppLog.put("执行图片链接click键值出错\n${e.localizedMessage}", e, true) + } + } + return true + } + val jsStr = urlOptionMap?.get("js") ?: return false + activity.lifecycleScope.launch(IO) { + try { + val source = ReadBook.bookSource ?: return@launch + val book = ReadBook.book ?: return@launch + val chapter = appDb.bookChapterDao.getChapter( + book.bookUrl, + ReadBook.durChapterIndex + ) ?: throw Exception("no find chapter") + val urlNoOption = src.take(urlMatcher.start()) + AnalyzeRule(book, source).apply { + setCoroutineContext(coroutineContext) + setBaseUrl(chapter.url) + setChapter(chapter) + evalJS(jsStr, urlNoOption) + } + } catch (e: Throwable) { + AppLog.put("执行图片链接js键值出错\n${e.localizedMessage}", e, true) + } + } + return true + } + return false + } + + override fun clickImg(click: String, src: String) { + activity.lifecycleScope.launch(IO) { + try { + val source = ReadBook.bookSource ?: return@launch + val java = SourceLoginJsExtensions(activity, source, BookType.text) + val book = ReadBook.book ?: return@launch + val chapter = + appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) + ?: throw Exception("no find chapter") + runScriptWithContext { + source.evalJS(click) { + put("java", java) + put("book", book) + put("chapter", chapter) + put("result", src) + } + } + } catch (e: Throwable) { + AppLog.put("执行图片链接click键值出错\n${e.localizedMessage}", e, true) + } + } + } + + override fun onTouch(v: View?, event: MotionEvent?): Boolean { + val r = refs ?: return false + if (v == null || event == null || !r.readView.isTextSelected) { + return false + } + when (event.action) { + MotionEvent.ACTION_DOWN -> textActionMenu.dismiss() + MotionEvent.ACTION_MOVE -> { + when (v.id) { + R.id.cursor_left -> if (!r.readView.curPage.getReverseStartCursor()) { + r.readView.curPage.selectStartMove( + event.rawX + r.cursorLeft.width, + event.rawY - r.cursorLeft.height + ) + } else { + r.readView.curPage.selectEndMove( + event.rawX - r.cursorRight.width, + event.rawY - r.cursorRight.height + ) + } + + R.id.cursor_right -> if (r.readView.curPage.getReverseEndCursor()) { + r.readView.curPage.selectStartMove( + event.rawX + r.cursorLeft.width, + event.rawY - r.cursorLeft.height + ) + } else { + r.readView.curPage.selectEndMove( + event.rawX - r.cursorRight.width, + event.rawY - r.cursorRight.height + ) + } + } + } + + MotionEvent.ACTION_UP -> { + r.readView.curPage.resetReverseCursor() + showTextActionMenu() + } + } + return true + } + + override val selectedText: String get() = refs?.readView?.getSelectText().orEmpty() + + override fun onMenuItemSelected(itemId: Int): Boolean { + when (itemId) { + R.id.menu_aloud -> { + viewModel.onIntent(ReadBookIntent.TextActionAloud(selectedText)) + return true + } + + R.id.menu_bookmark -> { + viewModel.onIntent(ReadBookIntent.TextActionBookmark(selectedText)) + return true + } + + R.id.menu_edit -> { + viewModel.onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ContentEdit)) + return true + } + + R.id.menu_replace -> { + viewModel.onIntent(ReadBookIntent.TextActionReplace(selectedText)) + return true + } + + R.id.menu_search_content -> { + viewModel.onIntent(ReadBookIntent.TextActionSearchContent(selectedText)) + return true + } + + R.id.menu_dict -> { + viewModel.onIntent(ReadBookIntent.TextActionDict(selectedText)) + return true + } + } + return false + } + + override fun onMenuActionFinally() { + textActionMenu.dismiss() + refs?.readView?.cancelSelect() + } + + // ── Effect handling ─────────────────────────────────────────────── + + /** + * Handles View-layer and Activity-API effects. + * Launcher-dependent effects are handled by the route layer. + */ + fun handleEffect(effect: ReadBookEffect) { + when (effect) { + // ── Already migrated (View-layer) ── + is ReadBookEffect.Finish -> closeReadBook() + is ReadBookEffect.Recreate -> activity.recreate() + is ReadBookEffect.UpdateReadViewConfig -> { + val r = refs ?: return + effect.actions.forEach { action -> + when (action) { + ConfigUpdateAction.UpdateSystemUi -> upSystemUiVisibility() + ConfigUpdateAction.UpdateBackground -> r.readView.upBg() + ConfigUpdateAction.UpdateStyle -> r.readView.upStyle() + ConfigUpdateAction.UpdateBackgroundAlpha -> r.readView.upBgAlpha() + ConfigUpdateAction.UpdatePageSlopSquare -> r.readView.upPageSlopSquare() + ConfigUpdateAction.ReloadContent -> if (viewModel.isInitFinish) ReadBook.loadContent(resetPageOffset = false) + ConfigUpdateAction.UpdateContent -> r.readView.upContent(resetPageOffset = false) + ConfigUpdateAction.UpdateChapterStyle -> ChapterProvider.upStyle() + ConfigUpdateAction.InvalidateTextPage -> r.readView.invalidateTextPage() + ConfigUpdateAction.UpdateLayout -> ChapterProvider.upLayout() + ConfigUpdateAction.SubmitRenderTask -> r.readView.submitRenderTask() + } + } + } + + is ReadBookEffect.UpContent -> { + refs?.readView?.upContent(effect.relativePosition, effect.resetPageOffset) + if (effect.relativePosition == 0) onUnhandledEffect(ReadBookEffect.UpSeekBar) + } + + is ReadBookEffect.UpPageAnim -> refs?.readView?.upPageAnim(effect.upRecorder) + is ReadBookEffect.UpTime -> refs?.readView?.upTime() + is ReadBookEffect.UpBattery -> refs?.readView?.upBattery(effect.level) + is ReadBookEffect.UpSystemUiVisibility -> upSystemUiVisibility() + is ReadBookEffect.PageAnimChanged -> { + refs?.readView?.upPageAnim() + ReadBook.loadContent(false) + } + + is ReadBookEffect.CancelSelect -> refs?.readView?.cancelSelect() + is ReadBookEffect.MenuImageStyleChanged -> refs?.readView?.upPageAnim() + + // ── Simple Activity-API effects ── + is ReadBookEffect.ShowToast -> activity.toastOnUi(effect.message) + is ReadBookEffect.LongToast -> activity.longToastOnUi(effect.message) + is ReadBookEffect.SetBrightness -> { + val lp = activity.window.attributes + lp.screenBrightness = effect.value / 255f + activity.window.attributes = lp + } + + // ── Launcher-dependent effects — now handled by route layer ── + + // ── DB query + bookmark effects — now handled by ViewModel ── + + // ── Phase 2: ViewRefs-only effects ── + is ReadBookEffect.UpSeekBar -> { /* no-op: Compose menu reads from state */ + } + + is ReadBookEffect.UpMenuView -> { /* no-op: Compose menu reads from state */ + } + + is ReadBookEffect.UpTextSelectAble -> { + refs?.readView?.curPage?.upSelectAble(effect.enabled) + } + + is ReadBookEffect.UpAloudState -> { + ReadBook.curTextChapter?.let { textChapter -> + val page = textChapter.getPageByReadPos(ReadBook.durChapterPos) + page?.removePageAloudSpan() + refs?.readView?.upContent(resetPageOffset = false) + } + } + + is ReadBookEffect.UpTtsAloudSpan -> { + activity.lifecycleScope.launch(IO) { + if (BaseReadAloudService.isPlay()) { + ReadBook.curTextChapter?.let { textChapter -> + val pageIndex = ReadBook.durPageIndex + val aloudSpanStart = + effect.chapterStart - textChapter.getReadLength(pageIndex) + textChapter.getPage(pageIndex)?.upPageAloudSpan(aloudSpanStart) + refs?.readView?.upContent() + } + } + } + } + + is ReadBookEffect.RefreshBookContent -> { + ReadBook.curTextChapter = null + refs?.readView?.upContent() + ReadBook.book?.let { viewModel.refreshContentDur(it) } + } + + is ReadBookEffect.PageChanged -> { + pageChanged = true + refs?.readView?.onPageChange() + viewModel.startBackupJob() + } + + is ReadBookEffect.LayoutPageCompleted -> { + upSeekBarThrottle.invoke() + refs?.readView?.onLayoutPageCompleted(effect.index, effect.page) + } + + is ReadBookEffect.ContentLoadFinish -> { + onStartContentLoadFinish?.invoke() + } + + is ReadBookEffect.UpScreenTimeOut -> { + screenOffTimerStart() + } + + is ReadBookEffect.ToggleBrightnessAuto -> { /* TODO */ + } + + // ── Phase 4: Activity-dependent effects ── + is ReadBookEffect.ToggleReadAloud -> onToggleReadAloud?.invoke() ?: toggleReadAloud() + is ReadBookEffect.ToggleAutoPage -> onToggleAutoPage?.invoke() ?: toggleAutoPage() + is ReadBookEffect.StopAutoPage -> onStopAutoPage?.invoke() ?: stopAutoPage() + is ReadBookEffect.TextActionAloudSelect -> { + activity.lifecycleScope.launch { refs?.readView?.aloudStartSelect() } + } + + is ReadBookEffect.TextActionSpeak -> speak(effect.text) + is ReadBookEffect.NavigateToSearchResult -> { + // Navigate handled by ReadView — no external callback needed + } + + is ReadBookEffect.ExitSearch -> { + if (viewModel.uiState.value.isShowingSearchResult) { + viewModel.onIntent(ReadBookIntent.SetShowingSearchResult(false)) + ReadBook.clearSearchResult() + refs?.readView?.cancelSelect(true) + } + } + + is ReadBookEffect.SyncBookProgress -> { + viewModel.onIntent(ReadBookIntent.ShowDialog( + ReadBookDialog.SureSyncProgress(BookProgress(effect.book)) + )) + } + + is ReadBookEffect.ShowConfirmSkipToChapter -> { + viewModel.onIntent(ReadBookIntent.ShowDialog(ReadBookDialog.ConfirmSkipToChapter)) + } + is ReadBookEffect.ToggleDayNight -> { + // Handled directly by ViewModel — effect not currently emitted + } + is ReadBookEffect.DownloadChapters -> { + ReadBook.book?.let { book -> + activity.lifecycleScope.launch { + CacheBook.start(activity, book, effect.start, effect.end) + } + } + } + + // ── Lifecycle — route/bridge Activity operations ── + is ReadBookEffect.RegisterTimeBatteryReceiver -> { + registerTimeBatteryReceiver() + } + + is ReadBookEffect.UnregisterTimeBatteryReceiver -> { + unregisterTimeBatteryReceiver() + } + + is ReadBookEffect.RegisterNetworkListener -> { + networkChangedListener.register() + networkChangedListener.onNetworkChanged = { + viewModel.onNetworkChanged() + } + } + + is ReadBookEffect.UnregisterNetworkListener -> { + networkChangedListener.unRegister() + } + + is ReadBookEffect.BackupNow -> { + Backup.autoBack(activity) + } + + // Launcher-dependent effects — handled by route layer, ignored here + is ReadBookEffect.OpenChapterList, + is ReadBookEffect.OpenSourceEdit, + is ReadBookEffect.OpenBookInfo, + is ReadBookEffect.OpenSearchActivity, + is ReadBookEffect.ShowLogin, + is ReadBookEffect.OpenWebView, + is ReadBookEffect.MenuSettingReplace, + is ReadBookEffect.TextActionReplace, + is ReadBookEffect.OpenReplaceEditor, + is ReadBookEffect.MenuTocRegex, + is ReadBookEffect.OpenFontFolderPicker, + is ReadBookEffect.OpenBooksDirPicker, + is ReadBookEffect.OpenReadStyleImagePicker, + is ReadBookEffect.OpenReadStyleImagePickerForMode, + is ReadBookEffect.OpenReadStyleImport, + is ReadBookEffect.OpenReadStyleExport, + is ReadBookEffect.OpenMenuCustomIconPicker, + is ReadBookEffect.OpenTitleBarCustomIconPicker, + is ReadBookEffect.OpenSystemTtsSettings, + is ReadBookEffect.TtsCacheCleared, + // DB query + bookmark effects — handled by ViewModel, ignored here + is ReadBookEffect.MenuChangeSource, + is ReadBookEffect.MenuBookChangeSource, + is ReadBookEffect.MenuChapterChangeSource, + is ReadBookEffect.AddBookmark -> { + // Handled by route/ViewModel — no-op here + } + } + } + + // ── Key handling ── + + private fun toggleReadAloud() { + viewModel.onIntent(ReadBookIntent.StopAutoPage) + when { + !BaseReadAloudService.isRun -> { + ReadAloud.upReadAloudClass() + val scrollPageAnim = ReadBook.pageAnim() == 3 + val readView = refs?.readView + if (scrollPageAnim && readView != null) { + val pos = readView.getReadAloudPos() + if (pos != null) { + val (index, line) = pos + if (ReadBook.durChapterIndex != index) { + ReadBook.openChapter(index, line.chapterPosition, false) { + ReadBook.readAloud(startPos = line.pagePosition) + } + } else { + ReadBook.durChapterPos = line.chapterPosition + ReadBook.readAloud(startPos = line.pagePosition) + } + } else { + ReadBook.readAloud() + } + } else { + ReadBook.readAloud() + } + } + + BaseReadAloudService.pause -> { + val scrollPageAnim = ReadBook.pageAnim() == 3 + val readView = refs?.readView + if (scrollPageAnim && pageChanged && readView != null) { + pageChanged = false + val pos = readView.getReadAloudPos() + if (pos != null) { + val (index, line) = pos + if (ReadBook.durChapterIndex != index) { + ReadBook.openChapter(index, line.chapterPosition, false) { + ReadBook.readAloud(startPos = line.pagePosition) + } + } else { + ReadBook.durChapterPos = line.chapterPosition + ReadBook.readAloud(startPos = line.pagePosition) + } + } else { + ReadBook.readAloud() + } + } else { + ReadAloud.resume(activity) + } + } + + else -> ReadAloud.pause(activity) + } + } + + private fun toggleAutoPage() { + ReadAloud.stop(activity) + if (isAutoPage) { + stopAutoPage() + } else { + refs?.readView?.autoPager?.start() + viewModel.setAutoPage(true) + onScreenOffTimerStart?.invoke() + } + } + + private fun stopAutoPage() { + if (isAutoPage) { + refs?.readView?.autoPager?.stop() + viewModel.setAutoPage(false) + viewModel.onIntent(ReadBookIntent.DismissSheet) + onScreenOffTimerStart?.invoke() + } + } + + override fun toggleMenu() { + viewModel.onIntent(ReadBookIntent.ToggleMenu) + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (menuLayoutIsVisible) { + return false + } + val longPress = event.repeatCount > 0 + when { + isPrevKey(keyCode) -> { + handleKeyPage(PageDirection.PREV, longPress) + return true + } + + isNextKey(keyCode) -> { + handleKeyPage(PageDirection.NEXT, longPress) + return true + } + } + when (keyCode) { + KeyEvent.KEYCODE_VOLUME_UP -> if (volumeKeyPage(PageDirection.PREV, longPress)) { + return true + } + + KeyEvent.KEYCODE_VOLUME_DOWN -> if (volumeKeyPage(PageDirection.NEXT, longPress)) { + return true + } + + KeyEvent.KEYCODE_PAGE_UP -> { + handleKeyPage(PageDirection.PREV, longPress) + return true + } + + KeyEvent.KEYCODE_PAGE_DOWN -> { + handleKeyPage(PageDirection.NEXT, longPress) + return true + } + + KeyEvent.KEYCODE_SPACE -> { + handleKeyPage(PageDirection.NEXT, longPress) + return true + } + + KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_LEFT -> { + handleKeyPage(PageDirection.PREV, longPress) + return true + } + + KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT -> { + handleKeyPage(PageDirection.NEXT, longPress) + return true + } + } + return false + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { + when (keyCode) { + KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN -> { + if (volumeKeyPage(PageDirection.NONE, false)) { + return true + } + } + } + return false + } + + override fun mouseWheelPage(direction: PageDirection) { + if (menuLayoutIsVisible || !AppConfig.mouseWheelPage) { + return + } + keyPageDebounce(direction, mouseWheel = true, longPress = false) + } + + private fun volumeKeyPage(direction: PageDirection, longPress: Boolean): Boolean { + if (!AppConfig.volumeKeyPage) { + return false + } + if (!AppConfig.volumeKeyPageOnPlay && BaseReadAloudService.isPlay()) { + return false + } + handleKeyPage(direction, longPress) + return true + } + + override fun handleKeyPage(direction: PageDirection, longPress: Boolean) { + if (AppConfig.keyPageOnLongPress || direction == PageDirection.NONE) { + keyPage(direction) + } else { + keyPageDebounce(direction, longPress = longPress) + } + } + + private fun keyPageDebounce( + direction: PageDirection, + mouseWheel: Boolean = false, + longPress: Boolean + ) { + if (longPress) { + return + } + nextPageDebounce.apply { + wait = if (mouseWheel) 200L else 600L + leading = !mouseWheel + trailing = mouseWheel + } + prevPageDebounce.apply { + wait = if (mouseWheel) 200L else 600L + leading = !mouseWheel + trailing = mouseWheel + } + when (direction) { + PageDirection.NEXT -> nextPageDebounce.invoke() + PageDirection.PREV -> prevPageDebounce.invoke() + else -> {} + } + } + + private fun keyPage(direction: PageDirection) { + refs?.readView?.cancelSelect() + refs?.readView?.pageDelegate?.isCancel = false + refs?.readView?.pageDelegate?.keyTurnPage(direction) + } + + private fun upScreenTimeOut() { + val keepLightPrefer = viewModel.readPreferences.value.keepLight.toIntOrNull() ?: 0 + screenTimeOut = keepLightPrefer * 1000L + screenOffTimerStartInternal() + } + + private fun screenOffTimerStartInternal() { + handler.post { + if (screenTimeOut < 0) { + keepScreenOn(true) + return@post + } + val t = screenTimeOut - activity.sysScreenOffTime + if (t > 0) { + keepScreenOn(true) + handler.removeCallbacks(screenOffRunnable) + handler.postDelayed(screenOffRunnable, screenTimeOut) + } else { + keepScreenOn(false) + } + } + } + + private fun keepScreenOn(on: Boolean) { + val isScreenOn = + (activity.window.attributes.flags and android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) != 0 + if (on == isScreenOn) return + if (on) { + activity.window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + activity.window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + + private fun registerTimeBatteryReceiver() { + if (timeBatteryReceiverRegistered) return + activity.registerReceiver(timeBatteryReceiver, timeBatteryReceiver.filter) + timeBatteryReceiverRegistered = true + } + + private fun unregisterTimeBatteryReceiver() { + if (!timeBatteryReceiverRegistered) return + activity.unregisterReceiver(timeBatteryReceiver) + timeBatteryReceiverRegistered = false + } + + private fun isPrevKey(keyCode: Int): Boolean { + if (keyCode == KeyEvent.KEYCODE_UNKNOWN) { + return false + } + val prevKeysStr = viewModel.readPreferences.value.prevKeys + return prevKeysStr.split(",").contains(keyCode.toString()) + } + + private fun isNextKey(keyCode: Int): Boolean { + if (keyCode == KeyEvent.KEYCODE_UNKNOWN) { + return false + } + val nextKeysStr = viewModel.readPreferences.value.nextKeys + return nextKeysStr.split(",").contains(keyCode.toString()) + } + + fun setOrientation() { + when (AppConfig.screenOrientation) { + "0" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + "1" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + "2" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + "3" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR + "4" -> activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt new file mode 100644 index 000000000..427f55a07 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt @@ -0,0 +1,2310 @@ +package io.legado.app.ui.book.read + +import android.content.Context +import android.os.Build +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.SwapHoriz +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp +import coil.compose.AsyncImage +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberBackdrop +import com.kyant.backdrop.backdrops.rememberCombinedBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.kyant.backdrop.highlight.Highlight +import com.kyant.backdrop.shadow.InnerShadow +import com.kyant.backdrop.shadow.Shadow +import com.kyant.capsule.ContinuousCapsule +import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import io.legado.app.R +import io.legado.app.constant.ReadMenuBlurMode +import io.legado.app.constant.ReadMenuBlurStyle +import io.legado.app.data.entities.Book +import io.legado.app.help.config.AppConfig +import io.legado.app.help.config.ReadStyleResolver +import io.legado.app.ui.animation.DampedDragAnimation +import io.legado.app.ui.animation.InteractiveHighlight +import io.legado.app.ui.book.read.sheet.AutoReadContent +import io.legado.app.ui.book.read.sheet.PaddingConfigContent +import io.legado.app.ui.book.read.sheet.ReadAloudContent +import io.legado.app.ui.book.read.sheet.ReadMenuButtonInfo +import io.legado.app.ui.book.read.sheet.ReadStyleContent +import io.legado.app.ui.book.read.sheet.ReadStyleTextTitleContent +import io.legado.app.ui.book.read.sheet.readMenuButtonInfos +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.hazeStyle.HazeLegado +import io.legado.app.ui.widget.components.AppSlider +import io.legado.app.ui.widget.components.bookmark.BookmarkEditContent +import io.legado.app.ui.widget.components.button.series.SmallTonalButton +import io.legado.app.ui.widget.components.divider.PillDivider +import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu +import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem +import io.legado.app.ui.widget.components.text.AppText +import kotlinx.coroutines.flow.collectLatest +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.ceil +import kotlin.math.cos +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.math.tanh + +/** + * Compose replacement for ReadMenu — main reading menu overlay. + */ +@Composable +fun ReadBookMenuBar( + state: ReadBookUiState, + onIntent: (ReadBookIntent) -> Unit, + backdrop: Backdrop? = null, + hazeState: HazeState? = null, +) { + val context = LocalContext.current + val currentRoute = state.menuState.currentRoute + val dialogLikeRoute = currentRoute == ReadBookMenuRoute.PaddingConfig + val menuColors = readMenuColors() + + Box(Modifier.fillMaxSize()) { + AnimatedVisibility( + visible = state.menuVisible, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { onIntent(ReadBookIntent.HideMenu) } + ) + } + + // Top title bar + floating icon row (top positions) + AnimatedVisibility( + visible = state.menuVisible && !dialogLikeRoute, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), + modifier = Modifier.align(Alignment.TopCenter), + ) { + Column { + MenuTitleBar( + state = state, + colors = menuColors, + onIntent = onIntent, + backdrop = backdrop, + hazeState = hazeState, + ) + if (state.menuConfig.showTitleBarIcons && state.menuConfig.titleBarIconPosition <= 1) { + FloatingIconRow( + state = state, + colors = menuColors, + alignment = if (state.menuConfig.titleBarIconPosition == 0) { + Alignment.Start + } else { + Alignment.End + }, + onIntent = onIntent, + backdrop = backdrop, + ) + } + } + } + + // Bottom menu + floating icon row (bottom positions) + AnimatedVisibility( + visible = state.menuVisible, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (state.menuConfig.showTitleBarIcons && state.menuConfig.titleBarIconPosition >= 2) { + FloatingIconRow( + state = state, + colors = menuColors, + alignment = if (state.menuConfig.titleBarIconPosition == 2) { + Alignment.Start + } else { + Alignment.End + }, + onIntent = onIntent, + backdrop = backdrop, + ) + } + ReadBookMenuSurface( + route = currentRoute, + state = state, + colors = menuColors, + onIntent = onIntent, + context = context, + backdrop = backdrop, + hazeState = hazeState, + ) + } + } + } +} + +@Composable +private fun ReadBookMenuSurface( + route: ReadBookMenuRoute, + state: ReadBookUiState, + colors: ReadMenuColors, + onIntent: (ReadBookIntent) -> Unit, + context: Context, + backdrop: Backdrop?, + hazeState: HazeState?, +) { + val expanded = route != ReadBookMenuRoute.Main + val dialogLikeRoute = route == ReadBookMenuRoute.PaddingConfig + val density = LocalDensity.current + val windowSize = LocalWindowInfo.current.containerSize + var surfaceHeightPx by remember { mutableIntStateOf(0) } + val morphProgress by animateFloatAsState( + targetValue = if (dialogLikeRoute) 1f else 0f, + label = "ReadBookMenuMorph", + ) + val maxHeight = with(density) { + windowSize.height.toDp() * 0.64f + } + val screenWidth = with(density) { windowSize.width.toDp() } + val dialogAvailableWidth = screenWidth - 48.dp + val dialogWidth = if (dialogAvailableWidth < 560.dp) { + dialogAvailableWidth + } else { + 560.dp + } + val isFloating = state.menuConfig.readMenuFloatingBottomBar + val navBarHeight = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() } + val floatingHorizontalMargin = if (isFloating) 16.dp else 0.dp + val floatingBottomMargin = if (isFloating) 16.dp + navBarHeight else 0.dp + val mainHorizontalMargin = + if (expanded && !isFloating) 0.dp else floatingHorizontalMargin + val mainBottomMargin = + if (expanded && !isFloating) 0.dp else floatingBottomMargin + val mainCorner = state.menuConfig.readMenuBottomCornerRadius.dp + val mainWidth = (screenWidth - mainHorizontalMargin * 2).coerceAtLeast(0.dp) + val surfaceWidth = if (expanded) { + if (isFloating && !dialogLikeRoute) mainWidth + else lerp(screenWidth, dialogWidth, morphProgress) + } else { + mainWidth + } + val bottomTopCorner by animateDpAsState( + targetValue = if (expanded && !isFloating) 24.dp else 0.dp, + label = "ReadBookMenuCorner", + ) + val corner = lerp(bottomTopCorner, 28.dp, morphProgress) + val bottomCorner = lerp(0.dp, 28.dp, morphProgress) + val surfaceShape = if (expanded) { + if (isFloating && !dialogLikeRoute) { + RoundedCornerShape(mainCorner) + } else { + RoundedCornerShape( + topStart = corner, + topEnd = corner, + bottomStart = bottomCorner, + bottomEnd = bottomCorner, + ) + } + } else if (isFloating) { + RoundedCornerShape(mainCorner) + } else { + RoundedCornerShape(topStart = mainCorner, topEnd = mainCorner) + } + + val bottomBarBorderWidth = state.menuConfig.readMenuBorderWidth + val bottomBarBorderColor = (if (ReadStyleResolver.isNightTheme()) { + state.menuConfig.readMenuBorderColorNight + } else { + state.menuConfig.readMenuBorderColor + }).takeIf { it != 0 } + ?: LegadoTheme.colorScheme.outlineVariant.hashCode() + val extendSurfaceToNavigationBar = !isFloating && !dialogLikeRoute + val useLiquidGlass = readMenuBottomBarLiquidGlassEnabled( + backdrop = backdrop, + menuConfig = state.menuConfig, + isFloating = isFloating, + ) + val useHaze = readMenuBottomBarHazeEnabled( + hazeState = hazeState, + menuConfig = state.menuConfig, + isFloating = isFloating, + ) + val useBottomBarButtonGlass = readMenuBottomBarButtonLiquidGlassEnabled( + backdrop = backdrop, + menuConfig = state.menuConfig, + ) + val useLens = useLiquidGlass && isFloating && mainCorner > 0.dp + val bottomBarProgressiveBlur = route == ReadBookMenuRoute.Main && + !isFloating && + state.menuConfig.readMenuBottomBarBlurStyle == ReadMenuBlurStyle.Progressive + val bottomBarTextColor = if (bottomBarProgressiveBlur) { + Color.White.copy(alpha = 0.87f).compositeOver(colors.background) + } else { + LegadoTheme.colorScheme.onSurface + } + val surfaceWindowInsetSides = when { + isFloating || extendSurfaceToNavigationBar -> WindowInsetsSides.Horizontal + else -> WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal + } + + Surface( + modifier = Modifier + .padding( + start = mainHorizontalMargin, + end = mainHorizontalMargin, + bottom = mainBottomMargin, + ) + .then( + if (route == ReadBookMenuRoute.Main) { + Modifier + } else { + Modifier.windowInsetsPadding( + WindowInsets.safeDrawing.only(surfaceWindowInsetSides) + ) + } + ) + .width(surfaceWidth) + .heightIn(max = maxHeight) + .onSizeChanged { surfaceHeightPx = it.height } + .offset { + val liftPx = ((windowSize.height - surfaceHeightPx) / 2f) * morphProgress + IntOffset(x = 0, y = -liftPx.roundToInt()) + } + .then( + if (useLiquidGlass) { + Modifier.readMenuLiquidGlass( + backdrop = backdrop, + colors = colors, + shape = surfaceShape, + useTopBarStyle = false, + useLens = useLens, + menuConfig = state.menuConfig, + ) + } else { + Modifier + } + ) + .then( + if (useHaze && hazeState != null) { + Modifier.readMenuBottomBarHazeEffect( + state = hazeState, + colors = colors, + shape = surfaceShape, + menuConfig = state.menuConfig, + progressive = bottomBarProgressiveBlur, + ) + } else { + Modifier + } + ) + .drawWithCache { + val strokeWidthPx = bottomBarBorderWidth.dp.toPx() + val outline = surfaceShape.createOutline(size, layoutDirection, this) + val strokeStyle = Stroke(width = strokeWidthPx * 2) + val outlinePath = when (outline) { + is Outline.Rounded -> Path().apply { addRoundRect(outline.roundRect) } + is Outline.Rectangle -> Path().apply { addRect(outline.rect) } + is Outline.Generic -> outline.path + } + onDrawBehind { + if (bottomBarBorderWidth > 0) { + drawPath( + path = outlinePath, + color = Color(bottomBarBorderColor), + style = strokeStyle, + ) + } + } + }, + shape = surfaceShape, + color = if (useLiquidGlass || useHaze) Color.Transparent else colors.background.copy( + alpha = state.menuConfig.readMenuBlurAlpha.coerceIn(0, 100) / 100f + ), + contentColor = colors.content + ) { + AnimatedContent( + targetState = route, + transitionSpec = { + (slideInVertically { it / 4 } + fadeIn()) + .togetherWith(slideOutVertically { -it / 4 } + fadeOut()) + .using(SizeTransform(clip = true)) + }, + label = "ReadBookMenuRoute", + ) { targetRoute -> + when (targetRoute) { + ReadBookMenuRoute.Main -> { + MenuBottomBar( + state = state, + colors = colors, + onIntent = onIntent, + context = context, + bottomPadding = if (extendSurfaceToNavigationBar) navBarHeight + 16.dp else 16.dp, + surfaceEffectEnabled = useLiquidGlass || useHaze, + buttonGlassEnabled = useBottomBarButtonGlass, + backdrop = backdrop, + labelColor = bottomBarTextColor, + ) + } + + ReadBookMenuRoute.ReadStyle -> { + ReadBookMenuRoutePage( + title = stringResource(R.string.read_config), + maxHeight = maxHeight, + bottomPadding = if (extendSurfaceToNavigationBar) navBarHeight else 0.dp, + onBack = { onIntent(ReadBookIntent.ReadMenuBack) }, + ) { + ReadStyleContent( + onOpenPaddingConfig = { + onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.PaddingConfig)) + }, + onOpenMoreConfig = { + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.MoreConfig)) + }, + onOpenBgTextConfig = { index -> + onIntent(ReadBookIntent.OpenBgTextConfig(index)) + }, + onOpenTextTitle = { + onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.TextTitle)) + }, + onOpenFontSelect = { + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.FontSelect)) + }, + onToggleDayNight = { + onIntent(ReadBookIntent.ToggleDayNight) + }, + readMenuCustomIcons = state.menuConfig.readMenuCustomIcons, + bottomBarButtons = state.menuConfig.bottomBarButtons, + onIntent = onIntent, + styleConfig = state.styleConfig, + ) + } + } + + ReadBookMenuRoute.PaddingConfig -> { + ReadBookMenuRoutePage( + title = stringResource(R.string.padding), + maxHeight = maxHeight, + scrollContent = true, + bottomPadding = if (extendSurfaceToNavigationBar) navBarHeight else 0.dp, + onBack = { onIntent(ReadBookIntent.ReadMenuBack) }, + ) { + PaddingConfigContent( + onIntent = onIntent, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + + ReadBookMenuRoute.TextTitle -> { + ReadBookMenuRoutePage( + title = stringResource(R.string.read_config_text_effects), + maxHeight = maxHeight, + bottomPadding = if (extendSurfaceToNavigationBar) navBarHeight else 0.dp, + onBack = { onIntent(ReadBookIntent.ReadMenuBack) }, + ) { + ReadStyleTextTitleContent( + onOpenShadowSet = { + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ShadowSet)) + }, + onOpenUnderlineConfig = { + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.UnderlineConfig)) + }, + onOpenHighlightRule = { + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.HighlightRuleConfig)) + }, + onOpenFontSelect = { + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.FontSelect)) + }, + modifier = Modifier.padding(horizontal = 16.dp), + onIntent = onIntent, + ) + } + } + + ReadBookMenuRoute.ReadAloud -> { + ReadBookMenuRoutePage( + title = stringResource(R.string.aloud_config), + maxHeight = maxHeight, + scrollContent = true, + bottomPadding = if (extendSurfaceToNavigationBar) navBarHeight else 0.dp, + onBack = { onIntent(ReadBookIntent.ReadMenuBack) }, + ) { + ReadAloudContent( + state = state, + onIntent = onIntent, + onDismissRequest = { onIntent(ReadBookIntent.HideMenu) }, + onOpenChapterList = { + onIntent(ReadBookIntent.HideMenu) + onIntent(ReadBookIntent.OpenChapterList) + }, + onGoToBackground = { onIntent(ReadBookIntent.CloseReadBook) }, + onShowReadAloudConfig = { + onIntent(ReadBookIntent.ShowReadAloudConfig) + }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + + ReadBookMenuRoute.AutoRead -> { + ReadBookMenuRoutePage( + title = stringResource(R.string.auto_page_speed), + maxHeight = maxHeight, + scrollContent = true, + bottomPadding = if (extendSurfaceToNavigationBar) navBarHeight else 0.dp, + onBack = { onIntent(ReadBookIntent.ReadMenuBack) }, + ) { + AutoReadContent( + onDismissRequest = { onIntent(ReadBookIntent.HideMenu) }, + onIntent = onIntent, + onOpenChapterList = { + onIntent(ReadBookIntent.HideMenu) + onIntent(ReadBookIntent.OpenChapterList) + }, + onStopAutoPage = { onIntent(ReadBookIntent.StopAutoPage) }, + onShowPageAnimConfig = { + onIntent(ReadBookIntent.ShowPageAnimConfig) + }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + + is ReadBookMenuRoute.Bookmark -> { + ReadBookMenuRoutePage( + title = targetRoute.bookmark.chapterName, + maxHeight = maxHeight, + scrollContent = true, + bottomPadding = if (extendSurfaceToNavigationBar) navBarHeight else 0.dp, + onBack = { onIntent(ReadBookIntent.ReadMenuBack) }, + ) { + Box(Modifier.padding(horizontal = 16.dp)) { + BookmarkEditContent( + bookmark = targetRoute.bookmark, + onSave = { onIntent(ReadBookIntent.SaveBookmark(it)) }, + onDelete = { onIntent(ReadBookIntent.DeleteBookmark(it)) }, + ) + } + } + } + } + } + } +} + +@Composable +private fun ReadBookMenuRoutePage( + title: String, + maxHeight: Dp, + scrollContent: Boolean = false, + bottomPadding: Dp = 0.dp, + onBack: () -> Unit, + content: @Composable () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = maxHeight) + .animateContentSize() + .padding(top = 16.dp, bottom = 16.dp + bottomPadding), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SmallTonalButton( + onClick = onBack, + icon = Icons.AutoMirrored.Filled.ArrowBack + ) + Text( + text = title, + modifier = Modifier + .weight(1f) + .padding(start = 12.dp), + style = LegadoTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(48.dp)) + } + + if (scrollContent) { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()), + ) { + content() + } + } else { + content() + } + } +} + +@Composable +private fun MenuTitleBar( + state: ReadBookUiState, + colors: ReadMenuColors, + onIntent: (ReadBookIntent) -> Unit, + backdrop: Backdrop?, + hazeState: HazeState?, +) { + val titleBarMode = AppConfig.titleBarMode + + var expanded by remember { mutableStateOf(false) } + + val topBarBorderWidth = state.menuConfig.readMenuBorderWidth + val topBarBorderColor = (if (ReadStyleResolver.isNightTheme()) { + state.menuConfig.readMenuBorderColorNight + } else { + state.menuConfig.readMenuBorderColor + }).takeIf { it != 0 } + ?: LegadoTheme.colorScheme.outlineVariant.hashCode() + val topBarAlpha = state.menuConfig.readMenuBlurAlpha.coerceIn(0, 100) / 100f + val useTopBarBlur = readMenuTopBarHazeEnabled(hazeState, state.menuConfig) + val topBarProgressiveBlur = state.menuConfig.readMenuTopBarBlurStyle == + ReadMenuBlurStyle.Progressive + val progressiveBlurActive = useTopBarBlur && topBarProgressiveBlur + val titleTextColor = if (progressiveBlurActive) { + Color.White.copy(alpha = 0.72f).compositeOver(colors.background) + } else { + LegadoTheme.colorScheme.onSurface + } + val labelStyle = if (progressiveBlurActive) { + LegadoTheme.typography.labelSmallEmphasized.copy( + shadow = androidx.compose.ui.graphics.Shadow( + color = Color.Black.copy(alpha = 0.12f), + offset = Offset.Zero, + blurRadius = 12f, + ) + ) + } else { + LegadoTheme.typography.labelSmallEmphasized + } + + Column( + modifier = Modifier + .fillMaxWidth() + .then( + if (useTopBarBlur && hazeState != null) { + Modifier + .background( + if (topBarProgressiveBlur) { + readMenuTopBarSurfaceBrush( + colors = colors, + alpha = topBarAlpha, + ) + } else { + readMenuTopBarSurfaceBrush(colors, topBarAlpha) + } + ) + .readMenuHazeEffect( + state = hazeState, + colors = colors, + menuConfig = state.menuConfig, + progressive = topBarProgressiveBlur, + ) + } else { + Modifier.background(colors.background) + } + ) + .then( + if (topBarBorderWidth > 0) { + Modifier.drawBehind { + val strokeWidth = topBarBorderWidth.dp.toPx() + drawLine( + color = Color(topBarBorderColor), + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = strokeWidth, + ) + } + } else Modifier + ) + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Top + WindowInsetsSides.Horizontal + ) + ) + ) { + // Title row: back + title + actions + overflow + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Back button + MenuTitleGlassButton( + onClick = { onIntent(ReadBookIntent.ReadMenuBack) }, + icon = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + state = state, + colors = colors, + backdrop = backdrop, + ) + + if (titleBarMode != "1" && titleBarMode != "3") { + AppText( + text = state.bookName, + modifier = Modifier + .weight(1f) + .clickable { onIntent(ReadBookIntent.OpenBookInfo) } + .padding(horizontal = 8.dp, vertical = 4.dp), + style = LegadoTheme.typography.titleMedium.copy( + shadow = androidx.compose.ui.graphics.Shadow( + color = Color.Black.copy(alpha = 0.12f), + offset = Offset.Zero, + blurRadius = 12f + ) + ), + color = titleTextColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + Spacer(Modifier.weight(1f)) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Source action button (non-local books only) + if (!state.isLocalBook) { + SourceActionButton( + state = state, + colors = colors, + onIntent = onIntent, + backdrop = backdrop, + ) + RefreshActionButton( + state = state, + colors = colors, + onIntent = onIntent, + backdrop = backdrop, + ) + } + + Box { + MenuTitleGlassButton( + onClick = { expanded = true }, + icon = Icons.Default.MoreVert, + state = state, + colors = colors, + backdrop = backdrop, + ) + OverflowDropdownMenu( + state = state, + onIntent = onIntent, + expanded = expanded, + onDismiss = { expanded = false }, + ) + } + } + } + + // Book name on its own line (mode "1") + if (titleBarMode == "1") { + AppText( + text = state.bookName, + modifier = Modifier + .fillMaxWidth() + .clickable { onIntent(ReadBookIntent.OpenBookInfo) } + .padding(horizontal = 16.dp, vertical = 4.dp), + style = LegadoTheme.typography.titleMedium.copy( + shadow = androidx.compose.ui.graphics.Shadow( + color = Color.Black.copy(alpha = 0.12f), + offset = Offset.Zero, + blurRadius = 12f + ) + ), + color = titleTextColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + // Chapter name + source action (modes "0" and "1") + if (titleBarMode == "0" || titleBarMode == "1") { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = state.chapterName, + modifier = Modifier.weight(1f), + style = labelStyle, + color = titleTextColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (!state.isLocalBook && state.bookSource != null) { + Text( + text = state.bookSource.bookSourceName, + modifier = Modifier + .clickable { onIntent(ReadBookIntent.OpenSourceEdit) } + .padding(start = 8.dp), + style = labelStyle, + color = titleTextColor, + maxLines = 1, + ) + } + } + } + + Spacer(Modifier.height(4.dp)) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun MenuTitleGlassButton( + onClick: () -> Unit, + icon: ImageVector, + state: ReadBookUiState, + colors: ReadMenuColors, + backdrop: Backdrop?, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + contentDescription: String? = null, +) { + ReadMenuGlassIconButton( + onClick = onClick, + icon = icon, + colors = colors, + backdrop = backdrop, + menuConfig = state.menuConfig, + glassEnabled = readMenuTopBarButtonLiquidGlassEnabled(backdrop, state.menuConfig), + modifier = modifier, + onLongClick = onLongClick, + contentDescription = contentDescription, + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ReadMenuGlassIconButton( + onClick: () -> Unit, + icon: ImageVector, + colors: ReadMenuColors, + backdrop: Backdrop?, + menuConfig: ReadMenuConfig, + glassEnabled: Boolean, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + selected: Boolean = false, + contentDescription: String? = null, +) { + ReadMenuGlassButtonSurface( + onClick = onClick, + colors = colors, + backdrop = backdrop, + menuConfig = menuConfig, + glassEnabled = glassEnabled, + modifier = modifier, + onLongClick = onLongClick, + selected = selected, + ) { tint -> + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = tint, + modifier = Modifier.size(20.dp), + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ReadMenuGlassButtonSurface( + onClick: () -> Unit, + colors: ReadMenuColors, + backdrop: Backdrop?, + menuConfig: ReadMenuConfig, + glassEnabled: Boolean, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + selected: Boolean = false, + content: @Composable (Color) -> Unit, +) { + val shape = CircleShape + val tint = when { + selected -> LegadoTheme.colorScheme.onPrimaryContainer + else -> LegadoTheme.colorScheme.onSurfaceVariant + } + val containerColor = when { + selected -> LegadoTheme.colorScheme.primaryContainer + else -> LegadoTheme.colorScheme.surfaceContainerLow + } + val border = if (selected) { + BorderStroke(1.5.dp, LegadoTheme.colorScheme.primary) + } else { + null + } + val outerSize = if (glassEnabled) 48.dp else 40.dp + val innerSize = 40.dp + + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(outerSize), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(innerSize) + .then( + if (glassEnabled) { + Modifier.readMenuLiquidGlass( + backdrop = backdrop, + colors = colors, + shape = shape, + useTopBarStyle = true, + useLens = true, + blurRadius = 32.dp, + interactive = true, + menuConfig = menuConfig, + ) + } else { + Modifier + .clip(shape) + .background(containerColor, shape) + } + ) + .then(if (border != null) Modifier.border(border, shape) else Modifier) + .combinedClickable( + indication = if (glassEnabled) null else LocalIndication.current, + interactionSource = remember { MutableInteractionSource() }, + role = Role.Button, + onLongClick = onLongClick, + onClick = onClick, + ), + ) { + content(tint) + } + } +} + +@Composable +private fun SourceActionButton( + state: ReadBookUiState, + colors: ReadMenuColors, + onIntent: (ReadBookIntent) -> Unit, + backdrop: Backdrop?, +) { + var expanded by remember { mutableStateOf(false) } + + Box { + MenuTitleGlassButton( + onClick = { onIntent(ReadBookIntent.MenuChangeSource) }, + onLongClick = { expanded = true }, + icon = Icons.Default.SwapHoriz, + contentDescription = stringResource(R.string.change_origin), + state = state, + colors = colors, + backdrop = backdrop, + ) + + RoundDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { dismiss -> + RoundDropdownMenuItem( + text = stringResource(R.string.change_origin), + onClick = { dismiss(); onIntent(ReadBookIntent.MenuChangeSource) }, + ) + RoundDropdownMenuItem( + text = stringResource(R.string.chapter_change_source), + onClick = { dismiss(); onIntent(ReadBookIntent.MenuChapterChangeSource) }, + ) + } + } +} + +@Composable +private fun RefreshActionButton( + state: ReadBookUiState, + colors: ReadMenuColors, + onIntent: (ReadBookIntent) -> Unit, + backdrop: Backdrop?, +) { + var expanded by remember { mutableStateOf(false) } + + Box { + MenuTitleGlassButton( + onClick = { onIntent(ReadBookIntent.MenuRefreshAfter) }, + onLongClick = { expanded = true }, + icon = Icons.Default.Refresh, + contentDescription = stringResource(R.string.menu_refresh_after), + state = state, + colors = colors, + backdrop = backdrop, + ) + + RoundDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { dismiss -> + RoundDropdownMenuItem( + text = stringResource(R.string.menu_refresh_dur), + onClick = { dismiss(); onIntent(ReadBookIntent.MenuRefreshDur) }, + ) + RoundDropdownMenuItem( + text = stringResource(R.string.menu_refresh_after), + onClick = { dismiss(); onIntent(ReadBookIntent.MenuRefreshAfter) }, + ) + } + } +} + +@Composable +private fun FloatingIconRow( + state: ReadBookUiState, + colors: ReadMenuColors, + alignment: Alignment.Horizontal = Alignment.CenterHorizontally, + onIntent: (ReadBookIntent) -> Unit, + backdrop: Backdrop?, +) { + val context = LocalContext.current + val titleBarIcons = remember( + state.menuConfig.titleBarButtons, + state.isReadAloudRunning, + state.isAutoPage, + ) { + loadFloatingIcons(context, state, onIntent) + } + + if (titleBarIcons.isEmpty()) return + + Row( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)) + .padding(all = 16.dp), + horizontalArrangement = when (alignment) { + Alignment.Start -> Arrangement.Start + Alignment.End -> Arrangement.End + else -> Arrangement.Center + }, + verticalAlignment = Alignment.CenterVertically, + ) { + titleBarIcons.forEach { iconDef -> + val customPath = remember(state.menuConfig.titleBarCustomIcons, iconDef.id) { + state.menuConfig.titleBarCustomIcons[iconDef.id] + } + val isCustom = !customPath.isNullOrBlank() + ReadMenuGlassButtonSurface( + onClick = iconDef.onClick, + colors = colors, + backdrop = backdrop, + menuConfig = state.menuConfig, + glassEnabled = !isCustom && readMenuTopBarButtonLiquidGlassEnabled( + backdrop, + state.menuConfig + ), + selected = iconDef.isActive, + modifier = Modifier.padding(horizontal = 4.dp), + ) { + if (isCustom) { + AsyncImage( + model = customPath, + contentDescription = iconDef.label, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(36.dp) + .clip(CircleShape), + ) + } else { + Icon( + imageVector = iconDef.icon, + contentDescription = iconDef.label, + tint = if (iconDef.isActive) LegadoTheme.colorScheme.primary else colors.content, + modifier = Modifier.size(20.dp), + ) + } + } + } + } +} + +@Composable +private fun OverflowDropdownMenu( + state: ReadBookUiState, + onIntent: (ReadBookIntent) -> Unit, + expanded: Boolean, + onDismiss: () -> Unit, +) { + RoundDropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss, + ) { dismiss -> + var imageStyleExpanded by remember { mutableStateOf(false) } + + // Source actions + if (!state.isLocalBook) { + RoundDropdownMenuItem( + text = stringResource(R.string.menu_refresh_all), + onClick = { dismiss(); onIntent(ReadBookIntent.MenuRefreshAll) }, + ) + } + + // TXT + if (state.isLocalTxt) { + RoundDropdownMenuItem( + text = stringResource(R.string.txt_toc_rule), + onClick = { dismiss(); onIntent(ReadBookIntent.MenuTocRegex) }, + ) + } + + // Local book + if (state.isLocalBook) { + RoundDropdownMenuItem( + text = stringResource(R.string.set_charset), + onClick = { + dismiss() + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.Charset)) + }, + ) + } + + PillDivider() + + // Content operations + RoundDropdownMenuItem( + text = stringResource(R.string.bookmark_add), + onClick = { dismiss(); onIntent(ReadBookIntent.AddBookmark) }, + ) + RoundDropdownMenuItem( + text = stringResource(R.string.edit_content), + onClick = { + dismiss() + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ContentEdit)) + }, + ) + if (!state.isLocalBook) { + RoundDropdownMenuItem( + text = stringResource(R.string.offline_cache), + onClick = { + dismiss() + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.Download)) + }, + ) + } + RoundDropdownMenuItem( + text = stringResource(R.string.update_toc), + onClick = { dismiss(); onIntent(ReadBookIntent.MenuUpdateToc) }, + ) + RoundDropdownMenuItem( + text = stringResource(R.string.simulated_reading), + onClick = { + dismiss() + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.SimulatedReading)) + }, + ) + RoundDropdownMenuItem( + text = stringResource(R.string.reverse_content), + onClick = { dismiss(); onIntent(ReadBookIntent.MenuReverseContent) }, + ) + + PillDivider() + + // Checkable items + RoundDropdownMenuItem( + text = stringResource(R.string.replace_rule_title), + isSelected = state.useReplaceRule, + onClick = { onIntent(ReadBookIntent.MenuEnableReplace) }, + ) + RoundDropdownMenuItem( + text = stringResource(R.string.replace_rule_title_setting), + onClick = { dismiss(); onIntent(ReadBookIntent.MenuSettingReplace) }, + ) + RoundDropdownMenuItem( + text = stringResource(R.string.effective_replaces), + onClick = { + dismiss() + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.EffectiveReplaces)) + }, + ) + RoundDropdownMenuItem( + text = stringResource(R.string.same_title_removed), + isSelected = state.sameTitleRemoved, + onClick = { onIntent(ReadBookIntent.MenuSameTitleRemoved) }, + ) + RoundDropdownMenuItem( + text = stringResource(R.string.re_segment), + isSelected = state.reSegment, + onClick = { onIntent(ReadBookIntent.MenuReSegment) }, + ) + + // EPUB + if (state.isEpub) { + RoundDropdownMenuItem( + text = stringResource(R.string.del_ruby_tag), + isSelected = state.delRubyTag, + onClick = { onIntent(ReadBookIntent.MenuDelRubyTag) }, + ) + RoundDropdownMenuItem( + text = stringResource(R.string.del_h_tag), + isSelected = state.delHTag, + onClick = { onIntent(ReadBookIntent.MenuDelHTag) }, + ) + } + + PillDivider() + + // Config + Box { + RoundDropdownMenuItem( + text = stringResource(R.string.image_style), + onClick = { imageStyleExpanded = true }, + ) + RoundDropdownMenu( + expanded = imageStyleExpanded, + onDismissRequest = { imageStyleExpanded = false }, + ) { subDismiss -> + RoundDropdownMenuItem( + text = stringResource(R.string.btn_default_s), + onClick = { + subDismiss() + onIntent(ReadBookIntent.MenuImageStyle(Book.imgStyleDefault)) + }, + ) + RoundDropdownMenuItem( + text = stringResource(R.string.image_style_full), + onClick = { + subDismiss() + onIntent(ReadBookIntent.MenuImageStyle(Book.imgStyleFull)) + }, + ) + RoundDropdownMenuItem( + text = stringResource(R.string.image_style_text), + onClick = { + subDismiss() + onIntent(ReadBookIntent.MenuImageStyle(Book.imgStyleText)) + }, + ) + RoundDropdownMenuItem( + text = stringResource(R.string.image_style_single), + onClick = { + subDismiss() + onIntent(ReadBookIntent.MenuImageStyle(Book.imgStyleSingle)) + }, + ) + } + } + RoundDropdownMenuItem( + text = stringResource(R.string.book_page_anim), + onClick = { + dismiss() + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.PageAnim)) + }, + ) + RoundDropdownMenuItem( + text = stringResource(R.string.config_btn), + onClick = { + dismiss() + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ToolButtonConfig)) + }, + ) + + // Progress sync + if (state.isReadingProgressSyncConfigured) { + RoundDropdownMenuItem( + text = stringResource(R.string.get_book_progress), + onClick = { dismiss(); onIntent(ReadBookIntent.MenuGetProgress) }, + ) + RoundDropdownMenuItem( + text = stringResource(R.string.cover_book_progress), + onClick = { dismiss(); onIntent(ReadBookIntent.MenuCoverProgress) }, + ) + } + + PillDivider() + + RoundDropdownMenuItem( + text = stringResource(R.string.log), + onClick = { + dismiss() + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.AppLog)) + }, + ) + } +} + +@Composable +private fun MenuBottomBar( + state: ReadBookUiState, + colors: ReadMenuColors, + onIntent: (ReadBookIntent) -> Unit, + context: Context, + bottomPadding: Dp = 0.dp, + surfaceEffectEnabled: Boolean = false, + buttonGlassEnabled: Boolean = false, + backdrop: Backdrop? = null, + labelColor: Color = LegadoTheme.colorScheme.onSurface, +) { + val seekMax = state.seekMax.coerceAtLeast(0) + val sliderMax = seekMax.toFloat().coerceAtLeast(1f) + var sliderValue by remember { mutableFloatStateOf(state.seekProgress.coerceIn(0, seekMax).toFloat()) } + var sliderDragging by remember { mutableStateOf(false) } + val toolButtonsBottomPadding = if (buttonGlassEnabled) 6.dp else 0.dp + val contentBottomPadding = if (bottomPadding > toolButtonsBottomPadding) { + bottomPadding - toolButtonsBottomPadding + } else { + 0.dp + } + + fun commitSliderValue(value: Float) { + val target = value.roundToInt().coerceIn(0, seekMax) + sliderDragging = false + sliderValue = target.toFloat() + val behavior = AppConfig.progressBarBehavior + if (behavior == "page") { + onIntent(ReadBookIntent.SkipToPage(target)) + } else { + onIntent(ReadBookIntent.SeekToChapter(target)) + } + } + + LaunchedEffect(state.seekProgress, seekMax) { + if (!sliderDragging) { + sliderValue = state.seekProgress.coerceIn(0, seekMax).toFloat() + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .background( + if (surfaceEffectEnabled) Color.Transparent else colors.background.copy( + alpha = state.menuConfig.readMenuBlurAlpha.coerceIn(0, 100) / 100f + ) + ) + .windowInsetsPadding( + WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) + ) + .padding(top = 8.dp, bottom = contentBottomPadding) + .animateContentSize(), + ) { + // Seek bar row: prev + slider + next + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + BottomBarGlassIconButton( + onClick = { onIntent(ReadBookIntent.PrevChapter) }, + icon = Icons.AutoMirrored.Filled.ArrowBack, + colors = colors, + backdrop = backdrop, + menuConfig = state.menuConfig, + glassEnabled = buttonGlassEnabled, + ) + + ReadMenuSlider( + value = sliderValue.coerceIn(0f, sliderMax), + onValueChange = { value -> + sliderDragging = true + sliderValue = value.coerceIn(0f, sliderMax) + }, + onValueChangeFinished = { + commitSliderValue(sliderValue) + }, + onValueCommit = ::commitSliderValue, + valueRange = 0f..sliderMax, + steps = (seekMax - 1).coerceAtLeast(0), + enabled = seekMax > 0, + backdrop = backdrop, + glassThumbEnabled = buttonGlassEnabled, + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp) + ) + + BottomBarGlassIconButton( + onClick = { onIntent(ReadBookIntent.NextChapter) }, + icon = Icons.AutoMirrored.Filled.ArrowForward, + colors = colors, + backdrop = backdrop, + menuConfig = state.menuConfig, + glassEnabled = buttonGlassEnabled, + ) + } + + Spacer(Modifier.height(8.dp)) + + // Tool buttons + val toolButtons = remember( + context, + state.menuConfig.bottomBarButtons, + state.menuConfig.readMenuCustomIcons, + state.isReadAloudRunning, + state.isAutoPage + ) { + loadToolButtons(context, state, onIntent) + } + val itemsPerRow = state.menuConfig.readMenuIconItemsPerRow + val rowCount = state.menuConfig.readMenuIconRowCount + val pageSize = (itemsPerRow * rowCount).coerceAtLeast(1) + val pageCount = ceil(toolButtons.size / pageSize.toFloat()).roundToInt().coerceAtLeast(1) + val pagerState = rememberPagerState(pageCount = { pageCount }) + + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth(), + ) { page -> + val pageButtons = toolButtons.drop(page * pageSize).take(pageSize) + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = toolButtonsBottomPadding), + ) { + pageButtons.chunked(itemsPerRow).forEach { rowButtons -> + Row( + horizontalArrangement = when { + rowButtons.size > 3 -> Arrangement.SpaceBetween + else -> Arrangement.spacedBy(32.dp, Alignment.CenterHorizontally) + }, + modifier = Modifier + .fillMaxWidth(), + ) { + rowButtons.forEach { button -> + ToolButtonItem( + button = button, + state = state, + colors = colors, + backdrop = backdrop, + glassEnabled = buttonGlassEnabled, + labelColor = labelColor, + modifier = Modifier.width(if (buttonGlassEnabled) 48.dp else 40.dp), + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun BottomBarGlassIconButton( + onClick: () -> Unit, + icon: ImageVector, + colors: ReadMenuColors, + backdrop: Backdrop?, + menuConfig: ReadMenuConfig, + glassEnabled: Boolean, + contentDescription: String? = null, +) { + ReadMenuGlassIconButton( + onClick = onClick, + icon = icon, + colors = colors, + backdrop = backdrop, + menuConfig = menuConfig, + glassEnabled = glassEnabled, + contentDescription = contentDescription, + ) +} + +@Composable +private fun ReadMenuSlider( + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + valueRange: ClosedFloatingPointRange = 0f..1f, + steps: Int = 0, + onValueChangeFinished: (() -> Unit)? = null, + onValueCommit: ((Float) -> Unit)? = null, + backdrop: Backdrop?, + glassThumbEnabled: Boolean, +) { + if (glassThumbEnabled && backdrop != null) { + ReadMenuLiquidSlider( + value = { value }, + onValueChange = onValueChange, + valueRange = valueRange, + visibilityThreshold = 0.001f, + backdrop = backdrop, + modifier = modifier, + enabled = enabled, + onValueChangeFinished = onValueChangeFinished, + onValueCommit = onValueCommit, + ) + return + } + + val commitAction = onValueChangeFinished ?: onValueCommit?.let { commit -> { commit(value) } } + + AppSlider( + value = value, + onValueChange = onValueChange, + modifier = modifier.padding(horizontal = 5.dp), + enabled = enabled, + valueRange = valueRange, + steps = steps, + onValueChangeFinished = commitAction, + ) +} + +@Composable +private fun ReadMenuLiquidSlider( + value: () -> Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange, + visibilityThreshold: Float, + backdrop: Backdrop, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onValueChangeFinished: (() -> Unit)? = null, + onValueCommit: ((Float) -> Unit)? = null, +) { + val accentColor = LegadoTheme.colorScheme.secondary + val trackColor = LegadoTheme.colorScheme.surfaceContainerLow + val thumbColor = Color.White.copy(alpha = 0.9f).compositeOver(LegadoTheme.colorScheme.surfaceContainerLow) + + val trackBackdrop = rememberLayerBackdrop() + + BoxWithConstraints( + modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterStart, + ) { + val trackWidth = constraints.maxWidth + val rangeStart = valueRange.start + val rangeEnd = valueRange.endInclusive + val range = rangeEnd - rangeStart + val animationScope = rememberCoroutineScope() + var didDrag by remember { mutableStateOf(false) } + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + val dampedDragAnimation = remember(animationScope) { + DampedDragAnimation( + animationScope = animationScope, + initialValue = value(), + valueRange = valueRange, + visibilityThreshold = visibilityThreshold, + initialScale = 1f, + pressedScale = 1.5f, + onDragStarted = {}, + onDragStopped = { + if (didDrag) { + onValueChange(targetValue) + onValueCommit?.invoke(targetValue) + } else { + onValueChangeFinished?.invoke() + } + }, + onDrag = { _, dragAmount -> + if (!didDrag) { + didDrag = dragAmount.x != 0f + } + val delta = range * (dragAmount.x / trackWidth) + val nextValue = if (isLtr) { + (targetValue + delta).coerceIn(valueRange) + } else { + (targetValue - delta).coerceIn(valueRange) + } + updateValue(nextValue) + onValueChange(nextValue) + }, + ) + } + + LaunchedEffect(dampedDragAnimation) { + snapshotFlow { value() } + .collectLatest { currentValue -> + if (dampedDragAnimation.targetValue != currentValue) { + dampedDragAnimation.updateValue(currentValue) + } + } + } + + val progress = if (range == 0f) { + 0f + } else { + ((dampedDragAnimation.value - rangeStart) / range).coerceIn(0f, 1f) + } + + Box(Modifier.layerBackdrop(trackBackdrop)) { + Box( + Modifier + .drawBackdrop( + backdrop = backdrop, + shape = { ContinuousCapsule }, + effects = {}, + highlight = null, + shadow = { + Shadow( + radius = 8.dp, + color = Color.Black.copy(alpha = 0.12f), + ) + }, + innerShadow = null, + onDrawSurface = { + drawRect(trackColor) + }, + ) + .pointerInput(enabled, animationScope, isLtr, trackWidth) { + if (!enabled) return@pointerInput + detectTapGestures { position -> + val delta = range * (position.x / trackWidth) + val targetValue = + (if (isLtr) rangeStart + delta else rangeEnd - delta) + .coerceIn(valueRange) + dampedDragAnimation.animateToValue(targetValue) + onValueChange(targetValue) + onValueCommit?.invoke(targetValue) ?: onValueChangeFinished?.invoke() + } + } + .height(6f.dp) + .fillMaxWidth(), + ) + Box( + Modifier + .clip(ContinuousCapsule) + .background(accentColor) + .height(6f.dp) + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val width = (constraints.maxWidth * progress).roundToInt() + layout(width, placeable.height) { + placeable.place(0, 0) + } + }, + ) + } + + Box( + Modifier + .graphicsLayer { + translationX = + (-size.width / 2f + trackWidth * progress) + .coerceIn(-size.width / 4f, trackWidth - size.width * 3f / 4f) * + if (isLtr) 1f else -1f + } + .then(dampedDragAnimation.modifier) + .drawBackdrop( + backdrop = rememberCombinedBackdrop( + backdrop, + rememberBackdrop(trackBackdrop) { drawBackdrop -> + val pressProgress = dampedDragAnimation.pressProgress + val scaleX = 2f / 3f + (1f / 3f) * pressProgress + scale(scaleX, pressProgress) { + drawBackdrop() + } + }, + ), + shape = { ContinuousCapsule }, + effects = { + val pressProgress = dampedDragAnimation.pressProgress + blur(8.dp.toPx() * (1f - pressProgress)) + lens( + 10.dp.toPx() * pressProgress, + 14.dp.toPx() * pressProgress, + chromaticAberration = true, + ) + }, + highlight = { + Highlight.Ambient.copy( + width = Highlight.Ambient.width / 1.5f, + blurRadius = Highlight.Ambient.blurRadius / 1.5f, + alpha = dampedDragAnimation.pressProgress, + ) + }, + shadow = { + Shadow( + radius = 8.dp, + color = Color.Black.copy(alpha = 0.12f), + ) + }, + innerShadow = { + InnerShadow( + radius = 4.dp * dampedDragAnimation.pressProgress, + alpha = dampedDragAnimation.pressProgress, + ) + }, + layerBlock = { + scaleX = dampedDragAnimation.scaleX + scaleY = dampedDragAnimation.scaleY + val velocity = dampedDragAnimation.velocity / 10f + scaleX /= 1f - (velocity * 0.75f).coerceIn(-0.2f, 0.2f) + scaleY *= 1f - (velocity * 0.25f).coerceIn(-0.2f, 0.2f) + }, + onDrawSurface = { + val pressProgress = dampedDragAnimation.pressProgress + drawRect(thumbColor.copy(alpha = 1f - pressProgress)) + }, + ) + .size(40f.dp, 24f.dp), + ) + } +} + +@Composable +private fun ToolButtonItem( + button: ToolButtonDef, + state: ReadBookUiState, + colors: ReadMenuColors, + backdrop: Backdrop?, + glassEnabled: Boolean, + labelColor: Color, + modifier: Modifier = Modifier, +) { + val iconTint = if (button.isActive) LegadoTheme.colorScheme.primary else colors.content + val badgeCount = when (button.id) { + "replace_badge" -> state.effectiveReplaceCount + else -> 0 + } + val buttonShape = RoundedCornerShape(16.dp) + val containerColor = when { + button.isActive -> LegadoTheme.colorScheme.secondaryContainer + state.menuConfig.readMenuIconStyle == 1 -> LegadoTheme.colorScheme.surfaceContainerLow + else -> Color.Transparent + } + val borderStroke = when { + button.isActive -> BorderStroke(1.5.dp, LegadoTheme.colorScheme.primary) + state.menuConfig.readMenuIconStyle == 2 -> BorderStroke(1.dp, iconTint.copy(alpha = 0.45f)) + else -> null + } + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (glassEnabled) { + ReadMenuGlassButtonSurface( + onClick = button.onClick, + colors = colors, + backdrop = backdrop, + menuConfig = state.menuConfig, + glassEnabled = true, + selected = button.isActive, + ) { tint -> + ToolButtonContent( + button = button, + tint = if (button.isActive) iconTint else tint, + badgeCount = badgeCount, + ) + } + } else { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(40.dp) + .clip(buttonShape) + .background(containerColor, buttonShape) + .then( + if (borderStroke != null) Modifier.border( + borderStroke, + buttonShape + ) else Modifier + ) + .combinedClickable( + indication = LocalIndication.current, + interactionSource = remember { MutableInteractionSource() }, + role = Role.Button, + onClick = button.onClick, + ), + ) { + ToolButtonContent( + button = button, + tint = iconTint, + badgeCount = badgeCount, + ) + } + } + if (state.menuConfig.readMenuIconShowText) { + Spacer(Modifier.height(2.dp)) + Text( + text = button.description, + style = LegadoTheme.typography.labelSmall.copy( + shadow = androidx.compose.ui.graphics.Shadow( + color = Color.Black.copy(alpha = 0.12f), + offset = Offset.Zero, + blurRadius = 12f, + ) + ), + color = labelColor, + maxLines = 1, + modifier = Modifier.wrapContentWidth( + align = Alignment.CenterHorizontally, + unbounded = true, + ), + ) + } + } +} + +@Composable +private fun ToolButtonContent( + button: ToolButtonDef, + tint: Color, + badgeCount: Int, +) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { + if (button.customIconPath.isNullOrBlank()) { + Icon( + imageVector = button.icon, + contentDescription = button.description, + modifier = Modifier.size(20.dp), + tint = tint, + ) + } else { + AsyncImage( + model = button.customIconPath, + contentDescription = button.description, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(36.dp) + .clip(CircleShape), + ) + } + if (badgeCount > 0) { + Text( + text = badgeCount.toString(), + modifier = Modifier + .align(Alignment.TopEnd) + .background( + LegadoTheme.colorScheme.error, + RoundedCornerShape(8.dp), + ) + .padding(horizontal = 4.dp, vertical = 1.dp), + style = LegadoTheme.typography.labelSmall, + color = LegadoTheme.colorScheme.onError, + ) + } + } +} + +private data class ToolButtonDef( + val id: String, + val icon: ImageVector, + val description: String, + val customIconPath: String?, + val isActive: Boolean = false, + val onClick: () -> Unit, +) + +private fun loadToolButtons( + context: Context, + state: ReadBookUiState, + onIntent: (ReadBookIntent) -> Unit, +): List { + val customIcons = state.menuConfig.readMenuCustomIcons + fun ReadMenuButtonInfo.toButton(isActive: Boolean = false, onClick: () -> Unit): ToolButtonDef { + return ToolButtonDef(id, icon, label, customIcons[id], isActive, onClick) + } + val infoMap = readMenuButtonInfos(context).associateBy { it.id } + val allButtons = listOf( + infoMap.getValue("search").toButton { + onIntent(ReadBookIntent.OpenSearch(null)) + }, + infoMap.getValue("catalog").toButton { + onIntent(ReadBookIntent.OpenChapterList) + }, + infoMap.getValue("read_aloud").toButton(isActive = state.isReadAloudRunning) { + if (state.isReadAloudRunning) { + onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.ReadAloud)) + } else { + onIntent(ReadBookIntent.ToggleReadAloud) + onIntent(ReadBookIntent.HideMenu) + } + }, + infoMap.getValue("setting").toButton { + onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.ReadStyle)) + }, + infoMap.getValue("addBookmark").toButton { + onIntent(ReadBookIntent.AddBookmark) + }, + infoMap.getValue("theme").toButton { + onIntent(ReadBookIntent.ToggleDayNight) + }, + infoMap.getValue("prev_chapter").toButton { + onIntent(ReadBookIntent.PrevChapter) + }, + infoMap.getValue("next_chapter").toButton { + onIntent(ReadBookIntent.NextChapter) + }, + infoMap.getValue("replace").toButton { + onIntent(ReadBookIntent.ChangeReplaceRule(true)) + }, + infoMap.getValue("replace_badge").toButton { + onIntent(ReadBookIntent.ChangeReplaceRule(true)) + }, + infoMap.getValue("auto_page").toButton(isActive = state.isAutoPage) { + if (state.isAutoPage) { + onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.AutoRead)) + } else { + onIntent(ReadBookIntent.ToggleAutoPage) + onIntent(ReadBookIntent.HideMenu) + } + }, + infoMap.getValue("translate").toButton { + onIntent(ReadBookIntent.ToggleTranslation) + }, + ) + + val allMap = allButtons.associateBy { it.id } + return state.menuConfig.bottomBarButtons + .asSequence() + .filter { it.enabled } + .mapNotNull { allMap[it.id] } + .toList() +} + +private data class ReadMenuColors( + val background: Color, + val content: Color, +) + +private fun readMenuLiquidGlassAvailable(backdrop: Backdrop?): Boolean { + return backdrop != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU +} + +private fun readMenuTopBarButtonLiquidGlassEnabled( + backdrop: Backdrop?, + menuConfig: ReadMenuConfig, +): Boolean { + return menuConfig.readMenuTopBarBlurMode != ReadMenuBlurMode.None && + menuConfig.readMenuTopBarLiquidGlassButtons && + readMenuLiquidGlassAvailable(backdrop) +} + +private fun readMenuBottomBarButtonLiquidGlassEnabled( + backdrop: Backdrop?, + menuConfig: ReadMenuConfig, +): Boolean { + return menuConfig.readMenuBottomBarLiquidGlassButtons && + readMenuLiquidGlassAvailable(backdrop) +} + +private fun readMenuTopBarHazeEnabled( + hazeState: HazeState?, + menuConfig: ReadMenuConfig, +): Boolean { + return hazeState != null && menuConfig.readMenuTopBarBlurMode == ReadMenuBlurMode.Haze +} + +private fun readMenuBottomBarEffectiveBlurMode( + menuConfig: ReadMenuConfig, + isFloating: Boolean, +): Int { + val mode = menuConfig.readMenuBottomBarBlurMode + return if (!isFloating && mode == ReadMenuBlurMode.LiquidGlass) { + ReadMenuBlurMode.Haze + } else { + mode + } +} + +private fun readMenuBottomBarLiquidGlassEnabled( + backdrop: Backdrop?, + menuConfig: ReadMenuConfig, + isFloating: Boolean, +): Boolean { + return isFloating && + readMenuBottomBarEffectiveBlurMode( + menuConfig, + isFloating + ) == ReadMenuBlurMode.LiquidGlass && + readMenuLiquidGlassAvailable(backdrop) +} + +private fun readMenuBottomBarHazeEnabled( + hazeState: HazeState?, + menuConfig: ReadMenuConfig, + isFloating: Boolean, +): Boolean { + return hazeState != null && + readMenuBottomBarEffectiveBlurMode(menuConfig, isFloating) == ReadMenuBlurMode.Haze +} + +@Composable +private fun Modifier.readMenuLiquidGlass( + backdrop: Backdrop?, + colors: ReadMenuColors, + shape: Shape, + useTopBarStyle: Boolean, + useLens: Boolean, + blurRadius: Dp? = null, + interactive: Boolean = false, + menuConfig: ReadMenuConfig, +): Modifier { + if (!readMenuLiquidGlassAvailable(backdrop)) return this + val animationScope = rememberCoroutineScope() + val interactiveHighlight = if (interactive) { + remember(animationScope) { InteractiveHighlight(animationScope = animationScope) } + } else { + null + } + val resolvedBlurRadius = blurRadius ?: menuConfig.readMenuBlurRadius.dp + val blurAlpha = menuConfig.readMenuBlurAlpha + val containerColor = colors.background.copy( + alpha = (blurAlpha.coerceIn(0, 100) / 100f).coerceAtMost(0.6f) + ) + val topBarSurfaceBrush = readMenuTopBarSurfaceBrush( + colors = colors, + alpha = containerColor.alpha, + ) + + return drawBackdrop( + backdrop = backdrop!!, + shape = { shape }, + effects = { + vibrancy() + blur(resolvedBlurRadius.coerceAtLeast(0.dp).toPx()) + if (useLens) { + val lensRadius = menuConfig.readMenuLensRadius + lens(lensRadius.dp.toPx(), lensRadius.dp.toPx()) + } + }, + highlight = { + Highlight.Default + }, + shadow = null, + layerBlock = if (interactiveHighlight != null) { + { + val width = size.width + val height = size.height + if (width > 0f && height > 0f) { + val progress = interactiveHighlight.pressProgress + val scale = 1f + 4.dp.toPx() / height * progress + val maxOffset = size.minDimension + val initialDerivative = 0.05f + val offset = interactiveHighlight.offset + translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset) + translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset) + + val maxDragScale = 4.dp.toPx() / height + val offsetAngle = atan2(offset.y, offset.x) + scaleX = scale + maxDragScale * + abs(cos(offsetAngle) * offset.x / size.maxDimension) * + (width / height).coerceAtMost(1f) + scaleY = scale + maxDragScale * + abs(sin(offsetAngle) * offset.y / size.maxDimension) * + (height / width).coerceAtMost(1f) + } + } + } else { + null + }, + onDrawSurface = { + if (useTopBarStyle) { + drawRect(topBarSurfaceBrush) + } else { + drawRect(containerColor) + } + }, + ) + .then(if (interactiveHighlight != null) interactiveHighlight.modifier else Modifier) + .then(if (interactiveHighlight != null) interactiveHighlight.gestureModifier else Modifier) +} + +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +private fun Modifier.readMenuBottomBarHazeEffect( + state: HazeState, + colors: ReadMenuColors, + shape: Shape, + menuConfig: ReadMenuConfig, + progressive: Boolean, +): Modifier { + val surfaceAlpha = menuConfig.readMenuBlurAlpha.coerceIn(0, 100) / 100f + val backgroundModifier = if (progressive) { + Modifier.background( + readMenuBottomBarSurfaceBrush( + colors = colors, + alpha = surfaceAlpha, + ) + ) + } else { + Modifier + } + return clip(shape) + .then(backgroundModifier) + .readMenuHazeEffect( + state = state, + colors = colors, + menuConfig = menuConfig, + progressive = progressive, + progressiveBottomToTop = progressive, + ) +} + +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +private fun Modifier.readMenuHazeEffect( + state: HazeState, + colors: ReadMenuColors, + menuConfig: ReadMenuConfig, + progressive: Boolean = false, + progressiveBottomToTop: Boolean = false, +): Modifier { + val surfaceAlpha = menuConfig.readMenuBlurAlpha.coerceIn(0, 100) / 100f + val hazeContainerColor = if (progressive) { + Color.Black.copy(alpha = surfaceAlpha) + } else { + colors.background.copy(alpha = surfaceAlpha) + } + val style = HazeLegado.custom( + containerColor = hazeContainerColor, + blurRadius = menuConfig.readMenuBlurRadius, + blurAlpha = menuConfig.readMenuBlurAlpha, + ) + + return hazeEffect( + state = state, + style = style, + ) { + this.progressive = if (progressive) { + HazeProgressive.verticalGradient( + startIntensity = if (progressiveBottomToTop) 0f else 1f, + endIntensity = if (progressiveBottomToTop) 1f else 0f, + ) + } else { + null + } + } +} + +@Composable +private fun readMenuTopBarSurfaceBrush( + colors: ReadMenuColors, + alpha: Float, +): Brush { + val topColor = colors.background.copy( + alpha = alpha.coerceIn(0f, 1f), + ) + val bottomColor = colors.background.copy( + alpha = (alpha * 0.72f).coerceIn(0f, 1f), + ) + return Brush.verticalGradient( + colors = listOf(topColor, bottomColor), + ) +} + +@Composable +private fun readMenuBottomBarSurfaceBrush( + colors: ReadMenuColors, + alpha: Float, +): Brush { + val strongColor = colors.background.copy( + alpha = alpha.coerceIn(0f, 1f), + ) + val weakColor = colors.background.copy( + alpha = (alpha * 0.72f).coerceIn(0f, 1f), + ) + return Brush.verticalGradient( + colors = listOf(weakColor, strongColor), + ) +} + +@Composable +private fun readMenuColors(): ReadMenuColors { + val themeBackground = LegadoTheme.colorScheme.surfaceContainerHigh + val themeContent = LegadoTheme.colorScheme.onSurface + return when (AppConfig.readBarStyle) { + 1 -> ReadMenuColors( + background = themeBackground, + content = themeContent, + ) + + 2 -> ReadMenuColors( + background = LegadoTheme.colorScheme.surfaceContainerHigh, + content = LegadoTheme.colorScheme.primary, + ) + + else -> ReadMenuColors(themeBackground, themeContent) + } +} + +// ========== Title Bar Icons ========== + +private data class TitleBarIconDef( + val id: String, + val icon: ImageVector, + val label: String, + val isActive: Boolean = false, + val onClick: () -> Unit, +) + +private fun loadFloatingIcons( + context: Context, + state: ReadBookUiState, + onIntent: (ReadBookIntent) -> Unit, +): List { + val infoMap = readMenuButtonInfos(context).associateBy { it.id } + + val actionMap: Map Unit> = mapOf( + "search" to { onIntent(ReadBookIntent.OpenSearch(null)) }, + "catalog" to { onIntent(ReadBookIntent.OpenChapterList) }, + "read_aloud" to { + if (state.isReadAloudRunning) { + onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.ReadAloud)) + } else { + onIntent(ReadBookIntent.ToggleReadAloud) + onIntent(ReadBookIntent.HideMenu) + } + }, + "setting" to { onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.ReadStyle)) }, + "addBookmark" to { onIntent(ReadBookIntent.AddBookmark) }, + "theme" to { onIntent(ReadBookIntent.ToggleDayNight) }, + "prev_chapter" to { onIntent(ReadBookIntent.PrevChapter) }, + "next_chapter" to { onIntent(ReadBookIntent.NextChapter) }, + "replace" to { onIntent(ReadBookIntent.ChangeReplaceRule(true)) }, + "replace_badge" to { onIntent(ReadBookIntent.ChangeReplaceRule(true)) }, + "auto_page" to { + if (state.isAutoPage) { + onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.AutoRead)) + } else { + onIntent(ReadBookIntent.ToggleAutoPage) + onIntent(ReadBookIntent.HideMenu) + } + }, + "translate" to { onIntent(ReadBookIntent.ToggleTranslation) }, + ) + + val activeIds = buildSet { + if (state.isReadAloudRunning) add("read_aloud") + if (state.isAutoPage) add("auto_page") + } + + return state.menuConfig.titleBarButtons + .asSequence() + .filter { it.enabled } + .mapNotNull { item -> + val id = item.id + val info = infoMap[id] ?: return@mapNotNull null + TitleBarIconDef( + id = id, + icon = info.icon, + label = info.label, + isActive = id in activeIds, + onClick = actionMap[id] ?: {}, + ) + } + .toList() +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt new file mode 100644 index 000000000..7ea96c5bf --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt @@ -0,0 +1,473 @@ +package io.legado.app.ui.book.read + +import android.content.Intent +import android.view.KeyEvent +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import io.legado.app.R +import io.legado.app.constant.ReadMenuBlurMode +import io.legado.app.help.IntentData +import io.legado.app.help.IntentHelp +import io.legado.app.model.ReadBook +import io.legado.app.utils.toastOnUi +import io.legado.app.ui.browser.WebViewActivity +import io.legado.app.ui.book.info.BookInfoActivity +import io.legado.app.ui.book.read.page.ContentTextView +import io.legado.app.ui.book.read.page.ReadView +import io.legado.app.ui.book.read.page.entities.PageDirection +import io.legado.app.ui.book.searchContent.SearchContentActivity +import io.legado.app.ui.book.searchContent.SearchResult +import io.legado.app.ui.book.source.edit.BookSourceEditActivity +import io.legado.app.ui.book.toc.TocActivityResult +import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity +import io.legado.app.ui.login.SourceLoginActivity +import io.legado.app.ui.replace.ReplaceEditRoute +import io.legado.app.ui.replace.ReplaceRuleActivity +import io.legado.app.utils.StartActivityContract +import io.legado.app.utils.takePersistablePermissionSafely +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield + + +data class ReadBookViewRefs( + val root: FrameLayout, + val readView: ReadView, + val textMenuPosition: View, + val cursorLeft: ImageView, + val cursorRight: ImageView, + val navigationBar: View, +) + +interface ReadBookRouteHost : + View.OnTouchListener, + ReadView.CallBack, + ContentTextView.CallBack { + + val isInMultiWindowModeCompat: Boolean + + fun closeReadBook() + + fun upSystemUiVisibility( + isInMultiWindow: Boolean, + toolBarHide: Boolean, + ) +} + +/** + * Narrow interface for hardware input delegation from Activity. + * MainActivity holds this instead of the full bridge/controller. + */ +interface ReadBookInputHandler { + fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean + fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean + fun mouseWheelPage(direction: PageDirection) + fun handleKeyPage(direction: PageDirection, longPress: Boolean = false) + fun toggleMenu() +} + +/** + * Outer wrapper for ReadBookScreen — handles system UI state sync + * and ActivityResult launcher registration. + */ +@Composable +fun ReadBookRouteScreen( + viewModel: ReadBookViewModel, + host: ReadBookRouteHost, + controller: ReadBookController, + onEffectsReady: () -> Unit = {}, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val readPreferences by viewModel.readPreferences.collectAsStateWithLifecycle() + val context = LocalContext.current + val menuBackdrop = rememberLayerBackdrop() + val menuHazeState = remember { HazeState() } + val useMenuHazeSource = state.menuConfig.readMenuTopBarBlurMode == ReadMenuBlurMode.Haze || + state.menuConfig.readMenuBottomBarBlurMode == ReadMenuBlurMode.Haze || + ( + !state.menuConfig.readMenuFloatingBottomBar && + state.menuConfig.readMenuBottomBarBlurMode == ReadMenuBlurMode.LiquidGlass + ) + + // ── ActivityResult Launchers ────────────────────────────────────── + + val tocLauncher = rememberLauncherForActivityResult(TocActivityResult()) { result -> + result?.let { (index, chapterPos, _) -> + viewModel.onIntent(ReadBookIntent.OpenChapterResult(index, chapterPos)) + } + } + + val sourceEditLauncher = rememberLauncherForActivityResult( + StartActivityContract(BookSourceEditActivity::class.java) + ) { result -> + if (result.resultCode == android.app.Activity.RESULT_OK) { + viewModel.onIntent(ReadBookIntent.SourceEditResult) + } + } + + val replaceLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == android.app.Activity.RESULT_OK) { + viewModel.onIntent(ReadBookIntent.ReplaceRuleResult) + } + } + + val fontFolderPicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocumentTree() + ) { uri -> + uri?.let { + it.takePersistablePermissionSafely(context) + viewModel.onIntent(ReadBookIntent.FontFolderSelected(it)) + } + } + + val booksDirPicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocumentTree() + ) { uri -> + uri?.let { + it.takePersistablePermissionSafely(context) + viewModel.onIntent(ReadBookIntent.BooksDirSelected(it)) + } + } + + val readStyleImagePicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + uri?.let { viewModel.onIntent(ReadBookIntent.ReadStyleImageSelected(it)) } + } + + var pendingReadStyleImageIsNight by remember { mutableStateOf(false) } + val readStyleImagePickerForMode = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + uri?.let { + viewModel.onIntent(ReadBookIntent.ReadStyleImageSelectedForMode(it, pendingReadStyleImageIsNight)) + } + } + + val readStyleImportPicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + uri?.let { viewModel.onIntent(ReadBookIntent.ReadStyleConfigImportSelected(it)) } + } + + val readStyleExportPicker = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/zip") + ) { uri -> + uri?.let { viewModel.onIntent(ReadBookIntent.ReadStyleConfigExportSelected(it)) } + } + + var pendingMenuCustomIconId by remember { mutableStateOf(null) } + val menuCustomIconPicker = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri -> + val id = pendingMenuCustomIconId + pendingMenuCustomIconId = null + if (id != null && uri != null) { + viewModel.onIntent(ReadBookIntent.SaveMenuCustomIcon(id, uri)) + } + } + + var pendingTitleBarCustomIconId by remember { mutableStateOf(null) } + val titleBarCustomIconPicker = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri -> + val id = pendingTitleBarCustomIconId + pendingTitleBarCustomIconId = null + if (id != null && uri != null) { + viewModel.onIntent(ReadBookIntent.SaveTitleBarCustomIcon(id, uri)) + } + } + + val txtTocRuleLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == android.app.Activity.RESULT_OK) { + result.data?.getStringExtra("tocRegex")?.let { rule -> + viewModel.onIntent(ReadBookIntent.TocRegexResult(rule)) + } + } + } + + val searchContentLauncher = rememberLauncherForActivityResult( + StartActivityContract(SearchContentActivity::class.java) + ) { result -> + val data = result.data ?: return@rememberLauncherForActivityResult + val key = data.getLongExtra("key", System.currentTimeMillis()) + val index = data.getIntExtra("index", 0) + val searchResult = IntentData.get("searchResult$key") + val searchResultList = IntentData.get>("searchResultList$key") + if (searchResult != null && searchResultList != null) { + viewModel.onIntent(ReadBookIntent.SetSearchResults(searchResultList, index, searchResult.query)) + } + } + + val bookInfoLauncher = rememberLauncherForActivityResult( + StartActivityContract(BookInfoActivity::class.java) + ) { result -> + viewModel.onIntent(ReadBookIntent.BookInfoResult(result.resultCode == android.app.Activity.RESULT_OK)) + } + + // ── Effect collection: route handles launcher effects, rest goes to bridge ── + + LaunchedEffect(viewModel) { + coroutineScope { + val collector = launch { + viewModel.effects.collect { effect -> + when (effect) { + // Launcher-dependent effects — handled directly by route + is ReadBookEffect.OpenChapterList -> { + tocLauncher.launch(effect.bookUrl) + } + is ReadBookEffect.OpenSourceEdit -> { + sourceEditLauncher.launch { putExtra("sourceUrl", effect.sourceUrl) } + } + is ReadBookEffect.OpenBookInfo -> { + bookInfoLauncher.launch { + putExtra("name", effect.name) + putExtra("author", effect.author) + putExtra("bookUrl", effect.bookUrl) + } + } + is ReadBookEffect.ShowLogin -> { + context.startActivity( + Intent(context, SourceLoginActivity::class.java).apply { + putExtra("type", "bookSource") + putExtra("key", effect.sourceUrl) + } + ) + } + is ReadBookEffect.OpenWebView -> { + context.startActivity( + Intent(context, WebViewActivity::class.java).apply { + putExtra("title", effect.title) + putExtra("url", effect.url) + putExtra("sourceOrigin", effect.sourceOrigin) + putExtra("sourceName", effect.sourceName) + effect.sourceType?.let { putExtra("sourceType", it) } + } + ) + } + is ReadBookEffect.OpenSearchActivity -> { + val currentState = viewModel.uiState.value + val lambda: (Intent.() -> Unit)? = { intent -> + intent.putExtra("bookUrl", effect.bookUrl) + intent.putExtra("searchWord", effect.word) + intent.putExtra("searchResultIndex", currentState.searchResultIndex) + currentState.searchResultList.firstOrNull()?.let { + if (it.query == currentState.searchContentQuery) { + IntentData.put("searchResultList", currentState.searchResultList) + } + } + } + searchContentLauncher.launch(lambda) + } + is ReadBookEffect.MenuSettingReplace -> { + replaceLauncher.launch(Intent(context, ReplaceRuleActivity::class.java)) + } + is ReadBookEffect.TextActionReplace -> { + val scopes = arrayListOf() + effect.bookName?.let { scopes.add(it) } + effect.bookSourceUrl?.let { scopes.add(it) } + val text = effect.text.lineSequence().map { it.trim() }.joinToString("\n") + val editRoute = ReplaceEditRoute( + id = -1, pattern = text, + scope = scopes.joinToString(";"), + isScopeTitle = false, isScopeContent = true, + ) + replaceLauncher.launch(ReplaceRuleActivity.startIntent(context, editRoute)) + } + is ReadBookEffect.OpenReplaceEditor -> { + val editRoute = ReplaceEditRoute(id = effect.id, pattern = effect.pattern) + replaceLauncher.launch(ReplaceRuleActivity.startIntent(context, editRoute)) + } + is ReadBookEffect.MenuTocRegex -> { + val intent = Intent(context, TxtTocRuleActivity::class.java) + intent.putExtra("tocRegex", effect.tocRegex) + txtTocRuleLauncher.launch(intent) + } + is ReadBookEffect.OpenFontFolderPicker -> { + fontFolderPicker.launch(null) + } + is ReadBookEffect.OpenBooksDirPicker -> { + booksDirPicker.launch(null) + } + is ReadBookEffect.OpenReadStyleImagePicker -> { + readStyleImagePicker.launch(arrayOf("image/*")) + } + is ReadBookEffect.OpenReadStyleImagePickerForMode -> { + pendingReadStyleImageIsNight = effect.isNight + readStyleImagePickerForMode.launch(arrayOf("image/*")) + } + is ReadBookEffect.OpenReadStyleImport -> { + readStyleImportPicker.launch( + arrayOf("application/zip", "application/octet-stream", "*/*") + ) + } + is ReadBookEffect.OpenReadStyleExport -> { + readStyleExportPicker.launch("readConfig.zip") + } + is ReadBookEffect.OpenMenuCustomIconPicker -> { + pendingMenuCustomIconId = effect.id + menuCustomIconPicker.launch("image/*") + } + is ReadBookEffect.OpenTitleBarCustomIconPicker -> { + pendingTitleBarCustomIconId = effect.id + titleBarCustomIconPicker.launch("image/*") + } + is ReadBookEffect.OpenSystemTtsSettings -> { + IntentHelp.openTTSSetting() + } + is ReadBookEffect.TtsCacheCleared -> { + context.toastOnUi(effect.message) + } + + // All other effects — delegate to bridge (View/Window/Activity operations) + else -> controller.handleEffect(effect) + } + } + } + yield() + onEffectsReady() + collector.join() + } + } + + // ── System UI sync ──────────────────────────────────────────────── + + LaunchedEffect(state.menuVisible) { + host.upSystemUiVisibility(host.isInMultiWindowModeCompat, !state.menuVisible) + } + // ── View layer + Compose UI ─────────────────────────────────────── + + Box(Modifier.fillMaxSize()) { + key(controller) { + ReadBookViewLayer( + modifier = Modifier + .then(if (useMenuHazeSource) Modifier.hazeSource(menuHazeState) else Modifier) + .layerBackdrop(menuBackdrop), + onRefsReady = { controller.onRefsReady(it) }, + onCursorTouch = controller, + readViewCallBack = controller, + contentTextViewCallBack = controller, + ) + } + ReadBookColorTheme( + styleConfig = state.styleConfig, + preferences = readPreferences, + ) { + ReadBookMenuBar( + state = state, + onIntent = viewModel::onIntent, + backdrop = menuBackdrop, + hazeState = if (useMenuHazeSource) menuHazeState else null, + ) + ReadBookSearchBar(state = state, onIntent = viewModel::onIntent) + ReadBookScreen( + state = state, + onIntent = viewModel::onIntent, + onBack = { controller.closeReadBook() }, + ) + } + } +} + +@Composable +private fun ReadBookViewLayer( + modifier: Modifier = Modifier, + onRefsReady: (ReadBookViewRefs) -> Unit, + onCursorTouch: View.OnTouchListener, + readViewCallBack: ReadView.CallBack, + contentTextViewCallBack: ContentTextView.CallBack, +) { + AndroidView( + modifier = modifier.fillMaxSize(), + factory = { context -> + FrameLayout(context).apply { + val readView = ReadView( + context = context, + callBack = readViewCallBack, + contentCallBack = contentTextViewCallBack, + ).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ) + } + val textMenuPosition = View(context).apply { + id = R.id.text_menu_position + visibility = View.INVISIBLE + layoutParams = FrameLayout.LayoutParams(0, 0) + } + val cursorLeft = ImageView(context).apply { + id = R.id.cursor_left + contentDescription = context.getString(R.string.select_start) + setImageResource(R.drawable.ic_cursor_left) + visibility = View.INVISIBLE + setOnTouchListener(onCursorTouch) + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + ) + } + val cursorRight = ImageView(context).apply { + id = R.id.cursor_right + contentDescription = context.getString(R.string.select_end) + setImageResource(R.drawable.ic_cursor_right) + visibility = View.INVISIBLE + setOnTouchListener(onCursorTouch) + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + ) + } + val navigationBar = View(context).apply { + id = R.id.navigation_bar + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + 0, + android.view.Gravity.BOTTOM, + ) + } + + addView(readView) + addView(textMenuPosition) + addView(cursorLeft) + addView(cursorRight) + addView(navigationBar) + + onRefsReady( + ReadBookViewRefs( + root = this, + readView = readView, + textMenuPosition = textMenuPosition, + cursorLeft = cursorLeft, + cursorRight = cursorRight, + navigationBar = navigationBar, + ) + ) + } + }, + ) +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt new file mode 100644 index 000000000..50b4575ee --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt @@ -0,0 +1,414 @@ +package io.legado.app.ui.book.read + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.R +import io.legado.app.ui.book.info.ChangeSourceSheet +import io.legado.app.ui.widget.components.log.AppLogSheet +import io.legado.app.ui.book.read.sheet.BgTextConfigSheet +import io.legado.app.ui.book.read.sheet.ChangeChapterSourceSheet +import io.legado.app.ui.book.read.sheet.CharsetConfigSheet +import io.legado.app.ui.book.read.sheet.ClickActionConfigSheet +import io.legado.app.ui.book.read.sheet.ContentEditSheet +import io.legado.app.ui.book.read.sheet.DictSheet +import io.legado.app.ui.book.read.sheet.DownloadSheet +import io.legado.app.ui.book.read.sheet.EffectiveReplacesSheet +import io.legado.app.ui.book.read.sheet.FontSelectSheet +import io.legado.app.ui.book.read.sheet.MoreConfigSheet +import io.legado.app.ui.book.read.sheet.PageAnimConfigSheet +import io.legado.app.ui.book.read.sheet.PageKeyConfigSheet +import io.legado.app.ui.book.read.sheet.PhotoSheet +import io.legado.app.ui.book.read.sheet.ReadAloudConfigSheet +import io.legado.app.ui.book.read.sheet.ReadAloudNumberConfigSheet +import io.legado.app.ui.book.read.sheet.HighlightRuleConfigSheet +import io.legado.app.ui.book.read.sheet.ShadowSetSheet +import io.legado.app.ui.book.read.sheet.SimulatedReadingSheet +import io.legado.app.ui.book.read.sheet.SpeakEngineConfigSheet +import io.legado.app.ui.book.read.sheet.TitleBarIconSheet +import io.legado.app.ui.book.read.sheet.ToolButtonConfigSheet +import io.legado.app.ui.book.read.sheet.UnderlineConfigSheet +import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.flow.collectLatest + +/** + * Stateless ReadBook screen — renders BackHandler + dialogs + sheets. + * ReadView is hosted in the XML layout, not here. + */ +@Composable +fun ReadBookScreen( + state: ReadBookUiState, + onIntent: (ReadBookIntent) -> Unit, + onBack: () -> Unit, +) { + BackHandler { + when { + state.isShowingSearchResult -> onIntent(ReadBookIntent.ExitSearch) + state.menuVisible -> onIntent(ReadBookIntent.ReadMenuBack) + state.isAutoPage -> onIntent(ReadBookIntent.StopAutoPage) + else -> onBack() + } + } + + // Dialogs driven by activeDialog state + when (val dialog = state.activeDialog) { + is ReadBookDialog.ConfirmRestoreProgress -> { + AppAlertDialog( + show = true, + onDismissRequest = { onIntent(ReadBookIntent.DismissDialog) }, + title = stringResource(R.string.restore_progress), + text = stringResource(R.string.found_cloud_progress), + confirmText = stringResource(R.string.ok), + onConfirm = { + onIntent(ReadBookIntent.SureNewProgress(dialog.progress)) + onIntent(ReadBookIntent.DismissDialog) + }, + dismissText = stringResource(R.string.cancel), + onDismiss = { onIntent(ReadBookIntent.DismissDialog) }, + ) + } + + is ReadBookDialog.SureSyncProgress -> { + AppAlertDialog( + show = true, + onDismissRequest = { onIntent(ReadBookIntent.DismissDialog) }, + title = stringResource(R.string.sync_progress), + text = stringResource(R.string.progress_exceeds_cloud), + confirmText = stringResource(R.string.ok), + onConfirm = { + onIntent(ReadBookIntent.SureSyncProgress(dialog.progress)) + onIntent(ReadBookIntent.DismissDialog) + }, + dismissText = stringResource(R.string.cancel), + onDismiss = { onIntent(ReadBookIntent.DismissDialog) }, + ) + } + + is ReadBookDialog.ConfirmSkipToChapter -> { + AppAlertDialog( + show = true, + onDismissRequest = { onIntent(ReadBookIntent.DismissDialog) }, + title = stringResource(R.string.chapter_list), + text = stringResource(R.string.confirm_skip_to_chapter), + confirmText = stringResource(R.string.ok), + onConfirm = { onIntent(ReadBookIntent.DismissDialog) }, + dismissText = stringResource(R.string.cancel), + onDismiss = { onIntent(ReadBookIntent.DismissDialog) }, + ) + } + + is ReadBookDialog.ConfirmChapterPay -> { + AppAlertDialog( + show = true, + onDismissRequest = { onIntent(ReadBookIntent.DismissDialog) }, + title = stringResource(R.string.chapter_pay), + text = dialog.chapterTitle, + confirmText = stringResource(R.string.ok), + onConfirm = { + onIntent(ReadBookIntent.DismissDialog) + onIntent(ReadBookIntent.ConfirmPayAction) + }, + dismissText = stringResource(R.string.cancel), + onDismiss = { onIntent(ReadBookIntent.DismissDialog) }, + ) + } + + null -> {} + } + + // AppModalBottomSheet-based sheets — always composed, controlled by show flag + // for proper enter/exit animations + val dismissSheet = { onIntent(ReadBookIntent.DismissSheet) } + + ShadowSetSheet( + show = state.activeSheet is ReadBookSheet.ShadowSet, + onDismissRequest = dismissSheet, + onIntent = onIntent, + ) + EffectiveReplacesSheet( + show = state.activeSheet is ReadBookSheet.EffectiveReplaces, + onDismissRequest = dismissSheet, + onOpenReplaceEditor = { id, pattern -> + onIntent(ReadBookIntent.OpenReplaceEditor(id, pattern)) + }, + onReplaceRuleChanged = { onIntent(ReadBookIntent.ReplaceRuleChanged) }, + ) + UnderlineConfigSheet( + show = state.activeSheet is ReadBookSheet.UnderlineConfig, + onDismissRequest = dismissSheet, + onIntent = onIntent, + ) + FontSelectSheet( + show = state.activeSheet is ReadBookSheet.FontSelect, + onDismissRequest = dismissSheet, + onSelectFont = { onIntent(ReadBookIntent.SelectFont(it)) }, + onSelectSystemTypeface = { onIntent(ReadBookIntent.SelectSystemTypeface(it)) }, + onOpenFolderPicker = { onIntent(ReadBookIntent.OpenFontFolderPicker) }, + ) + ToolButtonConfigSheet( + show = state.activeSheet is ReadBookSheet.ToolButtonConfig, + items = state.menuConfig.bottomBarButtons, + customIcons = state.menuConfig.readMenuCustomIcons, + onDismissRequest = dismissSheet, + onIntent = onIntent, + ) + TitleBarIconSheet( + show = state.activeSheet is ReadBookSheet.TitleBarIconConfig, + items = state.menuConfig.titleBarButtons, + customIcons = state.menuConfig.titleBarCustomIcons, + onDismissRequest = dismissSheet, + onIntent = onIntent, + ) + HighlightRuleConfigSheet( + show = state.activeSheet is ReadBookSheet.HighlightRuleConfig, + onDismissRequest = dismissSheet, + onIntent = onIntent, + ) + ContentEditSheet( + show = state.activeSheet is ReadBookSheet.ContentEdit, + state = state, + onIntent = onIntent, + onDismissRequest = dismissSheet, + ) + MoreConfigSheet( + show = state.activeSheet is ReadBookSheet.MoreConfig, + onDismissRequest = dismissSheet, + onIntent = onIntent, + onOpenClickRegionalConfig = { + onIntent(ReadBookIntent.DismissSheet) + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ClickActionConfig)) + }, + onOpenPageKeyConfig = { + onIntent(ReadBookIntent.DismissSheet) + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.PageKeyConfig)) + }, + ) + ReadAloudConfigSheet( + show = state.activeSheet is ReadBookSheet.ReadAloudConfig, + state = state, + onIntent = onIntent, + onDismissRequest = dismissSheet, + ) + SpeakEngineConfigSheet( + show = state.activeSheet is ReadBookSheet.SpeakEngineConfig, + items = state.ttsEngineItems, + selectedValue = state.selectedTtsEngine, + onSelect = { onIntent(ReadBookIntent.ApplySpeakEngine(it)) }, + onDismissRequest = { + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ReadAloudConfig)) + }, + ) + ReadAloudNumberConfigSheet( + show = state.activeSheet is ReadBookSheet.PreDownloadConfig, + title = stringResource(R.string.read_aloud_preload), + description = stringResource(R.string.read_aloud_preload_summary, state.preDownloadNum), + value = state.preDownloadNum, + defaultValue = 10, + valueRange = 0f..100f, + onValueChange = { onIntent(ReadBookIntent.ApplyPreDownloadNum(it)) }, + onDismissRequest = { + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ReadAloudConfig)) + }, + ) + ReadAloudNumberConfigSheet( + show = state.activeSheet is ReadBookSheet.AudioCacheCleanConfig, + title = stringResource(R.string.audio_cache_clean_time), + description = stringResource( + R.string.audio_cache_clean_time_summary, + state.audioCacheCleanTime + ), + value = state.audioCacheCleanTime, + defaultValue = 10, + valueRange = 0f..10080f, + onValueChange = { onIntent(ReadBookIntent.ApplyAudioCacheCleanTime(it)) }, + onDismissRequest = { + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ReadAloudConfig)) + }, + ) + AppLogSheet( + show = state.activeSheet is ReadBookSheet.AppLog, + onDismissRequest = dismissSheet, + ) + BgTextConfigSheet( + show = state.activeSheet is ReadBookSheet.BgTextConfig, + onDismissRequest = dismissSheet, + onIntent = onIntent, + onSelectImage = { onIntent(ReadBookIntent.OpenReadStyleImagePicker) }, + onSelectImageForMode = { isNight -> + onIntent(ReadBookIntent.OpenReadStyleImagePickerForMode(isNight)) + }, + onImportConfig = { onIntent(ReadBookIntent.OpenReadStyleImport) }, + onExportConfig = { onIntent(ReadBookIntent.OpenReadStyleExport) }, + styleConfig = state.styleConfig, + ) + val dictSheet = state.activeSheet as? ReadBookSheet.Dict + DictSheet( + show = dictSheet != null, + word = dictSheet?.word ?: "", + onDismissRequest = dismissSheet, + ) + val photoSheet = state.activeSheet as? ReadBookSheet.Photo + PhotoSheet( + show = photoSheet != null, + src = photoSheet?.src ?: "", + sourceOrigin = photoSheet?.sourceOrigin, + onDismissRequest = dismissSheet, + ) + + // AlertDialog-based sheets and special cases — conditionally composed + when (state.activeSheet) { + is ReadBookSheet.ClickActionConfig -> { + ClickActionConfigSheet( + onDismissRequest = dismissSheet, + ) + } + + is ReadBookSheet.PageKeyConfig -> { + PageKeyConfigSheet( + onDismissRequest = dismissSheet, + ) + } + + is ReadBookSheet.PageAnim -> { + PageAnimConfigSheet( + onDismissRequest = dismissSheet, + onAnimChanged = { onIntent(ReadBookIntent.PageAnimChanged) }, + ) + } + + is ReadBookSheet.Download -> { + DownloadSheet( + onDismissRequest = dismissSheet, + onDownload = { start, end -> + onIntent(ReadBookIntent.DismissSheet) + onIntent(ReadBookIntent.DownloadChapters(start, end)) + }, + ) + } + + is ReadBookSheet.Charset -> { + CharsetConfigSheet( + onDismissRequest = dismissSheet, + ) + } + + is ReadBookSheet.SimulatedReading -> { + SimulatedReadingSheet( + onDismissRequest = dismissSheet, + onApply = { onIntent(ReadBookIntent.ApplySimulatedReading) }, + ) + } + + is ReadBookSheet.Bookmark -> { + // Handled by ViewModel — redirects to menu route + } + + is ReadBookSheet.InfoConfig -> { + // Integrated into ReadStyleSheet's HeaderFooterPage + LaunchedEffect(state.activeSheet) { + onIntent(ReadBookIntent.DismissSheet) + } + } + + is ReadBookSheet.ChangeChapterSource -> { + val sheet = state.activeSheet + val book = state.book + if (book != null) { + var showSheet by remember { mutableStateOf(true) } + LaunchedEffect(showSheet) { + if (!showSheet) { + kotlinx.coroutines.delay(300) + onIntent(ReadBookIntent.SetActiveSheet(null)) + } + } + val viewModel = androidx.compose.runtime.key( + "chapter-source-${book.bookUrl}-${sheet.chapterIndex}" + ) { + org.koin.androidx.compose.koinViewModel() + } + androidx.compose.runtime.DisposableEffect(viewModel) { + onDispose { viewModel.dispose() } + } + LaunchedEffect(book.bookUrl, sheet.chapterIndex) { + viewModel.initData( + book, + sheet.chapterIndex, + sheet.chapterTitle + ) + } + val context = androidx.compose.ui.platform.LocalContext.current + ChangeChapterSourceSheet( + state = viewModel.uiState.collectAsStateWithLifecycle().value, + onIntent = viewModel::onIntent, + show = showSheet, + onDismissRequest = { showSheet = false }, + onAnimationFinish = { onIntent(ReadBookIntent.SetActiveSheet(null)) }, + bookScoreFlow = viewModel::bookScoreFlow, + onBookScoreClick = viewModel::onBookScoreClick, + onEditSource = { sourceUrl -> + onIntent(ReadBookIntent.OpenSourceEditByUrl(sourceUrl)) + }, + ) + // Handle ReplaceContent effect + LaunchedEffect(viewModel) { + viewModel.effects.collectLatest { effect -> + when (effect) { + is io.legado.app.ui.book.changesource.ChangeChapterSourceEffect.ReplaceContent -> { + showSheet = false + onIntent(ReadBookIntent.SaveChapterContent(effect.content, sheet.chapterIndex)) + } + + is io.legado.app.ui.book.changesource.ChangeChapterSourceEffect.ShowToast -> { + context.toastOnUi(effect.message) + } + + is io.legado.app.ui.book.changesource.ChangeChapterSourceEffect.Dismiss -> { + // Handled by showSheet animation — no-op + } + } + } + } + } else { + LaunchedEffect(sheet) { + onIntent(ReadBookIntent.DismissSheet) + } + } + } + + is ReadBookSheet.ChangeBookSource -> { + val sheet = state.activeSheet + val book = state.book + if (book != null) { + ChangeSourceSheet( + show = true, + oldBook = book, + onDismissRequest = { onIntent(ReadBookIntent.DismissSheet) }, + onReplace = { _, newBook, toc, _ -> + onIntent(ReadBookIntent.DismissSheet) + onIntent(ReadBookIntent.ChangeSource(newBook, toc)) + }, + onAddAsNew = { newBook, toc -> + onIntent(ReadBookIntent.DismissSheet) + onIntent(ReadBookIntent.AddSourceAsNewBook(newBook, toc)) + }, + ) + } else { + LaunchedEffect(sheet) { + onIntent(ReadBookIntent.DismissSheet) + } + } + } + + null -> {} + + // Sheets using AppModalBottomSheet are composed unconditionally above + else -> {} + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookSearchBar.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookSearchBar.kt new file mode 100644 index 000000000..0593368fa --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookSearchBar.kt @@ -0,0 +1,246 @@ +package io.legado.app.ui.book.read + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Compose replacement for SearchMenu — search result navigation overlay. + */ +@Composable +fun ReadBookSearchBar( + state: ReadBookUiState, + onIntent: (ReadBookIntent) -> Unit, +) { + val searchVisible = state.isShowingSearchResult && + !(state.menuVisible && state.menuState.currentRoute != ReadBookMenuRoute.Main) + val hasResults = state.searchResultList.isNotEmpty() + val currentIndex = state.searchResultIndex + val totalResults = state.searchResultList.size + val currentResult = if (hasResults && currentIndex in state.searchResultList.indices) { + state.searchResultList[currentIndex] + } else null + + Box(Modifier.fillMaxSize()) { + // Left FAB - previous result + AnimatedVisibility( + visible = searchVisible && hasResults && currentIndex > 0, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 16.dp), + ) { + SmallFloatingActionButton( + onClick = { + val prevIndex = currentIndex - 1 + onIntent( + ReadBookIntent.NavigateToSearchResult( + state.searchResultList[prevIndex], prevIndex + ) + ) + }, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Previous", + modifier = Modifier.size(20.dp), + ) + } + } + + // Right FAB - next result + AnimatedVisibility( + visible = searchVisible && hasResults && currentIndex < totalResults - 1, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 16.dp), + ) { + SmallFloatingActionButton( + onClick = { + val nextIndex = currentIndex + 1 + onIntent( + ReadBookIntent.NavigateToSearchResult( + state.searchResultList[nextIndex], nextIndex + ) + ) + }, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Next", + modifier = Modifier.size(20.dp), + ) + } + } + + // Tap background to dismiss search menu + AnimatedVisibility( + visible = searchVisible && state.searchMenuVisible, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { onIntent(ReadBookIntent.HideSearchMenu) } + ) + } + + // Bottom menu + AnimatedVisibility( + visible = searchVisible && state.searchMenuVisible, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter), + ) { + SearchBottomMenu( + state = state, + currentResult = currentResult, + onIntent = onIntent, + ) + } + } +} + +@Composable +private fun SearchBottomMenu( + state: ReadBookUiState, + currentResult: io.legado.app.ui.book.searchContent.SearchResult?, + onIntent: (ReadBookIntent) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(bottom = 16.dp), + ) { + // Search progress info + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + // Fraction: "3 / 10" + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${state.searchResultIndex + 1} / ${state.searchResultList.size}", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + val percent = if (state.searchResultList.isNotEmpty()) { + ((state.searchResultIndex + 1) * 100 / state.searchResultList.size) + } else 0 + Text( + text = "$percent%", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Current chapter + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) { + Text( + text = state.chapterName, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + ) + } + } + + Spacer(Modifier.height(4.dp)) + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + TextButton( + onClick = { onIntent(ReadBookIntent.OpenSearch(null)) }, + modifier = Modifier.weight(1f), + ) { + Icon( + Icons.Default.Search, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(4.dp)) + Text("搜索内容") + } + TextButton( + onClick = { onIntent(ReadBookIntent.ShowMenu) }, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Default.Menu, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + Text("主菜单") + } + IconButton( + onClick = { onIntent(ReadBookIntent.ExitSearch) }, + ) { + Icon(Icons.Default.Close, contentDescription = "Exit search") + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt index f13f172bd..e9ca91a4f 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt @@ -1,45 +1,88 @@ package io.legado.app.ui.book.read import android.app.Application +import android.content.Context import android.content.Intent -import androidx.lifecycle.MutableLiveData +import android.net.Uri +import android.provider.OpenableColumns +import androidx.lifecycle.viewModelScope +import io.legado.app.BuildConfig import io.legado.app.R import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppLog import io.legado.app.constant.BookType import io.legado.app.constant.EventBus +import io.legado.app.constant.PreferKey +import io.legado.app.constant.ReadMenuBlurMode +import io.legado.app.constant.Status import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookProgress +import io.legado.app.data.entities.Bookmark +import io.legado.app.data.repository.ReadAloudSettingsRepository +import io.legado.app.data.repository.ReadBookStyleConfigRepository +import io.legado.app.data.repository.ReadPreferences +import io.legado.app.data.repository.ReadSettingsRepository import io.legado.app.domain.model.ReadingProgress import io.legado.app.domain.usecase.GetReadingProgressUseCase import io.legado.app.domain.usecase.UploadReadingProgressUseCase import io.legado.app.exception.NoStackTraceException import io.legado.app.help.book.BookHelp import io.legado.app.help.book.ContentProcessor +import io.legado.app.help.book.isEpub import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocalModified +import io.legado.app.help.book.isLocalTxt +import io.legado.app.help.book.isMobi import io.legado.app.help.book.removeType import io.legado.app.help.book.simulatedTotalChapterNum import io.legado.app.help.config.AppConfig +import io.legado.app.help.config.ReadBookConfig +import io.legado.app.ui.config.themeConfig.ThemeConfig +import io.legado.app.ui.book.read.config.HighlightRuleStore import io.legado.app.help.coroutine.Coroutine +import io.legado.app.help.source.getSourceType import io.legado.app.model.ImageProvider import io.legado.app.model.ReadAloud import io.legado.app.model.ReadBook import io.legado.app.model.SourceCallBack +import io.legado.app.model.analyzeRule.AnalyzeRule +import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter +import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext import io.legado.app.model.localBook.LocalBook import io.legado.app.model.webBook.WebBook import io.legado.app.service.BaseReadAloudService import io.legado.app.ui.book.read.page.entities.TextChapter +import io.legado.app.ui.book.read.page.entities.TextPage +import io.legado.app.ui.book.read.page.provider.ChapterProvider +import io.legado.app.ui.book.read.page.provider.TextChapterLayout +import splitties.init.appCtx import io.legado.app.ui.book.searchContent.SearchResult import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.utils.ImageSaveUtils +import io.legado.app.utils.NetworkUtils +import io.legado.app.utils.hexString +import io.legado.app.utils.isAbsUrl +import io.legado.app.utils.isTrue import io.legado.app.utils.mapParallelSafe import io.legado.app.utils.postEvent +import io.legado.app.utils.putPrefInt import io.legado.app.utils.toStringArray import io.legado.app.utils.toastOnUi +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flow @@ -48,29 +91,1335 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEmpty import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.FileNotFoundException import kotlin.coroutines.coroutineContext /** - * 阅读界面数据处理 + * 阅读界面 ViewModel — MVI/UDF 架构 + * + * 实现 ReadBook.CallBack,桥接 ReadBook 单例回调到 StateFlow/Effect。 + * 保留 BaseViewModel 的 execute {} 模式用于后台任务。 */ class ReadBookViewModel( application: Application, private val getReadingProgressUseCase: GetReadingProgressUseCase, private val uploadReadingProgressUseCase: UploadReadingProgressUseCase, - val translateChapterUseCase: io.legado.app.domain.usecase.TranslateChapterUseCase -) : BaseViewModel(application) { - val permissionDenialLiveData = MutableLiveData() - var isInitFinish = false - var searchContentQuery = "" - var searchResultList: List? = null - var searchResultIndex: Int = 0 + val translateChapterUseCase: io.legado.app.domain.usecase.TranslateChapterUseCase, + private val readSettingsRepository: ReadSettingsRepository, + private val readBookStyleConfigRepository: ReadBookStyleConfigRepository, + private val readAloudSettingsRepository: ReadAloudSettingsRepository +) : BaseViewModel(application), ReadBook.CallBack { + + // --- MVI State --- + + private val _uiState = MutableStateFlow(ReadBookUiState()) + val uiState = _uiState.asStateFlow() + + private val _effects = MutableSharedFlow(extraBufferCapacity = 16) + val effects = _effects.asSharedFlow() + + private val _readPreferences = MutableStateFlow(ReadPreferences()) + val readPreferences = _readPreferences.asStateFlow() + private var changeSourceCoroutine: Coroutine<*>? = null + private var pendingBooksDirReloadChapterList: Boolean = false + + val isInitFinish: Boolean get() = _uiState.value.isInitFinish + + fun setAutoPage(active: Boolean) { + _uiState.update { it.copy(isAutoPage = active) } + } init { AppConfig.detectClickArea() + ReadBook.register(this) + refreshButtonConfigs() + collectReadPreferences() + collectReadAloudPreferences() + collectEventBus() } + // --- MVI Intent Dispatcher --- + + fun onIntent(intent: ReadBookIntent) { + when (intent) { + is ReadBookIntent.InitData -> { + justInitData = true + initData(intent.intent) + } + is ReadBookIntent.InitReadBookConfig -> initReadBookConfig(intent.intent) + is ReadBookIntent.NextPage -> ReadBook.moveToNextPage() + is ReadBookIntent.PrevPage -> ReadBook.moveToPrevPage() + is ReadBookIntent.NextChapter -> ReadBook.moveToNextChapter(upContent = true) + is ReadBookIntent.PrevChapter -> ReadBook.moveToPrevChapter(upContent = true) + is ReadBookIntent.OpenChapter -> openChapter(intent.index, intent.pos) + is ReadBookIntent.SkipToPage -> ReadBook.skipToPage(intent.pageIndex) + is ReadBookIntent.ToggleMenu -> _uiState.update { + if (it.menuVisible) { + readBookStyleConfigRepository.save() + it.copy(menuState = ReadBookMenuState()) + } else { + it.copy(menuState = ReadBookMenuState(visible = true)) + } + } + + is ReadBookIntent.ShowMenu -> _uiState.update { + it.copy(menuState = ReadBookMenuState(visible = true)) + } + + is ReadBookIntent.HideMenu -> _uiState.update { + readBookStyleConfigRepository.save() + it.copy(menuState = ReadBookMenuState()) + } + + is ReadBookIntent.OpenReadMenuRoute -> _uiState.update { + val currentStack = it.menuState.routeStack + val nextStack = if (currentStack.lastOrNull() == intent.route) { + currentStack + } else { + (currentStack + intent.route).toImmutableList() + } + it.copy( + menuState = it.menuState.copy( + visible = true, + routeStack = nextStack, + ), + ) + } + + is ReadBookIntent.ReadMenuBack -> _uiState.update { + if (it.menuState.canNavigateBack) { + readBookStyleConfigRepository.save() + val nextStack = it.menuState.routeStack.dropLast(1).toImmutableList() + it.copy(menuState = it.menuState.copy(routeStack = nextStack)) + } else { + readBookStyleConfigRepository.save() + it.copy(menuState = ReadBookMenuState()) + } + } + + is ReadBookIntent.OpenSearch -> { + _uiState.update { it.copy(searchContentQuery = intent.word ?: "") } + ReadBook.book?.bookUrl?.let { bookUrl -> + _effects.tryEmit(ReadBookEffect.OpenSearchActivity(intent.word, bookUrl)) + } + } + + is ReadBookIntent.ExitSearch -> exitSearch() + is ReadBookIntent.ShowSearchMenu -> _uiState.update { it.copy(searchMenuVisible = true) } + is ReadBookIntent.HideSearchMenu -> _uiState.update { it.copy(searchMenuVisible = false) } + is ReadBookIntent.SetSearchResults -> { + _uiState.update { + it.copy( + searchResultList = intent.results.toImmutableList(), + searchResultIndex = intent.index, + isShowingSearchResult = true, + searchContentQuery = intent.query ?: it.searchContentQuery, + ) + } + } + + is ReadBookIntent.SetSearchResultIndex -> { + _uiState.update { it.copy(searchResultIndex = intent.index) } + } + + is ReadBookIntent.SetShowingSearchResult -> { + _uiState.update { it.copy(isShowingSearchResult = intent.value) } + } + + is ReadBookIntent.NavigateToSearchResult -> { + _uiState.update { it.copy(searchResultIndex = intent.index) } + _effects.tryEmit(ReadBookEffect.NavigateToSearchResult(intent.result)) + } + + is ReadBookIntent.ToggleReadAloud -> { + if (!BaseReadAloudService.isRun) { + openReadMenuRoute(ReadBookMenuRoute.ReadAloud) + } + _effects.tryEmit(ReadBookEffect.ToggleReadAloud) + } + + is ReadBookIntent.ToggleAutoPage -> _effects.tryEmit(ReadBookEffect.ToggleAutoPage) + is ReadBookIntent.StopAutoPage -> _effects.tryEmit(ReadBookEffect.StopAutoPage) + is ReadBookIntent.RefreshCurrentChapter -> refreshCurrentChapter() + is ReadBookIntent.RefreshAllChapters -> refreshAllChapters() + is ReadBookIntent.RefreshContentAfter -> refreshContentAfter() + is ReadBookIntent.ChangeReplaceRule -> changeReplaceRule(intent.enabled) + is ReadBookIntent.ToggleTranslation -> toggleTranslation() + is ReadBookIntent.ChangeSource -> changeTo(intent.book, intent.toc) + is ReadBookIntent.AddSourceAsNewBook -> addToBookshelf(intent.book, intent.toc) + is ReadBookIntent.OpenChapterResult -> openChapter(intent.index, intent.chapterPos) + is ReadBookIntent.SourceEditResult -> upBookSource() + is ReadBookIntent.ReplaceRuleResult -> replaceRuleChanged() + is ReadBookIntent.BookInfoResult -> { + if (intent.bookDeleted) { + _effects.tryEmit(ReadBookEffect.Finish) + } else { + ReadBook.loadOrUpContent() + } + } + is ReadBookIntent.FontFolderSelected -> { + setFontFolder(intent.uri.toString()) + _uiState.update { it.copy(activeSheet = null) } + _uiState.update { it.copy(activeSheet = ReadBookSheet.FontSelect) } + } + is ReadBookIntent.SureNewProgress -> ReadBook.setProgress(intent.progress) + is ReadBookIntent.SureSyncProgress -> ReadBook.setProgress(intent.progress) + is ReadBookIntent.AddBookmark -> handleAddBookmark() + is ReadBookIntent.SaveBookmark -> saveBookmark(intent.bookmark) + is ReadBookIntent.DeleteBookmark -> deleteBookmark(intent.bookmark) + is ReadBookIntent.CancelSelect -> _effects.tryEmit(ReadBookEffect.CancelSelect) + is ReadBookIntent.UpSystemUiVisibility -> _effects.tryEmit(ReadBookEffect.UpSystemUiVisibility) + is ReadBookIntent.UpContent -> ReadBook.loadOrUpContent() + is ReadBookIntent.SetBrightness -> _effects.tryEmit(ReadBookEffect.SetBrightness(intent.value)) + is ReadBookIntent.ToggleBrightnessAuto -> _effects.tryEmit(ReadBookEffect.ToggleBrightnessAuto) + is ReadBookIntent.SeekToChapter -> { + ReadBook.saveCurrentBookProgress() + openChapter(intent.index) + } + + is ReadBookIntent.ShowSheet -> { + if (intent.sheet is ReadBookSheet.Bookmark) { + // Bookmark is shown as a menu route, not a sheet + openReadMenuRoute(ReadBookMenuRoute.Bookmark(intent.sheet.bookmark)) + } else { + _uiState.update { it.copy(activeSheet = intent.sheet) } + } + } + is ReadBookIntent.DismissSheet -> _uiState.update { + if (it.activeSheet is ReadBookSheet.ContentEdit) { + it.copy( + activeSheet = null, + contentEditText = "", + contentEditTitle = "", + contentEditLoading = false, + contentEditSaveToSource = false, + ) + } else { + it.copy(activeSheet = null) + } + } + is ReadBookIntent.SetActiveSheet -> _uiState.update { + it.copy(activeSheet = intent.sheet) + } + is ReadBookIntent.ShowDialog -> _uiState.update { it.copy(activeDialog = intent.dialog) } + is ReadBookIntent.DismissDialog -> _uiState.update { it.copy(activeDialog = null) } + is ReadBookIntent.ShowLogin -> { + ReadBook.bookSource?.bookSourceUrl?.let { sourceUrl -> + _effects.tryEmit(ReadBookEffect.ShowLogin(sourceUrl)) + } + } + is ReadBookIntent.PayAction -> showPayDialog() + is ReadBookIntent.ConfirmPayAction -> confirmPayAction() + is ReadBookIntent.DisableSource -> disableSource() + is ReadBookIntent.OpenSourceEditByUrl -> { + _effects.tryEmit(ReadBookEffect.OpenSourceEdit(intent.sourceUrl)) + } + is ReadBookIntent.OpenSourceEdit -> { + ReadBook.bookSource?.let { src -> + _effects.tryEmit(ReadBookEffect.OpenSourceEdit(src.bookSourceUrl)) + } + } + is ReadBookIntent.OpenBookInfo -> { + ReadBook.book?.let { book -> + _effects.tryEmit(ReadBookEffect.OpenBookInfo(book.name, book.author, book.bookUrl)) + } + } + is ReadBookIntent.OpenChapterList -> { + ReadBook.book?.bookUrl?.let { bookUrl -> + _effects.tryEmit(ReadBookEffect.OpenChapterList(bookUrl)) + } + } + is ReadBookIntent.LoadContentEdit -> loadContentEdit() + is ReadBookIntent.SaveContentEdit -> saveContentEdit(intent.content, intent.saveToSource) + is ReadBookIntent.ResetContentEdit -> resetContentEdit() + is ReadBookIntent.SetContentEditText -> { + _uiState.update { it.copy(contentEditText = intent.text) } + } + is ReadBookIntent.SetContentEditSaveToSource -> { + _uiState.update { it.copy(contentEditSaveToSource = intent.value) } + } + is ReadBookIntent.RefreshImage -> refreshImage(intent.src) + is ReadBookIntent.SaveImage -> saveImage(intent.src) + is ReadBookIntent.ReverseContent -> reverseContent() + is ReadBookIntent.ReverseRemoveSameTitle -> reverseRemoveSameTitle() + is ReadBookIntent.RetranslateCurrentChapter -> retranslateCurrentChapter() + // Menu actions + is ReadBookIntent.MenuUpdateToc -> { + ReadBook.book?.let { book -> + if (book.isEpub) { + io.legado.app.help.book.BookHelp.clearCache(book) + io.legado.app.model.localBook.EpubFile.clear() + } + if (book.isMobi) { + io.legado.app.model.localBook.MobiFile.clear() + } + loadChapterList(book) + } + } + + is ReadBookIntent.MenuCoverProgress -> { + ReadBook.book?.let { + ReadBook.uploadProgress(true) { context.toastOnUi(R.string.upload_book_success) } + } + } + + is ReadBookIntent.MenuSameTitleRemoved -> { + ReadBook.book?.let { + val contentProcessor = ContentProcessor.get(it) + val textChapter = ReadBook.curTextChapter + if (textChapter != null + && !textChapter.sameTitleRemoved + && !contentProcessor.removeSameTitleCache.contains( + textChapter.chapter.getFileName("nr") + ) + ) { + context.toastOnUi("未找到可移除的重复标题") + } + } + reverseRemoveSameTitle() + } + + is ReadBookIntent.MenuImageStyle -> { + ReadBook.book?.setImageStyle(intent.style) + if (intent.style == Book.imgStyleSingle) { + ReadBook.book?.setPageAnim(0) + _effects.tryEmit(ReadBookEffect.MenuImageStyleChanged(intent.style)) + } + ReadBook.loadContent(false) + } + + is ReadBookIntent.MenuGetProgress -> { + ReadBook.book?.let { book -> + _effects.tryEmit(ReadBookEffect.SyncBookProgress(book)) + } + } + + is ReadBookIntent.MenuChangeSource -> handleChangeSource() + is ReadBookIntent.MenuBookChangeSource -> { + _uiState.update { it.copy(activeSheet = ReadBookSheet.ChangeBookSource) } + } + is ReadBookIntent.MenuChapterChangeSource -> handleChapterChangeSource() + is ReadBookIntent.MenuSettingReplace -> _effects.tryEmit(ReadBookEffect.MenuSettingReplace) + is ReadBookIntent.MenuTocRegex -> { + _effects.tryEmit(ReadBookEffect.MenuTocRegex(ReadBook.book?.tocUrl)) + } + is ReadBookIntent.TocRegexResult -> { + ReadBook.book?.let { + it.tocUrl = intent.tocRegex + loadChapterList(it) + } + } + is ReadBookIntent.MenuRefreshDur -> { + ReadBook.book?.let { book -> + if (ReadBook.bookSource == null) { + _effects.tryEmit(ReadBookEffect.UpContent(0, true)) + } else { + ReadBook.curTextChapter = null + _effects.tryEmit(ReadBookEffect.UpContent(0, true)) + refreshContentDur(book) + } + } + } + + is ReadBookIntent.MenuRefreshAfter -> { + ReadBook.book?.let { book -> + if (ReadBook.bookSource == null) { + _effects.tryEmit(ReadBookEffect.UpContent(0, true)) + } else { + ReadBook.clearTextChapter() + _effects.tryEmit(ReadBookEffect.UpContent(0, true)) + refreshContentAfter(book) + } + } + } + + is ReadBookIntent.MenuRefreshAll -> { + ReadBook.book?.let { book -> + if (ReadBook.bookSource == null) { + _effects.tryEmit(ReadBookEffect.UpContent(0, true)) + } else { + ReadBook.clearTextChapter() + _effects.tryEmit(ReadBookEffect.UpContent(0, true)) + refreshContentAll(book) + } + } + } + + is ReadBookIntent.MenuEnableReplace -> { + ReadBook.book?.let { + it.setUseReplaceRule(!it.getUseReplaceRule()) + ReadBook.saveRead() + replaceRuleChanged() + } + } + + is ReadBookIntent.MenuReSegment -> { + ReadBook.book?.let { + it.setReSegment(!it.getReSegment()) + ReadBook.loadContent(false) + } + } + + is ReadBookIntent.MenuDelRubyTag -> { + ReadBook.book?.let { + if (it.getDelTag(Book.rubyTag)) it.removeDelTag(Book.rubyTag) + else it.addDelTag(Book.rubyTag) + refreshContentAll(it) + } + } + + is ReadBookIntent.MenuDelHTag -> { + ReadBook.book?.let { + if (it.getDelTag(Book.hTag)) it.removeDelTag(Book.hTag) + else it.addDelTag(Book.hTag) + refreshContentAll(it) + } + } + + is ReadBookIntent.MenuReverseContent -> { + ReadBook.book?.let { reverseContent(it) } + } + + is ReadBookIntent.RemoveFromBookshelf -> removeFromBookshelf() + is ReadBookIntent.OnConfigUpdated -> { + _uiState.update { it.copy(styleConfig = buildStyleConfig()) } + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig(intent.actions)) + } + + is ReadBookIntent.UpdateConfig -> { + handleConfigUpdate(intent.update) + } + is ReadBookIntent.SaveMenuCustomIcon -> saveMenuCustomIcon(intent.id, intent.uri) + is ReadBookIntent.SaveTitleBarCustomIcon -> saveTitleBarCustomIcon(intent.id, intent.uri) + is ReadBookIntent.OpenMenuCustomIconPicker -> { + _effects.tryEmit(ReadBookEffect.OpenMenuCustomIconPicker(intent.id)) + } + is ReadBookIntent.OpenTitleBarCustomIconPicker -> { + _effects.tryEmit(ReadBookEffect.OpenTitleBarCustomIconPicker(intent.id)) + } + is ReadBookIntent.SaveMenuButtonConfig -> saveMenuButtonConfig(intent.items) + is ReadBookIntent.SaveTitleBarButtonConfig -> saveTitleBarButtonConfig(intent.items) + + is ReadBookIntent.KeepLightChanged -> _effects.tryEmit(ReadBookEffect.UpScreenTimeOut) + is ReadBookIntent.TextSelectAbleChanged -> _effects.tryEmit( + ReadBookEffect.UpTextSelectAble( + intent.enabled + ) + ) + + is ReadBookIntent.MediaButtonPressed -> { + if (intent.play) { + _effects.tryEmit(ReadBookEffect.ToggleReadAloud) + } else { + ReadBook.readAloud(!BaseReadAloudService.pause) + } + } + + is ReadBookIntent.TtsProgress -> _effects.tryEmit(ReadBookEffect.UpTtsAloudSpan(intent.chapterStart)) + is ReadBookIntent.ReadAloudAction -> { + openReadMenuRoute(ReadBookMenuRoute.ReadAloud) + } + + is ReadBookIntent.ShowReadAloudConfig -> { + _uiState.update { it.copy(activeSheet = ReadBookSheet.ReadAloudConfig) } + } + + is ReadBookIntent.SelectSpeakEngine -> { + showSpeakEngineConfig() + } + + is ReadBookIntent.OpenPreDownloadNumPicker -> { + _uiState.update { + it.copy( + preDownloadNum = AppConfig.preDownloadNum, + activeSheet = ReadBookSheet.PreDownloadConfig, + ) + } + } + + is ReadBookIntent.OpenCacheCleanTimePicker -> { + _uiState.update { + it.copy( + audioCacheCleanTime = AppConfig.audioCacheCleanTimeOrgin, + activeSheet = ReadBookSheet.AudioCacheCleanConfig, + ) + } + } + + is ReadBookIntent.ApplySpeakEngine -> { + AppConfig.ttsEngine = intent.value + _uiState.update { + it.copy( + selectedTtsEngine = intent.value, + activeSheet = ReadBookSheet.ReadAloudConfig, + ) + } + } + + is ReadBookIntent.ApplyPreDownloadNum -> { + AppConfig.preDownloadNum = intent.value + _uiState.update { + it.copy( + preDownloadNum = intent.value, + activeSheet = ReadBookSheet.ReadAloudConfig, + ) + } + } + + is ReadBookIntent.ApplyAudioCacheCleanTime -> { + context.putPrefInt(PreferKey.audioCacheCleanTime, intent.value) + _uiState.update { + it.copy( + audioCacheCleanTime = intent.value, + activeSheet = ReadBookSheet.ReadAloudConfig, + ) + } + } + is ReadBookIntent.SetReadAloudIgnoreAudioFocus -> { + viewModelScope.launch { readAloudSettingsRepository.setIgnoreAudioFocus(intent.value) } + } + is ReadBookIntent.SetReadAloudPauseOnPhoneCall -> { + viewModelScope.launch { readAloudSettingsRepository.setPauseReadAloudWhilePhoneCalls(intent.value) } + } + is ReadBookIntent.SetReadAloudWakeLock -> { + viewModelScope.launch { readAloudSettingsRepository.setReadAloudWakeLock(intent.value) } + } + is ReadBookIntent.SetReadAloudMediaButtonPerNext -> { + viewModelScope.launch { readAloudSettingsRepository.setMediaButtonPerNext(intent.value) } + } + is ReadBookIntent.SetReadAloudByPage -> { + viewModelScope.launch { readAloudSettingsRepository.setReadAloudByPage(intent.value) } + if (intent.value) postEvent(EventBus.MEDIA_BUTTON, false) + } + is ReadBookIntent.SetReadAloudSystemMediaCompat -> { + viewModelScope.launch { readAloudSettingsRepository.setSystemMediaControlCompatibilityChange(intent.value) } + } + is ReadBookIntent.SetReadAloudStreamAudio -> { + viewModelScope.launch { readAloudSettingsRepository.setStreamReadAloudAudio(intent.value) } + if (intent.value) postEvent(EventBus.MEDIA_BUTTON, false) + } + is ReadBookIntent.ReadAloudPrevParagraph -> ReadAloud.prevParagraph(context) + is ReadBookIntent.ReadAloudTogglePause -> toggleReadAloudPause() + is ReadBookIntent.ReadAloudStop -> { + ReadAloud.stop(context) + _uiState.update { it.copy(isReadAloudRunning = false, isReadAloudPaused = false) } + } + is ReadBookIntent.ReadAloudNextParagraph -> ReadAloud.nextParagraph(context) + is ReadBookIntent.ReadAloudPrevChapter -> ReadBook.moveToPrevChapter( + upContent = true, + toLast = false + ) + is ReadBookIntent.ReadAloudNextChapter -> ReadBook.moveToNextChapter(true) + is ReadBookIntent.SetReadAloudTtsTimer -> setReadAloudTtsTimer(intent.value) + is ReadBookIntent.SetReadAloudTtsFollowSys -> { + viewModelScope.launch { readAloudSettingsRepository.setTtsFollowSys(intent.value) } + _uiState.update { it.copy(readAloudTtsFollowSys = intent.value) } + } + is ReadBookIntent.SetReadAloudTtsSpeechRate -> setReadAloudTtsSpeechRate(intent.value) + is ReadBookIntent.OpenSystemTtsSettings -> { + _effects.tryEmit(ReadBookEffect.OpenSystemTtsSettings) + } + is ReadBookIntent.ClearTtsCache -> { + io.legado.app.utils.TTSCacheUtils.clearTtsCache() + _effects.tryEmit(ReadBookEffect.TtsCacheCleared(context.getString(R.string.clear_cache_success))) + } + + is ReadBookIntent.SelectFont -> selectFont(intent.path) + is ReadBookIntent.SelectSystemTypeface -> { + AppConfig.systemTypefaces = intent.index + ReadBookConfig.textFont = "" + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig( + setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent) + )) + } + + is ReadBookIntent.ColorSelected -> colorSelected(intent.dialogId, intent.color) + is ReadBookIntent.ShowPageAnimConfig -> { + _uiState.update { it.copy(activeSheet = ReadBookSheet.PageAnim) } + } + + is ReadBookIntent.OpenReplaceEditor -> _effects.tryEmit( + ReadBookEffect.OpenReplaceEditor( + intent.id, + intent.pattern + ) + ) + + is ReadBookIntent.ReplaceRuleChanged -> replaceRuleChanged() + is ReadBookIntent.OpenFontFolderPicker -> _effects.tryEmit(ReadBookEffect.OpenFontFolderPicker) + is ReadBookIntent.OpenReadStyleImagePicker -> { + _effects.tryEmit(ReadBookEffect.OpenReadStyleImagePicker) + } + is ReadBookIntent.OpenReadStyleImagePickerForMode -> { + _effects.tryEmit(ReadBookEffect.OpenReadStyleImagePickerForMode(intent.isNight)) + } + is ReadBookIntent.OpenReadStyleImport -> { + _effects.tryEmit(ReadBookEffect.OpenReadStyleImport) + } + is ReadBookIntent.OpenReadStyleExport -> { + _effects.tryEmit(ReadBookEffect.OpenReadStyleExport) + } + is ReadBookIntent.ReadStyleImageSelected -> { + applyReadStyleBackgroundImage(intent.uri) + } + is ReadBookIntent.ReadStyleImageSelectedForMode -> { + applyReadStyleBackgroundImageForMode(intent.uri, intent.isNight) + } + is ReadBookIntent.ReadStyleConfigImportSelected -> { + importReadStyleConfig(intent.uri) + } + is ReadBookIntent.ReadStyleConfigExportSelected -> { + exportReadStyleConfig(intent.uri) + } + is ReadBookIntent.SaveReadStyleConfig -> { + readBookStyleConfigRepository.save() + } + is ReadBookIntent.AddReadStyleConfig -> { + val newIndex = readBookStyleConfigRepository.addStyle() + handleConfigUpdate(ConfigUpdate.StyleSelect(newIndex)) + } + is ReadBookIntent.DeleteCurrentReadStyleConfig -> { + if (readBookStyleConfigRepository.deleteCurrentStyle()) { + _uiState.update { + it.copy( + styleConfig = buildStyleConfig(), + activeSheet = null, + ) + } + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig( + setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent) + )) + } + } + is ReadBookIntent.OpenBgTextConfig -> { + ReadBookConfig.styleSelect = intent.index + viewModelScope.launch { + readSettingsRepository.setStyleSelect(ReadBookConfig.isComic, intent.index) + } + _uiState.update { it.copy(activeSheet = ReadBookSheet.BgTextConfig) } + } + + is ReadBookIntent.ToggleDayNight -> toggleDayNight() + // Text action menu + is ReadBookIntent.TextActionAloud -> { + when (AppConfig.contentSelectSpeakMod) { + 1 -> _effects.tryEmit(ReadBookEffect.TextActionAloudSelect) + else -> _effects.tryEmit(ReadBookEffect.TextActionSpeak(intent.text)) + } + } + + is ReadBookIntent.TextActionBookmark -> { + val book = ReadBook.book + val page = ReadBook.curTextChapter?.getPage(ReadBook.durPageIndex) + if (book != null && page != null) { + val bookmark = book.createBookMark().apply { + chapterIndex = ReadBook.durChapterIndex + chapterPos = ReadBook.durChapterPos + chapterName = page.title + bookText = page.text.replace(Regex("[袮꧁]"), "").trim() + } + _uiState.update { + it.copy( + menuState = ReadBookMenuState( + visible = true, + routeStack = kotlinx.collections.immutable.persistentListOf( + ReadBookMenuRoute.Main, + ReadBookMenuRoute.Bookmark(bookmark), + ), + ), + ) + } + } else { + context.toastOnUi(R.string.create_bookmark_error) + } + } + + is ReadBookIntent.TextActionReplace -> { + _effects.tryEmit( + ReadBookEffect.TextActionReplace( + text = intent.text, + bookName = ReadBook.book?.name, + bookSourceUrl = ReadBook.bookSource?.bookSourceUrl, + ) + ) + } + + is ReadBookIntent.TextActionSearchContent -> { + _uiState.update { it.copy(searchContentQuery = intent.text) } + ReadBook.book?.bookUrl?.let { bookUrl -> + _effects.tryEmit(ReadBookEffect.OpenSearchActivity(intent.text, bookUrl)) + } + } + + is ReadBookIntent.TextActionDict -> { + _uiState.update { it.copy(activeSheet = ReadBookSheet.Dict(intent.text)) } + } + + is ReadBookIntent.ApplySimulatedReading -> { + ReadBook.clearTextChapter() + execute { + ReadBook.book?.let { initBook(it) } + } + } + + is ReadBookIntent.PageAnimChanged -> { + _effects.tryEmit(ReadBookEffect.PageAnimChanged) + } + + is ReadBookIntent.DownloadChapters -> { + _effects.tryEmit(ReadBookEffect.DownloadChapters(intent.start, intent.end)) + } + + is ReadBookIntent.SaveChapterContent -> { + ReadBook.book?.let { + saveContent(it, intent.content, intent.chapterIndex) + } + } + + is ReadBookIntent.OnResume -> handleOnResume() + is ReadBookIntent.OnPause -> handleOnPause() + is ReadBookIntent.OnDispose -> handleOnDispose() + is ReadBookIntent.CloseReadBook -> _effects.tryEmit(ReadBookEffect.Finish) + is ReadBookIntent.OpenBooksDirPicker -> requestBooksDirPicker(reloadChapterList = false) + is ReadBookIntent.BooksDirSelected -> onBooksDirSelected(intent.uri) + } + } + + // --- Lifecycle handlers (migrated from ReadBookController) --- + + private fun handleOnResume() { + // Read time tracking + ReadBook.readStartTime = System.currentTimeMillis() + ReadBook.initReadTime() + ReadBook.startAutoSaveSession() + + // Web book progress sync + ReadBook.webBookProgress?.let { + ReadBook.setProgress(it) + ReadBook.webBookProgress = null + } + + // View-layer operations via effects + _effects.tryEmit(ReadBookEffect.UpSystemUiVisibility) + _effects.tryEmit(ReadBookEffect.UpTime) + _effects.tryEmit(ReadBookEffect.UpScreenTimeOut) + + // Activity-level operations + _effects.tryEmit(ReadBookEffect.RegisterTimeBatteryReceiver) + _effects.tryEmit(ReadBookEffect.RegisterNetworkListener) + } + + private var justInitData = false + + private fun handleOnPause() { + backupJob?.cancel() + _effects.tryEmit(ReadBookEffect.StopAutoPage) + + // Read time tracking + ReadBook.saveRead() + ReadBook.stopAutoSaveSession() + ReadBook.commitReadSession() + ReadBook.cancelPreDownloadTask() + + // View-layer + _effects.tryEmit(ReadBookEffect.UpSystemUiVisibility) + + // Activity-level operations + _effects.tryEmit(ReadBookEffect.UnregisterTimeBatteryReceiver) + _effects.tryEmit(ReadBookEffect.UnregisterNetworkListener) + + if (!BuildConfig.DEBUG) { + if (AppConfig.syncBookProgressPlus) { + ReadBook.syncProgress() + } else { + ReadBook.uploadProgress() + } + _effects.tryEmit(ReadBookEffect.BackupNow) + } + justInitData = false + } + + private fun handleOnDispose() { + // TTS and view cleanup — bridge handles via clearTts() + backupJob?.cancel() + ReadBook.cancelPreDownloadTask() + } + + private fun showSpeakEngineConfig() { + execute { + buildList { + add(ReadBookTtsEngineItem(context.getString(R.string.system_tts), null)) + appDb.httpTTSDao.all.forEach { httpTts -> + add(ReadBookTtsEngineItem(httpTts.name, httpTts.id.toString())) + } + } + }.onSuccess { items -> + _uiState.update { + it.copy( + ttsEngineItems = items.toImmutableList(), + selectedTtsEngine = AppConfig.ttsEngine, + activeSheet = ReadBookSheet.SpeakEngineConfig, + ) + } + } + } + + /** + * Called from the network changed listener (registered by route). + */ + fun onNetworkChanged() { + if (AppConfig.syncBookProgressPlus && NetworkUtils.isAvailable() && !justInitData) { + ReadBook.syncProgress(newProgressAction = { progress -> + sureNewProgress(progress) + }) + } + } + + /** + * Start the auto-backup job (called on page change). + */ + fun startBackupJob() { + backupJob?.cancel() + backupJob = viewModelScope.launch(IO) { + delay(5 * 60 * 1000) // 5 minutes + ReadBook.book?.let { book -> + uploadBookProgress(book) + coroutineContext.ensureActive() + _effects.tryEmit(ReadBookEffect.BackupNow) + } + } + } + + private var backupJob: Job? = null + + private fun handleChangeSource() { + viewModelScope.launch { + if (AppConfig.defaultSourceChangeAll) { + _uiState.update { it.copy(activeSheet = ReadBookSheet.ChangeBookSource) } + } else { + val book = ReadBook.book ?: return@launch + val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) + ?: return@launch + _uiState.update { + it.copy( + activeSheet = ReadBookSheet.ChangeChapterSource( + chapter.index, chapter.title + ) + ) + } + } + } + } + + private fun handleChapterChangeSource() { + viewModelScope.launch { + val book = ReadBook.book ?: return@launch + val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) + ?: return@launch + _uiState.update { + it.copy( + activeSheet = ReadBookSheet.ChangeChapterSource( + chapter.index, chapter.title + ) + ) + } + } + } + + private fun handleAddBookmark() { + viewModelScope.launch(IO) { + val book = ReadBook.book ?: return@launch + val chapter = ReadBook.curTextChapter ?: return@launch + val page = chapter.pages.getOrNull(ReadBook.durPageIndex) ?: return@launch + val bookmark = Bookmark( + bookName = book.name, + bookAuthor = book.author, + chapterIndex = chapter.chapter.index, + chapterName = chapter.title, + chapterPos = ReadBook.durPageIndex, + bookText = page.text, + content = "", + ) + withContext(Main) { + openReadMenuRoute(ReadBookMenuRoute.Bookmark(bookmark)) + } + } + } + + // --- ReadBook.CallBack Implementation --- + + override fun upMenuView() { + _uiState.update { syncFromReadBook(it) } + } + + override fun loadChapterList(book: Book) { + ReadBook.upMsg(context.getString(R.string.toc_updateing)) + doLoadChapterList(book) + } + + override fun upContent( + relativePosition: Int, + resetPageOffset: Boolean, + success: (() -> Unit)? + ) { + _uiState.update { syncFromReadBook(it) } + _effects.tryEmit( + ReadBookEffect.UpContent(relativePosition, resetPageOffset) + ) + success?.invoke() + } + + override suspend fun upContentAwait( + relativePosition: Int, + resetPageOffset: Boolean, + success: (() -> Unit)? + ) { + withContext(Main.immediate) { + _uiState.update { syncFromReadBook(it) } + _effects.tryEmit( + ReadBookEffect.UpContent(relativePosition, resetPageOffset) + ) + success?.invoke() + } + } + + override fun pageChanged() { + _uiState.update { syncFromReadBook(it) } + _effects.tryEmit(ReadBookEffect.PageChanged) + } + + override fun contentLoadFinish() { + _uiState.update { syncFromReadBook(it).copy(isInitFinish = true) } + _effects.tryEmit(ReadBookEffect.ContentLoadFinish) + } + + override fun upPageAnim(upRecorder: Boolean) { + _effects.tryEmit(ReadBookEffect.UpPageAnim(upRecorder)) + } + + override fun notifyBookChanged() { + _uiState.update { syncFromReadBook(it) } + if (!ReadBook.inBookshelf) { + removeFromBookshelf { _effects.tryEmit(ReadBookEffect.Finish) } + } + } + + override fun sureNewProgress(progress: BookProgress) { + _uiState.update { + it.copy(activeDialog = ReadBookDialog.ConfirmRestoreProgress(progress)) + } + } + + override fun cancelSelect() { + _effects.tryEmit(ReadBookEffect.CancelSelect) + } + + // LayoutProgressListener + override fun onLayoutPageCompleted(index: Int, page: TextPage) { + _uiState.update { syncFromReadBook(it) } + _effects.tryEmit(ReadBookEffect.LayoutPageCompleted(index, page)) + } + + override fun onLayoutCompleted() { + _uiState.update { syncFromReadBook(it) } + } + + override fun onLayoutException(e: Throwable) { + // no-op: ReadView handles this internally + } + + // --- EventBus Bridge --- + + private inline fun eventFlow(tag: String) = callbackFlow { + val obs = androidx.lifecycle.Observer { trySend(it) } + com.jeremyliao.liveeventbus.LiveEventBus.get(tag).observeForever(obs) + awaitClose { + com.jeremyliao.liveeventbus.LiveEventBus.get(tag).removeObserver(obs) + } + } + + private inline fun eventFlowSticky(tag: String) = callbackFlow { + val obs = androidx.lifecycle.Observer { trySend(it) } + com.jeremyliao.liveeventbus.LiveEventBus.get(tag).observeStickyForever(obs) + awaitClose { + com.jeremyliao.liveeventbus.LiveEventBus.get(tag).removeObserver(obs) + } + } + + private fun collectEventBus() { + viewModelScope.launch { + eventFlow(EventBus.TIME_CHANGED).collect { time -> + _uiState.update { it.copy(time = time) } + _effects.tryEmit(ReadBookEffect.UpTime) + } + } + viewModelScope.launch { + eventFlow(EventBus.BATTERY_CHANGED).collect { level -> + _uiState.update { it.copy(battery = level) } + _effects.tryEmit(ReadBookEffect.UpBattery(level)) + } + } + viewModelScope.launch { + eventFlow>(EventBus.UP_CONFIG).collect { values -> + _uiState.update { + it.copy(styleConfig = buildStyleConfig()) + } + // Convert legacy integer codes to ConfigUpdateAction set + val actions = values.mapNotNull { code -> + when (code) { + 0 -> ConfigUpdateAction.UpdateSystemUi + 1 -> ConfigUpdateAction.UpdateBackground + 2 -> ConfigUpdateAction.UpdateStyle + 3 -> ConfigUpdateAction.UpdateBackgroundAlpha + 4 -> ConfigUpdateAction.UpdatePageSlopSquare + 5 -> ConfigUpdateAction.ReloadContent + 6 -> ConfigUpdateAction.UpdateContent + 8 -> ConfigUpdateAction.UpdateChapterStyle + 9 -> ConfigUpdateAction.InvalidateTextPage + 10 -> ConfigUpdateAction.UpdateLayout + 11 -> ConfigUpdateAction.SubmitRenderTask + else -> null + } + }.toSet() + if (actions.isNotEmpty()) { + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig(actions)) + } + } + } + viewModelScope.launch { + eventFlow(EventBus.ALOUD_STATE).collect { state -> + _uiState.update { + it.copy( + isReadAloudRunning = state != Status.STOP, + isReadAloudPaused = state == Status.PAUSE, + ) + } + if (state == Status.STOP || state == Status.PAUSE) { + _effects.tryEmit(ReadBookEffect.UpAloudState) + } + } + } + viewModelScope.launch { + @Suppress("UNCHECKED_CAST") + eventFlow>(EventBus.SEARCH_RESULT).collect { results -> + _uiState.update { it.copy(searchResultList = results.toImmutableList()) } + } + } + viewModelScope.launch { + eventFlow(EventBus.UP_SEEK_BAR).collect { + _uiState.update { syncFromReadBook(it) } + _effects.tryEmit(ReadBookEffect.UpSeekBar) + } + } + viewModelScope.launch { + eventFlow(EventBus.REFRESH_BOOK_CONTENT).collect { + _effects.tryEmit(ReadBookEffect.RefreshBookContent) + } + } + viewModelScope.launch { + eventFlow(EventBus.MEDIA_BUTTON).collect { play -> + if (play) { + _effects.tryEmit(ReadBookEffect.ToggleReadAloud) + } else { + ReadBook.readAloud(!BaseReadAloudService.pause) + } + } + } + viewModelScope.launch { + eventFlowSticky(EventBus.TTS_PROGRESS).collect { chapterStart -> + _effects.tryEmit(ReadBookEffect.UpTtsAloudSpan(chapterStart)) + } + } + } + + private fun collectReadPreferences() { + viewModelScope.launch { + var previous: ReadPreferences? = null + readSettingsRepository.preferences.collect { preferences -> + val old = previous + previous = preferences + ReadBookConfig.syncPreferences(preferences) + AppConfig.syncReadPreferences(preferences) + _readPreferences.value = preferences + if (!preferences.hasMenuClickArea()) { + AppConfig.detectClickArea() + readSettingsRepository.setClickAction(PreferKey.clickActionMC, 0) + } + if (old != null && old.keepLight != preferences.keepLight) { + _effects.tryEmit(ReadBookEffect.UpScreenTimeOut) + } + } + } + } + + private fun collectReadAloudPreferences() { + viewModelScope.launch { + readAloudSettingsRepository.preferences.collect { prefs -> + _uiState.update { + it.copy( + readAloudIgnoreAudioFocus = prefs.ignoreAudioFocus, + readAloudPauseOnPhoneCall = prefs.pauseReadAloudWhilePhoneCalls, + readAloudWakeLock = prefs.readAloudWakeLock, + readAloudMediaButtonPerNext = prefs.mediaButtonPerNext, + readAloudByPage = prefs.readAloudByPage, + readAloudSystemMediaCompat = prefs.systemMediaControlCompatibilityChange, + readAloudStreamAudio = prefs.streamReadAloudAudio, + readAloudTtsFollowSys = prefs.ttsFollowSys, + readAloudTtsSpeechRate = prefs.ttsSpeechRate, + readAloudTtsTimer = if (BaseReadAloudService.timeMinute > 0) { + BaseReadAloudService.timeMinute + } else { + prefs.ttsTimer + }, + ) + } + } + } + } + + private fun toggleReadAloudPause() { + if (_uiState.value.isReadAloudPaused) { + ReadAloud.resume(context) + _uiState.update { it.copy(isReadAloudPaused = false) } + } else { + ReadAloud.pause(context) + _uiState.update { it.copy(isReadAloudPaused = true) } + } + } + + private fun setReadAloudTtsTimer(value: Int) { + viewModelScope.launch { + readAloudSettingsRepository.setTtsTimer(value) + } + ReadAloud.setTimer(context, value) + _uiState.update { it.copy(readAloudTtsTimer = value) } + } + + private fun setReadAloudTtsSpeechRate(value: Int) { + viewModelScope.launch { + readAloudSettingsRepository.setTtsSpeechRate(value) + ReadAloud.upTtsSpeechRate(context) + } + _uiState.update { it.copy(readAloudTtsSpeechRate = value) } + } + + fun setFontFolder(value: String) { + viewModelScope.launch { + readSettingsRepository.setFontFolder(value) + } + } + + private fun ReadPreferences.hasMenuClickArea(): Boolean { + return clickActionTL * clickActionTC * clickActionTR * + clickActionML * clickActionMC * clickActionMR * + clickActionBL * clickActionBC * clickActionBR == 0 + } + + // --- State Sync --- + + private fun buildStyleConfig(): ReadBookStyleConfig { + val config = ReadBookConfig + val dur = config.durConfig + return ReadBookStyleConfig( + styleSelect = config.styleSelect, + styleName = dur.name.ifBlank { "文字" }, + bgAlpha = config.bgAlpha.toFloat(), + bgType = dur.bgType, + bgStr = dur.bgStr, + darkStatusIcon = dur.getDarkStatusIcon(), + bgTypeNight = dur.bgTypeNight, + bgStrNight = dur.bgStrNight, + darkStatusIconNight = dur.getDarkStatusIconNight(), + bgTypeEInk = dur.bgTypeEInk, + bgStrEInk = dur.bgStrEInk, + darkStatusIconEInk = dur.getDarkStatusIconEInk(), + textSize = config.textSize, + textColor = dur.getTextColor(), + textColorNight = dur.getTextColorNight(), + textColorEInk = dur.getTextColorEInk(), + pageAnim = dur.getPageAnim(), + pageAnimEInk = dur.getPageAnimEInk(), + shareLayout = config.shareLayout, + configCount = config.configList.size, + ) + } + + private fun syncFromReadBook(current: ReadBookUiState): ReadBookUiState { + val book = ReadBook.book + val textChapter = ReadBook.curTextChapter + return current.copy( + book = book, + bookSource = ReadBook.bookSource, + bookName = book?.name ?: "", + chapterName = textChapter?.title ?: "", + chapterUrl = textChapter?.chapter?.url ?: "", + chapterSize = ReadBook.chapterSize, + durChapterIndex = ReadBook.durChapterIndex, + durChapterPos = ReadBook.durChapterPos, + durPageIndex = ReadBook.durPageIndex, + isLocalBook = ReadBook.isLocalBook, + msg = ReadBook.msg, + curTextChapter = textChapter, + seekProgress = calculateSeekProgress(), + seekMax = calculateSeekMax(), + replaceRuleEnabled = book?.getUseReplaceRule() ?: false, + effectiveReplaceCount = textChapter?.effectiveReplaceRules?.size ?: 0, + translationMode = book?.getTranslationMode() ?: false, + isLocalTxt = book?.isLocalTxt == true, + isEpub = book?.isEpub == true, + useReplaceRule = book?.getUseReplaceRule() ?: false, + reSegment = book?.getReSegment() ?: false, + delRubyTag = book?.getDelTag(Book.rubyTag) ?: false, + delHTag = book?.getDelTag(Book.hTag) ?: false, + sameTitleRemoved = textChapter?.sameTitleRemoved ?: false, + isReadingProgressSyncConfigured = isReadingProgressSyncConfigured(), + styleConfig = buildStyleConfig(), + menuConfig = ReadMenuConfig( + titleBarIconPosition = ReadBookConfig.titleBarIconPosition, + showTitleBarIcons = ReadBookConfig.showTitleBarIcons, + readMenuFloatingBottomBar = ReadBookConfig.readMenuFloatingBottomBar, + readMenuBottomCornerRadius = ReadBookConfig.readMenuBottomCornerRadius, + readMenuIconItemsPerRow = ReadBookConfig.readMenuIconItemsPerRow, + readMenuIconRowCount = ReadBookConfig.readMenuIconRowCount, + readMenuBorderWidth = ReadBookConfig.readMenuBorderWidth, + readMenuBorderColor = ReadBookConfig.readMenuBorderColor, + readMenuBorderColorNight = ReadBookConfig.readMenuBorderColorNight, + readMenuBlurAlpha = ReadBookConfig.readMenuBlurAlpha, + readMenuBlurRadius = ReadBookConfig.readMenuBlurRadius, + readMenuLensRadius = ReadBookConfig.readMenuLensRadius, + readMenuTopBarBlurMode = ReadBookConfig.readMenuTopBarBlurMode, + readMenuBottomBarBlurMode = ReadBookConfig.readMenuBottomBarBlurMode, + readMenuTopBarLiquidGlassButtons = ReadBookConfig.readMenuTopBarLiquidGlassButtons, + readMenuBottomBarLiquidGlassButtons = ReadBookConfig.readMenuBottomBarLiquidGlassButtons, + readMenuTopBarBlurStyle = ReadBookConfig.readMenuTopBarBlurStyle, + readMenuBottomBarBlurStyle = ReadBookConfig.readMenuBottomBarBlurStyle, + readMenuIconStyle = ReadBookConfig.readMenuIconStyle, + readMenuIconShowText = ReadBookConfig.readMenuIconShowText, + titleBarCustomIcons = ReadBookConfig.titleBarCustomIcons.toImmutableMap(), + readMenuCustomIcons = ReadBookConfig.readMenuCustomIcons.toImmutableMap(), + titleBarButtons = current.menuConfig.titleBarButtons, + bottomBarButtons = current.menuConfig.bottomBarButtons, + ), + ) + } + + private fun refreshButtonConfigs() { + val titleBarButtons = loadButtonConfig(TITLE_BAR_ICON_PREFS, TITLE_BAR_ICON_KEY) + val bottomBarButtons = loadButtonConfig(TOOL_BUTTON_PREFS, TOOL_BUTTON_KEY) + _uiState.update { + it.copy( + menuConfig = it.menuConfig.copy( + titleBarButtons = titleBarButtons.toImmutableList(), + bottomBarButtons = bottomBarButtons.toImmutableList(), + ), + ) + } + } + + private fun saveTitleBarButtonConfig(items: List) { + val normalized = normalizeButtonConfig(items) + saveButtonConfig(TITLE_BAR_ICON_PREFS, TITLE_BAR_ICON_KEY, normalized) + _uiState.update { + it.copy( + menuConfig = it.menuConfig.copy( + titleBarButtons = normalized.toImmutableList(), + ), + ) + } + } + + private fun saveMenuButtonConfig(items: List) { + val normalized = normalizeButtonConfig(items) + saveButtonConfig(TOOL_BUTTON_PREFS, TOOL_BUTTON_KEY, normalized) + _uiState.update { + it.copy( + menuConfig = it.menuConfig.copy( + bottomBarButtons = normalized.toImmutableList(), + ), + ) + } + } + + private fun loadButtonConfig( + preferenceName: String, + key: String, + ): List { + val prefs = context.getSharedPreferences(preferenceName, Context.MODE_PRIVATE) + val raw = prefs.getString(key, null) + ?.split(";") + ?.mapNotNull { token -> + val parts = token.split(",") + val id = parts.getOrNull(0)?.takeIf { it in ReadBookButtonIds } + val enabled = parts.getOrNull(1)?.toBooleanStrictOrNull() + if (id != null && enabled != null) { + ReadBookButtonConfigItem(id, enabled) + } else { + null + } + } + ?: emptyList() + + return if (raw.isEmpty()) { + ReadBookButtonIds.mapIndexed { index, id -> + ReadBookButtonConfigItem(id, index < DEFAULT_ENABLED_BUTTON_COUNT) + } + } else { + normalizeButtonConfig(raw) + } + } + + private fun saveButtonConfig( + preferenceName: String, + key: String, + items: List, + ) { + val value = items.joinToString(";") { "${it.id},${it.enabled}" } + context.getSharedPreferences(preferenceName, Context.MODE_PRIVATE) + .edit() + .putString(key, value) + .apply() + } + + private fun normalizeButtonConfig( + items: List, + ): List { + val seen = mutableSetOf() + val normalized = items.mapNotNull { item -> + val id = item.id + if (id in ReadBookButtonIds && seen.add(id)) { + ReadBookButtonConfigItem(id, item.enabled) + } else { + null + } + }.toMutableList() + ReadBookButtonIds.forEach { id -> + if (seen.add(id)) { + normalized.add(ReadBookButtonConfigItem(id, true)) + } + } + return normalized + } + + private fun calculateSeekProgress(): Int { + return when (AppConfig.progressBarBehavior) { + "page" -> ReadBook.durPageIndex + else -> ReadBook.durChapterIndex + } + } + + private fun calculateSeekMax(): Int { + return when (AppConfig.progressBarBehavior) { + "page" -> (ReadBook.curTextChapter?.pages?.size ?: 1) - 1 + else -> ReadBook.chapterSize - 1 + } + } + + // --- Business Logic (migrated from Activity / kept from old ViewModel) --- + fun initReadBookConfig(intent: Intent) { val bookUrl = intent.getStringExtra("bookUrl") val book = when { @@ -80,9 +1429,6 @@ class ReadBookViewModel( ReadBook.upReadBookConfig(book) } - /** - * 初始化 - */ fun initData(intent: Intent, success: (() -> Unit)? = null) { execute { ReadBook.inBookshelf = intent.getBooleanExtra("inBookshelf", true) @@ -101,8 +1447,8 @@ class ReadBookViewModel( } val index = intent.getIntExtra("index", -1) val chapterPos = intent.getIntExtra("chapterPos", -1) - if (index >= 0 && chapterPos >= 0) { //从书签打开的正文,有进度传递 - ReadBook.saveCurrentBookProgress() //启用恢复进度提示 + if (index >= 0 && chapterPos >= 0) { + ReadBook.saveCurrentBookProgress() openChapter(index, chapterPos) } }.onSuccess { @@ -123,7 +1469,7 @@ class ReadBookViewModel( } else { ReadBook.resetData(book) } - isInitFinish = true + _uiState.update { it.copy(isInitFinish = true) } if (!book.isLocal && book.tocUrl.isEmpty() && !loadBookInfo(book)) { return } @@ -159,11 +1505,10 @@ class ReadBookViewModel( } } if (ReadBook.chapterChanged) { - // 有章节跳转不同步阅读进度 ReadBook.chapterChanged = false } else if (!(isSameBook && BaseReadAloudService.isRun) && ReadBook.inBookshelf) { if (AppConfig.syncBookProgressPlus) { - ReadBook.syncProgress({ progress -> ReadBook.callBack?.sureNewProgress(progress) }) + ReadBook.syncProgress({ progress -> sureNewProgress(progress) }) } else { syncBookProgress(book) } @@ -181,15 +1526,12 @@ class ReadBookViewModel( } catch (e: Throwable) { ReadBook.upMsg("打开本地书籍出错: ${e.localizedMessage}") if (e is SecurityException || e is FileNotFoundException) { - permissionDenialLiveData.postValue(0) + requestBooksDirPicker(reloadChapterList = false) } return false } } - /** - * 加载详情页 - */ private suspend fun loadBookInfo(book: Book): Boolean { val source = ReadBook.bookSource ?: return true try { @@ -202,10 +1544,7 @@ class ReadBookViewModel( } } - /** - * 加载目录 - */ - fun loadChapterList(book: Book) { + private fun doLoadChapterList(book: Book) { execute { if (loadChapterListAwait(book)) { ReadBook.upMsg(null) @@ -226,9 +1565,8 @@ class ReadBookViewModel( }.onFailure { when (it) { is SecurityException, is FileNotFoundException -> { - permissionDenialLiveData.postValue(1) + requestBooksDirPicker(reloadChapterList = true) } - else -> { AppLog.put("LoadTocError:${it.localizedMessage}", it) ReadBook.upMsg("LoadTocError:${it.localizedMessage}") @@ -261,9 +1599,6 @@ class ReadBookViewModel( return true } - /** - * 同步进度 - */ fun syncBookProgress( book: Book, alertSync: ((progress: BookProgress) -> Unit)? = null @@ -316,9 +1651,6 @@ class ReadBookViewModel( durChapterTitle = durChapterTitle ) - /** - * 换源 - */ fun changeTo(book: Book, toc: List) { changeSourceCoroutine?.cancel() changeSourceCoroutine = execute { @@ -339,9 +1671,6 @@ class ReadBookViewModel( } } - /** - * 自动换源 - */ private fun autoChangeSource(name: String, author: String) { if (!AppConfig.autoChangeSource) return execute { @@ -390,7 +1719,7 @@ class ReadBookViewModel( ReadBook.openChapter(index, durChapterPos, success = success) } - fun removeFromBookshelf(success: (() -> Unit)?) { + fun removeFromBookshelf(success: (() -> Unit)? = null) { val book = ReadBook.book Coroutine.async { book?.delete() @@ -399,7 +1728,7 @@ class ReadBookViewModel( } } - fun upBookSource(success: (() -> Unit)?) { + fun upBookSource(success: (() -> Unit)? = null) { execute { ReadBook.book?.let { book -> ReadBook.bookSource = appDb.bookSourceDao.getBookSource(book.origin) @@ -409,54 +1738,133 @@ class ReadBookViewModel( } } - fun refreshContentDur(book: Book) { + private fun refreshCurrentChapter() { execute { - appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) - ?.let { chapter -> - BookHelp.delContent(book, chapter) - ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false) - } - } - } - - fun refreshContentAfter(book: Book) { - execute { - appDb.bookChapterDao.getChapterList( - book.bookUrl, - ReadBook.durChapterIndex, - book.totalChapterNum - ).forEach { chapter -> - BookHelp.delContent(book, chapter) + ReadBook.book?.let { book -> + appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) + ?.let { chapter -> + BookHelp.delContent(book, chapter) + ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false) + } } - ReadBook.loadContent(false) } } + // Backward-compatible alias for Activity + fun refreshContentDur(book: Book) { + refreshCurrentChapter() + } + + private fun refreshContentAfter() { + execute { + ReadBook.book?.let { book -> + appDb.bookChapterDao.getChapterList( + book.bookUrl, + ReadBook.durChapterIndex, + book.totalChapterNum + ).forEach { chapter -> + BookHelp.delContent(book, chapter) + } + ReadBook.loadContent(false) + } + } + } + + // Backward-compatible alias for Activity + fun refreshContentAfter(book: Book) { + refreshContentAfter() + } + + private fun refreshAllChapters() { + execute { + ReadBook.book?.let { book -> + BookHelp.clearCache(book) + ReadBook.loadContent(false) + } + } + } + + // Backward-compatible alias for Activity fun refreshContentAll(book: Book) { - execute { - BookHelp.clearCache(book) - ReadBook.loadContent(false) - } + refreshAllChapters() } - /** - * 保存内容 - */ - fun saveContent(book: Book, content: String) { + fun saveContent(book: Book, content: String, chapterIndex: Int = ReadBook.durChapterIndex) { execute { - appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) + appDb.bookChapterDao.getChapter(book.bookUrl, chapterIndex) ?.let { chapter -> BookHelp.saveText(book, chapter, content) - ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false) + ReadBook.loadContent(chapterIndex, resetPageOffset = false) } } } - /** - * 反转内容 - */ - fun reverseContent(book: Book) { + private fun loadContentEdit() { + _uiState.update { it.copy(contentEditLoading = true, contentEditText = "") } execute { + val book = ReadBook.book ?: return@execute + val chapter = appDb.bookChapterDao + .getChapter(book.bookUrl, ReadBook.durChapterIndex) + ?: return@execute + val title = chapter.getDisplayTitle() + val contentProcessor = ContentProcessor.get(book.name, book.origin) + val rawContent = BookHelp.getContent(book, chapter) ?: return@execute + val text = contentProcessor.getContent(book, chapter, rawContent, includeTitle = false) + .toString() + _uiState.update { + it.copy( + contentEditText = text, + contentEditTitle = title, + contentEditIsLocalTxt = book.isLocalTxt, + ) + } + }.onFinally { + _uiState.update { it.copy(contentEditLoading = false) } + } + } + + private fun saveContentEdit(content: String, saveToSource: Boolean) { + execute { + val book = ReadBook.book ?: return@execute + val chapter = appDb.bookChapterDao + .getChapter(book.bookUrl, ReadBook.durChapterIndex) + ?: return@execute + BookHelp.saveText(book, chapter, content, saveToSource) + ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false) + } + } + + private fun resetContentEdit() { + _uiState.update { it.copy(contentEditLoading = true) } + execute { + val book = ReadBook.book ?: return@execute + val chapter = appDb.bookChapterDao + .getChapter(book.bookUrl, ReadBook.durChapterIndex) + ?: return@execute + BookHelp.delContent(book, chapter) + if (!book.isLocal) { + ReadBook.bookSource?.let { bookSource -> + WebBook.getContentAwait(bookSource, book, chapter) + } + } + val contentProcessor = ContentProcessor.get(book.name, book.origin) + val rawContent = BookHelp.getContent(book, chapter) + val text = if (rawContent != null) { + contentProcessor.getContent(book, chapter, rawContent, includeTitle = false) + .toString() + } else { + "" + } + _uiState.update { it.copy(contentEditText = text, contentEditLoading = false) } + ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false) + }.onError { + _uiState.update { it.copy(contentEditLoading = false) } + } + } + + fun reverseContent() { + execute { + val book = ReadBook.book ?: return@execute val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) ?: return@execute val content = BookHelp.getContent(book, chapter) ?: return@execute @@ -469,22 +1877,24 @@ class ReadBookViewModel( } } - /** - * 内容搜索跳转 - */ + // Backward-compatible overload for Activity + fun reverseContent(book: Book) { + reverseContent() + } + fun searchResultPositions( textChapter: TextChapter, searchResult: SearchResult ): Array { - // calculate search result's pageIndex val pages = textChapter.pages val content = textChapter.getContent() - val queryLength = searchContentQuery.length + val query = _uiState.value.searchContentQuery + val queryLength = query.length var count = 0 - var index = content.indexOf(searchContentQuery) + var index = content.indexOf(query) while (count != searchResult.resultCountWithinChapter) { - index = content.indexOf(searchContentQuery, index + queryLength) + index = content.indexOf(query, index + queryLength) count += 1 } val contentPosition = index @@ -495,7 +1905,6 @@ class ReadBookViewModel( length += pages[pageIndex].text.length } - // calculate search result's lineIndex val currentPage = pages[pageIndex] val curTextLines = currentPage.lines var lineIndex = 0 @@ -509,7 +1918,6 @@ class ReadBookViewModel( if (curLine.isParagraphEnd) length++ } - // charIndex val currentLine = currentPage.lines[lineIndex] var curLineLength = currentLine.text.length if (currentLine.isParagraphEnd) curLineLength++ @@ -518,12 +1926,10 @@ class ReadBookViewModel( val charIndex = contentPosition - length var addLine = 0 var charIndex2 = 0 - // change line if ((charIndex + queryLength) > curLineLength) { addLine = 1 charIndex2 = charIndex + queryLength - curLineLength - 1 } - // changePage if ((lineIndex + addLine + 1) > currentPage.lines.size) { addLine = -1 charIndex2 = charIndex + queryLength - curLineLength - 1 @@ -531,9 +1937,6 @@ class ReadBookViewModel( return arrayOf(pageIndex, lineIndex, charIndex, addLine, charIndex2) } - /** - * 翻转删除重复标题 - */ fun reverseRemoveSameTitle() { execute { val book = ReadBook.book ?: return@execute @@ -545,9 +1948,6 @@ class ReadBookViewModel( } } - /** - * 刷新图片 - */ fun refreshImage(src: String) { execute { ReadBook.book?.let { book -> @@ -560,9 +1960,6 @@ class ReadBookViewModel( } } - /** - * 保存图片 - */ fun saveImage(src: String?) { src ?: return val book = ReadBook.book ?: return @@ -583,9 +1980,6 @@ class ReadBookViewModel( } } - /** - * 替换规则变化 - */ fun replaceRuleChanged() { execute { ReadBook.book?.let { @@ -595,6 +1989,814 @@ class ReadBookViewModel( } } + private fun changeReplaceRule(enabled: Boolean) { + ReadBook.book?.let { + it.setUseReplaceRule(enabled) + ReadBook.saveRead() + replaceRuleChanged() + } + } + + private fun saveBookmark(bookmark: Bookmark) { + viewModelScope.launch(IO) { + appDb.bookmarkDao.insert(bookmark) + _uiState.update { + it.copy( + activeSheet = null, + menuState = ReadBookMenuState(), + ) + } + } + } + + private fun deleteBookmark(bookmark: Bookmark) { + viewModelScope.launch(IO) { + appDb.bookmarkDao.delete(bookmark) + _uiState.update { + it.copy( + activeSheet = null, + menuState = ReadBookMenuState(), + ) + } + } + } + + private fun openReadMenuRoute(route: ReadBookMenuRoute) { + _uiState.update { + it.copy( + menuState = ReadBookMenuState( + visible = true, + routeStack = kotlinx.collections.immutable.persistentListOf( + ReadBookMenuRoute.Main, + route, + ), + ), + ) + } + } + + @Suppress("LongMethod") + private fun handleConfigUpdate(update: ConfigUpdate) { + when (update) { + // --- Text style --- + is ConfigUpdate.TextSize -> ReadBookConfig.textSize = update.value + is ConfigUpdate.LetterSpacing -> ReadBookConfig.letterSpacing = update.value + is ConfigUpdate.LineSpacing -> ReadBookConfig.lineSpacingExtra = update.value + is ConfigUpdate.ParagraphSpacing -> ReadBookConfig.paragraphSpacing = update.value + is ConfigUpdate.ParagraphIndent -> ReadBookConfig.paragraphIndent = update.value + is ConfigUpdate.TextItalic -> ReadBookConfig.textItalic = update.value + is ConfigUpdate.TextBold -> ReadBookConfig.textBold = update.value + is ConfigUpdate.TextColor -> ReadBookConfig.durConfig.setCurTextColor(update.color) + is ConfigUpdate.TextAccentColor -> ReadBookConfig.durConfig.setCurTextAccentColor(update.color) + + // --- Title style --- + is ConfigUpdate.TitleMode -> ReadBookConfig.titleMode = update.value + is ConfigUpdate.TitleBold -> ReadBookConfig.titleBold = update.value + is ConfigUpdate.TitleSegScaling -> ReadBookConfig.titleSegScaling = update.value + is ConfigUpdate.TitleLineSpacingExtra -> ReadBookConfig.titleLineSpacingExtra = update.value + is ConfigUpdate.TitleLineSpacingSub -> ReadBookConfig.titleLineSpacingSub = update.value + is ConfigUpdate.TitleSize -> ReadBookConfig.titleSize = update.value + is ConfigUpdate.TitleTopSpacing -> ReadBookConfig.titleTopSpacing = update.value + is ConfigUpdate.TitleBottomSpacing -> ReadBookConfig.titleBottomSpacing = update.value + is ConfigUpdate.TitleColor -> ReadBookConfig.titleColor = update.color + + // --- Header / footer tips --- + is ConfigUpdate.HeaderMode -> ReadBookConfig.headerMode = update.value + is ConfigUpdate.FooterMode -> ReadBookConfig.footerMode = update.value + is ConfigUpdate.TipHeaderLeft -> ReadBookConfig.tipHeaderLeft = update.value + is ConfigUpdate.TipHeaderMiddle -> ReadBookConfig.tipHeaderMiddle = update.value + is ConfigUpdate.TipHeaderRight -> ReadBookConfig.tipHeaderRight = update.value + is ConfigUpdate.TipFooterLeft -> ReadBookConfig.tipFooterLeft = update.value + is ConfigUpdate.TipFooterMiddle -> ReadBookConfig.tipFooterMiddle = update.value + is ConfigUpdate.TipFooterRight -> ReadBookConfig.tipFooterRight = update.value + is ConfigUpdate.HeaderFontSize -> ReadBookConfig.headerFontSize = update.value + is ConfigUpdate.TipHeaderColor -> ReadBookConfig.tipHeaderColor = update.color + is ConfigUpdate.TipFooterColor -> ReadBookConfig.tipFooterColor = update.color + is ConfigUpdate.TipDividerColor -> ReadBookConfig.tipDividerColor = update.color + + // --- Layout / style --- + is ConfigUpdate.StyleSelect -> { + ReadBookConfig.styleSelect = update.index + viewModelScope.launch { + readSettingsRepository.setStyleSelect(ReadBookConfig.isComic, update.index) + } + } + is ConfigUpdate.ShareLayout -> { + ReadBookConfig.shareLayout = update.value + viewModelScope.launch { + readSettingsRepository.setShareLayout(update.value) + } + } + is ConfigUpdate.PageAnim -> ReadBookConfig.pageAnim = update.value + + // --- Menu appearance --- + is ConfigUpdate.MenuBgColor -> { + ReadBookConfig.readMenuBgColor = update.color + viewModelScope.launch { + readSettingsRepository.setReadMenuBgColor(update.color) + } + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + is ConfigUpdate.MenuAccentColor -> { + ReadBookConfig.readMenuAccentColor = update.color + viewModelScope.launch { + readSettingsRepository.setReadMenuAccentColor(update.color) + } + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + is ConfigUpdate.MenuContainerColor -> { + ReadBookConfig.readMenuContainerColor = update.color + viewModelScope.launch { + readSettingsRepository.setReadMenuContainerColor(update.color) + } + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + is ConfigUpdate.MenuBgColorNight -> { + ReadBookConfig.readMenuBgColorNight = update.color + viewModelScope.launch { + readSettingsRepository.setReadMenuBgColorNight(update.color) + } + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + is ConfigUpdate.MenuAccentColorNight -> { + ReadBookConfig.readMenuAccentColorNight = update.color + viewModelScope.launch { + readSettingsRepository.setReadMenuAccentColorNight(update.color) + } + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + is ConfigUpdate.MenuContainerColorNight -> { + ReadBookConfig.readMenuContainerColorNight = update.color + viewModelScope.launch { + readSettingsRepository.setReadMenuContainerColorNight(update.color) + } + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + is ConfigUpdate.MenuColorMode -> { + val value = update.value.coerceIn(0, 1) + ReadBookConfig.readMenuColorMode = value + viewModelScope.launch { + readSettingsRepository.setReadMenuColorMode(value) + } + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + is ConfigUpdate.ReadBarStyle -> { + val value = update.value.coerceIn(0, 2) + AppConfig.updateReadBarStyleCache(value) + viewModelScope.launch { + readSettingsRepository.setReadBarStyle(value) + } + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + + // --- Menu bar border --- + is ConfigUpdate.BorderWidth -> { + ReadBookConfig.readMenuBorderWidth = update.value + viewModelScope.launch { + readSettingsRepository.setReadMenuBorderWidth(update.value) + } + _uiState.update { it.copy(menuConfig = it.menuConfig.copy(readMenuBorderWidth = update.value)) } + } + is ConfigUpdate.BorderColor -> { + ReadBookConfig.readMenuBorderColor = update.color + viewModelScope.launch { + readSettingsRepository.setReadMenuBorderColor(update.color) + } + _uiState.update { it.copy(menuConfig = it.menuConfig.copy(readMenuBorderColor = update.color)) } + } + is ConfigUpdate.BorderColorNight -> { + ReadBookConfig.readMenuBorderColorNight = update.color + viewModelScope.launch { + readSettingsRepository.setReadMenuBorderColorNight(update.color) + } + _uiState.update { it.copy(menuConfig = it.menuConfig.copy(readMenuBorderColorNight = update.color)) } + } + + // --- Shadow --- + is ConfigUpdate.TextShadow -> ReadBookConfig.textShadow = update.value + is ConfigUpdate.ShadowRadius -> ReadBookConfig.shadowRadius = update.value + is ConfigUpdate.ShadowDx -> ReadBookConfig.shadowDx = update.value + is ConfigUpdate.ShadowDy -> ReadBookConfig.shadowDy = update.value + is ConfigUpdate.ShadowColor -> ReadBookConfig.durConfig.setCurShadColor(update.color) + + // --- Underline --- + is ConfigUpdate.Underline -> ReadBookConfig.underline = update.value + is ConfigUpdate.DottedLine -> ReadBookConfig.dottedLine = update.value + is ConfigUpdate.UnderlineExtend -> ReadBookConfig.underlineExtend = update.value + is ConfigUpdate.UnderlineHeight -> ReadBookConfig.underlineHeight = update.value + is ConfigUpdate.UnderlinePadding -> ReadBookConfig.underlinePadding = update.value + is ConfigUpdate.DottedBase -> ReadBookConfig.durConfig.dottedBase = update.value + is ConfigUpdate.DottedRatio -> ReadBookConfig.durConfig.dottedRatio = update.value + is ConfigUpdate.UnderlineColor -> ReadBookConfig.durConfig.setUnderlineColor(update.color) + + // --- Body padding --- + is ConfigUpdate.PaddingTop -> ReadBookConfig.paddingTop = update.value + is ConfigUpdate.PaddingBottom -> ReadBookConfig.paddingBottom = update.value + is ConfigUpdate.PaddingLeft -> ReadBookConfig.paddingLeft = update.value + is ConfigUpdate.PaddingRight -> ReadBookConfig.paddingRight = update.value + + // --- Header padding --- + is ConfigUpdate.HeaderPaddingTop -> ReadBookConfig.headerPaddingTop = update.value + is ConfigUpdate.HeaderPaddingBottom -> ReadBookConfig.headerPaddingBottom = update.value + is ConfigUpdate.HeaderPaddingLeft -> ReadBookConfig.headerPaddingLeft = update.value + is ConfigUpdate.HeaderPaddingRight -> ReadBookConfig.headerPaddingRight = update.value + is ConfigUpdate.ShowHeaderLine -> ReadBookConfig.showHeaderLine = update.value + + // --- Footer padding --- + is ConfigUpdate.FooterPaddingTop -> ReadBookConfig.footerPaddingTop = update.value + is ConfigUpdate.FooterPaddingBottom -> ReadBookConfig.footerPaddingBottom = update.value + is ConfigUpdate.FooterPaddingLeft -> ReadBookConfig.footerPaddingLeft = update.value + is ConfigUpdate.FooterPaddingRight -> ReadBookConfig.footerPaddingRight = update.value + is ConfigUpdate.ShowFooterLine -> ReadBookConfig.showFooterLine = update.value + + // --- Background / display --- + is ConfigUpdate.BgStr -> ReadBookConfig.durConfig.bgStr = update.value + is ConfigUpdate.BgStrNight -> ReadBookConfig.durConfig.bgStrNight = update.value + is ConfigUpdate.BgStrEInk -> ReadBookConfig.durConfig.bgStrEInk = update.value + is ConfigUpdate.BgType -> ReadBookConfig.durConfig.bgType = update.value + is ConfigUpdate.BgTypeNight -> ReadBookConfig.durConfig.bgTypeNight = update.value + is ConfigUpdate.BgTypeEInk -> ReadBookConfig.durConfig.bgTypeEInk = update.value + is ConfigUpdate.BgAlpha -> ReadBookConfig.bgAlpha = update.value + is ConfigUpdate.StatusIconDark -> ReadBookConfig.durConfig.setCurStatusIconDark(update.value) + is ConfigUpdate.MenuIconShowText -> { + ReadBookConfig.readMenuIconShowText = update.value + viewModelScope.launch { + readSettingsRepository.setReadMenuIconShowText(update.value) + } + _uiState.update { it.copy(menuConfig = it.menuConfig.copy(readMenuIconShowText = update.value)) } + } + is ConfigUpdate.MenuIconStyle -> { + val value = update.value.coerceIn(0, 2) + ReadBookConfig.readMenuIconStyle = value + viewModelScope.launch { + readSettingsRepository.setReadMenuIconStyle(value) + } + _uiState.update { it.copy(menuConfig = it.menuConfig.copy(readMenuIconStyle = value)) } + } + is ConfigUpdate.MenuIconItemsPerRow -> { + val value = update.value.coerceIn(2, 8) + ReadBookConfig.readMenuIconItemsPerRow = value + viewModelScope.launch { + readSettingsRepository.setReadMenuIconItemsPerRow(value) + } + _uiState.update { it.copy(menuConfig = it.menuConfig.copy(readMenuIconItemsPerRow = value)) } + } + is ConfigUpdate.MenuIconRowCount -> { + val value = update.value.coerceIn(1, 2) + ReadBookConfig.readMenuIconRowCount = value + viewModelScope.launch { + readSettingsRepository.setReadMenuIconRowCount(value) + } + _uiState.update { it.copy(menuConfig = it.menuConfig.copy(readMenuIconRowCount = value)) } + } + is ConfigUpdate.MenuBottomCornerRadius -> { + val value = update.value.coerceIn(0, 32) + ReadBookConfig.readMenuBottomCornerRadius = value + viewModelScope.launch { + readSettingsRepository.setReadMenuBottomCornerRadius(value) + } + _uiState.update { it.copy(menuConfig = it.menuConfig.copy(readMenuBottomCornerRadius = value)) } + } + is ConfigUpdate.FloatingBottomBar -> { + ReadBookConfig.readMenuFloatingBottomBar = update.value + viewModelScope.launch { + readSettingsRepository.setReadMenuFloatingBottomBar(update.value) + } + _uiState.update { it.copy(menuConfig = it.menuConfig.copy(readMenuFloatingBottomBar = update.value)) } + } + is ConfigUpdate.MenuTopBarBlurMode -> { + val mode = update.value.coerceIn(0, 2).let { + if (it == ReadMenuBlurMode.LiquidGlass) ReadMenuBlurMode.Haze else it + } + ReadBookConfig.readMenuTopBarBlurMode = mode + viewModelScope.launch { + readSettingsRepository.setReadMenuTopBarBlurMode(mode) + } + _uiState.update { + it.copy(menuConfig = it.menuConfig.copy(readMenuTopBarBlurMode = mode)) + } + } + + is ConfigUpdate.MenuBottomBarBlurMode -> { + val mode = update.value.coerceIn(0, 2) + ReadBookConfig.readMenuBottomBarBlurMode = mode + viewModelScope.launch { + readSettingsRepository.setReadMenuBottomBarBlurMode(mode) + } + _uiState.update { + it.copy(menuConfig = it.menuConfig.copy(readMenuBottomBarBlurMode = mode)) + } + } + + is ConfigUpdate.MenuTopBarLiquidGlassButtons -> { + ReadBookConfig.readMenuTopBarLiquidGlassButtons = update.value + viewModelScope.launch { + readSettingsRepository.setReadMenuTopBarLiquidGlassButtons(update.value) + } + _uiState.update { + it.copy(menuConfig = it.menuConfig.copy(readMenuTopBarLiquidGlassButtons = update.value)) + } + } + + is ConfigUpdate.MenuBottomBarLiquidGlassButtons -> { + ReadBookConfig.readMenuBottomBarLiquidGlassButtons = update.value + viewModelScope.launch { + readSettingsRepository.setReadMenuBottomBarLiquidGlassButtons(update.value) + } + _uiState.update { + it.copy(menuConfig = it.menuConfig.copy(readMenuBottomBarLiquidGlassButtons = update.value)) + } + } + + is ConfigUpdate.MenuTopBarBlurSelection -> { + val mode = update.mode.coerceIn(0, 2).let { + if (it == ReadMenuBlurMode.LiquidGlass) ReadMenuBlurMode.Haze else it + } + val style = update.style.coerceIn(0, 1) + ReadBookConfig.readMenuTopBarBlurMode = mode + ReadBookConfig.readMenuTopBarBlurStyle = style + viewModelScope.launch { + readSettingsRepository.setReadMenuTopBarBlurMode(mode) + readSettingsRepository.setReadMenuTopBarBlurStyle(style) + } + _uiState.update { + it.copy( + menuConfig = it.menuConfig.copy( + readMenuTopBarBlurMode = mode, + readMenuTopBarBlurStyle = style, + ) + ) + } + } + + is ConfigUpdate.MenuBottomBarBlurStyle -> { + val style = update.value.coerceIn(0, 1) + ReadBookConfig.readMenuBottomBarBlurStyle = style + viewModelScope.launch { + readSettingsRepository.setReadMenuBottomBarBlurStyle(style) + } + _uiState.update { + it.copy(menuConfig = it.menuConfig.copy(readMenuBottomBarBlurStyle = style)) + } + } + is ConfigUpdate.MenuBlurRadius -> { + ReadBookConfig.readMenuBlurRadius = update.value + viewModelScope.launch { + readSettingsRepository.setReadMenuBlurRadius(update.value) + } + _uiState.update { it.copy(menuConfig = it.menuConfig.copy(readMenuBlurRadius = update.value)) } + } + is ConfigUpdate.MenuBlurAlpha -> { + ReadBookConfig.readMenuBlurAlpha = update.value + viewModelScope.launch { + readSettingsRepository.setReadMenuBlurAlpha(update.value) + } + _uiState.update { it.copy(menuConfig = it.menuConfig.copy(readMenuBlurAlpha = update.value)) } + } + is ConfigUpdate.MenuLensRadius -> { + ReadBookConfig.readMenuLensRadius = update.value + viewModelScope.launch { + readSettingsRepository.setReadMenuLensRadius(update.value) + } + _uiState.update { it.copy(menuConfig = it.menuConfig.copy(readMenuLensRadius = update.value)) } + } + is ConfigUpdate.MenuCustomIcon -> { + val icons = ReadBookConfig.readMenuCustomIcons.toMutableMap() + if (update.path.isBlank()) { + icons.remove(update.id)?.let { path -> + runCatching { java.io.File(path).delete() } + } + } else { + icons[update.id] = update.path + } + ReadBookConfig.readMenuCustomIcons = icons + viewModelScope.launch { + readSettingsRepository.setReadMenuCustomIcons( + ReadBookConfig.encodeReadMenuCustomIcons(icons) + ) + } + _uiState.update { it.copy(menuConfig = it.menuConfig.copy(readMenuCustomIcons = icons.toImmutableMap())) } + } + is ConfigUpdate.TitleBarCustomIcon -> { + val icons = ReadBookConfig.titleBarCustomIcons.toMutableMap() + if (update.path.isBlank()) { + icons.remove(update.id)?.let { path -> + runCatching { java.io.File(path).delete() } + } + } else { + icons[update.id] = update.path + } + ReadBookConfig.titleBarCustomIcons = icons + viewModelScope.launch { + readSettingsRepository.setTitleBarCustomIcons( + ReadBookConfig.encodeReadMenuCustomIcons(icons) + ) + } + _uiState.update { it.copy(menuConfig = it.menuConfig.copy(titleBarCustomIcons = icons.toImmutableMap())) } + } + is ConfigUpdate.TitleBarIconPosition -> { + ReadBookConfig.titleBarIconPosition = update.value + viewModelScope.launch { + readSettingsRepository.setTitleBarIconPosition(update.value) + } + _uiState.update { it.copy(menuConfig = it.menuConfig.copy(titleBarIconPosition = update.value)) } + } + is ConfigUpdate.ShowTitleBarIcons -> { + ReadBookConfig.showTitleBarIcons = update.value + viewModelScope.launch { + readSettingsRepository.setShowTitleBarIcons(update.value) + } + _uiState.update { it.copy(menuConfig = it.menuConfig.copy(showTitleBarIcons = update.value)) } + } + + // --- System UI (also persists to DataStore) --- + is ConfigUpdate.HideStatusBar -> { + ReadBookConfig.hideStatusBar = update.value + viewModelScope.launch { + readSettingsRepository.setHideStatusBar(update.value) + } + } + is ConfigUpdate.HideNavigationBar -> { + ReadBookConfig.hideNavigationBar = update.value + viewModelScope.launch { + readSettingsRepository.setHideNavigationBar(update.value) + } + } + + // --- Display toggles --- + is ConfigUpdate.PaddingDisplayCutouts -> { + viewModelScope.launch { + readSettingsRepository.setPaddingDisplayCutouts(update.value) + } + } + is ConfigUpdate.TitleBarMode -> { + viewModelScope.launch { + readSettingsRepository.setTitleBarMode(update.value) + } + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + is ConfigUpdate.TextFullJustify -> { + viewModelScope.launch { + readSettingsRepository.setTextFullJustify(update.value) + } + } + is ConfigUpdate.TextBottomJustify -> { + viewModelScope.launch { + readSettingsRepository.setTextBottomJustify(update.value) + } + } + is ConfigUpdate.AdaptSpecialStyle -> { + viewModelScope.launch { + readSettingsRepository.setAdaptSpecialStyle(update.value) + } + } + is ConfigUpdate.UseZhLayout -> { + ReadBookConfig.useZhLayout = update.value + viewModelScope.launch { + readSettingsRepository.setUseZhLayout(update.value) + } + } + is ConfigUpdate.ShowBrightnessView -> { + viewModelScope.launch { + readSettingsRepository.setShowBrightnessView(update.value) + } + postEvent(PreferKey.showBrightnessView, "") + } + is ConfigUpdate.UseUnderlineGlobal -> { + viewModelScope.launch { + readSettingsRepository.setUseUnderline(update.value) + } + } + is ConfigUpdate.ReadSliderMode -> { + viewModelScope.launch { + readSettingsRepository.setReadSliderMode(update.value) + } + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + is ConfigUpdate.DoubleHorizontalPage -> { + viewModelScope.launch { + readSettingsRepository.setDoubleHorizontalPage(update.value) + } + ChapterProvider.upLayout() + ReadBook.loadContent(false) + } + is ConfigUpdate.ProgressBarBehavior -> { + viewModelScope.launch { + readSettingsRepository.setProgressBarBehavior(update.value) + } + _uiState.update { it.copy(styleConfig = buildStyleConfig()) } + } + is ConfigUpdate.NoAnimScrollPage -> { + viewModelScope.launch { + readSettingsRepository.setNoAnimScrollPage(update.value) + } + _effects.tryEmit(ReadBookEffect.UpPageAnim(upRecorder = false)) + } + is ConfigUpdate.ShowReadTitleAddition -> { + viewModelScope.launch { + readSettingsRepository.setShowReadTitleAddition(update.value) + } + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + + // --- Highlight rules --- + is ConfigUpdate.HighlightRules -> { + HighlightRuleStore.save(update.rules) + TextChapterLayout.invalidateRegexCache() + } + + // --- Auto read --- + is ConfigUpdate.AutoReadSpeed -> { + ReadBookConfig.autoReadSpeed = update.value + viewModelScope.launch { + readSettingsRepository.setAutoReadSpeed(update.value) + } + } + } + + // Notify rendering layer + if (update.actions.isNotEmpty()) { + _uiState.update { it.copy(styleConfig = buildStyleConfig()) } + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig(update.actions)) + } else { + _uiState.update { it.copy(styleConfig = buildStyleConfig()) } + } + } + + private fun saveMenuCustomIcon(id: String, uri: Uri) { + execute { + val iconFile = java.io.File(context.filesDir, "read_menu_icons/$id.png") + iconFile.parentFile?.mkdirs() + context.contentResolver.openInputStream(uri)?.use { input -> + iconFile.outputStream().use { output -> input.copyTo(output) } + } + handleConfigUpdate(ConfigUpdate.MenuCustomIcon(id, iconFile.absolutePath)) + } + } + + private fun saveTitleBarCustomIcon(id: String, uri: Uri) { + execute { + val iconFile = java.io.File(context.filesDir, "title_bar_icons/$id.png") + iconFile.parentFile?.mkdirs() + context.contentResolver.openInputStream(uri)?.use { input -> + iconFile.outputStream().use { output -> input.copyTo(output) } + } + handleConfigUpdate(ConfigUpdate.TitleBarCustomIcon(id, iconFile.absolutePath)) + } + } + + private fun selectFont(path: String) { + ReadBookConfig.textFont = path + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig( + setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.UpdateStyle) + )) + } + + private fun toggleDayNight() { + val nextMode = when (ThemeConfig.themeMode) { + "0" -> "1" // follow system → light + "1" -> "2" // light → dark + else -> "0" // dark → follow system + } + ThemeConfig.themeMode = nextMode + AppConfig.themeMode = nextMode + _uiState.update { it.copy(styleConfig = buildStyleConfig()) } + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig( + setOf( + ConfigUpdateAction.UpdateBackground, + ConfigUpdateAction.UpdateStyle, + ConfigUpdateAction.UpdateContent + ) + )) + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + + private fun applyReadStyleBackgroundImage(uri: Uri) { + viewModelScope.launch(IO) { + runCatching { + val name = queryDisplayName(uri) + val path = context.contentResolver.openInputStream(uri)?.use { + readBookStyleConfigRepository.saveBackgroundImage(it, name) + } ?: throw FileNotFoundException(uri.toString()) + readBookStyleConfigRepository.setCurrentBackgroundImage(path) + _uiState.update { it.copy(styleConfig = buildStyleConfig()) } + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig( + setOf(ConfigUpdateAction.UpdateBackground) + )) + context.getString(R.string.success) + }.onSuccess { message -> + _effects.tryEmit(ReadBookEffect.ShowToast(message)) + }.onFailure { throwable -> + AppLog.put("选择阅读背景图失败", throwable) + _effects.tryEmit(ReadBookEffect.LongToast(throwable.localizedMessage ?: context.getString(R.string.error))) + } + } + } + + private fun applyReadStyleBackgroundImageForMode(uri: Uri, isNight: Boolean) { + viewModelScope.launch(IO) { + runCatching { + val name = queryDisplayName(uri) + val path = context.contentResolver.openInputStream(uri)?.use { + readBookStyleConfigRepository.saveBackgroundImage(it, name) + } ?: throw FileNotFoundException(uri.toString()) + readBookStyleConfigRepository.setCurrentBackgroundImageForMode(path, isNight) + _uiState.update { it.copy(styleConfig = buildStyleConfig()) } + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig( + setOf(ConfigUpdateAction.UpdateBackground) + )) + context.getString(R.string.success) + }.onSuccess { message -> + _effects.tryEmit(ReadBookEffect.ShowToast(message)) + }.onFailure { throwable -> + AppLog.put("选择阅读背景图失败", throwable) + _effects.tryEmit(ReadBookEffect.LongToast(throwable.localizedMessage ?: context.getString(R.string.error))) + } + } + } + + private fun importReadStyleConfig(uri: Uri) { + viewModelScope.launch(IO) { + runCatching { + val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw FileNotFoundException(uri.toString()) + readBookStyleConfigRepository.importCurrentStyle(bytes) + _uiState.update { it.copy(styleConfig = buildStyleConfig()) } + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig( + setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent) + )) + context.getString(R.string.success) + }.onSuccess { message -> + _effects.tryEmit(ReadBookEffect.ShowToast(message)) + }.onFailure { throwable -> + AppLog.put("导入阅读样式失败", throwable) + _effects.tryEmit(ReadBookEffect.LongToast(throwable.localizedMessage ?: context.getString(R.string.error))) + } + } + } + + private fun exportReadStyleConfig(uri: Uri) { + viewModelScope.launch(IO) { + runCatching { + val bytes = readBookStyleConfigRepository.exportCurrentStyle() + context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) } + ?: throw FileNotFoundException(uri.toString()) + context.getString(R.string.export_success) + }.onSuccess { message -> + _effects.tryEmit(ReadBookEffect.ShowToast(message)) + }.onFailure { throwable -> + AppLog.put("导出阅读样式失败", throwable) + _effects.tryEmit(ReadBookEffect.LongToast(throwable.localizedMessage ?: context.getString(R.string.error))) + } + } + } + + private fun queryDisplayName(uri: Uri): String? { + return context.contentResolver.query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME), + null, + null, + null + )?.use { cursor -> + if (cursor.moveToFirst()) { + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index >= 0) cursor.getString(index) else null + } else { + null + } + } + } + + private fun colorSelected(dialogId: Int, color: Int) { + ReadBookConfig.durConfig.apply { + when (dialogId) { + ReadBookColorPickerIds.SHADOW_COLOR -> { + setCurShadColor(color) + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig( + setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent, ConfigUpdateAction.InvalidateTextPage, ConfigUpdateAction.SubmitRenderTask) + )) + } + + ReadBookColorPickerIds.TEXT_COLOR -> { + setCurTextColor(color) + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig( + setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent, ConfigUpdateAction.InvalidateTextPage, ConfigUpdateAction.SubmitRenderTask) + )) + if (AppConfig.readBarStyleFollowPage) { + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + } + + ReadBookColorPickerIds.TEXT_ACCENT_COLOR -> { + setCurTextAccentColor(color) + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig( + setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent, ConfigUpdateAction.InvalidateTextPage, ConfigUpdateAction.SubmitRenderTask) + )) + if (AppConfig.readBarStyleFollowPage) { + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + } + + ReadBookColorPickerIds.BG_COLOR -> { + setCurBg(0, "#${color.hexString}") + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig( + setOf(ConfigUpdateAction.UpdateBackground) + )) + if (AppConfig.readBarStyleFollowPage) { + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + } + + ReadBookColorPickerIds.TIP_HEADER_COLOR -> { + ReadBookConfig.tipHeaderColor = color + postEvent(EventBus.TIP_COLOR, "") + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig( + setOf(ConfigUpdateAction.UpdateStyle) + )) + } + + ReadBookColorPickerIds.TIP_FOOTER_COLOR -> { + ReadBookConfig.tipFooterColor = color + postEvent(EventBus.TIP_COLOR, "") + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig( + setOf(ConfigUpdateAction.UpdateStyle) + )) + } + + ReadBookColorPickerIds.TIP_DIVIDER_COLOR -> { + ReadBookConfig.tipDividerColor = color + postEvent(EventBus.TIP_COLOR, "") + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig( + setOf(ConfigUpdateAction.UpdateStyle) + )) + } + + ReadBookColorPickerIds.TITLE_COLOR -> { + ReadBookConfig.titleColor = color + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig( + setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent) + )) + } + + ReadBookColorPickerIds.HIGHLIGHT_RULE_COLOR -> { + val pos = ReadBookColorPickerIds.pendingHighlightRulePosition + val rules = HighlightRuleStore.load() + if (pos in rules.indices) { + HighlightRuleStore.update(rules[pos].copy(textColor = color)) + TextChapterLayout.invalidateRegexCache() + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig( + setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent) + )) + } + } + + ReadBookColorPickerIds.MENU_BG_COLOR -> { + ReadBookConfig.readMenuBgColor = color + viewModelScope.launch { + readSettingsRepository.setReadMenuBgColor(color) + } + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + + ReadBookColorPickerIds.MENU_ACCENT_COLOR -> { + ReadBookConfig.readMenuAccentColor = color + viewModelScope.launch { + readSettingsRepository.setReadMenuAccentColor(color) + } + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + + ReadBookColorPickerIds.UNDERLINE_COLOR -> { + setUnderlineColor(color) + _effects.tryEmit(ReadBookEffect.UpdateReadViewConfig( + setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent, ConfigUpdateAction.InvalidateTextPage, ConfigUpdateAction.SubmitRenderTask) + )) + } + } + } + } + + private fun toggleTranslation() { + val book = ReadBook.book ?: return + book.setTranslationMode(!book.getTranslationMode()) + book.save() + ReadBook.loadContent(false) + } + + private fun retranslateCurrentChapter() { + val book = ReadBook.book ?: return + viewModelScope.launch { + val chapter = appDb.bookChapterDao.getChapter( + book.bookUrl, ReadBook.durChapterIndex + ) ?: return@launch + io.legado.app.model.translation.TranslationManager.deleteTranslationCache(book, chapter) + book.setTranslationMode(true) + book.save() + ReadBook.loadContent(false) + } + } + fun disableSource() { execute { ReadBook.bookSource?.let { @@ -604,11 +2806,86 @@ class ReadBookViewModel( } } + private fun showPayDialog() { + val book = ReadBook.book ?: return + if (book.isLocal) return + val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) + if (chapter == null) { + context.toastOnUi(R.string.no_chapter) + return + } + _uiState.update { it.copy(activeDialog = ReadBookDialog.ConfirmChapterPay(chapter.title)) } + } + + private fun confirmPayAction() { + val book = ReadBook.book ?: return + if (book.isLocal) return + execute { + val source = ReadBook.bookSource ?: throw NoStackTraceException("no book source") + val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) + ?: throw NoStackTraceException(context.getString(R.string.no_chapter)) + val payAction = source.getContentRule().payAction + if (payAction.isNullOrBlank()) { + throw NoStackTraceException("no pay action") + } + val analyzeRule = AnalyzeRule(book, source) + analyzeRule.setCoroutineContext(coroutineContext) + analyzeRule.setBaseUrl(chapter.url) + analyzeRule.setChapter(chapter) + analyzeRule.evalJS(payAction).toString() to chapter + }.onSuccess(IO) { (result, chapter) -> + if (result.isAbsUrl()) { + _effects.tryEmit( + ReadBookEffect.OpenWebView( + title = context.getString(R.string.chapter_pay), + url = result, + sourceOrigin = ReadBook.bookSource?.bookSourceUrl, + sourceName = ReadBook.bookSource?.bookSourceName, + sourceType = ReadBook.bookSource?.getSourceType(), + ) + ) + } else if (result.isTrue()) { + BookHelp.delContent(book, chapter) + loadChapterList(book) + } + }.onError { + AppLog.put("执行购买操作出错\n${it.localizedMessage}", it, true) + } + } + + private fun requestBooksDirPicker(reloadChapterList: Boolean) { + pendingBooksDirReloadChapterList = reloadChapterList + _effects.tryEmit(ReadBookEffect.OpenBooksDirPicker) + } + + private fun onBooksDirSelected(uri: Uri) { + OtherConfig.defaultBookTreeUri = uri.toString() + val reloadChapterList = pendingBooksDirReloadChapterList + pendingBooksDirReloadChapterList = false + val book = ReadBook.book ?: return + if (reloadChapterList) { + doLoadChapterList(book) + } else { + execute { initBook(book) } + } + } + + private fun exitSearch() { + _uiState.update { + it.copy( + isShowingSearchResult = false, + searchMenuVisible = false + ) + } + _effects.tryEmit(ReadBookEffect.ExitSearch) + } + override fun onCleared() { super.onCleared() if (BaseReadAloudService.isRun && BaseReadAloudService.pause) { ReadAloud.stop(context) } + ReadBook.unregister(this) } fun addToBookshelf(book: Book, toc: List, success: (() -> Unit)? = null) { @@ -617,7 +2894,6 @@ class ReadBookViewModel( if (book.order == 0) { book.order = appDb.bookDao.minOrder - 1 } - appDb.bookDao.insert(book) appDb.bookChapterDao.insert(*toc.toTypedArray()) }.onSuccess { @@ -627,5 +2903,10 @@ class ReadBookViewModel( context.toastOnUi("添加书籍失败") } } - } + +private const val TITLE_BAR_ICON_PREFS = "title_bar_icons" +private const val TITLE_BAR_ICON_KEY = "icons" +private const val TOOL_BUTTON_PREFS = "tool_button_config" +private const val TOOL_BUTTON_KEY = "tool_buttons" +private const val DEFAULT_ENABLED_BUTTON_COUNT = 5 diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt deleted file mode 100644 index 9a71d6e40..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt +++ /dev/null @@ -1,948 +0,0 @@ -package io.legado.app.ui.book.read - -import android.annotation.SuppressLint -import android.content.Context -import android.content.res.ColorStateList -import android.content.res.Configuration -import android.graphics.drawable.GradientDrawable -import android.graphics.drawable.RippleDrawable -import android.util.AttributeSet -import android.view.Gravity -import android.view.LayoutInflater -import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE -import android.view.animation.AlphaAnimation -import android.view.animation.Animation -import android.widget.FrameLayout -import android.widget.SeekBar -import androidx.annotation.OptIn -import androidx.appcompat.widget.PopupMenu -import androidx.core.graphics.ColorUtils -import androidx.core.graphics.toColorInt -import androidx.core.view.HapticFeedbackConstantsCompat -import androidx.core.view.doOnAttach -import androidx.core.view.forEach -import androidx.core.view.isGone -import androidx.core.view.isVisible -import com.google.android.material.badge.BadgeDrawable -import com.google.android.material.badge.BadgeUtils -import com.google.android.material.badge.ExperimentalBadgeUtils -import com.google.android.material.button.MaterialButton -import com.google.android.material.button.MaterialButtonGroup -import com.google.android.material.overflow.OverflowLinearLayout -import com.google.android.material.slider.Slider -import io.legado.app.R -import io.legado.app.constant.PreferKey -import io.legado.app.databinding.ViewReadMenuBinding -import io.legado.app.help.config.AppConfig -import io.legado.app.help.config.LocalConfig -import io.legado.app.help.config.OldThemeConfig -import io.legado.app.help.config.ReadBookConfig -import io.legado.app.help.coroutine.Coroutine -import io.legado.app.help.source.getSourceType -import io.legado.app.lib.dialogs.alert -import io.legado.app.model.ReadBook -import io.legado.app.ui.browser.WebViewActivity -import io.legado.app.ui.widget.seekbar.SeekBarChangeListener -import io.legado.app.utils.ConstraintModify -import io.legado.app.utils.activity -import io.legado.app.utils.applyNavigationBarPadding -import io.legado.app.utils.dpToPx -import io.legado.app.utils.getPrefBoolean -import io.legado.app.utils.gone -import io.legado.app.utils.invisible -import io.legado.app.utils.loadAnimation -import io.legado.app.utils.modifyBegin -import io.legado.app.utils.openUrl -import io.legado.app.utils.putPrefBoolean -import io.legado.app.utils.startActivity -import io.legado.app.utils.themeColor -import io.legado.app.utils.visible -import splitties.views.onClick - -/** - * 阅读界面菜单 - */ -class ReadMenu @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null -) : FrameLayout(context, attrs) { - var canShowMenu: Boolean = false - private val callBack: CallBack get() = activity as CallBack - private val binding = ViewReadMenuBinding.inflate(LayoutInflater.from(context), this, true) - private var confirmSkipToChapter: Boolean = false - private var isMenuOutAnimating = false - - private val menuTopIn: Animation by lazy { - loadAnimation(context, R.anim.anim_readbook_top_in) - } - private val menuTopOut: Animation by lazy { - loadAnimation(context, R.anim.anim_readbook_top_out) - } - private val menuBottomIn: Animation by lazy { - loadAnimation(context, R.anim.anim_readbook_bottom_in) - } - private val menuBottomOut: Animation by lazy { - loadAnimation(context, R.anim.anim_readbook_bottom_out) - } - - private val fadeIn = AlphaAnimation(0f, 1f).apply { - duration = 280 - fillAfter = true - } - - private val fadeOut = AlphaAnimation(1f, 0f).apply { - duration = 280 - fillAfter = true - } - - private val colorSurfaceContainer: Int - get() = context.themeColor(com.google.android.material.R.attr.colorSurfaceContainer) - - private val colorSecondary: Int - get() = context.themeColor(androidx.appcompat.R.attr.colorPrimary) - - private val colorSecondaryContainer: Int - get() = context.themeColor(com.google.android.material.R.attr.colorSecondaryContainer) - - private val bgColor: Int - get() = when (AppConfig.readBarStyle) { - 0 -> colorSurfaceContainer - 1 -> runCatching { - ReadBookConfig.durConfig.curBgStr().toColorInt() - }.getOrDefault(colorSurfaceContainer) - else -> ReadBookConfig.durConfig.curMenuBg() - } - - - private val acColor: Int - get() = when (AppConfig.readBarStyle) { - 0 -> colorSecondary - 1 -> runCatching { - ReadBookConfig.durConfig.curTextColor() - }.getOrDefault(colorSecondary) - else -> ReadBookConfig.durConfig.curMenuAc() - } - - - private val bgcColor: Int - get() = when (AppConfig.readBarStyle) { - 0 -> colorSecondaryContainer - 1 -> runCatching { - val baseColor = ReadBookConfig.durConfig.curTextColor() - ColorUtils.setAlphaComponent(baseColor, (255 * 0.1f).toInt()) - }.getOrDefault(colorSecondaryContainer) - else -> ColorUtils.setAlphaComponent(acColor, (255 * 0.1f).toInt()) - } - - private var onMenuOutEnd: (() -> Unit)? = null - - private val showBrightnessView - get() = context.getPrefBoolean( - PreferKey.showBrightnessView, - true - ) - - private val sourceMenu by lazy { - PopupMenu(context, binding.tvSourceAction).apply { - inflate(R.menu.book_read_source) - setOnMenuItemClickListener { - when (it.itemId) { - R.id.menu_login -> callBack.showLogin() - R.id.menu_chapter_pay -> callBack.payAction() - R.id.menu_edit_source -> callBack.openSourceEditActivity() - R.id.menu_disable_source -> callBack.disableSource() - } - true - } - } - } - - private val menuInListener = object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation) { - binding.tvSourceAction.text = - ReadBook.bookSource?.bookSourceName ?: context.getString(R.string.book_source) - binding.tvSourceAction.isGone = ReadBook.isLocalBook - callBack.upSystemUiVisibility() - binding.llBrightness.visible(showBrightnessView) - } - - @SuppressLint("RtlHardcoded") - override fun onAnimationEnd(animation: Animation) { - binding.vwMenuBg.setOnClickListener { runMenuOut() } - callBack.upSystemUiVisibility() - if (!LocalConfig.readMenuHelpVersionIsLast) { - callBack.showHelp() - } - } - - override fun onAnimationRepeat(animation: Animation) = Unit - } - private val menuOutListener = object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation) { - isMenuOutAnimating = true - binding.vwMenuBg.setOnClickListener(null) - } - - override fun onAnimationEnd(animation: Animation) { - this@ReadMenu.invisible() - binding.titleBar.invisible() - binding.bottomMenu.invisible() - canShowMenu = false - isMenuOutAnimating = false - onMenuOutEnd?.invoke() - callBack.upSystemUiVisibility() - } - - override fun onAnimationRepeat(animation: Animation) = Unit - } - - init { - doOnAttach { - initView() - upBrightnessState() - bindEvent() - } - } - - private fun initView() = binding.run { - val bgColor = this@ReadMenu.bgColor - val acColor = this@ReadMenu.acColor - val bgcColor = this@ReadMenu.bgcColor - val alphaBgColor = ColorUtils.setAlphaComponent(bgColor, (AppConfig.menuAlpha / 100f * 255).toInt()) - initAnimation() - updateSliderVisibility() - val brightnessBackground = GradientDrawable() - brightnessBackground.cornerRadius = 5F.dpToPx() - llBrightness.background = brightnessBackground - -// if (AppConfig.isEInkMode) { -// titleBar.setBackgroundResource(R.drawable.bg_eink_border_bottom) -// } - - llBrightness.setOnClickListener(null) - seekBrightness.post { - seekBrightness.progress = AppConfig.readBrightness - } - if (AppConfig.showReadTitleBarAddition) { - titleBarAddition.visible() - } else { - titleBarAddition.gone() - } - binding.bottomView.post { - val allButtons = getUserButtons() - renderButtons(binding.bottomView, allButtons) - } - titleBar.setBackgroundColor(alphaBgColor) - titleBar.toolbar.setBackgroundColor(alphaBgColor) - bottomView.setBackgroundColor(alphaBgColor) - (tvPre.background as? RippleDrawable)?.setColor(ColorStateList.valueOf(bgcColor)) - (tvNext.background as? RippleDrawable)?.setColor(ColorStateList.valueOf(bgcColor)) - cdSlider.setCardBackgroundColor(alphaBgColor) - seekReadPage.trackInactiveTintList = ColorStateList.valueOf(bgcColor) - seekReadPage.trackActiveTintList = ColorStateList.valueOf(acColor) - seekReadPage.thumbTintList = ColorStateList.valueOf(acColor) - seekReadPage.tickActiveTintList = ColorStateList.valueOf(bgColor) - seekReadPage.tickInactiveTintList = ColorStateList.valueOf(acColor) - tvPre.iconTint = ColorStateList.valueOf(acColor) - tvNext.iconTint = ColorStateList.valueOf(acColor) - tvBookName.setTextColor(acColor) - tvChapterName.setTextColor(acColor) - tvChapterUrl.setTextColor(acColor) - tvSourceAction.setTextColor(acColor) - tvPre.backgroundTintList = ColorStateList.valueOf(bgColor) - tvNext.backgroundTintList = ColorStateList.valueOf(bgColor) - tvPre.alpha = AppConfig.menuAlpha / 100f * 255 - tvNext.alpha = AppConfig.menuAlpha / 100f * 255 - upBrightnessVwPos() - /** - * 确保视图不被导航栏遮挡 - */ - if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { - binding.bottomMenu.applyNavigationBarPadding() - } else { - binding.bottomView.applyNavigationBarPadding() - } - } - - fun updateToolBarColor() { - val acColor = this@ReadMenu.acColor - binding.titleBar.toolbar.navigationIcon?.setTint(acColor) - binding.titleBar.toolbar.apply { - setTitleTextColor(acColor) - setSubtitleTextColor(acColor) - } - binding.titleBar.toolbar.menu.forEach { item -> - item.icon?.setTint(acColor) - } - binding.titleBar.toolbar.overflowIcon?.setTint(acColor) - } - - fun reset() { - initView() - updateToolBarColor() - upBookView() - } - - fun refreshMenuColorFilter() { -// if (immersiveMenu) { -// //binding.titleBar.setColorFilter(textColor) -// } - } - - fun upBrightnessState() { - if (brightnessAuto()) { - binding.ivBrightnessAuto.setColorFilter(context.themeColor(androidx.appcompat.R.attr.colorPrimary)) - binding.seekBrightness.isEnabled = false - } else { - binding.ivBrightnessAuto.setColorFilter(context.themeColor(com.google.android.material.R.attr.colorOnSurface)) - binding.seekBrightness.isEnabled = true - } - setScreenBrightness(AppConfig.readBrightness.toFloat()) - } - - /** - * 设置屏幕亮度 - */ - fun setScreenBrightness(value: Float) { - activity?.run { - var brightness = BRIGHTNESS_OVERRIDE_NONE - if (!brightnessAuto() && value != BRIGHTNESS_OVERRIDE_NONE) { - brightness = value - if (brightness < 1f) brightness = 1f - brightness /= 255f - } - val params = window.attributes - params.screenBrightness = brightness - window.attributes = params - } - } - - fun runMenuIn(anim: Boolean = !AppConfig.isEInkMode) { - callBack.onMenuShow() - this.visible() - binding.titleBar.visible() - binding.bottomMenu.visible() - updateToolBarColor() - changeReplace(ReadBook.book?.getUseReplaceRule() ?: false) - updateBadge("replace_badge", ReadBook.curTextChapter?.effectiveReplaceRules?.size ?: 0) - if (anim) { - binding.titleBar.startAnimation(menuTopIn) - binding.bottomMenu.startAnimation(menuBottomIn) - updateBrightnessVisibility(true) - } else { - menuInListener.onAnimationStart(menuBottomIn) - menuInListener.onAnimationEnd(menuBottomIn) - } - } - - fun runMenuOut(anim: Boolean = !AppConfig.isEInkMode, onMenuOutEnd: (() -> Unit)? = null) { - if (isMenuOutAnimating) { - return - } - callBack.onMenuHide() - this.onMenuOutEnd = onMenuOutEnd - if (this.isVisible) { - if (anim) { - binding.titleBar.startAnimation(menuTopOut) - binding.bottomMenu.startAnimation(menuBottomOut) - updateBrightnessVisibility(false) - - } else { - menuOutListener.onAnimationStart(menuBottomOut) - menuOutListener.onAnimationEnd(menuBottomOut) - } - } - } - - fun updateBrightnessVisibility(boolean: Boolean) { - if (showBrightnessView) { - if(boolean){ - binding.llBrightness.startAnimation(fadeIn) - }else{ - binding.llBrightness.startAnimation(fadeOut) - } - } - } - - private fun brightnessAuto(): Boolean { - return context.getPrefBoolean("brightnessAuto", true) || !showBrightnessView - } - - private fun bindEvent() = binding.run { - vwMenuBg.setOnClickListener { runMenuOut() } - titleBar.toolbar.setOnClickListener { - callBack.openBookInfoActivity() - } - tvBookName.setOnClickListener { - callBack.openBookInfoActivity() - } - val chapterViewClickListener = OnClickListener { - if (ReadBook.isLocalBook) { - return@OnClickListener - } - if (AppConfig.readUrlInBrowser) { - context.openUrl(tvChapterUrl.text.toString().substringBefore(",{")) - } else { - Coroutine.async { - context.startActivity { - val url = tvChapterUrl.text.toString() - val bookSource = ReadBook.bookSource - putExtra("title", tvChapterName.text) - putExtra("url", url) - putExtra("sourceOrigin", bookSource?.bookSourceUrl) - putExtra("sourceName", bookSource?.bookSourceName) - putExtra("sourceType", bookSource?.getSourceType()) - } - } - } - } - val chapterViewLongClickListener = OnLongClickListener { - if (ReadBook.isLocalBook) { - return@OnLongClickListener true - } - context.alert(R.string.open_fun) { - setMessage(R.string.use_browser_open) - okButton { - AppConfig.readUrlInBrowser = true - } - noButton { - AppConfig.readUrlInBrowser = false - } - } - true - } - tvChapterName.setOnClickListener(chapterViewClickListener) - tvChapterName.setOnLongClickListener(chapterViewLongClickListener) - //书源操作 - tvSourceAction.onClick { - sourceMenu.menu.findItem(R.id.menu_login).isVisible = - !ReadBook.bookSource?.loginUrl.isNullOrEmpty() - sourceMenu.menu.findItem(R.id.menu_chapter_pay).isVisible = - !ReadBook.bookSource?.loginUrl.isNullOrEmpty() - && ReadBook.curTextChapter?.isVip == true - && ReadBook.curTextChapter?.isPay != true - sourceMenu.show() - } - //亮度跟随 - ivBrightnessAuto.setOnClickListener { - context.putPrefBoolean("brightnessAuto", !brightnessAuto()) - upBrightnessState() - } - //亮度调节 - seekBrightness.setOnSeekBarChangeListener(object : SeekBarChangeListener { - - override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { - if (fromUser) { - setScreenBrightness(progress.toFloat()) - } - } - - override fun onStopTrackingTouch(seekBar: SeekBar) { - AppConfig.readBrightness = seekBar.progress - } - - }) - - vwBrightnessPosAdjust.setOnClickListener { - AppConfig.brightnessVwPos = !AppConfig.brightnessVwPos - upBrightnessVwPos() - } - - seekReadPage.addOnChangeListener { _, value, fromUser -> - if (fromUser) { - if (AppConfig.progressBarBehavior == "page") - ReadBook.skipToPage(value.toInt() - 1) - if (AppConfig.sliderVibrator) - HapticFeedbackConstantsCompat.TEXT_HANDLE_MOVE - } - } - - seekReadPage.addOnSliderTouchListener(object : Slider.OnSliderTouchListener { - override fun onStartTrackingTouch(slider: Slider) { - vwMenuBg.setOnClickListener(null) - //VibrationUtils.vibrate(context, 16) - } - - override fun onStopTrackingTouch(slider: Slider) { - vwMenuBg.setOnClickListener { runMenuOut() } - val progress = slider.value.toInt() - - when (AppConfig.progressBarBehavior) { - "page" -> ReadBook.skipToPage(progress - 1) - "chapter" -> { - if (confirmSkipToChapter) { - callBack.skipToChapter(progress - 1) - } else { - context.alert("章节跳转确认", "确定要跳转章节吗?") { - yesButton { - confirmSkipToChapter = true - callBack.skipToChapter(progress - 1) - } - noButton { upSeekBar() } - onCancelled { upSeekBar() } - } - } - } - } - } - }) - - //上一章 - tvPre.setOnClickListener { ReadBook.moveToPrevChapter(upContent = true, toLast = false) } - - //下一章 - tvNext.setOnClickListener { ReadBook.moveToNextChapter(true) } - } - - private fun updateSliderVisibility() { - when (AppConfig.readSliderMode) { - "0" -> { - binding.llSlider.gravity = Gravity.CENTER - binding.llSlider.isVisible = true - binding.cdSlider.isVisible = true - binding.tvPre.isVisible = true - binding.tvNext.isVisible = true - } - - "1" -> { - binding.llSlider.gravity = Gravity.CENTER - binding.llSlider.isVisible = false - binding.cdSlider.isVisible = false - binding.tvPre.isVisible = false - binding.tvNext.isVisible = false - } - - "2" -> { - binding.llSlider.gravity = Gravity.START - binding.llSlider.isVisible = true - binding.cdSlider.isVisible = false - binding.tvPre.isVisible = true - binding.tvNext.isVisible = true - } - - "3" -> { - binding.llSlider.gravity = Gravity.END - binding.llSlider.isVisible = true - binding.cdSlider.isVisible = false - binding.tvPre.isVisible = true - binding.tvNext.isVisible = true - } - - "4" -> { - binding.llSlider.gravity = Gravity.CENTER - binding.llSlider.isVisible = true - binding.cdSlider.isVisible = true - binding.tvPre.isVisible = false - binding.tvNext.isVisible = false - } - - else -> { - binding.llSlider.isVisible = true - binding.cdSlider.isVisible = true - binding.tvPre.isVisible = true - binding.tvNext.isVisible = true - } - } - } - - private val buttonMap = mutableMapOf() - - fun renderButtons(group: MaterialButtonGroup, buttons: List) { - group.removeAllViews() - buttonMap.clear() - - buttons.forEach { btn -> - val style = com.google.android.material.R.attr.materialIconButtonOutlinedStyle - val button = MaterialButton(group.context, null, style).apply { - id = btn.id.hashCode() - setIconResource(btn.iconRes) - contentDescription = btn.description - tooltipText = btn.description - strokeWidth = 0 - iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START - iconTint = ColorStateList.valueOf(acColor) - val bgColorState = ColorStateList( - arrayOf( - intArrayOf(android.R.attr.state_checked), - intArrayOf(-android.R.attr.state_checked) - ), - intArrayOf( - bgcColor, - bgColor - ) - ) - backgroundTintList = bgColorState - maxLines = 1 - if (btn.onCheck != null) { - isCheckable = true - isChecked = btn.state - setOnClickListener { - isChecked = !isChecked - btn.state = isChecked - btn.onCheck.invoke() - } - } else { - setOnClickListener { btn.onClick() } - } - btn.onLongClick?.let { longAction -> - setOnLongClickListener { - longAction() - true - } - } - } - group.addView( - button, - OverflowLinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, 1f) - ) - val lp = - button.layoutParams as MaterialButtonGroup.LayoutParams - lp.overflowText = btn.description - buttonMap[btn.id] = button - } - } - - private val badgeMap = mutableMapOf() - - @OptIn(ExperimentalBadgeUtils::class) - fun updateBadge(id: String, count: Int) { - val btn = buttonMap[id] ?: return - if (count != 0 && btn.isChecked) { - btn.addBadge(count) - } else { - badgeMap[btn]?.let { BadgeUtils.detachBadgeDrawable(it, btn) } - badgeMap.remove(btn) - } - } - - @OptIn(ExperimentalBadgeUtils::class) - private fun MaterialButton.addBadge(count: Int) { - val badgeDrawable = BadgeDrawable.create(context).apply { - number = count - backgroundColor = colorSecondary - badgeTextColor = colorSecondaryContainer - maxCharacterCount = 3 - badgeGravity = BadgeDrawable.TOP_END - verticalOffset = (16).dpToPx() - } - - BadgeUtils.attachBadgeDrawable(badgeDrawable, this, null) - badgeMap[this] = badgeDrawable - } - - - private fun getAllButtons(): List { - return listOf( - ToolButton( - id = "search", - iconRes = R.drawable.ic_search, - description = context.getString(R.string.search_content), - onClick = { runMenuOut { callBack.openSearchActivity(null) } } - ), - ToolButton( - id = "catalog", - iconRes = R.drawable.ic_toc, - description = context.getString(R.string.chapter_list), - onClick = { runMenuOut { callBack.openChapterList() } } - ), - ToolButton( - id = "read_aloud", - iconRes = R.drawable.ic_read_aloud, - description = context.getString(R.string.read_aloud), - onClick = { runMenuOut { callBack.onClickReadAloud() } }, - onLongClick = { runMenuOut { callBack.onClickReadAloud() } } - ), - ToolButton( - id = "setting", - iconRes = R.drawable.ic_settings, - description = context.getString(R.string.setting), - onClick = { runMenuOut { callBack.showReadStyle() } } - ), - ToolButton( - id = "addBookmark", - iconRes = R.drawable.ic_bookmark, - description = context.getString(R.string.bookmark), - onClick = { runMenuOut { callBack.addBookmark() } } - ), - ToolButton( - id = "theme", - iconRes = if (AppConfig.isNightTheme) R.drawable.ic_daytime else R.drawable.ic_brightness, - description = context.getString(R.string.day_night_switch), - onClick = { - AppConfig.isNightTheme = !AppConfig.isNightTheme - OldThemeConfig.applyDayNight(context) - buttonMap["theme"]?.setIconResource( - if (AppConfig.isNightTheme) R.drawable.ic_daytime else R.drawable.ic_brightness - ) - } - ), - ToolButton( - id = "prev_chapter", - iconRes = R.drawable.ic_previous, - description = context.getString(R.string.previous_chapter), - onClick = { ReadBook.moveToPrevChapter(upContent = true, toLast = false) } - ), - ToolButton( - id = "next_chapter", - iconRes = R.drawable.ic_next, - description = context.getString(R.string.next_chapter), - onClick = { ReadBook.moveToNextChapter(true) } - ), - ToolButton( - id = "replace", - iconRes = R.drawable.ic_find_replace, - description = context.getString(R.string.replace_purify), - onLongClick = { runMenuOut { callBack.openReplaceRule() } }, - onCheck = { runMenuOut { callBack.changeReplaceRuleState() } }, - onClick = { } - ), - ToolButton( - id = "replace_badge", - iconRes = R.drawable.ic_find_replace, - description = context.getString(R.string.replace_purify_badge), - onLongClick = { runMenuOut { callBack.openReplaceRule() } }, - onCheck = { runMenuOut { callBack.changeReplaceRuleState() } }, - onClick = { } - ), - ToolButton( - id = "auto_page", - iconRes = R.drawable.ic_auto_page, - description = context.getString(R.string.auto_next_page), - onClick = { runMenuOut { callBack.autoPage() } } - ), - ToolButton( - id = "translate", - iconRes = R.drawable.ic_translate, - description = context.getString(R.string.translate), - onClick = { runMenuOut { callBack.onTranslationClick() } }, - onLongClick = { runMenuOut { callBack.onTranslationLongClick() } } - ) - ) - } - - fun changeReplace(boolean: Boolean) { - buttonMap["replace"]?.isChecked = boolean - buttonMap["replace_badge"]?.isChecked = boolean - } - - private fun getUserButtons(): List { - val prefs by lazy { - context.getSharedPreferences("tool_button_config", Context.MODE_PRIVATE) - } - val allButtons = getAllButtons().associateBy { it.id } - - val str = prefs.getString("tool_buttons", null) - val savedList = str?.split(";")?.mapNotNull { - val parts = it.split(",") - if (parts.size == 2) parts[0] to parts[1].toBoolean() else null - } ?: emptyList() - - val result = mutableListOf() - - if (savedList.isNotEmpty()) { - savedList.forEach { (id, enabled) -> - if (enabled) allButtons[id]?.let { result.add(it) } - } - - getAllButtons().forEach { btn -> - if (savedList.none { it.first == btn.id }) { - result.add(btn) - } - } - } else { - result.addAll(getAllButtons().take(5)) - } - - - return result - } - - fun setAutoPage(autoPage: Boolean) { - buttonMap["auto_page"]?.apply { - val icon = if (autoPage) R.drawable.ic_auto_page_stop else R.drawable.ic_auto_page - val desc = - context.getString(if (autoPage) R.string.auto_next_page_stop else R.string.auto_next_page) - setIconResource(icon) - contentDescription = desc - tooltipText = desc - } - } - - fun updateTranslationButton(translationMode: Boolean) { - val btn = buttonMap["translate"] ?: return - - // Update icon based on mode - if (translationMode) { - btn.setIconResource(R.drawable.ic_return) - } else { - btn.setIconResource(R.drawable.ic_translate) - } - } - - private fun initAnimation() { - menuTopIn.setAnimationListener(menuInListener) - menuTopOut.setAnimationListener(menuOutListener) - } - - fun upBookView() { - val bookName = ReadBook.book?.name ?: "" - - val mode = AppConfig.titleBarMode?.toInt() - - when (mode) { - 0 -> { // 在应用栏上显示 - binding.titleBar.title = bookName - binding.llBook.visible() - binding.tvBookName.gone() - } - 1 -> { // 在独立行上显示 - binding.titleBar.title = " " - binding.tvBookName.text = bookName - binding.llBook.visible() - binding.tvBookName.visible() - } - 2 -> { // 仅显示标题 - binding.titleBar.title = bookName - binding.llBook.gone() - } - 3 -> { // 不显示 - binding.titleBar.title = " " - binding.llBook.gone() - } - else -> { - binding.titleBar.title = " " - binding.tvBookName.text = bookName - binding.llBook.visible() - } - } - - ReadBook.curTextChapter?.let { - binding.tvChapterName.text = it.title - if (!ReadBook.isLocalBook) { - binding.tvChapterUrl.text = it.chapter.getAbsoluteURL() - //binding.tvChapterUrl.visible() - } else { - binding.tvChapterUrl.gone() - } - - upSeekBar() - binding.tvPre.isEnabled = ReadBook.durChapterIndex != 0 - binding.tvNext.isEnabled = ReadBook.durChapterIndex != ReadBook.simulatedChapterSize - 1 - } ?: run { - binding.tvChapterUrl.gone() - } - } - - - fun upSeekBar() = binding.seekReadPage.apply { - - fun safeSet(rangeFrom: Float, rangeTo: Float, step: Float, rawValue: Float) { - valueFrom = rangeFrom - valueTo = rangeTo - stepSize = step - val safeValue = rawValue.coerceIn(rangeFrom, rangeTo) - if (value != safeValue) value = safeValue - } - - when (AppConfig.progressBarBehavior) { - "page" -> { - ReadBook.curTextChapter?.let { chapter -> - if (chapter.pageSize > 0 && ReadBook.durPageIndex >= 0) { - safeSet( - rangeFrom = 1f, - rangeTo = chapter.pageSize.toFloat().coerceAtLeast(2f), - step = 1f, - rawValue = ReadBook.durPageIndex.toFloat() - ) - } else { - safeSet(0f, 100000f, 0f, 0f) - } - } - } - - "chapter" -> { - if (ReadBook.simulatedChapterSize > 0) { - safeSet( - rangeFrom = 1f, - rangeTo = ReadBook.simulatedChapterSize.toFloat().coerceAtLeast(2f), - step = 1f, - rawValue = ReadBook.durChapterIndex.toFloat() - ) - } else { - safeSet(0f, 100000f, 0f, 0f) - } - } - } - } - - -// fun upSeekBar() { -// binding.seekReadPage.apply { -// when (AppConfig.progressBarBehavior) { -// "page" -> { -// ReadBook.curTextChapter?.let { -// max = it.pageSize.minus(1) -// progress = ReadBook.durPageIndex -// } -// } -// -// "chapter" -> { -// max = ReadBook.simulatedChapterSize - 1 -// progress = ReadBook.durChapterIndex -// } -// } -// } -// } - - fun setSeekPage(seek: Int) { - binding.seekReadPage.value = seek.toFloat() + 1 - } - - private fun upBrightnessVwPos() { - if (AppConfig.brightnessVwPos) { - binding.root.modifyBegin() - .clear(R.id.ll_brightness, ConstraintModify.Anchor.LEFT) - .rightToRightOf(R.id.ll_brightness, R.id.vw_menu_root) - .commit() - } else { - binding.root.modifyBegin() - .clear(R.id.ll_brightness, ConstraintModify.Anchor.RIGHT) - .leftToLeftOf(R.id.ll_brightness, R.id.vw_menu_root) - .commit() - } - } - - interface CallBack { - fun autoPage() - fun openReplaceRule() - fun openChapterList() - fun openSearchActivity(searchWord: String?) - fun openSourceEditActivity() - fun openBookInfoActivity() - fun showReadStyle() - fun addBookmark() - fun showReadAloudDialog() - fun upSystemUiVisibility() - fun onClickReadAloud() - fun showHelp() - fun showLogin() - fun payAction() - fun disableSource() - fun skipToChapter(index: Int) - fun onMenuShow() - fun onMenuHide() - fun changeReplaceRuleState() - fun onTranslationClick() - fun onTranslationLongClick() - } - - data class ToolButton( - val id: String, // 唯一标识 - val iconRes: Int, // 图标资源 - val description: String, // contentDescription / tooltipText - val onClick: () -> Unit, // 点击事件 - val onLongClick: (() -> Unit)? = null, // 可选长按 - val onCheck: (() -> Unit)? = null,// 可选 - var state: Boolean = false // 动态 - ) -} diff --git a/app/src/main/java/io/legado/app/ui/book/read/SearchMenu.kt b/app/src/main/java/io/legado/app/ui/book/read/SearchMenu.kt deleted file mode 100644 index 5978e8cff..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/SearchMenu.kt +++ /dev/null @@ -1,208 +0,0 @@ -package io.legado.app.ui.book.read - -import android.annotation.SuppressLint -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.animation.Animation -import android.widget.FrameLayout -import androidx.core.view.isVisible -import io.legado.app.R -import io.legado.app.databinding.ViewSearchMenuBinding -//import io.legado.app.lib.theme.bottomBackground -//import io.legado.app.lib.theme.getPrimaryTextColor -import io.legado.app.model.ReadBook -import io.legado.app.ui.book.searchContent.SearchResult -import io.legado.app.utils.activity -import io.legado.app.utils.applyNavigationBarPadding -import io.legado.app.utils.invisible -import io.legado.app.utils.loadAnimation -import io.legado.app.utils.visible - -/** - * 搜索界面菜单 - */ -class SearchMenu @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null -) : FrameLayout(context, attrs) { - - private val callBack: CallBack get() = activity as CallBack - private val binding = ViewSearchMenuBinding.inflate(LayoutInflater.from(context), this, true) - - private val menuBottomIn: Animation = loadAnimation(context, R.anim.anim_readbook_bottom_in) - private val menuBottomOut: Animation = loadAnimation(context, R.anim.anim_readbook_bottom_out) - - private var onMenuOutEnd: (() -> Unit)? = null - private var isMenuOutAnimating = false - - private val searchResultList: MutableList = mutableListOf() - private var currentSearchResultIndex: Int = -1 - private var lastSearchResultIndex: Int = -1 - private val hasSearchResult: Boolean - get() = searchResultList.isNotEmpty() - val selectedSearchResult: SearchResult? - get() = searchResultList.getOrNull(currentSearchResultIndex) - val previousSearchResult: SearchResult? - get() = searchResultList.getOrNull(lastSearchResultIndex) - val bottomMenuVisible get() = isVisible && binding.llBottomMenu.isVisible - - init { - initAnimation() - initView() - bindEvent() - updateSearchInfo() - } - - fun upSearchResultList(resultList: List) { - searchResultList.clear() - searchResultList.addAll(resultList) - updateSearchInfo() - } - - private fun initView() = binding.run { - applyNavigationBarPadding() - } - - - fun runMenuIn() { - this.visible() - binding.llBottomMenu.visible() - binding.vwMenuBg.visible() - binding.llBottomMenu.startAnimation(menuBottomIn) - } - - fun runMenuOut(onMenuOutEnd: (() -> Unit)? = null) { - if (isMenuOutAnimating) { - return - } - this.onMenuOutEnd = onMenuOutEnd - if (this.isVisible) { - binding.llBottomMenu.startAnimation(menuBottomOut) - } - } - - @SuppressLint("SetTextI18n") - fun updateSearchInfo() { - ReadBook.curTextChapter?.let { - binding.tvCurrentChapter.text = "当前章节: ${it.title}" - } - updateSearchProgress() - } - - fun updateSearchResultIndex(updateIndex: Int) { - lastSearchResultIndex = currentSearchResultIndex - currentSearchResultIndex = when { - updateIndex < 0 -> 0 - updateIndex >= searchResultList.size -> searchResultList.size - 1 - else -> updateIndex - } - updateSearchProgress() - } - - private fun updateSearchProgress() { - val total = searchResultList.size - if (total == 0) { - binding.tvSearchProgress.text = "0%" - binding.tvSearchFraction.text = "0/0" - } else { - val current = currentSearchResultIndex + 1 - val progress = (current * 100 / total) - binding.tvSearchProgress.text = "$progress%" - binding.tvSearchFraction.text = "$current / $total" - } - } - - private fun bindEvent() = binding.run { - //搜索结果 - ivSearchResults.setOnClickListener { - runMenuOut { - callBack.openSearchActivity(selectedSearchResult?.query) - } - } - - //主菜单 - ivMainMenu.setOnClickListener { - runMenuOut { - callBack.cancelSelect() - callBack.showMenuBar() - this@SearchMenu.invisible() - } - } - - //退出 - ivSearchExit.setOnClickListener { - runMenuOut { - callBack.exitSearchMenu() - } - } - - fabLeft.setOnClickListener { - updateSearchResultIndex(currentSearchResultIndex - 1) - callBack.navigateToSearch( - searchResultList[currentSearchResultIndex], - currentSearchResultIndex - ) - } - - fabRight.setOnClickListener { - updateSearchResultIndex(currentSearchResultIndex + 1) - callBack.navigateToSearch( - searchResultList[currentSearchResultIndex], - currentSearchResultIndex - ) - } - } - - private fun initAnimation() { - //显示菜单 - menuBottomIn.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation) { - callBack.upSystemUiVisibility() - binding.fabLeft.visible(hasSearchResult) - binding.fabRight.visible(hasSearchResult) - } - - @SuppressLint("RtlHardcoded") - override fun onAnimationEnd(animation: Animation) { - binding.vwMenuBg.setOnClickListener { runMenuOut() } - callBack.upSystemUiVisibility() - } - - override fun onAnimationRepeat(animation: Animation) = Unit - }) - - //隐藏菜单 - menuBottomOut.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation) { - isMenuOutAnimating = true - binding.vwMenuBg.setOnClickListener(null) - } - - override fun onAnimationEnd(animation: Animation) { - isMenuOutAnimating = false - binding.llBottomMenu.invisible() - binding.vwMenuBg.invisible() - binding.vwMenuBg.setOnClickListener { runMenuOut() } - - onMenuOutEnd?.invoke() - callBack.upSystemUiVisibility() - } - - override fun onAnimationRepeat(animation: Animation) = Unit - }) - } - - interface CallBack { - var isShowingSearchResult: Boolean - fun openSearchActivity(searchWord: String?) - fun showSearchSetting() - fun upSystemUiVisibility() - fun exitSearchMenu() - fun showMenuBar() - fun navigateToSearch(searchResult: SearchResult, index: Int) - fun onMenuShow() - fun onMenuHide() - fun cancelSelect() - } - -} diff --git a/app/src/main/java/io/legado/app/ui/book/read/TextActionMenu.kt b/app/src/main/java/io/legado/app/ui/book/read/TextActionMenu.kt index 450956dc8..2bb14deff 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/TextActionMenu.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/TextActionMenu.kt @@ -18,11 +18,9 @@ import androidx.core.view.isVisible import io.legado.app.R import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.RecyclerAdapter -import io.legado.app.constant.PreferKey import io.legado.app.databinding.ItemTextBinding import io.legado.app.databinding.PopupActionMenuBinding import io.legado.app.help.config.AppConfig -import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.gone import io.legado.app.utils.isAbsUrl import io.legado.app.utils.printOnDebug @@ -34,7 +32,11 @@ import androidx.core.net.toUri import io.legado.app.constant.AppLog @SuppressLint("RestrictedApi") -class TextActionMenu(private val context: Context, private val callBack: CallBack) : +class TextActionMenu( + private val context: Context, + private val callBack: CallBack, + private val expandTextMenu: () -> Boolean +) : PopupWindow(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) { private val binding = PopupActionMenuBinding.inflate(LayoutInflater.from(context)) @@ -45,7 +47,6 @@ class TextActionMenu(private val context: Context, private val callBack: CallBac private val menuItems: List private val visibleMenuItems = arrayListOf() private val moreMenuItems = arrayListOf() - private val expandTextMenu get() = context.getPrefBoolean(PreferKey.expandTextMenu) init { @SuppressLint("InflateParams") @@ -66,7 +67,7 @@ class TextActionMenu(private val context: Context, private val callBack: CallBac binding.recyclerView.adapter = adapter binding.recyclerViewMore.adapter = adapter setOnDismissListener { - if (!context.getPrefBoolean(PreferKey.expandTextMenu)) { + if (!expandTextMenu()) { binding.ivMenuMore.setImageResource(R.drawable.ic_more_vert) binding.recyclerViewMore.gone() adapter.setItems(visibleMenuItems) @@ -90,7 +91,7 @@ class TextActionMenu(private val context: Context, private val callBack: CallBac } fun upMenu() { - if (expandTextMenu) { + if (expandTextMenu()) { adapter.setItems(menuItems) binding.ivMenuMore.gone() } else { @@ -108,7 +109,7 @@ class TextActionMenu(private val context: Context, private val callBack: CallBac endX: Int, endBottomY: Int ) { - if (expandTextMenu) { + if (expandTextMenu()) { when { startTopY > 500 -> { showAtLocation( @@ -272,4 +273,4 @@ class TextActionMenu(private val context: Context, private val callBack: CallBac fun onMenuActionFinally() } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/AutoReadDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/AutoReadDialog.kt deleted file mode 100644 index f1c5c8bfc..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/AutoReadDialog.kt +++ /dev/null @@ -1,100 +0,0 @@ -package io.legado.app.ui.book.read.config - -import android.content.DialogInterface -import android.os.Bundle -import android.view.View -import com.google.android.material.slider.Slider -import io.legado.app.R -import io.legado.app.base.BaseBottomSheetDialogFragment -import io.legado.app.databinding.DialogAutoReadBinding -import io.legado.app.help.config.ReadBookConfig -//import io.legado.app.lib.theme.bottomBackground -//import io.legado.app.lib.theme.getPrimaryTextColor -import io.legado.app.model.ReadAloud -import io.legado.app.model.ReadBook -import io.legado.app.service.BaseReadAloudService -import io.legado.app.ui.book.read.BaseReadBookActivity -import io.legado.app.ui.book.read.ReadBookActivity -import io.legado.app.utils.viewbindingdelegate.viewBinding -import java.util.Locale - - -class AutoReadDialog : BaseBottomSheetDialogFragment(R.layout.dialog_auto_read) { - - private val binding by viewBinding(DialogAutoReadBinding::bind) - private val callBack: CallBack? get() = activity as? CallBack - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - (activity as ReadBookActivity).bottomDialog-- - } - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) = binding.run { - val bottomDialog = (activity as ReadBookActivity).bottomDialog++ - if (bottomDialog > 0) { - dismiss() - return@run - } - initOnChange() - initData() - initEvent() - } - - private fun initData() { - val speed = if (ReadBookConfig.autoReadSpeed < 1) 1 else ReadBookConfig.autoReadSpeed - binding.tvReadSpeed.text = String.format(Locale.ROOT, "%ds", speed) - binding.seekAutoRead.value = speed.toFloat() - } - - private fun initOnChange() { - binding.seekAutoRead.addOnChangeListener { slider, value, fromUser -> - val speed = if (value < 1) 1 else value.toInt() - binding.tvReadSpeed.text = String.format(Locale.ROOT, "%ds", speed) - } - - binding.seekAutoRead.addOnSliderTouchListener(object : Slider.OnSliderTouchListener { - override fun onStartTrackingTouch(slider: Slider) { - - } - - override fun onStopTrackingTouch(slider: Slider) { - ReadBookConfig.autoReadSpeed = if (slider.value < 1) 1 else slider.value.toInt() - upTtsSpeechRate() - } - }) - } - - private fun initEvent() { - binding.btnMainMenu.setOnClickListener { - callBack?.showMenuBar() - dismissAllowingStateLoss() - } - binding.btnSetting.setOnClickListener { - (activity as BaseReadBookActivity).showPageAnimConfig { - (activity as ReadBookActivity).upPageAnim() - ReadBook.loadContent(false) - } - } - binding.btnCatalog.setOnClickListener { callBack?.openChapterList() } - binding.btnAutoPageStop.setOnClickListener { - callBack?.autoPageStop() - binding.btnAutoPageStop.post { - dismissAllowingStateLoss() - } - } - } - - private fun upTtsSpeechRate() { - ReadAloud.upTtsSpeechRate(requireContext()) - if (!BaseReadAloudService.pause) { - ReadAloud.pause(requireContext()) - ReadAloud.resume(requireContext()) - } - } - - interface CallBack { - fun showMenuBar() - fun openChapterList() - fun autoPageStop() - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/BgAdapter.kt b/app/src/main/java/io/legado/app/ui/book/read/config/BgAdapter.kt deleted file mode 100644 index ff541218e..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/BgAdapter.kt +++ /dev/null @@ -1,48 +0,0 @@ -package io.legado.app.ui.book.read.config - -import android.content.Context -import android.view.ViewGroup -import io.legado.app.base.adapter.ItemViewHolder -import io.legado.app.base.adapter.RecyclerAdapter -import io.legado.app.constant.EventBus -import io.legado.app.databinding.ItemBgImageBinding -import io.legado.app.help.config.ReadBookConfig -import io.legado.app.help.glide.ImageLoader -import io.legado.app.utils.postEvent -import java.io.File - -class BgAdapter(context: Context, val textColor: Int) : - RecyclerAdapter(context) { - - override fun getViewBinding(parent: ViewGroup): ItemBgImageBinding { - return ItemBgImageBinding.inflate(inflater, parent, false) - } - - override fun convert( - holder: ItemViewHolder, - binding: ItemBgImageBinding, - item: String, - payloads: MutableList - ) { - binding.run { - tvName.text = item.substringBeforeLast(".") - ImageLoader.load( - context, - context.assets.open("bg${File.separator}$item").readBytes() - ) - .centerCrop() - .into(ivBg) - } - } - - override fun registerListener(holder: ItemViewHolder, binding: ItemBgImageBinding) { - holder.itemView.apply { - this.setOnClickListener { - getItemByLayoutPosition(holder.layoutPosition)?.let { - ReadBookConfig.durConfig.setCurBg(1, it) - postEvent(EventBus.UP_CONFIG, arrayListOf(1)) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/BgImageSpan.kt b/app/src/main/java/io/legado/app/ui/book/read/config/BgImageSpan.kt new file mode 100644 index 000000000..f4f5f9736 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/BgImageSpan.kt @@ -0,0 +1,161 @@ +package io.legado.app.ui.book.read.config + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Shader +import android.text.style.ReplacementSpan +import io.legado.app.ui.book.read.page.entities.TextLine +import io.legado.app.utils.dpToPx + +/** + * 背景图+下划线 Span,用于高亮规则匹配区域 + */ +class BgImageSpan( + private val textColor: Int, + private val bgImagePath: String, + private val bgImageFit: Int = 0, + private val bgImageScale: Float = 1f, + private val underlineMode: Int = 0, + private val underlineColor: Int = 0, + private val underlineWidth: Float = 1f, + private val underlineSvgPath: String = "", + private val underlineOffset: Float = 6f, +) : ReplacementSpan() { + + private val offsetPx = underlineOffset.toInt().dpToPx() + + override fun getSize( + paint: Paint, + text: CharSequence, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int { + if (fm != null) { + val metrics = paint.fontMetricsInt + fm.top = metrics.top + fm.ascent = metrics.ascent + fm.descent = metrics.descent + if (underlineMode != 0) offsetPx else 0 + fm.bottom = metrics.bottom + if (underlineMode != 0) offsetPx else 0 + } + return paint.measureText(text, start, end).toInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + val width = paint.measureText(text, start, end) + val rectWidth = width + val rectHeight = (bottom - top).toFloat() + val scale = bgImageScale.coerceIn(0.1f, 5f) + + val bitmap = TextLine.getBgBitmap(bgImagePath) + if (bitmap != null) { + val bgPaint = Paint().apply { + style = Paint.Style.FILL + isAntiAlias = true + isFilterBitmap = true + } + when (bgImageFit) { + 1 -> { + val sw = rectWidth * scale + val sh = rectHeight * scale + val dx = x + (rectWidth - sw) / 2f + val dy = top + (rectHeight - sh) / 2f + canvas.save() + canvas.clipRect(x, top.toFloat(), x + width, bottom.toFloat()) + canvas.drawBitmap(bitmap, null, RectF(dx, dy, dx + sw, dy + sh), bgPaint) + canvas.restore() + } + 2 -> { + val bw = bitmap.width.toFloat() + val bh = bitmap.height.toFloat() + val fitScale = (rectWidth / bw).coerceAtLeast(rectHeight / bh) * scale + val scaledW = bw * fitScale + val scaledH = bh * fitScale + val dx = x + (rectWidth - scaledW) / 2f + val dy = top + (rectHeight - scaledH) / 2f + canvas.save() + canvas.clipRect(x, top.toFloat(), x + width, bottom.toFloat()) + canvas.drawBitmap(bitmap, null, RectF(dx, dy, dx + scaledW, dy + scaledH), bgPaint) + canvas.restore() + } + else -> { + val shader = BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT) + val matrix = Matrix() + if (scale != 1f) { + matrix.setScale(scale, scale) + } + matrix.postTranslate(x, top.toFloat()) + shader.setLocalMatrix(matrix) + bgPaint.shader = shader + canvas.drawRect(x, top.toFloat(), x + width, bottom.toFloat(), bgPaint) + } + } + } + + paint.color = textColor + paint.shader = null + canvas.drawText(text, start, end, x, y.toFloat(), paint) + + if (underlineMode != 0) { + drawUnderline(canvas, x, x + width, y + offsetPx, paint) + } + } + + private fun drawUnderline(canvas: Canvas, startX: Float, endX: Float, lineY: Int, paint: Paint) { + val ulPaint = Paint(paint).apply { + color = underlineColor + style = Paint.Style.STROKE + strokeWidth = underlineWidth.dpToPx() + isAntiAlias = true + } + when (underlineMode) { + 1 -> canvas.drawLine(startX, lineY.toFloat(), endX, lineY.toFloat(), ulPaint) + 2 -> { + ulPaint.pathEffect = android.graphics.DashPathEffect(floatArrayOf(10f, 10f), 0f) + canvas.drawLine(startX, lineY.toFloat(), endX, lineY.toFloat(), ulPaint) + } + 3 -> { + val path = android.graphics.Path() + val waveAmplitude = 3.dpToPx().toFloat() + val waveLength = 12.dpToPx().toFloat() + path.moveTo(startX, lineY.toFloat()) + var currentX = startX + val endY = lineY.toFloat() + while (currentX < endX) { + val nextX = (currentX + waveLength).coerceAtMost(endX) + val midX = (currentX + nextX) / 2 + path.quadTo(midX, endY - waveAmplitude, nextX, endY) + currentX = nextX + if (currentX < endX) { + val nextX2 = (currentX + waveLength).coerceAtMost(endX) + val midX2 = (currentX + nextX2) / 2 + path.quadTo(midX2, endY + waveAmplitude, nextX2, endY) + currentX = nextX2 + } + } + canvas.drawPath(path, ulPaint) + } + 4 -> { + val lineGap = 3.dpToPx() + val line2Y = lineY + lineGap + underlineWidth.dpToPx() + canvas.drawLine(startX, lineY.toFloat(), endX, lineY.toFloat(), ulPaint) + canvas.drawLine(startX, line2Y.toFloat(), endX, line2Y.toFloat(), ulPaint) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/BgTextConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/BgTextConfigDialog.kt deleted file mode 100644 index 68b8b5cac..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/BgTextConfigDialog.kt +++ /dev/null @@ -1,368 +0,0 @@ -package io.legado.app.ui.book.read.config - -import android.annotation.SuppressLint -import android.content.DialogInterface -import android.net.Uri -import android.os.Bundle -import android.view.View -import androidx.appcompat.widget.TooltipCompat -import androidx.core.graphics.toColorInt -import androidx.documentfile.provider.DocumentFile -import com.google.android.material.slider.Slider -import com.jaredrummler.android.colorpicker.ColorPickerDialog -import io.legado.app.R -import io.legado.app.base.BaseBottomSheetDialogFragment -import io.legado.app.constant.AppLog -import io.legado.app.constant.EventBus -import io.legado.app.databinding.DialogEditTextBinding -import io.legado.app.databinding.DialogReadBgTextBinding -import io.legado.app.databinding.ItemBgImageBinding -import io.legado.app.help.DefaultData -import io.legado.app.help.config.ReadBookConfig -import io.legado.app.help.http.newCallResponseBody -import io.legado.app.help.http.okHttpClient -import io.legado.app.lib.dialogs.SelectItem -import io.legado.app.lib.dialogs.alert -import io.legado.app.lib.dialogs.selector -import io.legado.app.ui.book.read.ReadBookActivity -import io.legado.app.ui.file.HandleFileContract -import io.legado.app.utils.FileDoc -import io.legado.app.utils.FileUtils -import io.legado.app.utils.GSON -import io.legado.app.utils.MD5Utils -import io.legado.app.utils.SelectImageContract -import io.legado.app.utils.compress.ZipUtils -import io.legado.app.utils.createFileIfNotExist -import io.legado.app.utils.createFileReplace -import io.legado.app.utils.createFolderReplace -import io.legado.app.utils.delete -import io.legado.app.utils.externalCache -import io.legado.app.utils.externalFiles -import io.legado.app.utils.find -import io.legado.app.utils.getFile -import io.legado.app.utils.inputStream -import io.legado.app.utils.isContentScheme -import io.legado.app.utils.launch -import io.legado.app.utils.longToast -import io.legado.app.utils.openInputStream -import io.legado.app.utils.openOutputStream -import io.legado.app.utils.outputStream -import io.legado.app.utils.parseToUri -import io.legado.app.utils.postEvent -import io.legado.app.utils.printOnDebug -import io.legado.app.utils.readBytes -import io.legado.app.utils.readUri -import io.legado.app.utils.stackTraceStr -import io.legado.app.utils.toastOnUi -import io.legado.app.utils.viewbindingdelegate.viewBinding -import splitties.init.appCtx -import java.io.File -import java.io.FileOutputStream - -class BgTextConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_bg_text) { - - companion object { - const val BG_COLOR = 122 - } - - private val binding by viewBinding(DialogReadBgTextBinding::bind) - private val configFileName = "readConfig.zip" - private val adapter by lazy { BgAdapter(requireContext(), secondaryTextColor) } - private var secondaryTextColor = 0 - private val importFormNet = "网络导入" - private val selectBgImage = registerForActivityResult(SelectImageContract()) { - it.uri?.let { uri -> - setBgFromUri(uri) - } - } - private val selectExportDir = registerForActivityResult(HandleFileContract()) { - it.uri?.let { uri -> - exportConfig(uri) - } - } - private val selectImportDoc = registerForActivityResult(HandleFileContract()) { - it.uri?.let { uri -> - if (uri.toString() == importFormNet) { - importNetConfigAlert() - } else { - importConfig(uri) - } - } - } - - override fun onStart() { - super.onStart() - dialog?.window?.run { - - } - } - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - (activity as ReadBookActivity).bottomDialog++ - initView() - initData() - initEvent() - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - ReadBookConfig.save() - (activity as ReadBookActivity).bottomDialog-- - } - - private fun initView() = binding.run { - recyclerView.adapter = adapter - adapter.addHeaderView { - ItemBgImageBinding.inflate(layoutInflater, it, false).apply { - tvName.text = getString(R.string.select_image) - ivBg.setImageResource(R.drawable.ic_add) - root.setOnClickListener { - selectBgImage.launch() - } - } - } - requireContext().assets.list("bg")?.let { - adapter.setItems(it.toList()) - } - } - - @SuppressLint("InflateParams") - private fun initData() = with(ReadBookConfig.durConfig) { - binding.tvName.text = name.ifBlank { "文字" } - binding.swDarkStatusIcon.isChecked = curStatusIconDark() - binding.sbBgAlpha.value = ReadBookConfig.bgAlpha.toFloat() - binding.dottedRatio.valueFormat = { - (ReadBookConfig.dottedRatio * 100).toInt().toString() - } - binding.dottedBase.valueFormat = { - (ReadBookConfig.dottedBase * 100).toInt().toString() - } - } - - @SuppressLint("InflateParams") - private fun initEvent() = with(ReadBookConfig.durConfig) { - binding.ivEdit.setOnClickListener { - alert(R.string.style_name) { - val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { - editView.hint = "name" - editView.setText(ReadBookConfig.durConfig.name) - } - customView { alertBinding.root } - okButton { - alertBinding.editView.text?.toString()?.let { - binding.tvName.text = it - ReadBookConfig.durConfig.name = it - } - } - cancelButton() - } - } - binding.tvRestore.setOnClickListener { - val defaultConfigs = DefaultData.readConfigs - val layoutNames = defaultConfigs.map { it.name } - context?.selector("选择预设布局", layoutNames) { _, i -> - if (i >= 0) { - ReadBookConfig.durConfig = defaultConfigs[i].copy() - initData() - postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5)) - } - } - } - binding.swDarkStatusIcon.setOnCheckedChangeListener { _, isChecked -> - setCurStatusIconDark(isChecked) - (activity as? ReadBookActivity)?.upSystemUiVisibility() - } - binding.tvBgColor.setOnClickListener { - val bgColor = - if (curBgType() == 0) curBgStr().toColorInt() - else "#015A86".toColorInt() - ColorPickerDialog.newBuilder() - .setColor(bgColor) - .setShowAlphaSlider(false) - .setDialogType(ColorPickerDialog.TYPE_CUSTOM) - .setDialogId(BG_COLOR) - .show(requireActivity()) - } - binding.tvBgColor.apply { - TooltipCompat.setTooltipText(this, text) - } - binding.ivImport.setOnClickListener { - selectImportDoc.launch { - mode = HandleFileContract.FILE - title = getString(R.string.import_str) - allowExtensions = arrayOf("zip") - otherActions = arrayListOf(SelectItem(importFormNet, -1)) - } - } - binding.ivExport.setOnClickListener { - selectExportDir.launch { - title = getString(R.string.export_str) - } - } - binding.ivDelete.setOnClickListener { - if (ReadBookConfig.deleteDur()) { - postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5)) - dismissAllowingStateLoss() - } else { - toastOnUi("数量已是最少,不能删除.") - } - } - binding.sbBgAlpha.addOnChangeListener { slider, value, fromUser -> - ReadBookConfig.bgAlpha = value.toInt() - postEvent(EventBus.UP_CONFIG, arrayListOf(3)) - } - - binding.sbBgAlpha.addOnSliderTouchListener(object : Slider.OnSliderTouchListener { - override fun onStartTrackingTouch(slider: Slider) { - // 可留空 - } - - override fun onStopTrackingTouch(slider: Slider) { - postEvent(EventBus.UP_CONFIG, arrayListOf(3)) - } - }) - binding.dottedRatio.onChanged = { - ReadBookConfig.dottedRatio = it / 100f - postEvent(EventBus.UP_CONFIG, arrayListOf(6, 9, 11)) - } - binding.dottedBase.onChanged = { - ReadBookConfig.dottedBase = it / 100f - postEvent(EventBus.UP_CONFIG, arrayListOf(6, 9, 11)) - } - } - - private fun exportConfig(uri: Uri) { - val exportFileName = if (ReadBookConfig.config.name.isBlank()) { - configFileName - } else { - "${ReadBookConfig.config.name}.zip" - } - execute { - val exportFiles = arrayListOf() - val configDir = requireContext().externalCache.getFile("readConfig") - configDir.createFolderReplace() - val configFile = configDir.getFile("readConfig.json") - configFile.createFileReplace() - val config = ReadBookConfig.getExportConfig() - val fontPath = ReadBookConfig.textFont - if (fontPath.isNotEmpty()) { - val fontDoc = FileDoc.fromFile(fontPath) - val fontName = fontDoc.name - val fontInputStream = fontDoc.openInputStream().getOrNull() - fontInputStream?.use { - val fontExportFile = FileUtils.createFileIfNotExist(configDir, fontName) - fontExportFile.outputStream().use { out -> - it.copyTo(out) - } - config.textFont = fontName - exportFiles.add(fontExportFile) - } - } - configFile.writeText(GSON.toJson(config)) - exportFiles.add(configFile) - repeat(3) { - val path = ReadBookConfig.durConfig.getBgPath(it) ?: return@repeat - val bgExportFile = copyBgImage(path, configDir) ?: return@repeat - exportFiles.add(bgExportFile) - } - val configZipPath = FileUtils.getPath(requireContext().externalCache, configFileName) - if (ZipUtils.zipFiles(exportFiles, File(configZipPath))) { - val exportDir = FileDoc.fromDir(uri) - exportDir.find(exportFileName)?.delete() - val exportFileDoc = exportDir.createFileIfNotExist(exportFileName) - exportFileDoc.openOutputStream().getOrThrow().use { out -> - File(configZipPath).inputStream().use { - it.copyTo(out) - } - } - } - }.onSuccess { - toastOnUi("导出成功, 文件名为 $exportFileName") - }.onError { - it.printOnDebug() - AppLog.put("导出失败:${it.localizedMessage}", it) - longToast("导出失败:${it.localizedMessage}") - } - } - - private fun copyBgImage(path: String, configDir: File): File? { - val bgName = FileUtils.getName(path) - val bgFile = File(path) - if (bgFile.exists()) { - val bgExportFile = File(FileUtils.getPath(configDir, bgName)) - if (!bgExportFile.exists()) { - bgFile.copyTo(bgExportFile) - return bgExportFile - } - } - return null - } - - @SuppressLint("InflateParams") - private fun importNetConfigAlert() { - alert("输入地址") { - val alertBinding = DialogEditTextBinding.inflate(layoutInflater) - customView { alertBinding.root } - okButton { - alertBinding.editView.text?.toString()?.let { url -> - importNetConfig(url) - } - } - cancelButton() - } - } - - private fun importNetConfig(url: String) { - execute { - okHttpClient.newCallResponseBody { - url(url) - }.bytes().let { - importConfig(it) - } - }.onError { - longToast(it.stackTraceStr) - } - } - - private fun importConfig(uri: Uri) { - execute { - importConfig(uri.readBytes(requireContext())) - }.onError { - it.printOnDebug() - longToast("导入失败:${it.localizedMessage}") - } - } - - private fun importConfig(byteArray: ByteArray) { - execute { - ReadBookConfig.import(byteArray) - }.onSuccess { - ReadBookConfig.durConfig = it - postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5)) - toastOnUi("导入成功") - }.onError { - it.printOnDebug() - longToast("导入失败:${it.localizedMessage}") - } - } - - private fun setBgFromUri(uri: Uri) { - readUri(uri) { fileDoc, inputStream -> - kotlin.runCatching { - var file = requireContext().externalFiles - val suffix = fileDoc.name.substringAfterLast(".") - val fileName = uri.inputStream(requireContext()).getOrThrow().use { - MD5Utils.md5Encode(it) + ".$suffix" - } - file = FileUtils.createFileIfNotExist(file, "bg", fileName) - FileOutputStream(file).use { outputStream -> - inputStream.copyTo(outputStream) - } - ReadBookConfig.durConfig.setCurBg(2, fileName) - postEvent(EventBus.UP_CONFIG, arrayListOf(1)) - }.onFailure { - appCtx.toastOnUi(it.localizedMessage) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/ClickActionConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/ClickActionConfigDialog.kt deleted file mode 100644 index 39c526c12..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/ClickActionConfigDialog.kt +++ /dev/null @@ -1,150 +0,0 @@ -package io.legado.app.ui.book.read.config - -import android.content.DialogInterface -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import io.legado.app.R -import io.legado.app.base.BaseOverlayDialogFragment -import io.legado.app.constant.PreferKey -import io.legado.app.databinding.DialogClickActionConfigBinding -import io.legado.app.help.config.AppConfig -import io.legado.app.lib.dialogs.selector -import io.legado.app.ui.book.read.ReadBookActivity -import io.legado.app.utils.getCompatColor -import io.legado.app.utils.putPrefInt -import io.legado.app.utils.viewbindingdelegate.viewBinding - -/** - * 点击区域设置 - */ -class ClickActionConfigDialog : BaseOverlayDialogFragment(R.layout.dialog_click_action_config) { - private val binding by viewBinding(DialogClickActionConfigBinding::bind) - private val actions by lazy { - linkedMapOf( - Pair(-1, getString(R.string.non_action)), - Pair(0, getString(R.string.menu)), - Pair(1, getString(R.string.next_page)), - Pair(2, getString(R.string.prev_page)), - Pair(3, getString(R.string.next_chapter)), - Pair(4, getString(R.string.previous_chapter)), - Pair(5, getString(R.string.read_aloud_prev_paragraph)), - Pair(6, getString(R.string.read_aloud_next_paragraph)), - Pair(7, getString(R.string.bookmark_add)), - Pair(8, getString(R.string.edit_content)), - Pair(9, getString(R.string.replace_state_change)), - Pair(10, getString(R.string.chapter_list)), - Pair(11, getString(R.string.search_content)), - Pair(12, getString(R.string.sync_book_progress_t)), - Pair(13, getString(R.string.read_aloud_pause_resume)) - ) - } - - override fun onStart() { - super.onStart() - dialog?.window?.run { - setBackgroundDrawableResource(R.color.transparent) - setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - } - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - (activity as ReadBookActivity).bottomDialog-- - } - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - (activity as ReadBookActivity).bottomDialog++ - view.setBackgroundColor(getCompatColor(R.color.translucent)) - initData() - initViewEvent() - } - - private fun initData() = binding.run { - tvTopLeft.text = actions[AppConfig.clickActionTL] - tvTopCenter.text = actions[AppConfig.clickActionTC] - tvTopRight.text = actions[AppConfig.clickActionTR] - tvMiddleLeft.text = actions[AppConfig.clickActionML] - tvMiddleCenter.text = actions[AppConfig.clickActionMC] - tvMiddleRight.text = actions[AppConfig.clickActionMR] - tvBottomLeft.text = actions[AppConfig.clickActionBL] - tvBottomCenter.text = actions[AppConfig.clickActionBC] - tvBottomRight.text = actions[AppConfig.clickActionBR] - } - - private fun initViewEvent() { - binding.ivClose.setOnClickListener { - dismissAllowingStateLoss() - } - binding.tvTopLeft.setOnClickListener { - selectAction { action -> - putPrefInt(PreferKey.clickActionTL, action) - (it as? TextView)?.text = actions[action] - } - } - binding.tvTopCenter.setOnClickListener { - selectAction { action -> - putPrefInt(PreferKey.clickActionTC, action) - (it as? TextView)?.text = actions[action] - } - } - binding.tvTopRight.setOnClickListener { - selectAction { action -> - putPrefInt(PreferKey.clickActionTR, action) - (it as? TextView)?.text = actions[action] - } - } - binding.tvMiddleLeft.setOnClickListener { - selectAction { action -> - putPrefInt(PreferKey.clickActionML, action) - (it as? TextView)?.text = actions[action] - } - } - binding.tvMiddleCenter.setOnClickListener { - selectAction { action -> - putPrefInt(PreferKey.clickActionMC, action) - (it as? TextView)?.text = actions[action] - } - } - binding.tvMiddleRight.setOnClickListener { - selectAction { action -> - putPrefInt(PreferKey.clickActionMR, action) - (it as? TextView)?.text = actions[action] - } - } - binding.tvBottomLeft.setOnClickListener { - selectAction { action -> - putPrefInt(PreferKey.clickActionBL, action) - (it as? TextView)?.text = actions[action] - } - } - binding.tvBottomCenter.setOnClickListener { - selectAction { action -> - putPrefInt(PreferKey.clickActionBC, action) - (it as? TextView)?.text = actions[action] - } - } - binding.tvBottomRight.setOnClickListener { - selectAction { action -> - putPrefInt(PreferKey.clickActionBR, action) - (it as? TextView)?.text = actions[action] - } - } - } - - private fun selectAction(success: (action: Int) -> Unit) { - context?.selector( - getString(R.string.select_action), - actions.values.toList() - ) { _, index -> - success.invoke(actions.keys.toList()[index]) - } - } - - override fun onDestroy() { - super.onDestroy() - AppConfig.detectClickArea() - } - -} diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/DashUnderlineSpan.kt b/app/src/main/java/io/legado/app/ui/book/read/config/DashUnderlineSpan.kt new file mode 100644 index 000000000..4cb9d3e87 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/DashUnderlineSpan.kt @@ -0,0 +1,64 @@ +package io.legado.app.ui.book.read.config + +import android.graphics.Canvas +import android.graphics.DashPathEffect +import android.graphics.Paint +import android.text.style.ReplacementSpan +import io.legado.app.utils.dpToPx + +/** + * 虚线下划线 Span + */ +class DashUnderlineSpan( + private val textColor: Int, + private val underlineColor: Int, + private val underlineWidth: Float = 1f, + private val underlineOffset: Float = 6f, +) : ReplacementSpan() { + + private val offsetPx = underlineOffset.toInt().dpToPx() + + override fun getSize( + paint: Paint, + text: CharSequence, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int { + if (fm != null) { + val metrics = paint.fontMetricsInt + fm.top = metrics.top + fm.ascent = metrics.ascent + fm.descent = metrics.descent + offsetPx + fm.bottom = metrics.bottom + offsetPx + } + return paint.measureText(text, start, end).toInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + val textStr = text.subSequence(start, end).toString() + paint.color = textColor + canvas.drawText(textStr, x, y.toFloat(), paint) + + val width = paint.measureText(text, start, end) + val lineY = y + offsetPx + val dashPaint = Paint(paint).apply { + color = underlineColor + style = Paint.Style.STROKE + strokeWidth = underlineWidth.dpToPx() + pathEffect = DashPathEffect(floatArrayOf(10f, 10f), 0f) + isAntiAlias = true + } + canvas.drawLine(x, lineY.toFloat(), x + width, lineY.toFloat(), dashPaint) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/DoubleUnderlineSpan.kt b/app/src/main/java/io/legado/app/ui/book/read/config/DoubleUnderlineSpan.kt new file mode 100644 index 000000000..c25013e7c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/DoubleUnderlineSpan.kt @@ -0,0 +1,66 @@ +package io.legado.app.ui.book.read.config + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.style.ReplacementSpan +import io.legado.app.utils.dpToPx + +/** + * 双线下划线 Span + */ +class DoubleUnderlineSpan( + private val textColor: Int, + private val underlineColor: Int, + private val underlineWidth: Float = 1f, + private val underlineOffset: Float = 6f, +) : ReplacementSpan() { + + private val offsetPx = underlineOffset.toInt().dpToPx() + private val lineGap = 3.dpToPx() + private val widthPx = underlineWidth.toInt().dpToPx() + + override fun getSize( + paint: Paint, + text: CharSequence, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int { + if (fm != null) { + val metrics = paint.fontMetricsInt + fm.top = metrics.top + fm.ascent = metrics.ascent + fm.descent = metrics.descent + offsetPx + lineGap + widthPx + fm.bottom = metrics.bottom + offsetPx + lineGap + widthPx + } + return paint.measureText(text, start, end).toInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + val textStr = text.subSequence(start, end).toString() + paint.color = textColor + canvas.drawText(textStr, x, y.toFloat(), paint) + + val width = paint.measureText(text, start, end) + val line1Y = y + offsetPx + val line2Y = line1Y + lineGap + widthPx + val linePaint = Paint(paint).apply { + color = underlineColor + style = Paint.Style.STROKE + strokeWidth = underlineWidth.dpToPx() + isAntiAlias = true + } + canvas.drawLine(x, line1Y.toFloat(), x + width, line1Y.toFloat(), linePaint) + canvas.drawLine(x, line2Y.toFloat(), x + width, line2Y.toFloat(), linePaint) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/FontConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/FontConfigDialog.kt deleted file mode 100644 index 30ff24826..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/FontConfigDialog.kt +++ /dev/null @@ -1,206 +0,0 @@ -package io.legado.app.ui.book.read.config - -import android.os.Bundle -import android.view.View -import com.jaredrummler.android.colorpicker.ColorPickerDialog -import io.legado.app.R -import io.legado.app.base.BaseBottomSheetDialogFragment -import io.legado.app.constant.EventBus -import io.legado.app.databinding.DialogFontConfigBinding -import io.legado.app.help.config.AppConfig -import io.legado.app.help.config.ReadBookConfig -import io.legado.app.help.config.ReadBookConfig.underline -import io.legado.app.lib.dialogs.alert -import io.legado.app.ui.book.read.ReadBookActivity -import io.legado.app.utils.observeEvent -import io.legado.app.utils.postEvent -import io.legado.app.utils.viewbindingdelegate.viewBinding - -/** - * 字体选择对话框 - */ -class FontConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_font_config) { - - companion object { - const val S_COLOR = 123 - const val TEXT_COLOR = 121 - const val TEXT_ACCENT_COLOR = 125 - } - private val binding by viewBinding(DialogFontConfigBinding::bind) - - private val callBack2 get() = activity as? ReadBookActivity - private val weightIconMap = mapOf( - 0 to R.drawable.ic_text_weight_0, - 1 to R.drawable.ic_text_weight_1, - 2 to R.drawable.ic_text_weight_2, - ) - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - observeEvent>(EventBus.UP_CONFIG) { list -> - if (list.contains(2)) { - binding.btnTextColor.color = ReadBookConfig.durConfig.curTextColor() - binding.btnShadowColor.color = ReadBookConfig.durConfig.curTextShadowColor() - binding.btnTextAccentColor.color = ReadBookConfig.durConfig.curTextAccentColor() - } - } - initView() - upView() - initViewEvent() - } - - private fun initView() = binding.run { - binding.btnTextColor.color = ReadBookConfig.durConfig.curTextColor() - binding.btnShadowColor.color = ReadBookConfig.durConfig.curTextShadowColor() - binding.btnTextAccentColor.color = ReadBookConfig.durConfig.curTextAccentColor() - binding.swUnderline.isChecked = underline - dsbTextLetterSpacing.valueFormat = { - ((it - 50) / 100f).toString() - } - dsbLineSize.valueFormat = { ((it - 10) / 10f).toString() } - binding.dsbParagraphSpacing.valueFormat = { value -> - (value / 10f).toString() - } - binding.btnIndentLayout.apply { - valueFormat = { value -> - value.toString() - } - onChanged = { value -> - val indentCount = value.coerceIn(0, 4) - ReadBookConfig.paragraphIndent = " ".repeat(indentCount) - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - progress = ReadBookConfig.paragraphIndent.length - } - - val weightOptions = context?.resources?.getStringArray(R.array.text_font_weight) - val weightValues = listOf(0, 1, 2) - val initialIndex = weightValues.indexOf(ReadBookConfig.textBold) - val initialIconRes = weightIconMap[initialIndex] ?: R.drawable.ic_custom_text - binding.textFontWeightConverter.setIconResource(initialIconRes) - binding.textFontWeightConverter.setOnClickListener { - context?.alert(titleResource = R.string.text_font_weight_converter) { - weightOptions?.let { options -> - items(options.toList()) { _, i -> - ReadBookConfig.textBold = weightValues[i] - binding.sliderFontWeight.progress = - ReadBookConfig.textBold.coerceAtLeast(100) - val iconRes = weightIconMap[i] ?: R.drawable.ic_custom_text - binding.textFontWeightConverter.setIconResource(iconRes) - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 9, 6)) - } - } - } - } - - binding.sliderFontWeight.apply { - min = 100 - max = 900 - progress = ReadBookConfig.textBold.coerceAtLeast(100) - onChanged = { - binding.textFontWeightConverter.setIconResource(R.drawable.ic_custom_text) - ReadBookConfig.textBold = it - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 9, 6)) - } - } - - binding.btnShadowSet.setOnClickListener { - callBack2?.showShadowSet() - } - - binding.btnRegexColor.setOnClickListener { - RegexColorConfigDialog().show(childFragmentManager, "regexColorConfig") - } - - binding.btnSelectFonts.setOnClickListener { - callBack2?.showFontSelect() - } - binding.btnTextItalic.isChecked = ReadBookConfig.textItalic - binding.btnTextShadow.isChecked = ReadBookConfig.textShadow - binding.btnShadowColor.color = ReadBookConfig.textShadowColor - - } - - private fun initViewEvent() = binding.run { - dsbTextLetterSpacing.onChanged = { - ReadBookConfig.letterSpacing = (it - 50) / 100f - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - dsbLineSize.onChanged = { - ReadBookConfig.lineSpacingExtra = it - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - binding.btnTextColor.setOnClickListener { - ColorPickerDialog.newBuilder() - .setColor(ReadBookConfig.durConfig.curTextColor()) - .setShowAlphaSlider(false) - .setDialogType(ColorPickerDialog.TYPE_CUSTOM) - .setDialogId(TEXT_COLOR) - .show(requireActivity()) - } - binding.btnTextAccentColor.setOnClickListener { - ColorPickerDialog.newBuilder() - .setColor(ReadBookConfig.durConfig.curTextAccentColor()) - .setShowAlphaSlider(false) - .setDialogType(ColorPickerDialog.TYPE_CUSTOM) - .setDialogId(TEXT_ACCENT_COLOR) - .show(requireActivity()) - } - binding.swUnderline.addOnCheckedChangeListener { _, isChecked -> - callBack2?.showUnderlineConfig() - } - - binding.btnDefaultFonts.setOnClickListener { - val requireContext = requireContext() - alert(titleResource = R.string.system_typeface) { - items( - requireContext.resources.getStringArray(R.array.system_typefaces).toList() - ) { _, i -> - AppConfig.systemTypefaces = i - onDefaultFontChange() - } - } - } - binding.dsbParagraphSpacing.onChanged = { value -> - ReadBookConfig.paragraphSpacing = value - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - - binding.btnTextItalic.addOnCheckedChangeListener { _, isChecked -> - ReadBookConfig.textItalic = isChecked - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - binding.btnTextShadow.addOnCheckedChangeListener { _, isChecked -> - ReadBookConfig.textShadow = isChecked - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - binding.btnShadowColor.setOnClickListener { - ColorPickerDialog.newBuilder() - .setColor(ReadBookConfig.config.curTextShadowColor()) - .setShowAlphaSlider(false) - .setDialogType(ColorPickerDialog.TYPE_CUSTOM) - .setDialogId(S_COLOR) - .show(requireActivity()) - //postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - } - - private fun upView() = binding.run { - ReadBookConfig.let { - dsbTextLetterSpacing.progress = (it.letterSpacing * 100).toInt() + 50 - dsbLineSize.progress = it.lineSpacingExtra - dsbParagraphSpacing.progress = it.paragraphSpacing - } - } - - private fun onDefaultFontChange() { - callBack?.selectFont("") - } - - private val callBack: CallBack? - get() = (parentFragment as? CallBack) ?: (activity as? CallBack) - - interface CallBack { - fun selectFont(path: String) - val curFontPath: String - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/FontSelectDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/FontSelectDialog.kt deleted file mode 100644 index a352c76e7..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/FontSelectDialog.kt +++ /dev/null @@ -1,258 +0,0 @@ -package io.legado.app.ui.book.read.config - -import android.content.Context -import android.graphics.Typeface -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import androidx.core.net.toUri -import androidx.core.widget.addTextChangedListener -import androidx.documentfile.provider.DocumentFile -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.GridLayoutManager -import io.legado.app.R -import io.legado.app.base.BaseBottomSheetDialogFragment -import io.legado.app.base.adapter.ItemViewHolder -import io.legado.app.base.adapter.RecyclerAdapter -import io.legado.app.constant.AppLog -import io.legado.app.constant.PreferKey -import io.legado.app.databinding.DialogFontSelectBinding -import io.legado.app.databinding.ItemFontBinding -import io.legado.app.help.config.AppConfig -import io.legado.app.lib.dialogs.SelectItem -import io.legado.app.lib.dialogs.alert -import io.legado.app.lib.permission.Permissions -import io.legado.app.lib.permission.PermissionsCompat -import io.legado.app.ui.file.HandleFileContract -import io.legado.app.utils.FileDoc -import io.legado.app.utils.FileUtils -import io.legado.app.utils.RealPathUtil -import io.legado.app.utils.cnCompare -import io.legado.app.utils.externalFiles -import io.legado.app.utils.getPrefString -import io.legado.app.utils.invisible -import io.legado.app.utils.isContentScheme -import io.legado.app.utils.list -import io.legado.app.utils.listFileDocs -import io.legado.app.utils.printOnDebug -import io.legado.app.utils.putPrefString -import io.legado.app.utils.toastOnUi -import io.legado.app.utils.viewbindingdelegate.viewBinding -import io.legado.app.utils.visible -import kotlinx.coroutines.launch -import java.io.File -import java.net.URLDecoder - -class FontSelectDialog : BaseBottomSheetDialogFragment(R.layout.dialog_font_select) { - - private val fontRegex = Regex("(?i).*\\.[ot]tf") - private val binding by viewBinding(DialogFontSelectBinding::bind) - private val adapter by lazy { - val curFontPath = callBack?.curFontPath ?: "" - FontAdapter(requireContext(), curFontPath) - } - private var allFontItems: List = emptyList() - - private val selectFontDir = registerForActivityResult(HandleFileContract()) { - it.uri?.let { uri -> - if (uri.isContentScheme()) { - putPrefString(PreferKey.fontFolder, uri.toString()) - val doc = DocumentFile.fromTreeUri(requireContext(), uri) - if (doc != null) { - loadFontFiles(FileDoc.fromDocumentFile(doc)) - } else { - RealPathUtil.getPath(requireContext(), uri)?.let { path -> - loadFontFilesByPermission(path) - } - } - } else { - uri.path?.let { path -> - putPrefString(PreferKey.fontFolder, path) - loadFontFilesByPermission(path) - } - } - } - } - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - binding.recyclerView.layoutManager = GridLayoutManager(context, 2) - binding.recyclerView.adapter = adapter - initView() - initSearch() - } - - private fun initSearch() { - binding.etSearch.addTextChangedListener { - filterFonts(it.toString()) - } - } - - private fun filterFonts(keyword: String) { - val filtered = if (keyword.isBlank()) { - allFontItems - } else { - allFontItems.filter { it.name.contains(keyword, ignoreCase = true) } - } - adapter.setItems(filtered) - } - - private fun initView() { - val fontPath = getPrefString(PreferKey.fontFolder) - if (fontPath.isNullOrEmpty()) { - openFolder() - } else { - if (fontPath.isContentScheme()) { - val doc = DocumentFile.fromTreeUri(requireContext(), fontPath.toUri()) - if (doc?.canRead() == true) { - loadFontFiles(FileDoc.fromDocumentFile(doc)) - } else { - openFolder() - } - } else { - loadFontFilesByPermission(fontPath) - } - } - - binding.btnOtherDir.setOnClickListener { - openFolder() - } - } - - private fun openFolder() { - lifecycleScope.launch { - val defaultPath = "SD${File.separator}Fonts" - selectFontDir.launch { - otherActions = arrayListOf(SelectItem(defaultPath, -1)) - } - } - } - - private fun getLocalFonts(): ArrayList { - val path = FileUtils.getPath(requireContext().externalFiles, "font") - return File(path).listFileDocs { - it.name.matches(fontRegex) - } - } - - private fun loadFontFilesByPermission(path: String) { - PermissionsCompat.Builder() - .addPermissions(*Permissions.Group.STORAGE) - .rationale(R.string.tip_perm_request_storage) - .onGranted { - loadFontFiles( - FileDoc.fromFile(File(path)) - ) - } - .request() - } - - private fun loadFontFiles(fileDoc: FileDoc) { - execute { - val fontItems = fileDoc.list { - it.name.matches(fontRegex) - } ?: ArrayList() - mergeFontItems(fontItems, getLocalFonts()) - }.onSuccess { - allFontItems = it - adapter.setItems(it) - }.onError { - AppLog.put("加载字体文件失败\n${it.localizedMessage}", it) - toastOnUi("getFontFiles:${it.localizedMessage}") - } - } - - private fun mergeFontItems( - items1: ArrayList, - items2: ArrayList - ): List { - val items = ArrayList(items1) - items2.forEach { item2 -> - var isInFirst = false - items1.forEach for1@{ item1 -> - if (item2.name == item1.name) { - isInFirst = true - return@for1 - } - } - if (!isInFirst) { - items.add(item2) - } - } - return items.sortedWith { o1, o2 -> - o1.name.cnCompare(o2.name) - } - } - - fun onFontSelect(docItem: FileDoc) { - execute { - callBack?.selectFont(docItem.toString()) - }.onSuccess { - dismissAllowingStateLoss() - } - } - - private fun onDefaultFontChange() { - callBack?.selectFont("") - } - - var explicitCallback: CallBack? = null - - private val callBack: CallBack? - get() = explicitCallback ?: (parentFragment as? CallBack) ?: (activity as? CallBack) - - inner class FontAdapter(context: Context, curFilePath: String) : - RecyclerAdapter(context) { - - private val curName = runCatching { - URLDecoder.decode(curFilePath, "utf-8") - }.getOrNull()?.substringAfterLast(File.separator) - - override fun getViewBinding(parent: ViewGroup): ItemFontBinding { - return ItemFontBinding.inflate(inflater, parent, false) - } - - override fun convert( - holder: ItemViewHolder, - binding: ItemFontBinding, - item: FileDoc, - payloads: MutableList - ) { - binding.run { - runCatching { - val typeface: Typeface? = if (item.isContentScheme) { - context.contentResolver - .openFileDescriptor(item.uri, "r")?.use { - Typeface.Builder(it.fileDescriptor).build() - } - } else { - Typeface.createFromFile(item.uri.path!!) - } - tvFont.typeface = typeface - }.onFailure { - it.printOnDebug() - AppLog.put("读取字体 ${item.name} 出错\n${it.localizedMessage}", it, true) - } - tvFont.text = item.name - root.setOnClickListener { onFontSelect(item) } - if (item.name == curName) { - ivChecked.visible() - } else { - ivChecked.invisible() - } - } - } - - override fun registerListener(holder: ItemViewHolder, binding: ItemFontBinding) { - holder.itemView.setOnClickListener { - getItem(holder.layoutPosition)?.let { - onFontSelect(it) - } - } - } - } - - interface CallBack { - fun selectFont(path: String) - val curFontPath: String - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/HighlightRuleStore.kt b/app/src/main/java/io/legado/app/ui/book/read/config/HighlightRuleStore.kt new file mode 100644 index 000000000..d70b441b7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/HighlightRuleStore.kt @@ -0,0 +1,356 @@ +package io.legado.app.ui.book.read.config + +import io.legado.app.constant.PreferKey +import io.legado.app.data.appDb +import io.legado.app.data.entities.HighlightRule +import io.legado.app.help.config.ReadBookConfig +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonArray +import io.legado.app.utils.getPrefBoolean +import io.legado.app.utils.getPrefString +import io.legado.app.utils.putPrefBoolean +import io.legado.app.utils.putPrefString +import splitties.init.appCtx +import java.io.File + +object HighlightRuleStore { + + const val backupFileName = "highlightRule.json" + + data class BackupData( + val rules: List = emptyList(), + val dialogEnabled: Boolean = true, + val bookTitleEnabled: Boolean = true, + val bracketNoteEnabled: Boolean = true, + ) + + private val dao get() = appDb.highlightRuleDao + + fun load(): List { + migrateFromPrefsIfNeeded() + return dao.getAll() + } + + fun loadEnabled(): List { + migrateFromPrefsIfNeeded() + return dao.getEnabled() + } + + fun save(rules: List) { + val sanitized = rules.mapIndexed { index, rule -> + sanitizeRule(rule).copy(position = index) + } + dao.replaceAll(sanitized) + cleanupUnusedBgImages(sanitized) + } + + fun update(rule: HighlightRule) { + dao.update(sanitizeRule(rule)) + } + + fun delete(rule: HighlightRule) { + dao.delete(rule) + } + + fun reset(): List { + val defaults = createDefaultRules() + dao.replaceAll(defaults) + return defaults + } + + fun createBackupData(): BackupData { + return BackupData( + rules = load(), + dialogEnabled = appCtx.getPrefBoolean(PreferKey.highlightRuleDialog, true), + bookTitleEnabled = appCtx.getPrefBoolean(PreferKey.highlightRuleBookTitle, true), + bracketNoteEnabled = appCtx.getPrefBoolean(PreferKey.highlightRuleBracketNote, true), + ) + } + + fun restoreBackupData(backupData: BackupData, backupRootPath: String? = null) { + val rules = backupData.rules.map { rule -> + val safeRule = sanitizeRule(rule) + val restoredBgImage = restoreRuleBgImage(backupRootPath, safeRule.bgImage) + safeRule.copy(bgImage = restoredBgImage) + } + save(rules) + appCtx.putPrefBoolean(PreferKey.highlightRuleDialog, backupData.dialogEnabled) + appCtx.putPrefBoolean(PreferKey.highlightRuleBookTitle, backupData.bookTitleEnabled) + appCtx.putPrefBoolean(PreferKey.highlightRuleBracketNote, backupData.bracketNoteEnabled) + } + + /** + * 从旧版 SharedPreferences 迁移数据(一次性) + */ + private fun migrateFromPrefsIfNeeded() { + if (dao.count() > 0) return + // 尝试从 SharedPreferences 读取旧数据 + val stored = appCtx.getPrefString(PreferKey.highlightRuleItems) + if (!stored.isNullOrBlank()) { + val oldRules = GSON.fromJsonArray(stored).getOrNull() + if (!oldRules.isNullOrEmpty()) { + val migrated = oldRules.mapIndexed { index, old -> + sanitizeRule( + HighlightRule( + id = old.id, + name = old.name, + pattern = old.pattern, + sampleText = old.sampleText, + targetScope = old.targetScope, + enabled = old.enabled, + position = index, + textColor = old.textColor, + underlineMode = old.underlineMode, + underlineColor = old.underlineColor, + underlineWidth = old.underlineWidth, + underlineOffset = old.underlineOffset, + underlineSvgPath = old.underlineSvgPath, + bgImage = old.bgImage, + bgImageFit = old.bgImageFit, + bgImageScale = old.bgImageScale, + ) + ).copy(position = index) + } + dao.insertAll(migrated) + // 清除旧 SharedPreferences 数据 + appCtx.putPrefString(PreferKey.highlightRuleItems, null) + return + } + } + // 尝试从旧版 RegexColorRule 迁移 + migrateFromRegexColorRules() + } + + private fun migrateFromRegexColorRules() { + val oldRules = ReadBookConfig.regexColorRules + if (oldRules.isEmpty()) return + val migrated = oldRules.mapIndexed { index, old -> + HighlightRule( + name = old.name, + pattern = old.pattern, + position = index, + textColor = old.color, + ) + } + dao.insertAll(migrated) + oldRules.clear() + ReadBookConfig.save() + } + + fun sanitizeRule(rule: HighlightRule): HighlightRule { + val name = runCatching { rule.name }.getOrNull().orEmpty() + val pattern = runCatching { rule.pattern }.getOrNull().orEmpty() + val sampleText = runCatching { rule.sampleText }.getOrNull().orEmpty() + val id = runCatching { rule.id }.getOrNull().orEmpty().ifBlank { + "${System.currentTimeMillis()}_${listOf(name, pattern).joinToString("|").hashCode().toUInt().toString(16)}" + } + return HighlightRule( + id = id, + name = name, + pattern = pattern, + sampleText = sampleText, + targetScope = normalizeTargetScope(runCatching { rule.targetScope }.getOrDefault(HighlightRule.TARGET_ALL)), + enabled = runCatching { rule.enabled }.getOrDefault(true), + position = runCatching { rule.position }.getOrDefault(0), + textColor = runCatching { rule.textColor }.getOrNull(), + bgColor = runCatching { rule.bgColor }.getOrNull(), + underlineMode = runCatching { rule.underlineMode }.getOrDefault(0).coerceIn(0, 5), + underlineColor = runCatching { rule.underlineColor }.getOrNull(), + underlineWidth = runCatching { rule.underlineWidth }.getOrDefault(1f).coerceIn(0.1f, 10f), + underlineOffset = runCatching { rule.underlineOffset }.getOrDefault(2f).coerceIn(0f, 20f), + underlineSvgPath = runCatching { rule.underlineSvgPath }.getOrNull(), + bgImage = runCatching { rule.bgImage }.getOrNull()?.takeIf { it.isNotBlank() }, + bgImageFit = runCatching { rule.bgImageFit }.getOrDefault(0).coerceIn(0, 2), + bgImageScale = runCatching { rule.bgImageScale }.getOrDefault(1f).coerceIn(0.1f, 5f), + ) + } + + private fun normalizeTargetScope(value: Int, fallback: Int = HighlightRule.TARGET_ALL): Int { + return when (value) { + HighlightRule.TARGET_ALL, + HighlightRule.TARGET_TITLE, + HighlightRule.TARGET_BODY -> value + else -> fallback + } + } + + fun createDefaultRules(): List { + val ctx = appCtx + return listOf( + HighlightRule( + id = "dialog_default", + name = "对话高亮", + pattern = "“[^\\u201d\\n]{1,120}\\u201d|\"[^\"\\n]{1,120}\"|「[^」\\n]{1,120}」|『[^』\\n]{1,120}』", + sampleText = "她轻声说:“今晚就出发。”", + position = 0, + enabled = ctx.getPrefBoolean(PreferKey.highlightRuleDialog, true), + textColor = 0xFFFF8C00.toInt() + ), + HighlightRule( + id = "book_title_default", + name = "书名号高亮", + pattern = "《[^》\\n]{1,80}》", + sampleText = "最近在重读《百年孤独》,节奏依然很稳。", + position = 1, + enabled = ctx.getPrefBoolean(PreferKey.highlightRuleBookTitle, true), + underlineMode = 3, + underlineWidth = 0.5f, + underlineColor = 0xFF63C37D.toInt() + ), + HighlightRule( + id = "bracket_note_default", + name = "括号标注高亮", + pattern = "([^()\\n]{1,80})|\\([^()\\n]{1,80}\\)|【[^】\\n]{1,80}】|\\[[^\\]\\n]{1,80}]", + sampleText = "他停了一下(像是忽然想起了什么)。", + position = 2, + enabled = ctx.getPrefBoolean(PreferKey.highlightRuleBracketNote, true), + textColor = 0xFF8F959E.toInt(), + underlineMode = 2, + underlineWidth = 0.5f, + underlineColor = 0xFF5A8DEE.toInt() + ), + HighlightRule( + id = "title_emphasis_default", + name = "标题强调", + pattern = "(?m)^\\s{0,2}(?:第[0-9零〇一二两三四五六七八九十百千万IVXLCDMivxlcdm]{1,12}[章节卷回部篇集幕]|序章|楔子|引子|终章|尾声|后记|番外)[^\\n]{0,40}$", + sampleText = "第一章 雨夜来客", + targetScope = HighlightRule.TARGET_TITLE, + position = 3, + enabled = true, + textColor = 0xFF333333.toInt(), + underlineMode = 4, + underlineColor = 0xFF7C5634.toInt() + ), + HighlightRule( + id = "thought_default", + name = "心理活动", + pattern = "([^)\\n]{0,40}(?:心想|暗道|心道|想到|寻思着|琢磨|嘀咕)[^)\\n]{0,40})", + sampleText = "她心中一紧(暗道不对,这里一定有问题)。", + position = 4, + enabled = false, + textColor = 0xFF9370DB.toInt(), + underlineMode = 1, + underlineWidth = 0.5f, + underlineColor = 0xFF9370DB.toInt() + ), + HighlightRule( + id = "narrator_default", + name = "旁白说明", + pattern = "(?:未完待续|待续|下文再表|按:?|注:?)[^\\n]{0,40}|((?:注|旁白|作者有话说)[::][^)\\n]{0,40})", + sampleText = "(注:此处时间线与前文同步)", + position = 5, + enabled = false, + textColor = 0xFF708090.toInt() + ), + HighlightRule( + id = "emphasis_default", + name = "重点强调", + pattern = "(?:\\*\\*|__)[^\\n*_]{1,40}(?:\\*\\*|__)|(?:!!!|!?|\\?!)[^\\n]{0,20}", + sampleText = "**这是重点内容**,需要特别注意。", + position = 6, + enabled = false, + textColor = 0xFFDC143C.toInt(), + underlineMode = 1, + underlineColor = 0xFFDC143C.toInt() + ), + HighlightRule( + id = "poetry_default", + name = "诗词引用", + pattern = "(?m)^[\\p{IsHan},。!?;:、]{5,24}$", + sampleText = "床前明月光,\n疑是地上霜。", + position = 7, + enabled = false, + textColor = 0xFF2F4F4F.toInt(), + underlineMode = 3, + underlineWidth = 0.5f, + underlineColor = 0xFF2F4F4F.toInt() + ), + HighlightRule( + id = "ellipsis_default", + name = "省略停顿", + pattern = "…{2,}|\\.{3,}|—{2,}|-{3,}", + sampleText = "他沉默了很久……最后还是点了头。", + position = 8, + enabled = false, + textColor = 0xFF8B8B8B.toInt() + ), + HighlightRule( + id = "number_default", + name = "数字金额", + pattern = "(?:¥|¥)?\\d+(?:\\.\\d+)?(?:元|块|万|千|百|亿|%|%)|[零〇一二两三四五六七八九十百千万亿]+(?:元|块|万|千|百|亿)", + sampleText = "原价100元,现在只要50元。", + position = 9, + enabled = false, + textColor = 0xFF4169E1.toInt() + ), + HighlightRule( + id = "english_default", + name = "英文单词", + pattern = "\\b[A-Za-z]{2,}[A-Za-z0-9'-]*\\b", + sampleText = "Hello World,你好世界。", + position = 10, + enabled = false, + textColor = 0xFF4169E1.toInt() + ), + HighlightRule( + id = "date_time_default", + name = "时间日期", + pattern = "(?:\\d{2,4}|[零〇一二两三四五六七八九十]{2,4})年(?:\\d{1,2}|[正一二三四五六七八九十冬腊])月(?:\\d{1,2}|[一二三四五六七八九十廿三])?[日号]?|\\b\\d{1,2}:\\d{2}\\b|(?:[0-1]?\\d|2[0-3])点(?:[0-5]?\\d分?)?", + sampleText = "2024年8月12日,上午10:30出发。", + position = 11, + enabled = false, + textColor = 0xFF20B2AA.toInt() + ) + ) + } + + private fun cleanupUnusedBgImages(rules: List) { + val usedPaths = rules.mapNotNull { it.bgImage } + .filter { it.isNotBlank() && !it.startsWith("assets://") } + .toSet() + val dir = File(appCtx.filesDir, "bg_images") + if (!dir.exists()) return + dir.listFiles()?.forEach { file -> + if (file.absolutePath !in usedPaths) { + runCatching { file.delete() } + } + } + } + + private fun restoreRuleBgImage(backupRootPath: String?, bgImage: String?): String? { + val path = bgImage ?: return null + if (path.isBlank() || path.startsWith("assets://")) return path + val rootPath = backupRootPath ?: return path + val backupFile = File(rootPath, "highlightRuleBg${File.separator}${File(path).name}") + .takeIf { it.exists() && it.isFile } + ?: return path + val dir = File(appCtx.filesDir, "bg_images") + if (!dir.exists()) dir.mkdirs() + val targetFile = File(dir, backupFile.name) + if (!targetFile.exists() || targetFile.length() != backupFile.length()) { + backupFile.copyTo(targetFile, overwrite = true) + } + return targetFile.absolutePath + } + + /** + * 旧版 SharedPreferences 数据结构(用于迁移) + */ + private data class LegacyHighlightRule( + val id: String = "", + val name: String = "", + val pattern: String = "", + val sampleText: String = "", + val targetScope: Int = 0, + val enabled: Boolean = true, + val textColor: Int? = null, + val underlineMode: Int = 0, + val underlineColor: Int? = null, + val underlineWidth: Float = 1f, + val underlineOffset: Float = 2f, + val underlineSvgPath: String? = null, + val bgImage: String? = null, + val bgImageFit: Int = 0, + val bgImageScale: Float = 1f, + ) +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/HighlightStyleSpan.kt b/app/src/main/java/io/legado/app/ui/book/read/config/HighlightStyleSpan.kt new file mode 100644 index 000000000..26496acb4 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/HighlightStyleSpan.kt @@ -0,0 +1,23 @@ +package io.legado.app.ui.book.read.config + +import android.text.TextPaint +import android.text.style.CharacterStyle +import android.text.style.UpdateAppearance + +/** + * 用于在阅读排版阶段传递局部下划线样式 + */ +class HighlightStyleSpan( + val underlineMode: Int, + val underlineColor: Int, + val underlineWidth: Float = 1f, + val underlineOffset: Float = 2f, + val underlineSvgPath: String = "", + val bgImage: String = "", + val bgImageFit: Int = 0, + val bgImageScale: Float = 1f, +) : CharacterStyle(), UpdateAppearance { + + override fun updateDrawState(tp: TextPaint) = Unit + +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/HttpTtsEditDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/HttpTtsEditDialog.kt deleted file mode 100644 index 641eb1da2..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/HttpTtsEditDialog.kt +++ /dev/null @@ -1,135 +0,0 @@ -package io.legado.app.ui.book.read.config - -import android.os.Bundle -import android.view.MenuItem -import android.view.View -import androidx.appcompat.widget.Toolbar -import androidx.fragment.app.viewModels -import io.legado.app.R -import io.legado.app.base.BaseBottomSheetDialogFragment -import io.legado.app.data.entities.HttpTTS -import io.legado.app.databinding.DialogHttpTtsEditBinding -import io.legado.app.lib.dialogs.alert -//import io.legado.app.lib.theme.primaryColor -import io.legado.app.ui.about.AppLogDialog -import io.legado.app.ui.login.SourceLoginActivity -import io.legado.app.ui.widget.code.addJsPattern -import io.legado.app.ui.widget.code.addJsonPattern -import io.legado.app.ui.widget.code.addLegadoPattern -import io.legado.app.utils.GSON -import io.legado.app.utils.sendToClip -import io.legado.app.utils.showDialogFragment -import io.legado.app.utils.showHelp -import io.legado.app.utils.startActivity -import io.legado.app.utils.toastOnUi -import io.legado.app.utils.viewbindingdelegate.viewBinding - -class HttpTtsEditDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_http_tts_edit), - Toolbar.OnMenuItemClickListener { - - constructor(id: Long) : this() { - arguments = Bundle().apply { - putLong("id", id) - } - } - - private val binding by viewBinding(DialogHttpTtsEditBinding::bind) - private val viewModel by viewModels() - - override fun onStart() { - super.onStart() - } - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - //binding.toolBar.setBackgroundColor(primaryColor) - binding.tvUrl.run { - addLegadoPattern() - addJsonPattern() - addJsPattern() - } - binding.tvLoginUrl.run { - addLegadoPattern() - addJsonPattern() - addJsPattern() - } - binding.tvLoginUi.addJsonPattern() - binding.tvLoginCheckJs.addJsPattern() - binding.tvHeaders.run { - addLegadoPattern() - addJsonPattern() - addJsPattern() - } - viewModel.initData(arguments) { - initView(httpTTS = it) - } - initMenu() - } - - fun initMenu() { - binding.toolBar.inflateMenu(R.menu.speak_engine_edit) - //binding.toolBar.menu.applyTint(requireContext()) - binding.toolBar.setOnMenuItemClickListener(this) - } - - fun initView(httpTTS: HttpTTS) { - binding.tvName.setText(httpTTS.name) - binding.tvUrl.setText(httpTTS.url) - binding.tvContentType.setText(httpTTS.contentType) - binding.tvConcurrentRate.setText(httpTTS.concurrentRate) - binding.tvLoginUrl.setText(httpTTS.loginUrl) - binding.tvLoginUi.setText(httpTTS.loginUi) - binding.tvLoginCheckJs.setText(httpTTS.loginCheckJs) - binding.tvHeaders.setText(httpTTS.header) - } - - override fun onMenuItemClick(item: MenuItem?): Boolean { - when (item?.itemId) { - R.id.menu_save -> viewModel.save(dataFromView()) { - toastOnUi("保存成功") - } - R.id.menu_login -> dataFromView().let { httpTts -> - if (httpTts.loginUrl.isNullOrBlank()) { - toastOnUi("登录url不能为空") - } else { - viewModel.save(httpTts) { - startActivity { - putExtra("type", "httpTts") - putExtra("key", httpTts.id.toString()) - } - } - } - } - R.id.menu_show_login_header -> alert { - setTitle(R.string.login_header) - dataFromView().getLoginHeader()?.let { loginHeader -> - setMessage(loginHeader) - } - } - R.id.menu_del_login_header -> dataFromView().removeLoginHeader() - R.id.menu_copy_source -> dataFromView().let { - context?.sendToClip(GSON.toJson(it)) - } - R.id.menu_paste_source -> viewModel.importFromClip { - initView(it) - } - R.id.menu_log -> showDialogFragment() - R.id.menu_help -> showHelp("httpTTSHelp") - } - return true - } - - private fun dataFromView(): HttpTTS { - return HttpTTS( - id = viewModel.id ?: System.currentTimeMillis(), - name = binding.tvName.text.toString(), - url = binding.tvUrl.text.toString(), - contentType = binding.tvContentType.text?.toString(), - concurrentRate = binding.tvConcurrentRate.text?.toString(), - loginUrl = binding.tvLoginUrl.text?.toString(), - loginUi = binding.tvLoginUi.text?.toString(), - loginCheckJs = binding.tvLoginCheckJs.text?.toString(), - header = binding.tvHeaders.text?.toString() - ) - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/HttpTtsEditViewModel.kt b/app/src/main/java/io/legado/app/ui/book/read/config/HttpTtsEditViewModel.kt deleted file mode 100644 index 17222d770..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/HttpTtsEditViewModel.kt +++ /dev/null @@ -1,76 +0,0 @@ -package io.legado.app.ui.book.read.config - -import android.app.Application -import android.os.Bundle -import io.legado.app.base.BaseViewModel -import io.legado.app.data.appDb -import io.legado.app.data.entities.HttpTTS -import io.legado.app.exception.NoStackTraceException -import io.legado.app.model.ReadAloud -import io.legado.app.utils.getClipText -import io.legado.app.utils.isJsonArray -import io.legado.app.utils.isJsonObject -import io.legado.app.utils.toastOnUi - -class HttpTtsEditViewModel(app: Application) : BaseViewModel(app) { - - var id: Long? = null - - fun initData(arguments: Bundle?, success: (httpTTS: HttpTTS) -> Unit) { - execute { - if (id == null) { - val argumentId = arguments?.getLong("id") - if (argumentId != null && argumentId != 0L) { - id = argumentId - return@execute appDb.httpTTSDao.get(argumentId) - } - } - return@execute null - }.onSuccess { - it?.let { - success.invoke(it) - } - } - } - - fun save(httpTTS: HttpTTS, success: (() -> Unit)? = null) { - id = httpTTS.id - execute { - appDb.httpTTSDao.insert(httpTTS) - if (ReadAloud.ttsEngine == httpTTS.id.toString()) ReadAloud.upReadAloudClass() - }.onSuccess { - success?.invoke() - } - } - - fun importFromClip(onSuccess: (httpTTS: HttpTTS) -> Unit) { - val text = context.getClipText() - if (text.isNullOrBlank()) { - context.toastOnUi("剪贴板为空") - } else { - importSource(text, onSuccess) - } - } - - fun importSource(text: String, onSuccess: (httpTTS: HttpTTS) -> Unit) { - val text1 = text.trim() - execute { - when { - text1.isJsonObject() -> { - HttpTTS.fromJson(text1).getOrThrow() - } - text1.isJsonArray() -> { - HttpTTS.fromJsonArray(text1).getOrThrow().first() - } - else -> { - throw NoStackTraceException("格式不对") - } - } - }.onSuccess { - onSuccess.invoke(it) - }.onError { - context.toastOnUi(it.localizedMessage) - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/InfoConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/InfoConfigDialog.kt deleted file mode 100644 index b778c0ad9..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/InfoConfigDialog.kt +++ /dev/null @@ -1,284 +0,0 @@ -package io.legado.app.ui.book.read.config - -import android.os.Bundle -import android.view.View -import com.jaredrummler.android.colorpicker.ColorPickerDialog -import io.legado.app.R -import io.legado.app.base.BaseBottomSheetDialogFragment -import io.legado.app.constant.EventBus -import io.legado.app.databinding.DialogReadInfoBinding -import io.legado.app.help.config.ReadBookConfig -import io.legado.app.help.config.ReadTipConfig -import io.legado.app.lib.dialogs.selector -import io.legado.app.ui.book.read.ReadBookActivity -import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TIP_DIVIDER_COLOR -import io.legado.app.utils.getCompatColor -import io.legado.app.utils.observeEvent -import io.legado.app.utils.postEvent -import io.legado.app.utils.viewbindingdelegate.viewBinding - -class InfoConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_info), FontSelectDialog.CallBack { - - private val binding by viewBinding(DialogReadInfoBinding::bind) - private val callBack get() = activity as? ReadBookActivity - - override val curFontPath: String - get() = ReadBookConfig.headerFont - - override fun selectFont(path: String) { - ReadBookConfig.headerFont = path - ReadBookConfig.save() - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - initView() - initEvent() - observeEvent(EventBus.TIP_COLOR) { - upTvHeaderColor() - upTvFooterColor() - upTvTipDividerColor() - } - observeEvent>(EventBus.UP_CONFIG) { list -> - if (list.contains(2)) { - upBtnHeaderMode() - upBtnFooterMode() - } - } - } - - private fun initView() { - ReadTipConfig.run { - tipNames.let { tipNames -> - binding.tvHeaderLeft.text = - tipNames.getOrElse(tipValues.indexOf(tipHeaderLeft)) { tipNames[none] } - binding.tvHeaderMiddle.text = - tipNames.getOrElse(tipValues.indexOf(tipHeaderMiddle)) { tipNames[none] } - binding.tvHeaderRight.text = - tipNames.getOrElse(tipValues.indexOf(tipHeaderRight)) { tipNames[none] } - binding.tvFooterLeft.text = - tipNames.getOrElse(tipValues.indexOf(tipFooterLeft)) { tipNames[none] } - binding.tvFooterMiddle.text = - tipNames.getOrElse(tipValues.indexOf(tipFooterMiddle)) { tipNames[none] } - binding.tvFooterRight.text = - tipNames.getOrElse(tipValues.indexOf(tipFooterRight)) { tipNames[none] } - } - } - binding.btnPaddingSetting.setOnClickListener { - callBack?.showPaddingConfig() - dismissAllowingStateLoss() - } - upTvHeaderColor() - upTvFooterColor() - upTvTipDividerColor() - binding.scvHeaderFontSize.progress = ReadBookConfig.headerFontSize - } - - private fun upTvHeaderColor() { - val tipColor = if (ReadTipConfig.tipHeaderColor == 0) { - ReadBookConfig.textColor - } else { - ReadTipConfig.tipHeaderColor - } - binding.btnHeaderColor.color = tipColor - } - - private fun upTvFooterColor() { - val tipColor = if (ReadTipConfig.tipFooterColor == 0) { - ReadBookConfig.textColor - } else { - ReadTipConfig.tipFooterColor - } - binding.btnFooterColor.color = tipColor - } - - private fun upTvTipDividerColor() { - val tipDividerColor = when (ReadTipConfig.tipDividerColor) { - -1 -> getCompatColor(R.color.divider) - 0 -> ReadBookConfig.textColor - else -> ReadTipConfig.tipDividerColor - } - binding.btnDividerColor.color = tipDividerColor - } - - private fun upBtnHeaderMode() { - val headerModes = ReadTipConfig.getHeaderModes(requireContext()) - binding.btnHeaderMode.text = headerModes[ReadTipConfig.headerMode] ?: getString(R.string.header) - } - - private fun upBtnFooterMode() { - val footerModes = ReadTipConfig.getFooterModes(requireContext()) - binding.btnFooterMode.text = footerModes[ReadTipConfig.footerMode] ?: getString(R.string.footer) - } - - private fun initEvent() = binding.run { - - val headerModes = ReadTipConfig.getHeaderModes(requireContext()) - binding.btnHeaderMode.text = headerModes[ReadTipConfig.headerMode] ?: getString(R.string.header) - binding.btnHeaderMode.setOnClickListener { - val items = headerModes.values.toList() - context?.selector(items = items) { _, index -> - val selectedKey = headerModes.keys.toList()[index] - ReadTipConfig.headerMode = selectedKey - binding.btnHeaderMode.text = headerModes[selectedKey] - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - } - - val footerModes = ReadTipConfig.getFooterModes(requireContext()) - binding.btnFooterMode.text = footerModes[ReadTipConfig.footerMode] ?: getString(R.string.footer) - binding.btnFooterMode.setOnClickListener { - val items = footerModes.values.toList() - context?.selector(items = items) { _, index -> - val selectedKey = footerModes.keys.toList()[index] - ReadTipConfig.footerMode = selectedKey - binding.btnFooterMode.text = footerModes[selectedKey] - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - } - - llHeaderLeft.setOnClickListener { - context?.selector(items = ReadTipConfig.tipNames) { _, i -> - val tipValue = ReadTipConfig.tipValues[i] - clearRepeat(tipValue) - ReadTipConfig.tipHeaderLeft = tipValue - tvHeaderLeft.text = ReadTipConfig.tipNames[i] - postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) - } - } - llHeaderMiddle.setOnClickListener { - context?.selector(items = ReadTipConfig.tipNames) { _, i -> - val tipValue = ReadTipConfig.tipValues[i] - clearRepeat(tipValue) - ReadTipConfig.tipHeaderMiddle = tipValue - tvHeaderMiddle.text = ReadTipConfig.tipNames[i] - postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) - } - } - llHeaderRight.setOnClickListener { - context?.selector(items = ReadTipConfig.tipNames) { _, i -> - val tipValue = ReadTipConfig.tipValues[i] - clearRepeat(tipValue) - ReadTipConfig.tipHeaderRight = tipValue - tvHeaderRight.text = ReadTipConfig.tipNames[i] - postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) - } - } - llFooterLeft.setOnClickListener { - context?.selector(items = ReadTipConfig.tipNames) { _, i -> - val tipValue = ReadTipConfig.tipValues[i] - clearRepeat(tipValue) - ReadTipConfig.tipFooterLeft = tipValue - tvFooterLeft.text = ReadTipConfig.tipNames[i] - postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) - } - } - llFooterMiddle.setOnClickListener { - context?.selector(items = ReadTipConfig.tipNames) { _, i -> - val tipValue = ReadTipConfig.tipValues[i] - clearRepeat(tipValue) - ReadTipConfig.tipFooterMiddle = tipValue - tvFooterMiddle.text = ReadTipConfig.tipNames[i] - postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) - } - } - llFooterRight.setOnClickListener { - context?.selector(items = ReadTipConfig.tipNames) { _, i -> - val tipValue = ReadTipConfig.tipValues[i] - clearRepeat(tipValue) - ReadTipConfig.tipFooterRight = tipValue - tvFooterRight.text = ReadTipConfig.tipNames[i] - postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) - } - } - btnHeaderColor.setOnClickListener { - context?.selector(items = ReadTipConfig.tipColorNames) { _, i -> - when (i) { - 0 -> { - ReadTipConfig.tipHeaderColor = 0 - upTvHeaderColor() - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - - 1 -> ColorPickerDialog.newBuilder() - .setShowAlphaSlider(false) - .setDialogType(ColorPickerDialog.TYPE_CUSTOM) - .setDialogId(TipConfigDialog.TIP_HEADER_COLOR) - .show(requireActivity()) - } - } - } - btnFooterColor.setOnClickListener { - context?.selector(items = ReadTipConfig.tipColorNames) { _, i -> - when (i) { - 0 -> { - ReadTipConfig.tipFooterColor = 0 - upTvFooterColor() - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - - 1 -> ColorPickerDialog.newBuilder() - .setShowAlphaSlider(false) - .setDialogType(ColorPickerDialog.TYPE_CUSTOM) - .setDialogId(TipConfigDialog.TIP_FOOTER_COLOR) - .show(requireActivity()) - } - } - } - btnDividerColor.setOnClickListener { - context?.selector(items = ReadTipConfig.tipDividerColorNames) { _, i -> - when (i) { - 0, 1 -> { - ReadTipConfig.tipDividerColor = i - 1 - upTvTipDividerColor() - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - - 2 -> ColorPickerDialog.newBuilder() - .setShowAlphaSlider(false) - .setDialogType(ColorPickerDialog.TYPE_CUSTOM) - .setDialogId(TIP_DIVIDER_COLOR) - .show(requireActivity()) - } - } - } - btnHeaderFont.setOnClickListener { - FontSelectDialog().show(childFragmentManager, "headerFontSelect") - } - binding.scvHeaderFontSize.onChanged = { - ReadBookConfig.headerFontSize = it - ReadBookConfig.save() - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - } - - private fun clearRepeat(repeat: Int) = ReadTipConfig.apply { - if (repeat != none) { - if (tipHeaderLeft == repeat) { - tipHeaderLeft = none - binding.tvHeaderLeft.text = tipNames[none] - } - if (tipHeaderMiddle == repeat) { - tipHeaderMiddle = none - binding.tvHeaderMiddle.text = tipNames[none] - } - if (tipHeaderRight == repeat) { - tipHeaderRight = none - binding.tvHeaderRight.text = tipNames[none] - } - if (tipFooterLeft == repeat) { - tipFooterLeft = none - binding.tvFooterLeft.text = tipNames[none] - } - if (tipFooterMiddle == repeat) { - tipFooterMiddle = none - binding.tvFooterMiddle.text = tipNames[none] - } - if (tipFooterRight == repeat) { - tipFooterRight = none - binding.tvFooterRight.text = tipNames[none] - } - } - } - -} diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/MoreConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/MoreConfigDialog.kt deleted file mode 100644 index cfd3caafa..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/MoreConfigDialog.kt +++ /dev/null @@ -1,188 +0,0 @@ -package io.legado.app.ui.book.read.config - -//import io.legado.app.lib.theme.bottomBackground -//import io.legado.app.lib.theme.primaryColor -import android.annotation.SuppressLint -import android.content.DialogInterface -import android.content.SharedPreferences -import android.os.Bundle -import android.view.View -import android.view.ViewConfiguration -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import io.legado.app.R -import io.legado.app.base.BaseBottomSheetDialogFragment -import io.legado.app.constant.EventBus -import io.legado.app.constant.PreferKey -import io.legado.app.help.config.AppConfig -import io.legado.app.help.config.ReadBookConfig -import io.legado.app.model.ReadBook -import io.legado.app.ui.book.read.ReadBookActivity -import io.legado.app.ui.book.read.page.provider.ChapterProvider -import io.legado.app.ui.widget.number.NumberPickerDialog -import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory -import io.legado.app.utils.getPrefBoolean -import io.legado.app.utils.postEvent -import io.legado.app.utils.removePref - -class MoreConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_more_config) { - private val readPreferTag = "readPreferenceFragment" - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - (activity as? ReadBookActivity)?.bottomDialog++ - var preferenceFragment = childFragmentManager.findFragmentByTag(readPreferTag) - if (preferenceFragment == null) preferenceFragment = ReadPreferenceFragment() - childFragmentManager.beginTransaction() - .replace(R.id.containerPreferences, preferenceFragment, readPreferTag) - .commit() - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - (activity as ReadBookActivity).bottomDialog-- - } - - class ReadPreferenceFragment : PreferenceFragmentCompat(), - SharedPreferences.OnSharedPreferenceChangeListener { - - private val slopSquare by lazy { ViewConfiguration.get(requireContext()).scaledTouchSlop } - - @SuppressLint("RestrictedApi") - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_config_read) - upPreferenceSummary(PreferKey.menuAlpha, AppConfig.menuAlpha.toString()) - upPreferenceSummary(PreferKey.pageTouchSlop, slopSquare.toString()) - if (!CanvasRecorderFactory.isSupport) { - removePref(PreferKey.optimizeRender) - preferenceScreen.removePreferenceRecursively(PreferKey.optimizeRender) - } - } - - override fun onResume() { - super.onResume() - preferenceManager - .sharedPreferences - ?.registerOnSharedPreferenceChangeListener(this) - } - - override fun onPause() { - preferenceManager - .sharedPreferences - ?.unregisterOnSharedPreferenceChangeListener(this) - super.onPause() - } - - override fun onSharedPreferenceChanged( - sharedPreferences: SharedPreferences?, - key: String? - ) { - when (key) { - PreferKey.readBodyToLh -> activity?.recreate() - PreferKey.hideStatusBar -> { - ReadBookConfig.hideStatusBar = getPrefBoolean(PreferKey.hideStatusBar) - postEvent(EventBus.UP_CONFIG, arrayListOf(0, 2)) - } - - PreferKey.hideNavigationBar -> { - ReadBookConfig.hideNavigationBar = getPrefBoolean(PreferKey.hideNavigationBar) - postEvent(EventBus.UP_CONFIG, arrayListOf(0, 2)) - } - - PreferKey.keepLight -> postEvent(key, true) - PreferKey.readSliderMode, PreferKey.titleBarMode -> postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) - PreferKey.textSelectAble -> postEvent(key, getPrefBoolean(key)) - PreferKey.screenOrientation -> { - (activity as? ReadBookActivity)?.setOrientation() - } - - PreferKey.textFullJustify, - PreferKey.textBottomJustify, - PreferKey.useZhLayout, PreferKey.adaptSpecialStyle, PreferKey.useUnderline -> { - postEvent(EventBus.UP_CONFIG, arrayListOf(5)) - } - - PreferKey.showBrightnessView -> { - postEvent(PreferKey.showBrightnessView, "") - } - - PreferKey.expandTextMenu -> { - (activity as? ReadBookActivity)?.textActionMenu?.upMenu() - } - - PreferKey.doublePageHorizontal -> { - ChapterProvider.upLayout() - ReadBook.loadContent(false) - } - - PreferKey.showReadTitleAddition, - PreferKey.readBarStyleFollowPage -> { - postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) - } - - PreferKey.progressBarBehavior -> { - postEvent(EventBus.UP_SEEK_BAR, true) - } - - PreferKey.noAnimScrollPage -> { - ReadBook.callBack?.upPageAnim() - } - - PreferKey.optimizeRender -> { - ChapterProvider.upStyle() - ReadBook.callBack?.upPageAnim(true) - ReadBook.loadContent(false) - } - - PreferKey.paddingDisplayCutouts -> { - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - } - } - - override fun onPreferenceTreeClick(preference: Preference): Boolean { - when (preference.key) { - "customPageKey" -> PageKeyDialog(requireContext()).show() - "clickRegionalConfig" -> { - (activity as? ReadBookActivity)?.showClickRegionalConfig() - } - - PreferKey.menuAlpha -> { - NumberPickerDialog(requireContext()) - .setTitle(getString(R.string.menu_alpha)) - .setMaxValue(100) - .setMinValue(0) - .setValue(AppConfig.menuAlpha) - .show { - AppConfig.menuAlpha = it - upPreferenceSummary(PreferKey.menuAlpha, it.toString()) - postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) - } - } - - PreferKey.pageTouchSlop -> { - NumberPickerDialog(requireContext()) - .setTitle(getString(R.string.page_touch_slop_dialog_title)) - .setMaxValue(9999) - .setMinValue(0) - .setValue(AppConfig.pageTouchSlop) - .show { - AppConfig.pageTouchSlop = it - postEvent(EventBus.UP_CONFIG, arrayListOf(4)) - } - } - } - return super.onPreferenceTreeClick(preference) - } - - @Suppress("SameParameterValue") - private fun upPreferenceSummary(preferenceKey: String, value: String?) { - val preference = findPreference(preferenceKey) ?: return - when (preferenceKey) { - PreferKey.menuAlpha -> preference.summary = - getString(R.string.menu_alpha_sum, AppConfig.menuAlpha) - PreferKey.pageTouchSlop -> preference.summary = - getString(R.string.page_touch_slop_summary, value) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/PaddingConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/PaddingConfigDialog.kt deleted file mode 100644 index 85ff4a7ae..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/PaddingConfigDialog.kt +++ /dev/null @@ -1,119 +0,0 @@ -package io.legado.app.ui.book.read.config - -import android.content.DialogInterface -import android.os.Bundle -import android.view.View -import io.legado.app.R -import io.legado.app.base.BaseDialogFragment -import io.legado.app.constant.EventBus -import io.legado.app.databinding.DialogReadPaddingBinding -import io.legado.app.help.config.ReadBookConfig -import io.legado.app.utils.postEvent -import io.legado.app.utils.setLayout -import io.legado.app.utils.viewbindingdelegate.viewBinding - -class PaddingConfigDialog : BaseDialogFragment(R.layout.dialog_read_padding) { - - private val binding by viewBinding(DialogReadPaddingBinding::bind) - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - initData() - initView() - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - ReadBookConfig.save() - } - - override fun onStart() { - super.onStart() - if (resources.configuration.smallestScreenWidthDp > 600) - setLayout(0.6f, 0.6f) - else - setLayout(0.9f, 0.8f) - } - - private fun initData() = binding.run { - //正文 - dsbPaddingTop.progress = ReadBookConfig.paddingTop - dsbPaddingBottom.progress = ReadBookConfig.paddingBottom - dsbPaddingLeft.progress = ReadBookConfig.paddingLeft - dsbPaddingRight.progress = ReadBookConfig.paddingRight - //页眉 - dsbHeaderPaddingTop.progress = ReadBookConfig.headerPaddingTop - dsbHeaderPaddingBottom.progress = ReadBookConfig.headerPaddingBottom - dsbHeaderPaddingLeft.progress = ReadBookConfig.headerPaddingLeft - dsbHeaderPaddingRight.progress = ReadBookConfig.headerPaddingRight - //页脚 - dsbFooterPaddingTop.progress = ReadBookConfig.footerPaddingTop - dsbFooterPaddingBottom.progress = ReadBookConfig.footerPaddingBottom - dsbFooterPaddingLeft.progress = ReadBookConfig.footerPaddingLeft - dsbFooterPaddingRight.progress = ReadBookConfig.footerPaddingRight - cbShowTopLine.isChecked = ReadBookConfig.showHeaderLine - cbShowBottomLine.isChecked = ReadBookConfig.showFooterLine - } - - private fun initView() = binding.run { - //正文 - dsbPaddingTop.onChanged = { - ReadBookConfig.paddingTop = it - postEvent(EventBus.UP_CONFIG, arrayListOf(10, 5)) - } - dsbPaddingBottom.onChanged = { - ReadBookConfig.paddingBottom = it - postEvent(EventBus.UP_CONFIG, arrayListOf(10, 5)) - } - dsbPaddingLeft.onChanged = { - ReadBookConfig.paddingLeft = it - postEvent(EventBus.UP_CONFIG, arrayListOf(10, 5)) - } - dsbPaddingRight.onChanged = { - ReadBookConfig.paddingRight = it - postEvent(EventBus.UP_CONFIG, arrayListOf(10, 5)) - } - //页眉 - dsbHeaderPaddingTop.onChanged = { - ReadBookConfig.headerPaddingTop = it - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - dsbHeaderPaddingBottom.onChanged = { - ReadBookConfig.headerPaddingBottom = it - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - dsbHeaderPaddingLeft.onChanged = { - ReadBookConfig.headerPaddingLeft = it - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - dsbHeaderPaddingRight.onChanged = { - ReadBookConfig.headerPaddingRight = it - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - //页脚 - dsbFooterPaddingTop.onChanged = { - ReadBookConfig.footerPaddingTop = it - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - dsbFooterPaddingBottom.onChanged = { - ReadBookConfig.footerPaddingBottom = it - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - dsbFooterPaddingLeft.onChanged = { - ReadBookConfig.footerPaddingLeft = it - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - dsbFooterPaddingRight.onChanged = { - ReadBookConfig.footerPaddingRight = it - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - cbShowTopLine.setOnCheckedChangeListener { _, isChecked -> - ReadBookConfig.showHeaderLine = isChecked - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - cbShowBottomLine.setOnCheckedChangeListener { _, isChecked -> - ReadBookConfig.showFooterLine = isChecked - postEvent(EventBus.UP_CONFIG, arrayListOf(2)) - } - } - -} diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/PageKeyDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/PageKeyDialog.kt deleted file mode 100644 index f34f01622..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/PageKeyDialog.kt +++ /dev/null @@ -1,72 +0,0 @@ -package io.legado.app.ui.book.read.config - -import android.app.Dialog -import android.content.Context -import android.view.KeyEvent -import android.view.ViewGroup -import io.legado.app.constant.PreferKey -import io.legado.app.databinding.DialogPageKeyBinding -//import io.legado.app.lib.theme.backgroundColor -import io.legado.app.utils.getPrefString -import io.legado.app.utils.hideSoftInput -import io.legado.app.utils.putPrefString -import io.legado.app.utils.setLayout -import splitties.views.onClick - - -class PageKeyDialog(context: Context) : Dialog(context) { - - private val binding = DialogPageKeyBinding.inflate(layoutInflater) - - override fun onStart() { - super.onStart() - setLayout(0.9f, ViewGroup.LayoutParams.WRAP_CONTENT) - } - - init { - setContentView(binding.root) - binding.run { - //contentView.setBackgroundColor(context.backgroundColor) - etPrev.setText(context.getPrefString(PreferKey.prevKeys)) - etNext.setText(context.getPrefString(PreferKey.nextKeys)) - tvReset.onClick { - etPrev.setText("") - etNext.setText("") - } - tvOk.setOnClickListener { - context.putPrefString(PreferKey.prevKeys, etPrev.text?.toString()) - context.putPrefString(PreferKey.nextKeys, etNext.text?.toString()) - dismiss() - } - } - } - - override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { - if (keyCode != KeyEvent.KEYCODE_BACK && keyCode != KeyEvent.KEYCODE_DEL) { - if (binding.etPrev.hasFocus()) { - val editableText = binding.etPrev.editableText - if (editableText.isEmpty() or editableText.endsWith(",")) { - editableText.append(keyCode.toString()) - } else { - editableText.append(",").append(keyCode.toString()) - } - return true - } else if (binding.etNext.hasFocus()) { - val editableText = binding.etNext.editableText - if (editableText.isEmpty() or editableText.endsWith(",")) { - editableText.append(keyCode.toString()) - } else { - editableText.append(",").append(keyCode.toString()) - } - return true - } - } - return super.onKeyDown(keyCode, event) - } - - override fun dismiss() { - super.dismiss() - currentFocus?.hideSoftInput() - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudConfigDialog.kt deleted file mode 100644 index 8d15ab380..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudConfigDialog.kt +++ /dev/null @@ -1,211 +0,0 @@ -package io.legado.app.ui.book.read.config - -//import io.legado.app.lib.theme.backgroundColor -//import io.legado.app.lib.theme.primaryColor -// 【新增引用】为了显示清理成功的提示 -import android.content.SharedPreferences -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.LinearLayout -import androidx.preference.ListPreference -import androidx.preference.Preference -import io.legado.app.R -import io.legado.app.base.BasePrefDialogFragment -import io.legado.app.constant.EventBus -import io.legado.app.constant.PreferKey -import io.legado.app.data.appDb -import io.legado.app.help.IntentHelp -import io.legado.app.help.config.AppConfig -import io.legado.app.lib.dialogs.SelectItem -import io.legado.app.lib.prefs.SwitchPreference -import io.legado.app.lib.prefs.fragment.PreferenceFragment -import io.legado.app.model.ReadAloud -import io.legado.app.service.BaseReadAloudService -import io.legado.app.ui.widget.number.NumberPickerDialog -import io.legado.app.utils.GSON -import io.legado.app.utils.StringUtils -import io.legado.app.utils.TTSCacheUtils -import io.legado.app.utils.fromJsonObject -import io.legado.app.utils.postEvent -import io.legado.app.utils.putPrefInt -import io.legado.app.utils.showDialogFragment -import io.legado.app.utils.toastOnUi - -class ReadAloudConfigDialog : BasePrefDialogFragment() { - private val readAloudPreferTag = "readAloudPreferTag" - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val view = LinearLayout(requireContext()) - //view.setBackgroundColor(requireContext().backgroundColor) - view.id = R.id.tag1 - container?.addView(view) - return view - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - var preferenceFragment = childFragmentManager.findFragmentByTag(readAloudPreferTag) - if (preferenceFragment == null) preferenceFragment = ReadAloudPreferenceFragment() - childFragmentManager.beginTransaction() - .replace(view.id, preferenceFragment, readAloudPreferTag) - .commit() - } - - class ReadAloudPreferenceFragment : PreferenceFragment(), - SpeakEngineDialog.CallBack, - SharedPreferences.OnSharedPreferenceChangeListener { - - private val speakEngineSummary: String - get() { - val ttsEngine = ReadAloud.ttsEngine - ?: return getString(R.string.system_tts) - if (StringUtils.isNumeric(ttsEngine)) { - return appDb.httpTTSDao.getName(ttsEngine.toLong()) - ?: getString(R.string.system_tts) - } - return GSON.fromJsonObject>(ttsEngine).getOrNull()?.title - ?: getString(R.string.system_tts) - } - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_config_aloud) - upSpeakEngineSummary() - upPreferenceSummary(PreferKey.audioPreDownloadNum) - upPreferenceSummary(PreferKey.audioCacheCleanTime) - findPreference(PreferKey.pauseReadAloudWhilePhoneCalls)?.let { - it.isEnabled = AppConfig.ignoreAudioFocus - } - - findPreference("clear_cache")?.let { - it.summary = getString(R.string.clear_cache) - it.setOnPreferenceClickListener { - TTSCacheUtils.clearTtsCache() - toastOnUi("音频缓存已清理") - true - } - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - //listView.setEdgeEffectColor(primaryColor) - } - - override fun onResume() { - super.onResume() - preferenceManager.sharedPreferences?.registerOnSharedPreferenceChangeListener(this) - } - - override fun onPause() { - preferenceManager.sharedPreferences?.unregisterOnSharedPreferenceChangeListener(this) - super.onPause() - } - - override fun onPreferenceTreeClick(preference: Preference): Boolean { - when (preference.key) { - PreferKey.audioPreDownloadNum -> { - NumberPickerDialog(requireContext()) - .setTitle(getString(R.string.read_aloud_preload)) - .setMaxValue(50) - .setMinValue(0) - .setValue(10) - .setCustomButton((R.string.btn_default_s)) { - putPrefInt(PreferKey.audioPreDownloadNum, 10) - upPreferenceSummary(PreferKey.audioPreDownloadNum) - } - .show { - putPrefInt(PreferKey.audioPreDownloadNum, it) - upPreferenceSummary(PreferKey.audioPreDownloadNum) - } - } - - PreferKey.audioCacheCleanTime -> { - NumberPickerDialog(requireContext()) - .setTitle(getString(R.string.audio_cache_clean_time)) - .setMaxValue(50) - .setMinValue(0) - .setValue(1) - .setCustomButton((R.string.btn_default_s)) { - putPrefInt(PreferKey.audioCacheCleanTime, 10) - upPreferenceSummary(PreferKey.audioCacheCleanTime) - } - .show { - putPrefInt(PreferKey.audioCacheCleanTime, it) - upPreferenceSummary(PreferKey.audioCacheCleanTime) - } - } - - PreferKey.ttsEngine -> showDialogFragment(SpeakEngineDialog()) - "sysTtsConfig" -> IntentHelp.openTTSSetting() - } - return super.onPreferenceTreeClick(preference) - } - - override fun onSharedPreferenceChanged( - sharedPreferences: SharedPreferences?, - key: String? - ) { - when (key) { - PreferKey.readAloudByPage, PreferKey.streamReadAloudAudio -> { - if (BaseReadAloudService.isRun) { - postEvent(EventBus.MEDIA_BUTTON, false) - } - } - - PreferKey.ignoreAudioFocus -> { - findPreference(PreferKey.pauseReadAloudWhilePhoneCalls)?.let { - it.isEnabled = AppConfig.ignoreAudioFocus - } - } - } - } - - private fun upPreferenceSummary(preference: Preference?, value: String) { - when (preference) { - is ListPreference -> { - val index = preference.findIndexOfValue(value) - preference.summary = if (index >= 0) preference.entries[index] else null - } - - - else -> { - preference?.summary = value - } - } - } - - private fun upPreferenceSummary(preferenceKey: String, value: String? = null) { - val preference = findPreference(preferenceKey) ?: return - when (preferenceKey) { - PreferKey.audioPreDownloadNum -> { - preference.summary = getString( - R.string.read_aloud_preload_summary, - AppConfig.audioPreDownloadNum - ) - } - - PreferKey.audioCacheCleanTime -> { - preference.summary = getString( - R.string.audio_cache_clean_time_summary, - AppConfig.audioCacheCleanTimeOrgin - ) - } - - else -> preference.summary = value - } - } - - override fun upSpeakEngineSummary() { - upPreferenceSummary( - findPreference(PreferKey.ttsEngine), - speakEngineSummary - ) - } - } -} diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudDialog.kt deleted file mode 100644 index 631765bb2..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/ReadAloudDialog.kt +++ /dev/null @@ -1,261 +0,0 @@ -package io.legado.app.ui.book.read.config - -//import io.legado.app.lib.theme.bottomBackground -//import io.legado.app.lib.theme.getPrimaryTextColor -import android.annotation.SuppressLint -import android.content.DialogInterface -import android.os.Bundle -import android.view.View -import androidx.core.content.ContextCompat -import com.google.android.material.slider.Slider -import io.legado.app.R -import io.legado.app.base.BaseBottomSheetDialogFragment -import io.legado.app.constant.EventBus -import io.legado.app.databinding.DialogReadAloudBinding -import io.legado.app.help.config.AppConfig -import io.legado.app.lib.dialogs.selector -import io.legado.app.model.ReadAloud -import io.legado.app.model.ReadBook -import io.legado.app.service.BaseReadAloudService -import io.legado.app.ui.book.read.ReadBookActivity -import io.legado.app.utils.getPrefBoolean -import io.legado.app.utils.observeEvent -import io.legado.app.utils.toastOnUi -import io.legado.app.utils.viewbindingdelegate.viewBinding -import io.legado.app.utils.visible - - -class ReadAloudDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_aloud) { - private val callBack: CallBack? get() = activity as? CallBack - private val binding by viewBinding(DialogReadAloudBinding::bind) - - override fun onStart() { - super.onStart() -// dialog?.window?.run { -// clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) -// setBackgroundDrawableResource(R.color.background) -// decorView.setPadding(0, 0, 0, 0) -// val attr = attributes -// attr.dimAmount = 0.0f -// attr.gravity = Gravity.BOTTOM -// attributes = attr -// setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) -// } - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - (activity as ReadBookActivity).bottomDialog-- - } - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - val bottomDialog = (activity as ReadBookActivity).bottomDialog++ - if (bottomDialog > 0) { - dismiss() - return - } - //val bg = requireContext().bottomBackground - //val isLight = ColorUtils.isColorLight(bg) - //val textColor = requireContext().getPrimaryTextColor(isLight) - binding.run { -// rootView.setBackgroundColor(bg) -// tvPre.setTextColor(textColor) -// tvNext.setTextColor(textColor) -// ivPlayPrev.setColorFilter(textColor) -// ivPlayPause.setColorFilter(textColor) -// ivPlayNext.setColorFilter(textColor) -// ivStop.setColorFilter(textColor) -// ivTimer.setColorFilter(textColor) -// tvTimer.setTextColor(textColor) -// ivTtsSpeechReduce.setColorFilter(textColor) -// tvTtsSpeed.setTextColor(textColor) -// tvTtsSpeedValue.setTextColor(textColor) -// ivTtsSpeechAdd.setColorFilter(textColor) -// ivCatalog.setColorFilter(textColor) -// tvCatalog.setTextColor(textColor) -// ivMainMenu.setColorFilter(textColor) -// tvMainMenu.setTextColor(textColor) -// ivToBackstage.setColorFilter(textColor) -// tvToBackstage.setTextColor(textColor) -// ivSetting.setColorFilter(textColor) -// tvSetting.setTextColor(textColor) -// cbTtsFollowSys.setTextColor(textColor) - } - initData() - initEvent() - } - - private fun initData() = binding.run { - upPlayState() - upTimerText(BaseReadAloudService.timeMinute) - cbTtsFollowSys.isChecked = requireContext().getPrefBoolean("ttsFollowSys", true) - upTtsSpeechRateEnabled(!cbTtsFollowSys.isChecked) - upSeekTimer() - } - - private fun initEvent() = binding.run { - ivMainMenu.setOnClickListener { - callBack?.showMenuBar() - dismissAllowingStateLoss() - } - ivSetting.setOnClickListener { - ReadAloudConfigDialog().show(childFragmentManager, "readAloudConfigDialog") - } - tvPre.setOnClickListener { ReadBook.moveToPrevChapter(upContent = true, toLast = false) } - tvNext.setOnClickListener { ReadBook.moveToNextChapter(true) } - ivStop.setOnClickListener { - ReadAloud.stop(requireContext()) - dismissAllowingStateLoss() - } - ivPlayPause.setOnClickListener { callBack?.onClickReadAloud() } - ivPlayPrev.setOnClickListener { ReadAloud.prevParagraph(requireContext()) } - ivPlayNext.setOnClickListener { ReadAloud.nextParagraph(requireContext()) } - ivCatalog.setOnClickListener { callBack?.openChapterList() } - ivToBackstage.setOnClickListener { callBack?.finish() } - cbTtsFollowSys.setOnCheckedChangeListener { _, isChecked -> - AppConfig.ttsFlowSys = isChecked - upTtsSpeechRateEnabled(!isChecked) - upTtsSpeechRate() - } - - ivTimer.setOnClickListener { - AppConfig.ttsTimer = seekTimer.value.toInt() - toastOnUi("保存设定时间成功!") - } - - // 设置初始值 - seekTtsSpeechRate.value = AppConfig.ttsSpeechRate.toFloat() - seekTimer.value = if (BaseReadAloudService.timeMinute > 0) - BaseReadAloudService.timeMinute.toFloat() - else AppConfig.ttsTimer.toFloat() - - // 减速按钮逻辑 - ivTtsSpeechReduce.setOnClickListener { - val newValue = (seekTtsSpeechRate.value - 1).coerceAtLeast(seekTtsSpeechRate.valueFrom) - seekTtsSpeechRate.value = newValue - AppConfig.ttsSpeechRate = newValue.toInt() - upTtsSpeechRateText(newValue.toInt()) - upTtsSpeechRate() - } - - // 加速按钮逻辑 - ivTtsSpeechAdd.setOnClickListener { - val newValue = (seekTtsSpeechRate.value + 1).coerceAtMost(seekTtsSpeechRate.valueTo) - seekTtsSpeechRate.value = newValue - AppConfig.ttsSpeechRate = newValue.toInt() - upTtsSpeechRateText(newValue.toInt()) - upTtsSpeechRate() - } - - btnTimer.setOnClickListener { - val times = intArrayOf(0, 5, 10, 15, 30, 60, 90, 180) - val timeKeys = times.map { "$it 分钟" } - context?.selector("设定时间", timeKeys) { _, index -> - ReadAloud.setTimer(requireContext(), times[index]) - upTimerText(times[index]) - } - } - - //设置保存的默认值 - seekTtsSpeechRate.addOnChangeListener { _, value, fromUser -> - if (fromUser) { - upTtsSpeechRateText(value.toInt()) - } - } - - seekTtsSpeechRate.addOnSliderTouchListener(object : Slider.OnSliderTouchListener { - override fun onStartTrackingTouch(slider: Slider) {} - override fun onStopTrackingTouch(slider: Slider) { - AppConfig.ttsSpeechRate = slider.value.toInt() - upTtsSpeechRate() - } - }) - - seekTimer.addOnChangeListener { _, value, fromUser -> - if (fromUser) { - upTimerText(value.toInt()) - } - } - - seekTimer.addOnSliderTouchListener(object : Slider.OnSliderTouchListener { - override fun onStartTrackingTouch(slider: Slider) {} - override fun onStopTrackingTouch(slider: Slider) { - ReadAloud.setTimer(requireContext(), slider.value.toInt()) - } - }) - - } - - private fun upTtsSpeechRateEnabled(enabled: Boolean) { - binding.run { - upTtsSpeechRateText(AppConfig.ttsSpeechRate) - tvTtsSpeedValue.visible(enabled) - seekTtsSpeechRate.isEnabled = enabled - ivTtsSpeechReduce.isEnabled = enabled - ivTtsSpeechAdd.isEnabled = enabled - } - } - - private fun upPlayState() { - if (!BaseReadAloudService.pause) { - binding.ivPlayPause.icon = - ContextCompat.getDrawable(requireContext(), R.drawable.ic_pause) - binding.ivPlayPause.contentDescription = getString(R.string.pause) - } else { - binding.ivPlayPause.icon = - ContextCompat.getDrawable(requireContext(), R.drawable.ic_play) - binding.ivPlayPause.contentDescription = getString(R.string.audio_play) - } - - // val bg = requireContext().bottomBackground - // val isLight = ColorUtils.isColorLight(bg) - // val textColor = requireContext().getPrimaryTextColor(isLight) - // binding.ivPlayPause.iconTint = ColorStateList.valueOf(textColor) - } - - private fun upSeekTimer() { - binding.seekTimer.post { - binding.seekTimer.value = if (BaseReadAloudService.timeMinute > 0) { - BaseReadAloudService.timeMinute.toFloat() - } else { - AppConfig.ttsTimer.toFloat() - } - } - } - - private fun upTimerText(timeMinute: Int) { - if (timeMinute < 0) { - binding.btnTimer.text = requireContext().getString(R.string.timer_m, 0) - } else { - binding.btnTimer.text = requireContext().getString(R.string.timer_m, timeMinute) - } - } - - @SuppressLint("SetTextI18n") - private fun upTtsSpeechRateText(value: Int) { - binding.tvTtsSpeedValue.text = value.toString() - } - - private fun upTtsSpeechRate() { - ReadAloud.upTtsSpeechRate(requireContext()) - if (!BaseReadAloudService.pause) { - ReadAloud.pause(requireContext()) - ReadAloud.resume(requireContext()) - } - } - - override fun observeLiveBus() { - observeEvent(EventBus.ALOUD_STATE) { upPlayState() } - observeEvent(EventBus.READ_ALOUD_DS) { - val value = it.coerceIn(binding.seekTimer.valueFrom.toInt(), binding.seekTimer.valueTo.toInt()) - binding.seekTimer.value = value.toFloat() - } - } - - interface CallBack { - fun showMenuBar() - fun openChapterList() - fun onClickReadAloud() - fun finish() - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/ReadStyleDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/ReadStyleDialog.kt deleted file mode 100644 index e33c80252..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/ReadStyleDialog.kt +++ /dev/null @@ -1,246 +0,0 @@ -package io.legado.app.ui.book.read.config - -import android.content.DialogInterface -import android.os.Bundle -import android.view.Gravity -import android.view.View -import android.view.ViewGroup -import androidx.core.view.get -import com.github.liuyueyi.quick.transfer.constants.TransType -import io.legado.app.R -import io.legado.app.base.BaseBottomSheetDialogFragment -import io.legado.app.base.adapter.ItemViewHolder -import io.legado.app.base.adapter.RecyclerAdapter -import io.legado.app.constant.EventBus -import io.legado.app.databinding.DialogReadBookStyleBinding -import io.legado.app.databinding.ItemReadStyleBinding -import io.legado.app.help.config.AppConfig -import io.legado.app.help.config.OldThemeConfig -import io.legado.app.help.config.ReadBookConfig -import io.legado.app.lib.dialogs.alert -import io.legado.app.model.ReadBook -import io.legado.app.ui.book.read.ReadBookActivity -import io.legado.app.utils.ChineseUtils -import io.legado.app.utils.dpToPx -import io.legado.app.utils.postEvent -import io.legado.app.utils.showDialogFragment -import io.legado.app.utils.viewbindingdelegate.viewBinding - -class ReadStyleDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_book_style), - FontConfigDialog.CallBack { - - private val binding by viewBinding(DialogReadBookStyleBinding::bind) - private val callBack get() = activity as? ReadBookActivity - private lateinit var styleAdapter: StyleAdapter - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - (activity as ReadBookActivity).bottomDialog++ - initView() - initData() - initViewEvent() - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - ReadBookConfig.save() - (activity as ReadBookActivity).bottomDialog-- - } - - private fun initView() = binding.run { - if (AppConfig.isNightTheme) { - tvDayNight.setIconResource(R.drawable.ic_daytime) - } else { - tvDayNight.setIconResource(R.drawable.ic_brightness) - } - dsbTextSize.valueFormat = { - (it + 5).toString() - } - - styleAdapter = StyleAdapter() - rvStyle.adapter = styleAdapter - styleAdapter.addFooterView { - ItemReadStyleBinding.inflate(layoutInflater, it, false).apply { - tvStyle.text = "" - cdStyle.cardElevation = 0f - cdStyle.radius = 8f.dpToPx() - cdStyle.strokeWidth = 1.dpToPx() - ivStyle.setImageResource(R.drawable.ic_add) - ivStyle.setPadding(12.dpToPx(),12.dpToPx(),12.dpToPx(),12.dpToPx()) - root.setOnClickListener { - ReadBookConfig.configList.add(ReadBookConfig.Config()) - showBgTextConfig(ReadBookConfig.configList.lastIndex) - } - } - } - - - } - - private fun initData() { - binding.cbShareLayout.isChecked = ReadBookConfig.shareLayout - upView() - styleAdapter.setItems(ReadBookConfig.configList) - } - - private fun updateChineseIcon() { - val text = when (AppConfig.chineseConverterType) { - 1 -> " 简" - 2 -> " 繁" - else -> null - } - binding.btnChineseConverter.text = text - } - - - private fun initViewEvent() = binding.run { - updateChineseIcon() - btnChineseConverter.setOnClickListener { - alert(titleResource = R.string.chinese_converter) { - items(resources.getStringArray(R.array.chinese_mode).toList()) { _, i -> - AppConfig.chineseConverterType = i - ChineseUtils.unLoad(*TransType.entries.toTypedArray()) - postEvent(EventBus.UP_CONFIG, arrayListOf(5)) - updateChineseIcon() - } - } - } - - tvTextFont.setOnClickListener { - callBack?.showFont() - } - - tvPadding.setOnClickListener { - callBack?.showInfoConfig() - } - tvTip.setOnClickListener { - TipConfigDialog().show(childFragmentManager, "tipConfigDialog") - } - tvMore.setOnClickListener { - showDialogFragment() - } - tvDayNight.setOnClickListener { - AppConfig.isNightTheme = !AppConfig.isNightTheme - OldThemeConfig.applyDayNight(requireContext()) - } -// rgPageAnim.setOnCheckedChangeListener { _, checkedId -> -// ReadBook.book?.setPageAnim(-1) -// ReadBookConfig.pageAnim = binding.rgPageAnim.getIndexById(checkedId) -// callBack?.upPageAnim() -// ReadBook.loadContent(false) -// } - binding.rgPageAnim.setOnCheckedStateChangeListener { group, checkedIds -> - val checkedId = checkedIds.firstOrNull() ?: return@setOnCheckedStateChangeListener - ReadBook.book?.setPageAnim(-1) - ReadBookConfig.pageAnim = when (checkedId) { - R.id.rb_anim0 -> 0 // 覆盖动画 - R.id.rb_anim1 -> 1 // 滑动动画 - R.id.rb_simulation_anim -> 2 // 仿真翻页 - R.id.rb_scroll_anim -> 3 // 滚动动画 - R.id.rb_fade_anim -> 4 - R.id.rb_no_anim -> 5 // 无动画 - else -> 0 - } - callBack?.upPageAnim() - ReadBook.loadContent(false) - } - - cbShareLayout.addOnCheckedChangeListener { _, isChecked -> - ReadBookConfig.shareLayout = isChecked - upView() - postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5)) - } - - dsbTextSize.onChanged = { - ReadBookConfig.textSize = it + 5 - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - } - - private fun changeBgTextConfig(index: Int) { - val oldIndex = ReadBookConfig.styleSelect - if (index != oldIndex) { - ReadBookConfig.styleSelect = index - upView() - styleAdapter.notifyItemChanged(oldIndex) - styleAdapter.notifyItemChanged(index) - postEvent(EventBus.UP_CONFIG, arrayListOf(1, 2, 5)) - if (AppConfig.readBarStyleFollowPage) { - postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) - } - } - } - - private fun showBgTextConfig(index: Int): Boolean { - changeBgTextConfig(index) - callBack?.showBgTextConfig() - return true - } - - private fun upView() = binding.run { - ReadBook.pageAnim().let { - if (it >= 0 && it < rgPageAnim.childCount) { - rgPageAnim.check(rgPageAnim[it].id) - } - } - ReadBookConfig.let { - dsbTextSize.progress = it.textSize - 5 - } - } - - override val curFontPath: String - get() = ReadBookConfig.textFont - - override fun selectFont(path: String) { - if (path != ReadBookConfig.textFont || path.isEmpty()) { - ReadBookConfig.textFont = path - postEvent(EventBus.UP_CONFIG, arrayListOf(2, 5)) - } - } - - inner class StyleAdapter : - RecyclerAdapter(requireContext()) { - - override fun getViewBinding(parent: ViewGroup): ItemReadStyleBinding { - return ItemReadStyleBinding.inflate(inflater, parent, false) - } - - override fun convert( - holder: ItemViewHolder, - binding: ItemReadStyleBinding, - item: ReadBookConfig.Config, - payloads: MutableList - ) { - binding.apply { - tvStyle.text = item.name.ifBlank { "文字" } - tvStyle.setTextColor(item.curTextColor()) - ivStyle.setImageDrawable(item.curBgDrawable(100, 150)) - cdStyle.strokeWidth = 1.dpToPx() - if (ReadBookConfig.styleSelect == holder.layoutPosition) { - llStyle.gravity = Gravity.TOP - cdStyle.radius = 32f.dpToPx() - //cdStyle.strokeColor = item.curTextColor() - //tvStyle.setTextBold(true) - } else { - cdStyle.radius = 8f.dpToPx() - //cdStyle.strokeColor = item.curTextColor() - //tvStyle.setTextBold(false) - } - } - } - - override fun registerListener(holder: ItemViewHolder, binding: ItemReadStyleBinding) { - binding.apply { - cdStyle.setOnClickListener { - changeBgTextConfig(holder.layoutPosition) - } - - cdStyle.setOnLongClickListener { - dismissAllowingStateLoss() - showBgTextConfig(holder.layoutPosition) - true - } - } - } - - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/RegexColorConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/RegexColorConfigDialog.kt deleted file mode 100644 index 49bace693..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/RegexColorConfigDialog.kt +++ /dev/null @@ -1,204 +0,0 @@ -package io.legado.app.ui.book.read.config - -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 io.legado.app.R -import io.legado.app.base.BaseBottomSheetDialogFragment -import io.legado.app.constant.EventBus -import io.legado.app.databinding.DialogRegexColorConfigBinding -import io.legado.app.help.config.ReadBookConfig -import io.legado.app.help.config.RegexColorRule -import io.legado.app.lib.dialogs.alert -import io.legado.app.ui.book.read.ReadBookActivity -import io.legado.app.ui.book.read.page.provider.TextChapterLayout -import io.legado.app.ui.widget.AccentColorButton -import io.legado.app.utils.postEvent -import io.legado.app.utils.viewbindingdelegate.viewBinding -import com.jaredrummler.android.colorpicker.ColorPickerDialog - -class RegexColorConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_regex_color_config), - FontSelectDialog.CallBack { - - private val binding by viewBinding(DialogRegexColorConfigBinding::bind) - private val callBack2 get() = activity as? ReadBookActivity - private lateinit var adapter: RegexColorRuleAdapter - private var editingRulePosition = -1 - - companion object { - const val REGEX_RULE_COLOR = 7900 - var pendingColorPosition = -1 - } - - override val curFontPath: String - get() = if (editingRulePosition in ReadBookConfig.regexColorRules.indices) { - ReadBookConfig.regexColorRules[editingRulePosition].fontPath - } else "" - - override fun selectFont(path: String) { - if (editingRulePosition in ReadBookConfig.regexColorRules.indices) { - ReadBookConfig.regexColorRules[editingRulePosition].fontPath = path - notifyConfigChanged() - } - } - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - adapter = RegexColorRuleAdapter( - onDeleteClick = { position -> deleteRule(position) }, - onColorClick = { position -> showColorPicker(position) }, - onFontClick = { position -> showFontSelect(position) } - ) - initView() - initViewEvent() - } - - private fun initView() = binding.run { - recyclerView.layoutManager = LinearLayoutManager(context) - recyclerView.adapter = adapter - adapter.setItems(ReadBookConfig.regexColorRules) - } - - private fun initViewEvent() = binding.run { - btnAddRule.setOnClickListener { - showAddRuleDialog() - } - } - - private fun showAddRuleDialog() { - val defaultPatterns = listOf( - "\u201C匹配内容\u201D" to "\u201C.+?\u201D", - "《匹配内容》" to "《.+?》", - "\"匹配内容\"" to "\".+?\"" - ) - val displayItems = defaultPatterns.map { it.first } + "自定义规则" - context?.alert(title = "添加正则规则") { - items(displayItems) { _, i -> - if (i < defaultPatterns.size) { - val (name, pattern) = defaultPatterns[i] - addRule(name, pattern) - } else { - showCustomRuleDialog() - } - } - } - } - - private fun showCustomRuleDialog() { - val editText = android.widget.EditText(context).apply { - hint = "输入正则表达式,如:\\u201C.+?\\u201D" - } - context?.alert(title = "自定义正则规则") { - customView { editText } - okButton { - val pattern = editText.text.toString().trim() - if (pattern.isNotEmpty()) { - addRule(pattern, pattern) - } - } - cancelButton() - } - } - - private fun addRule(name: String, pattern: String) { - val rule = RegexColorRule(name, pattern, ReadBookConfig.durConfig.curTextAccentColor()) - ReadBookConfig.regexColorRules.add(rule) - notifyConfigChanged() - } - - private fun deleteRule(position: Int) { - if (position >= 0 && position < ReadBookConfig.regexColorRules.size) { - ReadBookConfig.regexColorRules.removeAt(position) - notifyConfigChanged() - } - } - - private fun showColorPicker(position: Int) { - if (position !in ReadBookConfig.regexColorRules.indices) return - editingRulePosition = position - pendingColorPosition = position - val rule = ReadBookConfig.regexColorRules[position] - val colorValue = rule.color or 0xFF000000.toInt() - ColorPickerDialog.newBuilder() - .setColor(colorValue) - .setShowAlphaSlider(false) - .setDialogType(ColorPickerDialog.TYPE_CUSTOM) - .setDialogId(REGEX_RULE_COLOR) - .show(requireActivity()) - } - - private fun showFontSelect(position: Int) { - if (position !in ReadBookConfig.regexColorRules.indices) return - editingRulePosition = position - FontSelectDialog().apply { - explicitCallback = this@RegexColorConfigDialog - }.show(childFragmentManager, "regexFontSelect") - } - - fun onColorSelected(color: Int) { - if (editingRulePosition in ReadBookConfig.regexColorRules.indices) { - ReadBookConfig.regexColorRules[editingRulePosition].color = color - notifyConfigChanged() - } - } - - private fun notifyConfigChanged() { - ReadBookConfig.saveRegexColorRules() - TextChapterLayout.invalidateRegexCache() - adapter.setItems(ReadBookConfig.regexColorRules) - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } -} - -class RegexColorRuleAdapter( - private val onDeleteClick: ((Int) -> Unit)? = null, - private val onColorClick: ((Int) -> Unit)? = null, - private val onFontClick: ((Int) -> Unit)? = null -) : RecyclerView.Adapter() { - - private var items: List = emptyList() - - fun setItems(items: List) { - this.items = items - notifyDataSetChanged() - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val view = LayoutInflater.from(parent.context).inflate(R.layout.item_regex_color_rule, parent, false) - return ViewHolder(view) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val item = items[position] - holder.bind(item, position) - } - - override fun getItemCount(): Int { - return items.size - } - - inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - val tvRuleName = itemView.findViewById(R.id.tv_rule_name) - val tvRulePattern = itemView.findViewById(R.id.tv_rule_pattern) - val btnSelectFont = itemView.findViewById(R.id.btn_select_font) - val btnSelectColor = itemView.findViewById(R.id.btn_select_color) - val btnDelete = itemView.findViewById(R.id.btn_delete) - - fun bind(item: RegexColorRule, position: Int) { - tvRuleName.text = item.name - tvRulePattern.text = item.pattern - btnSelectColor.color = item.color or 0xFF000000.toInt() - btnSelectFont.setOnClickListener { - onFontClick?.invoke(position) - } - btnSelectColor.setOnClickListener { - onColorClick?.invoke(position) - } - btnDelete.setOnClickListener { - onDeleteClick?.invoke(position) - } - } - } -} diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/ShadowSetDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/ShadowSetDialog.kt deleted file mode 100644 index 020da7858..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/ShadowSetDialog.kt +++ /dev/null @@ -1,66 +0,0 @@ -package io.legado.app.ui.book.read.config - -import android.os.Bundle -import android.view.View -import io.legado.app.help.config.ReadBookConfig -import io.legado.app.R -import io.legado.app.base.BaseBottomSheetDialogFragment -import io.legado.app.constant.EventBus -import io.legado.app.databinding.DialogShadowSetBinding -import io.legado.app.utils.postEvent -import io.legado.app.utils.viewbindingdelegate.viewBinding - -/** - * 字体选择对话框 - */ -class ShadowSetDialog : BaseBottomSheetDialogFragment(R.layout.dialog_shadow_set) { - - companion object { - const val S_COLOR = 123 - } - private val fontRegex = Regex("(?i).*\\.[ot]tf") - private val binding by viewBinding(DialogShadowSetBinding::bind) - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - initView() - upView() - initViewEvent() - } - - private fun initView() = binding.run { - binding.dsbShadowRadius.valueFormat = { "$it px" } - binding.dsbShadowDx.valueFormat = { "$it px" } - binding.dsbShadowDy.valueFormat = { "$it px" } - } - - private fun initViewEvent() = binding.run { - binding.dsbShadowRadius.onChanged = { - ReadBookConfig.shadowRadius = it.toFloat() - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - binding.dsbShadowDx.onChanged = { - ReadBookConfig.shadowDx = it.toFloat() - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - binding.dsbShadowDy.onChanged = { - ReadBookConfig.shadowDy = it.toFloat() - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - } - - private fun upView() = binding.run { - ReadBookConfig.let { - binding.dsbShadowRadius.progress = it.shadowRadius.toInt() - binding.dsbShadowDx.progress = it.shadowDx.toInt() - binding.dsbShadowDy.progress = it.shadowDy.toInt() - } - } - - private val callBack: CallBack? - get() = (parentFragment as? CallBack) ?: (activity as? CallBack) - - interface CallBack { - fun selectFont(path: String) - val curFontPath: String - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/SolidUnderlineSpan.kt b/app/src/main/java/io/legado/app/ui/book/read/config/SolidUnderlineSpan.kt new file mode 100644 index 000000000..c4b34dd6b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/SolidUnderlineSpan.kt @@ -0,0 +1,62 @@ +package io.legado.app.ui.book.read.config + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.style.ReplacementSpan +import io.legado.app.utils.dpToPx + +/** + * 实线下划线 Span + */ +class SolidUnderlineSpan( + private val textColor: Int, + private val underlineColor: Int, + private val underlineWidth: Float = 1f, + private val underlineOffset: Float = 6f, +) : ReplacementSpan() { + + private val offsetPx = underlineOffset.toInt().dpToPx() + + override fun getSize( + paint: Paint, + text: CharSequence, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int { + if (fm != null) { + val metrics = paint.fontMetricsInt + fm.top = metrics.top + fm.ascent = metrics.ascent + fm.descent = metrics.descent + offsetPx + fm.bottom = metrics.bottom + offsetPx + } + return paint.measureText(text, start, end).toInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + val textStr = text.subSequence(start, end).toString() + paint.color = textColor + canvas.drawText(textStr, x, y.toFloat(), paint) + + val width = paint.measureText(text, start, end) + val lineY = y + offsetPx + val linePaint = Paint(paint).apply { + color = underlineColor + style = Paint.Style.STROKE + strokeWidth = underlineWidth.dpToPx() + isAntiAlias = true + } + canvas.drawLine(x, lineY.toFloat(), x + width, lineY.toFloat(), linePaint) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/SpeakEngineDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/SpeakEngineDialog.kt deleted file mode 100644 index 871fc04b8..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/SpeakEngineDialog.kt +++ /dev/null @@ -1,299 +0,0 @@ -package io.legado.app.ui.book.read.config - -//import io.legado.app.lib.theme.primaryColor -import android.content.Context -import android.os.Bundle -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.widget.RadioButton -import androidx.appcompat.widget.Toolbar -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import io.legado.app.R -import io.legado.app.base.BaseBottomSheetDialogFragment -import io.legado.app.base.adapter.ItemViewHolder -import io.legado.app.base.adapter.RecyclerAdapter -import io.legado.app.constant.AppLog -import io.legado.app.data.appDb -import io.legado.app.data.entities.HttpTTS -import io.legado.app.databinding.DialogEditTextBinding -import io.legado.app.databinding.DialogRecyclerViewBinding -import io.legado.app.databinding.ItemHttpTtsBinding -import io.legado.app.help.DirectLinkUpload -import io.legado.app.help.config.AppConfig -import io.legado.app.lib.dialogs.SelectItem -import io.legado.app.lib.dialogs.alert -import io.legado.app.model.ReadAloud -import io.legado.app.model.ReadBook -import io.legado.app.ui.association.ImportHttpTtsDialog -import io.legado.app.ui.file.HandleFileContract -import io.legado.app.ui.login.SourceLoginActivity -import io.legado.app.utils.ACache -import io.legado.app.utils.GSON -import io.legado.app.utils.TTSCacheUtils -import io.legado.app.utils.fromJsonObject -import io.legado.app.utils.gone -import io.legado.app.utils.isAbsUrl -import io.legado.app.utils.isJsonObject -import io.legado.app.utils.sendToClip -import io.legado.app.utils.showDialogFragment -import io.legado.app.utils.splitNotBlank -import io.legado.app.utils.startActivity -import io.legado.app.utils.toastOnUi -import io.legado.app.utils.viewbindingdelegate.viewBinding -import io.legado.app.utils.visible -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.conflate -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.launch - -/** - * tts引擎管理 - */ -class SpeakEngineDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_recycler_view), - Toolbar.OnMenuItemClickListener { - - private val binding by viewBinding(DialogRecyclerViewBinding::bind) - private val viewModel: SpeakEngineViewModel by viewModels() - private val ttsUrlKey = "ttsUrlKey" - private val adapter by lazy { Adapter(requireContext()) } - private var ttsEngine: String? = ReadAloud.ttsEngine - private val sysTtsViews = arrayListOf() - private val callBack: CallBack? get() = parentFragment as? CallBack - private val importDocResult = registerForActivityResult(HandleFileContract()) { - it.uri?.let { uri -> - showDialogFragment(ImportHttpTtsDialog(uri.toString())) - } - } - private val exportDirResult = registerForActivityResult(HandleFileContract()) { - it.uri?.let { uri -> - alert(R.string.export_success) { - if (uri.toString().isAbsUrl()) { - setMessage(DirectLinkUpload.getSummary()) - } - val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { - editView.hint = getString(R.string.path) - editView.setText(uri.toString()) - } - customView { alertBinding.root } - okButton { - requireContext().sendToClip(uri.toString()) - } - } - } - } - - override fun onStart() { - super.onStart() - //setLayout(ViewGroup.LayoutParams.MATCH_PARENT, 0.9f) - } - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - initView() - initMenu() - initData() - } - - private fun initView() = binding.run { - //toolBar.setBackgroundColor(primaryColor) - toolBar.setTitle(R.string.speak_engine) - //recyclerView.setEdgeEffectColor(primaryColor) - recyclerView.layoutManager = LinearLayoutManager(requireContext()) - recyclerView.adapter = adapter - adapter.addHeaderView { - ItemHttpTtsBinding.inflate(layoutInflater, recyclerView, false).apply { - sysTtsViews.add(cbName) - ivEdit.gone() - ivMenuDelete.gone() - labelSys.visible() - cbName.text = "系统默认" - cbName.tag = "" - cbName.isChecked = ttsEngine == null || ttsEngine!!.isJsonObject() - && GSON.fromJsonObject>(ttsEngine) - .getOrNull()?.value.isNullOrEmpty() - cbName.setOnClickListener { - upTts(GSON.toJson(SelectItem("系统默认", ""))) - } - } - } - viewModel.sysEngines.forEach { engine -> - adapter.addHeaderView { - ItemHttpTtsBinding.inflate(layoutInflater, recyclerView, false).apply { - sysTtsViews.add(cbName) - ivEdit.gone() - ivMenuDelete.gone() - labelSys.visible() - cbName.text = engine.label - cbName.tag = engine.name - cbName.isChecked = GSON.fromJsonObject>(ttsEngine) - .getOrNull()?.value == cbName.tag - cbName.setOnClickListener { - upTts(GSON.toJson(SelectItem(engine.label, engine.name))) - } - } - } - } - tvFooterLeft.setText(R.string.book) - tvFooterLeft.visible() - tvFooterLeft.setOnClickListener { - ReadBook.book?.setTtsEngine(ttsEngine) - callBack?.upSpeakEngineSummary() - ReadAloud.upReadAloudClass() - dismissAllowingStateLoss() - } - tvOk.setText(R.string.general) - tvOk.visible() - tvOk.setOnClickListener { - ReadBook.book?.setTtsEngine(null) - AppConfig.ttsEngine = ttsEngine - callBack?.upSpeakEngineSummary() - ReadAloud.upReadAloudClass() - dismissAllowingStateLoss() - } - tvCancel.visible() - tvCancel.setOnClickListener { - dismissAllowingStateLoss() - } - } - - private fun initMenu() = binding.run { - toolBar.inflateMenu(R.menu.speak_engine) - //toolBar.menu.applyTint(requireContext()) - toolBar.setOnMenuItemClickListener(this@SpeakEngineDialog) - } - - private fun initData() { - lifecycleScope.launch { - appDb.httpTTSDao.flowAll().catch { - AppLog.put("朗读引擎界面获取数据失败\n${it.localizedMessage}", it) - }.flowOn(IO).conflate().collect { - adapter.setItems(it) - } - } - } - - override fun onMenuItemClick(item: MenuItem?): Boolean { - when (item?.itemId) { - R.id.menu_clear -> clearCache() - R.id.menu_add -> showDialogFragment() - R.id.menu_default -> viewModel.importDefault() - R.id.menu_import_local -> importDocResult.launch { - mode = HandleFileContract.FILE - allowExtensions = arrayOf("txt", "json") - } - - R.id.menu_import_onLine -> importAlert() - R.id.menu_export -> exportDirResult.launch { - mode = HandleFileContract.EXPORT - fileData = HandleFileContract.FileData( - "httpTts.json", - GSON.toJson(adapter.getItems()).toByteArray(), - "application/json" - ) - } - } - return true - } - - private fun importAlert() { - val aCache = ACache.get(cacheDir = false) - val cacheUrls: MutableList = aCache - .getAsString(ttsUrlKey) - ?.splitNotBlank(",") - ?.toMutableList() ?: mutableListOf() - alert(R.string.import_on_line) { - val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { - editView.hint = "url" - editView.setFilterValues(cacheUrls) - editView.delCallBack = { - cacheUrls.remove(it) - aCache.put(ttsUrlKey, cacheUrls.joinToString(",")) - } - } - customView { alertBinding.root } - okButton { - alertBinding.editView.text?.toString()?.let { url -> - if (url.isAbsUrl() && !cacheUrls.contains(url)) { - cacheUrls.add(0, url) - aCache.put(ttsUrlKey, cacheUrls.joinToString(",")) - } - showDialogFragment(ImportHttpTtsDialog(url)) - } - } - } - } - - private fun upTts(tts: String) { - ttsEngine = tts - sysTtsViews.forEach { - it.isChecked = GSON.fromJsonObject>(ttsEngine) - .getOrNull()?.value == it.tag - } - adapter.notifyItemRangeChanged(adapter.getHeaderCount(), adapter.itemCount) - } - - fun clearCache() { - execute { - ReadAloud.upReadAloudClass() - TTSCacheUtils.clearTtsCache() - toastOnUi(R.string.clear_cache_success) - } - } - - inner class Adapter(context: Context) : - RecyclerAdapter(context) { - - override fun getViewBinding(parent: ViewGroup): ItemHttpTtsBinding { - return ItemHttpTtsBinding.inflate(inflater, parent, false) - } - - override fun convert( - holder: ItemViewHolder, - binding: ItemHttpTtsBinding, - item: HttpTTS, - payloads: MutableList - ) { - binding.apply { - cbName.text = item.name - cbName.isChecked = item.id.toString() == ttsEngine - } - } - - override fun registerListener(holder: ItemViewHolder, binding: ItemHttpTtsBinding) { - binding.run { - cbName.setOnClickListener { - getItemByLayoutPosition(holder.layoutPosition)?.let { httpTTS -> - val id = httpTTS.id.toString() - upTts(id) - if (!httpTTS.loginUrl.isNullOrBlank() - && httpTTS.getLoginInfo().isNullOrBlank() - ) { - startActivity { - putExtra("type", "httpTts") - putExtra("key", id) - } - } - } - } - ivEdit.setOnClickListener { - val id = getItemByLayoutPosition(holder.layoutPosition)!!.id - showDialogFragment(HttpTtsEditDialog(id)) - } - ivMenuDelete.setOnClickListener { - getItemByLayoutPosition(holder.layoutPosition)?.let { httpTTS -> - appDb.httpTTSDao.delete(httpTTS) - } - } - } - } - - } - - interface CallBack { - fun upSpeakEngineSummary() - } - -} diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/SpeakEngineViewModel.kt b/app/src/main/java/io/legado/app/ui/book/read/config/SpeakEngineViewModel.kt deleted file mode 100644 index ae9ab7b90..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/SpeakEngineViewModel.kt +++ /dev/null @@ -1,23 +0,0 @@ -package io.legado.app.ui.book.read.config - -import android.app.Application -import android.speech.tts.TextToSpeech -import io.legado.app.base.BaseViewModel -import io.legado.app.help.DefaultData - -class SpeakEngineViewModel(application: Application) : BaseViewModel(application) { - - val sysEngines: List by lazy { - val tts = TextToSpeech(context, null) - val engines = tts.engines - tts.shutdown() - engines - } - - fun importDefault() { - execute { - DefaultData.importDefaultHttpTTS() - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/SvgPathParser.kt b/app/src/main/java/io/legado/app/ui/book/read/config/SvgPathParser.kt new file mode 100644 index 000000000..b780022c9 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/SvgPathParser.kt @@ -0,0 +1,410 @@ +package io.legado.app.ui.book.read.config + +import android.graphics.Path +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.sin + +object SvgPathParser { + + private val cache = android.util.LruCache(32) + + fun parse(svgPath: String): Path? { + if (svgPath.isBlank()) return null + cache.get(svgPath)?.let { return it } + val path = parseInternal(svgPath) ?: return null + cache.put(svgPath, path) + return path + } + + private fun parseInternal(svgPath: String): Path? { + if (svgPath.isBlank()) return null + + val path = Path() + val tokens = tokenize(svgPath) + if (tokens.isEmpty()) return null + + var currentX = 0f + var currentY = 0f + var startX = 0f + var startY = 0f + var lastControlX = 0f + var lastControlY = 0f + var lastCommand = "" + var index = 0 + + while (index < tokens.size) { + val token = tokens[index] + + when (token) { + "M" -> { + index++ + if (index + 1 < tokens.size) { + currentX = tokens[index].toFloatOrNull() ?: currentX + currentY = tokens[index + 1].toFloatOrNull() ?: currentY + startX = currentX + startY = currentY + path.moveTo(currentX, currentY) + index += 2 + } + lastCommand = "M" + } + "m" -> { + index++ + if (index + 1 < tokens.size) { + val dx = tokens[index].toFloatOrNull() ?: 0f + val dy = tokens[index + 1].toFloatOrNull() ?: 0f + currentX += dx + currentY += dy + startX = currentX + startY = currentY + path.moveTo(currentX, currentY) + index += 2 + } + lastCommand = "m" + } + "L" -> { + index++ + while (index + 1 < tokens.size && !isCommand(tokens[index])) { + currentX = tokens[index].toFloatOrNull() ?: currentX + currentY = tokens[index + 1].toFloatOrNull() ?: currentY + path.lineTo(currentX, currentY) + index += 2 + } + lastCommand = "L" + } + "l" -> { + index++ + while (index + 1 < tokens.size && !isCommand(tokens[index])) { + val dx = tokens[index].toFloatOrNull() ?: 0f + val dy = tokens[index + 1].toFloatOrNull() ?: 0f + currentX += dx + currentY += dy + path.lineTo(currentX, currentY) + index += 2 + } + lastCommand = "l" + } + "H" -> { + index++ + while (index < tokens.size && !isCommand(tokens[index])) { + currentX = tokens[index].toFloatOrNull() ?: currentX + path.lineTo(currentX, currentY) + index++ + } + lastCommand = "H" + } + "h" -> { + index++ + while (index < tokens.size && !isCommand(tokens[index])) { + val dx = tokens[index].toFloatOrNull() ?: 0f + currentX += dx + path.lineTo(currentX, currentY) + index++ + } + lastCommand = "h" + } + "V" -> { + index++ + while (index < tokens.size && !isCommand(tokens[index])) { + currentY = tokens[index].toFloatOrNull() ?: currentY + path.lineTo(currentX, currentY) + index++ + } + lastCommand = "V" + } + "v" -> { + index++ + while (index < tokens.size && !isCommand(tokens[index])) { + val dy = tokens[index].toFloatOrNull() ?: 0f + currentY += dy + path.lineTo(currentX, currentY) + index++ + } + lastCommand = "v" + } + "C" -> { + index++ + while (index + 5 < tokens.size && !isCommand(tokens[index])) { + val x1 = tokens[index].toFloatOrNull() ?: currentX + val y1 = tokens[index + 1].toFloatOrNull() ?: currentY + val x2 = tokens[index + 2].toFloatOrNull() ?: currentX + val y2 = tokens[index + 3].toFloatOrNull() ?: currentY + val x = tokens[index + 4].toFloatOrNull() ?: currentX + val y = tokens[index + 5].toFloatOrNull() ?: currentY + path.cubicTo(x1, y1, x2, y2, x, y) + lastControlX = x2 + lastControlY = y2 + currentX = x + currentY = y + index += 6 + } + lastCommand = "C" + } + "c" -> { + index++ + while (index + 5 < tokens.size && !isCommand(tokens[index])) { + val x1 = currentX + (tokens[index].toFloatOrNull() ?: 0f) + val y1 = currentY + (tokens[index + 1].toFloatOrNull() ?: 0f) + val x2 = currentX + (tokens[index + 2].toFloatOrNull() ?: 0f) + val y2 = currentY + (tokens[index + 3].toFloatOrNull() ?: 0f) + val x = currentX + (tokens[index + 4].toFloatOrNull() ?: 0f) + val y = currentY + (tokens[index + 5].toFloatOrNull() ?: 0f) + path.cubicTo(x1, y1, x2, y2, x, y) + lastControlX = x2 + lastControlY = y2 + currentX = x + currentY = y + index += 6 + } + lastCommand = "c" + } + "S" -> { + index++ + while (index + 3 < tokens.size && !isCommand(tokens[index])) { + val x2 = tokens[index].toFloatOrNull() ?: currentX + val y2 = tokens[index + 1].toFloatOrNull() ?: currentY + val x = tokens[index + 2].toFloatOrNull() ?: currentX + val y = tokens[index + 3].toFloatOrNull() ?: currentY + val x1 = if (lastCommand == "C" || lastCommand == "c" || lastCommand == "S" || lastCommand == "s") { + 2 * currentX - lastControlX + } else { + currentX + } + val y1 = if (lastCommand == "C" || lastCommand == "c" || lastCommand == "S" || lastCommand == "s") { + 2 * currentY - lastControlY + } else { + currentY + } + path.cubicTo(x1, y1, x2, y2, x, y) + lastControlX = x2 + lastControlY = y2 + currentX = x + currentY = y + index += 4 + } + lastCommand = "S" + } + "s" -> { + index++ + while (index + 3 < tokens.size && !isCommand(tokens[index])) { + val x2 = currentX + (tokens[index].toFloatOrNull() ?: 0f) + val y2 = currentY + (tokens[index + 1].toFloatOrNull() ?: 0f) + val x = currentX + (tokens[index + 2].toFloatOrNull() ?: 0f) + val y = currentY + (tokens[index + 3].toFloatOrNull() ?: 0f) + val x1 = if (lastCommand == "C" || lastCommand == "c" || lastCommand == "S" || lastCommand == "s") { + 2 * currentX - lastControlX + } else { + currentX + } + val y1 = if (lastCommand == "C" || lastCommand == "c" || lastCommand == "S" || lastCommand == "s") { + 2 * currentY - lastControlY + } else { + currentY + } + path.cubicTo(x1, y1, x2, y2, x, y) + lastControlX = x2 + lastControlY = y2 + currentX = x + currentY = y + index += 4 + } + lastCommand = "s" + } + "Q" -> { + index++ + while (index + 3 < tokens.size && !isCommand(tokens[index])) { + val x1 = tokens[index].toFloatOrNull() ?: currentX + val y1 = tokens[index + 1].toFloatOrNull() ?: currentY + val x = tokens[index + 2].toFloatOrNull() ?: currentX + val y = tokens[index + 3].toFloatOrNull() ?: currentY + path.quadTo(x1, y1, x, y) + lastControlX = x1 + lastControlY = y1 + currentX = x + currentY = y + index += 4 + } + lastCommand = "Q" + } + "q" -> { + index++ + while (index + 3 < tokens.size && !isCommand(tokens[index])) { + val x1 = currentX + (tokens[index].toFloatOrNull() ?: 0f) + val y1 = currentY + (tokens[index + 1].toFloatOrNull() ?: 0f) + val x = currentX + (tokens[index + 2].toFloatOrNull() ?: 0f) + val y = currentY + (tokens[index + 3].toFloatOrNull() ?: 0f) + path.quadTo(x1, y1, x, y) + lastControlX = x1 + lastControlY = y1 + currentX = x + currentY = y + index += 4 + } + lastCommand = "q" + } + "A", "a" -> { + val isRelative = token == "a" + index++ + while (index + 6 < tokens.size && !isCommand(tokens[index])) { + val arcRx = abs(tokens[index].toFloatOrNull() ?: 0f) + val arcRy = abs(tokens[index + 1].toFloatOrNull() ?: 0f) + val xAxisRotation = tokens[index + 2].toFloatOrNull() ?: 0f + val largeArcFlag = tokens[index + 3].toIntOrNull() ?: 0 + val sweepFlag = tokens[index + 4].toIntOrNull() ?: 0 + val x = if (isRelative) currentX + (tokens[index + 5].toFloatOrNull() ?: 0f) else tokens[index + 5].toFloatOrNull() ?: currentX + val y = if (isRelative) currentY + (tokens[index + 6].toFloatOrNull() ?: 0f) else tokens[index + 6].toFloatOrNull() ?: currentY + + drawArc(path, currentX, currentY, x, y, arcRx, arcRy, xAxisRotation, largeArcFlag == 1, sweepFlag == 1) + currentX = x + currentY = y + index += 7 + } + lastCommand = token + } + "Z", "z" -> { + path.close() + currentX = startX + currentY = startY + index++ + lastCommand = token + } + else -> { + index++ + } + } + } + + return path + } + + private fun tokenize(svgPath: String): List { + val tokens = mutableListOf() + val sb = StringBuilder() + var i = 0 + + while (i < svgPath.length) { + val c = svgPath[i] + + when { + c.isWhitespace() || c == ',' -> { + if (sb.isNotEmpty()) { + tokens.add(sb.toString()) + sb.clear() + } + } + c.isLetter() -> { + if (sb.isNotEmpty()) { + tokens.add(sb.toString()) + sb.clear() + } + tokens.add(c.toString()) + } + c == '-' -> { + if (sb.isNotEmpty() && !sb.endsWith('e', ignoreCase = true)) { + tokens.add(sb.toString()) + sb.clear() + } + sb.append(c) + } + c == '.' -> { + if (sb.contains('.')) { + tokens.add(sb.toString()) + sb.clear() + } + sb.append(c) + } + else -> { + sb.append(c) + } + } + i++ + } + + if (sb.isNotEmpty()) { + tokens.add(sb.toString()) + } + + return tokens + } + + private fun isCommand(token: String): Boolean { + return token.length == 1 && token[0].isLetter() + } + + private fun drawArc( + path: Path, + x1: Float, y1: Float, + x2: Float, y2: Float, + arcRx: Float, arcRy: Float, + phi: Float, + largeArc: Boolean, + sweep: Boolean + ) { + if (arcRx == 0f || arcRy == 0f) { + path.lineTo(x2, y2) + return + } + + var localRx = arcRx + var localRy = arcRy + + val phiRad = Math.toRadians(phi.toDouble()) + val cosPhi = cos(phiRad).toFloat() + val sinPhi = sin(phiRad).toFloat() + + val dx = (x1 - x2) / 2f + val dy = (y1 - y2) / 2f + + val x1p = cosPhi * dx + sinPhi * dy + val y1p = -sinPhi * dx + cosPhi * dy + + var rxSq = localRx * localRx + var rySq = localRy * localRy + val x1pSq = x1p * x1p + val y1pSq = y1p * y1p + + var cr = x1pSq / rxSq + y1pSq / rySq + if (cr > 1f) { + val sqrtCr = kotlin.math.sqrt(cr.toDouble()).toFloat() + localRx *= sqrtCr + localRy *= sqrtCr + rxSq = localRx * localRx + rySq = localRy * localRy + } + + val rq = rxSq * rySq - rxSq * y1pSq - rySq * x1pSq + val cq = rxSq * y1pSq + rySq * x1pSq + val sqrtVal = kotlin.math.sqrt(kotlin.math.max(0.0, rq.toDouble()) / kotlin.math.max(1e-10, cq.toDouble())).toFloat() + val sign = if (largeArc != sweep) 1f else -1f + val cxp = sign * sqrtVal * (localRx * y1p / localRy) + val cyp = -sign * sqrtVal * (localRy * x1p / localRx) + + val cx = cosPhi * cxp - sinPhi * cyp + (x1 + x2) / 2f + val cy = sinPhi * cxp + cosPhi * cyp + (y1 + y2) / 2f + + val theta1 = angle(1f, 0f, (x1p - cxp) / localRx, (y1p - cyp) / localRy) + var dtheta = angle( + (x1p - cxp) / localRx, (y1p - cyp) / localRy, + (-x1p - cxp) / localRx, (-y1p - cyp) / localRy + ) + + if (!sweep && dtheta > 0) dtheta -= 360f + if (sweep && dtheta < 0) dtheta += 360f + + val sweepAngle = dtheta + path.arcTo( + android.graphics.RectF(cx - localRx, cy - localRy, cx + localRx, cy + localRy), + theta1, + sweepAngle, + false + ) + } + + private fun angle(ux: Float, uy: Float, vx: Float, vy: Float): Float { + val n = kotlin.math.sqrt((ux * ux + uy * uy).toDouble()) * kotlin.math.sqrt((vx * vx + vy * vy).toDouble()) + val c = (ux * vx + uy * vy) / kotlin.math.max(n, 1e-10) + val angle = Math.toDegrees(kotlin.math.acos(c.coerceIn(-1.0, 1.0))) + return if (ux * vy - uy * vx < 0) -angle.toFloat() else angle.toFloat() + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/SvgUnderlineSpan.kt b/app/src/main/java/io/legado/app/ui/book/read/config/SvgUnderlineSpan.kt new file mode 100644 index 000000000..79ad454eb --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/SvgUnderlineSpan.kt @@ -0,0 +1,75 @@ +package io.legado.app.ui.book.read.config + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.style.ReplacementSpan +import io.legado.app.utils.dpToPx + +/** + * SVG 路径下划线 Span + */ +class SvgUnderlineSpan( + private val textColor: Int, + private val underlineColor: Int, + private val underlineWidth: Float = 1f, + private val svgPath: String, +) : ReplacementSpan() { + + override fun getSize( + paint: Paint, + text: CharSequence, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int { + if (fm != null) { + val metrics = paint.fontMetricsInt + fm.top = metrics.top + fm.ascent = metrics.ascent + fm.descent = metrics.descent + fm.bottom = metrics.bottom + } + return paint.measureText(text, start, end).toInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + val textStr = text.subSequence(start, end).toString() + val textPaint = Paint(paint).apply { + color = textColor + } + canvas.drawText(textStr, x, y.toFloat(), textPaint) + + if (svgPath.isNotBlank()) { + val path = SvgPathParser.parse(svgPath) + if (path != null) { + val textWidth = paint.measureText(textStr) + val baseWidth = 100f + val baseY = 50f + val lineY = y + 6.dpToPx() + + val underlinePaint = Paint(paint).apply { + color = underlineColor + strokeWidth = underlineWidth.dpToPx() + style = Paint.Style.STROKE + isAntiAlias = true + } + + canvas.save() + canvas.translate(x, lineY - baseY) + canvas.scale(textWidth / baseWidth, 1f) + canvas.drawPath(path, underlinePaint) + canvas.restore() + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/TipConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/TipConfigDialog.kt deleted file mode 100644 index 4dae1af7e..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/TipConfigDialog.kt +++ /dev/null @@ -1,283 +0,0 @@ -package io.legado.app.ui.book.read.config - -import android.os.Bundle -import android.text.InputType -import android.view.View -import com.jaredrummler.android.colorpicker.ColorPickerDialog -import io.legado.app.R -import io.legado.app.base.BaseBottomSheetDialogFragment -import io.legado.app.constant.EventBus -import io.legado.app.databinding.DialogEditTextBinding -import io.legado.app.databinding.DialogTipConfigBinding -import io.legado.app.help.config.AppConfig -import io.legado.app.help.config.ReadBookConfig -import io.legado.app.lib.dialogs.alert -import io.legado.app.utils.observeEvent -import io.legado.app.utils.postEvent -import io.legado.app.utils.requestInputMethod -import io.legado.app.utils.toastOnUi -import io.legado.app.utils.viewbindingdelegate.viewBinding - - -class TipConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_tip_config), FontSelectDialog.CallBack { - - companion object { - const val TIP_HEADER_COLOR = 7897 - const val TIP_FOOTER_COLOR = 7899 - const val TIP_DIVIDER_COLOR = 7898 - const val TITLE_COLOR = 7896 - const val B_COLOR = 114 - const val A_COLOR = 514 - } - - private val binding by viewBinding(DialogTipConfigBinding::bind) - - override val curFontPath: String - get() = ReadBookConfig.titleFont - - override fun selectFont(path: String) { - ReadBookConfig.titleFont = path - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - - override fun onStart() { - super.onStart() - } - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - initView() - initEvent() - observeEvent(EventBus.UPDATE_READ_ACTION_BAR) { - binding.abtnBackgroundColor.color = ReadBookConfig.durConfig.curMenuBg() - binding.abtnAccentColor.color = ReadBookConfig.durConfig.curMenuAc() - } - observeEvent>(EventBus.UP_CONFIG) { - val preview = if (ReadBookConfig.titleColor != 0) ReadBookConfig.titleColor else ReadBookConfig.textColor - binding.abtnTitleColor.color = preview or 0xFF000000.toInt() - } - } - - private fun initView() { - - when (ReadBookConfig.titleMode) { - 0 -> binding.rgTitleMode.check(R.id.rb_title_mode1) - 1 -> binding.rgTitleMode.check(R.id.rb_title_mode2) - 2 -> binding.rgTitleMode.check(R.id.rb_title_mode3) - else -> { } - } - val weightOptions = context?.resources?.getStringArray(R.array.text_font_weight) - val weightValues = listOf(0, 1, 2) - val initialWeightIndex = weightValues.indexOf(ReadBookConfig.titleBold) - val weightIconMap = mapOf( - 0 to R.drawable.ic_text_weight_0, - 1 to R.drawable.ic_text_weight_1, - 2 to R.drawable.ic_text_weight_2, - ) - val initialIconRes = weightIconMap[initialWeightIndex] ?: R.drawable.ic_text_weight_2 - binding.textFontWeightConverter.setIconResource(initialIconRes) - binding.textFontWeightConverter.setOnClickListener { - context?.alert(titleResource = R.string.text_font_weight_converter) { - weightOptions?.let { options -> - items(options.toList()) { _, i -> - ReadBookConfig.titleBold = weightValues[i] - val iconRes = weightIconMap[i] ?: R.drawable.ic_text_weight_2 - binding.textFontWeightConverter.setIconResource(iconRes) - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 9, 6)) - } - } - } - } - binding.btnSelectTitleFont.setOnClickListener { - FontSelectDialog().show(childFragmentManager, "fontSelect") - } - val titleColorValue = ReadBookConfig.titleColor - val titleColorPreview = if (titleColorValue != 0) titleColorValue else ReadBookConfig.textColor - binding.abtnTitleColor.color = titleColorPreview or 0xFF000000.toInt() - binding.abtnTitleColor.setOnClickListener { - ColorPickerDialog.newBuilder() - .setColor(titleColorPreview or 0xFF000000.toInt()) - .setShowAlphaSlider(false) - .setDialogType(ColorPickerDialog.TYPE_CUSTOM) - .setDialogId(TITLE_COLOR) - .show(requireActivity()) - } - binding.abtnBackgroundColor.color = ReadBookConfig.durConfig.curMenuBg() - binding.abtnAccentColor.color = ReadBookConfig.durConfig.curMenuAc() - binding.abtnBackgroundColor.setOnClickListener { - ColorPickerDialog.newBuilder() - .setColor(ReadBookConfig.durConfig.curMenuBg()) - .setShowAlphaSlider(false) - .setDialogType(ColorPickerDialog.TYPE_CUSTOM) - .setDialogId(B_COLOR) - .show(requireActivity()) - } - - binding.abtnAccentColor.setOnClickListener { - ColorPickerDialog.newBuilder() - .setColor(ReadBookConfig.durConfig.curMenuAc()) - .setShowAlphaSlider(false) - .setDialogType(ColorPickerDialog.TYPE_CUSTOM) - .setDialogId(A_COLOR) - .show(requireActivity()) - } - - binding.bottomMode.check( - when (AppConfig.readBarStyle) { - 0 -> R.id.bottom_mode1 - 1 -> R.id.bottom_mode2 - else -> R.id.bottom_mode3 - } - ) - binding.bottomMode.setOnCheckedStateChangeListener { group, checkedIds -> - val checkedId = checkedIds.firstOrNull() ?: return@setOnCheckedStateChangeListener - AppConfig.readBarStyle = when (checkedId) { - R.id.bottom_mode1 -> 0 - R.id.bottom_mode2 -> 1 - R.id.bottom_mode3 -> 2 - else -> 0 - } - postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) - } - - binding.btnTitleSegType.setOnClickListener { - val types = arrayOf("不分段", "按字符数分段", "按标志字符串分段", "正则表达式分段") - val current = ReadBookConfig.titleSegType - - alert(title = "选择标题分段模式") { - singleChoiceItems(types, current) { _, which -> - ReadBookConfig.titleSegType = which - } - positiveButton("确定") { - toastOnUi("分段模式已设置为:${types[ReadBookConfig.titleSegType]}") - postEvent(EventBus.UP_CONFIG, arrayListOf(5)) - } - negativeButton("取消") - }.show() - } - - binding.btnTitleSegConfig.setOnClickListener { - when (ReadBookConfig.titleSegType) { - 1 -> { // 按字符数分段 - alert(title = "设置分段字符数") { - val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { - editView.inputType = InputType.TYPE_CLASS_NUMBER - editView.setText(ReadBookConfig.titleSegDistance.toString()) - editView.hint = "输入分段字符数" - } - customView { alertBinding.root } - - okButton { - val value = alertBinding.editView.text?.toString()?.toIntOrNull() - if (value != null && value > 0) { - ReadBookConfig.titleSegDistance = value - toastOnUi("分段字符数设置为 $value") - postEvent(EventBus.UP_CONFIG, arrayListOf(5)) - } else { - toastOnUi("请输入有效数字") - } - } - cancelButton() - }.requestInputMethod() - } - - 2 -> { // 按标志字符串分段 - alert(title = "设置分段标志") { - val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { - editView.inputType = InputType.TYPE_CLASS_TEXT - editView.setText(ReadBookConfig.titleSegFlag) - editLayout.hint = "输入多个标志,用英文逗号分隔,例如:章,回,篇" - } - customView { alertBinding.root } - - okButton { - val value = alertBinding.editView.text?.toString()?.trim() - if (!value.isNullOrEmpty()) { - ReadBookConfig.titleSegFlag = value - toastOnUi("分段标志设置为 \"$value\"") - postEvent(EventBus.UP_CONFIG, arrayListOf(5)) - } else { - toastOnUi("标志不能为空") - } - } - cancelButton() - }.requestInputMethod() - } - - 3 -> { // 正则表达式分段 - alert(title = "设置正则分段规则") { - val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply { - editView.inputType = InputType.TYPE_CLASS_TEXT - editView.setText(ReadBookConfig.titleSegFlag) - editLayout.hint = "例如: [章回篇] 或 (第.{1,3}章)" - editView.isSingleLine = true - } - - customView { alertBinding.root } - - okButton { - val value = alertBinding.editView.text?.toString()?.trim() - if (!value.isNullOrEmpty()) { - try { - Regex(value) - ReadBookConfig.titleSegFlag = value - toastOnUi("正则规则已保存") - postEvent(EventBus.UP_CONFIG, arrayListOf(5)) - } catch (e: Exception) { - toastOnUi("正则表达式格式错误") - } - } else { - toastOnUi("规则不能为空") - } - } - cancelButton() - }.requestInputMethod() - } - - else -> { - toastOnUi("当前分段模式无需配置参数") - } - } - } - - binding.dsbTitleSegScaling.progress = ReadBookConfig.titleSegScaling.toInt() * 10 - binding.dsbTitleLineSpacingExtra.progress = ReadBookConfig.titleLineSpacingExtra - binding.dsbTitleLineSpacingSub.progress = ReadBookConfig.titleLineSpacingSub - binding.dsbTitleSize.progress = ReadBookConfig.titleSize - binding.dsbTitleTop.progress = ReadBookConfig.titleTopSpacing - binding.dsbTitleBottom.progress = ReadBookConfig.titleBottomSpacing - } - - private fun initEvent() = binding.run { - binding.rgTitleMode.setOnCheckedStateChangeListener { group, checkedIds -> - if (checkedIds.isNotEmpty()) { - ReadBookConfig.titleMode = group.indexOfChild(group.findViewById(checkedIds.first())) - postEvent(EventBus.UP_CONFIG, arrayListOf(5)) - } - } - binding.dsbTitleSegScaling.onChanged = { - ReadBookConfig.titleSegScaling = it / 10f - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - binding.dsbTitleLineSpacingExtra.onChanged = { - ReadBookConfig.titleLineSpacingExtra = it - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - binding.dsbTitleLineSpacingSub.onChanged = { - ReadBookConfig.titleLineSpacingSub = it - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - dsbTitleSize.onChanged = { - ReadBookConfig.titleSize = it - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - dsbTitleTop.onChanged = { - ReadBookConfig.titleTopSpacing = it - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - dsbTitleBottom.onChanged = { - ReadBookConfig.titleBottomSpacing = it - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5)) - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/ToolButtonConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/ToolButtonConfigDialog.kt deleted file mode 100644 index 1edcc0481..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/ToolButtonConfigDialog.kt +++ /dev/null @@ -1,221 +0,0 @@ -package io.legado.app.ui.book.read.config - -import android.content.Context -import android.content.DialogInterface -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.core.content.edit -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import io.legado.app.R -import io.legado.app.base.BaseBottomSheetDialogFragment -import io.legado.app.databinding.DialogToolButtonConfigBinding -import io.legado.app.databinding.ItemToolButtonBinding -import io.legado.app.ui.book.read.ReadBookActivity -import io.legado.app.utils.viewbindingdelegate.viewBinding - -class ToolButtonConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_tool_button_config) { - - private val binding by viewBinding(DialogToolButtonConfigBinding::bind) - private val prefs by lazy { - requireContext().getSharedPreferences( - "tool_button_config", - Context.MODE_PRIVATE - ) - } - - private val callBack: CallBack? get() = activity as? CallBack - - private lateinit var adapter: ToolButtonAdapter - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) = binding.run { - val configList = loadButtonConfig().toMutableList() - - adapter = ToolButtonAdapter(configList) - recyclerView.adapter = adapter - binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) - - val touchHelper = ItemTouchHelper(TouchHelperCallback(adapter)) - touchHelper.attachToRecyclerView(recyclerView) - - btnSave.setOnClickListener { - saveButtonConfig(adapter.items) - callBack?.refresh() - dismiss() - } - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - (activity as ReadBookActivity).bottomDialog-- - } - - data class ConfigEntry(val id: String, var enabled: Boolean) - - inner class ToolButtonAdapter(val items: MutableList) : - RecyclerView.Adapter() { - - inner class VH(val binding: ItemToolButtonBinding) : RecyclerView.ViewHolder(binding.root) - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { - val binding = ItemToolButtonBinding.inflate(layoutInflater, parent, false) - return VH(binding) - } - - override fun onBindViewHolder(holder: VH, position: Int) { - val item = items[position] - - val (iconRes, name) = getButtonInfo(item.id) - holder.binding.tvName.text = name - holder.binding.ivIcon.setIconResource(iconRes) - holder.binding.ivIcon.isEnabled = item.enabled - holder.binding.btnDisable.icon = ContextCompat.getDrawable( - holder.itemView.context, - if (item.enabled) R.drawable.ic_visibility_on else R.drawable.ic_visibility_off - ) - - holder.binding.btnDisable.setOnClickListener { - val pos = holder.bindingAdapterPosition - if (pos == RecyclerView.NO_POSITION) return@setOnClickListener - - item.enabled = !item.enabled - - holder.binding.btnDisable.icon = ContextCompat.getDrawable( - holder.itemView.context, - if (item.enabled) R.drawable.ic_visibility_on else R.drawable.ic_visibility_off - ) - - if (!item.enabled) { - val removed = items.removeAt(pos) - items.add(removed) - notifyItemMoved(pos, items.size - 1) - } else { - notifyItemChanged(pos) - } - holder.binding.ivIcon.isEnabled = item.enabled - } - } - - - override fun getItemCount() = items.size - - fun swap(from: Int, to: Int) { - if (from == RecyclerView.NO_POSITION || to == RecyclerView.NO_POSITION) return - val fromItem = items[from] - val toItem = items[to] - if (!fromItem.enabled || !toItem.enabled) return - - items.add(to, items.removeAt(from)) - notifyItemMoved(from, to) - } - } - - private fun getAllButtonIds(): List { - return listOf( - "search", - "auto_page", - "catalog", - "read_aloud", - "setting", - "addBookmark", - "theme", - "prev_chapter", - "next_chapter", - "replace", - "replace_badge", - "translate" - ) - } - - private fun getButtonInfo(id: String): Pair { - return when (id) { - "search" -> R.drawable.ic_search to getString(R.string.search_content) - "auto_page" -> R.drawable.ic_auto_page to getString(R.string.auto_next_page) - "catalog" -> R.drawable.ic_toc to getString(R.string.chapter_list) - "read_aloud" -> R.drawable.ic_read_aloud to getString(R.string.read_aloud) - "setting" -> R.drawable.ic_settings to getString(R.string.setting) - "addBookmark" -> R.drawable.ic_bookmark to getString(R.string.bookmark) - "theme" -> R.drawable.ic_daytime to getString(R.string.day_night_switch) - "prev_chapter" -> R.drawable.ic_previous to getString(R.string.previous_chapter) - "next_chapter" -> R.drawable.ic_next to getString(R.string.next_chapter) - "translate" -> R.drawable.ic_translate to getString(R.string.translate) - "replace" -> R.drawable.ic_find_replace to getString(R.string.replace_purify) - "replace_badge" -> R.drawable.ic_find_replace to getString(R.string.replace_purify_badge) - else -> R.drawable.ic_help to id - } - } - - private fun loadButtonConfig(): List { - val str = prefs.getString("tool_buttons", null) - - return if (str.isNullOrBlank()) { - getAllButtonIds().mapIndexed { index, id -> - ConfigEntry(id, index < 5) - } - } else { - val saved = str.split(";").mapNotNull { - val parts = it.split(",") - if (parts.size == 2) ConfigEntry(parts[0], parts[1].toBoolean()) else null - }.toMutableList() - - val allIds = getAllButtonIds() - for (id in allIds) { - if (saved.none { it.id == id }) { - saved.add(ConfigEntry(id, true)) - } - } - - saved - } - } - - private fun saveButtonConfig(list: List) { - val str = list.joinToString(";") { "${it.id},${it.enabled}" } - prefs.edit { putString("tool_buttons", str) } - } - - class TouchHelperCallback( - private val adapter: ToolButtonAdapter - ) : ItemTouchHelper.Callback() { - - override fun getMovementFlags( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder - ): Int { - val pos = viewHolder.bindingAdapterPosition - if (pos == RecyclerView.NO_POSITION) return 0 - - val item = adapter.items[pos] - return if (!item.enabled) { - 0 - } else { - val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN - makeMovementFlags(dragFlags, 0) - } - } - - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder - ): Boolean { - val fromPos = viewHolder.bindingAdapterPosition - val toPos = target.bindingAdapterPosition - if (fromPos == RecyclerView.NO_POSITION || toPos == RecyclerView.NO_POSITION) return false - - adapter.swap(fromPos, toPos) - return true - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit - override fun isLongPressDragEnabled() = true - } - - interface CallBack { - fun refresh() - } - -} diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/UnderlineConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/UnderlineConfigDialog.kt deleted file mode 100644 index 6f26ed132..000000000 --- a/app/src/main/java/io/legado/app/ui/book/read/config/UnderlineConfigDialog.kt +++ /dev/null @@ -1,107 +0,0 @@ -package io.legado.app.ui.book.read.config - -import android.os.Bundle -import android.view.View -import com.jaredrummler.android.colorpicker.ColorPickerDialog -import io.legado.app.R -import io.legado.app.base.BaseBottomSheetDialogFragment -import io.legado.app.constant.EventBus -import io.legado.app.databinding.DialogUnderlineConfigBinding -import io.legado.app.help.config.ReadBookConfig -import io.legado.app.help.config.ReadBookConfig.dottedLine -import io.legado.app.help.config.ReadBookConfig.underline -import io.legado.app.help.config.ReadBookConfig.underlineExtend -import io.legado.app.utils.observeEvent -import io.legado.app.utils.postEvent -import io.legado.app.utils.viewbindingdelegate.viewBinding - -/** - * 字体选择对话框 - */ -class UnderlineConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_underline_config) { - - companion object { - const val U_COLOR = 810 - } - - private val binding by viewBinding(DialogUnderlineConfigBinding::bind) - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - observeEvent>(EventBus.UP_CONFIG) { list -> - if (list.contains(2)) { - binding.btnUnderlineColor.color = ReadBookConfig.durConfig.curUnderlineColor() - } - } - initView() - } - - private fun initView() = binding.run { - binding.btnUnderlineColor.color = ReadBookConfig.durConfig.curTextColor() - binding.swUnderline.isChecked = underline - binding.swDottedline.isChecked = dottedLine - binding.swDottedline.isEnabled = underline - binding.swUnderlineExtend.isChecked = underlineExtend - - binding.swUnderline.addOnCheckedChangeListener { _, isChecked -> - underline = isChecked - binding.swDottedline.isEnabled = isChecked - if (!isChecked) { - dottedLine = false - binding.swDottedline.isChecked = false - } - postEvent(EventBus.UP_CONFIG, arrayListOf(6, 9, 11)) - } - - binding.swDottedline.addOnCheckedChangeListener { _, isChecked -> - dottedLine = isChecked - postEvent(EventBus.UP_CONFIG, arrayListOf(6, 9, 11)) - } - - binding.swUnderlineExtend.addOnCheckedChangeListener { _, isChecked -> - underlineExtend = isChecked - postEvent(EventBus.UP_CONFIG, arrayListOf(6, 9, 11)) - } - - binding.btnDottedLineBlack.apply { - progress = ReadBookConfig.durConfig.dottedBase.toInt() - onChanged = { - ReadBookConfig.durConfig.dottedBase = it.toFloat() - postEvent(EventBus.UP_CONFIG, arrayListOf(6, 8, 10)) - } - } - - binding.btnDottedLineWhile.apply { - progress = ReadBookConfig.durConfig.dottedRatio.toInt() - onChanged = { - ReadBookConfig.durConfig.dottedRatio = it.toFloat() - postEvent(EventBus.UP_CONFIG, arrayListOf(6, 8, 10)) - } - } - - binding.btnUnderlineColor.setOnClickListener { - ColorPickerDialog.newBuilder() - .setColor(ReadBookConfig.durConfig.curUnderlineColor()) - .setShowAlphaSlider(false) - .setDialogType(ColorPickerDialog.TYPE_CUSTOM) - .setDialogId(U_COLOR) - .show(requireActivity()) - } - - binding.btnUnderlineHeight.apply { - progress = ReadBookConfig.underlineHeight - onChanged = { - ReadBookConfig.underlineHeight = it - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 9, 6)) - } - } - - binding.btnUnderlinePadding.apply { - progress = ReadBookConfig.underlinePadding - onChanged = { - ReadBookConfig.underlinePadding = it - postEvent(EventBus.UP_CONFIG, arrayListOf(8, 9, 6)) - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/WaveUnderlineSpan.kt b/app/src/main/java/io/legado/app/ui/book/read/config/WaveUnderlineSpan.kt new file mode 100644 index 000000000..9f0990028 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/WaveUnderlineSpan.kt @@ -0,0 +1,81 @@ +package io.legado.app.ui.book.read.config + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.text.style.ReplacementSpan +import io.legado.app.utils.dpToPx + +/** + * 波浪线下划线 Span + */ +class WaveUnderlineSpan( + private val textColor: Int, + private val underlineColor: Int, + private val underlineWidth: Float = 1f, + private val underlineOffset: Float = 6f, +) : ReplacementSpan() { + + private val offsetPx = underlineOffset.toInt().dpToPx() + private val waveAmplitude = 3.dpToPx().toFloat() + private val extraSpace = offsetPx + waveAmplitude.toInt() + + override fun getSize( + paint: Paint, + text: CharSequence, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int { + if (fm != null) { + val metrics = paint.fontMetricsInt + fm.top = metrics.top + fm.ascent = metrics.ascent + fm.descent = metrics.descent + extraSpace + fm.bottom = metrics.bottom + extraSpace + } + return paint.measureText(text, start, end).toInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + val textStr = text.subSequence(start, end).toString() + paint.color = textColor + canvas.drawText(textStr, x, y.toFloat(), paint) + + val width = paint.measureText(text, start, end) + val lineY = y + offsetPx + val waveLength = 12.dpToPx().toFloat() + val wavePaint = Paint(paint).apply { + color = underlineColor + style = Paint.Style.STROKE + strokeWidth = underlineWidth.dpToPx() + isAntiAlias = true + } + val path = Path().apply { moveTo(x, lineY.toFloat()) } + var currentX = x + val endX = x + width + while (currentX < endX) { + val nextX = (currentX + waveLength).coerceAtMost(endX) + val midX = (currentX + nextX) / 2 + path.quadTo(midX, lineY - waveAmplitude, nextX, lineY.toFloat()) + currentX = nextX + if (currentX < endX) { + val nextX2 = (currentX + waveLength).coerceAtMost(endX) + val midX2 = (currentX + nextX2) / 2 + path.quadTo(midX2, lineY + waveAmplitude, nextX2, lineY.toFloat()) + currentX = nextX2 + } + } + canvas.drawPath(path, wavePaint) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/AutoPager.kt b/app/src/main/java/io/legado/app/ui/book/read/page/AutoPager.kt index d3514b93b..164d6c164 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/AutoPager.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/page/AutoPager.kt @@ -25,6 +25,7 @@ class AutoPager(private val readView: ReadView) : Runnable { private var lastTimeMillis = 0L private var canvasRecorder = CanvasRecorderFactory.create() private val paint by lazy { Paint() } + var onStop: (() -> Unit)? = null fun start() { @@ -52,6 +53,7 @@ class AutoPager(private val readView: ReadView) : Runnable { readView.invalidate() reset() canvasRecorder.recycle() + onStop?.invoke() } fun pause() { diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt b/app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt index 855aac3be..915c1c5f4 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt @@ -47,7 +47,9 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at style = Paint.Style.FILL } } - private var callBack: CallBack + private var callBack: CallBack? = null + private val requireCallBack: CallBack + get() = callBack ?: activity as CallBack private val visibleRect = ChapterProvider.visibleRect val selectStart = TextPos(0, -1, -1) private val selectEnd = TextPos(0, -1, -1) @@ -59,8 +61,8 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at var reverseEndCursor = false //滚动参数 - private val pageFactory get() = callBack.pageFactory - private val pageDelegate get() = callBack.pageDelegate + private val pageFactory get() = requireCallBack.pageFactory + private val pageDelegate get() = requireCallBack.pageDelegate private var pageOffset = 0 private var autoPager: AutoPager? = null private var isScroll = false @@ -75,8 +77,8 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at } } - init { - callBack = activity as CallBack + fun setCallBack(callBack: CallBack) { + this.callBack = callBack } /** @@ -116,7 +118,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at private fun drawPage(canvas: Canvas) { var relativeOffset = relativeOffset(0) textPage.draw(this, canvas, relativeOffset) - if (!callBack.isScroll) return + if (!requireCallBack.isScroll) return //滚动翻页 if (!pageFactory.hasNext()) return val textPage1 = relativePage(1) @@ -190,10 +192,10 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at if (curPage.render(view)) { invalidate = true } - if (hasNext() && nextPage.render(view) && callBack.isScroll) { + if (hasNext() && nextPage.render(view) && requireCallBack.isScroll) { invalidate = true } - if (hasNextPlus() && nextPlusPage.render(view) && callBack.isScroll + if (hasNextPlus() && nextPlusPage.render(view) && requireCallBack.isScroll && relativeOffset(2) < ChapterProvider.visibleHeight ) { invalidate = true @@ -222,7 +224,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at ) { touch(x, y) { _, textPos, _, _, column -> when (column) { - is ImageColumn -> callBack.onImageLongPress(x, y, column.src) + is ImageColumn -> requireCallBack.onImageLongPress(x, y, column.src) is TextColumn -> { if (!selectAble) return@touch column.selected = true @@ -275,10 +277,10 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at val click = column.click val src = column.src if (!click.isNullOrBlank()) { - callBack.clickImg(click, src) + requireCallBack.clickImg(click, src) handled = true } else { - handled = callBack.oldClickImg(src) + handled = requireCallBack.oldClickImg(src) } } } @@ -290,7 +292,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at if (doubleClick) { val click = column.click if (!click.isNullOrBlank()) { - callBack.clickImg(click, column.src) + requireCallBack.clickImg(click, column.src) handled = true } } else { @@ -301,7 +303,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at if (!debounceClick) { val click = column.click if (!click.isNullOrBlank()) { - callBack.clickImg(click, column.src) + requireCallBack.clickImg(click, column.src) handled = true } } @@ -405,7 +407,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at relativeOffset = relativeOffset(relativePos) if (relativePos > 0) { //滚动翻页 - if (!callBack.isScroll) return + if (!requireCallBack.isScroll) return if (relativeOffset >= ChapterProvider.visibleHeight) return } val textPage = relativePage(relativePos) @@ -448,7 +450,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at relativeOffset = relativeOffset(relativePos) if (relativePos > 0) { //滚动翻页 - if (!callBack.isScroll) return + if (!requireCallBack.isScroll) return if (relativeOffset >= ChapterProvider.visibleHeight) return } val textPage = relativePage(relativePos) @@ -497,7 +499,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at relativeOffset = relativeOffset(relativePos) if (relativePos > 0) { //滚动翻页 - if (!callBack.isScroll) break + if (!requireCallBack.isScroll) break if (relativeOffset >= ChapterProvider.visibleHeight) break } val textPage = relativePage(relativePos) @@ -522,7 +524,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at relativeOffset = relativeOffset(relativePos) if (relativePos > 0) { //滚动翻页 - if (!callBack.isScroll) break + if (!requireCallBack.isScroll) break if (relativeOffset >= ChapterProvider.visibleHeight) break } val textPage = relativePage(relativePos) @@ -594,7 +596,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at if (!selectStart.isSelected() && !selectEnd.isSelected()) { return } - val last = if (callBack.isScroll) 2 else 0 + val last = if (requireCallBack.isScroll) 2 else 0 val textPos = TextPos(0, 0, 0) for (relativePos in 0..last) { textPos.relativePagePos = relativePos @@ -608,7 +610,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at val compareEnd = textPos.compare(selectEnd) column.selected = compareStart >= 0 && compareEnd <= 0 column.isSearchResult = - column.selected && callBack.isSelectingSearchResult + column.selected && requireCallBack.isSelectingSearchResult if (column.isSearchResult) { textPage.searchResult.add(column) } @@ -620,13 +622,13 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at } private fun upSelectedStart(x: Float, y: Float, top: Float) { - callBack.run { + requireCallBack.run { upSelectedStart(x + imgBgPaddingStart, y + headerHeight, top + headerHeight) } } private fun upSelectedEnd(x: Float, y: Float) { - callBack.run { + requireCallBack.run { upSelectedEnd(x + imgBgPaddingStart, y + headerHeight) } } @@ -637,7 +639,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at } fun cancelSelect(clearSearchResult: Boolean = false) { - val last = if (callBack.isScroll) 2 else 0 + val last = if (requireCallBack.isScroll) 2 else 0 for (relativePos in 0..last) { val textPage = relativePage(relativePos) textPage.lines.forEach { textLine -> @@ -655,7 +657,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at selectStart.reset() selectEnd.reset() postInvalidate() - callBack.onCancelSelect() + requireCallBack.onCancelSelect() } fun getSelectedText(): String { @@ -742,7 +744,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at } override fun canScrollVertically(direction: Int): Boolean { - return callBack.isScroll && pageFactory.hasNext() + return requireCallBack.isScroll && pageFactory.hasNext() } override fun dispatchTouchEvent(event: MotionEvent): Boolean { @@ -757,7 +759,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at scrollY = 0 } } - return callBack.onLongScreenshotTouchEvent(event) + return requireCallBack.onLongScreenshotTouchEvent(event) } companion object { diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt b/app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt index b3901e4c9..1b22fa616 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt @@ -18,9 +18,7 @@ import io.legado.app.data.entities.Bookmark import io.legado.app.databinding.ViewBookPageBinding import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ReadBookConfig -import io.legado.app.help.config.ReadTipConfig import io.legado.app.model.ReadBook -import io.legado.app.ui.book.read.ReadBookActivity import io.legado.app.ui.book.read.page.entities.TextLine import io.legado.app.ui.book.read.page.entities.TextPage import io.legado.app.ui.book.read.page.entities.TextPos @@ -41,10 +39,12 @@ import java.util.Date /** * 页面视图 */ -class PageView(context: Context) : FrameLayout(context) { +class PageView( + context: Context, + callBack: ContentTextView.CallBack? = null, +) : FrameLayout(context) { private val binding = ViewBookPageBinding.inflate(LayoutInflater.from(context), this, true) - private val readBookActivity get() = activity as? ReadBookActivity private var battery = 100 private var tvTitle: BatteryView? = null private var tvTime: BatteryView? = null @@ -80,6 +80,7 @@ class PageView(context: Context) : FrameLayout(context) { } init { + callBack?.let { binding.contentTextView.setCallBack(it) } upStyle() binding.vwStatusBar.applyStatusBarPadding() binding.vwNavigationBar.applyNavigationBarPadding() @@ -94,13 +95,13 @@ class PageView(context: Context) : FrameLayout(context) { upTipStyle() ReadBookConfig.let { val textColor = it.textColor - val headerColor = with(ReadTipConfig) { + val headerColor = with(ReadBookConfig) { if (tipHeaderColor == 0) textColor else tipHeaderColor } - val footerColor = with(ReadTipConfig) { + val footerColor = with(ReadBookConfig) { if (tipFooterColor == 0) textColor else tipFooterColor } - val tipDividerColor = with(ReadTipConfig) { + val tipDividerColor = with(ReadBookConfig) { when (tipDividerColor) { -1 -> ContextCompat.getColor(context, R.color.divider) 0 -> textColor @@ -160,7 +161,7 @@ class PageView(context: Context) : FrameLayout(context) { */ fun upStatusBar() = with(binding.vwStatusBar) { setPadding(paddingLeft, context.statusBarHeight, paddingRight, paddingBottom) - isGone = ReadBookConfig.hideStatusBar || readBookActivity?.isInMultiWindow == true + isGone = ReadBookConfig.hideStatusBar || activity?.isInMultiWindowMode == true } fun upNavigationBar() { @@ -195,20 +196,20 @@ class PageView(context: Context) : FrameLayout(context) { tvFooterLeft.tag = null tvFooterMiddle.tag = null tvFooterRight.tag = null - llHeader.isGone = when (ReadTipConfig.headerMode) { + llHeader.isGone = when (ReadBookConfig.headerMode) { 1 -> false 2 -> true else -> !ReadBookConfig.hideStatusBar } - llFooter.isGone = when (ReadTipConfig.footerMode) { + llFooter.isGone = when (ReadBookConfig.footerMode) { 1 -> true else -> false } - ReadTipConfig.apply { - tvHeaderLeft.isGone = tipHeaderLeft == none - tvHeaderMiddle.isGone = tipHeaderMiddle == none - if (tipHeaderRight == none) { - if (tipHeaderMiddle == none && tipHeaderLeft == none) { + ReadBookConfig.apply { + tvHeaderLeft.isGone = tipHeaderLeft == tipNone + tvHeaderMiddle.isGone = tipHeaderMiddle == tipNone + if (tipHeaderRight == tipNone) { + if (tipHeaderMiddle == tipNone && tipHeaderLeft == tipNone) { tvHeaderRight.isGone = true } else { tvHeaderRight.isGone = false @@ -218,10 +219,10 @@ class PageView(context: Context) : FrameLayout(context) { tvHeaderRight.isGone = false tvHeaderRight.batteryMode = BatteryView.BatteryMode.NO_BATTERY } - tvFooterLeft.isGone = tipFooterLeft == none - tvFooterMiddle.isGone = tipFooterMiddle == none - if (tipFooterRight == none) { - if (tipFooterLeft == none && tipFooterMiddle == none) { + tvFooterLeft.isGone = tipFooterLeft == tipNone + tvFooterMiddle.isGone = tipFooterMiddle == tipNone + if (tipFooterRight == tipNone) { + if (tipFooterLeft == tipNone && tipFooterMiddle == tipNone) { tvFooterRight.isGone = true } else { tvFooterRight.isGone = false @@ -234,103 +235,103 @@ class PageView(context: Context) : FrameLayout(context) { } val tipTypeface = loadTypeface(ReadBookConfig.headerFont) ?: ChapterProvider.typeface val tipTextSize = ReadBookConfig.headerFontSize.toFloat() - tvTitle = getTipView(ReadTipConfig.chapterTitle)?.apply { - tag = ReadTipConfig.chapterTitle + tvTitle = getTipView(ReadBookConfig.tipChapterTitle)?.apply { + tag = ReadBookConfig.tipChapterTitle typeface = tipTypeface textSize = tipTextSize batteryMode = BatteryView.BatteryMode.NO_BATTERY } - tvTitleArrow = getTipView(ReadTipConfig.chapterTitleArrow)?.apply { - tag = ReadTipConfig.chapterTitleArrow + tvTitleArrow = getTipView(ReadBookConfig.tipChapterTitleArrow)?.apply { + tag = ReadBookConfig.tipChapterTitleArrow typeface = Typeface.DEFAULT textSize = tipTextSize batteryMode = BatteryView.BatteryMode.ARROW } - tvTitleArrowClassic = getTipView(ReadTipConfig.chapterTitleArrowClassic)?.apply { - tag = ReadTipConfig.chapterTitleArrowClassic + tvTitleArrowClassic = getTipView(ReadBookConfig.tipChapterTitleArrowClassic)?.apply { + tag = ReadBookConfig.tipChapterTitleArrowClassic typeface = tipTypeface textSize = tipTextSize batteryMode = BatteryView.BatteryMode.ARROW } - tvTime = getTipView(ReadTipConfig.time)?.apply { - tag = ReadTipConfig.time + tvTime = getTipView(ReadBookConfig.tipTime)?.apply { + tag = ReadBookConfig.tipTime typeface = tipTypeface textSize = tipTextSize batteryMode = BatteryView.BatteryMode.NO_BATTERY } - tvBattery = getTipView(ReadTipConfig.battery)?.apply { - tag = ReadTipConfig.battery + tvBattery = getTipView(ReadBookConfig.tipBattery)?.apply { + tag = ReadBookConfig.tipBattery typeface = Typeface.DEFAULT textSize = tipTextSize batteryMode = BatteryView.BatteryMode.OUTER } - tvBatteryClassic = getTipView(ReadTipConfig.batteryClassic)?.apply { - tag = ReadTipConfig.batteryClassic + tvBatteryClassic = getTipView(ReadBookConfig.tipBatteryClassic)?.apply { + tag = ReadBookConfig.tipBatteryClassic textSize = tipTextSize batteryMode = BatteryView.BatteryMode.CLASSIC } - tvBatteryInside = getTipView(ReadTipConfig.batteryInside)?.apply { - tag = ReadTipConfig.batteryInside + tvBatteryInside = getTipView(ReadBookConfig.tipBatteryInside)?.apply { + tag = ReadBookConfig.tipBatteryInside typeface = Typeface.DEFAULT textSize = tipTextSize batteryMode = BatteryView.BatteryMode.INNER } - tvBatteryIcon = getTipView(ReadTipConfig.batteryIcon)?.apply { - tag = ReadTipConfig.batteryIcon + tvBatteryIcon = getTipView(ReadBookConfig.tipBatteryIcon)?.apply { + tag = ReadBookConfig.tipBatteryIcon typeface = Typeface.DEFAULT textSize = tipTextSize batteryMode = BatteryView.BatteryMode.ICON } - tvPage = getTipView(ReadTipConfig.page)?.apply { - tag = ReadTipConfig.page + tvPage = getTipView(ReadBookConfig.tipPage)?.apply { + tag = ReadBookConfig.tipPage typeface = tipTypeface textSize = tipTextSize batteryMode = BatteryView.BatteryMode.NO_BATTERY } - tvTotalProgress = getTipView(ReadTipConfig.totalProgress)?.apply { - tag = ReadTipConfig.totalProgress + tvTotalProgress = getTipView(ReadBookConfig.tipTotalProgress)?.apply { + tag = ReadBookConfig.tipTotalProgress batteryMode = BatteryView.BatteryMode.NO_BATTERY typeface = tipTypeface textSize = tipTextSize } - tvTotalProgress1 = getTipView(ReadTipConfig.totalProgress1)?.apply { - tag = ReadTipConfig.totalProgress1 + tvTotalProgress1 = getTipView(ReadBookConfig.tipTotalProgress1)?.apply { + tag = ReadBookConfig.tipTotalProgress1 batteryMode = BatteryView.BatteryMode.NO_BATTERY typeface = tipTypeface textSize = tipTextSize } - tvPageAndTotal = getTipView(ReadTipConfig.pageAndTotal)?.apply { - tag = ReadTipConfig.pageAndTotal + tvPageAndTotal = getTipView(ReadBookConfig.tipPageAndTotal)?.apply { + tag = ReadBookConfig.tipPageAndTotal batteryMode = BatteryView.BatteryMode.NO_BATTERY typeface = tipTypeface textSize = tipTextSize } - tvBookName = getTipView(ReadTipConfig.bookName)?.apply { - tag = ReadTipConfig.bookName + tvBookName = getTipView(ReadBookConfig.tipBookName)?.apply { + tag = ReadBookConfig.tipBookName batteryMode = BatteryView.BatteryMode.NO_BATTERY typeface = tipTypeface textSize = tipTextSize } - tvTimeBattery = getTipView(ReadTipConfig.timeBattery)?.apply { - tag = ReadTipConfig.timeBattery + tvTimeBattery = getTipView(ReadBookConfig.tipTimeBattery)?.apply { + tag = ReadBookConfig.tipTimeBattery typeface = Typeface.DEFAULT textSize = tipTextSize batteryMode = BatteryView.BatteryMode.TIME } - tvTimeBatteryClassic = getTipView(ReadTipConfig.timeBatteryClassic)?.apply { - tag = ReadTipConfig.timeBatteryClassic + tvTimeBatteryClassic = getTipView(ReadBookConfig.tipTimeBatteryClassic)?.apply { + tag = ReadBookConfig.tipTimeBatteryClassic typeface = tipTypeface textSize = tipTextSize batteryMode = BatteryView.BatteryMode.CLASSIC } - tvBatteryP = getTipView(ReadTipConfig.batteryPercentage)?.apply { - tag = ReadTipConfig.batteryPercentage + tvBatteryP = getTipView(ReadBookConfig.tipBatteryPercentage)?.apply { + tag = ReadBookConfig.tipBatteryPercentage batteryMode = BatteryView.BatteryMode.NO_BATTERY typeface = tipTypeface textSize = tipTextSize } - tvTimeBatteryP = getTipView(ReadTipConfig.timeBatteryPercentage)?.apply { - tag = ReadTipConfig.timeBatteryPercentage + tvTimeBatteryP = getTipView(ReadBookConfig.tipTimeBatteryPercentage)?.apply { + tag = ReadBookConfig.tipTimeBatteryPercentage batteryMode = BatteryView.BatteryMode.NO_BATTERY typeface = tipTypeface textSize = tipTextSize @@ -343,12 +344,12 @@ class PageView(context: Context) : FrameLayout(context) { */ private fun getTipView(tip: Int): BatteryView? = binding.run { return when (tip) { - ReadTipConfig.tipHeaderLeft -> tvHeaderLeft - ReadTipConfig.tipHeaderMiddle -> tvHeaderMiddle - ReadTipConfig.tipHeaderRight -> tvHeaderRight - ReadTipConfig.tipFooterLeft -> tvFooterLeft - ReadTipConfig.tipFooterMiddle -> tvFooterMiddle - ReadTipConfig.tipFooterRight -> tvFooterRight + ReadBookConfig.tipHeaderLeft -> tvHeaderLeft + ReadBookConfig.tipHeaderMiddle -> tvHeaderMiddle + ReadBookConfig.tipHeaderRight -> tvHeaderRight + ReadBookConfig.tipFooterLeft -> tvFooterLeft + ReadBookConfig.tipFooterMiddle -> tvFooterMiddle + ReadBookConfig.tipFooterRight -> tvFooterRight else -> null } } @@ -600,4 +601,4 @@ class PageView(context: Context) : FrameLayout(context) { val selectedText: String get() = binding.contentTextView.getSelectedText() val selectStartPos get() = binding.contentTextView.selectStart -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt b/app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt index 07b2ae669..2774a9e10 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt @@ -18,7 +18,7 @@ import io.legado.app.help.config.ReadBookConfig import io.legado.app.model.ReadAloud import io.legado.app.model.ReadBook import io.legado.app.service.BaseReadAloudService -import io.legado.app.ui.book.read.ContentEditDialog + import io.legado.app.ui.book.read.page.api.DataSource import io.legado.app.ui.book.read.page.delegate.CoverPageDelegate import io.legado.app.ui.book.read.page.delegate.FadePageDelegate @@ -40,7 +40,7 @@ import io.legado.app.ui.book.read.page.provider.TextPageFactory import io.legado.app.utils.activity import io.legado.app.utils.invisible import io.legado.app.utils.longToastOnUi -import io.legado.app.utils.showDialogFragment + import io.legado.app.utils.throttle import java.text.BreakIterator import java.util.Locale @@ -49,11 +49,17 @@ import kotlin.math.abs /** * 阅读视图 */ -class ReadView(context: Context, attrs: AttributeSet) : +class ReadView( + context: Context, + attrs: AttributeSet? = null, + callBack: CallBack? = null, + contentCallBack: ContentTextView.CallBack? = null, +) : FrameLayout(context, attrs), DataSource, LayoutProgressListener { - val callBack: CallBack get() = activity as CallBack + private var injectedCallBack: CallBack? = callBack + val callBack: CallBack get() = injectedCallBack ?: activity as CallBack var pageFactory: TextPageFactory = TextPageFactory(this) var pageDelegate: PageDelegate? = null private set(value) { @@ -63,9 +69,9 @@ class ReadView(context: Context, attrs: AttributeSet) : upContent() } override var isScroll = false - val prevPage by lazy { PageView(context) } - val curPage by lazy { PageView(context) } - val nextPage by lazy { PageView(context) } + val prevPage by lazy { PageView(context, contentCallBack) } + val curPage by lazy { PageView(context, contentCallBack) } + val nextPage by lazy { PageView(context, contentCallBack) } val defaultAnimationSpeed = 300 private var pressDown = false private var isMove = false @@ -446,7 +452,7 @@ class ReadView(context: Context, attrs: AttributeSet) : 5 -> ReadAloud.prevParagraph(context) 6 -> ReadAloud.nextParagraph(context) 7 -> callBack.addBookmark() - 8 -> activity?.showDialogFragment(ContentEditDialog()) + 8 -> callBack.openContentEdit() 9 -> callBack.changeReplaceRuleState() 10 -> callBack.openChapterList() 11 -> callBack.openSearchActivity(null) @@ -760,6 +766,7 @@ class ReadView(context: Context, attrs: AttributeSet) : fun showTextActionMenu() fun autoPageStop() fun openChapterList() + fun openContentEdit() fun addBookmark() fun changeReplaceRuleState() fun openSearchActivity(searchWord: String?) diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextLine.kt b/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextLine.kt index 7fad78178..5ae229b1e 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextLine.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextLine.kt @@ -1,9 +1,15 @@ package io.legado.app.ui.book.read.page.entities import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.BitmapShader import android.graphics.Canvas -import android.graphics.Paint.FontMetrics +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Shader import android.os.Build +import android.text.TextPaint import androidx.annotation.Keep import io.legado.app.help.PaintPool import io.legado.app.help.book.isImage @@ -13,11 +19,13 @@ import io.legado.app.model.ReadBook import io.legado.app.ui.book.read.page.ContentTextView import io.legado.app.ui.book.read.page.entities.TextPage.Companion.emptyTextPage import io.legado.app.ui.book.read.page.entities.column.BaseColumn +import io.legado.app.ui.book.read.page.entities.column.TextBaseColumn import io.legado.app.ui.book.read.page.entities.column.TextColumn import io.legado.app.ui.book.read.page.provider.ChapterProvider import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory import io.legado.app.utils.canvasrecorder.recordIfNeededThenDraw import io.legado.app.utils.dpToPx +import splitties.init.appCtx /** * 行信息 @@ -70,10 +78,11 @@ data class TextLine( var isLeftLine = true val useUnderline: Boolean get() = AppConfig.useUnderline + fun addColumn(column: BaseColumn) { if (column !is TextColumn) { onlyTextColumn = false - } else if (column.color != null) { + } else if (column.textColor != null || column.bgColor != null || column.underlineMode != 0 || column.bgImage.isNotEmpty()) { onlyTextColumn = false } column.textLine = this @@ -102,7 +111,7 @@ data class TextLine( return textColumns.size } - fun upTopBottom(durY: Float, textHeight: Float, fontMetrics: FontMetrics) { + fun upTopBottom(durY: Float, textHeight: Float, fontMetrics: android.graphics.Paint.FontMetrics) { lineTop = ChapterProvider.paddingTop + durY lineBottom = lineTop + textHeight lineBase = lineBottom - fontMetrics.descent @@ -127,10 +136,8 @@ data class TextLine( val visibleTop = ChapterProvider.paddingTop val visibleBottom = ChapterProvider.visibleBottom val visible = when { - // 完全可视 top >= visibleTop && bottom <= visibleBottom -> true top <= visibleTop && bottom >= visibleBottom -> true - // 上方第一行部分可视 top < visibleTop && bottom > visibleTop && bottom < visibleBottom -> { if (isImage) { true @@ -139,7 +146,6 @@ data class TextLine( visibleRate > 0.6 } } - // 下方第一行部分可视 top > visibleTop && top < visibleBottom && bottom > visibleBottom -> { if (isImage) { true @@ -148,7 +154,6 @@ data class TextLine( visibleRate > 0.6 } } - // 不可视 else -> false } return visible @@ -165,6 +170,8 @@ data class TextLine( } private fun drawTextLine(view: ContentTextView, canvas: Canvas) { + drawStyledBackgrounds(canvas) + drawBgColors(canvas) if (checkFastDraw()) { fastDrawTextLine(view, canvas) } else { @@ -177,6 +184,8 @@ data class TextLine( canvas.drawLine(lineStart + indentWidth, lineY, lineEnd, lineY, linePaint) } + drawStyledUnderlines(canvas) + if (ReadBookConfig.underline && !isImage && ReadBook.book?.isImage != true) { drawUnderline(canvas, ReadBookConfig.dottedLine) } @@ -246,7 +255,306 @@ data class TextLine( canvas.drawLine(startX, lineY, endX, lineY, paint) } + /** + * 绘制高亮规则匹配文本的背景图 + */ + private fun drawStyledBackgrounds(canvas: Canvas) { + if (isImage || columns.isEmpty()) return + if (columns.none { (it as? TextBaseColumn)?.bgImage?.isNotEmpty() == true }) return + var rangeStart = 0f + var rangeEnd = 0f + var currentBgImage = "" + var currentBgImageFit = 0 + var currentBgImageScale = 1f + var active = false + columns.forEachIndexed { index, column -> + val textColumn = column as? TextBaseColumn + val bgImage = textColumn?.bgImage ?: "" + val bgImageFit = textColumn?.bgImageFit ?: 0 + val bgImageScale = textColumn?.bgImageScale ?: 1f + when { + bgImage.isEmpty() && active -> { + drawBgImageSegment(canvas, rangeStart, rangeEnd, currentBgImage, currentBgImageFit, currentBgImageScale) + active = false + } + bgImage.isNotEmpty() && !active -> { + rangeStart = textColumn!!.start + rangeEnd = textColumn.end + currentBgImage = bgImage + currentBgImageFit = bgImageFit + currentBgImageScale = bgImageScale + active = true + } + bgImage.isNotEmpty() && bgImage == currentBgImage && bgImageFit == currentBgImageFit && bgImageScale == currentBgImageScale -> { + rangeEnd = textColumn!!.end + } + bgImage.isNotEmpty() -> { + drawBgImageSegment(canvas, rangeStart, rangeEnd, currentBgImage, currentBgImageFit, currentBgImageScale) + rangeStart = textColumn!!.start + rangeEnd = textColumn.end + currentBgImage = bgImage + currentBgImageFit = bgImageFit + currentBgImageScale = bgImageScale + } + } + if (active && index == columns.lastIndex) { + drawBgImageSegment(canvas, rangeStart, rangeEnd, currentBgImage, currentBgImageFit, currentBgImageScale) + } + } + } + /** + * 绘制高亮规则匹配文本的背景色 + */ + private fun drawBgColors(canvas: Canvas) { + if (isImage || columns.isEmpty()) return + var i = 0 + while (i < columns.size) { + val col = columns[i] as? TextBaseColumn + if (col == null) { i++; continue } + val color = col.bgColor + if (color == null) { i++; continue } + val left = col.start + var right = col.end + var j = i + 1 + while (j < columns.size) { + val next = columns[j] as? TextBaseColumn ?: break + if (next.bgColor != color) break + right = next.end + j++ + } + val top = 0f + val bottom = height.toFloat() + val paint = PaintPool.obtain() + paint.color = color + paint.style = Paint.Style.FILL + canvas.drawRect(left, top, right, bottom, paint) + PaintPool.recycle(paint) + i = j + } + } + + /** + * 绘制高亮规则匹配文本的下划线段 + */ + private fun drawStyledUnderlines(canvas: Canvas) { + if (isImage || columns.isEmpty()) return + if (columns.none { (it as? TextBaseColumn)?.underlineMode?.let { m -> m != 0 } == true }) return + var rangeStart = 0f + var rangeEnd = 0f + var mode = 0 + var color = 0 + var width = 1f + var offset = 2f + var svgPath = "" + var active = false + columns.forEachIndexed { index, column -> + val textColumn = column as? TextBaseColumn + val currentMode = textColumn?.underlineMode ?: 0 + val currentColor = textColumn?.underlineColor + ?: textColumn?.textColor + ?: ReadBookConfig.textColor + val currentWidth = textColumn?.underlineWidth ?: 1f + val currentOffset = textColumn?.underlineOffset ?: 2f + val currentSvgPath = textColumn?.underlineSvgPath ?: "" + val shouldContinue = active && + currentMode == mode && + currentColor == color && + currentWidth == width && + currentOffset == offset && + currentSvgPath == svgPath + when { + currentMode == 0 && active -> { + drawUnderlineSegment(canvas, rangeStart, rangeEnd, mode, color, width, offset, svgPath) + active = false + } + currentMode != 0 && !active -> { + rangeStart = textColumn!!.start + rangeEnd = textColumn.end + mode = currentMode + color = currentColor + width = currentWidth + offset = currentOffset + svgPath = currentSvgPath + active = true + } + currentMode != 0 && shouldContinue -> { + rangeEnd = textColumn!!.end + } + currentMode != 0 -> { + drawUnderlineSegment(canvas, rangeStart, rangeEnd, mode, color, width, offset, svgPath) + rangeStart = textColumn!!.start + rangeEnd = textColumn.end + mode = currentMode + color = currentColor + width = currentWidth + offset = currentOffset + svgPath = currentSvgPath + } + } + if (active && index == columns.lastIndex) { + drawUnderlineSegment(canvas, rangeStart, rangeEnd, mode, color, width, offset, svgPath) + } + } + } + + /** + * 绘制单段下划线 + */ + private fun drawUnderlineSegment( + canvas: Canvas, + startX: Float, + endX: Float, + underlineMode: Int, + underlineColor: Int, + underlineWidth: Float = 1f, + underlineOffset: Float = 2f, + svgPathStr: String = "", + ) { + val paint = PaintPool.obtain() + paint.set(ChapterProvider.contentPaint) + paint.color = underlineColor + paint.strokeWidth = underlineWidth.dpToPx() + paint.style = Paint.Style.STROKE + val lineY = height + underlineOffset.dpToPx() + when (underlineMode) { + 1 -> canvas.drawLine(startX, lineY, endX, lineY, paint) + 2 -> drawDashedLine(canvas, paint, startX, lineY, endX, underlineWidth) + 3 -> drawWavyLine(canvas, paint, startX, lineY, endX, underlineWidth) + 4 -> { + val line2Y = lineY + doubleLineGap + underlineWidth.dpToPx() + canvas.drawLine(startX, lineY, endX, lineY, paint) + canvas.drawLine(startX, line2Y, endX, line2Y, paint) + } + 5 -> { + if (svgPathStr.isNotBlank()) { + drawSvgPath(canvas, startX, endX, lineY, svgPathStr, paint) + } + } + } + PaintPool.recycle(paint) + } + + private fun drawDashedLine(canvas: Canvas, paint: Paint, startX: Float, y: Float, endX: Float, underlineWidth: Float) { + paint.strokeWidth = underlineWidth.dpToPx() + val dashLen = 8.dpToPx().toFloat() + val gapLen = 5.dpToPx().toFloat() + var x = startX + while (x < endX) { + val x2 = (x + dashLen).coerceAtMost(endX) + canvas.drawLine(x, y, x2, y, paint) + x += dashLen + gapLen + } + } + + private fun drawWavyLine(canvas: Canvas, paint: Paint, startX: Float, y: Float, endX: Float, underlineWidth: Float) { + paint.strokeWidth = underlineWidth.dpToPx() + val path = Path() + val waveAmp = waveAmplitude + val waveLen = waveLength + path.moveTo(startX, y) + var currentX = startX + while (currentX < endX) { + val nextX = (currentX + waveLen).coerceAtMost(endX) + val midX = (currentX + nextX) / 2 + path.quadTo(midX, y - waveAmp, nextX, y) + currentX = nextX + if (currentX < endX) { + val nextX2 = (currentX + waveLen).coerceAtMost(endX) + val midX2 = (currentX + nextX2) / 2 + path.quadTo(midX2, y + waveAmp, nextX2, y) + currentX = nextX2 + } + } + canvas.drawPath(path, paint) + } + + private fun drawSvgPath( + canvas: Canvas, + startX: Float, + endX: Float, + lineY: Float, + svgPathStr: String, + paint: Paint + ) { + val baseWidth = 100f + val baseY = 50f + val path = io.legado.app.ui.book.read.config.SvgPathParser.parse(svgPathStr) ?: return + + val width = endX - startX + val scaleX = width / baseWidth + val scaleY = 1f + val translateX = startX + val translateY = lineY - baseY + + canvas.save() + canvas.translate(translateX, translateY) + canvas.scale(scaleX, scaleY) + canvas.drawPath(path, paint) + canvas.restore() + } + + private fun drawBgImageSegment( + canvas: Canvas, + startX: Float, + endX: Float, + bgImage: String, + bgImageFit: Int, + bgImageScale: Float, + ) { + val bitmap = getBgBitmap(bgImage) ?: return + val paint = PaintPool.obtain() + paint.style = Paint.Style.FILL + paint.isAntiAlias = true + paint.isFilterBitmap = true + val top = bgPaddingTop + val bottom = height - bgPaddingBottom + val rectWidth = endX - startX + val rectHeight = bottom - top + val scale = bgImageScale.coerceIn(0.1f, 5f) + when (bgImageFit) { + 1 -> { + val sw = rectWidth * scale + val sh = rectHeight * scale + val dx = startX + (rectWidth - sw) / 2f + val dy = top + (rectHeight - sh) / 2f + canvas.save() + canvas.clipRect(startX, top, endX, bottom) + canvas.drawBitmap(bitmap, null, android.graphics.RectF(dx, dy, dx + sw, dy + sh), paint) + canvas.restore() + } + 2 -> { + val bw = bitmap.width.toFloat() + val bh = bitmap.height.toFloat() + val fitScale = (rectWidth / bw).coerceAtLeast(rectHeight / bh) * scale + val scaledW = bw * fitScale + val scaledH = bh * fitScale + val dx = startX + (rectWidth - scaledW) / 2f + val dy = top + (rectHeight - scaledH) / 2f + canvas.save() + canvas.clipRect(startX, top, endX, bottom) + canvas.drawBitmap(bitmap, null, android.graphics.RectF(dx, dy, dx + scaledW, dy + scaledH), paint) + canvas.restore() + } + else -> { + val tileBitmap = if (scale != 1f) { + val sw = (bitmap.width * scale).toInt().coerceAtLeast(1) + val sh = (bitmap.height * scale).toInt().coerceAtLeast(1) + getScaledBitmap("${bgImage}_s${scale}", bitmap, sw, sh) + } else { + bitmap + } + val shader = BitmapShader(tileBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT) + val matrix = android.graphics.Matrix() + matrix.setTranslate(startX, top) + shader.setLocalMatrix(matrix) + paint.shader = shader + canvas.drawRect(startX, top, endX, bottom, paint) + paint.shader = null + } + } + PaintPool.recycle(paint) + } fun checkFastDraw(): Boolean { if (!AppConfig.optimizeRender || exceed || !onlyTextColumn || textPage.isMsgPage) { @@ -276,8 +584,43 @@ data class TextLine( val emptyTextLine = TextLine() private val atLeastApi26 = true private val atLeastApi35 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM + private val bgPaddingTop = 1.dpToPx().toFloat() + private val bgPaddingBottom = 1.dpToPx().toFloat() + private val waveAmplitude = 3.dpToPx().toFloat() + private val waveLength = 12.dpToPx().toFloat() + private val doubleLineGap = 3.dpToPx().toFloat() + private val bgBitmapCache = android.util.LruCache(16 * 1024 * 1024) + private val bgScaledBitmapCache = android.util.LruCache(8 * 1024 * 1024) + + /** + * Trims bitmap caches to free memory under pressure. + * Call from [android.app.Application.onTrimMemory]. + */ + fun trimCaches(level: Int) { + when { + level >= android.content.ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> { + bgBitmapCache.evictAll() + bgScaledBitmapCache.evictAll() + } + level >= android.content.ComponentCallbacks2.TRIM_MEMORY_MODERATE -> { + bgBitmapCache.trimToSize(4 * 1024 * 1024) + bgScaledBitmapCache.trimToSize(2 * 1024 * 1024) + } + level >= android.content.ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> { + bgBitmapCache.trimToSize(8 * 1024 * 1024) + bgScaledBitmapCache.trimToSize(4 * 1024 * 1024) + } + } + } + + private val bgSampleWidth by lazy { + appCtx.resources.displayMetrics.widthPixels + } + private val bgSampleHeight by lazy { + appCtx.resources.displayMetrics.heightPixels + } + private val wordSpacingWorking by lazy { - // issue 3785 3846 val paint = PaintPool.obtain() val text = "一二 三" val width1 = paint.measureText(text) @@ -291,6 +634,114 @@ data class TextLine( PaintPool.recycle(paint) } } + + fun getBgBitmap(path: String): Bitmap? { + if (path.isBlank()) return null + bgBitmapCache.get(path)?.let { return it } + val bitmap = loadBgBitmap(path) ?: return null + bgBitmapCache.put(path, bitmap) + return bitmap + } + + private fun getScaledBitmap(path: String, source: Bitmap, width: Int, height: Int): Bitmap { + if (width <= 0 || height <= 0) return source + val key = "${path}_${width}_${height}" + bgScaledBitmapCache.get(key)?.let { return it } + val scaled = Bitmap.createScaledBitmap(source, width, height, true) + bgScaledBitmapCache.put(key, scaled) + return scaled + } + + private fun loadBgBitmap(path: String): Bitmap? { + return try { + val ctx = appCtx + if (path.startsWith("assets://")) { + val assetPath = path.removePrefix("assets://") + ctx.assets.open(assetPath).use { input -> + decodeSampledBitmap(input) + } + } else if (path.startsWith("content://")) { + val uri = android.net.Uri.parse(path) + ctx.contentResolver.openInputStream(uri)?.use { input -> + decodeSampledBitmap(input) + } + } else { + val file = java.io.File(path) + if (file.exists()) { + decodeSampledBitmapFile(path) + } else { + val assetPath = if (path.startsWith("bg/")) path else "bg/$path" + runCatching { + ctx.assets.open(assetPath).use { input -> + decodeSampledBitmap(input) + } + }.getOrNull() + } + } + } catch (e: Exception) { + null + } + } + + private fun decodeSampledBitmap(input: java.io.InputStream): Bitmap? { + val buffered = if (input.markSupported()) input else java.io.BufferedInputStream(input) + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + buffered.mark(buffered.available()) + BitmapFactory.decodeStream(buffered, null, options) + options.inSampleSize = calculateInSampleSize(options, bgSampleWidth, bgSampleHeight) + options.inJustDecodeBounds = false + buffered.reset() + return BitmapFactory.decodeStream(buffered, null, options) + } + + private fun decodeSampledBitmapFile(path: String): Bitmap? { + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, options) + options.inSampleSize = calculateInSampleSize(options, bgSampleWidth, bgSampleHeight) + options.inJustDecodeBounds = false + return BitmapFactory.decodeFile(path, options) + } + + private fun calculateInSampleSize( + options: BitmapFactory.Options, + reqWidth: Int, + reqHeight: Int + ): Int { + val (height, width) = options.outHeight to options.outWidth + var inSampleSize = 1 + if (height > reqHeight || width > reqWidth) { + val halfHeight = height / 2 + val halfWidth = width / 2 + while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { + inSampleSize *= 2 + } + } + return inSampleSize + } + + fun cleanupUnusedBgImages(context: android.content.Context, usedPaths: Set) { + val dir = java.io.File(context.filesDir, "bg_images") + if (!dir.exists()) return + dir.listFiles()?.forEach { file -> + if (file.absolutePath !in usedPaths) { + runCatching { file.delete() } + } + } + } + + fun copyBgImageToInternal(context: android.content.Context, sourcePath: String): String? { + return runCatching { + val sourceFile = java.io.File(sourcePath) + if (!sourceFile.exists() || !sourceFile.isFile) return@runCatching null + val dir = java.io.File(context.filesDir, "bg_images") + if (!dir.exists()) dir.mkdirs() + val targetFile = java.io.File(dir, sourceFile.name) + if (!targetFile.exists() || targetFile.length() != sourceFile.length()) { + sourceFile.copyTo(targetFile, overwrite = true) + } + targetFile.absolutePath + }.getOrNull() + } } } diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextBaseColumn.kt b/app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextBaseColumn.kt index e04556a7d..1711a1b28 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextBaseColumn.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextBaseColumn.kt @@ -7,6 +7,16 @@ interface TextBaseColumn : BaseColumn { override var start: Float override var end: Float val charData: String + val textColor: Int? + val bgColor: Int? + val underlineMode: Int + val underlineColor: Int? + val underlineWidth: Float + val underlineOffset: Float + val underlineSvgPath: String + val bgImage: String + val bgImageFit: Int + val bgImageScale: Float var selected: Boolean var isSearchResult: Boolean } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextColumn.kt b/app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextColumn.kt index 7330c1291..9ad6fda93 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextColumn.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextColumn.kt @@ -4,23 +4,27 @@ import android.graphics.Canvas import android.graphics.Typeface import android.os.Build import androidx.annotation.Keep -import androidx.core.net.toUri import io.legado.app.help.config.ReadBookConfig import io.legado.app.ui.book.read.page.ContentTextView import io.legado.app.ui.book.read.page.entities.TextLine import io.legado.app.ui.book.read.page.entities.TextLine.Companion.emptyTextLine import io.legado.app.ui.book.read.page.provider.ChapterProvider -import io.legado.app.utils.isContentScheme -import splitties.init.appCtx -import java.io.File @Keep data class TextColumn( override var start: Float, override var end: Float, override val charData: String, - var color: Int? = null, - var fontPath: String? = null, + override val textColor: Int? = null, + override val bgColor: Int? = null, + override val underlineMode: Int = 0, + override val underlineColor: Int? = null, + override val underlineWidth: Float = 1f, + override val underlineOffset: Float = 2f, + override val underlineSvgPath: String = "", + override val bgImage: String = "", + override val bgImageFit: Int = 0, + override val bgImageScale: Float = 1f, ) : TextBaseColumn { override var textLine: TextLine = emptyTextLine @@ -51,21 +55,23 @@ data class TextColumn( } else { ChapterProvider.contentPaint } - val textColor = color ?: if (!textLine.useUnderline && (textLine.isReadAloud || isSearchResult)) { + val drawColor = if (textLine.isReadAloud || isSearchResult) { ReadBookConfig.textAccentColor - } else if (textLine.isTitle && ReadBookConfig.titleColor != 0) { - ReadBookConfig.titleColor } else { - ReadBookConfig.textColor + textColor ?: if (textLine.isTitle && ReadBookConfig.titleColor != 0) { + ReadBookConfig.titleColor + } else { + ReadBookConfig.textColor + } } val needRestoreSize = textLine.titleTextSize != null - val needRestoreColor = textPaint.color != textColor - val customTypeface = fontPath?.let { getTypeface(it) } + val needRestoreColor = textPaint.color != drawColor + val customTypeface = getCustomTypeface() val needRestoreTypeface = customTypeface != null if (needRestoreSize) { val originalSize = textPaint.textSize textPaint.textSize = textLine.titleTextSize!! - if (needRestoreColor) textPaint.color = textColor + if (needRestoreColor) textPaint.color = drawColor if (needRestoreTypeface) textPaint.typeface = customTypeface val y = textLine.lineBase - textLine.lineTop drawText(canvas, y, textPaint) @@ -73,7 +79,7 @@ data class TextColumn( } else if (needRestoreColor || needRestoreTypeface) { val originalColor = textPaint.color val originalTypeface = textPaint.typeface - if (needRestoreColor) textPaint.color = textColor + if (needRestoreColor) textPaint.color = drawColor if (needRestoreTypeface) textPaint.typeface = customTypeface val y = textLine.lineBase - textLine.lineTop drawText(canvas, y, textPaint) @@ -98,30 +104,8 @@ data class TextColumn( } } - companion object { - private val typefaceCache = mutableMapOf() - - private fun getTypeface(fontPath: String): Typeface? { - return typefaceCache.getOrPut(fontPath) { - kotlin.runCatching { - when { - fontPath.isContentScheme() -> { - appCtx.contentResolver - .openFileDescriptor(fontPath.toUri(), "r")!! - .use { - Typeface.Builder(it.fileDescriptor).build() - } - } - - fontPath.isNotEmpty() -> { - Typeface.Builder(File(fontPath)).build() - } - - else -> null - } - }.getOrNull() - } - } + private fun getCustomTypeface(): Typeface? { + // TODO: HighlightRule 不再存储 fontPath,需要重新设计自定义字体方案 + return null } - } diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextHtmlColumn.kt b/app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextHtmlColumn.kt index 5cbd3bd69..719b58d4d 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextHtmlColumn.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextHtmlColumn.kt @@ -20,9 +20,20 @@ data class TextHtmlColumn( override val charData: String, val mTextSize: Float, val mTextColor: Int?, - val linkUrl: String? + val linkUrl: String?, + override val bgColor: Int? = null, + override val underlineMode: Int = 0, + override val underlineColor: Int? = null, + override val underlineWidth: Float = 1f, + override val underlineOffset: Float = 2f, + override val underlineSvgPath: String = "", + override val bgImage: String = "", + override val bgImageFit: Int = 0, + override val bgImageScale: Float = 1f, ) : TextBaseColumn { + override val textColor: Int? get() = mTextColor + override var textLine: TextLine = emptyTextLine private val textPaint: TextPaint by lazy { diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/provider/CharStyle.kt b/app/src/main/java/io/legado/app/ui/book/read/page/provider/CharStyle.kt new file mode 100644 index 000000000..d2ebfb7e7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/page/provider/CharStyle.kt @@ -0,0 +1,20 @@ +package io.legado.app.ui.book.read.page.provider + +/** + * 每个字符的高亮样式,由高亮规则匹配后填充 + */ +data class CharStyle( + val textColor: Int? = null, + val bgColor: Int? = null, + val underlineMode: Int = 0, + val underlineColor: Int? = null, + val underlineWidth: Float = 1f, + val underlineOffset: Float = 2f, + val underlineSvgPath: String = "", + val bgImage: String = "", + val bgImageFit: Int = 0, + val bgImageScale: Float = 1f, +) { + val hasStyle: Boolean + get() = textColor != null || bgColor != null || underlineMode != 0 || bgImage.isNotEmpty() +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt b/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt index bb59b687a..bb78a53e7 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt @@ -27,6 +27,8 @@ import io.legado.app.help.book.BookHelp import io.legado.app.help.book.getBookSource import io.legado.app.help.config.AppConfig import io.legado.app.help.config.ReadBookConfig +import io.legado.app.data.entities.HighlightRule +import io.legado.app.ui.book.read.config.HighlightRuleStore import io.legado.app.help.coroutine.Coroutine import io.legado.app.model.ImageProvider import io.legado.app.model.ReadBook @@ -70,13 +72,24 @@ class TextChapterLayout( ) { companion object { - private val regexCache = mutableMapOf() + @Volatile + private var cachedHighlightRules: List? = null fun invalidateRegexCache() { - regexCache.clear() + cachedHighlightRules = null } } + private val compiledHighlightRules: List + get() = cachedHighlightRules ?: HighlightRuleStore.loadEnabled().mapNotNull { rule -> + runCatching { + CompiledHighlightRule( + rule = rule, + regex = Regex(rule.pattern) + ) + }.getOrNull() + }.also { cachedHighlightRules = it } + @Volatile private var listener: LayoutProgressListener? = textChapter @@ -134,8 +147,6 @@ class TextChapterLayout( var channel = Channel(Channel.UNLIMITED) - private var globalRegexResult: RegexMatchResult? = null - init { job = Coroutine.async( @@ -255,61 +266,6 @@ class TextChapterLayout( } } else null - if (ReadBookConfig.regexColorRules.isNotEmpty()) { - val fullTextBuilder = StringBuilder() - allTitleSegments?.forEachIndexed { index, segment -> - val reviewImg = bookChapter.reviewImg - var reviewTxt = "" - if (index == allTitleSegments.lastIndex && reviewImg != null) { - reviewTxt = if (reviewImg.contains("TEXT")) reviewChar else srcReplaceChar - } - fullTextBuilder.append(segment.text).append(reviewTxt).append("\n") - } - contents.forEach { content -> - if (adaptSpecialStyle) { - val t = content.trim() - if (t == "[newpage]" || t.startsWith("")) { - fullTextBuilder.append(content).append("\n") - return@forEach - } - } - val text = content.replace(srcReplaceCharC, srcReplaceCharD) - if (isTextImageStyle) { - val matcher = AppPattern.imgPattern.matcher(text) - val ssb = StringBuffer() - while (matcher.find()) { - if (matcher.group(1) != null) { - matcher.appendReplacement(ssb, srcReplaceChar) - } - } - matcher.appendTail(ssb) - fullTextBuilder.append(ssb.toString()) - } else { - val matcher = AppPattern.imgPattern.matcher(text) - var start = 0 - while (matcher.find()) { - val imgSrc = matcher.group(1) ?: continue - val iStyle = if (imgSrc.contains("TEXT")) "text" else imageStyle - if (start < matcher.start()) { - fullTextBuilder.append(text.substring(start, matcher.start())) - } - if (iStyle == "text" || iStyle == "TEXT") { - fullTextBuilder.append(if (iStyle == "TEXT") reviewChar else srcReplaceChar) - } else { - fullTextBuilder.append(" ") - } - start = matcher.end() - } - if (start < text.length) { - fullTextBuilder.append(text.substring(start)) - } - if (AppConfig.enableReview) fullTextBuilder.append(reviewChar) - } - fullTextBuilder.append("\n") - } - preApplyRegexColorRules(fullTextBuilder.toString()) - } - var currentOffset = 0 if (allTitleSegments != null) { @@ -916,7 +872,7 @@ class TextChapterLayout( private fun extractTextColor(spanned: Spanned, index: Int): Int? { val foregroundSpans = spanned.getSpans(index, index + 1, ForegroundColorSpan::class.java) - return foregroundSpans.firstOrNull()?.foregroundColor + return foregroundSpans.lastOrNull()?.foregroundColor } private fun extractLinkUrl(spanned: Spanned, index: Int): String? { @@ -948,9 +904,9 @@ class TextChapterLayout( clickList: LinkedList? = null, offset: Int = -1 ) { + val charStyles = applyHighlightRules(text, isTitle) val widthsArray = allocateFloatArray(text.length) textPaint.getTextWidthsCompat(text, widthsArray) - val colorMap = applyRegexColorRules(text, offset) val layout = if (useZhLayout) { val (words, widths) = measureTextSplit(text, widthsArray) val indentSize = if (isFirstLine) paragraphIndent.length else 0 @@ -1002,21 +958,15 @@ class TextChapterLayout( val (words, widths) = measureTextSplit(lineText, widthsArray, lineStart) val desiredWidth = widths.fastSum() textLine.text = lineText - val lineWordStyles = if (colorMap != null) { - buildWordStyles(words, lineText, colorMap.colorArray, colorMap.fontPathArray, lineStart) - } else null when (lineIndex) { 0 if layout.lineCount > 1 && !isTitle && isFirstLine -> { - //多行的第一行 非标题 addCharsToLineFirst( book, absStartX, textLine, words, textPaint, - desiredWidth, widths, srcList, clickList, lineWordStyles + desiredWidth, widths, srcList, clickList, charStyles, lineStart ) } layout.lineCount - 1 -> { - //最后一行、单行 - //标题x轴居中 val startX = if ( isTitle && (isMiddleTitle || emptyContent || isVolumeTitle @@ -1028,7 +978,7 @@ class TextChapterLayout( } addCharsToLineNatural( book, absStartX, textLine, words, - startX, !isTitle && lineIndex == 0, widths, srcList, clickList, lineWordStyles + startX, !isTitle && lineIndex == 0, widths, srcList, clickList, charStyles, lineStart ) } else -> { @@ -1037,17 +987,15 @@ class TextChapterLayout( (isMiddleTitle || emptyContent || isVolumeTitle || imageStyle?.uppercase() == Book.imgStyleSingle) ) { - //标题居中 val startX = (visibleWidth - desiredWidth) / 2 addCharsToLineNatural( book, absStartX, textLine, words, - startX, false, widths, srcList, clickList, lineWordStyles + startX, false, widths, srcList, clickList, charStyles, lineStart ) } else { - //中间行 addCharsToLineMiddle( book, absStartX, textLine, words, textPaint, - desiredWidth, 0f, widths, srcList, clickList, lineWordStyles + desiredWidth, 0f, widths, srcList, clickList, charStyles, lineStart ) } } @@ -1101,13 +1049,14 @@ class TextChapterLayout( textWidths: List, srcList: LinkedList?, clickList: LinkedList?, - wordStyles: List? = null + charStyles: Array?, + lineStart: Int ) { var x = 0f if (!textFullJustify) { addCharsToLineNatural( book, absStartX, textLine, words, - x, true, textWidths, srcList, clickList, wordStyles + x, true, textWidths, srcList, clickList, charStyles, lineStart ) return } @@ -1128,10 +1077,10 @@ class TextChapterLayout( if (words.size > bodyIndent.length) { val text1 = words.subList(bodyIndent.length, words.size) val textWidths1 = textWidths.subList(bodyIndent.length, textWidths.size) - val wordStyles1 = wordStyles?.subList(bodyIndent.length, wordStyles.size) + val lineStart1 = lineStart + bodyIndent.length addCharsToLineMiddle( book, absStartX, textLine, text1, textPaint, - desiredWidth, x, textWidths1, srcList, clickList, wordStyles1 + desiredWidth, x, textWidths1, srcList, clickList, charStyles, lineStart1 ) } } @@ -1150,12 +1099,13 @@ class TextChapterLayout( textWidths: List, srcList: LinkedList?, clickList: LinkedList?, - wordStyles: List? = null + charStyles: Array?, + lineStart: Int ) { if (!textFullJustify) { addCharsToLineNatural( book, absStartX, textLine, words, - startX, false, textWidths, srcList, clickList, wordStyles + startX, false, textWidths, srcList, clickList, charStyles, lineStart ) return } @@ -1177,8 +1127,7 @@ class TextChapterLayout( addCharToLine( book, absStartX, textLine, char, x, x1, index + 1 == words.size, srcList, clickList, - wordStyles?.getOrNull(index)?.color, - wordStyles?.getOrNull(index)?.fontPath + charStyles, lineStart + index ) x = x1 } @@ -1195,8 +1144,7 @@ class TextChapterLayout( addCharToLine( book, absStartX, textLine, char, x, x1, index + 1 == words.size, srcList, clickList, - wordStyles?.getOrNull(index)?.color, - wordStyles?.getOrNull(index)?.fontPath + charStyles, lineStart + index ) x = x1 } @@ -1217,7 +1165,8 @@ class TextChapterLayout( textWidths: List, srcList: LinkedList?, clickList: LinkedList?, - wordStyles: List? = null + charStyles: Array?, + lineStart: Int ) { val indentLength = paragraphIndent.length var x = startX @@ -1236,8 +1185,8 @@ class TextChapterLayout( index + 1 == words.size, srcList, clickList, - wordStyles?.getOrNull(index)?.color, - wordStyles?.getOrNull(index)?.fontPath + charStyles, + lineStart + index ) x = x1 if (hasIndent && index == indentLength - 1) { @@ -1260,9 +1209,10 @@ class TextChapterLayout( isLineEnd: Boolean, srcList: LinkedList?, clickList: LinkedList?, - color: Int? = null, - fontPath: String? = null + charStyles: Array?, + textIndex: Int ) { + val style = charStyles?.getOrNull(textIndex) val column = when { !srcList.isNullOrEmpty() && (char == srcReplaceChar || char == reviewChar) -> { val src = srcList.removeFirst() @@ -1281,8 +1231,16 @@ class TextChapterLayout( start = absStartX + xStart, end = absStartX + xEnd, charData = char, - color = color, - fontPath = fontPath + textColor = style?.textColor, + bgColor = style?.bgColor, + underlineMode = style?.underlineMode ?: 0, + underlineColor = style?.underlineColor, + underlineWidth = style?.underlineWidth ?: 1f, + underlineOffset = style?.underlineOffset ?: 2f, + underlineSvgPath = style?.underlineSvgPath ?: "", + bgImage = style?.bgImage ?: "", + bgImageFit = style?.bgImageFit ?: 0, + bgImageScale = style?.bgImageScale ?: 1f ) } } @@ -1383,99 +1341,62 @@ class TextChapterLayout( return code == 8203 || code == 8204 || code == 8205 || code == 8288 } - private data class RegexMatchResult( - val colorArray: IntArray, - val fontPathArray: Array - ) - - private fun preApplyRegexColorRules(fullText: String) { - val rules = ReadBookConfig.regexColorRules - if (rules.isEmpty()) return - val colorArray = IntArray(fullText.length) { -1 } - val fontPathArray = arrayOfNulls(fullText.length) + /** + * 对文本应用高亮规则,返回每字符的样式数组。无匹配时返回 null。 + */ + private fun applyHighlightRules( + text: String, + isTitle: Boolean = false + ): Array? { + if (compiledHighlightRules.isEmpty()) return null var hasMatch = false - for (rule in rules) { - try { - val regex = synchronized(regexCache) { - regexCache.getOrPut(rule.pattern) { - Regex(rule.pattern, RegexOption.DOT_MATCHES_ALL) - } - } - regex.findAll(fullText).forEach { match -> - hasMatch = true - for (i in match.range) { - colorArray[i] = rule.color - if (rule.fontPath.isNotEmpty()) { - fontPathArray[i] = rule.fontPath - } - } - } - } catch (_: Exception) { - } - } - if (hasMatch) globalRegexResult = RegexMatchResult(colorArray, fontPathArray) - } - - private fun applyRegexColorRules(text: String, offset: Int): RegexMatchResult? { - val globalResult = globalRegexResult ?: return null - if (offset < 0) return null - val endIdx = minOf(offset + text.length, globalResult.colorArray.size) - var hasMatch = false - for (globalIdx in offset until endIdx) { - if (globalResult.colorArray[globalIdx] != -1 || globalResult.fontPathArray[globalIdx] != null) { + // 先检查是否有任何规则匹配 + for (compiled in compiledHighlightRules) { + if (!compiled.rule.appliesTo(isTitle)) continue + if (compiled.regex.containsMatchIn(text)) { hasMatch = true break } } if (!hasMatch) return null - val colorArray = IntArray(text.length) { -1 } - val fontPathArray = arrayOfNulls(text.length) - for (i in text.indices) { - val globalIdx = offset + i - if (globalIdx >= 0 && globalIdx < globalResult.colorArray.size) { - if (globalResult.colorArray[globalIdx] != -1) { - colorArray[i] = globalResult.colorArray[globalIdx] - } - if (globalResult.fontPathArray[globalIdx] != null) { - fontPathArray[i] = globalResult.fontPathArray[globalIdx] + // 填充样式数组 + val styles = arrayOfNulls(text.length) + for (compiled in compiledHighlightRules) { + if (!compiled.rule.appliesTo(isTitle)) continue + val rule = compiled.rule + val charStyle = CharStyle( + textColor = rule.textColor, + bgColor = rule.bgColor, + underlineMode = rule.underlineMode, + underlineColor = rule.underlineColor ?: rule.textColor ?: 0xFF63C37D.toInt(), + underlineWidth = rule.underlineWidth, + underlineOffset = rule.underlineOffset, + underlineSvgPath = rule.underlineSvgPath.orEmpty(), + bgImage = rule.bgImage.orEmpty(), + bgImageFit = rule.bgImageFit, + bgImageScale = rule.bgImageScale + ) + compiled.regex.findAll(text).forEach { match -> + for (i in match.range) { + // 后来的规则覆盖先前的(与 Legado_Max 行为一致) + styles[i] = charStyle } } } - return RegexMatchResult(colorArray, fontPathArray) + return styles } - private data class WordStyle( - val color: Int?, - val fontPath: String? + private data class CompiledHighlightRule( + val rule: HighlightRule, + val regex: Regex, ) - private fun buildWordStyles( - words: List, - lineText: String, - colorArray: IntArray, - fontPathArray: Array, - lineStart: Int - ): List { - val wordStyles = mutableListOf() - var charOffset = 0 - for (word in words) { - val wordLen = word.length - var color: Int? = null - var fontPath: String? = null - for (j in 0 until wordLen) { - val idx = lineStart + charOffset + j - if (color == null && colorArray[idx] != -1) { - color = colorArray[idx] - } - if (fontPath == null && fontPathArray[idx] != null) { - fontPath = fontPathArray[idx] - } - if (color != null && fontPath != null) break - } - wordStyles.add(WordStyle(color, fontPath)) - charOffset += wordLen + private fun HighlightRule.appliesTo(isTitle: Boolean): Boolean { + return when (targetScope) { + HighlightRule.TARGET_TITLE -> isTitle + HighlightRule.TARGET_BODY -> !isTitle + else -> true } - return wordStyles } } diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/AutoReadSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/AutoReadSheet.kt new file mode 100644 index 000000000..55dd70a15 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/AutoReadSheet.kt @@ -0,0 +1,172 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.R +import io.legado.app.data.repository.ReadPreferences +import io.legado.app.data.repository.ReadSettingsRepository +import io.legado.app.ui.book.read.ConfigUpdate +import io.legado.app.ui.book.read.ReadBookIntent +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem +import org.koin.compose.koinInject +import java.util.Locale +import kotlin.math.roundToInt + +@Composable +fun AutoReadSheet( + onDismissRequest: () -> Unit, + onIntent: (ReadBookIntent) -> Unit, + onOpenChapterList: () -> Unit, + onStopAutoPage: () -> Unit, + onShowPageAnimConfig: () -> Unit, +) { + AppModalBottomSheet( + show = true, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.auto_page_speed), + ) { + AutoReadContent( + onDismissRequest = onDismissRequest, + onIntent = onIntent, + onOpenChapterList = onOpenChapterList, + onStopAutoPage = onStopAutoPage, + onShowPageAnimConfig = onShowPageAnimConfig, + modifier = Modifier + .padding(bottom = 16.dp), + ) + } +} + +@Composable +fun AutoReadContent( + onDismissRequest: () -> Unit, + onIntent: (ReadBookIntent) -> Unit, + onOpenChapterList: () -> Unit, + onStopAutoPage: () -> Unit, + onShowPageAnimConfig: () -> Unit, + modifier: Modifier = Modifier, +) { + val readSettingsRepository: ReadSettingsRepository = koinInject() + val preferences by readSettingsRepository.preferences.collectAsStateWithLifecycle( + initialValue = ReadPreferences() + ) + val initialSpeed = remember { preferences.autoReadSpeed.coerceIn(1, 120).toFloat() } + var speed by remember { mutableFloatStateOf(initialSpeed) } + + LaunchedEffect(preferences.autoReadSpeed) { + speed = preferences.autoReadSpeed.coerceIn(1, 120).toFloat() + } + + Column( + modifier = modifier.fillMaxWidth(), + ) { + // Speed display + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.auto_page_speed), + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = String.format(Locale.ROOT, "%ds", speed.roundToInt()), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + + Spacer(Modifier.height(8.dp)) + + TinySliderSettingItem( + title = stringResource(R.string.auto_page_speed), + description = String.format(Locale.ROOT, "%ds", speed.roundToInt()), + value = speed, + valueRange = 1f..120f, + steps = 118, + onValueChange = { + speed = it + val intSpeed = it.roundToInt().coerceIn(1, 120) + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.AutoReadSpeed(intSpeed))) + }, + ) + + Spacer(Modifier.height(12.dp)) + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + ActionButton( + icon = R.drawable.ic_toc, + label = stringResource(R.string.chapter_list), + onClick = { + onDismissRequest() + onOpenChapterList() + }, + ) + ActionButton( + icon = R.drawable.ic_auto_page_stop, + label = stringResource(R.string.stop), + onClick = { + onStopAutoPage() + onDismissRequest() + }, + ) + ActionButton( + icon = R.drawable.ic_settings, + label = stringResource(R.string.setting), + onClick = { + onDismissRequest() + onShowPageAnimConfig() + }, + ) + } + } +} + +@Composable +private fun ActionButton( + icon: Int, + label: String, + onClick: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + androidx.compose.material3.FilledTonalIconButton(onClick = onClick) { + Icon( + painter = painterResource(icon), + contentDescription = label, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/BgTextConfigSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/BgTextConfigSheet.kt new file mode 100644 index 000000000..c901b1d4d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/BgTextConfigSheet.kt @@ -0,0 +1,285 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.core.graphics.toColorInt +import io.legado.app.R +import io.legado.app.ui.book.read.ConfigUpdate +import io.legado.app.ui.book.read.ReadBookColorPickerIds +import io.legado.app.ui.book.read.ReadBookIntent +import io.legado.app.ui.book.read.ReadBookStyleConfig +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.card.NormalCard +import io.legado.app.ui.widget.components.dialog.ColorPickerSheet +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.settingItem.TinyBgImageModeSettingItem +import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem +import io.legado.app.ui.widget.components.settingItem.TinyColorModeSettingItem +import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem +import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem +import io.legado.app.utils.hexString + +@Composable +fun BgTextConfigSheet( + show: Boolean, + onDismissRequest: () -> Unit, + onIntent: (ReadBookIntent) -> Unit, + onSelectImage: () -> Unit, + onSelectImageForMode: (isNight: Boolean) -> Unit, + onImportConfig: () -> Unit, + onExportConfig: () -> Unit, + styleConfig: ReadBookStyleConfig = ReadBookStyleConfig(), +) { + // Derive values directly from styleConfig (reactive state) + val styleName = styleConfig.styleName + val darkStatusIcon = styleConfig.darkStatusIcon + val bgAlpha = styleConfig.bgAlpha + val dayBgColor = if (styleConfig.bgType == 0) styleConfig.bgStr.toColorInt() else 0 + val nightBgColor = if (styleConfig.bgTypeNight == 0) styleConfig.bgStrNight.toColorInt() else 0 + val dayBgImage = if (styleConfig.bgType != 0) styleConfig.bgStr else null + val nightBgImage = if (styleConfig.bgTypeNight != 0) styleConfig.bgStrNight else null + + var showColorPicker by remember { mutableStateOf(false) } + var colorPickerIsNight by remember { mutableStateOf(false) } + + AppModalBottomSheet( + show = show, + onDismissRequest = { + onIntent(ReadBookIntent.SaveReadStyleConfig) + onDismissRequest() + }, + title = stringResource(R.string.style_name), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + // Style name section + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.style_name), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = styleName, + style = MaterialTheme.typography.headlineMedium, + ) + } + Row { + IconButton(onClick = { + // TODO: Show edit name dialog + }) { + Icon( + painter = painterResource(R.drawable.ic_edit), + contentDescription = stringResource(R.string.edit), + ) + } + IconButton(onClick = { + onIntent(ReadBookIntent.DeleteCurrentReadStyleConfig) + }) { + Icon( + painter = painterResource(R.drawable.ic_clear_all), + contentDescription = stringResource(R.string.delete), + ) + } + } + } + + Spacer(Modifier.height(8.dp)) + + // Action buttons row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + ActionCard( + title = stringResource(R.string.restore), + imageVector = Icons.Default.Refresh, + modifier = Modifier.weight(1f), + onClick = { /* TODO: Show restore presets dialog */ }, + ) + ActionCard( + title = stringResource(R.string.import_str), + imageVector = Icons.Default.Download, + modifier = Modifier.weight(1f), + onClick = onImportConfig, + ) + ActionCard( + title = stringResource(R.string.export_str), + imageVector = Icons.Default.Upload, + modifier = Modifier.weight(1f), + onClick = onExportConfig, + ) + } + + Spacer(Modifier.height(12.dp)) + + TinySwitchSettingItem( + title = stringResource(R.string.dark_status_icon), + checked = darkStatusIcon, + onCheckedChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.StatusIconDark(it))) + }, + ) + + // Background mode switch: color vs image + val isDayBgImage = styleConfig.isDayBgImage + val isNightBgImage = styleConfig.isNightBgImage + val useBgImage = isDayBgImage || isNightBgImage + + TinySwitchSettingItem( + title = stringResource(R.string.use_bg_image), + checked = useBgImage, + onCheckedChange = { useImage -> + if (useImage) { + // Switch to image mode: set bgType to 1 (assets image) with empty path + // This will show the image picker UI + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BgType(1))) + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BgStr(""))) + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BgTypeNight(1))) + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BgStrNight(""))) + } else { + // Switch to color mode: reset both day and night to color + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BgType(0))) + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BgStr("#EEEEEE"))) + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BgTypeNight(0))) + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BgStrNight("#000000"))) + } + }, + ) + + if (!useBgImage) { + // Color mode + TinyColorModeSettingItem( + title = stringResource(R.string.bg_color), + dayColor = dayBgColor, + nightColor = nightBgColor, + onClickColor = { isNight -> + colorPickerIsNight = isNight + showColorPicker = true + }, + ) + } else { + // Image mode + TinyBgImageModeSettingItem( + title = stringResource(R.string.bg_image), + dayBgImage = dayBgImage, + nightBgImage = nightBgImage, + onClickImage = { isNight -> + onSelectImageForMode(isNight) + }, + onClearImage = { isNight -> + if (isNight) { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BgTypeNight(0))) + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BgStrNight("#000000"))) + } else { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BgType(0))) + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BgStr("#EEEEEE"))) + } + }, + ) + } + + TinySliderSettingItem( + title = stringResource(R.string.bg_alpha), + value = bgAlpha, + valueRange = 0f..100f, + steps = 99, + onValueChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BgAlpha(it.toInt()))) + }, + ) + + // TODO: Add background image grid from assets + } + } + + if (showColorPicker) { + val initialColor = if (colorPickerIsNight) nightBgColor else dayBgColor + ColorPickerSheet( + show = true, + initialColor = if (initialColor != 0) initialColor else if (colorPickerIsNight) 0xFF000000.toInt() else 0xFFEEEEEE.toInt(), + onDismissRequest = { showColorPicker = false }, + onColorSelected = { color -> + if (colorPickerIsNight) { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BgStrNight("#${color.hexString}"))) + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BgTypeNight(0))) + } else { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BgStr("#${color.hexString}"))) + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BgType(0))) + } + showColorPicker = false + }, + ) + } +} + +@Composable +private fun ActionCard( + title: String, + imageVector: ImageVector, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + NormalCard( + onClick = onClick, + modifier = modifier, + containerColor = LegadoTheme.colorScheme.surfaceContainerLow, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + tint = LegadoTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.height(4.dp)) + Text( + text = title, + style = LegadoTheme.typography.labelSmall, + color = LegadoTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/ChangeChapterSourceSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/ChangeChapterSourceSheet.kt new file mode 100644 index 000000000..ebac6b55c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/ChangeChapterSourceSheet.kt @@ -0,0 +1,322 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PauseCircleOutline +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.R +import io.legado.app.data.entities.SearchBook +import io.legado.app.ui.book.changesource.ChangeChapterSourceIntent +import io.legado.app.ui.book.changesource.ChangeChapterSourceUiState +import io.legado.app.ui.book.search.ScopeSelectSheet +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.AppTextField +import io.legado.app.ui.widget.components.EmptyMessage +import io.legado.app.ui.widget.components.button.series.MediumPlainButton +import io.legado.app.ui.widget.components.card.SelectionItemCard +import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu +import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator +import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator +import io.legado.app.ui.widget.components.text.AppText + +@Composable +fun ChangeChapterSourceSheet( + state: ChangeChapterSourceUiState, + onIntent: (ChangeChapterSourceIntent) -> Unit, + show: Boolean = true, + onDismissRequest: () -> Unit, + onAnimationFinish: () -> Unit = onDismissRequest, + bookScoreFlow: (SearchBook) -> kotlinx.coroutines.flow.StateFlow, + onBookScoreClick: (SearchBook) -> Unit, + onEditSource: (String) -> Unit, +) { + var showOptionsMenu by remember { mutableStateOf(false) } + var showFilterSheet by remember { mutableStateOf(false) } + + AppModalBottomSheet( + show = show, + onDismissRequest = onAnimationFinish, + title = if (state.showToc) state.selectedSourceName else stringResource(R.string.chapter_change_source), + startAction = { + if (!state.showToc) { + Box { + MediumPlainButton( + onClick = { showOptionsMenu = true }, + icon = Icons.Default.MoreVert + ) + RoundDropdownMenu( + expanded = showOptionsMenu, + onDismissRequest = { showOptionsMenu = false } + ) { dismiss -> + RoundDropdownMenuItem( + text = "校验作者", + isSelected = state.checkAuthor, + onClick = { + onIntent(ChangeChapterSourceIntent.SetCheckAuthor(!state.checkAuthor)) + dismiss() + } + ) + RoundDropdownMenuItem( + text = "加载详情", + isSelected = state.loadInfo, + onClick = { + onIntent(ChangeChapterSourceIntent.SetLoadInfo(!state.loadInfo)) + dismiss() + } + ) + RoundDropdownMenuItem( + text = "加载目录", + isSelected = state.loadToc, + onClick = { + onIntent(ChangeChapterSourceIntent.SetLoadToc(!state.loadToc)) + dismiss() + } + ) + RoundDropdownMenuItem( + text = "显示更多信息", + isSelected = state.loadWordCount, + onClick = { + onIntent(ChangeChapterSourceIntent.SetLoadWordCount(!state.loadWordCount)) + dismiss() + } + ) + } + } + } + }, + endAction = { + if (!state.showToc) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + MediumPlainButton( + onClick = { onIntent(ChangeChapterSourceIntent.StartStopSearch) }, + icon = if (state.isSearching) Icons.Default.PauseCircleOutline else Icons.Default.Refresh, + ) + MediumPlainButton( + onClick = { showFilterSheet = true }, + icon = Icons.Default.FilterList + ) + } + } + } + ) { + if (state.showToc) { + TocContent( + state = state, + onIntent = onIntent, + ) + } else { + SearchContent( + state = state, + onIntent = onIntent, + bookScoreFlow = bookScoreFlow, + onBookScoreClick = onBookScoreClick, + onEditSource = onEditSource, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + + ScopeSelectSheet( + show = showFilterSheet, + onDismissRequest = { showFilterSheet = false }, + isAll = state.scopeState.isAll, + onSelectAll = { onIntent(ChangeChapterSourceIntent.SelectAllScope) }, + groups = state.enabledGroups, + selectedGroups = state.scopeState.displayNames, + onToggleGroup = { onIntent(ChangeChapterSourceIntent.ToggleScopeGroup(it)) }, + sources = state.enabledSources, + selectedSources = state.scopeState.sourceUrls, + onToggleSource = { onIntent(ChangeChapterSourceIntent.ToggleScopeSource(it)) }, + isSourceScope = state.scopeState.isSource, + onConfirm = { + onIntent(ChangeChapterSourceIntent.ApplyScope) + showFilterSheet = false + } + ) +} + +@Composable +private fun TocContent( + state: ChangeChapterSourceUiState, + onIntent: (ChangeChapterSourceIntent) -> Unit, +) { + if (state.isLoadingToc) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 40.dp), + contentAlignment = Alignment.Center + ) { + AppCircularProgressIndicator() + } + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(state.tocItems) { chapter -> + SelectionItemCard( + title = chapter.title, + containerColor = LegadoTheme.colorScheme.surfaceContainerLow, + selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.32f), + isSelected = false, + onToggleSelection = { + onIntent(ChangeChapterSourceIntent.SelectChapter(chapter)) + }, + ) + } + } + } +} + +@Composable +private fun SearchContent( + state: ChangeChapterSourceUiState, + onIntent: (ChangeChapterSourceIntent) -> Unit, + bookScoreFlow: (SearchBook) -> kotlinx.coroutines.flow.StateFlow, + onBookScoreClick: (SearchBook) -> Unit, + onEditSource: (String) -> Unit, +) { + val context = LocalContext.current + + AppTextField( + value = state.searchQuery, + backgroundColor = LegadoTheme.colorScheme.surface, + onValueChange = { onIntent(ChangeChapterSourceIntent.UpdateQuery(it)) }, + label = stringResource(R.string.screen), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + if (state.isSearching) { + AppLinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Spacer(modifier = Modifier.height(8.dp)) + AppText( + text = "${state.searchProgress.first} / ${state.totalSourceCount} · ${state.searchResults.size}", + style = LegadoTheme.typography.bodySmall + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + if (state.searchResults.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 40.dp), + contentAlignment = Alignment.Center + ) { + EmptyMessage( + message = stringResource(R.string.search_empty) + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(state.searchResults, key = { it.bookUrl + it.origin }) { item -> + val bookScore by remember(item.origin, item.name, item.author) { + bookScoreFlow(item) + }.collectAsStateWithLifecycle() + SelectionItemCard( + title = item.originName, + containerColor = LegadoTheme.colorScheme.surfaceContainerLow, + selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.32f), + leadingContent = { + MediumPlainButton( + onClick = { onBookScoreClick(item) }, + icon = Icons.Default.PushPin, + tint = if (bookScore > 0) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline, + contentDescription = null + ) + }, + supportingContent = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + AppText( + text = item.author, + style = LegadoTheme.typography.labelLargeEmphasized + ) + AppText( + text = item.getDisplayLastChapterTitle(), + style = LegadoTheme.typography.labelMediumEmphasized + ) + item.chapterWordCountText?.takeIf { state.loadWordCount }?.let { + AppText( + text = it, + style = LegadoTheme.typography.labelSmallEmphasized, + color = LegadoTheme.colorScheme.primary + ) + } + } + }, + isSelected = false, + onToggleSelection = { + onIntent(ChangeChapterSourceIntent.SelectSource(item)) + }, + dropdownContent = { onDismiss: () -> Unit -> + RoundDropdownMenuItem( + text = stringResource(R.string.to_top), + onClick = { + onIntent(ChangeChapterSourceIntent.TopSource(item)) + onDismiss() + } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.to_bottom), + onClick = { + onIntent(ChangeChapterSourceIntent.BottomSource(item)) + onDismiss() + } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.edit), + onClick = { + onDismiss() + onEditSource(item.origin) + } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.disable_source), + onClick = { + onIntent(ChangeChapterSourceIntent.DisableSource(item)) + onDismiss() + } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.delete), + color = LegadoTheme.colorScheme.error, + onClick = { + onIntent(ChangeChapterSourceIntent.DeleteSource(item)) + onDismiss() + } + ) + } + ) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/CharsetConfigSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/CharsetConfigSheet.kt new file mode 100644 index 000000000..892b17ee7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/CharsetConfigSheet.kt @@ -0,0 +1,67 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import io.legado.app.R +import io.legado.app.constant.AppConst +import io.legado.app.model.ReadBook +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.settingItem.TinyDropdownSettingItem + +@Composable +fun CharsetConfigSheet( + onDismissRequest: () -> Unit, +) { + var charset by remember { mutableStateOf(ReadBook.book?.charset ?: "UTF-8") } + val charsetEntries = remember { AppConst.charsets.toTypedArray() } + + AlertDialog( + onDismissRequest = onDismissRequest, + containerColor = LegadoTheme.colorScheme.surfaceContainer, + title = { Text(stringResource(R.string.set_charset)) }, + text = { + Column { + OutlinedTextField( + value = charset, + onValueChange = { charset = it }, + label = { Text(stringResource(R.string.set_charset)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + ) + TinyDropdownSettingItem( + title = stringResource(R.string.set_charset), + selectedValue = charset, + displayEntries = charsetEntries, + entryValues = charsetEntries, + onValueChange = { charset = it }, + ) + } + }, + confirmButton = { + TextButton( + onClick = { + ReadBook.setCharset(charset) + onDismissRequest() + }, + ) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/ClickActionConfigSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/ClickActionConfigSheet.kt new file mode 100644 index 000000000..4aa1852cd --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/ClickActionConfigSheet.kt @@ -0,0 +1,248 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.R +import io.legado.app.constant.PreferKey +import io.legado.app.data.repository.ReadPreferences +import io.legado.app.data.repository.ReadSettingsRepository +import io.legado.app.ui.theme.LegadoTheme +import kotlinx.coroutines.launch +import org.koin.compose.koinInject + +@Composable +fun ClickActionConfigSheet( + onDismissRequest: () -> Unit, +) { + val context = LocalContext.current + val readSettingsRepository: ReadSettingsRepository = koinInject() + val preferences by readSettingsRepository.preferences.collectAsStateWithLifecycle( + initialValue = ReadPreferences() + ) + val scope = rememberCoroutineScope() + + val actions = remember { + linkedMapOf( + -1 to context.getString(R.string.non_action), + 0 to context.getString(R.string.menu), + 1 to context.getString(R.string.next_page), + 2 to context.getString(R.string.prev_page), + 3 to context.getString(R.string.next_chapter), + 4 to context.getString(R.string.previous_chapter), + 5 to context.getString(R.string.read_aloud_prev_paragraph), + 6 to context.getString(R.string.read_aloud_next_paragraph), + 7 to context.getString(R.string.bookmark_add), + 8 to context.getString(R.string.edit_content), + 9 to context.getString(R.string.replace_state_change), + 10 to context.getString(R.string.chapter_list), + 11 to context.getString(R.string.search_content), + 12 to context.getString(R.string.sync_book_progress_t), + 13 to context.getString(R.string.read_aloud_pause_resume), + ) + } + + var selectingPrefKey by remember { mutableStateOf(null) } + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f)) + .clickable(onClick = onDismissRequest), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(12.dp), + ) { + // Title bar + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.click_regional_config), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.close)) + } + } + + // 3x3 grid + Row( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + ClickAreaCell( + label = actions[preferences.clickActionTL] ?: "", + modifier = Modifier + .weight(1f) + .fillMaxSize() + .padding(3.dp), + onClick = { selectingPrefKey = PreferKey.clickActionTL }, + ) + ClickAreaCell( + label = actions[preferences.clickActionTC] ?: "", + modifier = Modifier + .weight(1f) + .fillMaxSize() + .padding(3.dp), + onClick = { selectingPrefKey = PreferKey.clickActionTC }, + ) + ClickAreaCell( + label = actions[preferences.clickActionTR] ?: "", + modifier = Modifier + .weight(1f) + .fillMaxSize() + .padding(3.dp), + onClick = { selectingPrefKey = PreferKey.clickActionTR }, + ) + } + Row( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + ClickAreaCell( + label = actions[preferences.clickActionML] ?: "", + modifier = Modifier + .weight(1f) + .fillMaxSize() + .padding(3.dp), + onClick = { selectingPrefKey = PreferKey.clickActionML }, + ) + ClickAreaCell( + label = actions[preferences.clickActionMC] ?: "", + modifier = Modifier + .weight(1f) + .fillMaxSize() + .padding(3.dp), + onClick = { selectingPrefKey = PreferKey.clickActionMC }, + ) + ClickAreaCell( + label = actions[preferences.clickActionMR] ?: "", + modifier = Modifier + .weight(1f) + .fillMaxSize() + .padding(3.dp), + onClick = { selectingPrefKey = PreferKey.clickActionMR }, + ) + } + Row( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + ClickAreaCell( + label = actions[preferences.clickActionBL] ?: "", + modifier = Modifier + .weight(1f) + .fillMaxSize() + .padding(3.dp), + onClick = { selectingPrefKey = PreferKey.clickActionBL }, + ) + ClickAreaCell( + label = actions[preferences.clickActionBC] ?: "", + modifier = Modifier + .weight(1f) + .fillMaxSize() + .padding(3.dp), + onClick = { selectingPrefKey = PreferKey.clickActionBC }, + ) + ClickAreaCell( + label = actions[preferences.clickActionBR] ?: "", + modifier = Modifier + .weight(1f) + .fillMaxSize() + .padding(3.dp), + onClick = { selectingPrefKey = PreferKey.clickActionBR }, + ) + } + } + } + + // Action selector dialog + if (selectingPrefKey != null) { + val actionKeys = actions.keys.toList() + val actionValues = actions.values.toList() + AlertDialog( + onDismissRequest = { selectingPrefKey = null }, + containerColor = LegadoTheme.colorScheme.surfaceContainer, + title = { Text(stringResource(R.string.select_action)) }, + text = { + Column { + actionValues.forEachIndexed { index, label -> + TextButton( + onClick = { + val selectedAction = actionKeys[index] + selectingPrefKey?.let { key -> + scope.launch { + readSettingsRepository.setClickAction(key, selectedAction) + } + } + selectingPrefKey = null + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(label) + } + } + } + }, + confirmButton = {}, + ) + } +} + +@Composable +private fun ClickAreaCell( + label: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Box( + modifier = modifier + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + MaterialTheme.shapes.medium, + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/ContentEditSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/ContentEditSheet.kt new file mode 100644 index 000000000..6721911c2 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/ContentEditSheet.kt @@ -0,0 +1,115 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.ui.book.read.ReadBookIntent +import io.legado.app.ui.book.read.ReadBookUiState +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.utils.sendToClip + +@Composable +fun ContentEditSheet( + show: Boolean, + state: ReadBookUiState, + onIntent: (ReadBookIntent) -> Unit, + onDismissRequest: () -> Unit, +) { + val context = LocalContext.current + + LaunchedEffect(show) { + if (!show) return@LaunchedEffect + onIntent(ReadBookIntent.LoadContentEdit) + } + + AppModalBottomSheet( + show = show, + onDismissRequest = { + if (state.contentEditTitle.isNotEmpty()) { + onIntent(ReadBookIntent.SaveContentEdit(state.contentEditText, state.contentEditSaveToSource)) + } + onDismissRequest() + }, + title = state.contentEditTitle, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + Row(modifier = Modifier.fillMaxWidth()) { + TextButton(onClick = { + onIntent(ReadBookIntent.SaveContentEdit(state.contentEditText, state.contentEditSaveToSource)) + onDismissRequest() + }) { + Text(stringResource(R.string.action_save)) + } + TextButton(onClick = { + onIntent(ReadBookIntent.ResetContentEdit) + }) { + Text(stringResource(R.string.reset)) + } + TextButton(onClick = { + context.sendToClip("${state.contentEditTitle}\n${state.contentEditText}") + }) { + Text(stringResource(R.string.copy_all)) + } + } + + Spacer(Modifier.height(8.dp)) + + if (state.contentEditLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + OutlinedTextField( + value = state.contentEditText, + onValueChange = { onIntent(ReadBookIntent.SetContentEditText(it)) }, + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .height(400.dp), + textStyle = MaterialTheme.typography.bodyMedium, + ) + } + + if (state.contentEditIsLocalTxt) { + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = state.contentEditSaveToSource, + onCheckedChange = { onIntent(ReadBookIntent.SetContentEditSaveToSource(it)) }, + ) + Text( + text = stringResource(R.string.save_to_source), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/DictSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/DictSheet.kt new file mode 100644 index 000000000..ccff88df9 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/DictSheet.kt @@ -0,0 +1,170 @@ +package io.legado.app.ui.book.read.sheet + +import android.text.method.LinkMovementMethod +import android.widget.TextView +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import io.legado.app.R +import io.legado.app.data.entities.DictRule +import io.legado.app.help.GlideImageGetter +import io.legado.app.ui.dict.DictViewModel +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.utils.setHtml +import org.koin.androidx.compose.koinViewModel + +@Composable +fun DictSheet( + show: Boolean, + word: String, + onDismissRequest: () -> Unit, + viewModel: DictViewModel = koinViewModel(), +) { + val context = LocalContext.current + var dictRules by remember { mutableStateOf>(emptyList()) } + var selectedTab by remember { mutableIntStateOf(0) } + var isLoading by remember { mutableStateOf(true) } + var htmlContent by remember { mutableStateOf("") } + var emptyMessage by remember { mutableStateOf(null) } + var glideImageGetter by remember { mutableStateOf(null) } + + LaunchedEffect(show) { + if (!show) return@LaunchedEffect + viewModel.initData { rules -> + dictRules = rules + if (rules.isEmpty()) { + emptyMessage = context.getString(R.string.empty) + isLoading = false + } else { + // Auto-select first tab + viewModel.dict(rules[0], word) { result -> + isLoading = false + if (result.isBlank()) { + emptyMessage = "没有查询到结果" + } else { + htmlContent = result + emptyMessage = null + } + } + } + } + } + + AppModalBottomSheet( + show = show, + onDismissRequest = { + glideImageGetter?.clear() + glideImageGetter = null + onDismissRequest() + }, + title = word, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + // Tab row + if (dictRules.size > 1) { + PrimaryScrollableTabRow( + selectedTabIndex = selectedTab.coerceIn( + 0, + (dictRules.size - 1).coerceAtLeast(0) + ), + modifier = Modifier.fillMaxWidth(), + edgePadding = 0.dp, + ) { + dictRules.forEachIndexed { index, rule -> + Tab( + selected = selectedTab == index, + onClick = { + selectedTab = index + isLoading = true + emptyMessage = null + viewModel.dict(rule, word) { result -> + isLoading = false + if (result.isBlank()) { + emptyMessage = "没有查询到结果" + } else { + htmlContent = result + emptyMessage = null + } + } + }, + text = { Text(rule.name) }, + ) + } + } + } + + // Content + when { + isLoading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + emptyMessage != null -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = emptyMessage!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + else -> { + AndroidView( + factory = { ctx -> + TextView(ctx).apply { + movementMethod = LinkMovementMethod() + setPadding(32, 16, 32, 16) + } + }, + update = { textView -> + textView.setHtml(htmlContent) + glideImageGetter?.clear() + glideImageGetter = + GlideImageGetter.create(context, textView, htmlContent) + textView.setHtml(htmlContent, glideImageGetter) + }, + modifier = Modifier + .fillMaxWidth() +, + ) + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/DownloadSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/DownloadSheet.kt new file mode 100644 index 000000000..3f156b0e5 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/DownloadSheet.kt @@ -0,0 +1,75 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.model.ReadBook +import io.legado.app.ui.theme.LegadoTheme + +@Composable +fun DownloadSheet( + onDismissRequest: () -> Unit, + onDownload: (start: Int, end: Int) -> Unit, +) { + val book = ReadBook.book + var startChapter by remember { mutableStateOf(((book?.durChapterIndex ?: 0) + 1).toString()) } + var endChapter by remember { mutableStateOf((book?.totalChapterNum ?: 0).toString()) } + + AlertDialog( + onDismissRequest = onDismissRequest, + containerColor = LegadoTheme.colorScheme.surfaceContainer, + title = { Text(stringResource(R.string.offline_cache)) }, + text = { + androidx.compose.foundation.layout.Column { + OutlinedTextField( + value = startChapter, + onValueChange = { startChapter = it }, + label = { Text(stringResource(R.string.start_chapter)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = endChapter, + onValueChange = { endChapter = it }, + label = { Text(stringResource(R.string.end_chapter)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + } + }, + confirmButton = { + TextButton( + onClick = { + val start = startChapter.toIntOrNull() ?: return@TextButton + val end = endChapter.toIntOrNull() ?: return@TextButton + if (start <= end) { + onDownload(start, end) + onDismissRequest() + } + }, + ) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.cancel)) + } + }, + ) +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/EffectiveReplacesSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/EffectiveReplacesSheet.kt new file mode 100644 index 000000000..e16db3f98 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/EffectiveReplacesSheet.kt @@ -0,0 +1,125 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.R +import io.legado.app.data.entities.ReplaceRule +import io.legado.app.data.repository.ReadSettingsRepository +import io.legado.app.model.ReadBook +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import kotlinx.coroutines.launch +import org.koin.compose.koinInject + +@Composable +fun EffectiveReplacesSheet( + show: Boolean, + onDismissRequest: () -> Unit, + onOpenReplaceEditor: (id: Long, pattern: String?) -> Unit, + onReplaceRuleChanged: () -> Unit, +) { + val effectiveRules = remember { + ReadBook.curTextChapter?.effectiveReplaceRules ?: emptyList() + } + val scope = rememberCoroutineScope() + val readSettingsRepository: ReadSettingsRepository = koinInject() + val preferences by readSettingsRepository.preferences.collectAsStateWithLifecycle( + initialValue = io.legado.app.data.repository.ReadPreferences() + ) + val showChineseConvert = preferences.chineseConverterType > 0 + val chineseConvert = remember { ReplaceRule(0, "繁简转换") } + val items = remember(effectiveRules, showChineseConvert) { + if (showChineseConvert) effectiveRules + chineseConvert else effectiveRules + } + + var isEdited by remember { mutableStateOf(false) } + var showChineseConvertDialog by remember { mutableStateOf(false) } + + AppModalBottomSheet( + show = show, + onDismissRequest = { + if (isEdited) onReplaceRuleChanged() + onDismissRequest() + }, + title = stringResource(R.string.effective_replaces), + ) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + ) { + items(items, key = { it.id }) { rule -> + Text( + text = rule.name, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .clickable { + if (rule == chineseConvert) { + showChineseConvertDialog = true + } else { + onOpenReplaceEditor(rule.id, rule.pattern) + } + } + .padding(vertical = 12.dp), + ) + HorizontalDivider() + } + } + } + + if (showChineseConvertDialog) { + val modes = stringArrayResource(R.array.chinese_mode) + AlertDialog( + onDismissRequest = { showChineseConvertDialog = false }, + containerColor = LegadoTheme.colorScheme.surfaceContainer, + title = { Text(stringResource(R.string.chinese_converter)) }, + text = { + Column { + modes.forEachIndexed { index, mode -> + Text( + text = mode, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .clickable { + if (preferences.chineseConverterType != index) { + scope.launch { + readSettingsRepository.setChineseConverterType(index) + } + isEdited = true + } + showChineseConvertDialog = false + } + .padding(vertical = 12.dp), + ) + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = { showChineseConvertDialog = false }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/FontSelectSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/FontSelectSheet.kt new file mode 100644 index 000000000..25cd06ea2 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/FontSelectSheet.kt @@ -0,0 +1,54 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.R +import io.legado.app.data.repository.ReadPreferences +import io.legado.app.data.repository.ReadSettingsRepository +import io.legado.app.help.config.ReadBookConfig +import org.koin.compose.koinInject +import java.io.File +import java.net.URLDecoder +import io.legado.app.ui.widget.components.FontSelectSheet as SharedFontSelectSheet + +@Composable +fun FontSelectSheet( + show: Boolean, + onDismissRequest: () -> Unit, + onSelectFont: (String) -> Unit, + onSelectSystemTypeface: (Int) -> Unit, + onOpenFolderPicker: () -> Unit, +) { + val context = LocalContext.current + val readSettingsRepository: ReadSettingsRepository = koinInject() + val preferences by readSettingsRepository.preferences.collectAsStateWithLifecycle( + initialValue = ReadPreferences() + ) + val fontFolderUri = preferences.fontFolder.takeIf { it.isNotEmpty() }?.toUri() + val curFontPath = remember { ReadBookConfig.textFont } + val curName = remember { + runCatching { + URLDecoder.decode(curFontPath, "utf-8") + }.getOrNull()?.substringAfterLast(File.separator) + } + val systemTypefaces = remember { + context.resources.getStringArray(R.array.system_typefaces) + } + + SharedFontSelectSheet( + show = show, + title = stringResource(R.string.select_font), + fontFolderUri = fontFolderUri, + selectedFontName = curName, + onDismissRequest = onDismissRequest, + onSelectFont = { doc -> onSelectFont(doc.toString()) }, + onOpenFolderPicker = onOpenFolderPicker, + onSelectSystemTypeface = onSelectSystemTypeface, + systemTypefaces = systemTypefaces, + ) +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/GlobalThemePage.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/GlobalThemePage.kt new file mode 100644 index 000000000..f06bbd21e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/GlobalThemePage.kt @@ -0,0 +1,436 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.BrightnessAuto +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.filled.SpaceBar +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.graphics.toColorInt +import coil.compose.AsyncImage +import io.legado.app.R +import io.legado.app.help.config.ReadBookConfig +import io.legado.app.help.config.ReadStyleResolver +import io.legado.app.model.ReadBook +import io.legado.app.ui.book.read.ConfigUpdate +import io.legado.app.ui.book.read.ReadBookIntent +import io.legado.app.ui.book.read.ReadBookStyleConfig +import io.legado.app.ui.config.themeConfig.ThemeConfig +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.fadingEdge +import io.legado.app.ui.widget.components.button.series.SmallTonalButton +import io.legado.app.ui.widget.components.card.NormalCard +import io.legado.app.ui.widget.components.card.TextCard +import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu +import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem +import io.legado.app.ui.widget.components.settingItem.TinySettingItem +import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem +import io.legado.app.ui.widget.components.text.AppText + +// ========== Page 0: Global & Theme ========== + +@Composable +fun GlobalThemePage( + onToggleDayNight: () -> Unit, + onOpenBgTextConfig: (Int) -> Unit, + onOpenTextTitle: () -> Unit, + onOpenPaddingConfig: () -> Unit, + onShareLayoutChange: (Boolean) -> Unit, + onStyleSelect: (Int) -> Unit, + modifier: Modifier = Modifier, + onIntent: (ReadBookIntent) -> Unit, + styleConfig: ReadBookStyleConfig = ReadBookStyleConfig(), +) { + // Derive values directly from styleConfig (reactive state) + val textSize = styleConfig.textSize + val pageAnim = styleConfig.pageAnim + val styleSelect = styleConfig.styleSelect + val shareLayout = styleConfig.shareLayout + // configList needs to be read from ReadBookConfig since it's a list of Config objects + // We use styleConfig.configCount to detect when the list changes + var configList by remember { mutableStateOf(ReadBookConfig.configList.toList()) } + + // Re-read configList when configCount changes (indicates list was modified) + LaunchedEffect(styleConfig.configCount) { + configList = ReadBookConfig.configList.toList() + } + + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TinySliderSettingItem( + title = stringResource(R.string.text_size), + value = textSize.toFloat(), + valueRange = 5f..50f, + steps = 44, + modifier = Modifier.weight(1f), + onValueChange = { value -> + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextSize(value.toInt()))) + }, + ) + NormalCard( + onClick = onOpenTextTitle, + modifier = Modifier + .height(56.dp) + .aspectRatio(1f), + containerColor = LegadoTheme.colorScheme.surfaceContainerLow, + cornerRadius = 12.dp + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { + Icon( + imageVector = Icons.Default.TextFields, + contentDescription = stringResource(R.string.read_config_text_effects), + tint = LegadoTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Spacer(Modifier.height(4.dp)) + + // Style section label + Day/Night + NormalCard( + containerColor = LegadoTheme.colorScheme.surfaceContainerLow, + cornerRadius = 12.dp + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp, top = 12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + AppText( + text = stringResource(R.string.text_bg_style), + style = LegadoTheme.typography.titleSmallEmphasized + ) + AppText( + text = stringResource(R.string.long_click_to_custom), + style = LegadoTheme.typography.labelSmall, + color = LegadoTheme.colorScheme.onSurfaceVariant, + ) + } + val themeMode = ThemeConfig.themeMode + SmallTonalButton( + onClick = onToggleDayNight, + icon = when (themeMode) { + "1" -> Icons.Default.LightMode + "2" -> Icons.Default.DarkMode + else -> Icons.Default.BrightnessAuto + }, + contentColor = LegadoTheme.colorScheme.onSurfaceVariant, + containerColor = LegadoTheme.colorScheme.surfaceContainerHigh, + selectedContainerColor = LegadoTheme.colorScheme.surfaceContainerHigh, + selectedContentColor = LegadoTheme.colorScheme.onSurfaceVariant, + contentDescription = stringResource(R.string.theme_mode), + ) + } + + Spacer(Modifier.height(8.dp)) + + // Style cards: [shareLayout] [cards...] + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp, bottom = 12.dp), + ) { + NormalCard( + onClick = { + val newShareLayout = !shareLayout + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShareLayout(newShareLayout))) + onShareLayoutChange(newShareLayout) + }, + modifier = Modifier + .width(40.dp) + .height(56.dp), + cornerRadius = 8.dp, + containerColor = if (shareLayout) { + LegadoTheme.colorScheme.secondaryContainer + } else { + LegadoTheme.colorScheme.surfaceContainerLow + }, + border = BorderStroke( + 1.dp, + LegadoTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f) + ), + contentColor = if (shareLayout) LegadoTheme.colorScheme.onSecondaryContainer else null + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.GridView, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + } + } + val styleListState = rememberLazyListState() + + // Auto-scroll to selected item (position as 2nd visible card) + LaunchedEffect(styleSelect) { + styleListState.animateScrollToItem(maxOf(0, styleSelect - 1)) + } + + // Auto-scroll to newly added style config (skip initial composition) + var previousSize by remember { mutableStateOf(configList.size) } + LaunchedEffect(configList.size) { + if (configList.size > previousSize) { + styleListState.animateScrollToItem(configList.size) + } + previousSize = configList.size + } + + LazyRow( + state = styleListState, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .weight(1f) + .padding(start = 8.dp) + .fadingEdge(styleListState), + ) { + itemsIndexed(configList) { index, config -> + StyleCard( + config = config, + isSelected = styleSelect == index, + onClick = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.StyleSelect(index))) + onStyleSelect(index) + }, + onLongClick = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.StyleSelect(index))) + onStyleSelect(index) + onOpenBgTextConfig(index) + }, + ) + } + item { + NormalCard( + onClick = { + onIntent(ReadBookIntent.AddReadStyleConfig) + }, + modifier = Modifier + .width(40.dp) + .height(56.dp), + containerColor = LegadoTheme.colorScheme.surfaceContainerLow, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + } + } + } + } + } + } + + Spacer(Modifier.height(8.dp)) + + val pageAnimOptions = listOf( + R.string.page_anim_cover, + R.string.page_anim_slide, + R.string.page_anim_simulation, + R.string.page_anim_scroll, + R.string.page_anim_fade, + R.string.page_anim_none, + ) + var showPageAnimMenu by remember { mutableStateOf(false) } + val pageAnimEntries = pageAnimOptions.map { stringResource(it) }.toTypedArray() + val pageAnimEntryValues = pageAnimOptions.indices.map { it.toString() }.toTypedArray() + val currentPageAnimDisplay = + pageAnimEntries.getOrNull(pageAnimEntryValues.indexOf(pageAnim.toString())) ?: "" + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box(modifier = Modifier.weight(1f)) { + TinySettingItem( + title = stringResource(R.string.page_anim), + modifier = Modifier.fillMaxWidth(), + trailingContent = { + TextCard( + cornerRadius = 8.dp, + horizontalPadding = 8.dp, + verticalPadding = 4.dp, + text = currentPageAnimDisplay, + backgroundColor = LegadoTheme.colorScheme.surfaceContainerLow, + contentColor = LegadoTheme.colorScheme.onSurface, + ) + }, + onClick = { showPageAnimMenu = true }, + ) + RoundDropdownMenu( + expanded = showPageAnimMenu, + onDismissRequest = { showPageAnimMenu = false }, + ) { dismiss -> + pageAnimEntries.forEachIndexed { index, display -> + RoundDropdownMenuItem( + text = display, + onClick = { + ReadBook.book?.setPageAnim(-1) + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PageAnim(index))) + ReadBook.loadContent(false) + dismiss() + }, + ) + } + } + } + NormalCard( + onClick = onOpenPaddingConfig, + modifier = Modifier + .height(56.dp) + .aspectRatio(1f), + containerColor = LegadoTheme.colorScheme.surfaceContainerLow, + cornerRadius = 12.dp + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { + Icon( + imageVector = Icons.Default.SpaceBar, + contentDescription = stringResource(R.string.padding), + tint = LegadoTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +// ========== Shared Components ========== + +@Composable +fun StyleCard( + config: ReadBookConfig.Config, + isSelected: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + val bgType = config.curBgType() + val bgColor = if (bgType == 0) { + try { + Color(config.curBgStr().toColorInt()) + } catch (_: Exception) { + LegadoTheme.colorScheme.surface + } + } else { + LegadoTheme.colorScheme.surface + } + val textColor = Color(config.curTextColor()) + val name = config.name.ifBlank { stringResource(R.string.text_bg_style) } + val bgPath = if (bgType != 0) { + ReadStyleResolver.backgroundPath(config, when { + ReadStyleResolver.currentMode() == ReadStyleResolver.ReadStyleMode.Night -> 1 + ReadStyleResolver.currentMode() == ReadStyleResolver.ReadStyleMode.EInk -> 2 + else -> 0 + }) + } else { + null + } + + NormalCard( + modifier = Modifier + .width(44.dp) + .height(56.dp), + cornerRadius = 8.dp, + containerColor = bgColor, + onClick = onClick, + onLongClick = onLongClick, + ) { + Box(modifier = Modifier.fillMaxSize()) { + if (bgPath != null) { + AsyncImage( + model = bgPath, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + AppText( + text = name, + style = LegadoTheme.typography.labelSmall, + color = textColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 8.dp), + ) + if (isSelected) { + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(12.dp) + .background( + color = LegadoTheme.colorScheme.surfaceContainerHigh + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = LegadoTheme.colorScheme.primary, + ) + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/HeaderFooterPage.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/HeaderFooterPage.kt new file mode 100644 index 000000000..52f9eae27 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/HeaderFooterPage.kt @@ -0,0 +1,410 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.help.config.ReadBookConfig +import io.legado.app.ui.book.read.ConfigUpdate +import io.legado.app.ui.book.read.ReadBookIntent +import io.legado.app.ui.widget.components.dialog.ColorPickerSheet +import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem +import io.legado.app.ui.widget.components.settingItem.TinyColorSettingItem +import io.legado.app.ui.widget.components.settingItem.TinyDropdownSettingItem +import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem +import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem +import io.legado.app.ui.widget.components.tabRow.CardTabRow +import io.legado.app.utils.getCompatColor +import kotlinx.coroutines.launch + +// Color picker IDs +private const val COLOR_HEADER = 7 +private const val COLOR_FOOTER = 8 +private const val COLOR_DIVIDER = 9 + +@Composable +internal fun HeaderFooterPage( + onOpenFontSelect: () -> Unit, + modifier: Modifier = Modifier, + onIntent: (ReadBookIntent) -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val tabTitles = listOf( + stringResource(R.string.header), + stringResource(R.string.footer), + stringResource(R.string.general), + ) + val pagerState = rememberPagerState(pageCount = { 3 }) + var selectedTab by remember { mutableIntStateOf(0) } + + // Header state + var headerMode by remember { mutableIntStateOf(ReadBookConfig.headerMode) } + var headerLeft by remember { mutableIntStateOf(ReadBookConfig.tipHeaderLeft) } + var headerMiddle by remember { mutableIntStateOf(ReadBookConfig.tipHeaderMiddle) } + var headerRight by remember { mutableIntStateOf(ReadBookConfig.tipHeaderRight) } + + // Footer state + var footerMode by remember { mutableIntStateOf(ReadBookConfig.footerMode) } + var footerLeft by remember { mutableIntStateOf(ReadBookConfig.tipFooterLeft) } + var footerMiddle by remember { mutableIntStateOf(ReadBookConfig.tipFooterMiddle) } + var footerRight by remember { mutableIntStateOf(ReadBookConfig.tipFooterRight) } + + // Line toggles + var showHeaderLine by remember { mutableStateOf(ReadBookConfig.showHeaderLine) } + var showFooterLine by remember { mutableStateOf(ReadBookConfig.showFooterLine) } + + // Global state + var headerFontSize by remember { mutableIntStateOf(ReadBookConfig.headerFontSize) } + + var showColorPicker by remember { mutableStateOf(false) } + var colorPickerId by remember { mutableIntStateOf(0) } + var colorPickerInitial by remember { mutableIntStateOf(0) } + + val tipNames = remember { ReadBookConfig.tipNames } + val tipValues = remember { ReadBookConfig.tipValues } + + fun clearRepeat(repeat: Int) { + if (repeat == ReadBookConfig.tipNone) return + if (headerLeft == repeat) { + headerLeft = ReadBookConfig.tipNone + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderLeft(ReadBookConfig.tipNone))) + } + if (headerMiddle == repeat) { + headerMiddle = ReadBookConfig.tipNone + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderMiddle(ReadBookConfig.tipNone))) + } + if (headerRight == repeat) { + headerRight = ReadBookConfig.tipNone + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderRight(ReadBookConfig.tipNone))) + } + if (footerLeft == repeat) { + footerLeft = ReadBookConfig.tipNone + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterLeft(ReadBookConfig.tipNone))) + } + if (footerMiddle == repeat) { + footerMiddle = ReadBookConfig.tipNone + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterMiddle(ReadBookConfig.tipNone))) + } + if (footerRight == repeat) { + footerRight = ReadBookConfig.tipNone + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterRight(ReadBookConfig.tipNone))) + } + } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage }.collect { selectedTab = it } + } + + Column( + modifier = modifier + .fillMaxWidth() + ) { + CardTabRow( + tabTitles = tabTitles, + selectedTabIndex = selectedTab, + onTabSelected = { index -> + selectedTab = index + scope.launch { pagerState.animateScrollToPage(index) } + }, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp), + ) + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + ) { page -> + when (page) { + 0 -> { + // Header tab + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + TinySwitchSettingItem( + title = stringResource(R.string.showLine), + checked = showHeaderLine, + onCheckedChange = { + showHeaderLine = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShowHeaderLine(it))) + }, + ) + val headerModes = ReadBookConfig.getHeaderModes(context) + TinyDropdownSettingItem( + title = stringResource(R.string.header), + selectedValue = headerMode.toString(), + displayEntries = headerModes.values.toTypedArray(), + entryValues = headerModes.keys.map { it.toString() }.toTypedArray(), + onValueChange = { + headerMode = it.toInt() + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderMode(headerMode))) + }, + ) + TipPositionDropdown( + label = stringResource(R.string.left), + value = headerLeft, + tipNames = tipNames, + tipValues = tipValues, + onValueChange = { + clearRepeat(it) + headerLeft = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderLeft(it))) + }, + ) + TipPositionDropdown( + label = stringResource(R.string.middle), + value = headerMiddle, + tipNames = tipNames, + tipValues = tipValues, + onValueChange = { + clearRepeat(it) + headerMiddle = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderMiddle(it))) + }, + ) + TipPositionDropdown( + label = stringResource(R.string.right), + value = headerRight, + tipNames = tipNames, + tipValues = tipValues, + onValueChange = { + clearRepeat(it) + headerRight = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderRight(it))) + }, + ) + TinyColorSettingItem( + title = stringResource(R.string.header_color), + colorValue = if (ReadBookConfig.tipHeaderColor != 0) { + ReadBookConfig.tipHeaderColor + } else { + ReadBookConfig.textColor + }, + onClick = { + colorPickerId = COLOR_HEADER + colorPickerInitial = if (ReadBookConfig.tipHeaderColor != 0) { + ReadBookConfig.tipHeaderColor + } else { + ReadBookConfig.textColor + } + showColorPicker = true + }, + ) + } + } + + 1 -> { + // Footer tab + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + TinySwitchSettingItem( + title = stringResource(R.string.showLine), + checked = showFooterLine, + onCheckedChange = { + showFooterLine = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShowFooterLine(it))) + }, + ) + val footerModes = ReadBookConfig.getFooterModes(context) + TinyDropdownSettingItem( + title = stringResource(R.string.footer), + selectedValue = footerMode.toString(), + displayEntries = footerModes.values.toTypedArray(), + entryValues = footerModes.keys.map { it.toString() }.toTypedArray(), + onValueChange = { + footerMode = it.toInt() + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterMode(footerMode))) + }, + ) + TipPositionDropdown( + label = stringResource(R.string.left), + value = footerLeft, + tipNames = tipNames, + tipValues = tipValues, + onValueChange = { + clearRepeat(it) + footerLeft = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterLeft(it))) + }, + ) + TipPositionDropdown( + label = stringResource(R.string.middle), + value = footerMiddle, + tipNames = tipNames, + tipValues = tipValues, + onValueChange = { + clearRepeat(it) + footerMiddle = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterMiddle(it))) + }, + ) + TipPositionDropdown( + label = stringResource(R.string.right), + value = footerRight, + tipNames = tipNames, + tipValues = tipValues, + onValueChange = { + clearRepeat(it) + footerRight = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterRight(it))) + }, + ) + TinyColorSettingItem( + title = stringResource(R.string.footer_color), + colorValue = if (ReadBookConfig.tipFooterColor != 0) { + ReadBookConfig.tipFooterColor + } else { + ReadBookConfig.textColor + }, + onClick = { + colorPickerId = COLOR_FOOTER + colorPickerInitial = if (ReadBookConfig.tipFooterColor != 0) { + ReadBookConfig.tipFooterColor + } else { + ReadBookConfig.textColor + } + showColorPicker = true + }, + ) + } + } + + 2 -> { + // Global tab + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource(R.string.read_config_divider_line), + style = MaterialTheme.typography.titleSmallEmphasized, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + textAlign = TextAlign.Center, + ) + TinyColorSettingItem( + title = stringResource(R.string.tip_divider_color), + colorValue = when (ReadBookConfig.tipDividerColor) { + -1 -> context.getCompatColor(R.color.divider) + 0 -> ReadBookConfig.textColor + else -> ReadBookConfig.tipDividerColor + }, + onClick = { + colorPickerId = COLOR_DIVIDER + colorPickerInitial = when (ReadBookConfig.tipDividerColor) { + -1 -> context.getCompatColor(R.color.divider) + 0 -> ReadBookConfig.textColor + else -> ReadBookConfig.tipDividerColor + } + showColorPicker = true + }, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.text_typeface), + style = MaterialTheme.typography.titleSmallEmphasized, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + textAlign = TextAlign.Center, + ) + TinyClickableSettingItem( + title = stringResource(R.string.header_font), + description = stringResource(R.string.select_font), + imageVector = Icons.Default.TextFields, + onClick = onOpenFontSelect, + ) + TinySliderSettingItem( + title = stringResource(R.string.header_font_size), + value = headerFontSize.toFloat(), + valueRange = 0f..100f, + onValueChange = { value -> + headerFontSize = value.toInt() + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderFontSize(value.toInt()))) + onIntent(ReadBookIntent.SaveReadStyleConfig) + }, + ) + } + } + } + } + } + + // Color picker + if (showColorPicker) { + ColorPickerSheet( + show = true, + initialColor = colorPickerInitial, + onDismissRequest = { showColorPicker = false }, + onColorSelected = { color -> + when (colorPickerId) { + COLOR_HEADER -> { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderColor(color))) + } + + COLOR_FOOTER -> { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterColor(color))) + } + + COLOR_DIVIDER -> { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipDividerColor(color))) + } + } + showColorPicker = false + }, + ) + } +} + +@Composable +internal fun TipPositionDropdown( + label: String, + value: Int, + tipNames: List, + tipValues: Array, + onValueChange: (Int) -> Unit, +) { + TinyDropdownSettingItem( + title = label, + selectedValue = value.toString(), + displayEntries = tipNames.toTypedArray(), + entryValues = tipValues.map { it.toString() }.toTypedArray(), + onValueChange = { onValueChange(it.toInt()) }, + ) +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleConfigSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleConfigSheet.kt new file mode 100644 index 000000000..6e5dcc4f8 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleConfigSheet.kt @@ -0,0 +1,195 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.data.entities.HighlightRule +import io.legado.app.ui.book.read.ReadBookIntent +import io.legado.app.ui.book.read.config.HighlightRuleStore +import io.legado.app.ui.widget.components.TinySwitch +import io.legado.app.ui.widget.components.button.series.SmallTonalButton +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.settingItem.TinySettingItem + +@Composable +fun HighlightRuleConfigSheet( + show: Boolean, + onDismissRequest: () -> Unit, + onIntent: (ReadBookIntent) -> Unit, +) { + var rules by remember { mutableStateOf(HighlightRuleStore.load()) } + var showDeleteConfirm by remember { mutableStateOf(false) } + var deleteIndex by remember { mutableIntStateOf(-1) } + var editingRule by remember { mutableStateOf(null) } + var showNewRule by remember { mutableStateOf(false) } + + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.highlight_rule_config), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f, fill = false), + ) { + itemsIndexed(rules, key = { _, rule -> rule.id }) { index, rule -> + HighlightRuleItem( + rule = rule, + onToggle = { enabled -> + rules = rules.toMutableList().also { + it[index] = it[index].copy(enabled = enabled) + } + saveRules(rules, onIntent) + }, + onEditClick = { editingRule = rule }, + onDeleteClick = { + deleteIndex = index + showDeleteConfirm = true + }, + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton( + onClick = { showNewRule = true }, + ) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.add)) + } + } + } + } + + // Edit existing rule + val editingRuleValue = editingRule + if (show && editingRuleValue != null) { + HighlightRuleEditSheet( + show = true, + rule = editingRuleValue, + onDismissRequest = { editingRule = null }, + onSave = { updated -> + rules = rules.map { if (it.id == updated.id) updated else it } + saveRules(rules, onIntent) + editingRule = null + }, + ) + } + + // Add new rule + if (show && showNewRule) { + HighlightRuleEditSheet( + show = true, + rule = null, + onDismissRequest = { showNewRule = false }, + onSave = { newRule -> + rules = rules + newRule + saveRules(rules, onIntent) + showNewRule = false + }, + ) + } + + // Delete confirmation + if (showDeleteConfirm && deleteIndex in rules.indices) { + AlertDialog( + onDismissRequest = { + showDeleteConfirm = false + deleteIndex = -1 + }, + containerColor = io.legado.app.ui.theme.LegadoTheme.colorScheme.surfaceContainer, + title = { Text(stringResource(R.string.delete)) }, + text = { Text(stringResource(R.string.sure_delete) + " \"${rules[deleteIndex].name}\"?") }, + confirmButton = { + TextButton(onClick = { + rules = rules.toMutableList().also { it.removeAt(deleteIndex) } + saveRules(rules, onIntent) + showDeleteConfirm = false + deleteIndex = -1 + }) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = { + showDeleteConfirm = false + deleteIndex = -1 + }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } +} + +@Composable +private fun HighlightRuleItem( + rule: HighlightRule, + onToggle: (Boolean) -> Unit, + onEditClick: () -> Unit, + onDeleteClick: () -> Unit, +) { + TinySettingItem( + title = rule.name.ifBlank { rule.displayPattern() }, + description = rule.styleSummary(), + onClick = onEditClick, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + TinySwitch( + checked = rule.enabled, + onCheckedChange = onToggle, + modifier = Modifier.size(36.dp), + ) + SmallTonalButton( + onClick = onEditClick, + icon = Icons.Default.Edit + ) + SmallTonalButton( + onClick = onDeleteClick, + icon = Icons.Default.Delete + ) + } + }, + ) +} + +private fun saveRules(rules: List, onIntent: (ReadBookIntent) -> Unit) { + onIntent(ReadBookIntent.UpdateConfig(io.legado.app.ui.book.read.ConfigUpdate.HighlightRules(rules))) +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleEditSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleEditSheet.kt new file mode 100644 index 000000000..cbabed404 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleEditSheet.kt @@ -0,0 +1,513 @@ +package io.legado.app.ui.book.read.sheet + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.legado.app.R +import io.legado.app.data.entities.HighlightRule +import io.legado.app.ui.book.read.config.HighlightRuleStore +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.AppTextField +import io.legado.app.ui.widget.components.SectionTitle +import io.legado.app.ui.widget.components.dialog.ColorPickerSheet +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem +import io.legado.app.ui.widget.components.settingItem.TinyColorSettingItem +import io.legado.app.ui.widget.components.settingItem.TinyDropdownSettingItem +import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem +import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem +import io.legado.app.ui.widget.components.text.AppText +import splitties.init.appCtx +import java.io.File + +@Composable +fun HighlightRuleEditSheet( + show: Boolean, + rule: HighlightRule?, + onDismissRequest: () -> Unit, + onSave: (HighlightRule) -> Unit, +) { + val isNew = rule == null + val initial = rule ?: HighlightRule() + val context = LocalContext.current + + // Rule info state + var pattern by remember { mutableStateOf(initial.pattern) } + var name by remember { mutableStateOf(initial.name) } + var targetScope by remember { mutableIntStateOf(initial.targetScope) } + var enabled by remember { mutableStateOf(initial.enabled) } + var sampleText by remember { + mutableStateOf(initial.sampleText.ifBlank { "她轻声说:今晚就出发。" }) + } + + // Style state + var textColor by remember { mutableIntStateOf(initial.textColor ?: 0xFF63C37D.toInt()) } + var hasTextColor by remember { mutableStateOf(initial.textColor != null) } + var bgColor by remember { mutableIntStateOf(initial.bgColor ?: 0x20FFEB3B) } + var hasBgColor by remember { mutableStateOf(initial.bgColor != null) } + var hasUnderline by remember { mutableStateOf(initial.underlineMode > 0) } + var underlineMode by remember { mutableIntStateOf(if (initial.underlineMode > 0) initial.underlineMode else 1) } + var underlineColor by remember { mutableIntStateOf(initial.underlineColor ?: 0xFF63C37D.toInt()) } + var hasUnderlineColor by remember { mutableStateOf(initial.underlineColor != null) } + var underlineWidth by remember { mutableFloatStateOf(initial.underlineWidth) } + var underlineOffset by remember { mutableFloatStateOf(initial.underlineOffset) } + var underlineSvgPath by remember { mutableStateOf(initial.underlineSvgPath.orEmpty()) } + var bgImage by remember { mutableStateOf(initial.bgImage.orEmpty()) } + var bgImageFit by remember { mutableIntStateOf(initial.bgImageFit) } + var bgImageScale by remember { mutableFloatStateOf(initial.bgImageScale) } + var hasBgImage by remember { mutableStateOf(initial.bgImage?.isNotBlank() == true) } + + // Color picker state + var showTextColorPicker by remember { mutableStateOf(false) } + var showBgColorPicker by remember { mutableStateOf(false) } + var showUnderlineColorPicker by remember { mutableStateOf(false) } + + // Validation + var patternError by remember { mutableStateOf(null) } + + // SAF image picker + val imagePicker = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri: Uri? -> + if (uri != null) { + val dir = File(appCtx.filesDir, "bg_images") + if (!dir.exists()) dir.mkdirs() + val target = File(dir, "bg_${System.currentTimeMillis()}.jpg") + context.contentResolver.openInputStream(uri)?.use { input -> + target.outputStream().use { output -> + input.copyTo(output) + } + } + bgImage = target.absolutePath + } + } + + val titleRes = if (isNew) R.string.new_rule else R.string.edit_rule + + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = stringResource(titleRes), + endAction = { + androidx.compose.material3.IconButton(onClick = { + if (pattern.isNotBlank()) { + val result = runCatching { Regex(pattern) } + if (result.isFailure) { + patternError = result.exceptionOrNull()?.message + return@IconButton + } + } + patternError = null + val sanitized = HighlightRuleStore.sanitizeRule( + HighlightRule( + id = initial.id, + name = name, + pattern = pattern, + sampleText = sampleText, + targetScope = targetScope, + enabled = enabled, + position = initial.position, + textColor = if (hasTextColor) textColor else null, + bgColor = if (hasBgColor) bgColor else null, + underlineMode = if (hasUnderline) underlineMode else 0, + underlineColor = if (hasUnderlineColor && hasUnderline) underlineColor else null, + underlineWidth = underlineWidth, + underlineOffset = underlineOffset, + underlineSvgPath = underlineSvgPath.ifBlank { null }, + bgImage = if (hasBgImage) bgImage.ifBlank { null } else null, + bgImageFit = bgImageFit, + bgImageScale = bgImageScale, + ) + ) + onSave(sanitized) + }) { + androidx.compose.material3.Icon( + Icons.Default.Done, + contentDescription = null, + ) + } + }, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + // === Section 1: Rule Info === + SectionTitle(stringResource(R.string.rule_info)) + + AppTextField( + value = pattern, + onValueChange = { + pattern = it + patternError = null + }, + label = stringResource(R.string.rule_pattern), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + isError = patternError != null, + supportingText = patternError?.let { + { AppText(it, color = MaterialTheme.colorScheme.error) } + }, + ) + + Spacer(Modifier.height(8.dp)) + + AppTextField( + value = name, + onValueChange = { name = it }, + label = stringResource(R.string.rule_name), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(8.dp)) + + val scopeEntries = arrayOf( + stringResource(R.string.target_all), + stringResource(R.string.target_title), + stringResource(R.string.target_body), + ) + val scopeValues = arrayOf( + HighlightRule.TARGET_ALL.toString(), + HighlightRule.TARGET_TITLE.toString(), + HighlightRule.TARGET_BODY.toString(), + ) + TinyDropdownSettingItem( + title = stringResource(R.string.target_scope), + selectedValue = targetScope.toString(), + displayEntries = scopeEntries, + entryValues = scopeValues, + onValueChange = { targetScope = it.toIntOrNull() ?: HighlightRule.TARGET_ALL }, + ) + + TinySwitchSettingItem( + title = stringResource(R.string.enable_rule), + checked = enabled, + onCheckedChange = { enabled = it }, + ) + + // === Section 2: Style Settings === + SectionTitle(stringResource(R.string.style_settings)) + + // Text color + TinySwitchSettingItem( + title = stringResource(R.string.text_color), + checked = hasTextColor, + onCheckedChange = { hasTextColor = it }, + ) + if (hasTextColor) { + TinyColorSettingItem( + title = stringResource(R.string.select_color), + colorValue = textColor, + onClick = { showTextColorPicker = true }, + ) + } + + // Underline + TinySwitchSettingItem( + title = stringResource(R.string.underline_style), + checked = hasUnderline, + onCheckedChange = { hasUnderline = it }, + ) + if (hasUnderline) { + val underlineEntries = arrayOf( + stringResource(R.string.underline_solid), + stringResource(R.string.underline_dashed), + stringResource(R.string.underline_wave), + stringResource(R.string.underline_title_bar), + stringResource(R.string.underline_svg), + ) + val underlineValues = arrayOf("1", "2", "3", "4", "5") + TinyDropdownSettingItem( + title = stringResource(R.string.underline_style), + selectedValue = underlineMode.toString(), + displayEntries = underlineEntries, + entryValues = underlineValues, + onValueChange = { underlineMode = it.toIntOrNull() ?: 1 }, + ) + + TinySwitchSettingItem( + title = stringResource(R.string.underline_color), + checked = hasUnderlineColor, + onCheckedChange = { hasUnderlineColor = it }, + ) + if (hasUnderlineColor) { + TinyColorSettingItem( + title = stringResource(R.string.select_color), + colorValue = underlineColor, + onClick = { showUnderlineColorPicker = true }, + ) + } + + TinySliderSettingItem( + title = stringResource(R.string.underline_width), + value = underlineWidth, + valueRange = 0.1f..10f, + description = String.format("%.1f dp", underlineWidth), + onValueChange = { underlineWidth = (it * 10).toInt() / 10f }, + ) + + TinySliderSettingItem( + title = stringResource(R.string.underline_offset), + value = underlineOffset, + valueRange = 0f..20f, + description = String.format("%.1f dp", underlineOffset), + onValueChange = { underlineOffset = (it * 10).toInt() / 10f }, + ) + + if (underlineMode == 5) { + AppTextField( + value = underlineSvgPath, + onValueChange = { underlineSvgPath = it }, + label = stringResource(R.string.svg_path), + modifier = Modifier.fillMaxWidth(), + ) + } + } + + // Background color + TinySwitchSettingItem( + title = stringResource(R.string.bg_color), + checked = hasBgColor, + onCheckedChange = { hasBgColor = it }, + ) + if (hasBgColor) { + TinyColorSettingItem( + title = stringResource(R.string.select_color), + colorValue = bgColor, + onClick = { showBgColorPicker = true }, + ) + } + + // Background image + TinySwitchSettingItem( + title = stringResource(R.string.highlight_bg_image), + checked = hasBgImage, + onCheckedChange = { hasBgImage = it }, + ) + if (hasBgImage) { + TinyClickableSettingItem( + title = stringResource(R.string.highlight_bg_image), + description = bgImage.ifBlank { null }?.let { File(it).name }, + onClick = { imagePicker.launch("image/*") }, + ) + } + if (hasBgImage && bgImage.isNotBlank()) { + val fitEntries = arrayOf( + stringResource(R.string.bg_fit_tile), + stringResource(R.string.bg_fit_stretch), + stringResource(R.string.bg_fit_crop), + ) + val fitValues = arrayOf("0", "1", "2") + TinyDropdownSettingItem( + title = stringResource(R.string.bg_image_fit), + selectedValue = bgImageFit.toString(), + displayEntries = fitEntries, + entryValues = fitValues, + onValueChange = { bgImageFit = it.toIntOrNull() ?: 0 }, + ) + + TinySliderSettingItem( + title = stringResource(R.string.highlight_bg_image_scale), + value = bgImageScale, + valueRange = 0.1f..5f, + description = String.format("%.1fx", bgImageScale), + onValueChange = { bgImageScale = (it * 10).toInt() / 10f }, + ) + } + + // === Section 3: Preview === + SectionTitle(stringResource(R.string.preview_effect)) + + AppTextField( + value = sampleText, + onValueChange = { sampleText = it }, + label = stringResource(R.string.sample_text), + modifier = Modifier.fillMaxWidth(), + ) + + HighlightRulePreview( + sampleText = sampleText, + textColor = if (hasTextColor) textColor else null, + bgColor = if (hasBgColor) bgColor else null, + underlineMode = if (hasUnderline) underlineMode else 0, + underlineColor = if (hasUnderlineColor && hasUnderline) underlineColor else null, + underlineWidth = underlineWidth, + underlineOffset = underlineOffset, + modifier = Modifier + .fillMaxWidth() + .height(80.dp) + .padding(top = 8.dp), + ) + } + } + + // Color pickers + if (showTextColorPicker) { + ColorPickerSheet( + show = true, + initialColor = textColor, + onDismissRequest = { showTextColorPicker = false }, + onColorSelected = { color -> + textColor = color + showTextColorPicker = false + }, + ) + } + if (showBgColorPicker) { + ColorPickerSheet( + show = true, + initialColor = bgColor, + onDismissRequest = { showBgColorPicker = false }, + onColorSelected = { color -> + bgColor = color + showBgColorPicker = false + }, + ) + } + if (showUnderlineColorPicker) { + ColorPickerSheet( + show = true, + initialColor = underlineColor, + onDismissRequest = { showUnderlineColorPicker = false }, + onColorSelected = { color -> + underlineColor = color + showUnderlineColorPicker = false + }, + ) + } +} + +@Composable +private fun HighlightRulePreview( + sampleText: String, + textColor: Int?, + bgColor: Int?, + underlineMode: Int, + underlineColor: Int?, + underlineWidth: Float, + underlineOffset: Float, + modifier: Modifier = Modifier, +) { + val textMeasurer = rememberTextMeasurer() + val defaultTextColor = LegadoTheme.colorScheme.onSurface + val resolvedTextColor = textColor?.let { Color(it) } ?: defaultTextColor + val resolvedUnderlineColor = underlineColor?.let { Color(it) } ?: resolvedTextColor + + val textStyle = TextStyle( + fontSize = 16.sp, + color = resolvedTextColor, + ) + + Canvas(modifier = modifier) { + val textResult = textMeasurer.measure( + text = sampleText, + style = textStyle, + maxLines = 3, + ) + if (bgColor != null) { + drawRect( + color = Color(bgColor), + topLeft = Offset(0f, 0f), + size = size.copy(height = textResult.size.height.toFloat()), + ) + } + drawText(textResult) + + if (underlineMode > 0) { + val strokeWidth = underlineWidth.dp.toPx() + val yBaseline = textResult.size.height.toFloat() - underlineOffset.dp.toPx() + + when (underlineMode) { + 1 -> { + drawLine( + color = resolvedUnderlineColor, + start = Offset(0f, yBaseline), + end = Offset(textResult.size.width.toFloat(), yBaseline), + strokeWidth = strokeWidth, + ) + } + 2 -> { + val dashLength = 8.dp.toPx() + val gapLength = 4.dp.toPx() + var x = 0f + while (x < textResult.size.width) { + val endX = minOf(x + dashLength, textResult.size.width.toFloat()) + drawLine( + color = resolvedUnderlineColor, + start = Offset(x, yBaseline), + end = Offset(endX, yBaseline), + strokeWidth = strokeWidth, + ) + x += dashLength + gapLength + } + } + 3 -> { + val amplitude = 2.dp.toPx() + val period = 12.dp.toPx() + val path = androidx.compose.ui.graphics.Path().apply { + moveTo(0f, yBaseline) + var x = 0f + while (x < textResult.size.width) { + val nextX = minOf(x + period / 2, textResult.size.width.toFloat()) + val controlY = if ((x / period).toInt() % 2 == 0) { + yBaseline - amplitude + } else { + yBaseline + amplitude + } + quadraticTo(x, controlY, nextX, yBaseline) + x += period / 2 + } + } + drawPath( + path = path, + color = resolvedUnderlineColor, + style = Stroke(width = strokeWidth, cap = StrokeCap.Round), + ) + } + 4 -> { + val barHeight = 3.dp.toPx() + drawLine( + color = resolvedUnderlineColor, + start = Offset(0f, yBaseline), + end = Offset(textResult.size.width.toFloat(), yBaseline), + strokeWidth = barHeight, + cap = StrokeCap.Round, + ) + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/MoreConfigSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/MoreConfigSheet.kt new file mode 100644 index 000000000..449145e61 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/MoreConfigSheet.kt @@ -0,0 +1,382 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.R +import io.legado.app.data.repository.ReadPreferences +import io.legado.app.data.repository.ReadSettingsRepository +import io.legado.app.ui.book.read.ConfigUpdate +import io.legado.app.ui.book.read.ReadBookIntent +import io.legado.app.ui.widget.components.SectionTitle +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem +import io.legado.app.ui.widget.components.settingItem.TinyDropdownSettingItem +import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem +import kotlinx.coroutines.launch +import org.koin.compose.koinInject + +@Composable +fun MoreConfigSheet( + show: Boolean, + onDismissRequest: () -> Unit, + onIntent: (ReadBookIntent) -> Unit, + onOpenClickRegionalConfig: () -> Unit, + onOpenPageKeyConfig: () -> Unit, +) { + val readSettingsRepository: ReadSettingsRepository = koinInject() + val preferences by readSettingsRepository.preferences.collectAsStateWithLifecycle( + initialValue = ReadPreferences() + ) + val scope = rememberCoroutineScope() + + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.more_setting), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + // Screen settings + SectionTitle(stringResource(R.string.screen_settings)) + ScreenSettings( + preferences = preferences, + onScreenOrientationChange = { + scope.launch { readSettingsRepository.setScreenOrientation(it) } + }, + onKeepLightChange = { + scope.launch { readSettingsRepository.setKeepLight(it) } + }, + onHideStatusBarChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HideStatusBar(it))) + }, + onHideNavigationBarChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HideNavigationBar(it))) + }, + onPaddingDisplayCutoutsChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingDisplayCutouts(it))) + }, + onReadBodyToLhChange = { + scope.launch { readSettingsRepository.setReadBodyToLh(it) } + }, + onTextFullJustifyChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextFullJustify(it))) + }, + onTextBottomJustifyChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextBottomJustify(it))) + }, + onAdaptSpecialStyleChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.AdaptSpecialStyle(it))) + }, + onUseZhLayoutChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.UseZhLayout(it))) + }, + onShowBrightnessViewChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShowBrightnessView(it))) + }, + onUseUnderlineChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.UseUnderlineGlobal(it))) + }, + ) + + // Page control + SectionTitle(stringResource(R.string.page_control)) + PageControlSettings( + preferences = preferences, + onReadSliderModeChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ReadSliderMode(it))) + }, + onDoubleHorizontalPageChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.DoubleHorizontalPage(it))) + }, + onProgressBarBehaviorChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ProgressBarBehavior(it))) + }, + onMouseWheelPageChange = { + scope.launch { readSettingsRepository.setMouseWheelPage(it) } + }, + onVolumeKeyPageChange = { + scope.launch { readSettingsRepository.setVolumeKeyPage(it) } + }, + onVolumeKeyPageOnPlayChange = { + scope.launch { readSettingsRepository.setVolumeKeyPageOnPlay(it) } + }, + onKeyPageOnLongPressChange = { + scope.launch { readSettingsRepository.setKeyPageOnLongPress(it) } + }, + ) + + // Other + SectionTitle(stringResource(R.string.other)) + OtherSettings( + preferences = preferences, + onSliderVibratorChange = { + scope.launch { readSettingsRepository.setSliderVibrator(it) } + }, + onSelectVibratorChange = { + scope.launch { readSettingsRepository.setSelectVibrator(it) } + }, + onAutoChangeSourceChange = { + scope.launch { readSettingsRepository.setAutoChangeSource(it) } + }, + onSelectTextChange = { + scope.launch { readSettingsRepository.setSelectText(it) } + }, + onNoAnimScrollPageChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.NoAnimScrollPage(it))) + }, + onClickImgWayChange = { + scope.launch { readSettingsRepository.setClickImgWay(it) } + }, + onOpenClickRegionalConfig = onOpenClickRegionalConfig, + onDisableReturnKeyChange = { + scope.launch { readSettingsRepository.setDisableReturnKey(it) } + }, + onOpenPageKeyConfig = onOpenPageKeyConfig, + onExpandTextMenuChange = { + scope.launch { readSettingsRepository.setExpandTextMenu(it) } + }, + onShowReadTitleAdditionChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShowReadTitleAddition(it))) + }, + ) + } + } +} + +@Composable +private fun ScreenSettings( + preferences: ReadPreferences, + onScreenOrientationChange: (String) -> Unit, + onKeepLightChange: (String) -> Unit, + onHideStatusBarChange: (Boolean) -> Unit, + onHideNavigationBarChange: (Boolean) -> Unit, + onPaddingDisplayCutoutsChange: (Boolean) -> Unit, + onReadBodyToLhChange: (Boolean) -> Unit, + onTextFullJustifyChange: (Boolean) -> Unit, + onTextBottomJustifyChange: (Boolean) -> Unit, + onAdaptSpecialStyleChange: (Boolean) -> Unit, + onUseZhLayoutChange: (Boolean) -> Unit, + onShowBrightnessViewChange: (Boolean) -> Unit, + onUseUnderlineChange: (Boolean) -> Unit, +) { + val screenDirectionEntries = stringArrayResource(R.array.screen_direction_title) + val screenDirectionValues = stringArrayResource(R.array.screen_direction_value) + val keepLightEntries = stringArrayResource(R.array.screen_time_out) + val keepLightValues = stringArrayResource(R.array.screen_time_out_value) + + TinyDropdownSettingItem( + title = stringResource(R.string.screen_direction), + selectedValue = preferences.screenOrientation, + displayEntries = screenDirectionEntries, + entryValues = screenDirectionValues, + onValueChange = onScreenOrientationChange, + ) + TinyDropdownSettingItem( + title = stringResource(R.string.keep_light), + selectedValue = preferences.keepLight, + displayEntries = keepLightEntries, + entryValues = keepLightValues, + onValueChange = onKeepLightChange, + ) + TinySwitchSettingItem( + title = stringResource(R.string.pt_hide_status_bar), + checked = preferences.hideStatusBar, + onCheckedChange = onHideStatusBarChange, + ) + TinySwitchSettingItem( + title = stringResource(R.string.pt_hide_navigation_bar), + checked = preferences.hideNavigationBar, + onCheckedChange = onHideNavigationBarChange, + ) + TinySwitchSettingItem( + title = stringResource(R.string.padding_display_cutouts), + checked = preferences.paddingDisplayCutouts, + onCheckedChange = onPaddingDisplayCutoutsChange, + ) + TinySwitchSettingItem( + title = stringResource(R.string.read_body_to_lh), + checked = preferences.readBodyToLh, + onCheckedChange = onReadBodyToLhChange, + ) + TinySwitchSettingItem( + title = stringResource(R.string.text_full_justify), + checked = preferences.textFullJustify, + onCheckedChange = onTextFullJustifyChange, + ) + TinySwitchSettingItem( + title = stringResource(R.string.text_bottom_justify), + checked = preferences.textBottomJustify, + onCheckedChange = onTextBottomJustifyChange, + ) + TinySwitchSettingItem( + title = stringResource(R.string.adapt_special_style), + checked = preferences.adaptSpecialStyle, + onCheckedChange = onAdaptSpecialStyleChange, + ) + TinySwitchSettingItem( + title = stringResource(R.string.use_zh_layout), + checked = preferences.useZhLayout, + onCheckedChange = onUseZhLayoutChange, + ) + TinySwitchSettingItem( + title = stringResource(R.string.show_brightness_view), + checked = preferences.showBrightnessView, + onCheckedChange = onShowBrightnessViewChange, + ) + TinySwitchSettingItem( + title = stringResource(R.string.use_underline), + checked = preferences.useUnderline, + onCheckedChange = onUseUnderlineChange, + ) +} + +@Composable +private fun PageControlSettings( + preferences: ReadPreferences, + onReadSliderModeChange: (String) -> Unit, + onDoubleHorizontalPageChange: (String) -> Unit, + onProgressBarBehaviorChange: (String) -> Unit, + onMouseWheelPageChange: (Boolean) -> Unit, + onVolumeKeyPageChange: (Boolean) -> Unit, + onVolumeKeyPageOnPlayChange: (Boolean) -> Unit, + onKeyPageOnLongPressChange: (Boolean) -> Unit, +) { + val readSliderModeEntries = stringArrayResource(R.array.read_slider_mode) + val readSliderModeValues = stringArrayResource(R.array.read_slider_mode_value) + val doublePageEntries = stringArrayResource(R.array.double_page_title) + val doublePageValues = stringArrayResource(R.array.double_page_value) + val progressBarEntries = stringArrayResource(R.array.progress_bar_behavior_title) + val progressBarValues = stringArrayResource(R.array.progress_bar_behavior_value) + + TinyDropdownSettingItem( + title = stringResource(R.string.read_slider_mode), + selectedValue = preferences.readSliderMode, + displayEntries = readSliderModeEntries, + entryValues = readSliderModeValues, + onValueChange = onReadSliderModeChange, + ) + TinyDropdownSettingItem( + title = stringResource(R.string.double_page_horizontal), + selectedValue = preferences.doubleHorizontalPage, + displayEntries = doublePageEntries, + entryValues = doublePageValues, + onValueChange = onDoubleHorizontalPageChange, + ) + TinyDropdownSettingItem( + title = stringResource(R.string.progress_bar_behavior), + selectedValue = preferences.progressBarBehavior, + displayEntries = progressBarEntries, + entryValues = progressBarValues, + onValueChange = onProgressBarBehaviorChange, + ) + TinySwitchSettingItem( + title = stringResource(R.string.mouse_wheel_page), + checked = preferences.mouseWheelPage, + onCheckedChange = onMouseWheelPageChange, + ) + TinySwitchSettingItem( + title = stringResource(R.string.volume_key_page), + checked = preferences.volumeKeyPage, + onCheckedChange = onVolumeKeyPageChange, + ) + TinySwitchSettingItem( + title = stringResource(R.string.volume_key_page_on_play), + checked = preferences.volumeKeyPageOnPlay, + onCheckedChange = onVolumeKeyPageOnPlayChange, + ) + TinySwitchSettingItem( + title = stringResource(R.string.key_page_on_long_press), + checked = preferences.keyPageOnLongPress, + onCheckedChange = onKeyPageOnLongPressChange, + ) +} + +@Composable +private fun OtherSettings( + preferences: ReadPreferences, + onSliderVibratorChange: (Boolean) -> Unit, + onSelectVibratorChange: (Boolean) -> Unit, + onAutoChangeSourceChange: (Boolean) -> Unit, + onSelectTextChange: (Boolean) -> Unit, + onNoAnimScrollPageChange: (Boolean) -> Unit, + onClickImgWayChange: (String) -> Unit, + onOpenClickRegionalConfig: () -> Unit, + onDisableReturnKeyChange: (Boolean) -> Unit, + onOpenPageKeyConfig: () -> Unit, + onExpandTextMenuChange: (Boolean) -> Unit, + onShowReadTitleAdditionChange: (Boolean) -> Unit, +) { + val clickImageWayEntries = stringArrayResource(R.array.click_image_way_title) + val clickImageWayValues = stringArrayResource(R.array.click_image_way_value) + + TinySwitchSettingItem( + title = stringResource(R.string.enable_slider_vibrator), + checked = preferences.sliderVibrator, + onCheckedChange = onSliderVibratorChange, + ) + TinySwitchSettingItem( + title = stringResource(R.string.enable_select_vibrator), + checked = preferences.selectVibrator, + onCheckedChange = onSelectVibratorChange, + ) + TinySwitchSettingItem( + title = stringResource(R.string.auto_change_source), + checked = preferences.autoChangeSource, + onCheckedChange = onAutoChangeSourceChange, + ) + TinySwitchSettingItem( + title = stringResource(R.string.selectText), + checked = preferences.selectText, + onCheckedChange = onSelectTextChange, + ) + TinySwitchSettingItem( + title = stringResource(R.string.no_anim_scroll_page), + checked = preferences.noAnimScrollPage, + onCheckedChange = onNoAnimScrollPageChange, + ) + TinyDropdownSettingItem( + title = stringResource(R.string.click_image_way), + selectedValue = preferences.clickImgWay, + displayEntries = clickImageWayEntries, + entryValues = clickImageWayValues, + onValueChange = onClickImgWayChange, + ) + TinyClickableSettingItem( + title = stringResource(R.string.click_regional_config), + onClick = onOpenClickRegionalConfig, + ) + TinySwitchSettingItem( + title = stringResource(R.string.disable_return_key), + checked = preferences.disableReturnKey, + onCheckedChange = onDisableReturnKeyChange, + ) + TinyClickableSettingItem( + title = stringResource(R.string.custom_page_key), + onClick = onOpenPageKeyConfig, + ) + TinySwitchSettingItem( + title = stringResource(R.string.expand_text_menu), + checked = preferences.expandTextMenu, + onCheckedChange = onExpandTextMenuChange, + ) + TinySwitchSettingItem( + title = stringResource(R.string.show_read_title_addition), + checked = preferences.showReadTitleAddition, + onCheckedChange = onShowReadTitleAdditionChange, + ) +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/PaddingConfigSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/PaddingConfigSheet.kt new file mode 100644 index 000000000..27f3040bb --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/PaddingConfigSheet.kt @@ -0,0 +1,205 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.help.config.ReadBookConfig +import io.legado.app.ui.book.read.ConfigUpdate +import io.legado.app.ui.book.read.ReadBookIntent +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem +import io.legado.app.ui.widget.components.tabRow.CardTabRow +import kotlinx.coroutines.launch + +@Composable +fun PaddingConfigSheet( + onDismissRequest: () -> Unit, + onIntent: (ReadBookIntent) -> Unit, +) { + AppModalBottomSheet( + show = true, + onDismissRequest = { + onIntent(ReadBookIntent.SaveReadStyleConfig) + onDismissRequest() + }, + title = stringResource(R.string.padding), + ) { + PaddingConfigContent( + onIntent = onIntent, + modifier = Modifier + .padding(bottom = 16.dp), + ) + } +} + +@Composable +fun PaddingConfigContent( + onIntent: (ReadBookIntent) -> Unit, + modifier: Modifier = Modifier, +) { + // Body padding + var paddingTop by remember { mutableFloatStateOf(ReadBookConfig.paddingTop.toFloat()) } + var paddingBottom by remember { mutableFloatStateOf(ReadBookConfig.paddingBottom.toFloat()) } + var paddingLeft by remember { mutableFloatStateOf(ReadBookConfig.paddingLeft.toFloat()) } + var paddingRight by remember { mutableFloatStateOf(ReadBookConfig.paddingRight.toFloat()) } + // Header padding + var headerPaddingTop by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingTop.toFloat()) } + var headerPaddingBottom by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingBottom.toFloat()) } + var headerPaddingLeft by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingLeft.toFloat()) } + var headerPaddingRight by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingRight.toFloat()) } + // Footer padding + var footerPaddingTop by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingTop.toFloat()) } + var footerPaddingBottom by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingBottom.toFloat()) } + var footerPaddingLeft by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingLeft.toFloat()) } + var footerPaddingRight by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingRight.toFloat()) } + + val scope = rememberCoroutineScope() + val tabTitles = listOf( + stringResource(R.string.header), + stringResource(R.string.main_body), + stringResource(R.string.footer), + ) + val pagerState = rememberPagerState(pageCount = { 3 }) + var selectedTab by remember { mutableIntStateOf(0) } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage }.collect { selectedTab = it } + } + + Column( + modifier = modifier.fillMaxWidth(), + ) { + CardTabRow( + tabTitles = tabTitles, + selectedTabIndex = selectedTab, + onTabSelected = { index -> + selectedTab = index + scope.launch { pagerState.animateScrollToPage(index) } + }, + modifier = Modifier.padding(bottom = 8.dp), + ) + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + ) { page -> + when (page) { + 0 -> Column(modifier = Modifier.padding(vertical = 8.dp)) { + PaddingSliders( + top = headerPaddingTop, bottom = headerPaddingBottom, + left = headerPaddingLeft, right = headerPaddingRight, + onTopChange = { + headerPaddingTop = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingTop(it.toInt()))) + }, + onBottomChange = { + headerPaddingBottom = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingBottom(it.toInt()))) + }, + onLeftChange = { + headerPaddingLeft = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingLeft(it.toInt()))) + }, + onRightChange = { + headerPaddingRight = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingRight(it.toInt()))) + }, + ) + } + + 1 -> Column(modifier = Modifier.padding(vertical = 8.dp)) { + PaddingSliders( + top = paddingTop, bottom = paddingBottom, + left = paddingLeft, right = paddingRight, + onTopChange = { + paddingTop = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingTop(it.toInt()))) + }, + onBottomChange = { + paddingBottom = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingBottom(it.toInt()))) + }, + onLeftChange = { + paddingLeft = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingLeft(it.toInt()))) + }, + onRightChange = { + paddingRight = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingRight(it.toInt()))) + }, + ) + } + + 2 -> Column(modifier = Modifier.padding(vertical = 8.dp)) { + PaddingSliders( + top = footerPaddingTop, bottom = footerPaddingBottom, + left = footerPaddingLeft, right = footerPaddingRight, + onTopChange = { + footerPaddingTop = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingTop(it.toInt()))) + }, + onBottomChange = { + footerPaddingBottom = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingBottom(it.toInt()))) + }, + onLeftChange = { + footerPaddingLeft = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingLeft(it.toInt()))) + }, + onRightChange = { + footerPaddingRight = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingRight(it.toInt()))) + }, + ) + } + } + } + } +} + +@Composable +private fun PaddingSliders( + top: Float, bottom: Float, left: Float, right: Float, + onTopChange: (Float) -> Unit, onBottomChange: (Float) -> Unit, + onLeftChange: (Float) -> Unit, onRightChange: (Float) -> Unit, +) { + TinySliderSettingItem( + title = stringResource(R.string.padding_top), + value = top, + valueRange = 0f..300f, + onValueChange = onTopChange, + ) + TinySliderSettingItem( + title = stringResource(R.string.padding_bottom), + value = bottom, + valueRange = 0f..300f, + onValueChange = onBottomChange, + ) + TinySliderSettingItem( + title = stringResource(R.string.padding_left), + value = left, + valueRange = 0f..300f, + onValueChange = onLeftChange, + ) + TinySliderSettingItem( + title = stringResource(R.string.padding_right), + value = right, + valueRange = 0f..300f, + onValueChange = onRightChange, + ) +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/PageAnimConfigSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/PageAnimConfigSheet.kt new file mode 100644 index 000000000..624962bac --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/PageAnimConfigSheet.kt @@ -0,0 +1,45 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.legado.app.R +import io.legado.app.model.ReadBook +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.settingItem.TinyDropdownSettingItem + +@Composable +fun PageAnimConfigSheet( + onDismissRequest: () -> Unit, + onAnimChanged: () -> Unit, +) { + val items = listOf( + R.string.btn_default_s, + R.string.page_anim_cover, + R.string.page_anim_slide, + R.string.page_anim_simulation, + R.string.page_anim_scroll, + R.string.page_anim_none, + ) + + AlertDialog( + onDismissRequest = onDismissRequest, + containerColor = LegadoTheme.colorScheme.surfaceContainer, + title = { Text(stringResource(R.string.page_anim)) }, + text = { + TinyDropdownSettingItem( + title = stringResource(R.string.page_anim), + selectedValue = ReadBook.book?.getPageAnim()?.toString() ?: "-1", + displayEntries = items.map { stringResource(it) }.toTypedArray(), + entryValues = items.indices.map { (it - 1).toString() }.toTypedArray(), + onValueChange = { + ReadBook.book?.setPageAnim(it.toInt()) + onAnimChanged() + onDismissRequest() + }, + ) + }, + confirmButton = {}, + ) +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/PageKeyConfigSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/PageKeyConfigSheet.kt new file mode 100644 index 000000000..79a739c07 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/PageKeyConfigSheet.kt @@ -0,0 +1,140 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.R +import io.legado.app.data.repository.ReadPreferences +import io.legado.app.data.repository.ReadSettingsRepository +import io.legado.app.ui.theme.LegadoTheme +import kotlinx.coroutines.launch +import org.koin.compose.koinInject + +@Composable +fun PageKeyConfigSheet( + onDismissRequest: () -> Unit, +) { + val readSettingsRepository: ReadSettingsRepository = koinInject() + val preferences by readSettingsRepository.preferences.collectAsStateWithLifecycle( + initialValue = ReadPreferences() + ) + val scope = rememberCoroutineScope() + var prevKeys by remember { mutableStateOf(preferences.prevKeys) } + var nextKeys by remember { mutableStateOf(preferences.nextKeys) } + + LaunchedEffect(preferences.prevKeys, preferences.nextKeys) { + prevKeys = preferences.prevKeys + nextKeys = preferences.nextKeys + } + + AlertDialog( + onDismissRequest = onDismissRequest, + containerColor = LegadoTheme.colorScheme.surfaceContainer, + title = { Text(stringResource(R.string.custom_page_key)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + OutlinedTextField( + value = prevKeys, + onValueChange = { prevKeys = it }, + label = { Text(stringResource(R.string.prev_page_key)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier + .fillMaxWidth() + .onKeyEvent { event -> + val keyCode = event.nativeKeyEvent.keyCode + if (keyCode != android.view.KeyEvent.KEYCODE_BACK && + keyCode != android.view.KeyEvent.KEYCODE_DEL + ) { + prevKeys = if (prevKeys.isEmpty() || prevKeys.endsWith(",")) { + "$prevKeys$keyCode" + } else { + "$prevKeys,$keyCode" + } + true + } else { + false + } + }, + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = nextKeys, + onValueChange = { nextKeys = it }, + label = { Text(stringResource(R.string.next_page_key)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + modifier = Modifier + .fillMaxWidth() + .onKeyEvent { event -> + val keyCode = event.nativeKeyEvent.keyCode + if (keyCode != android.view.KeyEvent.KEYCODE_BACK && + keyCode != android.view.KeyEvent.KEYCODE_DEL + ) { + nextKeys = if (nextKeys.isEmpty() || nextKeys.endsWith(",")) { + "$nextKeys$keyCode" + } else { + "$nextKeys,$keyCode" + } + true + } else { + false + } + }, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.page_key_set_help), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + dismissButton = { + TextButton( + onClick = { + prevKeys = "" + nextKeys = "" + }, + ) { + Text(stringResource(R.string.reset)) + } + }, + confirmButton = { + TextButton( + onClick = { + scope.launch { + readSettingsRepository.setPageKeys(prevKeys, nextKeys) + onDismissRequest() + } + }, + ) { + Text(stringResource(R.string.ok)) + } + }, + ) +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/PhotoSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/PhotoSheet.kt new file mode 100644 index 000000000..629ccd706 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/PhotoSheet.kt @@ -0,0 +1,105 @@ +package io.legado.app.ui.book.read.sheet + +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy +import com.bumptech.glide.request.RequestOptions +import io.legado.app.R +import io.legado.app.help.book.BookHelp +import io.legado.app.help.glide.ImageLoader +import io.legado.app.help.glide.OkHttpModelLoader +import io.legado.app.model.BookCover +import io.legado.app.model.ImageProvider +import io.legado.app.model.ReadBook +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.image.PhotoView +import io.legado.app.utils.ImageSaveUtils.saveImageToGallery +import io.legado.app.utils.toastOnUi +import java.io.ByteArrayOutputStream + +@Composable +fun PhotoSheet( + show: Boolean, + src: String, + sourceOrigin: String? = null, + onDismissRequest: () -> Unit, +) { + val context = LocalContext.current + + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.photo), + ) { + AndroidView( + factory = { ctx -> + PhotoView(ctx).apply { + // Try to load from ImageProvider first + val bitmap = ImageProvider.get(src) + if (bitmap != null) { + setImageBitmap(bitmap) + } else { + // Try to load from local file + val file = ReadBook.book?.let { book -> + BookHelp.getImage(book, src) + } + if (file?.exists() == true) { + ImageLoader.load(ctx, file) + .error(R.drawable.image_loading_error) + .dontTransform() + .downsample(DownsampleStrategy.NONE) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(this) + } else { + // Load from URL + val request = ImageLoader.load(ctx, src).apply { + sourceOrigin?.let { origin -> + apply( + RequestOptions().set( + OkHttpModelLoader.sourceOriginOption, + origin + ) + ) + } + } + request.error(BookCover.defaultDrawable) + .dontTransform() + .downsample(DownsampleStrategy.NONE) + .into(this) + } + } + + // Long press to save + setOnLongClickListener { + val drawable = drawable + val bmp = (drawable as? BitmapDrawable)?.bitmap + if (bmp != null) { + val byteArray = ByteArrayOutputStream().use { stream -> + bmp.compress(Bitmap.CompressFormat.JPEG, 100, stream) + stream.toByteArray() + } + val success = saveImageToGallery(context, byteArray) + context.toastOnUi( + if (success) context.getString(R.string.save_success) + else "保存失败" + ) + } + true + } + } + }, + modifier = Modifier + .fillMaxSize() + .padding(vertical = 8.dp), + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudConfigSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudConfigSheet.kt new file mode 100644 index 000000000..f845bf5e7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudConfigSheet.kt @@ -0,0 +1,186 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.ui.book.read.ReadBookIntent +import io.legado.app.ui.book.read.ReadBookTtsEngineItem +import io.legado.app.ui.book.read.ReadBookUiState +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.settingItem.SliderSettingItem +import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem +import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem + +@Composable +fun ReadAloudConfigSheet( + show: Boolean, + state: ReadBookUiState, + onIntent: (ReadBookIntent) -> Unit, + onDismissRequest: () -> Unit, +) { + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.aloud_config), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + TinySwitchSettingItem( + title = stringResource(R.string.ignore_audio_focus_title), + description = stringResource(R.string.ignore_audio_focus_summary), + checked = state.readAloudIgnoreAudioFocus, + onCheckedChange = { + onIntent(ReadBookIntent.SetReadAloudIgnoreAudioFocus(it)) + }, + ) + TinySwitchSettingItem( + title = stringResource(R.string.pause_read_aloud_while_phone_calls_title), + description = stringResource(R.string.pause_read_aloud_while_phone_calls_summary), + checked = state.readAloudPauseOnPhoneCall, + enabled = state.readAloudIgnoreAudioFocus, + onCheckedChange = { + onIntent(ReadBookIntent.SetReadAloudPauseOnPhoneCall(it)) + }, + ) + TinySwitchSettingItem( + title = stringResource(R.string.read_aloud_wake_lock), + description = stringResource(R.string.read_aloud_wake_lock_summary), + checked = state.readAloudWakeLock, + onCheckedChange = { + onIntent(ReadBookIntent.SetReadAloudWakeLock(it)) + }, + ) + TinySwitchSettingItem( + title = stringResource(R.string.pref_media_button_per_next), + description = stringResource(R.string.pref_media_button_per_next_summary), + checked = state.readAloudMediaButtonPerNext, + onCheckedChange = { + onIntent(ReadBookIntent.SetReadAloudMediaButtonPerNext(it)) + }, + ) + TinySwitchSettingItem( + title = stringResource(R.string.read_aloud_by_page), + description = stringResource(R.string.read_aloud_by_page_summary), + checked = state.readAloudByPage, + onCheckedChange = { + onIntent(ReadBookIntent.SetReadAloudByPage(it)) + }, + ) + TinySwitchSettingItem( + title = stringResource(R.string.system_media_control_compatibility_change), + description = stringResource(R.string.system_media_control_compatibility_change_summary), + checked = state.readAloudSystemMediaCompat, + onCheckedChange = { + onIntent(ReadBookIntent.SetReadAloudSystemMediaCompat(it)) + }, + ) + TinySwitchSettingItem( + title = stringResource(R.string.stream_read_aloud_audio), + description = stringResource(R.string.stream_read_aloud_audio_summary), + checked = state.readAloudStreamAudio, + onCheckedChange = { + onIntent(ReadBookIntent.SetReadAloudStreamAudio(it)) + }, + ) + TinyClickableSettingItem( + title = stringResource(R.string.speak_engine), + onClick = { onIntent(ReadBookIntent.SelectSpeakEngine) }, + ) + TinyClickableSettingItem( + title = stringResource(R.string.sys_tts_config), + onClick = { onIntent(ReadBookIntent.OpenSystemTtsSettings) }, + ) + TinyClickableSettingItem( + title = stringResource(R.string.read_aloud_preload), + onClick = { onIntent(ReadBookIntent.OpenPreDownloadNumPicker) }, + ) + TinyClickableSettingItem( + title = stringResource(R.string.audio_cache_clean_time), + onClick = { onIntent(ReadBookIntent.OpenCacheCleanTimePicker) }, + ) + TinyClickableSettingItem( + title = stringResource(R.string.clear_cache), + onClick = { onIntent(ReadBookIntent.ClearTtsCache) }, + ) + } + } +} + +@Composable +fun SpeakEngineConfigSheet( + show: Boolean, + items: List, + selectedValue: String?, + onSelect: (String?) -> Unit, + onDismissRequest: () -> Unit, +) { + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.speak_engine), + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + items(items) { item -> + TinyClickableSettingItem( + title = item.title, + description = if (item.value == selectedValue) { + stringResource(R.string.default_version) + } else { + null + }, + onClick = { onSelect(item.value) }, + ) + } + } + } +} + +@Composable +fun ReadAloudNumberConfigSheet( + show: Boolean, + title: String, + description: String, + value: Int, + defaultValue: Int, + valueRange: ClosedFloatingPointRange, + onValueChange: (Int) -> Unit, + onDismissRequest: () -> Unit, +) { + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = title, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + SliderSettingItem( + title = title, + description = description, + value = value.toFloat(), + defaultValue = defaultValue.toFloat(), + valueRange = valueRange, + onValueChange = { onValueChange(it.toInt()) }, + ) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudSheet.kt new file mode 100644 index 000000000..f6abeb9cd --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudSheet.kt @@ -0,0 +1,239 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.ui.book.read.ReadBookIntent +import io.legado.app.ui.book.read.ReadBookUiState +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem +import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem + +@Composable +fun ReadAloudSheet( + state: ReadBookUiState, + onIntent: (ReadBookIntent) -> Unit, + onDismissRequest: () -> Unit, + onOpenChapterList: () -> Unit, + onGoToBackground: () -> Unit, + onShowReadAloudConfig: () -> Unit, +) { + AppModalBottomSheet( + show = true, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.aloud_config), + ) { + ReadAloudContent( + state = state, + onIntent = onIntent, + onDismissRequest = onDismissRequest, + onOpenChapterList = onOpenChapterList, + onGoToBackground = onGoToBackground, + onShowReadAloudConfig = onShowReadAloudConfig, + modifier = Modifier + .padding(bottom = 16.dp), + ) + } +} + +@Composable +fun ReadAloudContent( + state: ReadBookUiState, + onIntent: (ReadBookIntent) -> Unit, + onDismissRequest: () -> Unit, + onOpenChapterList: () -> Unit, + onGoToBackground: () -> Unit, + onShowReadAloudConfig: () -> Unit, + modifier: Modifier = Modifier, +) { + val timerMinute = state.readAloudTtsTimer + val ttsSpeechRate = state.readAloudTtsSpeechRate + + Column( + modifier = modifier.fillMaxWidth(), + ) { + // Media controls + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + FilledTonalIconButton( + onClick = { onIntent(ReadBookIntent.ReadAloudPrevParagraph) }, + ) { + Icon( + painter = painterResource(R.drawable.ic_skip_previous), + contentDescription = stringResource(R.string.prev_sentence), + ) + } + Spacer(Modifier.width(6.dp)) + FilledTonalIconButton( + onClick = { + onIntent(ReadBookIntent.ReadAloudTogglePause) + }, + modifier = Modifier.size(48.dp), + ) { + Icon( + painter = painterResource( + if (state.isReadAloudPaused) R.drawable.ic_play else R.drawable.ic_pause + ), + contentDescription = stringResource( + if (state.isReadAloudPaused) R.string.audio_play else R.string.pause + ), + ) + } + Spacer(Modifier.width(6.dp)) + FilledTonalIconButton( + onClick = { + onIntent(ReadBookIntent.ReadAloudStop) + onDismissRequest() + }, + modifier = Modifier.size(48.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_stop_black_24dp), + contentDescription = stringResource(R.string.stop), + ) + } + Spacer(Modifier.width(6.dp)) + FilledTonalButton( + onClick = { onIntent(ReadBookIntent.ReadAloudNextParagraph) }, + ) { + Icon( + painter = painterResource(R.drawable.ic_skip_next), + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.next_sentence)) + } + } + + Spacer(Modifier.height(12.dp)) + + TinySliderSettingItem( + title = stringResource(R.string.set_timer), + description = stringResource(R.string.timer_m, timerMinute), + value = timerMinute.toFloat(), + valueRange = 0f..180f, + steps = 179, + onValueChange = { + onIntent(ReadBookIntent.SetReadAloudTtsTimer(it.toInt())) + }, + ) + + Spacer(Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { onIntent(ReadBookIntent.ReadAloudPrevChapter) }, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.previous_chapter)) + } + FilledTonalButton( + onClick = { + onIntent(ReadBookIntent.SetReadAloudTtsTimer(timerMinute)) + }, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.timer_m, timerMinute)) + } + OutlinedButton( + onClick = { onIntent(ReadBookIntent.ReadAloudNextChapter) }, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.next_chapter)) + } + } + + Spacer(Modifier.height(12.dp)) + + TinySwitchSettingItem( + title = stringResource(R.string.flow_sys), + checked = state.readAloudTtsFollowSys, + onCheckedChange = { + onIntent(ReadBookIntent.SetReadAloudTtsFollowSys(it)) + }, + ) + + TinySliderSettingItem( + title = stringResource(R.string.read_aloud_speed), + value = ttsSpeechRate.toFloat(), + valueRange = 0f..80f, + steps = 79, + enabled = !state.readAloudTtsFollowSys, + onValueChange = { + onIntent(ReadBookIntent.SetReadAloudTtsSpeechRate(it.toInt())) + }, + ) + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + ActionButton( + icon = R.drawable.ic_toc, + label = stringResource(R.string.chapter_list), + onClick = onOpenChapterList, + ) + ActionButton( + icon = R.drawable.ic_visibility_off, + label = stringResource(R.string.to_backstage), + onClick = onGoToBackground, + ) + ActionButton( + icon = R.drawable.ic_settings, + label = stringResource(R.string.setting), + onClick = onShowReadAloudConfig, + ) + } + } +} + +@Composable +private fun ActionButton( + icon: Int, + label: String, + onClick: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + FilledTonalIconButton(onClick = onClick) { + Icon( + painter = painterResource(icon), + contentDescription = label, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/ReadStyleSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/ReadStyleSheet.kt new file mode 100644 index 000000000..b9887d411 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/ReadStyleSheet.kt @@ -0,0 +1,161 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.ui.book.read.ConfigUpdate +import io.legado.app.ui.book.read.ReadBookButtonConfigItem +import io.legado.app.ui.book.read.ReadBookIntent +import io.legado.app.ui.book.read.ReadBookStyleConfig +import io.legado.app.ui.widget.components.SectionTitle +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.tabRow.CardTabRow +import kotlinx.coroutines.launch + +@Composable +fun ReadStyleSheet( + onDismissRequest: () -> Unit, + onOpenPaddingConfig: () -> Unit, + onOpenMoreConfig: () -> Unit, + onOpenBgTextConfig: (Int) -> Unit, + onOpenShadowSet: () -> Unit, + onOpenUnderlineConfig: () -> Unit, + onOpenHighlightRule: () -> Unit, + onOpenFontSelect: () -> Unit, + onToggleDayNight: () -> Unit, + readMenuCustomIcons: Map = emptyMap(), + bottomBarButtons: List = emptyList(), + onIntent: (ReadBookIntent) -> Unit, + styleConfig: ReadBookStyleConfig = ReadBookStyleConfig(), +) { + var showTextTitle by remember { mutableStateOf(false) } + + AppModalBottomSheet( + show = true, + onDismissRequest = { + onIntent(ReadBookIntent.SaveReadStyleConfig) + onDismissRequest() + }, + title = stringResource(R.string.read_config), + ) { + ReadStyleContent( + onOpenPaddingConfig = onOpenPaddingConfig, + onOpenMoreConfig = onOpenMoreConfig, + onOpenBgTextConfig = onOpenBgTextConfig, + onOpenTextTitle = { showTextTitle = true }, + onOpenFontSelect = onOpenFontSelect, + onToggleDayNight = onToggleDayNight, + readMenuCustomIcons = readMenuCustomIcons, + bottomBarButtons = bottomBarButtons, + onIntent = onIntent, + styleConfig = styleConfig, + ) + + TextTitlePage( + show = showTextTitle, + onDismissRequest = { showTextTitle = false }, + onOpenShadowSet = onOpenShadowSet, + onOpenUnderlineConfig = onOpenUnderlineConfig, + onOpenHighlightRule = onOpenHighlightRule, + onOpenFontSelect = onOpenFontSelect, + onIntent = onIntent, + ) + } +} + +@Composable +fun ReadStyleContent( + onOpenPaddingConfig: () -> Unit, + onOpenMoreConfig: () -> Unit, + onOpenBgTextConfig: (Int) -> Unit, + onOpenTextTitle: () -> Unit, + onOpenFontSelect: () -> Unit, + onToggleDayNight: () -> Unit, + readMenuCustomIcons: Map = emptyMap(), + bottomBarButtons: List = emptyList(), + modifier: Modifier = Modifier, + onIntent: (ReadBookIntent) -> Unit, + styleConfig: ReadBookStyleConfig = ReadBookStyleConfig(), +) { + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState(pageCount = { 3 }) + var currentPage by remember { mutableIntStateOf(0) } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage }.collect { page -> + currentPage = page + } + } + + Column( + modifier = modifier + .fillMaxWidth(), + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f, fill = false), + ) { page -> + when (page) { + 0 -> GlobalThemePage( + onToggleDayNight = onToggleDayNight, + onOpenBgTextConfig = onOpenBgTextConfig, + onOpenTextTitle = onOpenTextTitle, + onOpenPaddingConfig = onOpenPaddingConfig, + onShareLayoutChange = { shareLayout -> + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShareLayout(shareLayout))) + }, + onStyleSelect = { index -> + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.StyleSelect(index))) + }, + modifier = Modifier.padding(horizontal = 16.dp), + onIntent = onIntent, + styleConfig = styleConfig, + ) + + 1 -> SystemMenuPage( + customIcons = readMenuCustomIcons, + bottomBarButtons = bottomBarButtons, + onIntent = onIntent, + ) + 2 -> HeaderFooterPage( + onOpenFontSelect = onOpenFontSelect, + onIntent = onIntent, + ) + } + } + + val tabTitles = listOf( + stringResource(R.string.read_config_global_theme), + stringResource(R.string.read_config_menu_system), + stringResource(R.string.header_footer), + stringResource(R.string.more_setting), + ) + CardTabRow( + tabTitles = tabTitles, + selectedTabIndex = currentPage, + onTabSelected = { index -> + if (index < 3) { + scope.launch { pagerState.animateScrollToPage(index) } + } else { + onOpenMoreConfig() + } + }, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp), + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/ShadowSetSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/ShadowSetSheet.kt new file mode 100644 index 000000000..1294c71d0 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/ShadowSetSheet.kt @@ -0,0 +1,98 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.legado.app.R +import io.legado.app.help.config.ReadBookConfig +import io.legado.app.ui.book.read.ConfigUpdate +import io.legado.app.ui.book.read.ReadBookIntent +import io.legado.app.ui.widget.components.dialog.ColorPickerSheet +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.settingItem.TinyColorSettingItem +import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem +import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem + +@Composable +fun ShadowSetSheet( + show: Boolean, + onDismissRequest: () -> Unit, + onIntent: (ReadBookIntent) -> Unit, +) { + var textShadow by remember { mutableStateOf(ReadBookConfig.textShadow) } + var shadowColor by remember { mutableIntStateOf(ReadBookConfig.durConfig.curTextShadowColor()) } + var shadowRadius by remember { mutableFloatStateOf(ReadBookConfig.shadowRadius) } + var shadowDx by remember { mutableFloatStateOf(ReadBookConfig.shadowDx) } + var shadowDy by remember { mutableFloatStateOf(ReadBookConfig.shadowDy) } + var showColorPicker by remember { mutableStateOf(false) } + + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.text_shadow_set), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + TinySwitchSettingItem( + title = stringResource(R.string.text_shadow_set), + checked = textShadow, + onCheckedChange = { + textShadow = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextShadow(it))) + }, + ) + TinyColorSettingItem( + title = stringResource(R.string.text_shadow_color), + colorValue = shadowColor, + onClick = { showColorPicker = true }, + ) + TinySliderSettingItem( + title = stringResource(R.string.text_shadow_radius), + value = shadowRadius, + valueRange = 0f..100f, + onValueChange = { value -> + shadowRadius = value + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShadowRadius(value))) + }, + ) + TinySliderSettingItem( + title = stringResource(R.string.text_shadow_x), + value = shadowDx, + valueRange = -50f..50f, + onValueChange = { value -> + shadowDx = value + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShadowDx(value))) + }, + ) + TinySliderSettingItem( + title = stringResource(R.string.text_shadow_y), + value = shadowDy, + valueRange = -50f..50f, + onValueChange = { value -> + shadowDy = value + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShadowDy(value))) + }, + ) + } + } + + if (showColorPicker) { + ColorPickerSheet( + show = true, + initialColor = shadowColor, + onDismissRequest = { showColorPicker = false }, + onColorSelected = { color -> + shadowColor = color + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShadowColor(color))) + showColorPicker = false + }, + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/SimulatedReadingSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/SimulatedReadingSheet.kt new file mode 100644 index 000000000..1d39c9818 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/SimulatedReadingSheet.kt @@ -0,0 +1,153 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.model.ReadBook +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SimulatedReadingSheet( + onDismissRequest: () -> Unit, + onApply: () -> Unit, +) { + val book = ReadBook.book ?: return + var enabled by remember { mutableStateOf(book.getReadSimulating()) } + var startChapter by remember { mutableStateOf(book.getStartChapter().toString()) } + var dailyChapters by remember { mutableStateOf(book.getDailyChapters().toString()) } + var startDate by remember { mutableStateOf(book.getStartDate() ?: LocalDate.now()) } + var showDatePicker by remember { mutableStateOf(false) } + val dateFormatter = remember { DateTimeFormatter.ofPattern("yyyy-MM-dd") } + + AlertDialog( + onDismissRequest = onDismissRequest, + containerColor = LegadoTheme.colorScheme.surfaceContainer, + title = { Text(stringResource(R.string.simulated_reading)) }, + text = { + Column { + OutlinedTextField( + value = startDate.format(dateFormatter), + onValueChange = {}, + label = { Text(stringResource(R.string.start_from)) }, + readOnly = true, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .clickable { showDatePicker = true }, + enabled = false, + ) + Spacer(modifier = Modifier.height(8.dp)) + TinySwitchSettingItem( + title = stringResource(R.string.simulated_reading), + checked = enabled, + onCheckedChange = { enabled = it }, + ) + Spacer(modifier = Modifier.height(12.dp)) + // Start chapter + daily chapters + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = startChapter, + onValueChange = { startChapter = it }, + label = { Text(stringResource(R.string.start_chapter)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + OutlinedTextField( + value = dailyChapters, + onValueChange = { dailyChapters = it }, + label = { Text(stringResource(R.string.daily_chapters)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + book.setStartDate(startDate) + book.setDailyChapters(dailyChapters.toIntOrNull() ?: 0) + book.setStartChapter(startChapter.toIntOrNull() ?: 0) + book.setReadSimulating(enabled) + book.save() + onApply() + onDismissRequest() + }, + ) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.cancel)) + } + }, + ) + + // Date picker dialog + if (showDatePicker) { + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant() + .toEpochMilli(), + ) + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + colors = androidx.compose.material3.DatePickerDefaults.colors( + containerColor = LegadoTheme.colorScheme.surfaceContainer, + ), + confirmButton = { + TextButton( + onClick = { + datePickerState.selectedDateMillis?.let { millis -> + startDate = Instant.ofEpochMilli(millis) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + } + showDatePicker = false + }, + ) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = { showDatePicker = false }) { + Text(stringResource(R.string.cancel)) + } + }, + ) { + DatePicker(state = datePickerState) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/SystemMenuPage.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/SystemMenuPage.kt new file mode 100644 index 000000000..e5fef2b94 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/SystemMenuPage.kt @@ -0,0 +1,720 @@ +package io.legado.app.ui.book.read.sheet + +import android.content.Context +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.Brightness6 +import androidx.compose.material.icons.filled.FindReplace +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.RecordVoiceOver +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material.icons.filled.Translate +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.R +import io.legado.app.constant.ReadMenuBlurMode +import io.legado.app.constant.ReadMenuBlurStyle +import io.legado.app.data.repository.ReadPreferences +import io.legado.app.data.repository.ReadSettingsRepository +import io.legado.app.help.config.ReadBookConfig +import io.legado.app.ui.book.read.ConfigUpdate +import io.legado.app.ui.book.read.ReadBookButtonConfigItem +import io.legado.app.ui.book.read.ReadBookIntent +import io.legado.app.ui.book.read.ReadBookSheet +import io.legado.app.ui.widget.components.SectionTitle +import io.legado.app.ui.widget.components.dialog.ColorPickerSheet +import io.legado.app.ui.widget.components.settingItem.TinyClearColorModeSettingItem +import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem +import io.legado.app.ui.widget.components.settingItem.TinyColorModeSettingItem +import io.legado.app.ui.widget.components.settingItem.TinyDropdownSettingItem +import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem +import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem +import io.legado.app.ui.widget.components.tabRow.CardTabRow +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonObject +import kotlinx.coroutines.launch +import org.koin.compose.koinInject + +private const val COLOR_BG = 5 +private const val COLOR_MENU_ACCENT = 6 +private const val COLOR_MENU_CONTAINER = 7 +private const val COLOR_BG_NIGHT = 8 +private const val COLOR_MENU_ACCENT_NIGHT = 9 +private const val COLOR_MENU_CONTAINER_NIGHT = 10 +private const val COLOR_BORDER = 11 +private const val COLOR_BORDER_NIGHT = 12 + +@Composable +internal fun SystemMenuPage( + customIcons: Map, + bottomBarButtons: List, + modifier: Modifier = Modifier, + onIntent: (ReadBookIntent) -> Unit, +) { + val context = LocalContext.current + val readSettingsRepository: ReadSettingsRepository = koinInject() + val preferences by readSettingsRepository.preferences.collectAsStateWithLifecycle( + initialValue = ReadPreferences() + ) + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState(pageCount = { 3 }) + var selectedTab by remember { mutableIntStateOf(0) } + + // Shared state for sheets + var showColorPicker by remember { mutableStateOf(false) } + var colorPickerId by remember { mutableIntStateOf(0) } + var colorPickerInitial by remember { mutableIntStateOf(0) } + var showIconSheet by remember { mutableStateOf(false) } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage }.collect { selectedTab = it } + } + + Column( + modifier = modifier.fillMaxWidth(), + ) { + val tabTitles = listOf( + stringResource(R.string.read_config_menu_system), + stringResource(R.string.read_menu_bottom_bar_layout), + stringResource(R.string.title_bar_layout), + ) + CardTabRow( + tabTitles = tabTitles, + selectedTabIndex = selectedTab, + onTabSelected = { index -> + selectedTab = index + scope.launch { pagerState.animateScrollToPage(index) } + }, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp), + ) + + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f, fill = false), + ) { page -> + when (page) { + 0 -> GlobalMenuTab( + preferences = preferences, + readBarStyle = preferences.readBarStyle, + readMenuColorMode = preferences.readMenuColorMode, + onIntent = onIntent, + onShowColorPicker = { id, initial -> + colorPickerId = id + colorPickerInitial = initial + showColorPicker = true + }, + ) + 1 -> BottomBarTab( + preferences = preferences, + customIcons = customIcons, + onIntent = onIntent, + onShowIconSheet = { showIconSheet = true }, + onShowColorPicker = { id, initial -> + colorPickerId = id + colorPickerInitial = initial + showColorPicker = true + }, + ) + 2 -> TopBarTab(preferences = preferences, onIntent = onIntent) + } + } + } + + // Floating sheets + if (showColorPicker) { + ColorPickerSheet( + show = true, + initialColor = colorPickerInitial, + onDismissRequest = { showColorPicker = false }, + onColorSelected = { color -> + when (colorPickerId) { + COLOR_BG -> onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.MenuBgColor(color))) + COLOR_MENU_ACCENT -> onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.MenuAccentColor(color))) + COLOR_MENU_CONTAINER -> onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.MenuContainerColor(color))) + COLOR_BG_NIGHT -> onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.MenuBgColorNight(color))) + COLOR_MENU_ACCENT_NIGHT -> onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.MenuAccentColorNight(color))) + COLOR_MENU_CONTAINER_NIGHT -> onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.MenuContainerColorNight(color))) + COLOR_BORDER -> onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BorderColor(color))) + COLOR_BORDER_NIGHT -> onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BorderColorNight(color))) + } + showColorPicker = false + }, + ) + } + + BottomBarIconSheet( + show = showIconSheet, + items = bottomBarButtons, + customIcons = customIcons, + onDismissRequest = { showIconSheet = false }, + onIntent = onIntent, + ) +} + +// ========== Tab 0: Global ========== + +@Composable +private fun GlobalMenuTab( + preferences: ReadPreferences, + readBarStyle: Int, + readMenuColorMode: Int, + onIntent: (ReadBookIntent) -> Unit, + onShowColorPicker: (Int, Int) -> Unit, +) { + var bottomMode by remember(readBarStyle) { mutableIntStateOf(readBarStyle) } + var colorMode by remember(readMenuColorMode) { + mutableIntStateOf(readMenuColorMode.coerceIn(0, 1)) + } + val dayMenuBgColor = preferences.readMenuBgColor + .takeIf { it != 0 } + ?: ReadBookConfig.durConfig.menuBgColor(isNight = false) + val nightMenuBgColor = preferences.readMenuBgColorNight + .takeIf { it != 0 } + ?: ReadBookConfig.durConfig.menuBgColor(isNight = true) + val dayMenuAccentColor = preferences.readMenuAccentColor + .takeIf { it != 0 } + ?: ReadBookConfig.durConfig.menuAccentColor(isNight = false) + val nightMenuAccentColor = preferences.readMenuAccentColorNight + .takeIf { it != 0 } + ?: ReadBookConfig.durConfig.menuAccentColor(isNight = true) + val dayMenuContainerColor = preferences.readMenuContainerColor + .takeIf { it != 0 } + ?: dayMenuBgColor + val nightMenuContainerColor = preferences.readMenuContainerColorNight + .takeIf { it != 0 } + ?: nightMenuBgColor + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + TinyDropdownSettingItem( + title = stringResource(R.string.tool_bar_style), + selectedValue = bottomMode.toString(), + displayEntries = arrayOf( + stringResource(R.string.flow_sys), + stringResource(R.string.follow_read_background), + stringResource(R.string.custom), + ), + entryValues = arrayOf("0", "1", "2"), + onValueChange = { + bottomMode = it.toInt() + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ReadBarStyle(bottomMode))) + }, + ) + + AnimatedVisibility(visible = bottomMode == 2) { + Column { + TinyDropdownSettingItem( + title = stringResource(R.string.read_menu_color_source), + selectedValue = colorMode.toString(), + displayEntries = arrayOf( + stringResource(R.string.seed_color), + stringResource(R.string.custom_theme_colors), + ), + entryValues = arrayOf("0", "1"), + onValueChange = { + colorMode = it.toInt() + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.MenuColorMode(colorMode))) + }, + ) + + AnimatedVisibility(visible = colorMode == 0) { + TinyColorModeSettingItem( + title = stringResource(R.string.seed_color), + description = stringResource(R.string.seed_color_summary), + dayColor = dayMenuAccentColor, + nightColor = nightMenuAccentColor, + enabled = true, + onClickColor = { isNight -> + if (isNight) { + onShowColorPicker(COLOR_MENU_ACCENT_NIGHT, nightMenuAccentColor) + } else { + onShowColorPicker(COLOR_MENU_ACCENT, dayMenuAccentColor) + } + }, + ) + } + + AnimatedVisibility(visible = colorMode == 1) { + Column { + TinyColorModeSettingItem( + title = stringResource(R.string.background_color), + description = stringResource(R.string.read_menu_bg_color_summary), + dayColor = dayMenuBgColor, + nightColor = nightMenuBgColor, + enabled = true, + onClickColor = { isNight -> + if (isNight) { + onShowColorPicker(COLOR_BG_NIGHT, nightMenuBgColor) + } else { + onShowColorPicker(COLOR_BG, dayMenuBgColor) + } + }, + ) + TinyColorModeSettingItem( + title = stringResource(R.string.container_background_color), + description = stringResource(R.string.read_menu_container_color_summary), + dayColor = dayMenuContainerColor, + nightColor = nightMenuContainerColor, + enabled = true, + onClickColor = { isNight -> + if (isNight) { + onShowColorPicker(COLOR_MENU_CONTAINER_NIGHT, nightMenuContainerColor) + } else { + onShowColorPicker(COLOR_MENU_CONTAINER, dayMenuContainerColor) + } + }, + ) + TinyColorModeSettingItem( + title = stringResource(R.string.accent), + description = stringResource(R.string.read_menu_accent_color_summary), + dayColor = dayMenuAccentColor, + nightColor = nightMenuAccentColor, + enabled = true, + onClickColor = { isNight -> + if (isNight) { + onShowColorPicker(COLOR_MENU_ACCENT_NIGHT, nightMenuAccentColor) + } else { + onShowColorPicker(COLOR_MENU_ACCENT, dayMenuAccentColor) + } + }, + ) + } + } + } + } + + var iconPosition by remember { mutableIntStateOf(preferences.titleBarIconPosition) } + TinyDropdownSettingItem( + title = stringResource(R.string.title_bar_icon_position), + selectedValue = iconPosition.toString(), + displayEntries = arrayOf( + stringResource(R.string.position_top_start), + stringResource(R.string.position_top_end), + stringResource(R.string.position_bottom_start), + stringResource(R.string.position_bottom_end), + ), + entryValues = arrayOf("0", "1", "2", "3"), + onValueChange = { + iconPosition = it.toInt() + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleBarIconPosition(iconPosition))) + }, + ) + TinySwitchSettingItem( + title = stringResource(R.string.show_title_bar_icons), + checked = preferences.showTitleBarIcons, + onCheckedChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShowTitleBarIcons(it))) + }, + ) + + Spacer(Modifier.height(8.dp)) + + var borderEnabled by remember { + mutableStateOf(preferences.readMenuBorderWidth > 0) + } + TinySwitchSettingItem( + title = stringResource(R.string.read_menu_border), + checked = borderEnabled, + onCheckedChange = { + borderEnabled = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BorderWidth(if (it) 1 else 0))) + }, + ) + AnimatedVisibility(visible = borderEnabled) { + Column { + TinySliderSettingItem( + title = stringResource(R.string.read_menu_border_width), + value = preferences.readMenuBorderWidth.coerceIn(1, 4).toFloat(), + valueRange = 1f..4f, + onValueChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BorderWidth(it.toInt()))) + }, + ) + TinyClearColorModeSettingItem( + title = stringResource(R.string.read_menu_border_color), + dayColor = preferences.readMenuBorderColor, + nightColor = preferences.readMenuBorderColorNight, + onClearColor = { isNight -> + if (isNight) { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BorderColorNight(0))) + } else { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.BorderColor(0))) + } + }, + onClickColor = { isNight -> + if (isNight) { + onShowColorPicker(COLOR_BORDER_NIGHT, preferences.readMenuBorderColorNight) + } else { + onShowColorPicker(COLOR_BORDER, preferences.readMenuBorderColor) + } + }, + ) + } + } + + Spacer(Modifier.height(8.dp)) + + TinySliderSettingItem( + title = stringResource(R.string.read_menu_blur_radius), + value = preferences.readMenuBlurRadius.toFloat(), + valueRange = 0f..32f, + steps = 31, + description = stringResource(R.string.read_menu_blur_radius_summary), + onValueChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.MenuBlurRadius(it.toInt()))) + }, + ) + TinySliderSettingItem( + title = stringResource(R.string.read_menu_blur_alpha), + value = preferences.readMenuBlurAlpha.toFloat(), + valueRange = 0f..100f, + steps = 99, + onValueChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.MenuBlurAlpha(it.toInt()))) + }, + ) + } +} + +// ========== Tab 1: Bottom Bar ========== + +@Composable +private fun BottomBarTab( + preferences: ReadPreferences, + customIcons: Map, + onIntent: (ReadBookIntent) -> Unit, + onShowIconSheet: () -> Unit, + onShowColorPicker: (Int, Int) -> Unit, +) { + var showIconText by remember { mutableStateOf(preferences.readMenuIconShowText) } + var iconStyle by remember { mutableIntStateOf(preferences.readMenuIconStyle) } + var iconsPerRow by remember { mutableIntStateOf(preferences.readMenuIconItemsPerRow) } + var iconRowCount by remember { mutableIntStateOf(preferences.readMenuIconRowCount) } + var bottomCornerRadius by remember { mutableIntStateOf(preferences.readMenuBottomCornerRadius) } + val floatingBottomBar = preferences.readMenuFloatingBottomBar + val bottomBarBlurMode = if ( + !floatingBottomBar && + preferences.readMenuBottomBarBlurMode == ReadMenuBlurMode.LiquidGlass + ) { + ReadMenuBlurMode.Haze + } else { + preferences.readMenuBottomBarBlurMode + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + SectionTitle(stringResource(R.string.read_menu_icon_style)) + + TinySwitchSettingItem( + title = stringResource(R.string.read_menu_show_icon_text), + checked = showIconText, + onCheckedChange = { + showIconText = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.MenuIconShowText(it))) + }, + ) + TinyDropdownSettingItem( + title = stringResource(R.string.read_menu_icon_container_style), + selectedValue = iconStyle.toString(), + displayEntries = arrayOf( + stringResource(R.string.read_menu_icon_style_plain), + stringResource(R.string.read_menu_icon_style_tonal), + stringResource(R.string.read_menu_icon_style_outlined), + ), + entryValues = arrayOf("0", "1", "2"), + onValueChange = { + iconStyle = it.toInt() + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.MenuIconStyle(iconStyle))) + }, + ) + TinySliderSettingItem( + title = stringResource(R.string.read_menu_icons_per_row), + value = iconsPerRow.toFloat(), + valueRange = 2f..8f, + steps = 5, + onValueChange = { + iconsPerRow = it.toInt() + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.MenuIconItemsPerRow(iconsPerRow))) + }, + ) + TinySliderSettingItem( + title = stringResource(R.string.read_menu_icon_row_count), + value = iconRowCount.toFloat(), + valueRange = 1f..2f, + steps = 0, + onValueChange = { + iconRowCount = it.toInt() + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.MenuIconRowCount(iconRowCount))) + }, + ) + TinyClickableSettingItem( + title = stringResource(R.string.config_btn), + description = if (customIcons.isEmpty()) { + stringResource(R.string.read_menu_custom_icons_none) + } else { + stringResource(R.string.read_menu_custom_icons_count, customIcons.size) + }, + onClick = onShowIconSheet, + ) + + SectionTitle(stringResource(R.string.read_menu_bottom_bar_layout)) + + TinySliderSettingItem( + title = stringResource(R.string.read_menu_bottom_corner_radius), + value = bottomCornerRadius.toFloat(), + valueRange = 0f..32f, + steps = 31, + onValueChange = { + bottomCornerRadius = it.toInt() + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.MenuBottomCornerRadius(bottomCornerRadius))) + }, + ) + TinySwitchSettingItem( + title = stringResource(R.string.read_menu_floating_bottom_bar), + checked = floatingBottomBar, + onCheckedChange = { + if (!it && preferences.readMenuBottomBarBlurMode == ReadMenuBlurMode.LiquidGlass) { + onIntent( + ReadBookIntent.UpdateConfig( + ConfigUpdate.MenuBottomBarBlurMode( + ReadMenuBlurMode.Haze + ) + ) + ) + } + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FloatingBottomBar(it))) + }, + ) + TinySwitchSettingItem( + title = stringResource(R.string.read_menu_bar_blur), + checked = bottomBarBlurMode != ReadMenuBlurMode.None, + onCheckedChange = { + onIntent( + ReadBookIntent.UpdateConfig( + ConfigUpdate.MenuBottomBarBlurMode( + if (it) ReadMenuBlurMode.Haze else ReadMenuBlurMode.None + ) + ) + ) + }, + ) + AnimatedVisibility(visible = !floatingBottomBar && bottomBarBlurMode == ReadMenuBlurMode.Haze) { + TinyDropdownSettingItem( + title = stringResource(R.string.read_menu_bar_blur_style), + selectedValue = preferences.readMenuBottomBarBlurStyle.toString(), + displayEntries = arrayOf( + stringResource(R.string.read_menu_blur_style_solid), + stringResource(R.string.read_menu_blur_style_progressive), + ), + entryValues = arrayOf( + ReadMenuBlurStyle.Solid.toString(), + ReadMenuBlurStyle.Progressive.toString(), + ), + onValueChange = { + onIntent( + ReadBookIntent.UpdateConfig( + ConfigUpdate.MenuBottomBarBlurStyle(it.toInt()) + ) + ) + }, + ) + } + AnimatedVisibility(visible = floatingBottomBar) { + TinySwitchSettingItem( + title = stringResource(R.string.read_menu_bar_liquid_glass), + description = stringResource(R.string.read_menu_bar_liquid_glass_summary), + checked = preferences.readMenuBottomBarBlurMode == ReadMenuBlurMode.LiquidGlass, + onCheckedChange = { + onIntent( + ReadBookIntent.UpdateConfig( + ConfigUpdate.MenuBottomBarBlurMode( + if (it) { + ReadMenuBlurMode.LiquidGlass + } else if (bottomBarBlurMode != ReadMenuBlurMode.None) { + ReadMenuBlurMode.Haze + } else { + ReadMenuBlurMode.None + } + ) + ) + ) + }, + ) + } + TinySwitchSettingItem( + title = stringResource(R.string.read_menu_bar_liquid_glass_buttons), + description = stringResource(R.string.read_menu_bottom_bar_liquid_glass_buttons_summary), + checked = preferences.readMenuBottomBarLiquidGlassButtons, + onCheckedChange = { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.MenuBottomBarLiquidGlassButtons(it))) + }, + ) + } +} + +// ========== Tab 2: Top Bar ========== + +@Composable +private fun TopBarTab( + preferences: ReadPreferences, + onIntent: (ReadBookIntent) -> Unit, +) { + val customIconCount = remember(preferences.titleBarCustomIcons) { + countCustomIcons(preferences.titleBarCustomIcons) + } + val topBarBlurEnabled = preferences.readMenuTopBarBlurMode == ReadMenuBlurMode.Haze + + val titleBarModeEntries = stringArrayResource(R.array.title_bar_mode) + val titleBarModeValues = stringArrayResource(R.array.title_bar_mode_value) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + TinyDropdownSettingItem( + title = stringResource(R.string.title_bar_mode), + selectedValue = preferences.titleBarMode, + displayEntries = titleBarModeEntries, + entryValues = titleBarModeValues, + onValueChange = { value -> + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleBarMode(value))) + }, + ) + TinyClickableSettingItem( + title = stringResource(R.string.title_bar_icons), + description = if (customIconCount == 0) { + stringResource(R.string.read_menu_custom_icons_none) + } else { + stringResource(R.string.read_menu_custom_icons_count, customIconCount) + }, + onClick = { + onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.TitleBarIconConfig)) + }, + ) + Spacer(Modifier.height(8.dp)) + TinySwitchSettingItem( + title = stringResource(R.string.read_menu_bar_blur), + checked = topBarBlurEnabled, + onCheckedChange = { + onIntent( + ReadBookIntent.UpdateConfig( + ConfigUpdate.MenuTopBarBlurSelection( + mode = if (it) ReadMenuBlurMode.Haze else ReadMenuBlurMode.None, + style = preferences.readMenuTopBarBlurStyle, + ) + ) + ) + }, + ) + AnimatedVisibility(visible = topBarBlurEnabled) { + TinyDropdownSettingItem( + title = stringResource(R.string.read_menu_bar_blur_style), + selectedValue = preferences.readMenuTopBarBlurStyle.toString(), + displayEntries = arrayOf( + stringResource(R.string.read_menu_blur_style_solid), + stringResource(R.string.read_menu_blur_style_progressive), + ), + entryValues = arrayOf( + ReadMenuBlurStyle.Solid.toString(), + ReadMenuBlurStyle.Progressive.toString(), + ), + onValueChange = { + onIntent( + ReadBookIntent.UpdateConfig( + ConfigUpdate.MenuTopBarBlurSelection( + mode = ReadMenuBlurMode.Haze, + style = it.toInt(), + ) + ) + ) + }, + ) + } + AnimatedVisibility(visible = topBarBlurEnabled) { + TinySwitchSettingItem( + title = stringResource(R.string.read_menu_bar_liquid_glass_buttons), + description = stringResource(R.string.read_menu_top_bar_liquid_glass_buttons_summary), + checked = preferences.readMenuTopBarLiquidGlassButtons, + onCheckedChange = { + onIntent( + ReadBookIntent.UpdateConfig( + ConfigUpdate.MenuTopBarLiquidGlassButtons( + it + ) + ) + ) + }, + ) + } + } +} + +private fun countCustomIcons(value: String): Int { + if (value.isBlank()) return 0 + return GSON.fromJsonObject>(value) + .getOrNull() + ?.count { it.value.isNotBlank() } + ?: 0 +} + +// ========== Helpers ========== + +internal data class ReadMenuButtonInfo( + val id: String, + val icon: ImageVector, + val label: String, +) + +internal fun readMenuButtonInfos(context: Context): List = listOf( + ReadMenuButtonInfo("search", Icons.Default.Search, context.getString(R.string.search_content)), + ReadMenuButtonInfo("auto_page", Icons.Default.PlayArrow, context.getString(R.string.auto_next_page)), + ReadMenuButtonInfo("catalog", Icons.AutoMirrored.Filled.List, context.getString(R.string.chapter_list)), + ReadMenuButtonInfo("read_aloud", Icons.Default.RecordVoiceOver, context.getString(R.string.read_aloud)), + ReadMenuButtonInfo("setting", Icons.Default.Settings, context.getString(R.string.setting)), + ReadMenuButtonInfo("addBookmark", Icons.Default.Bookmark, context.getString(R.string.bookmark)), + ReadMenuButtonInfo("theme", Icons.Default.Brightness6, context.getString(R.string.day_night_switch)), + ReadMenuButtonInfo("prev_chapter", Icons.Default.SkipPrevious, context.getString(R.string.previous_chapter)), + ReadMenuButtonInfo("next_chapter", Icons.Default.SkipNext, context.getString(R.string.next_chapter)), + ReadMenuButtonInfo("replace", Icons.Default.FindReplace, context.getString(R.string.replace_purify)), + ReadMenuButtonInfo("replace_badge", Icons.Default.AutoAwesome, context.getString(R.string.replace_purify_badge)), + ReadMenuButtonInfo("translate", Icons.Default.Translate, context.getString(R.string.translate)), +) diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/TextTitleSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/TextTitleSheet.kt new file mode 100644 index 000000000..8de1a5337 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/TextTitleSheet.kt @@ -0,0 +1,532 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FormatBold +import androidx.compose.material.icons.filled.FormatItalic +import androidx.compose.material.icons.filled.FormatUnderlined +import androidx.compose.material.icons.filled.Layers +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.material.icons.filled.TextFormat +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.ui.book.read.ConfigUpdate +import io.legado.app.ui.book.read.ReadBookIntent +import io.legado.app.help.config.ReadBookConfig +import io.legado.app.ui.widget.components.dialog.ColorPickerSheet +import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem +import io.legado.app.ui.widget.components.settingItem.TinyColorSettingItem +import io.legado.app.ui.widget.components.settingItem.TinyDropdownSettingItem +import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem +import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem +import io.legado.app.ui.widget.components.tabRow.CardTabRow +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import kotlinx.coroutines.launch + +// Color picker IDs +private const val COLOR_TEXT = 1 +private const val COLOR_ACCENT = 2 +private const val COLOR_TITLE = 4 + +// ========== Text & Title Sheet ========== + +@Composable +internal fun TextTitlePage( + show: Boolean, + onDismissRequest: () -> Unit, + onOpenShadowSet: () -> Unit, + onOpenUnderlineConfig: () -> Unit, + onOpenHighlightRule: () -> Unit, + onOpenFontSelect: () -> Unit, + onIntent: (ReadBookIntent) -> Unit, +) { + val scope = rememberCoroutineScope() + val tabTitles = listOf( + stringResource(R.string.read_config_text_effects), + stringResource(R.string.read_config_layout_spacing), + stringResource(R.string.read_config_title_settings), + ) + val pagerState = rememberPagerState(pageCount = { 3 }) + var selectedTab by remember { mutableIntStateOf(0) } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage }.collect { selectedTab = it } + } + + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.read_config_text_effects), + ) { + ReadStyleTextTitleContent( + tabTitles = tabTitles, + selectedTab = selectedTab, + onSelectedTabChange = { selectedTab = it }, + pagerState = pagerState, + onOpenShadowSet = onOpenShadowSet, + onOpenUnderlineConfig = onOpenUnderlineConfig, + onOpenHighlightRule = onOpenHighlightRule, + onOpenFontSelect = onOpenFontSelect, + animateToPage = { page -> scope.launch { pagerState.animateScrollToPage(page) } }, + onIntent = onIntent, + ) + } +} + +@Composable +fun ReadStyleTextTitleContent( + onOpenShadowSet: () -> Unit, + onOpenUnderlineConfig: () -> Unit, + onOpenHighlightRule: () -> Unit, + onOpenFontSelect: () -> Unit, + modifier: Modifier = Modifier, + onIntent: (ReadBookIntent) -> Unit, +) { + val scope = rememberCoroutineScope() + val tabTitles = listOf( + stringResource(R.string.read_config_text_effects), + stringResource(R.string.read_config_layout_spacing), + stringResource(R.string.read_config_title_settings), + ) + val pagerState = rememberPagerState(pageCount = { 3 }) + var selectedTab by remember { mutableIntStateOf(0) } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage }.collect { selectedTab = it } + } + + ReadStyleTextTitleContent( + tabTitles = tabTitles, + selectedTab = selectedTab, + onSelectedTabChange = { selectedTab = it }, + pagerState = pagerState, + onOpenShadowSet = onOpenShadowSet, + onOpenUnderlineConfig = onOpenUnderlineConfig, + onOpenHighlightRule = onOpenHighlightRule, + onOpenFontSelect = onOpenFontSelect, + animateToPage = { page -> scope.launch { pagerState.animateScrollToPage(page) } }, + modifier = modifier, + onIntent = onIntent, + ) +} + +@Composable +internal fun ReadStyleTextTitleContent( + tabTitles: List, + selectedTab: Int, + onSelectedTabChange: (Int) -> Unit, + pagerState: PagerState, + onOpenShadowSet: () -> Unit, + onOpenUnderlineConfig: () -> Unit, + onOpenHighlightRule: () -> Unit, + onOpenFontSelect: () -> Unit, + animateToPage: (Int) -> Unit, + modifier: Modifier = Modifier, + onIntent: (ReadBookIntent) -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + ) { + CardTabRow( + tabTitles = tabTitles, + selectedTabIndex = selectedTab, + onTabSelected = { index -> + onSelectedTabChange(index) + animateToPage(index) + }, + modifier = Modifier.padding(bottom = 8.dp), + ) + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + ) { page -> + when (page) { + 0 -> TextEffectsPage( + onOpenShadowSet = onOpenShadowSet, + onOpenUnderlineConfig = onOpenUnderlineConfig, + onOpenHighlightRule = onOpenHighlightRule, + onOpenFontSelect = onOpenFontSelect, + onIntent = onIntent, + ) + + 1 -> LayoutSpacingPage(onIntent = onIntent) + 2 -> TitleSettingsPage(onIntent = onIntent) + } + } + } +} + +// ========== Tab: Layout & Spacing ========== + +@Composable +internal fun LayoutSpacingPage( + onIntent: (ReadBookIntent) -> Unit, +) { + var letterSpacing by remember { mutableFloatStateOf(ReadBookConfig.letterSpacing) } + var lineSpacing by remember { mutableFloatStateOf(ReadBookConfig.lineSpacingExtra.toFloat()) } + var paragraphSpacing by remember { mutableFloatStateOf(ReadBookConfig.paragraphSpacing.toFloat()) } + var indentCount by remember { mutableIntStateOf(ReadBookConfig.paragraphIndent.length) } + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource(R.string.read_config_body_spacing), + style = MaterialTheme.typography.titleSmallEmphasized, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + textAlign = TextAlign.Center, + ) + + TinySliderSettingItem( + title = stringResource(R.string.text_indent), + value = indentCount.toFloat(), + valueRange = 0f..4f, + steps = 3, + onValueChange = { value -> + indentCount = value.toInt() + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ParagraphIndent(" ".repeat(indentCount)))) + }, + ) + TinySliderSettingItem( + title = stringResource(R.string.text_letter_spacing), + value = (letterSpacing * 100) + 50, + valueRange = 0f..100f, + onValueChange = { value -> + letterSpacing = (value - 50) / 100f + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.LetterSpacing(letterSpacing))) + }, + ) + TinySliderSettingItem( + title = stringResource(R.string.line_size), + value = lineSpacing, + valueRange = 0f..20f, + onValueChange = { value -> + lineSpacing = value + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.LineSpacing(value.toInt()))) + }, + ) + TinySliderSettingItem( + title = stringResource(R.string.paragraph_size), + value = paragraphSpacing, + valueRange = 0f..20f, + onValueChange = { value -> + paragraphSpacing = value + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ParagraphSpacing(value.toInt()))) + }, + ) + } +} + +// ========== Text & Effects (sub-page) ========== + +@Composable +internal fun TextEffectsPage( + onOpenShadowSet: () -> Unit, + onOpenUnderlineConfig: () -> Unit, + onOpenHighlightRule: () -> Unit, + onOpenFontSelect: () -> Unit, + onIntent: (ReadBookIntent) -> Unit, +) { + var textItalic by remember { mutableStateOf(ReadBookConfig.textItalic) } + var textBold by remember { mutableIntStateOf(ReadBookConfig.textBold) } + + var showColorPicker by remember { mutableStateOf(false) } + var colorPickerId by remember { mutableIntStateOf(0) } + var colorPickerInitial by remember { mutableIntStateOf(0) } + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource(R.string.text_typeface), + style = MaterialTheme.typography.titleSmallEmphasized, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + textAlign = TextAlign.Center, + ) + TinySwitchSettingItem( + title = stringResource(R.string.read_config_italic), + checked = textItalic, + imageVector = Icons.Default.FormatItalic, + onCheckedChange = { + textItalic = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextItalic(it))) + }, + ) + TinySliderSettingItem( + title = stringResource(R.string.font_weight_text), + value = textBold.coerceAtLeast(100).toFloat(), + valueRange = 100f..900f, + imageVector = Icons.Default.FormatBold, + onValueChange = { value -> + textBold = value.toInt() + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextBold(value.toInt()))) + }, + ) + + TinyClickableSettingItem( + title = stringResource(R.string.select_font), + imageVector = Icons.Default.TextFields, + onClick = onOpenFontSelect, + ) + Spacer(Modifier.height(8.dp)) + + // Colors + Text( + text = stringResource(R.string.read_color), + style = MaterialTheme.typography.titleSmallEmphasized, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(4.dp)) + TinyColorSettingItem( + title = stringResource(R.string.text_color), + colorValue = ReadBookConfig.durConfig.curTextColor(), + onClick = { + colorPickerId = COLOR_TEXT + colorPickerInitial = ReadBookConfig.durConfig.curTextColor() + showColorPicker = true + }, + ) + TinyColorSettingItem( + title = stringResource(R.string.text_accent_color), + colorValue = ReadBookConfig.durConfig.curTextAccentColor(), + onClick = { + colorPickerId = COLOR_ACCENT + colorPickerInitial = ReadBookConfig.durConfig.curTextAccentColor() + showColorPicker = true + }, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.read_config_effects), + style = MaterialTheme.typography.titleSmallEmphasized, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + textAlign = TextAlign.Center, + ) + TinyClickableSettingItem( + title = stringResource(R.string.text_shadow_set), + description = stringResource(R.string.read_config_shadow_desc), + imageVector = Icons.Default.Layers, + onClick = onOpenShadowSet, + ) + TinyClickableSettingItem( + title = stringResource(R.string.text_underline), + description = stringResource(R.string.read_config_underline_desc), + imageVector = Icons.Default.FormatUnderlined, + onClick = onOpenUnderlineConfig, + ) + TinyClickableSettingItem( + title = stringResource(R.string.highlight_rule_config), + description = stringResource(R.string.read_config_regex_desc), + imageVector = Icons.Default.Tune, + onClick = onOpenHighlightRule, + ) + + Spacer(Modifier.height(8.dp)) + } + + // Color picker + if (showColorPicker) { + ColorPickerSheet( + show = true, + initialColor = colorPickerInitial, + onDismissRequest = { showColorPicker = false }, + onColorSelected = { color -> + when (colorPickerId) { + COLOR_TEXT -> { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextColor(color))) + } + + COLOR_ACCENT -> { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextAccentColor(color))) + } + } + showColorPicker = false + }, + ) + } +} + +// ========== Title Settings (sub-page) ========== + +@Composable +internal fun TitleSettingsPage( + onIntent: (ReadBookIntent) -> Unit, +) { + var titleMode by remember { mutableIntStateOf(ReadBookConfig.titleMode) } + var titleBold by remember { mutableIntStateOf(ReadBookConfig.titleBold) } + + var showColorPicker by remember { mutableStateOf(false) } + var colorPickerId by remember { mutableIntStateOf(0) } + var colorPickerInitial by remember { mutableIntStateOf(0) } + + val weightIconMap = mapOf( + 0 to Icons.Default.TextFields, + 1 to Icons.Default.TextFormat, + 2 to Icons.Default.FormatBold, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + TinyDropdownSettingItem( + title = stringResource(R.string.body_title), + selectedValue = titleMode.toString(), + displayEntries = arrayOf( + stringResource(R.string.title_left), + stringResource(R.string.title_center), + stringResource(R.string.title_hide), + ), + entryValues = arrayOf("0", "1", "2"), + onValueChange = { + titleMode = it.toInt() + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleMode(titleMode))) + }, + ) + + Spacer(Modifier.height(8.dp)) + + TinySliderSettingItem( + title = stringResource(R.string.font_weight_text), + value = titleBold.coerceAtLeast(100).toFloat(), + valueRange = 100f..900f, + imageVector = weightIconMap[titleBold] ?: Icons.Default.FormatBold, + onValueChange = { value -> + titleBold = value.toInt() + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleBold(value.toInt()))) + }, + ) + + TinyColorSettingItem( + title = stringResource(R.string.title_color), + colorValue = if (ReadBookConfig.titleColor != 0) { + ReadBookConfig.titleColor or 0xFF000000.toInt() + } else { + ReadBookConfig.textColor or 0xFF000000.toInt() + }, + onClick = { + colorPickerId = COLOR_TITLE + colorPickerInitial = if (ReadBookConfig.titleColor != 0) { + ReadBookConfig.titleColor or 0xFF000000.toInt() + } else { + ReadBookConfig.textColor or 0xFF000000.toInt() + } + showColorPicker = true + }, + ) + + Spacer(Modifier.height(8.dp)) + + // Title spacing sliders + TinySliderSettingItem( + title = stringResource(R.string.subtitle_scale), + value = ReadBookConfig.titleSegScaling * 10, + valueRange = 0f..100f, + onValueChange = { value -> + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleSegScaling(value / 10f))) + }, + ) + TinySliderSettingItem( + title = stringResource(R.string.heading_spacing), + value = ReadBookConfig.titleLineSpacingExtra.toFloat(), + valueRange = 0f..100f, + onValueChange = { value -> + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleLineSpacingExtra(value.toInt()))) + }, + ) + TinySliderSettingItem( + title = stringResource(R.string.subtitle_margin), + value = ReadBookConfig.titleLineSpacingSub.toFloat(), + valueRange = 0f..100f, + onValueChange = { value -> + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleLineSpacingSub(value.toInt()))) + }, + ) + TinySliderSettingItem( + title = stringResource(R.string.title_font_size), + value = ReadBookConfig.titleSize.toFloat(), + valueRange = 0f..100f, + onValueChange = { value -> + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleSize(value.toInt()))) + }, + ) + TinySliderSettingItem( + title = stringResource(R.string.title_margin_top), + value = ReadBookConfig.titleTopSpacing.toFloat(), + valueRange = 0f..100f, + onValueChange = { value -> + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleTopSpacing(value.toInt()))) + }, + ) + TinySliderSettingItem( + title = stringResource(R.string.title_margin_bottom), + value = ReadBookConfig.titleBottomSpacing.toFloat(), + valueRange = 0f..100f, + onValueChange = { value -> + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleBottomSpacing(value.toInt()))) + }, + ) + + } + + // Color picker + if (showColorPicker) { + ColorPickerSheet( + show = true, + initialColor = colorPickerInitial, + onDismissRequest = { showColorPicker = false }, + onColorSelected = { color -> + when (colorPickerId) { + COLOR_TITLE -> { + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleColor(color))) + } + } + showColorPicker = false + }, + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/TitleBarIconSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/TitleBarIconSheet.kt new file mode 100644 index 000000000..3311c81e6 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/TitleBarIconSheet.kt @@ -0,0 +1,318 @@ +package io.legado.app.ui.book.read.sheet + +import android.content.Context +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import io.legado.app.R +import io.legado.app.ui.book.read.ConfigUpdate +import io.legado.app.ui.book.read.ReadBookButtonConfigItem +import io.legado.app.ui.book.read.ReadBookIntent +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow +import io.legado.app.ui.widget.components.button.series.SmallTonalButton +import io.legado.app.ui.widget.components.card.NormalCard +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + +@Composable +fun TitleBarIconSheet( + show: Boolean, + items: List, + customIcons: Map, + onDismissRequest: () -> Unit, + onIntent: (ReadBookIntent) -> Unit, +) { + ButtonIconConfigSheet( + show = show, + title = stringResource(R.string.title_bar_icons), + items = items, + customIcons = customIcons, + onDismissRequest = onDismissRequest, + onSaved = { onIntent(ReadBookIntent.SaveTitleBarButtonConfig(it)) }, + onSelectIcon = { id -> onIntent(ReadBookIntent.OpenTitleBarCustomIconPicker(id)) }, + onClearIcon = { id -> + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TitleBarCustomIcon(id, ""))) + }, + ) +} + +@Composable +internal fun BottomBarIconSheet( + show: Boolean, + items: List, + customIcons: Map, + onDismissRequest: () -> Unit, + onIntent: (ReadBookIntent) -> Unit, +) { + ButtonIconConfigSheet( + show = show, + title = stringResource(R.string.config_btn), + items = items, + customIcons = customIcons, + onDismissRequest = onDismissRequest, + onSaved = { onIntent(ReadBookIntent.SaveMenuButtonConfig(it)) }, + onSelectIcon = { id -> onIntent(ReadBookIntent.OpenMenuCustomIconPicker(id)) }, + onClearIcon = { id -> + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.MenuCustomIcon(id, ""))) + }, + ) +} + +@Composable +private fun ButtonIconConfigSheet( + show: Boolean, + title: String, + items: List, + customIcons: Map, + onDismissRequest: () -> Unit, + onSaved: (List) -> Unit, + onSelectIcon: (String) -> Unit, + onClearIcon: (String) -> Unit, +) { + val context = LocalContext.current + var draftItems by remember(items) { + mutableStateOf(buildButtonIconEntries(items, context)) + } + + val lazyListState = rememberLazyListState() + val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to -> + draftItems = draftItems.toMutableList().apply { + add(to.index, removeAt(from.index)) + } + } + + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = title, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + LazyColumn( + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f, fill = false), + ) { + items(draftItems, key = { it.id }) { item -> + ReorderableItem(reorderableState, key = item.id) { isDragging -> + val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp) + NormalCard( + elevation = elevation, + cornerRadius = 12.dp, + containerColor = LegadoTheme.colorScheme.surfaceContainerLow + ) { + TitleBarIconItem( + item = item, + customIcon = customIcons[item.id], + onToggleEnabled = { + draftItems = draftItems.toggleButtonEnabled(item.id) + }, + onSelectIcon = { onSelectIcon(item.id) }, + onClearIcon = { onClearIcon(item.id) }, + dragHandleModifier = Modifier.draggableHandle(), + ) + } + } + } + } + + ConfirmDismissButtonsRow( + onDismiss = onDismissRequest, + onConfirm = { + onSaved(draftItems.map { ReadBookButtonConfigItem(it.id, it.enabled) }) + onDismissRequest() + }, + dismissText = stringResource(R.string.cancel), + confirmText = stringResource(R.string.action_save), + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} + +@Composable +private fun TitleBarIconItem( + item: ButtonIconEntry, + customIcon: String?, + onToggleEnabled: () -> Unit, + onSelectIcon: () -> Unit, + onClearIcon: () -> Unit, + dragHandleModifier: Modifier = Modifier, +) { + val icon = item.icon + val name = item.label + val alpha = if (item.enabled) 1f else 0.38f + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(all = 12.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(36.dp), + ) { + if (!customIcon.isNullOrBlank()) { + AsyncImage( + model = customIcon, + contentDescription = name, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize(), + alpha = alpha, + ) + } else { + Icon( + imageVector = icon, + contentDescription = name, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha), + modifier = Modifier.size(24.dp), + ) + } + } + + Text( + text = name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp), + ) + + if (item.enabled) { + // Custom icon button + if (!customIcon.isNullOrBlank()) { + IconButton( + onClick = onClearIcon, + modifier = Modifier.size(36.dp), + ) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.delete), + modifier = Modifier.size(18.dp), + ) + } + } else { + SmallTonalButton( + onClick = onSelectIcon, + icon = Icons.Default.Add, + modifier = Modifier.size(36.dp), + ) + } + } + + // Visibility toggle + IconButton( + onClick = onToggleEnabled, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = if (item.enabled) { + Icons.Default.Visibility + } else { + Icons.Default.VisibilityOff + }, + contentDescription = null, + tint = if (item.enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(20.dp), + ) + } + + // Drag handle + IconButton( + modifier = dragHandleModifier.size(36.dp), + onClick = {}, + ) { + Icon( + Icons.Default.Menu, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + } + } +} + +private data class ButtonIconEntry( + val id: String, + val enabled: Boolean, + val icon: ImageVector, + val label: String, +) + +private fun List.toggleButtonEnabled(id: String): List { + val index = indexOfFirst { it.id == id } + if (index < 0) return this + + val target = this[index] + val toggled = target.copy(enabled = !target.enabled) + val remaining = toMutableList().apply { removeAt(index) } + val insertIndex = if (toggled.enabled) { + remaining.indexOfLast { it.enabled } + 1 + } else { + remaining.indexOfFirst { !it.enabled } + .takeIf { it >= 0 } + ?: remaining.size + } + + return remaining.apply { + add(insertIndex.coerceIn(0, size), toggled) + } +} + +private fun buildButtonIconEntries( + items: List, + context: Context, +): List { + val infoMap = readMenuButtonInfos(context).associateBy { it.id } + return items.mapNotNull { item -> + val id = item.id + infoMap[id]?.let { info -> + ButtonIconEntry(id, item.enabled, info.icon, info.label) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/ToolButtonConfigSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/ToolButtonConfigSheet.kt new file mode 100644 index 000000000..4dc0f7e3a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/ToolButtonConfigSheet.kt @@ -0,0 +1,22 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.runtime.Composable +import io.legado.app.ui.book.read.ReadBookButtonConfigItem +import io.legado.app.ui.book.read.ReadBookIntent + +@Composable +fun ToolButtonConfigSheet( + show: Boolean, + items: List, + customIcons: Map, + onDismissRequest: () -> Unit, + onIntent: (ReadBookIntent) -> Unit, +) { + BottomBarIconSheet( + show = show, + items = items, + customIcons = customIcons, + onDismissRequest = onDismissRequest, + onIntent = onIntent, + ) +} diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/UnderlineConfigSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/UnderlineConfigSheet.kt new file mode 100644 index 000000000..5c3bc5e43 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/sheet/UnderlineConfigSheet.kt @@ -0,0 +1,161 @@ +package io.legado.app.ui.book.read.sheet + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.help.config.ReadBookConfig +import io.legado.app.ui.book.read.ConfigUpdate +import io.legado.app.ui.book.read.ReadBookIntent +import io.legado.app.ui.widget.components.dialog.ColorPickerSheet +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.settingItem.TinyColorSettingItem +import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem +import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem + +@Composable +fun UnderlineConfigSheet( + show: Boolean, + onDismissRequest: () -> Unit, + onIntent: (ReadBookIntent) -> Unit, +) { + var underline by remember { mutableStateOf(ReadBookConfig.underline) } + var dottedLine by remember { mutableStateOf(ReadBookConfig.dottedLine) } + var underlineExtend by remember { mutableStateOf(ReadBookConfig.underlineExtend) } + var underlineColor by remember { mutableStateOf(ReadBookConfig.durConfig.curUnderlineColor()) } + var underlineHeight by remember { mutableFloatStateOf(ReadBookConfig.underlineHeight.toFloat()) } + var underlinePadding by remember { mutableFloatStateOf(ReadBookConfig.underlinePadding.toFloat()) } + var dottedBase by remember { mutableFloatStateOf(ReadBookConfig.durConfig.dottedBase) } + var dottedRatio by remember { mutableFloatStateOf(ReadBookConfig.durConfig.dottedRatio) } + var showColorPicker by remember { mutableStateOf(false) } + + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.text_underline), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + TinySwitchSettingItem( + title = stringResource(R.string.text_underline), + checked = underline, + onCheckedChange = { + underline = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.Underline(it))) + if (!it) { + dottedLine = false + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.DottedLine(false))) + } + }, + ) + TinyColorSettingItem( + title = stringResource(R.string.underline_color), + colorValue = underlineColor, + onClick = { showColorPicker = true }, + ) + TinySwitchSettingItem( + title = stringResource(R.string.text_dottedline), + checked = dottedLine, + enabled = underline, + onCheckedChange = { + dottedLine = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.DottedLine(it))) + }, + ) + TinySwitchSettingItem( + title = stringResource(R.string.underline_extend), + checked = underlineExtend, + onCheckedChange = { + underlineExtend = it + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.UnderlineExtend(it))) + }, + ) + + // Underline height & padding + TinySliderSettingItem( + title = stringResource(R.string.underline_height), + value = underlineHeight, + valueRange = 0f..20f, + onValueChange = { value -> + underlineHeight = value + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.UnderlineHeight(value.toInt()))) + }, + ) + TinySliderSettingItem( + title = stringResource(R.string.underline_padding), + value = underlinePadding, + valueRange = 0f..20f, + onValueChange = { value -> + underlinePadding = value + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.UnderlinePadding(value.toInt()))) + }, + ) + + Spacer(Modifier.height(8.dp)) + + // Dotted line section title + Text( + text = stringResource(R.string.text_dottedline), + style = MaterialTheme.typography.titleSmallEmphasized, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(8.dp)) + + // Dotted line sliders + TinySliderSettingItem( + title = stringResource(R.string.dotted_line_black), + value = dottedBase, + valueRange = 0f..20f, + onValueChange = { value -> + dottedBase = value + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.DottedBase(value))) + }, + ) + TinySliderSettingItem( + title = stringResource(R.string.dotted_line_while), + value = dottedRatio, + valueRange = 0f..20f, + onValueChange = { value -> + dottedRatio = value + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.DottedRatio(value))) + }, + ) + } + } + + // Color picker + if (showColorPicker) { + ColorPickerSheet( + show = true, + initialColor = underlineColor, + onDismissRequest = { showColorPicker = false }, + onColorSelected = { color -> + underlineColor = color + onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.UnderlineColor(color))) + showColorPicker = false + }, + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt b/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt index 45ecc6418..6507c3073 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt @@ -3,7 +3,6 @@ package io.legado.app.ui.book.toc import android.app.Application import android.net.Uri import androidx.compose.runtime.Immutable -import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope @@ -14,6 +13,7 @@ import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.Bookmark import io.legado.app.data.entities.ReplaceRule +import io.legado.app.data.repository.ReadSettingsRepository import io.legado.app.domain.usecase.CacheBookChaptersUseCase import io.legado.app.help.book.BookHelp import io.legado.app.help.book.ContentProcessor @@ -24,7 +24,6 @@ import io.legado.app.model.CacheBook import io.legado.app.model.ReadBook import io.legado.app.model.cache.CacheBookDownloadState import io.legado.app.model.localBook.LocalBook -import io.legado.app.ui.config.readConfig.ReadConfig import io.legado.app.ui.widget.components.importComponents.BaseImportUiState import io.legado.app.ui.widget.components.list.ListUiState import io.legado.app.ui.widget.components.list.SelectableItem @@ -102,6 +101,11 @@ private data class TocUiConfig( val isReverse: Boolean ) +private data class TocPreferences( + val useReplace: Boolean, + val showWordCount: Boolean +) + private data class TitleCacheKey( val bookUrl: String, val useReplace: Boolean, @@ -114,7 +118,8 @@ private data class TitleCacheKey( class TocViewModel( application: Application, savedStateHandle: SavedStateHandle, - private val cacheBookChaptersUseCase: CacheBookChaptersUseCase + private val cacheBookChaptersUseCase: CacheBookChaptersUseCase, + private val readSettingsRepository: ReadSettingsRepository ) : BaseRuleViewModel( application, initialState = TocActionState() @@ -204,6 +209,19 @@ class TocViewModel( bookState.map { it?.getReverseToc() ?: false } .distinctUntilChanged() + private val tocPreferences = readSettingsRepository.preferences + .map { + TocPreferences( + useReplace = it.tocUiUseReplace, + showWordCount = it.tocCountWords + ) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = TocPreferences(useReplace = false, showWordCount = true) + ) + private val downloadContextFlow = combine( bookState.filterNotNull().map { it.bookUrl }.distinctUntilChanged(), CacheBook.downloadStateFlow, @@ -214,11 +232,10 @@ class TocViewModel( private val uiConfigFlow = combine( _collapsedVolumes, - snapshotFlow { ReadConfig.tocUiUseReplace }, - snapshotFlow { ReadConfig.tocCountWords }, + tocPreferences, reverseFlow - ) { collapsed, useReplace, showWordCount, isReverse -> - TocUiConfig(collapsed, useReplace, showWordCount, isReverse) + ) { collapsed, tocPreferences, isReverse -> + TocUiConfig(collapsed, tocPreferences.useReplace, tocPreferences.showWordCount, isReverse) } private val titleReplaceCache = MutableStateFlow>(emptyMap()) @@ -284,8 +301,8 @@ class TocViewModel( }.flowOn(Dispatchers.Default) - val useReplace get() = ReadConfig.tocUiUseReplace - val showWordCount get() = ReadConfig.tocCountWords + val useReplace get() = tocPreferences.value.useReplace + val showWordCount get() = tocPreferences.value.showWordCount override fun filterData(data: List, key: String): List { val collapsed = _collapsedVolumes.value @@ -375,11 +392,15 @@ class TocViewModel( } fun toggleUseReplace() { - ReadConfig.tocUiUseReplace = !ReadConfig.tocUiUseReplace + viewModelScope.launch { + readSettingsRepository.setTocUiUseReplace(!tocPreferences.value.useReplace) + } } fun toggleShowWordCount() { - ReadConfig.tocCountWords = !ReadConfig.tocCountWords + viewModelScope.launch { + readSettingsRepository.setTocCountWords(!tocPreferences.value.showWordCount) + } } fun toggleVolume(volumeIndex: Int) { diff --git a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigViewModel.kt b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigViewModel.kt index 001f85e46..6aac53108 100644 --- a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigViewModel.kt @@ -9,7 +9,6 @@ import io.legado.app.constant.PreferKey import io.legado.app.exception.NoStackTraceException import io.legado.app.lib.permission.Permissions import io.legado.app.lib.permission.PermissionsCompat -import io.legado.app.model.BookCover import io.legado.app.utils.FileDoc import io.legado.app.utils.FileUtils import io.legado.app.utils.MD5Utils @@ -57,7 +56,6 @@ class CoverConfigViewModel : ViewModel() { } else { CoverConfig.defaultCoverDark = newCovers } - BookCover.upDefaultCover() }.onFailure { appCtx.toastOnUi(it.localizedMessage) } @@ -112,7 +110,6 @@ class CoverConfigViewModel : ViewModel() { } else { CoverConfig.defaultCoverDark = newCovers } - BookCover.upDefaultCover() } fun updateShowName(show: Boolean, isNight: Boolean = false) { @@ -121,7 +118,6 @@ class CoverConfigViewModel : ViewModel() { } else { CoverConfig.coverShowName = show } - BookCover.upDefaultCover() } fun updateShowAuthor(show: Boolean, isNight: Boolean = false) { @@ -130,10 +126,9 @@ class CoverConfigViewModel : ViewModel() { } else { CoverConfig.coverShowAuthor = show } - BookCover.upDefaultCover() } fun updateCoverStyle() { - BookCover.upDefaultCover() + // no-op: Compose CoilBookCover reads CoverConfig preferences directly } } diff --git a/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfig.kt b/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfig.kt index 0dd7d5ad6..96f5c8214 100644 --- a/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfig.kt +++ b/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfig.kt @@ -62,21 +62,6 @@ object OtherConfig { true ) - var mediaButtonOnExit by prefDelegate( - PreferKey.mediaButtonOnExit, - true - ) - - var readAloudByMediaButton by prefDelegate( - PreferKey.readAloudByMediaButton, - false - ) - - var ignoreAudioFocus by prefDelegate( - PreferKey.ignoreAudioFocus, - false - ) - var autoClearExpired by prefDelegate( PreferKey.autoClearExpired, true @@ -87,11 +72,6 @@ object OtherConfig { true ) - var showMangaUi by prefDelegate( - PreferKey.showMangaUi, - true - ) - var userAgent: String get() = DownloadCacheConfig.userAgent set(value) { diff --git a/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt index 84d9f5481..1c8c3e67f 100644 --- a/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.legado.app.R import io.legado.app.service.WebService import io.legado.app.ui.theme.LegadoTheme @@ -47,6 +48,8 @@ fun OtherConfigScreen( viewModel: OtherConfigViewModel = koinViewModel() ) { val context = LocalContext.current + val readAloudPreferences by viewModel.readAloudPreferences.collectAsStateWithLifecycle() + val mangaPreferences by viewModel.mangaPreferences.collectAsStateWithLifecycle() val notificationPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() @@ -200,22 +203,22 @@ fun OtherConfigScreen( SwitchSettingItem( title = stringResource(R.string.media_button_on_exit_title), description = stringResource(R.string.media_button_on_exit_summary), - checked = OtherConfig.mediaButtonOnExit, - onCheckedChange = { OtherConfig.mediaButtonOnExit = it } + checked = readAloudPreferences.mediaButtonOnExit, + onCheckedChange = { viewModel.setMediaButtonOnExit(it) } ) SwitchSettingItem( title = stringResource(R.string.read_aloud_by_media_button_title), description = stringResource(R.string.read_aloud_by_media_button_summary), - checked = OtherConfig.readAloudByMediaButton, - onCheckedChange = { OtherConfig.readAloudByMediaButton = it } + checked = readAloudPreferences.readAloudByMediaButton, + onCheckedChange = { viewModel.setReadAloudByMediaButton(it) } ) SwitchSettingItem( title = stringResource(R.string.ignore_audio_focus_title), description = stringResource(R.string.ignore_audio_focus_summary), - checked = OtherConfig.ignoreAudioFocus, - onCheckedChange = { OtherConfig.ignoreAudioFocus = it } + checked = readAloudPreferences.ignoreAudioFocus, + onCheckedChange = { viewModel.setIgnoreAudioFocus(it) } ) SwitchSettingItem( @@ -234,8 +237,8 @@ fun OtherConfigScreen( SwitchSettingItem( title = stringResource(R.string.show_manga_ui), - checked = OtherConfig.showMangaUi, - onCheckedChange = { OtherConfig.showMangaUi = it } + checked = mangaPreferences.showMangaUi, + onCheckedChange = { viewModel.setShowMangaUi(it) } ) } diff --git a/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModel.kt b/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModel.kt index 5728afac2..5f5b5e18b 100644 --- a/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModel.kt @@ -9,6 +9,10 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.legado.app.constant.PreferKey +import io.legado.app.data.repository.MangaPreferences +import io.legado.app.data.repository.MangaSettingsRepository +import io.legado.app.data.repository.ReadAloudPreferences +import io.legado.app.data.repository.ReadAloudSettingsRepository import io.legado.app.help.DirectLinkUpload import io.legado.app.help.config.AppConfig import io.legado.app.help.config.LocalConfig @@ -21,10 +25,15 @@ import io.legado.app.utils.restart import io.legado.app.utils.toastOnUi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import splitties.init.appCtx -class OtherConfigViewModel : ViewModel() { +class OtherConfigViewModel( + private val readAloudSettingsRepository: ReadAloudSettingsRepository, + private val mangaSettingsRepository: MangaSettingsRepository +) : ViewModel() { private val packageManager = appCtx.packageManager private val componentName = ComponentName( @@ -38,6 +47,42 @@ class OtherConfigViewModel : ViewModel() { } } + val readAloudPreferences = readAloudSettingsRepository.preferences.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ReadAloudPreferences() + ) + + val mangaPreferences = mangaSettingsRepository.preferences.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = MangaPreferences() + ) + + fun setMediaButtonOnExit(value: Boolean) { + viewModelScope.launch { + readAloudSettingsRepository.setMediaButtonOnExit(value) + } + } + + fun setReadAloudByMediaButton(value: Boolean) { + viewModelScope.launch { + readAloudSettingsRepository.setReadAloudByMediaButton(value) + } + } + + fun setIgnoreAudioFocus(value: Boolean) { + viewModelScope.launch { + readAloudSettingsRepository.setIgnoreAudioFocus(value) + } + } + + fun setShowMangaUi(value: Boolean) { + viewModelScope.launch { + mangaSettingsRepository.setShowMangaUi(value) + } + } + fun isProcessTextEnabled(): Boolean { return packageManager.getComponentEnabledSetting(componentName) != PackageManager.COMPONENT_ENABLED_STATE_DISABLED } diff --git a/app/src/main/java/io/legado/app/ui/config/readConfig/PageKeySheet.kt b/app/src/main/java/io/legado/app/ui/config/readConfig/PageKeySheet.kt index 16b5acf90..74fcbc350 100644 --- a/app/src/main/java/io/legado/app/ui/config/readConfig/PageKeySheet.kt +++ b/app/src/main/java/io/legado/app/ui/config/readConfig/PageKeySheet.kt @@ -3,15 +3,11 @@ package io.legado.app.ui.config.readConfig import android.view.KeyEvent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -19,7 +15,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import io.legado.app.R import io.legado.app.ui.theme.LegadoTheme @@ -32,10 +27,20 @@ import io.legado.app.ui.widget.components.text.AppText @Composable fun PageKeySheet( show: Boolean, - onDismissRequest: () -> Unit + prevKeys: String, + nextKeys: String, + onDismissRequest: () -> Unit, + onConfirm: (prevKeys: String, nextKeys: String) -> Unit ) { - var prevKeys by remember { mutableStateOf(ReadConfig.prevKeys) } - var nextKeys by remember { mutableStateOf(ReadConfig.nextKeys) } + var prevKeysDraft by remember { mutableStateOf(prevKeys) } + var nextKeysDraft by remember { mutableStateOf(nextKeys) } + + LaunchedEffect(show, prevKeys, nextKeys) { + if (show) { + prevKeysDraft = prevKeys + nextKeysDraft = nextKeys + } + } AppModalBottomSheet( show = show, @@ -49,8 +54,8 @@ fun PageKeySheet( verticalArrangement = Arrangement.spacedBy(16.dp) ) { AppTextField( - value = prevKeys, - onValueChange = { prevKeys = it }, + value = prevKeysDraft, + onValueChange = { prevKeysDraft = it }, label = stringResource(R.string.prev_page_key), modifier = Modifier .fillMaxWidth() @@ -58,10 +63,10 @@ fun PageKeySheet( if (event.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) { val keyCode = event.nativeKeyEvent.keyCode if (keyCode != KeyEvent.KEYCODE_BACK && keyCode != KeyEvent.KEYCODE_DEL) { - prevKeys = if (prevKeys.isEmpty() || prevKeys.endsWith(",")) { - prevKeys + keyCode.toString() + prevKeysDraft = if (prevKeysDraft.isEmpty() || prevKeysDraft.endsWith(",")) { + prevKeysDraft + keyCode.toString() } else { - "$prevKeys,$keyCode" + "$prevKeysDraft,$keyCode" } return@onPreviewKeyEvent true } @@ -72,8 +77,8 @@ fun PageKeySheet( ) AppTextField( - value = nextKeys, - onValueChange = { nextKeys = it }, + value = nextKeysDraft, + onValueChange = { nextKeysDraft = it }, label = stringResource(R.string.next_page_key), modifier = Modifier.Companion .fillMaxWidth() @@ -81,10 +86,10 @@ fun PageKeySheet( if (event.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) { val keyCode = event.nativeKeyEvent.keyCode if (keyCode != KeyEvent.KEYCODE_BACK && keyCode != KeyEvent.KEYCODE_DEL) { - nextKeys = if (nextKeys.isEmpty() || nextKeys.endsWith(",")) { - nextKeys + keyCode.toString() + nextKeysDraft = if (nextKeysDraft.isEmpty() || nextKeysDraft.endsWith(",")) { + nextKeysDraft + keyCode.toString() } else { - "$nextKeys,$keyCode" + "$nextKeysDraft,$keyCode" } return@onPreviewKeyEvent true } @@ -102,17 +107,15 @@ fun PageKeySheet( ConfirmDismissButtonsRow( modifier = Modifier.fillMaxWidth(), onDismiss = { - prevKeys = "" - nextKeys = "" + prevKeysDraft = "" + nextKeysDraft = "" }, onConfirm = { - ReadConfig.prevKeys = prevKeys - ReadConfig.nextKeys = nextKeys - onDismissRequest() + onConfirm(prevKeysDraft, nextKeysDraft) }, dismissText = stringResource(R.string.reset), confirmText = stringResource(R.string.ok) ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigContract.kt b/app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigContract.kt new file mode 100644 index 000000000..90190ce80 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigContract.kt @@ -0,0 +1,77 @@ +package io.legado.app.ui.config.readConfig + +data class ReadConfigUiState( + val screenOrientation: String = "0", + val keepLight: String = "0", + val hideStatusBar: Boolean = false, + val hideNavigationBar: Boolean = false, + val paddingDisplayCutouts: Boolean = false, + val titleBarMode: String = "1", + val menuAlpha: Int = 100, + val readBodyToLh: Boolean = true, + val defaultSourceChangeAll: Boolean = true, + val textFullJustify: Boolean = true, + val textBottomJustify: Boolean = true, + val adaptSpecialStyle: Boolean = true, + val useZhLayout: Boolean = false, + val showBrightnessView: Boolean = true, + val useUnderline: Boolean = false, + val readSliderMode: String = "0", + val doubleHorizontalPage: String = "0", + val progressBarBehavior: String = "page", + val mouseWheelPage: Boolean = true, + val volumeKeyPage: Boolean = true, + val volumeKeyPageOnPlay: Boolean = true, + val keyPageOnLongPress: Boolean = false, + val pageTouchSlop: Int = 0, + val sliderVibrator: Boolean = false, + val selectVibrator: Boolean = false, + val autoChangeSource: Boolean = true, + val selectText: Boolean = true, + val noAnimScrollPage: Boolean = false, + val clickImgWay: String = "2", + val optimizeRender: Boolean = false, + val disableReturnKey: Boolean = false, + val expandTextMenu: Boolean = false, + val showReadTitleAddition: Boolean = true, + val autoReadSpeed: Int = 10, + val prevKeys: String = "", + val nextKeys: String = "" +) + +sealed interface ReadConfigIntent { + data class ScreenOrientationChanged(val value: String) : ReadConfigIntent + data class KeepLightChanged(val value: String) : ReadConfigIntent + data class HideStatusBarChanged(val value: Boolean) : ReadConfigIntent + data class HideNavigationBarChanged(val value: Boolean) : ReadConfigIntent + data class PaddingDisplayCutoutsChanged(val value: Boolean) : ReadConfigIntent + data class TitleBarModeChanged(val value: String) : ReadConfigIntent + data class MenuAlphaChanged(val value: Int) : ReadConfigIntent + data class ReadBodyToLhChanged(val value: Boolean) : ReadConfigIntent + data class DefaultSourceChangeAllChanged(val value: Boolean) : ReadConfigIntent + data class TextFullJustifyChanged(val value: Boolean) : ReadConfigIntent + data class TextBottomJustifyChanged(val value: Boolean) : ReadConfigIntent + data class AdaptSpecialStyleChanged(val value: Boolean) : ReadConfigIntent + data class UseZhLayoutChanged(val value: Boolean) : ReadConfigIntent + data class ShowBrightnessViewChanged(val value: Boolean) : ReadConfigIntent + data class UseUnderlineChanged(val value: Boolean) : ReadConfigIntent + data class ReadSliderModeChanged(val value: String) : ReadConfigIntent + data class DoubleHorizontalPageChanged(val value: String) : ReadConfigIntent + data class ProgressBarBehaviorChanged(val value: String) : ReadConfigIntent + data class MouseWheelPageChanged(val value: Boolean) : ReadConfigIntent + data class VolumeKeyPageChanged(val value: Boolean) : ReadConfigIntent + data class VolumeKeyPageOnPlayChanged(val value: Boolean) : ReadConfigIntent + data class KeyPageOnLongPressChanged(val value: Boolean) : ReadConfigIntent + data class PageTouchSlopChanged(val value: Int) : ReadConfigIntent + data class SliderVibratorChanged(val value: Boolean) : ReadConfigIntent + data class SelectVibratorChanged(val value: Boolean) : ReadConfigIntent + data class AutoChangeSourceChanged(val value: Boolean) : ReadConfigIntent + data class SelectTextChanged(val value: Boolean) : ReadConfigIntent + data class NoAnimScrollPageChanged(val value: Boolean) : ReadConfigIntent + data class ClickImgWayChanged(val value: String) : ReadConfigIntent + data class OptimizeRenderChanged(val value: Boolean) : ReadConfigIntent + data class DisableReturnKeyChanged(val value: Boolean) : ReadConfigIntent + data class ExpandTextMenuChanged(val value: Boolean) : ReadConfigIntent + data class ShowReadTitleAdditionChanged(val value: Boolean) : ReadConfigIntent + data class PageKeysChanged(val prevKeys: String, val nextKeys: String) : ReadConfigIntent +} diff --git a/app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigScreen.kt index d17259102..832741c7b 100644 --- a/app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigScreen.kt @@ -10,10 +10,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.legado.app.R import io.legado.app.ui.theme.adaptiveContentPadding import io.legado.app.ui.widget.components.AppScaffold @@ -34,9 +34,9 @@ fun ReadConfigScreen( onBackClick: () -> Unit, viewModel: ReadConfigViewModel = koinViewModel() ) { - val context = LocalContext.current val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() var showPageKeySheet by remember { mutableStateOf(false) } + val state by viewModel.uiState.collectAsStateWithLifecycle() AppScaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -61,228 +61,270 @@ fun ReadConfigScreen( SplicedColumnGroup(title = stringResource(R.string.screen_settings)) { DropdownListSettingItem( title = stringResource(R.string.screen_direction), - selectedValue = ReadConfig.screenOrientation, + selectedValue = state.screenOrientation, displayEntries = stringArrayResource(R.array.screen_direction_title), entryValues = stringArrayResource(R.array.screen_direction_value), - onValueChange = { ReadConfig.screenOrientation = it } + onValueChange = { + viewModel.onIntent(ReadConfigIntent.ScreenOrientationChanged(it)) + } ) DropdownListSettingItem( title = stringResource(R.string.keep_light), - selectedValue = ReadConfig.keepLight, + selectedValue = state.keepLight, displayEntries = stringArrayResource(R.array.screen_time_out), entryValues = stringArrayResource(R.array.screen_time_out_value), - onValueChange = { ReadConfig.keepLight = it } + onValueChange = { + viewModel.onIntent(ReadConfigIntent.KeepLightChanged(it)) + } ) SwitchSettingItem( title = stringResource(R.string.pt_hide_status_bar), - checked = ReadConfig.hideStatusBar, - onCheckedChange = { viewModel.updateHideStatusBar(it) } + checked = state.hideStatusBar, + onCheckedChange = { + viewModel.onIntent(ReadConfigIntent.HideStatusBarChanged(it)) + } ) SwitchSettingItem( title = stringResource(R.string.pt_hide_navigation_bar), - checked = ReadConfig.hideNavigationBar, - onCheckedChange = { viewModel.updateHideNavigationBar(it) } + checked = state.hideNavigationBar, + onCheckedChange = { + viewModel.onIntent(ReadConfigIntent.HideNavigationBarChanged(it)) + } ) SwitchSettingItem( title = stringResource(R.string.padding_display_cutouts), - checked = ReadConfig.paddingDisplayCutouts, - onCheckedChange = { ReadConfig.paddingDisplayCutouts = it } + checked = state.paddingDisplayCutouts, + onCheckedChange = { + viewModel.onIntent(ReadConfigIntent.PaddingDisplayCutoutsChanged(it)) + } ) DropdownListSettingItem( title = stringResource(R.string.title_bar_mode), - selectedValue = ReadConfig.titleBarMode, + selectedValue = state.titleBarMode, displayEntries = stringArrayResource(R.array.title_bar_mode), entryValues = stringArrayResource(R.array.title_bar_mode_value), - onValueChange = { ReadConfig.titleBarMode = it } + onValueChange = { + viewModel.onIntent(ReadConfigIntent.TitleBarModeChanged(it)) + } ) SliderSettingItem( title = stringResource(R.string.menu_alpha), - description = stringResource(R.string.menu_alpha_sum, ReadConfig.menuAlpha), - value = ReadConfig.menuAlpha.toFloat(), + description = stringResource(R.string.menu_alpha_sum, state.menuAlpha), + value = state.menuAlpha.toFloat(), defaultValue = 100f, valueRange = 0f..100f, - onValueChange = { viewModel.updateMenuAlpha(it.toInt()) } + onValueChange = { + viewModel.onIntent(ReadConfigIntent.MenuAlphaChanged(it.toInt())) + } ) SwitchSettingItem( title = stringResource(R.string.read_body_to_lh), - checked = ReadConfig.readBodyToLh, - onCheckedChange = { ReadConfig.readBodyToLh = it } + checked = state.readBodyToLh, + onCheckedChange = { + viewModel.onIntent(ReadConfigIntent.ReadBodyToLhChanged(it)) + } ) SwitchSettingItem( title = stringResource(R.string.read_change_all), description = stringResource(R.string.read_change_all_s), - checked = ReadConfig.defaultSourceChangeAll, - onCheckedChange = { ReadConfig.defaultSourceChangeAll = it } + checked = state.defaultSourceChangeAll, + onCheckedChange = { + viewModel.onIntent(ReadConfigIntent.DefaultSourceChangeAllChanged(it)) + } ) SwitchSettingItem( title = stringResource(R.string.text_full_justify), - checked = ReadConfig.textFullJustify, + checked = state.textFullJustify, onCheckedChange = { - ReadConfig.textFullJustify = it - viewModel.upLayout() + viewModel.onIntent(ReadConfigIntent.TextFullJustifyChanged(it)) } ) SwitchSettingItem( title = stringResource(R.string.text_bottom_justify), - checked = ReadConfig.textBottomJustify, + checked = state.textBottomJustify, onCheckedChange = { - ReadConfig.textBottomJustify = it - viewModel.upLayout() + viewModel.onIntent(ReadConfigIntent.TextBottomJustifyChanged(it)) } ) SwitchSettingItem( title = stringResource(R.string.adapt_special_style), - checked = ReadConfig.adaptSpecialStyle, - onCheckedChange = { ReadConfig.adaptSpecialStyle = it } + checked = state.adaptSpecialStyle, + onCheckedChange = { + viewModel.onIntent(ReadConfigIntent.AdaptSpecialStyleChanged(it)) + } ) SwitchSettingItem( title = stringResource(R.string.use_zh_layout), - checked = ReadConfig.useZhLayout, + checked = state.useZhLayout, onCheckedChange = { - ReadConfig.useZhLayout = it - viewModel.upLayout() + viewModel.onIntent(ReadConfigIntent.UseZhLayoutChanged(it)) } ) SwitchSettingItem( title = stringResource(R.string.show_brightness_view), - checked = ReadConfig.showBrightnessView, - onCheckedChange = { ReadConfig.showBrightnessView = it } + checked = state.showBrightnessView, + onCheckedChange = { + viewModel.onIntent(ReadConfigIntent.ShowBrightnessViewChanged(it)) + } ) SwitchSettingItem( title = stringResource(R.string.use_underline), - checked = ReadConfig.useUnderline, - onCheckedChange = { ReadConfig.useUnderline = it } + checked = state.useUnderline, + onCheckedChange = { + viewModel.onIntent(ReadConfigIntent.UseUnderlineChanged(it)) + } ) } SplicedColumnGroup(title = stringResource(R.string.page_control)) { DropdownListSettingItem( title = stringResource(R.string.read_slider_mode), - selectedValue = ReadConfig.readSliderMode, + selectedValue = state.readSliderMode, displayEntries = stringArrayResource(R.array.read_slider_mode), entryValues = stringArrayResource(R.array.read_slider_mode_value), - onValueChange = { viewModel.updateReadSliderMode(it) } + onValueChange = { + viewModel.onIntent(ReadConfigIntent.ReadSliderModeChanged(it)) + } ) DropdownListSettingItem( title = stringResource(R.string.double_page_horizontal), - selectedValue = ReadConfig.doubleHorizontalPage, + selectedValue = state.doubleHorizontalPage, displayEntries = stringArrayResource(R.array.double_page_title), entryValues = stringArrayResource(R.array.double_page_value), onValueChange = { - ReadConfig.doubleHorizontalPage = it - viewModel.upLayout() + viewModel.onIntent(ReadConfigIntent.DoubleHorizontalPageChanged(it)) } ) DropdownListSettingItem( title = stringResource(R.string.progress_bar_behavior), - selectedValue = ReadConfig.progressBarBehavior, + selectedValue = state.progressBarBehavior, displayEntries = stringArrayResource(R.array.progress_bar_behavior_title), entryValues = stringArrayResource(R.array.progress_bar_behavior_value), - onValueChange = { viewModel.updateProgressBarBehavior(it) } + onValueChange = { + viewModel.onIntent(ReadConfigIntent.ProgressBarBehaviorChanged(it)) + } ) SwitchSettingItem( title = stringResource(R.string.mouse_wheel_page), - checked = ReadConfig.mouseWheelPage, - onCheckedChange = { ReadConfig.mouseWheelPage = it } + checked = state.mouseWheelPage, + onCheckedChange = { + viewModel.onIntent(ReadConfigIntent.MouseWheelPageChanged(it)) + } ) SwitchSettingItem( title = stringResource(R.string.volume_key_page), - checked = ReadConfig.volumeKeyPage, - onCheckedChange = { ReadConfig.volumeKeyPage = it } + checked = state.volumeKeyPage, + onCheckedChange = { + viewModel.onIntent(ReadConfigIntent.VolumeKeyPageChanged(it)) + } ) SwitchSettingItem( title = stringResource(R.string.volume_key_page_on_play), - checked = ReadConfig.volumeKeyPageOnPlay, - onCheckedChange = { ReadConfig.volumeKeyPageOnPlay = it } + checked = state.volumeKeyPageOnPlay, + onCheckedChange = { + viewModel.onIntent(ReadConfigIntent.VolumeKeyPageOnPlayChanged(it)) + } ) SwitchSettingItem( title = stringResource(R.string.key_page_on_long_press), - checked = ReadConfig.keyPageOnLongPress, - onCheckedChange = { ReadConfig.keyPageOnLongPress = it } + checked = state.keyPageOnLongPress, + onCheckedChange = { + viewModel.onIntent(ReadConfigIntent.KeyPageOnLongPressChanged(it)) + } ) SliderSettingItem( title = stringResource(R.string.page_touch_slop_title), description = stringResource( R.string.page_touch_slop_summary, - ReadConfig.pageTouchSlop + state.pageTouchSlop ), - value = ReadConfig.pageTouchSlop.toFloat(), + value = state.pageTouchSlop.toFloat(), defaultValue = 0f, valueRange = 0f..1000f, - onValueChange = { viewModel.updatePageTouchSlop(it.toInt()) } + onValueChange = { + viewModel.onIntent(ReadConfigIntent.PageTouchSlopChanged(it.toInt())) + } ) } SplicedColumnGroup(title = stringResource(R.string.other)) { SwitchSettingItem( title = stringResource(R.string.enable_slider_vibrator), - checked = ReadConfig.sliderVibrator, - onCheckedChange = { ReadConfig.sliderVibrator = it } + checked = state.sliderVibrator, + onCheckedChange = { + viewModel.onIntent(ReadConfigIntent.SliderVibratorChanged(it)) + } ) SwitchSettingItem( title = stringResource(R.string.enable_select_vibrator), - checked = ReadConfig.selectVibrator, - onCheckedChange = { ReadConfig.selectVibrator = it } + checked = state.selectVibrator, + onCheckedChange = { + viewModel.onIntent(ReadConfigIntent.SelectVibratorChanged(it)) + } ) SwitchSettingItem( title = stringResource(R.string.auto_change_source), - checked = ReadConfig.autoChangeSource, - onCheckedChange = { ReadConfig.autoChangeSource = it } + checked = state.autoChangeSource, + onCheckedChange = { + viewModel.onIntent(ReadConfigIntent.AutoChangeSourceChanged(it)) + } ) SwitchSettingItem( title = stringResource(R.string.selectText), - checked = ReadConfig.selectText, - onCheckedChange = { ReadConfig.selectText = it } + checked = state.selectText, + onCheckedChange = { + viewModel.onIntent(ReadConfigIntent.SelectTextChanged(it)) + } ) SwitchSettingItem( title = stringResource(R.string.no_anim_scroll_page), - checked = ReadConfig.noAnimScrollPage, + checked = state.noAnimScrollPage, onCheckedChange = { - ReadConfig.noAnimScrollPage = it - viewModel.upPageAnim() + viewModel.onIntent(ReadConfigIntent.NoAnimScrollPageChanged(it)) } ) DropdownListSettingItem( title = stringResource(R.string.click_image_way), - selectedValue = ReadConfig.clickImgWay, + selectedValue = state.clickImgWay, displayEntries = stringArrayResource(R.array.click_image_way_title), entryValues = stringArrayResource(R.array.click_image_way_value), - onValueChange = { ReadConfig.clickImgWay = it } + onValueChange = { + viewModel.onIntent(ReadConfigIntent.ClickImgWayChanged(it)) + } ) if (CanvasRecorderFactory.isSupport) { SwitchSettingItem( title = stringResource(R.string.enable_optimize_render), - checked = ReadConfig.optimizeRender, + checked = state.optimizeRender, onCheckedChange = { - ReadConfig.optimizeRender = it - viewModel.upStyle() + viewModel.onIntent(ReadConfigIntent.OptimizeRenderChanged(it)) } ) } @@ -294,8 +336,10 @@ fun ReadConfigScreen( SwitchSettingItem( title = stringResource(R.string.disable_return_key), - checked = ReadConfig.disableReturnKey, - onCheckedChange = { ReadConfig.disableReturnKey = it } + checked = state.disableReturnKey, + onCheckedChange = { + viewModel.onIntent(ReadConfigIntent.DisableReturnKeyChanged(it)) + } ) ClickableSettingItem( @@ -305,14 +349,18 @@ fun ReadConfigScreen( SwitchSettingItem( title = stringResource(R.string.expand_text_menu), - checked = ReadConfig.expandTextMenu, - onCheckedChange = { ReadConfig.expandTextMenu = it } + checked = state.expandTextMenu, + onCheckedChange = { + viewModel.onIntent(ReadConfigIntent.ExpandTextMenuChanged(it)) + } ) SwitchSettingItem( title = stringResource(R.string.show_read_title_addition), - checked = ReadConfig.showReadTitleAddition, - onCheckedChange = { ReadConfig.showReadTitleAddition = it } + checked = state.showReadTitleAddition, + onCheckedChange = { + viewModel.onIntent(ReadConfigIntent.ShowReadTitleAdditionChanged(it)) + } ) } } @@ -321,6 +369,12 @@ fun ReadConfigScreen( PageKeySheet( show = showPageKeySheet, - onDismissRequest = { showPageKeySheet = false } + prevKeys = state.prevKeys, + nextKeys = state.nextKeys, + onDismissRequest = { showPageKeySheet = false }, + onConfirm = { prevKeys, nextKeys -> + viewModel.onIntent(ReadConfigIntent.PageKeysChanged(prevKeys, nextKeys)) + showPageKeySheet = false + } ) } diff --git a/app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigViewModel.kt b/app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigViewModel.kt index 4c2014cd9..3f1fe9a0d 100644 --- a/app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigViewModel.kt @@ -1,63 +1,236 @@ package io.legado.app.ui.config.readConfig import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import io.legado.app.constant.EventBus +import io.legado.app.data.repository.ReadPreferences +import io.legado.app.data.repository.ReadSettingsRepository import io.legado.app.help.config.ReadBookConfig import io.legado.app.model.ReadBook import io.legado.app.ui.book.read.page.provider.ChapterProvider import io.legado.app.utils.postEvent +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch -class ReadConfigViewModel : ViewModel() { +class ReadConfigViewModel( + private val readSettingsRepository: ReadSettingsRepository +) : ViewModel() { - fun updateHideStatusBar(hide: Boolean) { - ReadConfig.hideStatusBar = hide - ReadBookConfig.hideStatusBar = hide - postEvent(EventBus.UP_CONFIG, arrayListOf(0, 2)) + val uiState = readSettingsRepository.preferences.map { it.toUiState() }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ReadConfigUiState() + ) + + fun onIntent(intent: ReadConfigIntent) { + viewModelScope.launch { + when (intent) { + is ReadConfigIntent.ScreenOrientationChanged -> { + readSettingsRepository.setScreenOrientation(intent.value) + } + + is ReadConfigIntent.KeepLightChanged -> { + readSettingsRepository.setKeepLight(intent.value) + } + + is ReadConfigIntent.HideStatusBarChanged -> { + readSettingsRepository.setHideStatusBar(intent.value) + ReadBookConfig.hideStatusBar = intent.value + postEvent(EventBus.UP_CONFIG, arrayListOf(0, 2)) + } + + is ReadConfigIntent.HideNavigationBarChanged -> { + readSettingsRepository.setHideNavigationBar(intent.value) + ReadBookConfig.hideNavigationBar = intent.value + postEvent(EventBus.UP_CONFIG, arrayListOf(0, 2)) + } + + is ReadConfigIntent.PaddingDisplayCutoutsChanged -> { + readSettingsRepository.setPaddingDisplayCutouts(intent.value) + } + + is ReadConfigIntent.TitleBarModeChanged -> { + readSettingsRepository.setTitleBarMode(intent.value) + } + + is ReadConfigIntent.MenuAlphaChanged -> { + readSettingsRepository.setMenuAlpha(intent.value) + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + + is ReadConfigIntent.ReadBodyToLhChanged -> { + readSettingsRepository.setReadBodyToLh(intent.value) + } + + is ReadConfigIntent.DefaultSourceChangeAllChanged -> { + readSettingsRepository.setDefaultSourceChangeAll(intent.value) + } + + is ReadConfigIntent.TextFullJustifyChanged -> { + readSettingsRepository.setTextFullJustify(intent.value) + upLayout() + } + + is ReadConfigIntent.TextBottomJustifyChanged -> { + readSettingsRepository.setTextBottomJustify(intent.value) + upLayout() + } + + is ReadConfigIntent.AdaptSpecialStyleChanged -> { + readSettingsRepository.setAdaptSpecialStyle(intent.value) + } + + is ReadConfigIntent.UseZhLayoutChanged -> { + readSettingsRepository.setUseZhLayout(intent.value) + ReadBookConfig.useZhLayout = intent.value + upLayout() + } + + is ReadConfigIntent.ShowBrightnessViewChanged -> { + readSettingsRepository.setShowBrightnessView(intent.value) + } + + is ReadConfigIntent.UseUnderlineChanged -> { + readSettingsRepository.setUseUnderline(intent.value) + } + + is ReadConfigIntent.ReadSliderModeChanged -> { + readSettingsRepository.setReadSliderMode(intent.value) + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + + is ReadConfigIntent.DoubleHorizontalPageChanged -> { + readSettingsRepository.setDoubleHorizontalPage(intent.value) + upLayout() + } + + is ReadConfigIntent.ProgressBarBehaviorChanged -> { + readSettingsRepository.setProgressBarBehavior(intent.value) + postEvent(EventBus.UP_SEEK_BAR, true) + } + + is ReadConfigIntent.MouseWheelPageChanged -> { + readSettingsRepository.setMouseWheelPage(intent.value) + } + + is ReadConfigIntent.VolumeKeyPageChanged -> { + readSettingsRepository.setVolumeKeyPage(intent.value) + } + + is ReadConfigIntent.VolumeKeyPageOnPlayChanged -> { + readSettingsRepository.setVolumeKeyPageOnPlay(intent.value) + } + + is ReadConfigIntent.KeyPageOnLongPressChanged -> { + readSettingsRepository.setKeyPageOnLongPress(intent.value) + } + + is ReadConfigIntent.PageTouchSlopChanged -> { + readSettingsRepository.setPageTouchSlop(intent.value) + postEvent(EventBus.UP_CONFIG, arrayListOf(4)) + } + + is ReadConfigIntent.SliderVibratorChanged -> { + readSettingsRepository.setSliderVibrator(intent.value) + } + + is ReadConfigIntent.SelectVibratorChanged -> { + readSettingsRepository.setSelectVibrator(intent.value) + } + + is ReadConfigIntent.AutoChangeSourceChanged -> { + readSettingsRepository.setAutoChangeSource(intent.value) + } + + is ReadConfigIntent.SelectTextChanged -> { + readSettingsRepository.setSelectText(intent.value) + } + + is ReadConfigIntent.NoAnimScrollPageChanged -> { + readSettingsRepository.setNoAnimScrollPage(intent.value) + ReadBook.callBack?.upPageAnim() + } + + is ReadConfigIntent.ClickImgWayChanged -> { + readSettingsRepository.setClickImgWay(intent.value) + } + + is ReadConfigIntent.OptimizeRenderChanged -> { + readSettingsRepository.setOptimizeRender(intent.value) + upStyle() + } + + is ReadConfigIntent.DisableReturnKeyChanged -> { + readSettingsRepository.setDisableReturnKey(intent.value) + } + + is ReadConfigIntent.ExpandTextMenuChanged -> { + readSettingsRepository.setExpandTextMenu(intent.value) + } + + is ReadConfigIntent.ShowReadTitleAdditionChanged -> { + readSettingsRepository.setShowReadTitleAddition(intent.value) + postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + } + + is ReadConfigIntent.PageKeysChanged -> { + readSettingsRepository.setPageKeys(intent.prevKeys, intent.nextKeys) + } + } + } } - fun updateHideNavigationBar(hide: Boolean) { - ReadConfig.hideNavigationBar = hide - ReadBookConfig.hideNavigationBar = hide - postEvent(EventBus.UP_CONFIG, arrayListOf(0, 2)) - } - - fun upLayout() { + private fun upLayout() { ChapterProvider.upLayout() ReadBook.loadContent(false) } - fun upStyle() { + private fun upStyle() { ChapterProvider.upStyle() ReadBook.callBack?.upPageAnim(true) ReadBook.loadContent(false) } - fun upPageAnim() { - ReadBook.callBack?.upPageAnim() - } - - fun updateMenuAlpha(alpha: Int) { - ReadConfig.menuAlpha = alpha - postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) - } - - fun updatePageTouchSlop(slop: Int) { - ReadConfig.pageTouchSlop = slop - postEvent(EventBus.UP_CONFIG, arrayListOf(4)) - } - - fun updateReadSliderMode(mode: String) { - ReadConfig.readSliderMode = mode - postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) - } - - fun updateProgressBarBehavior(behavior: String) { - ReadConfig.progressBarBehavior = behavior - postEvent(EventBus.UP_SEEK_BAR, true) - } - - fun updateShowReadTitleAddition(show: Boolean) { - ReadConfig.showReadTitleAddition = show - postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) + private fun ReadPreferences.toUiState(): ReadConfigUiState { + return ReadConfigUiState( + screenOrientation = screenOrientation, + keepLight = keepLight, + hideStatusBar = hideStatusBar, + hideNavigationBar = hideNavigationBar, + paddingDisplayCutouts = paddingDisplayCutouts, + titleBarMode = titleBarMode, + menuAlpha = menuAlpha, + readBodyToLh = readBodyToLh, + defaultSourceChangeAll = defaultSourceChangeAll, + textFullJustify = textFullJustify, + textBottomJustify = textBottomJustify, + adaptSpecialStyle = adaptSpecialStyle, + useZhLayout = useZhLayout, + showBrightnessView = showBrightnessView, + useUnderline = useUnderline, + readSliderMode = readSliderMode, + doubleHorizontalPage = doubleHorizontalPage, + progressBarBehavior = progressBarBehavior, + mouseWheelPage = mouseWheelPage, + volumeKeyPage = volumeKeyPage, + volumeKeyPageOnPlay = volumeKeyPageOnPlay, + keyPageOnLongPress = keyPageOnLongPress, + pageTouchSlop = pageTouchSlop, + sliderVibrator = sliderVibrator, + selectVibrator = selectVibrator, + autoChangeSource = autoChangeSource, + selectText = selectText, + noAnimScrollPage = noAnimScrollPage, + clickImgWay = clickImgWay, + optimizeRender = optimizeRender, + disableReturnKey = disableReturnKey, + expandTextMenu = expandTextMenu, + showReadTitleAddition = showReadTitleAddition, + autoReadSpeed = autoReadSpeed, + prevKeys = prevKeys, + nextKeys = nextKeys + ) } } diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt index 2db476737..608506d6b 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt @@ -1,5 +1,6 @@ package io.legado.app.ui.config.themeConfig +import androidx.appcompat.app.AppCompatDelegate import io.legado.app.constant.EventBus import io.legado.app.constant.PreferKey import io.legado.app.ui.config.prefDelegate @@ -53,6 +54,14 @@ object ThemeConfig { var themeMode by prefDelegate(PreferKey.themeMode, "0") + fun initNightMode() { + when (appCtx.getPrefString(PreferKey.themeMode, "0")) { + "1" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + "2" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + } + } + var isPureBlack by prefDelegate(PreferKey.pureBlack, false) var bgImageLight by prefDelegate(PreferKey.bgImage, null) { diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt index e94c92e31..c5bfd718d 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt @@ -3,7 +3,6 @@ package io.legado.app.ui.config.themeConfig import android.annotation.SuppressLint import android.content.Context import android.content.Intent -import android.graphics.Typeface import android.os.Handler import android.os.Looper import android.widget.TextView @@ -30,8 +29,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -73,9 +70,9 @@ import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView import androidx.constraintlayout.compose.ConstraintLayout import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.legado.app.R import io.legado.app.base.AppContextWrapper @@ -83,7 +80,7 @@ import io.legado.app.constant.EventBus import io.legado.app.constant.PreferKey import io.legado.app.help.LauncherIconHelp import io.legado.app.help.config.AppConfig -import io.legado.app.help.config.OldThemeConfig +import io.legado.app.help.config.ThemeConfigStore import io.legado.app.help.loadFontFiles import io.legado.app.ui.config.labConfig.LabConfig import io.legado.app.ui.theme.LegadoTheme @@ -91,6 +88,7 @@ import io.legado.app.ui.theme.ThemeEngine import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.theme.adaptiveContentPadding import io.legado.app.ui.widget.components.AppScaffold +import io.legado.app.ui.widget.components.FontSelectSheet import io.legado.app.ui.widget.components.SplicedColumnGroup import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.button.series.SmallPlainButton @@ -98,7 +96,6 @@ import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.NormalCard import io.legado.app.ui.widget.components.dialog.ColorPickerSheet import io.legado.app.ui.widget.components.icon.AppIcons -import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem import io.legado.app.ui.widget.components.settingItem.SliderSettingItem @@ -143,10 +140,6 @@ fun ThemeConfigScreen( ) } - val fontItems = remember(fontFolderUri) { - loadFontFiles(context, fontFolderUri) - } - val fontFolderLauncher = rememberLauncherForActivityResult( ActivityResultContracts.OpenDocumentTree() ) { uri -> @@ -250,7 +243,7 @@ fun ThemeConfigScreen( onValueChange = { mode -> selectedThemeMode = mode ThemeConfig.themeMode = mode - OldThemeConfig.applyDayNight(context) + ThemeConfigStore.applyDayNight(context) } ) @@ -290,7 +283,7 @@ fun ThemeConfigScreen( onModeSelected = { mode -> selectedThemeMode = mode ThemeConfig.themeMode = mode - OldThemeConfig.applyDayNight(context) + ThemeConfigStore.applyDayNight(context) } ) } @@ -876,10 +869,24 @@ fun ThemeConfigScreen( } ) - AppModalBottomSheet( + val curName = remember(ThemeConfig.appFontPath) { + ThemeConfig.appFontPath?.let { uri -> + runCatching { + DocumentFile.fromSingleUri(context, uri.toUri())?.name + }.getOrNull() + } + } + + FontSelectSheet( show = showFontSheet, - onDismissRequest = { showFontSheet = false }, title = stringResource(R.string.font_setting), + fontFolderUri = fontFolderUri, + selectedFontName = curName, + onDismissRequest = { showFontSheet = false }, + onSelectFont = { doc -> + ThemeConfig.appFontPath = doc.uri.toString() + }, + onOpenFolderPicker = { fontFolderLauncher.launch(null) }, startAction = { SmallPlainButton( icon = Icons.Default.Delete, @@ -890,77 +897,9 @@ fun ThemeConfigScreen( } ) }, - endAction = { - SmallPlainButton( - icon = Icons.Default.Add, - contentDescription = stringResource(R.string.select_folder), - onClick = { fontFolderLauncher.launch(null) } - ) - }, - content = { - if (fontItems.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(120.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(R.string.theme_config_no_font_files), - style = MaterialTheme.typography.bodyLarge - ) - } - } else { - LazyVerticalGrid( - columns = GridCells.Fixed(2), - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - fontItems.forEach { fontDoc -> - item { - val textColor = LegadoTheme.colorScheme.onSurface.toArgb() - NormalCard( - modifier = Modifier - .fillMaxWidth() - .height(100.dp), - onClick = { - ThemeConfig.appFontPath = fontDoc.uri.toString() - showFontSheet = false - }, - containerColor = LegadoTheme.colorScheme.onSheetContent - ) { - AndroidView( - factory = { ctx -> - TextView(ctx).apply { - text = fontDoc.name - textSize = 14f - setTextColor(textColor) - gravity = android.view.Gravity.CENTER - maxLines = 2 - ellipsize = android.text.TextUtils.TruncateAt.END - runCatching { - val typeface: Typeface? = if (fontDoc.uri.scheme == "content") { - ctx.contentResolver.openFileDescriptor(fontDoc.uri, "r")?.use { - Typeface.Builder(it.fileDescriptor).build() - } - } else { - Typeface.createFromFile(fontDoc.uri.path!!) - } - this.typeface = typeface - } - } - }, - modifier = Modifier - .fillMaxSize() - .padding(12.dp) - ) - } - } - } - } - } - } + folderIcon = Icons.Default.Add, + folderContentDescription = stringResource(R.string.select_folder), + emptyText = stringResource(R.string.theme_config_no_font_files), ) } diff --git a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt index 34165cc65..199aa46f9 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt @@ -5,6 +5,9 @@ import android.content.Intent import android.content.res.Configuration import android.os.Bundle import android.text.format.DateUtils +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout @@ -38,11 +41,13 @@ import io.legado.app.lib.dialogs.alert import io.legado.app.service.WebService import io.legado.app.ui.about.CrashLogsDialog import io.legado.app.ui.about.UpdateDialog -import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.book.read.ReadBookInputHandler +import io.legado.app.ui.book.read.page.entities.PageDirection import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.welcome.WelcomeActivity import io.legado.app.ui.widget.dialog.TextDialog import io.legado.app.ui.widget.dialog.VariableDialog +import io.legado.app.utils.LogUtils import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.showDialogFragment import io.legado.app.utils.startActivity @@ -60,6 +65,9 @@ import kotlin.coroutines.suspendCoroutine open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { companion object { + @Volatile + var hasActiveReadBookRoute: Boolean = false + fun createLauncherIntent(context: Context): Intent = MainIntent.createLauncherIntent(context) @@ -91,6 +99,20 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { fun createBookCacheManageIntent(context: Context): Intent = MainIntent.createBookCacheManageIntent(context) + fun createReadBookIntent( + context: Context, + bookUrl: String? = null, + readAloud: Boolean = false, + inBookshelf: Boolean = true, + chapterChanged: Boolean = false, + ): Intent = MainIntent.createReadBookIntent( + context = context, + bookUrl = bookUrl, + readAloud = readAloud, + inBookshelf = inBookshelf, + chapterChanged = chapterChanged, + ) + fun createSearchIntent( context: Context, key: String? = null, @@ -118,6 +140,7 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { private val viewModel by viewModel() private val routeEvents = MutableSharedFlow(extraBufferCapacity = 1) private var bookInfoVariableSetter: ((String, String?) -> Unit)? = null + internal var activeReadBookInputHandler: ReadBookInputHandler? = null override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() @@ -251,7 +274,7 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { true } getPrefBoolean(PreferKey.defaultToRead) -> { - startActivity() + setIntent(createReadBookIntent(this)) false } else -> false @@ -343,6 +366,52 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { } } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + val keyCode = event.keyCode + val isDown = event.action == KeyEvent.ACTION_DOWN + if (keyCode == KeyEvent.KEYCODE_MENU && isDown) { + activeReadBookInputHandler?.toggleMenu() + if (activeReadBookInputHandler != null) return true + } + return super.dispatchKeyEvent(event) + } + + override fun onGenericMotionEvent(event: MotionEvent): Boolean { + val controller = activeReadBookInputHandler ?: return super.onGenericMotionEvent(event) + if (0 != (event.source and InputDevice.SOURCE_CLASS_POINTER) && + event.action == MotionEvent.ACTION_SCROLL + ) { + val axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL) + LogUtils.d("onGenericMotionEvent", "axisValue = $axisValue") + controller.mouseWheelPage( + if (axisValue < 0.0f) PageDirection.NEXT else PageDirection.PREV + ) + return true + } + if (0 != (event.source and InputDevice.SOURCE_CLASS_JOYSTICK) && + event.action == MotionEvent.ACTION_MOVE + ) { + val yAxis = event.getAxisValue(MotionEvent.AXIS_Y) + if (kotlin.math.abs(yAxis) > 0.5f) { + controller.handleKeyPage( + if (yAxis > 0) PageDirection.NEXT else PageDirection.PREV + ) + return true + } + } + return super.onGenericMotionEvent(event) + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (activeReadBookInputHandler?.onKeyDown(keyCode, event) == true) return true + return super.onKeyDown(keyCode, event) + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { + if (activeReadBookInputHandler?.onKeyUp(keyCode, event) == true) return true + return super.onKeyUp(keyCode, event) + } + override fun onDestroy() { super.onDestroy() Coroutine.async { diff --git a/app/src/main/java/io/legado/app/ui/main/MainIntent.kt b/app/src/main/java/io/legado/app/ui/main/MainIntent.kt index f14cabc33..7d9e8d156 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainIntent.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainIntent.kt @@ -14,6 +14,9 @@ object MainIntent { const val EXTRA_BOOK_URL = "bookUrl" const val EXTRA_BOOK_ORIGIN = "origin" const val EXTRA_BOOK_COVER = "coverPath" + const val EXTRA_READ_ALOUD = "readAloud" + const val EXTRA_IN_BOOKSHELF = "inBookshelf" + const val EXTRA_CHAPTER_CHANGED = "chapterChanged" const val EXTRA_EXPLORE_NAME = "exploreName" const val EXTRA_SOURCE_URL = "sourceUrl" const val EXTRA_EXPLORE_URL = "exploreUrl" @@ -100,6 +103,22 @@ object MainIntent { } } + fun createReadBookIntent( + context: Context, + bookUrl: String? = null, + readAloud: Boolean = false, + inBookshelf: Boolean = true, + chapterChanged: Boolean = false, + ): Intent { + return createLauncherIntent(context).apply { + putExtra(EXTRA_START_ROUTE, MainRouteConst.ROUTE_READ_BOOK) + bookUrl?.let { putExtra(EXTRA_BOOK_URL, it) } + putExtra(EXTRA_READ_ALOUD, readAloud) + putExtra(EXTRA_IN_BOOKSHELF, inBookshelf) + putExtra(EXTRA_CHAPTER_CHANGED, chapterChanged) + } + } + fun createSearchIntent( context: Context, key: String? = null, diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt index 2c26e0cb0..b1d2b2163 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt @@ -6,8 +6,13 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation3.runtime.NavKey @@ -27,6 +32,10 @@ import io.legado.app.ui.book.import.remote.RemoteBookScreen import io.legado.app.ui.book.info.BookInfoRouteScreen import io.legado.app.ui.book.info.BookInfoViewModel import io.legado.app.ui.book.manage.BookshelfManageRouteScreen +import io.legado.app.ui.book.read.ReadBookController +import io.legado.app.ui.book.read.ReadBookIntent +import io.legado.app.ui.book.read.ReadBookRouteScreen +import io.legado.app.ui.book.read.ReadBookViewModel import io.legado.app.ui.book.readRecord.ReadRecordOverviewScreen import io.legado.app.ui.book.readRecord.ReadRecordScreen import io.legado.app.ui.book.search.SearchIntent @@ -54,6 +63,7 @@ import io.legado.app.utils.openUrl import io.legado.app.utils.startActivity import io.legado.app.utils.startActivityForBook import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -245,6 +255,87 @@ fun MainActivity.mainEntryProvider( ) } + entry { route -> + val readBookViewModel = koinViewModel( + key = route.bookUrl ?: "last-read" + ) + val controller = remember(readBookViewModel) { + ReadBookController(this@mainEntryProvider, readBookViewModel) + } + val lifecycleOwner = LocalLifecycleOwner.current + val readIntent = remember(route) { + MainActivity.createReadBookIntent( + context = this@mainEntryProvider, + bookUrl = route.bookUrl, + readAloud = route.readAloud, + inBookshelf = route.inBookshelf, + chapterChanged = route.chapterChanged, + ) + } + val effectsReady = remember(readBookViewModel) { CompletableDeferred() } + val readerResumeState = remember(controller, lifecycleOwner) { booleanArrayOf(false) } + fun resumeReader() { + if (readerResumeState[0]) return + readerResumeState[0] = true + controller.onResume() + readBookViewModel.onIntent(ReadBookIntent.OnResume) + } + + fun pauseReader() { + if (!readerResumeState[0]) return + readerResumeState[0] = false + controller.onPause() + readBookViewModel.onIntent(ReadBookIntent.OnPause) + } + + ReadBookRouteScreen( + viewModel = readBookViewModel, + host = controller, + controller = controller, + onEffectsReady = { effectsReady.complete(Unit) }, + ) + + DisposableEffect(controller, lifecycleOwner, route.readAloud) { + activeReadBookInputHandler = controller + MainActivity.hasActiveReadBookRoute = true + controller.onClose = { onNavigateBack() } + controller.onStartContentLoadFinish = { + if (route.readAloud) { + io.legado.app.model.ReadBook.readAloud() + } + } + + val lifecycleObserver = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> resumeReader() + Lifecycle.Event.ON_PAUSE -> pauseReader() + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(lifecycleObserver) + onDispose { + pauseReader() + readBookViewModel.onIntent(ReadBookIntent.OnDispose) + lifecycleOwner.lifecycle.removeObserver(lifecycleObserver) + if (activeReadBookInputHandler === controller) { + activeReadBookInputHandler = null + } + MainActivity.hasActiveReadBookRoute = false + controller.clearTts() + } + } + + LaunchedEffect(route, readBookViewModel, lifecycleOwner) { + effectsReady.await() + readBookViewModel.initReadBookConfig(readIntent) + readBookViewModel.initData(readIntent) + controller.onRouteInitialized() + if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + resumeReader() + } + } + } + entry { route -> val searchViewModel = koinViewModel() @@ -428,6 +519,15 @@ fun MainActivity.mainEntryProvider( onOpenSearch = { keyword -> onNavigateToRoute(MainRouteSearch(key = keyword)) }, + onOpenReader = { bookUrl, inBookshelf, chapterChanged -> + onNavigateToRoute( + MainRouteReadBook( + bookUrl = bookUrl, + inBookshelf = inBookshelf, + chapterChanged = chapterChanged, + ) + ) + }, onNavigateToBookInfo = { name, author, bookUrl, origin, coverPath -> onNavigateToRoute(MainRouteBookInfo(name, author, bookUrl, origin, coverPath)) }, diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt b/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt index 0f1d6209e..4de1d10d4 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt @@ -60,6 +60,14 @@ data class MainRouteCache(val groupId: Long) : MainRoute @Serializable data object MainRouteBookCacheManage : MainRoute +@Serializable +data class MainRouteReadBook( + val bookUrl: String? = null, + val readAloud: Boolean = false, + val inBookshelf: Boolean = true, + val chapterChanged: Boolean = false, +) : MainRoute + @Serializable data class MainRouteSearch( val key: String?, @@ -108,6 +116,7 @@ object MainRouteConst { const val ROUTE_IMPORT_REMOTE = "import/remote" const val ROUTE_CACHE = "cache" const val ROUTE_BOOK_CACHE_MANAGE = "book/cache/manage" + const val ROUTE_READ_BOOK = "book/read" const val ROUTE_SEARCH = "search" const val ROUTE_BOOK_INFO = "book/info" const val ROUTE_EXPLORE_SHOW = "explore/show" diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt b/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt index fe2197638..6934f7b46 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt @@ -46,7 +46,8 @@ object MainNavigator { MainRouteImportLocal, MainRouteImportRemote, is MainRouteCache, - MainRouteBookCacheManage -> { + MainRouteBookCacheManage, + is MainRouteReadBook -> { if (currentRoute == MainRouteHome) { backStack.add(route) } else { @@ -245,6 +246,15 @@ object MainNavigator { ) MainRouteConst.ROUTE_BOOK_CACHE_MANAGE -> MainRouteBookCacheManage + MainRouteConst.ROUTE_READ_BOOK -> MainRouteReadBook( + bookUrl = intent?.getStringExtra(MainIntent.EXTRA_BOOK_URL), + readAloud = intent?.getBooleanExtra(MainIntent.EXTRA_READ_ALOUD, false) == true, + inBookshelf = intent?.getBooleanExtra(MainIntent.EXTRA_IN_BOOKSHELF, true) != false, + chapterChanged = intent?.getBooleanExtra( + MainIntent.EXTRA_CHAPTER_CHANGED, + false + ) == true, + ) MainRouteConst.ROUTE_SEARCH -> MainRouteSearch( key = intent?.getStringExtra(MainIntent.EXTRA_SEARCH_KEY), scopeRaw = intent?.getStringExtra(MainIntent.EXTRA_SEARCH_SCOPE) diff --git a/app/src/main/java/io/legado/app/ui/theme/AppTheme.kt b/app/src/main/java/io/legado/app/ui/theme/AppTheme.kt index 4083638a8..be8f007fd 100644 --- a/app/src/main/java/io/legado/app/ui/theme/AppTheme.kt +++ b/app/src/main/java/io/legado/app/ui/theme/AppTheme.kt @@ -19,6 +19,12 @@ fun AppTheme( // 1. 获取基础配置 val appThemeMode = ThemeResolver.resolveThemeMode(ThemeConfig.appTheme) + val themeModeValue = ThemeConfig.themeMode + val effectiveDarkTheme = when (themeModeValue) { + "1" -> false + "2" -> true + else -> darkTheme + } val isPureBlack = ThemeConfig.isPureBlack val paletteStyleValue = ThemeConfig.paletteStyle val materialVersion = ThemeConfig.materialVersion @@ -41,7 +47,7 @@ fun AppTheme( // 4. 解析配色方案 (Material 3 ColorScheme) val colorScheme = remember( - context, appThemeMode, darkTheme, isPureBlack, customPrimary, customNightPrimary, + context, appThemeMode, effectiveDarkTheme, isPureBlack, customPrimary, customNightPrimary, enableDeepPersonalization, themeColor, secondaryThemeColor, primaryTextColor, secondaryTextColor, themeBackgroundColor, customLabelContainerColor, paletteStyleValue, materialVersion @@ -57,13 +63,13 @@ fun AppTheme( secondaryFontColor = if (secondaryTextColor != 0) Color(secondaryTextColor) else Color(0xFF49454F), labelContainerColor = if (customLabelContainerColor != 0) Color(customLabelContainerColor) else Color(0xFFF7F2FA) ) - generateColorScheme(userPalette, darkTheme) + generateColorScheme(userPalette, effectiveDarkTheme) } else { - val customSeedColor = if (darkTheme) customNightPrimary else customPrimary + val customSeedColor = if (effectiveDarkTheme) customNightPrimary else customPrimary ThemeEngine.getColorScheme( context = context, mode = appThemeMode, - darkTheme = darkTheme, + darkTheme = effectiveDarkTheme, isAmoled = isPureBlack, paletteStyle = paletteStyleValue, materialVersion = materialVersion, @@ -73,9 +79,11 @@ fun AppTheme( } // 5. 确定种子颜色 - val themeSeedColor = remember(appThemeMode, colorScheme.primary) { + val themeSeedColor = remember( + appThemeMode, colorScheme.primary, effectiveDarkTheme, customPrimary, customNightPrimary + ) { if (appThemeMode == AppThemeMode.Custom) { - val seed = if (darkTheme) customNightPrimary else customPrimary + val seed = if (effectiveDarkTheme) customNightPrimary else customPrimary if (seed != 0) Color(seed) else colorScheme.primary } else { colorScheme.primary @@ -84,13 +92,14 @@ fun AppTheme( // 6. 构造 Legado 主题模式数据 val themeColors = remember( - colorScheme, darkTheme, themeSeedColor, paletteStyleValue, composeEngine, appThemeMode + colorScheme, effectiveDarkTheme, themeSeedColor, paletteStyleValue, composeEngine, + appThemeMode, themeModeValue ) { val paletteStyle = ThemeResolver.resolvePaletteStyle(paletteStyleValue) - val colorSchemeMode = ThemeResolver.resolveColorSchemeMode(ThemeConfig.themeMode) + val colorSchemeMode = ThemeResolver.resolveColorSchemeMode(themeModeValue) LegadoThemeMode( colorScheme = colorScheme, - isDark = darkTheme, + isDark = effectiveDarkTheme, seedColor = themeSeedColor, paletteStyle = paletteStyle, themeMode = colorSchemeMode, diff --git a/app/src/main/java/io/legado/app/ui/theme/LegadoTheme.kt b/app/src/main/java/io/legado/app/ui/theme/LegadoTheme.kt index af5a39cea..f99a34d69 100644 --- a/app/src/main/java/io/legado/app/ui/theme/LegadoTheme.kt +++ b/app/src/main/java/io/legado/app/ui/theme/LegadoTheme.kt @@ -5,7 +5,6 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import com.materialkolor.PaletteStyle @@ -110,15 +109,15 @@ data class LegadoTypography( ) -val LocalLegadoColorScheme = staticCompositionLocalOf { +val LocalLegadoColorScheme = compositionLocalOf { error("No ColorScheme provided") } -val LocalLegadoTypography = staticCompositionLocalOf { +val LocalLegadoTypography = compositionLocalOf { error("No Typography provided") } -val LocalLegadoThemeColors = staticCompositionLocalOf { +val LocalLegadoThemeColors = compositionLocalOf { LegadoThemeMode( colorScheme = lightColorScheme(), isDark = false, diff --git a/app/src/main/java/io/legado/app/ui/theme/ThemeComponents.kt b/app/src/main/java/io/legado/app/ui/theme/ThemeComponents.kt index 447476427..89a96760a 100644 --- a/app/src/main/java/io/legado/app/ui/theme/ThemeComponents.kt +++ b/app/src/main/java/io/legado/app/ui/theme/ThemeComponents.kt @@ -154,7 +154,7 @@ fun MiuixThemeWrapper( onErrorContainer = miuixColorScheme.onErrorContainer, outline = miuixColorScheme.outline, - outlineVariant = miuixColorScheme.dividerLine, + outlineVariant = miuixColorScheme.secondary.copy(alpha = 0.32f), scrim = miuixColorScheme.windowDimming, surfaceBright = miuixColorScheme.surface, diff --git a/app/src/main/java/io/legado/app/ui/theme/ThemeOverride.kt b/app/src/main/java/io/legado/app/ui/theme/ThemeOverride.kt index 24bf1c060..fecdec48c 100644 --- a/app/src/main/java/io/legado/app/ui/theme/ThemeOverride.kt +++ b/app/src/main/java/io/legado/app/ui/theme/ThemeOverride.kt @@ -67,16 +67,16 @@ fun ProvideThemeOverride( } val currentTheme = appliedTheme - ?: ThemeOverrideState( - seedColor = baseTheme.seedColor, - colorScheme = baseTheme.colorScheme - ) - ProvideColorSchemeOverride( - colorScheme = currentTheme.colorScheme, - seedColor = currentTheme.seedColor, - content = content - ) + if (currentTheme != null) { + ProvideColorSchemeOverride( + colorScheme = currentTheme.colorScheme, + seedColor = currentTheme.seedColor, + content = content + ) + } else { + content() + } } @Composable diff --git a/app/src/main/java/io/legado/app/ui/widget/components/AccentColorButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/AccentColorButton.kt new file mode 100644 index 000000000..8af9e9110 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/AccentColorButton.kt @@ -0,0 +1,45 @@ +package io.legado.app.ui.widget.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import io.legado.app.ui.theme.LegadoTheme + +/** + * A button that displays a color swatch. Clicking opens a color picker. + * + * @param color The current color value (ARGB int). + * @param onClick Called when the button is clicked. + * @param modifier Modifier for the button. + * @param enabled Whether the button is enabled. + */ +@Composable +fun AccentColorButton( + color: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Box( + modifier = modifier + .size(width = 36.dp, height = 36.dp) + .clip(RoundedCornerShape(32.dp)) + .background(Color(color)) + .border( + width = 1.dp, + color = LegadoTheme.colorScheme.outlineVariant, + shape = RoundedCornerShape(32.dp) + ) + .then( + if (enabled) Modifier.clickable(onClick = onClick) else Modifier + ) + ) +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/FontSelectGrid.kt b/app/src/main/java/io/legado/app/ui/widget/components/FontSelectGrid.kt new file mode 100644 index 000000000..cea27be18 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/FontSelectGrid.kt @@ -0,0 +1,187 @@ +package io.legado.app.ui.widget.components + +import android.graphics.Typeface +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import io.legado.app.R +import io.legado.app.help.loadFontFiles +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.utils.FileDoc +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Shared font selection grid with search support. + * + * @param fontFolderUri URI of the font folder to load from + * @param selectedFontName currently selected font name (for check mark), null to hide + * @param onSelectFont called when a font file is selected + * @param emptyText text to show when no fonts found + */ +@Composable +fun FontSelectGrid( + fontFolderUri: android.net.Uri?, + selectedFontName: String?, + onSelectFont: (FileDoc) -> Unit, + emptyText: String? = null, +) { + val context = LocalContext.current + var fontItems by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var searchQuery by remember { mutableStateOf("") } + + LaunchedEffect(fontFolderUri) { + isLoading = true + fontItems = withContext(Dispatchers.IO) { + loadFontFiles(context, fontFolderUri) + } + isLoading = false + } + + val filteredItems = remember(fontItems, searchQuery) { + if (searchQuery.isBlank()) fontItems + else fontItems.filter { it.name.contains(searchQuery, ignoreCase = true) } + } + + Column( + modifier = Modifier + .fillMaxWidth() + ) { + // Search + SearchBar( + query = searchQuery, + onQueryChange = { searchQuery = it }, + placeholder = stringResource(R.string.search_content), + autoFocus = false, + ) + + Spacer(Modifier.height(4.dp)) + + // Font grid + if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (filteredItems.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = emptyText ?: stringResource(R.string.empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + contentPadding = PaddingValues(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.height(360.dp), + ) { + items(filteredItems, key = { it.name }) { item -> + FontItem( + item = item, + isSelected = item.name == selectedFontName, + onClick = { onSelectFont(item) }, + ) + } + } + } + } +} + +@Composable +private fun FontItem( + item: FileDoc, + isSelected: Boolean, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(LegadoTheme.colorScheme.surfaceVariant) + .clickable(onClick = onClick) + .padding(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + val context = LocalContext.current + AndroidView( + factory = { ctx -> + android.widget.TextView(ctx).apply { + text = item.name + textSize = 14f + gravity = android.view.Gravity.CENTER + maxLines = 2 + ellipsize = android.text.TextUtils.TruncateAt.END + runCatching { + val uri = item.uri + val typeface: Typeface? = if (uri.scheme == "content") { + ctx.contentResolver.openFileDescriptor(uri, "r")?.use { + Typeface.Builder(it.fileDescriptor).build() + } + } else { + uri.path?.let { Typeface.createFromFile(it) } + } + this.typeface = typeface + } + } + }, + modifier = Modifier.weight(1f), + ) + if (isSelected) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/FontSelectSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/FontSelectSheet.kt new file mode 100644 index 000000000..7f59b4f31 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/FontSelectSheet.kt @@ -0,0 +1,84 @@ +package io.legado.app.ui.widget.components + +import android.net.Uri +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.vector.ImageVector +import io.legado.app.ui.widget.components.button.series.SmallPlainButton +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.utils.FileDoc + +@Composable +fun FontSelectSheet( + show: Boolean = true, + title: String, + fontFolderUri: Uri?, + selectedFontName: String?, + onDismissRequest: () -> Unit, + onSelectFont: (FileDoc) -> Unit, + onOpenFolderPicker: () -> Unit, + startAction: (@Composable () -> Unit)? = null, + folderIcon: ImageVector = Icons.Default.FolderOpen, + folderContentDescription: String? = null, + onSelectSystemTypeface: ((Int) -> Unit)? = null, + systemTypefaces: Array? = null, + emptyText: String? = null, +) { + var showTypefaceMenu by remember { mutableStateOf(false) } + + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = title, + startAction = { + startAction?.invoke() + if (systemTypefaces != null && onSelectSystemTypeface != null) { + DropdownMenu( + expanded = showTypefaceMenu, + onDismissRequest = { showTypefaceMenu = false }, + ) { + systemTypefaces.forEachIndexed { index, name -> + DropdownMenuItem( + text = { Text(name) }, + onClick = { + onSelectSystemTypeface(index) + showTypefaceMenu = false + onDismissRequest() + }, + ) + } + } + SmallPlainButton( + onClick = { showTypefaceMenu = true }, + icon = Icons.Default.TextFields, + ) + } + }, + endAction = { + SmallPlainButton( + onClick = onOpenFolderPicker, + icon = folderIcon, + contentDescription = folderContentDescription, + ) + }, + ) { + FontSelectGrid( + fontFolderUri = fontFolderUri, + selectedFontName = selectedFontName, + onSelectFont = { doc -> + onSelectFont(doc) + onDismissRequest() + }, + emptyText = emptyText, + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/IconSwitch.kt b/app/src/main/java/io/legado/app/ui/widget/components/IconSwitch.kt index c34eb4fef..850347698 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/IconSwitch.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/IconSwitch.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.SwitchColors import androidx.compose.material3.SwitchDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.vector.ImageVector import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.ThemeResolver @@ -35,6 +36,39 @@ fun AdaptiveSwitch( ) } else { IconSwitch( + modifier = modifier, + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + checkedIcon = checkedIcon, + uncheckedIcon = uncheckedIcon, + showIcon = showIcon + ) + } +} + +@Composable +fun TinySwitch( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + checkedIcon: ImageVector = Icons.Filled.Check, + uncheckedIcon: ImageVector? = null, + showIcon: Boolean = true +) { + val composeEngine = LegadoTheme.composeEngine + + if (ThemeResolver.isMiuixEngine(composeEngine)) { + MiuixSwitch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = Modifier.scale(0.9f), + enabled = enabled + ) + } else { + IconSwitch( + modifier = Modifier.scale(0.8f), checked = checked, onCheckedChange = onCheckedChange, enabled = enabled, @@ -47,6 +81,7 @@ fun AdaptiveSwitch( @Composable fun IconSwitch( + modifier: Modifier, checked: Boolean, onCheckedChange: (Boolean) -> Unit, enabled: Boolean = true, @@ -56,6 +91,7 @@ fun IconSwitch( colors: SwitchColors = SwitchDefaults.colors() ) { Switch( + modifier = modifier, checked = checked, onCheckedChange = onCheckedChange, enabled = enabled, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/SectionTitle.kt b/app/src/main/java/io/legado/app/ui/widget/components/SectionTitle.kt new file mode 100644 index 000000000..25932ee14 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/SectionTitle.kt @@ -0,0 +1,22 @@ +package io.legado.app.ui.widget.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun SectionTitle(title: String) { + Text( + text = title, + style = MaterialTheme.typography.labelMediumEmphasized, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + textAlign = TextAlign.Center, + ) +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/ValueStepper.kt b/app/src/main/java/io/legado/app/ui/widget/components/ValueStepper.kt index ad02da8e8..c734493ef 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/ValueStepper.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/ValueStepper.kt @@ -19,26 +19,28 @@ fun ValueStepper( displayValue: Float, valueRange: ClosedFloatingPointRange, onValueChange: (Float) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + enabled: Boolean = true, ) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(6.dp) ) { SmallOutlinedButton( onClick = { val newValue = (value.toInt() - 1).toFloat().coerceIn(valueRange) onValueChange(newValue) }, - icon = Icons.Default.Remove + enabled = enabled, + icon = Icons.Default.Remove, ) TextCard( cornerRadius = 8.dp, horizontalPadding = 8.dp, verticalPadding = 4.dp, text = displayValue.toInt().toString(), - backgroundColor = LegadoTheme.colorScheme.surfaceContainer, + backgroundColor = LegadoTheme.colorScheme.surfaceContainerHigh, contentColor = LegadoTheme.colorScheme.onSurface ) SmallOutlinedButton( @@ -46,7 +48,8 @@ fun ValueStepper( val newValue = (value.toInt() + 1).toFloat().coerceIn(valueRange) onValueChange(newValue) }, - icon = Icons.Default.Add + enabled = enabled, + icon = Icons.Default.Add, ) } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt b/app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt index 11fb1bc5f..ed344308e 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt @@ -95,7 +95,7 @@ fun AppAlertDialog( AlertDialog( onDismissRequest = onDismissRequest, modifier = modifier, - containerColor = LegadoTheme.colorScheme.surfaceContainerHigh, + containerColor = LegadoTheme.colorScheme.surfaceContainer, iconContentColor = LegadoTheme.colorScheme.primary, titleContentColor = LegadoTheme.colorScheme.onSurface, textContentColor = LegadoTheme.colorScheme.onSurfaceVariant, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/bookmark/BookmarkEditSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/bookmark/BookmarkEditSheet.kt index ca990e0c0..5f09c54d2 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/bookmark/BookmarkEditSheet.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/bookmark/BookmarkEditSheet.kt @@ -32,66 +32,78 @@ fun BookmarkEditSheet( onSave: (Bookmark) -> Unit, onDelete: (Bookmark) -> Unit ) { - - var showDeleteConfirmDialog by remember { mutableStateOf(false) } - var bookText by remember(bookmark) { mutableStateOf(bookmark.bookText) } - var content by remember(bookmark) { mutableStateOf(bookmark.content) } - AppModalBottomSheet( show = show, onDismissRequest = onDismiss, title = bookmark.chapterName, ) { - Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() + BookmarkEditContent( + bookmark = bookmark, + onSave = onSave, + onDelete = onDelete, + ) + } +} + +@Composable +fun BookmarkEditContent( + bookmark: Bookmark, + onSave: (Bookmark) -> Unit, + onDelete: (Bookmark) -> Unit +) { + var showDeleteConfirmDialog by remember { mutableStateOf(false) } + var bookText by remember(bookmark) { mutableStateOf(bookmark.bookText) } + var content by remember(bookmark) { mutableStateOf(bookmark.content) } + + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + ) { + AppTextFieldSurface( + value = bookText, + onValueChange = { bookText = it }, + label = "原文", + modifier = Modifier.fillMaxWidth(), + maxLines = 10 + ) + + Spacer(modifier = Modifier.height(12.dp)) + + AppTextFieldSurface( + value = content, + onValueChange = { content = it }, + label = "摘要/笔记", + modifier = Modifier.fillMaxWidth(), + maxLines = 5 + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically ) { - AppTextFieldSurface( - value = bookText, - onValueChange = { bookText = it }, - label = "原文", - modifier = Modifier.fillMaxWidth(), - maxLines = 10 + SecondaryButton( + onClick = { showDeleteConfirmDialog = true }, + modifier = Modifier.weight(1f), + text = "删除" ) - Spacer(modifier = Modifier.height(12.dp)) - - AppTextFieldSurface( - value = content, - onValueChange = { content = it }, - label = "摘要/笔记", - modifier = Modifier.fillMaxWidth(), - maxLines = 5 + PrimaryButton( + onClick = { + val newBookmark = bookmark.apply { + this.bookText = bookText + this.content = content + } + onSave(newBookmark) + }, + modifier = Modifier.weight(1f), + text = "保存" ) - - Spacer(modifier = Modifier.height(24.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - SecondaryButton( - onClick = { showDeleteConfirmDialog = true }, - modifier = Modifier.weight(1f), - text = "删除" - ) - - PrimaryButton( - onClick = { - val newBookmark = bookmark.apply { - this.bookText = bookText - this.content = content - } - onSave(newBookmark) - }, - modifier = Modifier.weight(1f), - text = "保存" - ) - } - Spacer(modifier = Modifier.height(16.dp)) } + Spacer(modifier = Modifier.height(16.dp)) } AppAlertDialog( diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumAnimatedButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumAnimatedButton.kt index 4ba6e5f3f..ca9cc8775 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumAnimatedButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumAnimatedButton.kt @@ -1,173 +1,63 @@ package io.legado.app.ui.widget.components.button.series -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.material3.TonalToggleButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import io.legado.app.ui.theme.LegadoTheme -import io.legado.app.ui.theme.LegadoTheme.composeEngine -import io.legado.app.ui.theme.ThemeResolver -import top.yukonga.miuix.kmp.theme.MiuixTheme -import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon -import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton -import top.yukonga.miuix.kmp.basic.Text as MiuixText +import kotlinx.coroutines.delay -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun MediumAnimatedButton( checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, enabled: Boolean = true, icon: ImageVector? = null, iconChecked: ImageVector? = null, text: String? = null, contentDescription: String? = null ) { - if (ThemeResolver.isMiuixEngine(composeEngine)) { - val containerColor by animateColorAsState( - targetValue = if (checked) MiuixTheme.colorScheme.primaryContainer else MiuixTheme.colorScheme.surfaceContainer, - animationSpec = tween(150), - label = "MiuixAnimatedContainerColor" - ) + var showText by remember { mutableStateOf(false) } + val currentIcon = if (checked) (iconChecked ?: icon)!! else icon!! - val contentColor by animateColorAsState( - targetValue = if (checked) MiuixTheme.colorScheme.onPrimaryContainer else MiuixTheme.colorScheme.onSurface, - animationSpec = tween(150), - label = "MiuixAnimatedContentColor" - ) - - if (text != null) { - AnimatedActionButtonCore( - checked = checked, - onCheckedChange = onCheckedChange, - iconChecked = iconChecked ?: icon!!, - iconUnchecked = icon ?: iconChecked!!, - activeText = text, - inactiveText = text, - modifier = modifier, - iconSize = 24.dp, - textStyle = LegadoTheme.typography.labelMedium, - textStartPadding = 8.dp, - contentColor = contentColor, - button = { buttonModifier, onToggle, content -> - MiuixIconButton( - onClick = { onToggle(!checked) }, - modifier = buttonModifier, - backgroundColor = containerColor - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier.padding(horizontal = 8.dp), - content = content - ) - } - }, - icon = { imageVector, iconModifier, tint -> - MiuixIcon( - tint = tint ?: Color.Unspecified, - imageVector = imageVector, - contentDescription = null, - modifier = iconModifier - ) - }, - text = { label, textModifier, textStyle, color -> - MiuixText( - text = label, - color = color ?: Color.Unspecified, - style = textStyle, - modifier = textModifier, - maxLines = 1, - softWrap = false - ) - } - ) - } else { - MiuixIconButton( - onClick = { onCheckedChange(!checked) }, - modifier = modifier, - enabled = enabled, - backgroundColor = containerColor - ) { - MiuixIcon( - imageVector = if (checked) (iconChecked ?: icon)!! else icon!!, - contentDescription = contentDescription, - tint = contentColor - ) - } - } - } else { - if (text != null) { - AnimatedActionButtonCore( - checked = checked, - onCheckedChange = onCheckedChange, - iconChecked = iconChecked ?: icon!!, - iconUnchecked = icon ?: iconChecked!!, - activeText = text, - inactiveText = text, - modifier = modifier.height(36.dp), - iconSize = 20.dp, - textStyle = LegadoTheme.typography.labelMedium, - textStartPadding = 8.dp, - button = { buttonModifier, onToggle, content -> - TonalToggleButton( - modifier = buttonModifier, - contentPadding = PaddingValues(horizontal = 8.dp), - checked = checked, - onCheckedChange = onToggle - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - content = content - ) - } - }, - icon = { imageVector, iconModifier, _ -> - AnimatedIcon( - imageVector = imageVector, - contentDescription = null, - modifier = iconModifier - ) - }, - text = { label, textModifier, textStyle, color -> - Text( - text = label, - style = textStyle, - color = color ?: Color.Unspecified, - modifier = textModifier, - maxLines = 1, - softWrap = false - ) - } - ) - } else { - FilledTonalIconButton( - onClick = { onCheckedChange(!checked) }, - modifier = modifier, - enabled = enabled, - ) { - Icon( - imageVector = if (checked) (iconChecked ?: icon)!! else icon!!, - contentDescription = contentDescription, - ) - } + LaunchedEffect(showText) { + if (showText) { + delay(1000) + showText = false } } + + SeriesButton( + onClick = { + onCheckedChange(!checked) + showText = true + }, + modifier = if (text == null) modifier else modifier.height(36.dp), + enabled = enabled, + selected = checked, + onLongClick = onLongClick, + size = if (text == null) MediumSeriesIconButtonSize else null, + style = SeriesIconButtonStyle.Tonal + ) { contentColor -> + SeriesAnimatedButtonContent( + icon = currentIcon, + text = text, + contentDescription = if (text == null) contentDescription else null, + showText = showText, + iconSize = MediumSeriesIconSize, + textStyle = LegadoTheme.typography.labelMedium, + contentColor = contentColor, + padding = PaddingValues(horizontal = 8.dp), + spacing = 8.dp + ) + } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumOutlinedButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumOutlinedButton.kt index 827ba55e5..1b4a48d54 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumOutlinedButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumOutlinedButton.kt @@ -1,78 +1,41 @@ package io.legado.app.ui.widget.components.button.series -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedIconButton +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp import io.legado.app.ui.theme.LegadoTheme -import io.legado.app.ui.theme.LegadoTheme.composeEngine -import io.legado.app.ui.theme.ThemeResolver -import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.CardDefaults -import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon -import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton @Composable fun MediumOutlinedButton( onClick: () -> Unit, modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, enabled: Boolean = true, + selected: Boolean = false, icon: ImageVector? = null, text: String? = null, contentDescription: String? = null ) { - if (ThemeResolver.isMiuixEngine(composeEngine)) { - if (icon != null && text == null) { - MiuixIconButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - backgroundColor = LegadoTheme.colorScheme.surfaceContainerHigh - ) { - MiuixIcon( - imageVector = icon, - contentDescription = contentDescription - ) - } - } else { - Card( - onClick = onClick, - modifier = modifier, - showIndication = true, - colors = CardDefaults.defaultColors( - color = LegadoTheme.colorScheme.surfaceContainerHigh, - contentColor = LegadoTheme.colorScheme.onSurfaceVariant - ) - ) { - MediumButtonContent(icon, text, contentDescription) - } - } - } else { - if (icon != null && text == null) { - OutlinedIconButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - border = ButtonDefaults.outlinedButtonBorder() - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = LegadoTheme.colorScheme.onSurface - ) - } - } else { - OutlinedButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - border = ButtonDefaults.outlinedButtonBorder() - ) { - MediumButtonContent(icon, text, contentDescription) - } - } + SeriesButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + selected = selected, + onLongClick = onLongClick, + size = if (text == null) MediumSeriesIconButtonSize else null, + style = SeriesIconButtonStyle.Outlined + ) { contentColor -> + SeriesButtonContent( + icon = icon, + text = text, + contentDescription = contentDescription, + iconSize = MediumSeriesIconSize, + textStyle = LegadoTheme.typography.labelMedium, + contentColor = contentColor, + padding = PaddingValues(horizontal = 16.dp, vertical = 10.dp), + spacing = 8.dp + ) } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumPlainButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumPlainButton.kt index 946878215..7d1a8139e 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumPlainButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumPlainButton.kt @@ -1,121 +1,42 @@ package io.legado.app.ui.widget.components.button.series -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import io.legado.app.ui.theme.LegadoTheme -import io.legado.app.ui.theme.LegadoTheme.composeEngine -import io.legado.app.ui.theme.ThemeResolver -import io.legado.app.ui.widget.components.text.AppText -import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.CardDefaults -import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon -import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton -import top.yukonga.miuix.kmp.basic.Text as MiuixText - -@Composable -internal fun MediumButtonContent( - icon: ImageVector?, - text: String?, - contentDescription: String? -) { - val isMiuix = ThemeResolver.isMiuixEngine(composeEngine) - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically - ) { - if (icon != null) { - if (isMiuix) { - MiuixIcon( - imageVector = icon, - contentDescription = contentDescription - ) - } else { - Icon( - imageVector = icon, - contentDescription = contentDescription - ) - } - } - if (text != null) { - if (isMiuix) { - MiuixText(text = text) - } else { - AppText(text = text) - } - } - } -} @Composable fun MediumPlainButton( onClick: () -> Unit, modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, enabled: Boolean = true, + selected: Boolean = false, icon: ImageVector? = null, text: String? = null, tint: androidx.compose.ui.graphics.Color = LegadoTheme.colorScheme.onSurface, contentDescription: String? = null ) { - if (ThemeResolver.isMiuixEngine(composeEngine)) { - if (icon != null && text == null) { - MiuixIconButton( - onClick = onClick, - modifier = modifier, - enabled = enabled - ) { - MiuixIcon( - imageVector = icon, - contentDescription = contentDescription, - tint = tint - ) - } - } else { - Card( - onClick = onClick, - modifier = modifier, - showIndication = true, - colors = CardDefaults.defaultColors( - color = LegadoTheme.colorScheme.surfaceVariant, - contentColor = LegadoTheme.colorScheme.onSurfaceVariant - ) - ) { - MediumButtonContent(icon, text, contentDescription) - } - } - } else { - if (icon != null && text == null) { - IconButton( - onClick = onClick, - modifier = modifier, - enabled = enabled - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = tint - ) - } - } else { - Card( - onClick = onClick, - modifier = modifier, - showIndication = true, - colors = CardDefaults.defaultColors( - color = LegadoTheme.colorScheme.surfaceVariant, - contentColor = LegadoTheme.colorScheme.onSurfaceVariant - ) - ) { - MediumButtonContent(icon, text, contentDescription) - } - } + SeriesButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + selected = selected, + onLongClick = onLongClick, + size = if (text == null) MediumSeriesIconButtonSize else null, + contentColor = tint + ) { contentColor -> + SeriesButtonContent( + icon = icon, + text = text, + contentDescription = contentDescription, + iconSize = MediumSeriesIconSize, + textStyle = LegadoTheme.typography.labelMedium, + contentColor = contentColor, + padding = PaddingValues(horizontal = 16.dp, vertical = 10.dp), + spacing = 8.dp + ) } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumToggleButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumToggleButton.kt index be39c468f..76c2308ee 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumToggleButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumToggleButton.kt @@ -7,8 +7,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.ToggleButton @@ -32,6 +30,7 @@ fun MediumToggleButton( checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, enabled: Boolean = true, style: ToggleStyle = ToggleStyle.Outlined, icon: ImageVector? = null, @@ -80,18 +79,23 @@ fun MediumToggleButton( } } } else { - MiuixIconButton( + SeriesIconButton( + icon = if (checked) (iconChecked ?: icon)!! else icon!!, + contentDescription = contentDescription, onClick = { onCheckedChange(!checked) }, modifier = modifier, enabled = enabled, - backgroundColor = containerColor - ) { - MiuixIcon( - imageVector = if (checked) (iconChecked ?: icon)!! else icon!!, - contentDescription = contentDescription, - tint = contentColor - ) - } + selected = checked, + onLongClick = onLongClick, + size = MediumSeriesIconButtonSize, + iconSize = MediumSeriesIconSize, + style = when (style) { + ToggleStyle.Outlined -> SeriesIconButtonStyle.Outlined + ToggleStyle.Tonal -> SeriesIconButtonStyle.Tonal + }, + selectedContainerColor = containerColor, + selectedContentColor = contentColor + ) } } else { if (text != null) { @@ -120,33 +124,21 @@ fun MediumToggleButton( } } } else { - when (style) { - ToggleStyle.Outlined -> { - FilledTonalButton( - onClick = { onCheckedChange(!checked) }, - modifier = modifier, - enabled = enabled, - ) { - Icon( - imageVector = if (checked) (iconChecked ?: icon)!! else icon!!, - contentDescription = contentDescription, - ) - } + SeriesIconButton( + icon = if (checked) (iconChecked ?: icon)!! else icon!!, + contentDescription = contentDescription, + onClick = { onCheckedChange(!checked) }, + modifier = modifier, + enabled = enabled, + selected = checked, + onLongClick = onLongClick, + size = MediumSeriesIconButtonSize, + iconSize = MediumSeriesIconSize, + style = when (style) { + ToggleStyle.Outlined -> SeriesIconButtonStyle.Outlined + ToggleStyle.Tonal -> SeriesIconButtonStyle.Tonal } - - ToggleStyle.Tonal -> { - FilledTonalIconButton( - onClick = { onCheckedChange(!checked) }, - modifier = modifier, - enabled = enabled, - ) { - Icon( - imageVector = if (checked) (iconChecked ?: icon)!! else icon!!, - contentDescription = contentDescription, - ) - } - } - } + ) } } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumTonalButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumTonalButton.kt index 24d7bac4a..31f8b1acc 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumTonalButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumTonalButton.kt @@ -1,74 +1,41 @@ package io.legado.app.ui.widget.components.button.series -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp import io.legado.app.ui.theme.LegadoTheme -import io.legado.app.ui.theme.LegadoTheme.composeEngine -import io.legado.app.ui.theme.ThemeResolver -import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.CardDefaults -import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon -import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton @Composable fun MediumTonalButton( onClick: () -> Unit, modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, enabled: Boolean = true, + selected: Boolean = false, icon: ImageVector? = null, text: String? = null, contentDescription: String? = null ) { - if (ThemeResolver.isMiuixEngine(composeEngine)) { - if (icon != null && text == null) { - MiuixIconButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - backgroundColor = LegadoTheme.colorScheme.surfaceContainer - ) { - MiuixIcon( - imageVector = icon, - contentDescription = contentDescription - ) - } - } else { - Card( - onClick = onClick, - modifier = modifier, - showIndication = true, - colors = CardDefaults.defaultColors( - color = LegadoTheme.colorScheme.surfaceContainer, - contentColor = LegadoTheme.colorScheme.onSurfaceVariant - ) - ) { - MediumButtonContent(icon, text, contentDescription) - } - } - } else { - if (icon != null && text == null) { - FilledTonalIconButton( - onClick = onClick, - modifier = modifier, - enabled = enabled - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription - ) - } - } else { - FilledTonalButton( - onClick = onClick, - modifier = modifier, - enabled = enabled - ) { - MediumButtonContent(icon, text, contentDescription) - } - } + SeriesButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + selected = selected, + onLongClick = onLongClick, + size = if (text == null) MediumSeriesIconButtonSize else null, + style = SeriesIconButtonStyle.Tonal + ) { contentColor -> + SeriesButtonContent( + icon = icon, + text = text, + contentDescription = contentDescription, + iconSize = MediumSeriesIconSize, + textStyle = LegadoTheme.typography.labelMedium, + contentColor = contentColor, + padding = PaddingValues(horizontal = 16.dp, vertical = 10.dp), + spacing = 8.dp + ) } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/SeriesIconButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/SeriesIconButton.kt new file mode 100644 index 000000000..1e0bac412 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/series/SeriesIconButton.kt @@ -0,0 +1,315 @@ +package io.legado.app.ui.widget.components.button.series + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.LegadoTheme.composeEngine +import io.legado.app.ui.theme.ThemeResolver +import io.legado.app.ui.widget.components.text.AppText +import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon +import top.yukonga.miuix.kmp.basic.Text as MiuixText + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +internal val SeriesIconSize: Dp + get() = IconButtonDefaults.mediumIconSize +internal val MediumSeriesIconButtonSize = DpSize(40.dp, 40.dp) +internal val MediumSeriesIconSize = SeriesIconSize + +internal enum class SeriesIconButtonStyle { + Plain, + Tonal, + Outlined +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun SeriesButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + selected: Boolean = false, + onLongClick: (() -> Unit)? = null, + size: DpSize? = null, + style: SeriesIconButtonStyle = SeriesIconButtonStyle.Plain, + contentColor: Color = LegadoTheme.colorScheme.onSurfaceVariant, + containerColor: Color? = null, + selectedContainerColor: Color = LegadoTheme.colorScheme.primaryContainer, + selectedContentColor: Color = LegadoTheme.colorScheme.onPrimaryContainer, + content: @Composable (Color) -> Unit +) { + val containerColor by animateColorAsState( + targetValue = when { + !enabled -> disabledContainerColor(style) + selected -> selectedContainerColor + else -> containerColor ?: containerColor(style) + }, + animationSpec = tween(150), + label = "SeriesIconContainerColor" + ) + val resolvedContentColor by animateColorAsState( + targetValue = when { + !enabled -> disabledContentColor(contentColor) + selected -> selectedContentColor + else -> contentColor + }, + animationSpec = tween(150), + label = "SeriesIconContentColor" + ) + val shape = IconButtonDefaults.extraSmallRoundShape + val border = borderStroke(style, enabled) + val interactionSource = remember { MutableInteractionSource() } + + Box( + modifier = modifier + .then(if (size != null) Modifier.size(size) else Modifier) + .clip(shape) + .background(containerColor, shape) + .then(if (border != null) Modifier.border(border, shape) else Modifier) + .combinedClickable( + interactionSource = interactionSource, + indication = ripple(bounded = true), + enabled = enabled, + role = Role.Button, + onLongClick = onLongClick, + onClick = onClick + ) + .semantics { + if (selected) { + this.selected = true + } + }, + contentAlignment = Alignment.Center + ) { + content(resolvedContentColor) + } +} + +@Composable +internal fun SeriesIconButton( + icon: ImageVector, + contentDescription: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + selected: Boolean = false, + onLongClick: (() -> Unit)? = null, + size: DpSize, + iconSize: Dp, + style: SeriesIconButtonStyle = SeriesIconButtonStyle.Plain, + contentColor: Color = LegadoTheme.colorScheme.onSurfaceVariant, + containerColor: Color? = null, + selectedContainerColor: Color = LegadoTheme.colorScheme.primaryContainer, + selectedContentColor: Color = LegadoTheme.colorScheme.onPrimaryContainer, +) { + SeriesButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + selected = selected, + onLongClick = onLongClick, + size = size, + style = style, + contentColor = contentColor, + containerColor = containerColor, + selectedContainerColor = selectedContainerColor, + selectedContentColor = selectedContentColor + ) { resolvedContentColor -> + SeriesIcon( + icon = icon, + contentDescription = contentDescription, + tint = resolvedContentColor, + modifier = Modifier.size(iconSize) + ) + } +} + +@Composable +internal fun SeriesButtonContent( + icon: ImageVector?, + text: String?, + contentDescription: String?, + iconSize: Dp, + textStyle: TextStyle, + contentColor: Color, + padding: PaddingValues, + spacing: Dp +) { + val hasText = text != null + Row( + modifier = Modifier.padding(if (hasText) padding else PaddingValues(0.dp)), + horizontalArrangement = Arrangement.spacedBy( + if (hasText) spacing else 0.dp, + Alignment.CenterHorizontally + ), + verticalAlignment = Alignment.CenterVertically + ) { + if (icon != null) { + SeriesIcon( + icon = icon, + contentDescription = contentDescription, + tint = contentColor, + modifier = Modifier.size(iconSize) + ) + } + if (text != null) { + if (ThemeResolver.isMiuixEngine(composeEngine)) { + MiuixText( + text = text, + style = textStyle, + color = contentColor + ) + } else { + AppText( + text = text, + style = textStyle, + color = contentColor + ) + } + } + } +} + +@Composable +internal fun SeriesAnimatedButtonContent( + icon: ImageVector, + text: String?, + contentDescription: String?, + showText: Boolean, + iconSize: Dp, + textStyle: TextStyle, + contentColor: Color, + padding: PaddingValues, + spacing: Dp +) { + val hasText = text != null + Row( + modifier = Modifier.padding(if (hasText) padding else PaddingValues(0.dp)), + horizontalArrangement = Arrangement.spacedBy( + if (hasText) spacing else 0.dp, + Alignment.CenterHorizontally + ), + verticalAlignment = Alignment.CenterVertically + ) { + SeriesIcon( + icon = icon, + contentDescription = contentDescription, + tint = contentColor, + modifier = Modifier.size(iconSize) + ) + AnimatedVisibility(visible = showText && text != null) { + if (text != null) { + if (ThemeResolver.isMiuixEngine(composeEngine)) { + MiuixText( + text = text, + style = textStyle, + color = contentColor, + maxLines = 1, + softWrap = false + ) + } else { + AppText( + text = text, + style = textStyle, + color = contentColor, + maxLines = 1, + softWrap = false + ) + } + } + } + } +} + +@Composable +private fun SeriesIcon( + icon: ImageVector, + contentDescription: String?, + tint: Color, + modifier: Modifier +) { + if (ThemeResolver.isMiuixEngine(composeEngine)) { + MiuixIcon( + imageVector = icon, + contentDescription = contentDescription, + tint = tint, + modifier = modifier + ) + } else { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = tint, + modifier = modifier + ) + } +} + +internal fun squareSize(size: Dp) = DpSize(size, size) + +@Composable +private fun containerColor(style: SeriesIconButtonStyle): Color { + return when (style) { + SeriesIconButtonStyle.Plain -> Color.Transparent + SeriesIconButtonStyle.Tonal, + SeriesIconButtonStyle.Outlined -> LegadoTheme.colorScheme.surfaceContainerLow + } +} + +@Composable +private fun disabledContainerColor(style: SeriesIconButtonStyle): Color { + return when (style) { + SeriesIconButtonStyle.Plain -> Color.Transparent + SeriesIconButtonStyle.Tonal, + SeriesIconButtonStyle.Outlined -> LegadoTheme.colorScheme.outlineVariant + } +} + +private fun disabledContentColor(contentColor: Color): Color { + return contentColor.copy(alpha = 0.38f) +} + +@Composable +private fun borderStroke(style: SeriesIconButtonStyle, enabled: Boolean): BorderStroke? { + return when (style) { + SeriesIconButtonStyle.Outlined -> if (enabled) { + BorderStroke(1.dp, LegadoTheme.colorScheme.outlineVariant) + } else { + BorderStroke(1.dp, LegadoTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) + } + + SeriesIconButtonStyle.Plain, + SeriesIconButtonStyle.Tonal -> null + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallAnimatedButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallAnimatedButton.kt index 69172217c..38d8847aa 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallAnimatedButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallAnimatedButton.kt @@ -1,30 +1,21 @@ package io.legado.app.ui.widget.components.button.series -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.TonalToggleButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import io.legado.app.ui.theme.LegadoTheme -import io.legado.app.ui.theme.LegadoTheme.composeEngine -import io.legado.app.ui.theme.ThemeResolver -import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon -import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton -import top.yukonga.miuix.kmp.basic.Text as MiuixText +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -32,145 +23,55 @@ fun SmallAnimatedButton( checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, enabled: Boolean = true, icon: ImageVector? = null, iconChecked: ImageVector? = null, text: String? = null, + contentColor: Color = LegadoTheme.colorScheme.onSurfaceVariant, + containerColor: Color? = null, + selectedContainerColor: Color = LegadoTheme.colorScheme.primaryContainer, + selectedContentColor: Color = LegadoTheme.colorScheme.onPrimaryContainer, contentDescription: String? = null ) { - if (ThemeResolver.isMiuixEngine(composeEngine)) { - val containerColor by animateColorAsState( - targetValue = if (checked) LegadoTheme.colorScheme.primaryContainer else LegadoTheme.colorScheme.surfaceContainer, - animationSpec = tween(150), - label = "MiuixAnimatedContainerColor" - ) + var showText by remember { mutableStateOf(false) } + val currentIcon = if (checked) (iconChecked ?: icon)!! else icon!! - val iconTint by animateColorAsState( - targetValue = if (checked) LegadoTheme.colorScheme.onPrimaryContainer else LegadoTheme.colorScheme.onSurfaceVariant, - animationSpec = tween(150), - label = "MiuixAnimatedIconTint" - ) - - if (text != null) { - AnimatedActionButtonCore( - checked = checked, - onCheckedChange = onCheckedChange, - iconChecked = iconChecked ?: icon!!, - iconUnchecked = icon ?: iconChecked!!, - activeText = text, - inactiveText = text, - modifier = modifier, - iconSize = 18.dp, - textStyle = LegadoTheme.typography.labelSmall, - textStartPadding = 6.dp, - contentColor = iconTint, - button = { buttonModifier, onToggle, content -> - MiuixIconButton( - onClick = { onToggle(!checked) }, - modifier = buttonModifier, - backgroundColor = containerColor - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), - content = content - ) - } - }, - icon = { imageVector, iconModifier, tint -> - MiuixIcon( - imageVector = imageVector, - contentDescription = null, - modifier = iconModifier, - tint = tint ?: Color.Unspecified - ) - }, - text = { label, textModifier, textStyle, color -> - MiuixText( - text = label, - style = textStyle, - color = color ?: Color.Unspecified, - modifier = textModifier, - maxLines = 1, - softWrap = false - ) - } - ) - } else { - MiuixIconButton( - onClick = { onCheckedChange(!checked) }, - modifier = modifier.size(SmallMiuixButtonSize), - enabled = enabled, - backgroundColor = containerColor - ) { - MiuixIcon( - imageVector = if (checked) (iconChecked ?: icon)!! else icon!!, - contentDescription = contentDescription, - tint = iconTint, - modifier = Modifier.size(SmallMiuixIconSize) - ) - } + LaunchedEffect(showText) { + if (showText) { + delay(1000.milliseconds) + showText = false } - } else { - SmallNoMinTouchTarget { - if (text != null) { - AnimatedActionButtonCore( - checked = checked, - onCheckedChange = onCheckedChange, - iconChecked = iconChecked ?: icon!!, - iconUnchecked = icon ?: iconChecked!!, - activeText = text, - inactiveText = text, - modifier = modifier.height(36.dp), - iconSize = 16.dp, - textStyle = LegadoTheme.typography.labelSmall, - textStartPadding = 6.dp, - button = { buttonModifier, onToggle, content -> - TonalToggleButton( - checked = checked, - onCheckedChange = onToggle, - modifier = buttonModifier, - contentPadding = PaddingValues(horizontal = 8.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - content = content - ) - } - }, - icon = { imageVector, iconModifier, _ -> - AnimatedIcon( - imageVector = imageVector, - contentDescription = null, - modifier = iconModifier - ) - }, - text = { label, textModifier, textStyle, color -> - androidx.compose.material3.Text( - text = label, - style = textStyle, - color = color ?: Color.Unspecified, - modifier = textModifier, - maxLines = 1, - softWrap = false - ) - } - ) - } else { - FilledTonalIconButton( - onClick = { onCheckedChange(!checked) }, - modifier = modifier.size(smallContainerSize()), - enabled = enabled, - ) { - Icon( - imageVector = if (checked) (iconChecked ?: icon)!! else icon!!, - contentDescription = contentDescription, - modifier = Modifier.size(smallIconSize), - ) - } - } + } + + SmallNoMinTouchTarget { + SeriesButton( + onClick = { + onCheckedChange(!checked) + showText = true + }, + modifier = if (text == null) modifier else modifier.height(36.dp), + enabled = enabled, + selected = checked, + onLongClick = onLongClick, + size = if (text == null) smallContainerSize() else null, + style = SeriesIconButtonStyle.Tonal, + contentColor = contentColor, + containerColor = containerColor, + selectedContainerColor = selectedContainerColor, + selectedContentColor = selectedContentColor + ) { contentColor -> + SeriesAnimatedButtonContent( + icon = currentIcon, + text = text, + contentDescription = if (text == null) contentDescription else null, + showText = showText, + iconSize = smallIconSize, + textStyle = LegadoTheme.typography.labelSmall, + contentColor = contentColor, + padding = PaddingValues(horizontal = 8.dp, vertical = 6.dp), + spacing = 6.dp + ) } } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallOutlinedButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallOutlinedButton.kt index 24939120d..8aee37f5b 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallOutlinedButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallOutlinedButton.kt @@ -1,93 +1,45 @@ package io.legado.app.ui.widget.components.button.series import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.size -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedIconButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import io.legado.app.ui.theme.LegadoTheme -import io.legado.app.ui.theme.LegadoTheme.composeEngine -import io.legado.app.ui.theme.ThemeResolver -import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.CardDefaults -import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon -import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SmallOutlinedButton( onClick: () -> Unit, modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, enabled: Boolean = true, + selected: Boolean = false, icon: ImageVector? = null, text: String? = null, contentDescription: String? = null ) { - if (ThemeResolver.isMiuixEngine(composeEngine)) { - if (icon != null && text == null) { - MiuixIconButton( - onClick = onClick, - modifier = modifier.size(SmallMiuixButtonSize), - enabled = enabled, - backgroundColor = LegadoTheme.colorScheme.surfaceContainer - ) { - MiuixIcon( - imageVector = icon, - contentDescription = contentDescription, - modifier = Modifier.size(SmallMiuixIconSize) - ) - } - } else { - Card( - onClick = onClick, - modifier = modifier, - showIndication = true, - colors = CardDefaults.defaultColors( - color = LegadoTheme.colorScheme.surfaceContainer, - contentColor = LegadoTheme.colorScheme.onSurfaceVariant - ) - ) { - SmallButtonContent(icon, text, contentDescription) - } - } - } else { - SmallNoMinTouchTarget { - when { - icon != null && text == null -> { - OutlinedIconButton( - onClick = onClick, - modifier = modifier.size(smallContainerSize()), - enabled = enabled, - shapes = IconButtonDefaults.shapes(), - border = ButtonDefaults.outlinedButtonBorder() - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - modifier = Modifier.size(smallIconSize) - ) - } - } - - else -> { - OutlinedButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), - border = ButtonDefaults.outlinedButtonBorder() - ) { - SmallButtonContent(icon, text, contentDescription) - } - } - } + SmallNoMinTouchTarget { + SeriesButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + selected = selected, + onLongClick = onLongClick, + size = if (text == null) smallContainerSize() else null, + style = SeriesIconButtonStyle.Outlined + ) { contentColor -> + SeriesButtonContent( + icon = icon, + text = text, + contentDescription = contentDescription, + iconSize = smallIconSize, + textStyle = LegadoTheme.typography.labelMedium, + contentColor = contentColor, + padding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + spacing = 4.dp + ) } } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallPlainButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallPlainButton.kt index 839ff94a3..b463f2eb0 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallPlainButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallPlainButton.kt @@ -1,36 +1,18 @@ package io.legado.app.ui.widget.components.button.series -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalMinimumInteractiveComponentSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import io.legado.app.ui.theme.LegadoTheme -import io.legado.app.ui.theme.LegadoTheme.composeEngine -import io.legado.app.ui.theme.ThemeResolver -import io.legado.app.ui.widget.components.text.AppText -import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.CardDefaults -import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon -import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton -import top.yukonga.miuix.kmp.basic.Text as MiuixText internal val SmallMiuixButtonSize = 32.dp -internal val SmallMiuixIconSize = 18.dp @OptIn(ExperimentalMaterial3ExpressiveApi::class) internal fun smallContainerSize() = IconButtonDefaults.extraSmallContainerSize( @@ -41,6 +23,9 @@ internal fun smallContainerSize() = IconButtonDefaults.extraSmallContainerSize( internal val smallIconSize: Dp get() = IconButtonDefaults.extraSmallIconSize +internal val SmallMiuixIconSize: Dp + get() = smallIconSize + @Composable internal fun SmallNoMinTouchTarget(content: @Composable () -> Unit) { CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { @@ -48,114 +33,37 @@ internal fun SmallNoMinTouchTarget(content: @Composable () -> Unit) { } } -@Composable -internal fun SmallButtonContent( - icon: ImageVector?, - text: String?, - contentDescription: String? -) { - val isMiuix = ThemeResolver.isMiuixEngine(composeEngine) - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically - ) { - if (icon != null) { - if (isMiuix) { - MiuixIcon( - imageVector = icon, - contentDescription = contentDescription, - modifier = Modifier.size(16.dp) - ) - } else { - Icon( - imageVector = icon, - contentDescription = contentDescription, - modifier = Modifier.size(16.dp) - ) - } - } - if (text != null) { - if (isMiuix) { - MiuixText( - text = text, - style = LegadoTheme.typography.labelMedium - ) - } else { - AppText( - text = text, - style = LegadoTheme.typography.labelMedium - ) - } - } - } -} - @Composable fun SmallPlainButton( onClick: () -> Unit, modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, enabled: Boolean = true, + selected: Boolean = false, icon: ImageVector? = null, text: String? = null, contentDescription: String? = null ) { - if (ThemeResolver.isMiuixEngine(composeEngine)) { - if (icon != null && text == null) { - MiuixIconButton( - onClick = onClick, - modifier = modifier, - enabled = enabled - ) { - MiuixIcon( - imageVector = icon, - contentDescription = contentDescription, - modifier = Modifier.size(SmallMiuixIconSize), - ) - } - } else { - Card( - onClick = onClick, - modifier = modifier, - showIndication = true, - colors = CardDefaults.defaultColors( - color = LegadoTheme.colorScheme.surfaceVariant, - contentColor = LegadoTheme.colorScheme.onSurfaceVariant - ) - ) { - SmallButtonContent(icon, text, contentDescription) - } - } - } else { - SmallNoMinTouchTarget { - when { - icon != null && text == null -> { - IconButton( - onClick = onClick, - modifier = modifier.size(smallContainerSize()), - enabled = enabled, - shape = IconButtonDefaults.extraSmallRoundShape, - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - modifier = Modifier.size(smallIconSize), - ) - } - } - - else -> { - TextButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), - shape = MaterialTheme.shapes.small - ) { - SmallButtonContent(icon, text, contentDescription) - } - } - } + SmallNoMinTouchTarget { + SeriesButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + selected = selected, + onLongClick = onLongClick, + size = if (text == null) smallContainerSize() else null, + contentColor = LegadoTheme.colorScheme.onSurfaceVariant + ) { contentColor -> + SeriesButtonContent( + icon = icon, + text = text, + contentDescription = contentDescription, + iconSize = smallIconSize, + textStyle = LegadoTheme.typography.labelMedium, + contentColor = contentColor, + padding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + spacing = 4.dp + ) } } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallToggleButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallToggleButton.kt index 77ce135fe..5e7c16e92 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallToggleButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallToggleButton.kt @@ -8,16 +8,11 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.IconToggleButtonShapes -import androidx.compose.material3.OutlinedIconToggleButton import androidx.compose.material3.Text import androidx.compose.material3.TonalToggleButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -37,6 +32,7 @@ fun SmallToggleButton( checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, enabled: Boolean = true, style: ToggleStyle = ToggleStyle.Outlined, icon: ImageVector? = null, @@ -86,19 +82,23 @@ fun SmallToggleButton( } } } else { - MiuixIconButton( + SeriesIconButton( + icon = if (checked) (iconChecked ?: icon)!! else icon!!, + contentDescription = contentDescription, onClick = { onCheckedChange(!checked) }, - modifier = modifier.size(SmallMiuixButtonSize), + modifier = modifier, enabled = enabled, - backgroundColor = containerColor - ) { - MiuixIcon( - imageVector = if (checked) (iconChecked ?: icon)!! else icon!!, - contentDescription = contentDescription, - tint = iconTint, - modifier = Modifier.size(SmallMiuixIconSize) - ) - } + selected = checked, + onLongClick = onLongClick, + size = squareSize(SmallMiuixButtonSize), + iconSize = SmallMiuixIconSize, + style = when (style) { + ToggleStyle.Outlined -> SeriesIconButtonStyle.Outlined + ToggleStyle.Tonal -> SeriesIconButtonStyle.Tonal + }, + selectedContainerColor = containerColor, + selectedContentColor = iconTint + ) } } else { SmallNoMinTouchTarget { @@ -129,49 +129,21 @@ fun SmallToggleButton( } } } else { - when (style) { - ToggleStyle.Outlined -> { - val defaultShape = IconButtonDefaults.extraSmallRoundShape - val pressedShape = IconButtonDefaults.extraSmallPressedShape - val checkedShape = IconButtonDefaults.extraSmallSelectedRoundShape - - val toggleShapes = remember(defaultShape, checkedShape) { - IconToggleButtonShapes( - shape = defaultShape, - pressedShape = pressedShape, - checkedShape = checkedShape - ) - } - - OutlinedIconToggleButton( - checked = checked, - onCheckedChange = onCheckedChange, - modifier = modifier.size(smallContainerSize()), - enabled = enabled, - shapes = toggleShapes - ) { - Icon( - imageVector = if (checked) (iconChecked ?: icon)!! else icon!!, - contentDescription = contentDescription, - modifier = Modifier.size(smallIconSize), - ) - } + SeriesIconButton( + icon = if (checked) (iconChecked ?: icon)!! else icon!!, + contentDescription = contentDescription, + onClick = { onCheckedChange(!checked) }, + modifier = modifier, + enabled = enabled, + selected = checked, + onLongClick = onLongClick, + size = smallContainerSize(), + iconSize = smallIconSize, + style = when (style) { + ToggleStyle.Outlined -> SeriesIconButtonStyle.Outlined + ToggleStyle.Tonal -> SeriesIconButtonStyle.Tonal } - - ToggleStyle.Tonal -> { - FilledTonalButton( - onClick = { onCheckedChange(!checked) }, - modifier = modifier.size(smallContainerSize()), - enabled = enabled, - ) { - Icon( - imageVector = if (checked) (iconChecked ?: icon)!! else icon!!, - contentDescription = contentDescription, - modifier = Modifier.size(smallIconSize), - ) - } - } - } + ) } } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallTonalButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallTonalButton.kt index 87709b123..dca5b47e5 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallTonalButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallTonalButton.kt @@ -1,91 +1,54 @@ package io.legado.app.ui.widget.components.button.series import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import io.legado.app.ui.theme.LegadoTheme -import io.legado.app.ui.theme.LegadoTheme.composeEngine -import io.legado.app.ui.theme.ThemeResolver -import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.CardDefaults -import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon -import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SmallTonalButton( onClick: () -> Unit, modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, enabled: Boolean = true, + selected: Boolean = false, icon: ImageVector? = null, text: String? = null, + contentColor: Color = LegadoTheme.colorScheme.onSurfaceVariant, + containerColor: Color? = null, + selectedContainerColor: Color = LegadoTheme.colorScheme.primaryContainer, + selectedContentColor: Color = LegadoTheme.colorScheme.onPrimaryContainer, contentDescription: String? = null ) { - if (ThemeResolver.isMiuixEngine(composeEngine)) { - if (icon != null && text == null) { - MiuixIconButton( - onClick = onClick, - modifier = modifier.size(SmallMiuixButtonSize), - enabled = enabled, - backgroundColor = LegadoTheme.colorScheme.surfaceContainer - ) { - MiuixIcon( - imageVector = icon, - contentDescription = contentDescription, - modifier = Modifier.size(SmallMiuixIconSize) - ) - } - } else { - Card( - onClick = onClick, - modifier = modifier, - showIndication = true, - colors = CardDefaults.defaultColors( - color = LegadoTheme.colorScheme.surfaceContainer, - contentColor = LegadoTheme.colorScheme.onSurfaceVariant - ) - ) { - SmallButtonContent(icon, text, contentDescription) - } - } - } else { - SmallNoMinTouchTarget { - when { - icon != null && text == null -> { - FilledTonalIconButton( - onClick = onClick, - modifier = modifier.size(smallContainerSize()), - enabled = enabled, - shapes = IconButtonDefaults.shapes(), - colors = IconButtonDefaults.filledTonalIconButtonColors() - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - modifier = Modifier.size(smallIconSize) - ) - } - } - - else -> { - FilledTonalButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp) - ) { - SmallButtonContent(icon, text, contentDescription) - } - } - } + SmallNoMinTouchTarget { + SeriesButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + selected = selected, + onLongClick = onLongClick, + size = if (text == null) smallContainerSize() else null, + style = SeriesIconButtonStyle.Tonal, + contentColor = contentColor, + containerColor = containerColor, + selectedContainerColor = selectedContainerColor, + selectedContentColor = selectedContentColor + ) { contentColor -> + SeriesButtonContent( + icon = icon, + text = text, + contentDescription = contentDescription, + iconSize = smallIconSize, + textStyle = LegadoTheme.typography.labelMedium, + contentColor = contentColor, + padding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + spacing = 4.dp + ) } } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/dialog/ColorPickerSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/dialog/ColorPickerSheet.kt index 319453f93..c482d567b 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/dialog/ColorPickerSheet.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/dialog/ColorPickerSheet.kt @@ -126,7 +126,7 @@ fun ColorPickerSheet( label = stringResource(R.string.color_value), singleLine = true, isError = isHexInputError, - backgroundColor = LegadoTheme.colorScheme.surface, + backgroundColor = LegadoTheme.colorScheme.surfaceContainerLow, keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Characters, keyboardType = KeyboardType.Ascii, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/log/AppLogSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/log/AppLogSheet.kt index 4e48cb95c..53634fcef 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/log/AppLogSheet.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/log/AppLogSheet.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -20,14 +19,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import io.legado.app.R -import io.legado.app.constant.AppLog import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.widget.components.EmptyMessage import io.legado.app.ui.widget.components.button.series.MediumPlainButton import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.text.AppText import io.legado.app.utils.LogUtils +import splitties.init.appCtx +import java.text.SimpleDateFormat import java.util.Date +import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -35,8 +36,8 @@ fun AppLogSheet( show: Boolean, onDismissRequest: () -> Unit ) { - var logs by remember { mutableStateOf(AppLog.logs) } - var showStackTrace by remember { mutableStateOf(null) } + var logs by remember(show) { mutableStateOf(loadAllLogs()) } + var showDetail by remember { mutableStateOf(null) } AppModalBottomSheet( show = show, @@ -45,7 +46,7 @@ fun AppLogSheet( endAction = { MediumPlainButton( onClick = { - AppLog.clear() + clearAllLogs() logs = emptyList() }, icon = Icons.Default.DeleteSweep @@ -61,9 +62,7 @@ fun AppLogSheet( ) { items(logs) { item -> LogItem(item) { - item.third?.let { - showStackTrace = it.stackTraceToString() - } + showDetail = item.content } } } @@ -71,16 +70,108 @@ fun AppLogSheet( } LogDetailSheet( - show = showStackTrace != null, + show = showDetail != null, title = "Log", - content = showStackTrace.orEmpty(), - onDismissRequest = { showStackTrace = null } + content = showDetail.orEmpty(), + onDismissRequest = { showDetail = null } ) } +private data class LogEntry( + val time: Long, + val message: String, + val isCrash: Boolean = false, +) { + val content: String get() = message +} + +/** + * 从本地日志文件加载所有日志(应用日志 + 崩溃日志) + */ +private fun loadAllLogs(): List { + val entries = mutableListOf() + loadAppLogFiles(entries) + loadCrashLogFiles(entries) + return entries.sortedByDescending { it.time } +} + +/** + * 从 externalCacheDir/logs/ 读取应用日志文件 + * 文件格式: appLog-{date}.txt, 每行格式: yy-MM-dd HH:mm:ss.SSS: message + */ +private fun loadAppLogFiles(entries: MutableList) { + val logDir = appCtx.externalCacheDir?.resolve("logs") ?: return + if (!logDir.isDirectory) return + logDir.listFiles() + ?.filter { it.isFile && it.name.startsWith("appLog-") && it.name.endsWith(".txt") } + ?.sortedByDescending { it.name } + ?.forEach { file -> + runCatching { + file.readLines().forEach { line -> + parseLogLine(line)?.let { entries.add(it) } + } + } + } +} + +/** + * 从 externalCacheDir/crash/ 读取崩溃日志文件 + */ +private fun loadCrashLogFiles(entries: MutableList) { + val crashDir = appCtx.externalCacheDir?.resolve("crash") ?: return + if (!crashDir.isDirectory) return + crashDir.listFiles() + ?.filter { it.isFile && it.name.startsWith("crash-") } + ?.forEach { file -> + runCatching { + entries.add( + LogEntry( + time = file.lastModified(), + message = file.readText(), + isCrash = true, + ) + ) + } + } +} + +/** + * 解析日志行: "yy-MM-dd HH:mm:ss.SSS: message" + */ +private fun parseLogLine(line: String): LogEntry? { + if (line.isBlank()) return null + // 格式: 25-06-07 12:34:56.789: message + val colonIdx = line.indexOf(": ") + if (colonIdx < 0 || colonIdx < 17) { + // 没有时间戳前缀,作为多行消息附加到上一条(此处忽略) + return null + } + val timeStr = line.substring(0, colonIdx) + val message = line.substring(colonIdx + 2) + val time = runCatching { + logFileDateFormat.parse(timeStr)?.time + }.getOrNull() ?: 0L + return LogEntry(time = time, message = message) +} + +private val logFileDateFormat = SimpleDateFormat("yy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) + +private fun clearAllLogs() { + // 清除应用日志 + val logDir = appCtx.externalCacheDir?.resolve("logs") + if (logDir?.isDirectory == true) { + logDir.listFiles()?.forEach { it.delete() } + } + // 清除崩溃日志 + val crashDir = appCtx.externalCacheDir?.resolve("crash") + if (crashDir?.isDirectory == true) { + crashDir.listFiles()?.forEach { it.delete() } + } +} + @Composable private fun LogItem( - item: Triple, + item: LogEntry, onClick: () -> Unit ) { Column( @@ -90,14 +181,31 @@ private fun LogItem( .padding(16.dp) ) { AppText( - text = LogUtils.logTimeFormat.format(Date(item.first)), + text = buildString { + if (item.isCrash) append("[崩溃] ") + if (item.time > 0) append(LogUtils.logTimeFormat.format(Date(item.time))) + }, style = LegadoTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.outline + color = if (item.isCrash) { + LegadoTheme.colorScheme.error + } else { + LegadoTheme.colorScheme.outline + } ) AppText( - text = item.second, + text = if (item.isCrash) { + item.message.lineSequence().firstOrNull().orEmpty() + } else { + item.message + }, style = LegadoTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 4.dp) + color = if (item.isCrash) { + LegadoTheme.colorScheme.error.copy(alpha = 0.8f) + } else { + LegadoTheme.colorScheme.onSurface + }, + modifier = Modifier.padding(top = 4.dp), + maxLines = 2, ) } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/settingItem/TinySettingItems.kt b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/TinySettingItems.kt new file mode 100644 index 000000000..ed0e845d4 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/TinySettingItems.kt @@ -0,0 +1,739 @@ +package io.legado.app.ui.widget.components.settingItem + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import io.legado.app.R +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.AccentColorButton +import io.legado.app.ui.widget.components.AppSlider +import io.legado.app.ui.widget.components.TinySwitch +import io.legado.app.ui.widget.components.ValueStepper +import io.legado.app.ui.widget.components.card.NormalCard +import io.legado.app.ui.widget.components.card.TextCard +import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu +import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem +import io.legado.app.ui.widget.components.text.AppText + +@Composable +fun TinySettingItem( + title: String, + description: String? = null, + imageVector: ImageVector? = null, + modifier: Modifier = Modifier, + color: Color? = LegadoTheme.colorScheme.surfaceContainerLow, + trailingContent: (@Composable () -> Unit)? = null, + expanded: Boolean = false, + onExpandChange: ((Boolean) -> Unit)? = null, + expandContent: (@Composable ColumnScope.() -> Unit)? = null, + enabled: Boolean = true, + onClick: (() -> Unit)? = null, +) { + val isExpandable = expandContent != null && onExpandChange != null + val alpha = if (enabled) 1f else 0.5f + + NormalCard( + onClick = if (enabled) { + { + when { + isExpandable -> onExpandChange.invoke(!expanded) + else -> onClick?.invoke() + } + } + } else null, + modifier = modifier + .padding(bottom = 4.dp) + .heightIn(min = 56.dp) + .fillMaxWidth(), + cornerRadius = 12.dp, + containerColor = color?.copy(alpha = alpha), + contentColor = LegadoTheme.colorScheme.onSurface.copy(alpha = alpha), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + imageVector?.let { + Icon( + imageVector = it, + contentDescription = null, + tint = LegadoTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha), + modifier = Modifier.size(18.dp), + ) + } + + Column( + modifier = Modifier.weight(1f) + ) { + AppText( + text = title, + style = LegadoTheme.typography.titleSmallEmphasized, + color = LegadoTheme.colorScheme.onSurface.copy(alpha = alpha), + ) + description?.let { + AppText( + text = it, + style = LegadoTheme.typography.labelSmall, + color = LegadoTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Box(contentAlignment = Alignment.Center) { + when { + trailingContent != null -> trailingContent() + isExpandable -> { + val rotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + label = "tinySettingArrow", + ) + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = LegadoTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha), + modifier = Modifier + .size(20.dp) + .rotate(rotation), + ) + } + } + } + } + + if (isExpandable) { + AnimatedVisibility(visible = expanded) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp, bottom = 12.dp), + ) { + expandContent.invoke(this) + } + } + } + } + } +} + +@Composable +fun TinyDropdownSettingItem( + title: String, + selectedValue: String, + displayEntries: Array, + entryValues: Array, + description: String? = null, + imageVector: ImageVector? = null, + modifier: Modifier = Modifier, + color: Color? = LegadoTheme.colorScheme.surfaceContainerLow, + onValueChange: (String) -> Unit, +) { + var showMenu by remember { mutableStateOf(false) } + val currentEntry = displayEntries.getOrNull(entryValues.indexOf(selectedValue)) ?: selectedValue + + Box(modifier = Modifier.fillMaxWidth()) { + TinySettingItem( + title = title, + description = description, + imageVector = imageVector, + modifier = modifier, + color = color, + trailingContent = { + TextCard( + cornerRadius = 8.dp, + horizontalPadding = 8.dp, + verticalPadding = 4.dp, + text = currentEntry, + backgroundColor = LegadoTheme.colorScheme.surfaceContainerHigh, + contentColor = LegadoTheme.colorScheme.onSurface, + ) + }, + onClick = { showMenu = true }, + ) + + RoundDropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { dismiss -> + displayEntries.forEachIndexed { index, display -> + RoundDropdownMenuItem( + text = display, + onClick = { + onValueChange(entryValues[index]) + dismiss() + }, + trailingIcon = if (selectedValue == entryValues[index]) { + { + Icon( + Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + } else null, + ) + } + } + } +} + +@Composable +fun TinySliderSettingItem( + title: String, + value: Float, + valueRange: ClosedFloatingPointRange, + steps: Int = 0, + description: String? = null, + imageVector: ImageVector? = null, + modifier: Modifier = Modifier, + color: Color? = LegadoTheme.colorScheme.surfaceContainerLow, + enabled: Boolean = true, + onValueChange: (Float) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + var sliderValue by remember(value) { mutableFloatStateOf(value) } + var displayValue by remember(value) { mutableFloatStateOf(value) } + + TinySettingItem( + title = title, + description = description, + imageVector = imageVector, + modifier = modifier, + color = color, + expanded = expanded, + onExpandChange = { expanded = it }, + enabled = enabled, + trailingContent = { + ValueStepper( + value = value, + displayValue = displayValue, + valueRange = valueRange, + onValueChange = onValueChange, + enabled = enabled, + ) + }, + expandContent = { + AppSlider( + value = sliderValue, + onValueChange = { + sliderValue = it + displayValue = it + }, + onValueChangeFinished = { + onValueChange(sliderValue) + }, + valueRange = valueRange, + steps = steps, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + ) + }, + ) + + LaunchedEffect(value) { + if (!expanded) { + sliderValue = value + displayValue = value + } + } +} + +@Composable +fun TinySwitchSettingItem( + title: String, + checked: Boolean, + description: String? = null, + imageVector: ImageVector? = null, + modifier: Modifier = Modifier, + color: Color? = LegadoTheme.colorScheme.surfaceContainerLow, + enabled: Boolean = true, + onCheckedChange: (Boolean) -> Unit, +) { + TinySettingItem( + title = title, + description = description, + imageVector = imageVector, + modifier = modifier, + color = color, + enabled = enabled, + trailingContent = { + TinySwitch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + ) + }, + onClick = { onCheckedChange(!checked) }, + ) +} + +@Composable +fun TinyClickableSettingItem( + title: String, + description: String? = null, + imageVector: ImageVector? = null, + modifier: Modifier = Modifier, + color: Color? = LegadoTheme.colorScheme.surfaceContainerLow, + trailingContent: (@Composable () -> Unit)? = null, + onClick: () -> Unit, +) { + TinySettingItem( + title = title, + description = description, + imageVector = imageVector, + modifier = modifier, + color = color, + trailingContent = trailingContent ?: { + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = LegadoTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + onClick = onClick, + ) +} + +@Composable +fun TinyColorSettingItem( + title: String, + colorValue: Int, + description: String? = null, + imageVector: ImageVector? = null, + modifier: Modifier = Modifier, + color: Color? = LegadoTheme.colorScheme.surfaceContainerLow, + enabled: Boolean = true, + onClick: () -> Unit, +) { + TinySettingItem( + title = title, + description = description, + imageVector = imageVector, + modifier = modifier, + color = color, + enabled = enabled, + trailingContent = { + AccentColorButton( + color = colorValue, + onClick = onClick, + enabled = enabled, + ) + }, + onClick = { onClick() }, + ) +} + +/** + * A color setting item with an integrated light/dark mode pill toggle. + * + * @param title The title text. + * @param dayColor The color value for light mode (ARGB int). + * @param nightColor The color value for dark mode (ARGB int). + * @param onClickColor Called when the color knob is clicked (passes current mode's selection). + */ +@Composable +fun TinyColorModeSettingItem( + title: String, + dayColor: Int, + nightColor: Int, + onClickColor: (isNight: Boolean) -> Unit, + description: String? = null, + imageVector: ImageVector? = null, + modifier: Modifier = Modifier, + color: Color? = LegadoTheme.colorScheme.surfaceContainerLow, + enabled: Boolean = true, +) { + val currentDarkMode = LegadoTheme.isDark + var isNightMode by remember(currentDarkMode) { mutableStateOf(currentDarkMode) } + + TinySettingItem( + title = title, + description = description, + imageVector = imageVector, + modifier = modifier, + color = color, + enabled = enabled, + trailingContent = { + ColorModePill( + dayColor = dayColor, + nightColor = nightColor, + isNightMode = isNightMode, + onToggleMode = { isNightMode = !isNightMode }, + onClickColor = { onClickColor(isNightMode) }, + enabled = enabled, + ) + }, + ) +} + +@Composable +private fun ColorModePill( + dayColor: Int, + nightColor: Int, + isNightMode: Boolean, + onToggleMode: () -> Unit, + onClickColor: () -> Unit, + enabled: Boolean = true, +) { + val pillWidth = 60.dp + val pillHeight = 32.dp + val knobSize = 24.dp + val padding = 4.dp + + val knobOffset by animateDpAsState( + targetValue = if (isNightMode) pillWidth - knobSize - padding else padding, + animationSpec = tween(durationMillis = 200), + label = "knobOffset", + ) + + val currentColor = if (isNightMode) nightColor else dayColor + + Box( + modifier = Modifier + .width(pillWidth) + .height(pillHeight) + .clip(RoundedCornerShape(pillHeight)) + .background(LegadoTheme.colorScheme.surfaceContainerHigh) + .clickable( + enabled = enabled, + onClick = onToggleMode, + ), + contentAlignment = Alignment.CenterStart, + ) { + // Icons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.DarkMode, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = if (!isNightMode) { + LegadoTheme.colorScheme.onSurface + } else { + LegadoTheme.colorScheme.onSurfaceVariant + }, + ) + Icon( + imageVector = Icons.Default.LightMode, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = if (isNightMode) { + LegadoTheme.colorScheme.onSurface + } else { + LegadoTheme.colorScheme.onSurfaceVariant + }, + ) + } + + // Knob — color or + icon + Box( + modifier = Modifier + .offset { IntOffset(x = knobOffset.roundToPx(), y = 0) } + .size(knobSize) + .clip(CircleShape) + .background( + if (currentColor != 0) Color(currentColor) + else LegadoTheme.colorScheme.surfaceContainerLow + ) + .clickable( + enabled = enabled, + onClick = onClickColor, + ), + contentAlignment = Alignment.Center, + ) { + if (currentColor == 0) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = LegadoTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** + * A color mode setting item with a reset icon to the left of the pill. + * Shows a reset button when the current mode has a custom color set. + */ +@Composable +fun TinyClearColorModeSettingItem( + title: String, + dayColor: Int, + nightColor: Int, + onClickColor: (isNight: Boolean) -> Unit, + onClearColor: (isNight: Boolean) -> Unit, + description: String? = null, + imageVector: ImageVector? = null, + modifier: Modifier = Modifier, + color: Color? = LegadoTheme.colorScheme.surfaceContainerLow, + enabled: Boolean = true, +) { + val currentDarkMode = LegadoTheme.isDark + var isNightMode by remember(currentDarkMode) { mutableStateOf(currentDarkMode) } + + TinySettingItem( + title = title, + description = description, + imageVector = imageVector, + modifier = modifier, + color = color, + enabled = enabled, + trailingContent = { + ClearColorModePill( + dayColor = dayColor, + nightColor = nightColor, + isNightMode = isNightMode, + enabled = enabled, + onToggleMode = { isNightMode = !isNightMode }, + onClickColor = { onClickColor(isNightMode) }, + onReset = { onClearColor(isNightMode) }, + ) + }, + ) +} + +@Composable +private fun ClearColorModePill( + dayColor: Int, + nightColor: Int, + isNightMode: Boolean, + enabled: Boolean, + onToggleMode: () -> Unit, + onClickColor: () -> Unit, + onReset: () -> Unit, +) { + val currentColor = if (isNightMode) nightColor else dayColor + val hasCustomColor = currentColor != 0 + val knobSize = 32.dp + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (hasCustomColor) { + Box( + modifier = Modifier + .size(knobSize) + .clip(CircleShape) + .clickable(enabled = enabled, onClick = onReset) + .border( + width = 1.dp, + color = LegadoTheme.colorScheme.surfaceContainerHigh, + shape = CircleShape + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = LegadoTheme.colorScheme.onSurfaceVariant, + ) + } + } + + ColorModePill( + dayColor = dayColor, + nightColor = nightColor, + isNightMode = isNightMode, + onToggleMode = onToggleMode, + onClickColor = onClickColor, + enabled = enabled, + ) + } +} + +/** + * A background image mode setting item with separate day/night cards. + * Each card shows the selected image and a reset button when an image is set. + */ +@Composable +fun TinyBgImageModeSettingItem( + title: String, + dayBgImage: String?, + nightBgImage: String?, + onClickImage: (isNight: Boolean) -> Unit, + onClearImage: (isNight: Boolean) -> Unit, + description: String? = null, + imageVector: ImageVector? = null, + modifier: Modifier = Modifier, + color: Color? = LegadoTheme.colorScheme.surfaceContainerLow, + enabled: Boolean = true, +) { + var expanded by remember { mutableStateOf(false) } + + TinySettingItem( + title = title, + description = description, + imageVector = imageVector, + modifier = modifier, + color = color, + enabled = enabled, + expanded = expanded, + onExpandChange = { expanded = it }, + expandContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) { + BgImageCard( + label = stringResource(R.string.day), + bgImage = dayBgImage, + enabled = enabled, + onClick = { onClickImage(false) }, + onReset = { onClearImage(false) }, + modifier = Modifier.weight(1f), + ) + BgImageCard( + label = stringResource(R.string.night), + bgImage = nightBgImage, + enabled = enabled, + onClick = { onClickImage(true) }, + onReset = { onClearImage(true) }, + modifier = Modifier.weight(1f), + ) + } + }, + ) +} + +@Composable +private fun BgImageCard( + label: String, + bgImage: String?, + enabled: Boolean, + onClick: () -> Unit, + onReset: () -> Unit, + modifier: Modifier = Modifier, +) { + val hasImage = !bgImage.isNullOrBlank() + + Box( + modifier = modifier + .height(56.dp) + .clip(RoundedCornerShape(8.dp)) + .background(LegadoTheme.colorScheme.surfaceContainerHigh) + .clickable(enabled = enabled, onClick = onClick), + ) { + if (hasImage) { + AsyncImage( + model = bgImage, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + alpha = 0.6f, + ) + } + + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + val labelStyle = if (hasImage) { + LegadoTheme.typography.labelSmall.copy( + shadow = androidx.compose.ui.graphics.Shadow( + color = Color.Black.copy(alpha = 0.6f), + offset = androidx.compose.ui.geometry.Offset(1f, 1f), + blurRadius = 3f, + ) + ) + } else { + LegadoTheme.typography.labelSmall + } + AppText( + text = label, + style = labelStyle, + color = if (hasImage) Color.White else LegadoTheme.colorScheme.onSurfaceVariant, + ) + + if (hasImage) { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(LegadoTheme.colorScheme.surface.copy(alpha = 0.7f)) + .clickable(enabled = enabled, onClick = onReset), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = LegadoTheme.colorScheme.onSurface, + ) + } + } else { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = LegadoTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/tabRow/CardTabRow.kt b/app/src/main/java/io/legado/app/ui/widget/components/tabRow/CardTabRow.kt new file mode 100644 index 000000000..9940cbc6a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/tabRow/CardTabRow.kt @@ -0,0 +1,76 @@ +package io.legado.app.ui.widget.components.tabRow + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.card.NormalCard +import io.legado.app.ui.widget.components.text.AppText + +@Composable +fun CardTabRow( + tabTitles: List, + selectedTabIndex: Int, + onTabSelected: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + tabTitles.forEachIndexed { index, title -> + val selected = selectedTabIndex == index + val containerColor by animateColorAsState( + targetValue = if (selected) { + LegadoTheme.colorScheme.secondaryContainer + } else { + LegadoTheme.colorScheme.surfaceContainerLow + }, + animationSpec = tween(durationMillis = 200), + label = "tabColor", + ) + val contentColor by animateColorAsState( + targetValue = if (selected) { + LegadoTheme.colorScheme.onSecondaryContainer + } else { + LegadoTheme.colorScheme.onSurfaceVariant + }, + animationSpec = tween(durationMillis = 200), + label = "tabContentColor", + ) + + NormalCard( + onClick = { onTabSelected(index) }, + modifier = Modifier.weight(1f), + containerColor = containerColor, + contentColor = contentColor, + cornerRadius = 12.dp + ) { + AppText( + text = title, + style = LegadoTheme.typography.labelMediumEmphasized, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + color = contentColor, + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp) + .align(Alignment.CenterHorizontally), + textAlign = TextAlign.Center, + ) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/topbar/TopBarButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/topbar/TopBarButton.kt index cc0abdae8..ae921f1e3 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/topbar/TopBarButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/topbar/TopBarButton.kt @@ -13,8 +13,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.Text import androidx.compose.material3.ToggleButton @@ -32,6 +30,7 @@ import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.widget.components.button.series.AnimatedActionButtonCore import io.legado.app.ui.widget.components.button.series.AnimatedIcon +import io.legado.app.ui.widget.components.button.series.MediumPlainButton import io.legado.app.ui.widget.components.icon.AppIcons import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon @@ -115,15 +114,11 @@ fun TopBarActionButton( modifier = modifier ) } else { - IconButton( + MediumPlainButton( onClick = onClick, - modifier = modifier - ) { - Icon( - imageVector = imageVector, - contentDescription = contentDescription - ) - } + modifier = modifier, + icon = imageVector + ) } } } diff --git a/app/src/main/java/io/legado/app/utils/ActivityExtensions.kt b/app/src/main/java/io/legado/app/utils/ActivityExtensions.kt index a6a9ecbc3..7710d1a8b 100644 --- a/app/src/main/java/io/legado/app/utils/ActivityExtensions.kt +++ b/app/src/main/java/io/legado/app/utils/ActivityExtensions.kt @@ -9,7 +9,6 @@ import android.util.DisplayMetrics import android.view.Gravity import android.view.View import android.view.ViewGroup -import android.view.Window import android.view.WindowInsets import android.view.WindowInsetsController import android.view.WindowManager @@ -77,7 +76,7 @@ val WindowManager.windowSize: DisplayMetrics @Suppress("DEPRECATION") fun Activity.fullScreen() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - window.setDecorFitsSystemWindows(true) + window.setDecorFitsSystemWindows(false) } window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_STABLE @@ -141,42 +140,6 @@ fun Activity.setLightStatusBar(isLightBar: Boolean) { } } -/** - * 设置导航栏颜色 - */ -@Suppress("DEPRECATION") -fun Window.setNavigationBarColorAuto(@ColorInt color: Int) { - if (Build.VERSION.SDK_INT >= 35) return - val isLightBar = ColorUtils.isColorLight(color) - navigationBarColor = color - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - insetsController?.let { - if (isLightBar) { - it.setSystemBarsAppearance( - WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS, - WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS - ) - } else { - it.setSystemBarsAppearance( - 0, - WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS - ) - } - } - } else{ - var flags = decorView.systemUiVisibility - flags = if (isLightBar) { - flags or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR - } else { - flags and View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR.inv() - } - decorView.systemUiVisibility = flags - } -} - - - fun Activity.keepScreenOn(on: Boolean) { val isScreenOn = (window.attributes.flags and WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) != 0 diff --git a/app/src/main/java/io/legado/app/utils/ContextExtensions.kt b/app/src/main/java/io/legado/app/utils/ContextExtensions.kt index d7ddc57c1..8b58509af 100644 --- a/app/src/main/java/io/legado/app/utils/ContextExtensions.kt +++ b/app/src/main/java/io/legado/app/utils/ContextExtensions.kt @@ -49,7 +49,7 @@ import io.legado.app.help.book.isLocal import io.legado.app.help.config.AppConfig import io.legado.app.ui.book.audio.AudioPlayActivity import io.legado.app.ui.book.manga.ReadMangaActivity -import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.main.MainActivity import io.legado.app.ui.main.bookshelf.BookShelfItem import splitties.systemservices.clipboardManager import splitties.systemservices.connectivityManager @@ -69,14 +69,17 @@ fun Context.startActivityForBook( book: Book, configIntent: Intent.() -> Unit = {}, ) { - val cls = when { - book.isAudio -> AudioPlayActivity::class.java - !book.isLocal && book.isImage && AppConfig.showMangaUi -> ReadMangaActivity::class.java - else -> ReadBookActivity::class.java + val intent = when { + book.isAudio -> Intent(this, AudioPlayActivity::class.java) + !book.isLocal && book.isImage && AppConfig.showMangaUi -> + Intent(this, ReadMangaActivity::class.java) + + else -> MainActivity.createReadBookIntent(this, book.bookUrl) } - val intent = Intent(this, cls) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - intent.putExtra("bookUrl", book.bookUrl) + if (book.isAudio || (!book.isLocal && book.isImage && AppConfig.showMangaUi)) { + intent.putExtra("bookUrl", book.bookUrl) + } intent.apply(configIntent) startActivity(intent) } @@ -85,14 +88,17 @@ fun Context.startActivityForBook( book: BookShelfItem, configIntent: Intent.() -> Unit = {}, ) { - val cls = when { - book.isAudio -> AudioPlayActivity::class.java - !book.isLocal && book.isImage && AppConfig.showMangaUi -> ReadMangaActivity::class.java - else -> ReadBookActivity::class.java + val intent = when { + book.isAudio -> Intent(this, AudioPlayActivity::class.java) + !book.isLocal && book.isImage && AppConfig.showMangaUi -> + Intent(this, ReadMangaActivity::class.java) + + else -> MainActivity.createReadBookIntent(this, book.bookUrl) } - val intent = Intent(this, cls) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - intent.putExtra("bookUrl", book.bookUrl) + if (book.isAudio || (!book.isLocal && book.isImage && AppConfig.showMangaUi)) { + intent.putExtra("bookUrl", book.bookUrl) + } intent.apply(configIntent) startActivity(intent) } diff --git a/app/src/main/java/io/legado/app/utils/FragmentExtensions.kt b/app/src/main/java/io/legado/app/utils/FragmentExtensions.kt index 10aa12948..21da365ce 100644 --- a/app/src/main/java/io/legado/app/utils/FragmentExtensions.kt +++ b/app/src/main/java/io/legado/app/utils/FragmentExtensions.kt @@ -21,7 +21,7 @@ import io.legado.app.help.book.isLocal import io.legado.app.help.config.AppConfig import io.legado.app.ui.book.audio.AudioPlayActivity import io.legado.app.ui.book.manga.ReadMangaActivity -import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.main.MainActivity import io.legado.app.ui.widget.dialog.TextDialog inline fun Fragment.showDialogFragment( @@ -92,14 +92,17 @@ fun Fragment.startActivityForBook( book: Book, configIntent: Intent.() -> Unit = {}, ) { - val cls = when { - book.isAudio -> AudioPlayActivity::class.java - !book.isLocal && book.isImage && AppConfig.showMangaUi -> ReadMangaActivity::class.java - else -> ReadBookActivity::class.java + val intent = when { + book.isAudio -> Intent(requireActivity(), AudioPlayActivity::class.java) + !book.isLocal && book.isImage && AppConfig.showMangaUi -> + Intent(requireActivity(), ReadMangaActivity::class.java) + + else -> MainActivity.createReadBookIntent(requireActivity(), book.bookUrl) } - val intent = Intent(requireActivity(), cls) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - intent.putExtra("bookUrl", book.bookUrl) + if (book.isAudio || (!book.isLocal && book.isImage && AppConfig.showMangaUi)) { + intent.putExtra("bookUrl", book.bookUrl) + } intent.apply(configIntent) startActivity(intent) } diff --git a/app/src/main/res/layout-land/view_read_menu.xml b/app/src/main/res/layout-land/view_read_menu.xml deleted file mode 100644 index 9c5615c3a..000000000 --- a/app/src/main/res/layout-land/view_read_menu.xml +++ /dev/null @@ -1,223 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_book_read.xml b/app/src/main/res/layout/activity_book_read.xml deleted file mode 100644 index 38f299f34..000000000 --- a/app/src/main/res/layout/activity_book_read.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_chapter_change_source.xml b/app/src/main/res/layout/dialog_chapter_change_source.xml deleted file mode 100644 index 501fed4dd..000000000 --- a/app/src/main/res/layout/dialog_chapter_change_source.xml +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_content_edit.xml b/app/src/main/res/layout/dialog_content_edit.xml deleted file mode 100644 index 4b7163be5..000000000 --- a/app/src/main/res/layout/dialog_content_edit.xml +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_font_select.xml b/app/src/main/res/layout/dialog_font_select.xml deleted file mode 100644 index 39c8c1571..000000000 --- a/app/src/main/res/layout/dialog_font_select.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_http_tts_edit.xml b/app/src/main/res/layout/dialog_http_tts_edit.xml deleted file mode 100644 index 3609d6d8b..000000000 --- a/app/src/main/res/layout/dialog_http_tts_edit.xml +++ /dev/null @@ -1,157 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_page_key.xml b/app/src/main/res/layout/dialog_page_key.xml deleted file mode 100644 index 20d184c22..000000000 --- a/app/src/main/res/layout/dialog_page_key.xml +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_read_aloud.xml b/app/src/main/res/layout/dialog_read_aloud.xml deleted file mode 100644 index 744d34c34..000000000 --- a/app/src/main/res/layout/dialog_read_aloud.xml +++ /dev/null @@ -1,393 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_regex_color_config.xml b/app/src/main/res/layout/dialog_regex_color_config.xml deleted file mode 100644 index 7d2e0339c..000000000 --- a/app/src/main/res/layout/dialog_regex_color_config.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_shadow_set.xml b/app/src/main/res/layout/dialog_shadow_set.xml deleted file mode 100644 index 3b9146445..000000000 --- a/app/src/main/res/layout/dialog_shadow_set.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_bg_image.xml b/app/src/main/res/layout/item_bg_image.xml deleted file mode 100644 index 4f9996138..000000000 --- a/app/src/main/res/layout/item_bg_image.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_font.xml b/app/src/main/res/layout/item_font.xml deleted file mode 100644 index 8b3789aca..000000000 --- a/app/src/main/res/layout/item_font.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_regex_color_rule.xml b/app/src/main/res/layout/item_regex_color_rule.xml deleted file mode 100644 index 2b63ded82..000000000 --- a/app/src/main/res/layout/item_regex_color_rule.xml +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/view_read_menu.xml b/app/src/main/res/layout/view_read_menu.xml deleted file mode 100644 index b115e8737..000000000 --- a/app/src/main/res/layout/view_read_menu.xml +++ /dev/null @@ -1,191 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/view_search_menu.xml b/app/src/main/res/layout/view_search_menu.xml deleted file mode 100644 index 57372e6cd..000000000 --- a/app/src/main/res/layout/view_search_menu.xml +++ /dev/null @@ -1,157 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b01649198..fc7742545 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -651,6 +651,7 @@ 朗读引擎 背景图片 背景颜色 + 使用背景图片 正文 页脚字号 选择图片 @@ -742,6 +743,43 @@ 导入默认规则 名称 正则 + 高亮规则 + 新增规则 + 编辑规则 + 规则信息 + 样式设置 + 正则表达式 + 规则名称 + 作用范围 + 作用于全部 + 仅作用于标题 + 仅作用于正文 + 启用规则 + 下划线样式 + + 实线下划线 + 虚线下划线 + 波浪下划线 + 标题强调条 + 自定义SVG + 下划线宽度 + 下划线偏移 + SVG路径 + 坐标范围:X(0-100),Y(0-100) + 背景适配 + 背景图片 + 图片大小 + 平铺 + 拉伸填充 + 居中裁剪 + 预览效果 + 示例文本 + 确定要删除吗? + 保存到源文件,保存后重置功能将无效 + 添加正则规则 + 自定义正则规则 + 自定义规则 + 输入正则表达式 更多菜单 @@ -839,6 +877,21 @@ 恢复忽略列表 选择恢复忽略内容 阅读界面 + 全局 + 正文间距 + 正文字体 + 标题字体 + 菜单 + 斜体 + 正文间距 + 调整正文上下左右边距 + 特效 + 配置阴影距离、半径和颜色 + 配置下划线颜色、高度、虚线和位置 + 根据正则表达式更换字体与颜色 + 配置页眉页脚模块、显示规则和颜色 + 调整页眉页脚上下左右边距 + 分割线 分组名称 备注 默认启用替换净化 @@ -898,7 +951,7 @@ 无操作 正文标题 显示/隐藏 - 页眉与页脚 + 信息 规则 还没有规则订阅! 拉取云端进度 @@ -929,6 +982,9 @@ 崩溃日志 使用自定义中文分行 图片样式 + 全屏 + 文字嵌入 + 单页 系统 TTS 导出格式 校验作者 @@ -1155,6 +1211,11 @@ 源编辑框最大行数 %s,设置行数小于屏幕可显示的最大行数可以更方便的滑动到其他的字段进行编辑 是否恢复到跳转前的阅读进度? + 是否跳转到该章节? + 恢复进度 + 同步进度 + 发现云端进度,是否恢复? + 当前阅读进度已超过云端,是否覆盖? 搜索范围 切换 是否确认清除所有搜索历史记录 @@ -1407,7 +1468,7 @@ 输入数值(%1$d-%2$d) 图标已修改 配置替换规则 - 更多设置 + 更多 共 %d 章 已读 %d 章 墨水屏 @@ -1568,6 +1629,7 @@ 段落样式 已有相同书籍 下划线颜色 + 撑满整行 实线段长 空隙比例 线段高度 @@ -1788,6 +1850,51 @@ 返回原文 翻译中 %1$d%% 开始进行翻译,请耐心等待…… + 阅读顶栏和底栏背景色 + 色板来源 + 容器背景色 + 用于生成阅读菜单色板的基础表面色 + 阅读顶栏和底栏文字与图标颜色 + 图标样式 + 实时预览 + 显示图标文字 + 图标容器样式 + 无容器 + Tonal + Outlined + 每行图标数 + 图标行数 + 自定义图标 + 使用默认图标 + 已自定义 %1$d 个图标 + 顶栏图标 + 顶栏布局 + 显示浮动图标 + 顶栏图标位置 + 左上 + 右上 + 左下 + 右下 + 底栏布局 + 底栏圆角 + 悬浮底栏 + 模糊模式 + 关闭 + 为悬浮底栏应用液态玻璃效果,需要 Android 13 及以上系统 + 需要 Android 13 及以上系统 + 模糊样式 + 渐进式模糊 + 需要 Android 13 及以上系统 + 模糊半径 + 模糊半径越大,系统运行越卡顿 + 菜单不透明度 + 边框 + 宽度 + 颜色 + 实心模糊 + 液态玻璃按钮 + 模糊效果 + 液态玻璃背景 实验室 diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index f3c6b003b..27891dc33 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -14,4 +14,9 @@ - \ No newline at end of file + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef27356fb..c9f749024 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -672,6 +672,7 @@ Speak Engine Background Images Background color + Use Background Image Text Color Header Footer @@ -768,6 +769,43 @@ Import default rules Name Regex + Highlight Rules + New Rule + Edit Rule + Rule Info + Style Settings + Regex Pattern + Rule Name + Target Scope + All + Title only + Body only + Enable Rule + Underline Style + None + Solid + Dashed + Wave + Title Bar + Custom SVG + Underline Width + Underline Offset + SVG Path + Coordinates: X(0-100), Y(0-100) + Background Fit + Background Image + Image Scale + Tile + Stretch + Center Crop + Preview + Sample Text + Are you sure you want to delete? + Save to source file (reset will be unavailable) + Add Regex Rule + Custom Regex Rule + Custom Rule + Input regex pattern Example More menu Minus @@ -864,6 +902,21 @@ Bypass List Ignore some contents while restoring Reading Interface Settings + Global & Theme + Layout & Spacing + Text & Effects + Title Settings + System & Menu + Italic + Body Spacing + Adjust body top, bottom, left, and right margins + Effects + Configure shadow offsets, radius, and color + Configure underline color, height, dash, and position + Change font and color by regular expression + Configure header/footer modules, visibility, and colors + Adjust header and footer top, bottom, left, and right margins + Divider Line Group name Remarks section Enable Replace Rules by Default @@ -959,6 +1012,9 @@ Crash log Custom Chinese Line Breaks Style of Images + Full screen + Text adhesion + Single page System tts Exported file format Check by author @@ -1185,6 +1241,11 @@ Maximum Number of Rows in the Source Edit Box %s,Setting the number of rows less than the maximum number of rows that can be displayed on the screen makes it easier to slide to other fields for editing. Whether to return to the reading progress before the jump? + Skip to this chapter? + Restore Progress + Sync Progress + Cloud progress found, restore? + Current progress exceeds cloud, overwrite? Search scope Handover Are you sure to clear all search history @@ -1602,6 +1663,7 @@ Paragraph Style A book with the same name already exists Underline Color + Extend Underline Dash Length Gap Length Underline Height @@ -1794,6 +1856,51 @@ · In effect · Blocked · Already joined + Background for the top and bottom reader bars + Palette Source + Container Background Color + Base surface color for the generated reader menu palette + Text and icon color for the top and bottom reader bars + Icon Style + Live preview + Show icon labels + Icon container style + Plain + Tonal + Outlined + Icons per row + Icon rows + Custom icons + Use default icons + %1$d custom icons + Title Bar Icons + Title Bar Layout + Show floating icons + Title bar icon position + Top left + Top right + Bottom left + Bottom right + Bottom Bar Layout + Bottom bar corner radius + Floating bottom bar + Menu blur mode + None + Use liquid glass for the floating bottom bar. Requires Android 13+ + Liquid glass bottom buttons + Apply liquid glass to bottom bar buttons and the slider thumb. Requires Android 13+ + Blur style + Solid blur + Progressive blur + Apply liquid glass to title bar action buttons. Requires Android 13+ + Blur radius + Higher radius may reduce performance + Menu opacity + Border + Width + Color + Enable Blur + Liquid glass background Lab diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9ea8e6460..1c2cf4f29 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -86,7 +86,7 @@ reorderable = "3.1.0" haze = "1.7.2" timber = "5.0.1" miuix = "0.9.1" -backdrop = "1.0.6" +backdrop = "2.0.0" capsule = "2.1.3" lyricViewx = "1.3.2" uiautomator = "2.3.0"