From 64f7de8120531fc302d42214f705e5ec667a2e74 Mon Sep 17 00:00:00 2001 From: HlllllH <70612933+325506@users.noreply.github.com> Date: Sun, 10 May 2026 04:38:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E5=A4=9A=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=20@325506=20(#994)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 第一次上传 * 新增深度个性化设置-可以给主题选择自定义字体了 * 新增深度个性化设置-存在背景图片时,应用栏和导航栏会对背景图片进行模糊处理 * 新增深度个性化设置-主题颜色高度自定义 * 新增深度个性化设置-新增边框设置 * 合并后bug修复 # Conflicts: # app/build.gradle.kts # app/google-services.json # app/src/main/java/io/legado/app/constant/PreferKey.kt # app/src/main/java/io/legado/app/di/appModule.kt # app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt # app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt # app/src/main/java/io/legado/app/ui/book/group/GroupEditSheet.kt # app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt # app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt # app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt # app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt # app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt # app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt # app/src/main/java/io/legado/app/ui/book/manga/config/MangaClickActionConfigDialog.kt # app/src/main/java/io/legado/app/ui/book/read/config/ClickActionConfigDialog.kt # app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt # app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt # app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt # app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt # app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt # app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt # app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt # app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt # app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigScreen.kt # app/src/main/java/io/legado/app/ui/config/otherConfig/CheckSourceBottomSheet.kt # app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt # app/src/main/java/io/legado/app/ui/config/readConfig/PageKeySheet.kt # app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt # app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt # app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt # app/src/main/java/io/legado/app/ui/main/MainActivity.kt # app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt # app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt # app/src/main/java/io/legado/app/ui/main/bookshelf/GroupManageSheet.kt # app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt # app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt # app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt # app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt # app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt # app/src/main/java/io/legado/app/ui/theme/AdaptivePadding.kt # app/src/main/java/io/legado/app/ui/theme/AppBackground.kt # app/src/main/java/io/legado/app/ui/theme/AppTheme.kt # app/src/main/java/io/legado/app/ui/theme/HazeStyle.kt # app/src/main/java/io/legado/app/ui/theme/ImageSeedColorExtractor.kt # app/src/main/java/io/legado/app/ui/theme/LegadoTheme.kt # app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt # app/src/main/java/io/legado/app/ui/theme/ThemeOverride.kt # app/src/main/java/io/legado/app/ui/theme/Typography.kt # app/src/main/java/io/legado/app/ui/theme/hazeStyle/HazeLegado.kt # app/src/main/java/io/legado/app/ui/widget/components/AppNavigationBar.kt # app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt # app/src/main/java/io/legado/app/ui/widget/components/AppTextField.kt # app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt # app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt # app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt # app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt # app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt # app/src/main/java/io/legado/app/ui/widget/components/button/ToggleChip.kt # app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt # app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt # app/src/main/java/io/legado/app/ui/widget/components/card/SettingCard.kt # app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt # app/src/main/java/io/legado/app/ui/widget/components/checkBox/CheckboxItem.kt # app/src/main/java/io/legado/app/ui/widget/components/cover/BookCover.kt # app/src/main/java/io/legado/app/ui/widget/components/cover/BookshelfCover.kt # app/src/main/java/io/legado/app/ui/widget/components/cover/Cover.kt # app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt # app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenu.kt # app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt # app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt # app/src/main/java/io/legado/app/ui/widget/components/settingItem/ClickableSettingItem.kt # app/src/main/java/io/legado/app/ui/widget/components/settingItem/SliderSettingItem.kt # app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt # app/src/main/java/io/legado/app/ui/widget/components/topbar/GlassMediumFlexibleTopAppBar.kt # app/src/main/res/drawable/ic_add.xml # app/src/main/res/drawable/ic_add_online.xml # app/src/main/res/drawable/ic_arrange.xml # app/src/main/res/drawable/ic_arrow_down.xml # app/src/main/res/drawable/ic_arrow_right.xml # app/src/main/res/drawable/ic_cfg_about.xml # app/src/main/res/drawable/ic_cfg_backup.xml # app/src/main/res/drawable/ic_cfg_other.xml # app/src/main/res/drawable/ic_cfg_replace.xml # app/src/main/res/drawable/ic_cfg_source.xml # app/src/main/res/drawable/ic_cfg_theme.xml # app/src/main/res/drawable/ic_cfg_web.xml # app/src/main/res/drawable/ic_groups.xml # app/src/main/res/drawable/ic_help.xml # app/src/main/res/drawable/ic_more.xml # app/src/main/res/drawable/ic_search.xml # app/src/main/res/drawable/ic_view_quilt.xml # app/src/main/res/layout/dialog_tip_config.xml # app/src/main/res/layout/view_preference.xml # app/src/main/res/layout/view_preference_category.xml # app/src/main/res/values-zh-rCN/strings.xml # app/src/main/res/values-zh-rHK/strings.xml # app/src/main/res/values-zh-rTW/strings.xml # app/src/main/res/values/arrays.xml # app/src/main/res/values/strings.xml # app/src/main/res/xml/pref_main.xml # avd.sh # gradle.properties # gradle/libs.versions.toml # gradle/wrapper/gradle-wrapper.properties # gradlew # modules/web/index.html * 边框设置bug修复 # Conflicts: # app/build.gradle.kts # app/google-services.json # app/src/main/java/io/legado/app/constant/PreferKey.kt # app/src/main/java/io/legado/app/di/appModule.kt # app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt # app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt # app/src/main/java/io/legado/app/ui/book/group/GroupEditSheet.kt # app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt # app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt # app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt # app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt # app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt # app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt # app/src/main/java/io/legado/app/ui/book/manga/config/MangaClickActionConfigDialog.kt # app/src/main/java/io/legado/app/ui/book/read/config/ClickActionConfigDialog.kt # app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt # app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt # app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt # app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt # app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt # app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt # app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt # app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt # app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigScreen.kt # app/src/main/java/io/legado/app/ui/config/otherConfig/CheckSourceBottomSheet.kt # app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt # app/src/main/java/io/legado/app/ui/config/readConfig/PageKeySheet.kt # app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt # app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt # app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt # app/src/main/java/io/legado/app/ui/main/MainActivity.kt # app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt # app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt # app/src/main/java/io/legado/app/ui/main/bookshelf/GroupManageSheet.kt # app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt # app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt # app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt # app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt # app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt # app/src/main/java/io/legado/app/ui/theme/AdaptivePadding.kt # app/src/main/java/io/legado/app/ui/theme/AppBackground.kt # app/src/main/java/io/legado/app/ui/theme/AppTheme.kt # app/src/main/java/io/legado/app/ui/theme/HazeStyle.kt # app/src/main/java/io/legado/app/ui/theme/ImageSeedColorExtractor.kt # app/src/main/java/io/legado/app/ui/theme/LegadoTheme.kt # app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt # app/src/main/java/io/legado/app/ui/theme/ThemeOverride.kt # app/src/main/java/io/legado/app/ui/theme/Typography.kt # app/src/main/java/io/legado/app/ui/theme/hazeStyle/HazeLegado.kt # app/src/main/java/io/legado/app/ui/widget/components/AppNavigationBar.kt # app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt # app/src/main/java/io/legado/app/ui/widget/components/AppTextField.kt # app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt # app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt # app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt # app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt # app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt # app/src/main/java/io/legado/app/ui/widget/components/button/ToggleChip.kt # app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt # app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt # app/src/main/java/io/legado/app/ui/widget/components/card/SettingCard.kt # app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt # app/src/main/java/io/legado/app/ui/widget/components/checkBox/CheckboxItem.kt # app/src/main/java/io/legado/app/ui/widget/components/cover/BookCover.kt # app/src/main/java/io/legado/app/ui/widget/components/cover/BookshelfCover.kt # app/src/main/java/io/legado/app/ui/widget/components/cover/Cover.kt # app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt # app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenu.kt # app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt # app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt # app/src/main/java/io/legado/app/ui/widget/components/settingItem/ClickableSettingItem.kt # app/src/main/java/io/legado/app/ui/widget/components/settingItem/SliderSettingItem.kt # app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt # app/src/main/java/io/legado/app/ui/widget/components/topbar/GlassMediumFlexibleTopAppBar.kt # app/src/main/res/drawable/ic_add.xml # app/src/main/res/drawable/ic_add_online.xml # app/src/main/res/drawable/ic_arrange.xml # app/src/main/res/drawable/ic_arrow_down.xml # app/src/main/res/drawable/ic_arrow_right.xml # app/src/main/res/drawable/ic_cfg_about.xml # app/src/main/res/drawable/ic_cfg_backup.xml # app/src/main/res/drawable/ic_cfg_other.xml # app/src/main/res/drawable/ic_cfg_replace.xml # app/src/main/res/drawable/ic_cfg_source.xml # app/src/main/res/drawable/ic_cfg_theme.xml # app/src/main/res/drawable/ic_cfg_web.xml # app/src/main/res/drawable/ic_groups.xml # app/src/main/res/drawable/ic_help.xml # app/src/main/res/drawable/ic_more.xml # app/src/main/res/drawable/ic_search.xml # app/src/main/res/drawable/ic_view_quilt.xml # app/src/main/res/layout/dialog_tip_config.xml # app/src/main/res/layout/view_preference.xml # app/src/main/res/layout/view_preference_category.xml # app/src/main/res/values-zh-rCN/strings.xml # app/src/main/res/values-zh-rHK/strings.xml # app/src/main/res/values-zh-rTW/strings.xml # app/src/main/res/values/arrays.xml # app/src/main/res/values/strings.xml # app/src/main/res/xml/pref_main.xml # avd.sh # gradle.properties # gradle/libs.versions.toml # gradle/wrapper/gradle-wrapper.properties # gradlew # modules/web/index.html * 实现播放音频显示歌词,但是布局需要调整 * 优化音频显示布局 * 合并代码 * 合并主题色设置,新增主题保存 * 修改一下颜色设置 * 边框bug修复 * 悬浮胶囊主题颜色修复 * 给发现和订阅项增加边框 * 内置浏览器设置非全屏 * 实现正则表达式匹配字段高亮 * 增加自定义正则表达式匹配颜色 * 正则表达式配置持久化保存 * 更新下载路径 * 修复png透明图片 * 修改当前版本号 * 同步lyc版本订阅源功能 * 同步lyc版本订阅源功能 * 合并后修复 * 书架样式调整,但有bug * 合并后提交 * bug修复 * 订阅源日志修复 * 合并后修复 * 正文标题可以自定义字体 * 列表模式可以显示更多内容 * 实现自定义标签 * 主题背景和封面背景支持动图 * 自定义标签颜色 * 自定义标签颜色 * 主题保存bug修复 * 实现个性化主题设置的备份与恢复 * 页眉页脚可自定义字号,颜色,字体 * 边距设置 * bug修复 * 主题应用bug修复 * 虚线延展 * 模糊效果设置 * 字体页面新增搜索 * 描述性文字添加 * 列表简介内容显示 * 主题导入bug修复 * 标题可以自定义字体颜色 * 匹配自定义正则表达式规则文字的字体和颜色 * 匹配自定义正则表达式规则文字的字体和颜色bug修复 * 自定义底栏图标 * 自定义底栏图标bug修复 * 实现主题设置统一管理 * 实现主题设置统一管理界面美化 * 文件夹列表模式显示美化 * 底栏bug修复 * 底栏bug修复 * 底栏液化玻璃参数提取 * 主题管理删除当前阅读背景保存 * 主题管理bug修复 * 使用自定义封面时不动态取色 * 规范性更改 * 正确的数据库迁移schema * 移动设置项 * 书架优化 * 书架优化 * 删除 EmptyMessageView.kt — 已有 EmptyMessage 完全覆盖且功能更全,该文件为无效重复 EmptyMessageView 与 EmptyMessage 实现了完全相同的空状态 UI(Kaomoji 动画、加载指示器、消息文字),但 EmptyMessageView 缺少 buttonText/buttonImageVector/onButtonClick 参数,无任何调用者。 * 消除重复代码:提取共享的 loadFontFiles、JsonObject 扩展函数,删除无效重复的 EmptyMessageView 和 BookGroupUi 常量 - 删除 EmptyMessageView.kt(已有 EmptyMessage 完全覆盖,零调用者) - 提取 loadFontFiles 到 help/FontLoader.kt(消除 ThemeConfigScreen 和 FontSelectScreen 间的重复) - 提取 JsonObject.getString/getInt/getFloat/getBoolean 到 utils/JsonExtensions.kt(消除 3 个配置类中的三份相同实现) - 移除 BookGroupUi 中与 BookGroup 实体重复的 12 个 Id* 常量,改用直接引用 * 拆分大文件:提取 ThemePackSerializer 和 ThemeAppearanceJson 到独立文件 - 提取 ThemePackSerializer 到 help/config/ThemePackSerializer.kt(106 行),从 ThemePackConfig.kt 移除 - 提取 ThemeAppearanceJson 序列化对象到 help/config/ThemeAppearanceJson.kt(140 行),从 ThemeAppearanceConfig.kt 移除 - ThemePackConfig 从 440 行减少到约 325 行,ThemeAppearanceConfig 从 267 行减少到约 117 行 BookshelfViewModel 的拆分留待后续——涉及状态管理 refactor 需要更深入的并发模型理解。 * 提取共用模式:添加 SplicedColumnDivider 和 secondaryColorOr 共用函数 - SplicedColumnGroup.kt 新增 SplicedColumnDivider() composable,消除 5 个 SettingItem 文件中各 7 行重复的 divider 前导代码 - GlassDefaults.kt 新增 secondaryColorOr() composable,统一处理深度个性化 secondaryThemeColor 覆盖逻辑(该模式曾出现 12 次) TypefaceLoader 提取跳过——三个调用点(PageView/TextColumn/ChapterProvider)在缓存策略和系统字体回退上存在差异,需更仔细的并发分析。 * 修复架构问题:移动 RegexColorRule 和 TagColorGenerator 到正确包,提取 Haze containerColor - 将 RegexColorRule 从 ui/book/read/config 移至 help/config,消除 ReadBookConfig 对 UI 层的反向依赖 - 将 TagColorGenerator 从 help 移至 help/config,消除对 ui.config.themeConfig 的跨层级依赖 - HazeStyle 中 3 处重复的 containerColor 解析改用 GlassDefaults.secondaryColorOr * 书架优化 * 书架优化 * 书架优化 --------- Co-authored-by: HapeLee <63206378+HapeLee@users.noreply.github.com> --- .claude/settings.local.json | 32 +- CLAUDE.md | 3 + app/build.gradle.kts | 1 + .../io.legado.app.data.AppDatabase/86.json | 2154 +++++++++++++++++ .../java/io/legado/app/constant/PreferKey.kt | 33 + .../java/io/legado/app/data/AppDatabase.kt | 5 +- .../java/io/legado/app/data/dao/BookDao.kt | 327 ++- .../io/legado/app/data/entities/RssSource.kt | 29 + .../java/io/legado/app/help/FontLoader.kt | 35 + .../java/io/legado/app/help/JsExtensions.kt | 4 +- .../help/config/PersonalizationThemeConfig.kt | 144 ++ .../legado/app/help/config/ReadBookConfig.kt | 100 +- .../legado/app/help/config/ReadTipConfig.kt | 12 +- .../legado/app/help/config/RegexColorRule.kt | 8 + .../app/help/config/TagColorGenerator.kt | 33 + .../app/help/config/ThemeAppearanceConfig.kt | 109 + .../app/help/config/ThemeAppearanceJson.kt | 159 ++ .../legado/app/help/config/ThemePackConfig.kt | 327 +++ .../app/help/config/ThemePackSerializer.kt | 83 + .../java/io/legado/app/help/storage/Backup.kt | 4 + .../legado/app/help/storage/BackupConfig.kt | 13 +- .../io/legado/app/help/storage/Restore.kt | 11 + .../legado/app/help/update/AppUpdateGitHub.kt | 12 +- .../app/help/webView/WebJsExtensions.kt | 324 ++- .../java/io/legado/app/model/AudioPlay.kt | 21 +- .../main/java/io/legado/app/model/Debug.kt | 84 + .../main/java/io/legado/app/model/rss/Rss.kt | 10 +- .../legado/app/model/webBook/BookContent.kt | 11 +- .../io/legado/app/ui/about/AboutScreen.kt | 101 +- .../legado/app/ui/about/CrashReportScreen.kt | 2 +- .../app/ui/book/audio/AudioPlayActivity.kt | 82 +- .../legado/app/ui/book/info/BookInfoScreen.kt | 23 +- .../app/ui/book/info/BookInfoViewModel.kt | 17 +- .../ui/book/info/edit/BookInfoEditScreen.kt | 119 + .../book/info/edit/BookInfoEditViewModel.kt | 8 + .../app/ui/book/read/ReadBookActivity.kt | 35 +- .../ui/book/read/config/FontConfigDialog.kt | 4 + .../ui/book/read/config/FontSelectDialog.kt | 23 +- .../ui/book/read/config/InfoConfigDialog.kt | 69 +- .../read/config/RegexColorConfigDialog.kt | 204 ++ .../ui/book/read/config/TipConfigDialog.kt | 42 +- .../book/read/config/UnderlineConfigDialog.kt | 7 + .../legado/app/ui/book/read/page/PageView.kt | 98 +- .../ui/book/read/page/entities/TextLine.kt | 16 +- .../ui/book/read/page/entities/TextPage.kt | 9 +- .../read/page/entities/column/TextColumn.kt | 77 +- .../read/page/provider/ChapterProvider.kt | 41 +- .../read/page/provider/TextChapterLayout.kt | 140 +- .../source/edit/BookSourceEditActivity.kt | 2 + .../io/legado/app/ui/config/PrefDelegate.kt | 4 + .../config/bookshelfConfig/BookshelfConfig.kt | 10 + .../ui/config/coverConfig/CoverManageSheet.kt | 4 +- .../config/customTheme/CustomThemeScreen.kt | 391 +++ .../app/ui/config/mainConfig/MainConfig.kt | 4 + .../personalizationConfig/FontSelectScreen.kt | 185 ++ .../themeConfig/BackgroundImageManageSheet.kt | 6 +- .../themeConfig/LabelColorManageSheet.kt | 153 ++ .../config/themeConfig/NavIconManageSheet.kt | 148 ++ .../app/ui/config/themeConfig/ThemeConfig.kt | 85 +- .../config/themeConfig/ThemeConfigScreen.kt | 1141 +++++---- .../ui/config/themeConfig/ThemeListDialog.kt | 161 -- .../ui/config/themePack/ThemePackScreen.kt | 372 +++ .../io/legado/app/ui/main/MainActivity.kt | 81 +- .../java/io/legado/app/ui/main/MainScreen.kt | 330 ++- .../app/ui/main/bookshelf/BookGroupUi.kt | 25 + .../legado/app/ui/main/bookshelf/BookItem.kt | 160 +- .../app/ui/main/bookshelf/BookShelfItem.kt | 4 +- .../ui/main/bookshelf/BookshelfConfigSheet.kt | 45 + .../app/ui/main/bookshelf/BookshelfScreen.kt | 51 +- .../app/ui/main/bookshelf/BookshelfUiState.kt | 12 +- .../ui/main/bookshelf/BookshelfViewModel.kt | 203 +- .../app/ui/main/explore/ExploreScreen.kt | 25 +- .../app/ui/rss/article/RssArticlesCompose.kt | 41 +- .../ui/rss/article/RssArticlesViewModel.kt | 13 +- .../legado/app/ui/rss/article/RssStartPage.kt | 274 +++ .../legado/app/ui/rss/read/RssJsExtensions.kt | 14 +- .../source/debug/RssSourceDebugActivity.kt | 74 +- .../rss/source/debug/RssSourceDebugModel.kt | 9 + .../rss/source/edit/RssSourceEditActivity.kt | 65 +- .../io/legado/app/ui/theme/AppBackground.kt | 3 +- .../java/io/legado/app/ui/theme/AppTheme.kt | 379 ++- .../java/io/legado/app/ui/theme/HazeStyle.kt | 37 +- .../app/ui/theme/ThemeColorSchemeOverride.kt | 22 +- .../java/io/legado/app/ui/theme/Typography.kt | 40 +- .../app/ui/theme/hazeStyle/HazeLegado.kt | 25 +- .../legado/app/ui/widget/SimpleCounterView.kt | 2 +- .../ui/widget/components/AppNavigationBar.kt | 77 +- .../app/ui/widget/components/AppScaffold.kt | 148 +- .../ui/widget/components/FloatingBottomBar.kt | 27 +- .../app/ui/widget/components/GlassDefaults.kt | 10 +- .../widget/components/SplicedColumnGroup.kt | 118 +- .../ui/widget/components/card/GlassCard.kt | 22 +- .../app/ui/widget/components/card/TextCard.kt | 7 +- .../components/divider/SettingItemDivider.kt | 45 + .../components/explore/ExploreKindItem.kt | 19 +- .../explore/ExploreKindMultiTypeItem.kt | 9 +- .../app/ui/widget/components/icon/AppIcons.kt | 2 +- .../settingItem/ClickableSettingItem.kt | 3 + .../settingItem/CompactSettingItems.kt | 38 + .../settingItem/InputSettingItem.kt | 2 + .../components/settingItem/ListSettingItem.kt | 2 + .../settingItem/SliderSettingItem.kt | 103 +- .../settingItem/SwitchSettingItem.kt | 2 + .../topbar/GlassMediumFlexibleTopAppBar.kt | 79 +- .../ui/widget/dialog/BottomWebViewDialog.kt | 3 + .../app/ui/widget/image/CoverImageView.kt | 4 + .../java/io/legado/app/utils/BitmapUtils.kt | 1 + .../io/legado/app/utils/ContextExtensions.kt | 13 + .../io/legado/app/utils/JsonExtensions.kt | 48 + app/src/main/res/drawable/ic_add.xml | 6 +- app/src/main/res/drawable/ic_add_online.xml | 6 +- app/src/main/res/drawable/ic_arrange.xml | 6 +- app/src/main/res/drawable/ic_arrow_down.xml | 6 +- app/src/main/res/drawable/ic_arrow_right.xml | 6 +- app/src/main/res/drawable/ic_cfg_about.xml | 6 +- app/src/main/res/drawable/ic_cfg_backup.xml | 6 +- app/src/main/res/drawable/ic_cfg_other.xml | 6 +- app/src/main/res/drawable/ic_cfg_replace.xml | 6 +- app/src/main/res/drawable/ic_cfg_source.xml | 4 +- app/src/main/res/drawable/ic_cfg_theme.xml | 6 +- app/src/main/res/drawable/ic_cfg_web.xml | 20 +- app/src/main/res/drawable/ic_code.xml | 7 + app/src/main/res/drawable/ic_groups.xml | 6 +- app/src/main/res/drawable/ic_help.xml | 6 +- app/src/main/res/drawable/ic_more.xml | 6 +- app/src/main/res/drawable/ic_search.xml | 6 +- app/src/main/res/drawable/ic_title_font.xml | 11 + app/src/main/res/drawable/ic_view_quilt.xml | 6 +- .../main/res/layout/activity_audio_play.xml | 23 +- .../res/layout/activity_rss_source_edit.xml | 54 + .../main/res/layout/dialog_font_config.xml | 7 + .../main/res/layout/dialog_font_select.xml | 36 + app/src/main/res/layout/dialog_read_info.xml | 43 +- .../main/res/layout/dialog_read_padding.xml | 24 +- .../res/layout/dialog_regex_color_config.xml | 51 + app/src/main/res/layout/dialog_tip_config.xml | 40 +- .../res/layout/dialog_underline_config.xml | 9 + .../main/res/layout/item_regex_color_rule.xml | 75 + app/src/main/res/layout/view_preference.xml | 120 +- .../res/layout/view_preference_category.xml | 36 +- .../main/res/layout/view_simple_counter.xml | 1 - app/src/main/res/menu/source_edit.xml | 5 + app/src/main/res/values-zh-rCN/strings.xml | 9 +- app/src/main/res/values-zh-rHK/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/arrays.xml | 14 + app/src/main/res/values/strings.xml | 156 +- app/src/main/res/values/styles.xml | 2 + app/src/main/res/xml/pref_main.xml | 2 +- gradle/libs.versions.toml | 2 + 150 files changed, 9630 insertions(+), 1800 deletions(-) create mode 100644 app/schemas/io.legado.app.data.AppDatabase/86.json create mode 100644 app/src/main/java/io/legado/app/help/FontLoader.kt create mode 100644 app/src/main/java/io/legado/app/help/config/PersonalizationThemeConfig.kt create mode 100644 app/src/main/java/io/legado/app/help/config/RegexColorRule.kt create mode 100644 app/src/main/java/io/legado/app/help/config/TagColorGenerator.kt create mode 100644 app/src/main/java/io/legado/app/help/config/ThemeAppearanceConfig.kt create mode 100644 app/src/main/java/io/legado/app/help/config/ThemeAppearanceJson.kt create mode 100644 app/src/main/java/io/legado/app/help/config/ThemePackConfig.kt create mode 100644 app/src/main/java/io/legado/app/help/config/ThemePackSerializer.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/read/config/RegexColorConfigDialog.kt create mode 100644 app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeScreen.kt create mode 100644 app/src/main/java/io/legado/app/ui/config/personalizationConfig/FontSelectScreen.kt create mode 100644 app/src/main/java/io/legado/app/ui/config/themeConfig/LabelColorManageSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/config/themeConfig/NavIconManageSheet.kt delete mode 100644 app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeListDialog.kt create mode 100644 app/src/main/java/io/legado/app/ui/config/themePack/ThemePackScreen.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/bookshelf/BookGroupUi.kt create mode 100644 app/src/main/java/io/legado/app/ui/rss/article/RssStartPage.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/divider/SettingItemDivider.kt create mode 100644 app/src/main/res/drawable/ic_code.xml create mode 100644 app/src/main/res/drawable/ic_title_font.xml create mode 100644 app/src/main/res/layout/dialog_regex_color_config.xml create mode 100644 app/src/main/res/layout/item_regex_color_rule.xml diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a2ce29d65..44e239764 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,37 @@ "Bash(cp -r \"D:/AndroidPrj/legado-with-MD3/.codex/skills/legado-compose-review/references\" \"D:/AndroidPrj/legado-with-MD3/.claude/skills/legado-compose-review/\")", "WebSearch", "Bash(./gradlew assembleAppDebug)", - "Bash(git checkout *)" + "Bash(git checkout *)", + "Bash(./gradlew :app:compileAppDebugKotlin --no-daemon)", + "Bash(Select-Object -Last 50)", + "PowerShell(./gradlew :app:compileAppDebugKotlin --no-daemon 2>&1 | Select-Object -Last 50)", + "PowerShell(./gradlew :app:compileAppDebugKotlin --rerun-tasks 2>&1 | Select-Object -Last 30)", + "PowerShell(./gradlew :app:compileAppDebugKotlin --rerun-tasks 2>&1 | Select-Object -Last 40)", + "PowerShell(./gradlew :app:compileAppDebugKotlin --rerun-tasks 2>&1 | Select-Object -Last 15)", + "PowerShell(./gradlew :app:compileAppDebugKotlin --rerun-tasks 2>&1 | Select-Object -Last 5)", + "Bash(./gradlew assembleAppNoR8)", + "Bash(.\\\\gradlew.bat :app:compileAppDebugKotlin)", + "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 30)", + "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 15)", + "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 10)", + "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 8)", + "Bash(Get-ChildItem \"app/src/main/java/io/legado/app/ui/main/bookshelf/\" -Name)", + "Bash(Sort-Object)", + "Bash(git rm *)", + "Bash(git commit *)", + "Bash(Select-Object -Last 30)", + "Bash(./gradlew.bat :app:compileAppDebugKotlin)", + "Bash(git add *)", + "Bash(git mv *)", + "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1)", + "WebFetch(domain:cs.android.com)", + "WebFetch(domain:github.com)", + "PowerShell(Get-ChildItem *)", + "PowerShell(Get-ChildItem -Path \"D:\\\\Android\\\\.gradle\\\\caches\\\\modules-2\\\\files-2.1\\\\androidx.navigation3\\\\navigation3-runtime-android\\\\1.0.1\" -Recurse | ForEach-Object { $_.FullName + \" \\(\" + $_.Length + \" bytes\\)\" })", + "Bash(jar tf *)", + "Read(//d/tmp/**)", + "Bash(jar xf *)", + "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 01a462f4f..4bc42530f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,9 @@ Transform tasks into verifiable goals: ## Build / Test / Run ```bash +# Quick compile check (Kotlin only, no dex/package — fastest for verifying code compiles) +.\gradlew.bat :app:compileAppDebugKotlin + # Assemble all variants ./gradlew assembleAppRelease diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2323cb69a..694cbaf48 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -278,5 +278,6 @@ dependencies { implementation(libs.miuix.shapes.android) implementation(libs.capsule) implementation(libs.backdrop) + implementation(libs.lyricViewx) implementation(libs.timber) } diff --git a/app/schemas/io.legado.app.data.AppDatabase/86.json b/app/schemas/io.legado.app.data.AppDatabase/86.json new file mode 100644 index 000000000..85c7e5844 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/86.json @@ -0,0 +1,2154 @@ +{ + "formatVersion": 1, + "database": { + "version": 86, + "identityHash": "3796a7cd8db90f34502bed88600a006f", + "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`)" + } + ] + }, + { + "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, 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" + } + ], + "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, 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" + } + ], + "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`)" + } + ] + } + ], + "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, '3796a7cd8db90f34502bed88600a006f')" + ] + } +} \ No newline at end of file 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 527b43f0c..ddf3fdc4d 100644 --- a/app/src/main/java/io/legado/app/constant/PreferKey.kt +++ b/app/src/main/java/io/legado/app/constant/PreferKey.kt @@ -4,6 +4,7 @@ package io.legado.app.constant object PreferKey { const val language = "language" const val fontScale = "fontScale" + const val appFontPath = "appFontPath" const val appTheme = "app_theme" const val themeMode = "themeMode" const val userAgent = "userAgent" @@ -101,6 +102,7 @@ object PreferKey { const val replaceEnableDefault = "replaceEnableDefault" const val showBrightnessView = "showBrightnessView" const val useUnderline = "useUnderline" + const val regexColorRules = "regexColorRules" const val adaptSpecialStyle = "adaptSpecialStyle" const val autoClearExpired = "autoClearExpired" const val autoChangeSource = "autoChangeSource" @@ -193,9 +195,32 @@ object PreferKey { const val bottomBarOpacity = "bottomBarOpacity" const val enableBlur = "enableBlur" const val enableProgressiveBlur = "enableProgressiveBlur" + const val topBarBlurRadius = "topBarBlurRadius" + const val bottomBarBlurRadius = "bottomBarBlurRadius" + const val topBarBlurAlpha = "topBarBlurAlpha" + const val bottomBarBlurAlpha = "bottomBarBlurAlpha" + const val bottomBarLensRadius = "bottomBarLensRadius" const val useFlexibleTopAppBar = "useFlexibleTopAppBar" const val cBackground = "colorBackground" const val cBBackground = "colorBottomBackground" + const val enableDeepPersonalization = "enableDeepPersonalization" + const val themeColor = "colorMD3Primary" + const val secondaryThemeColor = "colorMD3Secondary" + const val primaryTextColor = "colorMD3OnSurface" + const val secondaryTextColor = "colorMD3OnPrimaryContainer" + const val themeBackgroundColor = "colorMD3Background" + const val labelContainerColor = "colorMD3SurfaceContainerLow" + const val enableContainerBorder = "enableContainerBorder" + const val containerBorderWidth = "containerBorderWidth" + const val containerBorderStyle = "containerBorderStyle" + const val containerBorderColor = "containerBorderColor" + const val containerBorderDashWidth = "containerBorderDashWidth" + // 中间单线间隔设置 + const val enableItemDivider = "enableItemDivider" + const val itemDividerWidth = "itemDividerWidth" + const val itemDividerLength = "itemDividerLength" + const val itemDividerColor = "itemDividerColor" + const val bookInfoInputColor = "colorBookInfoInput" const val bgImage = "backgroundImage" const val bgImageBlurring = "backgroundImageBlurring" const val cNPrimary = "colorPrimaryNight" @@ -274,8 +299,16 @@ object PreferKey { const val showTip = "showTip" const val showBookCount = "showBookCount" + const val showBookIntro = "showBookIntro" + const val bookshelfIntroMaxLines = "bookshelfIntroMaxLines" const val sliderVibrator = "sliderVibrator" const val selectVibrator = "selectVibrator" const val audioCacheCleanTime = "audioCacheCleanTime" const val audioPreDownloadNum = "audioPreDownloadNum" + const val customTagColors = "customTagColors" + const val enableCustomTagColors = "enableCustomTagColors" + const val navIconBookshelf = "navIconBookshelf" + const val navIconExplore = "navIconExplore" + const val navIconRss = "navIconRss" + const val navIconMy = "navIconMy" } 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 93ca714e6..4f3595ec5 100644 --- a/app/src/main/java/io/legado/app/data/AppDatabase.kt +++ b/app/src/main/java/io/legado/app/data/AppDatabase.kt @@ -69,7 +69,7 @@ val appDb by lazy { } @Database( - version = 85, + version = 86, exportSchema = true, entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class, ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class, @@ -121,7 +121,8 @@ val appDb by lazy { AutoMigration(from = 81, to = 82), AutoMigration(from = 82, to = 83), AutoMigration(from = 83, to = 84), - AutoMigration(from = 84, to = 85) + AutoMigration(from = 84, to = 85), + AutoMigration(from = 85, to = 86) ] ) abstract class AppDatabase : RoomDatabase() { diff --git a/app/src/main/java/io/legado/app/data/dao/BookDao.kt b/app/src/main/java/io/legado/app/data/dao/BookDao.kt index f557ff3bc..31b933143 100644 --- a/app/src/main/java/io/legado/app/data/dao/BookDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/BookDao.kt @@ -17,6 +17,11 @@ import io.legado.app.ui.main.bookshelf.BookShelfItem import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +data class GroupBookCount( + val groupId: Long, + val count: Int +) + @Dao interface BookDao { @@ -91,7 +96,9 @@ interface BookDao { type, `group`, `order`, - canUpdate + canUpdate, + ifnull(customIntro, intro) as intro, + kind FROM books where type & ${BookType.text} > 0 and type & ${BookType.local} = 0 @@ -125,7 +132,9 @@ interface BookDao { type, `group`, `order`, - canUpdate + canUpdate, + ifnull(customIntro, intro) as intro, + kind FROM books ORDER BY durChapterTime DESC """ @@ -156,8 +165,10 @@ interface BookDao { type, `group`, `order`, - canUpdate - FROM books + canUpdate, + ifnull(customIntro, intro) as intro, + kind + FROM books WHERE type & ${BookType.audio} > 0 """ ) @@ -187,7 +198,9 @@ interface BookDao { type, `group`, `order`, - canUpdate + canUpdate, + ifnull(customIntro, intro) as intro, + kind FROM books WHERE type & ${BookType.local} > 0 """ @@ -223,7 +236,9 @@ interface BookDao { type, `group`, `order`, - canUpdate + canUpdate, + ifnull(customIntro, intro) as intro, + kind FROM books where type & ${BookType.audio} = 0 and type & ${BookType.local} = 0 and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0 @@ -260,7 +275,9 @@ interface BookDao { type, `group`, `order`, - canUpdate + canUpdate, + ifnull(customIntro, intro) as intro, + kind FROM books where type & ${BookType.local} > 0 and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0 @@ -292,7 +309,9 @@ interface BookDao { type, `group`, `order`, - canUpdate + canUpdate, + ifnull(customIntro, intro) as intro, + kind FROM books WHERE (`group` & :group) > 0 """ @@ -325,7 +344,9 @@ interface BookDao { type, `group`, `order`, - canUpdate + canUpdate, + ifnull(customIntro, intro) as intro, + kind FROM books WHERE name like '%'||:key||'%' or author like '%'||:key||'%' or originName like '%'||:key||'%' """ @@ -356,7 +377,9 @@ interface BookDao { type, `group`, `order`, - canUpdate + canUpdate, + ifnull(customIntro, intro) as intro, + kind FROM books where type & ${BookType.updateError} > 0 order by durChapterTime desc @@ -388,7 +411,9 @@ interface BookDao { type, `group`, `order`, - canUpdate + canUpdate, + ifnull(customIntro, intro) as intro, + kind FROM books WHERE durChapterIndex = 0 AND durChapterPos = 0 """ @@ -419,7 +444,9 @@ interface BookDao { type, `group`, `order`, - canUpdate + canUpdate, + ifnull(customIntro, intro) as intro, + kind FROM books WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1 """ @@ -450,7 +477,9 @@ interface BookDao { type, `group`, `order`, - canUpdate + canUpdate, + ifnull(customIntro, intro) as intro, + kind FROM books WHERE totalChapterNum > 0 AND durChapterIndex > 0 AND durChapterIndex < totalChapterNum - 1 """ @@ -481,7 +510,9 @@ interface BookDao { type, `group`, `order`, - canUpdate + canUpdate, + ifnull(customIntro, intro) as intro, + kind FROM books WHERE type & ${BookType.image} > 0 """ @@ -512,7 +543,9 @@ interface BookDao { type, `group`, `order`, - canUpdate + canUpdate, + ifnull(customIntro, intro) as intro, + kind FROM books WHERE type & ${BookType.text} > 0 """ @@ -627,4 +660,268 @@ interface BookDao { @Query("delete from books where type & ${BookType.notShelf} > 0") fun deleteNotShelfBook() + + // ── Group preview / count queries (DB-level, replaces in-memory buildGroupPreviewState) ── + + @Query("SELECT COUNT(*) FROM books") + fun flowAllBookShelfCount(): Flow + + @Query( + """ + SELECT ${BookGroup.IdAll} AS groupId, COUNT(*) AS count FROM books + UNION ALL SELECT ${BookGroup.IdRoot}, COUNT(*) FROM books + WHERE type & ${BookType.text} > 0 AND type & ${BookType.local} = 0 + AND ((SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0) & `group`) = 0 + AND (SELECT show FROM book_groups WHERE groupId = ${BookGroup.IdNetNone}) != 1 + UNION ALL SELECT ${BookGroup.IdLocal}, COUNT(*) FROM books WHERE type & ${BookType.local} > 0 + UNION ALL SELECT ${BookGroup.IdAudio}, COUNT(*) FROM books WHERE type & ${BookType.audio} > 0 + UNION ALL SELECT ${BookGroup.IdNetNone}, COUNT(*) FROM books + WHERE type & ${BookType.audio} = 0 AND type & ${BookType.local} = 0 + AND ((SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0) & `group`) = 0 + UNION ALL SELECT ${BookGroup.IdLocalNone}, COUNT(*) FROM books + WHERE type & ${BookType.local} > 0 + AND ((SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0) & `group`) = 0 + UNION ALL SELECT ${BookGroup.IdManga}, COUNT(*) FROM books WHERE type & ${BookType.image} > 0 + UNION ALL SELECT ${BookGroup.IdText}, COUNT(*) FROM books WHERE type & ${BookType.text} > 0 + UNION ALL SELECT ${BookGroup.IdError}, COUNT(*) FROM books WHERE type & ${BookType.updateError} > 0 + UNION ALL SELECT ${BookGroup.IdUnread}, COUNT(*) FROM books WHERE durChapterIndex = 0 AND durChapterPos = 0 + UNION ALL SELECT ${BookGroup.IdReading}, COUNT(*) FROM books WHERE totalChapterNum > 0 AND durChapterIndex > 0 AND durChapterIndex < totalChapterNum - 1 + UNION ALL SELECT ${BookGroup.IdReadFinished}, COUNT(*) FROM books WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1 + """ + ) + fun flowSystemGroupCounts(): Flow> + + @Query("SELECT COUNT(*) FROM books WHERE (`group` & :groupId) > 0") + fun flowUserGroupBookCount(groupId: Long): Flow + + fun flowGroupPreview(groupId: Long): Flow> { + return when (groupId) { + BookGroup.IdRoot -> flowBookShelfRootPreview() + BookGroup.IdAll -> flowBookShelfPreview() + BookGroup.IdLocal -> flowBookShelfLocalPreview() + BookGroup.IdAudio -> flowBookShelfAudioPreview() + BookGroup.IdNetNone -> flowBookShelfNetNoGroupPreview() + BookGroup.IdLocalNone -> flowBookShelfLocalNoGroupPreview() + BookGroup.IdManga -> flowBookShelfMangaPreview() + BookGroup.IdText -> flowBookShelfTextPreview() + BookGroup.IdError -> flowBookShelfUpdateErrorPreview() + BookGroup.IdUnread -> flowBookShelfUnreadPreview() + BookGroup.IdReading -> flowBookShelfReadingPreview() + BookGroup.IdReadFinished -> flowBookShelfReadFinishedPreview() + else -> flowBookShelfPreviewByUserGroup(groupId) + }.map { list -> + list.filterNot { it.isNotShelf } + } + } + + @Query( + """ + SELECT bookUrl, name, author, origin, originName, + coverUrl, customCoverUrl, durChapterTitle, durChapterTime, + durChapterPos, latestChapterTitle, latestChapterTime, + lastCheckCount, totalChapterNum, durChapterIndex, + type, `group`, `order`, canUpdate, + ifnull(customIntro, intro) as intro, kind + FROM books + ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC + LIMIT 4 + """ + ) + fun flowBookShelfPreview(): Flow> + + @Query( + """ + SELECT bookUrl, name, author, origin, originName, + coverUrl, customCoverUrl, durChapterTitle, durChapterTime, + durChapterPos, latestChapterTitle, latestChapterTime, + lastCheckCount, totalChapterNum, durChapterIndex, + type, `group`, `order`, canUpdate, + ifnull(customIntro, intro) as intro, kind + FROM books + WHERE type & ${BookType.text} > 0 AND type & ${BookType.local} = 0 + AND ((SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0) & `group`) = 0 + AND (SELECT show FROM book_groups WHERE groupId = ${BookGroup.IdNetNone}) != 1 + ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC + LIMIT 4 + """ + ) + fun flowBookShelfRootPreview(): Flow> + + @Query( + """ + SELECT bookUrl, name, author, origin, originName, + coverUrl, customCoverUrl, durChapterTitle, durChapterTime, + durChapterPos, latestChapterTitle, latestChapterTime, + lastCheckCount, totalChapterNum, durChapterIndex, + type, `group`, `order`, canUpdate, + ifnull(customIntro, intro) as intro, kind + FROM books + WHERE type & ${BookType.local} > 0 + ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC + LIMIT 4 + """ + ) + fun flowBookShelfLocalPreview(): Flow> + + @Query( + """ + SELECT bookUrl, name, author, origin, originName, + coverUrl, customCoverUrl, durChapterTitle, durChapterTime, + durChapterPos, latestChapterTitle, latestChapterTime, + lastCheckCount, totalChapterNum, durChapterIndex, + type, `group`, `order`, canUpdate, + ifnull(customIntro, intro) as intro, kind + FROM books + WHERE type & ${BookType.audio} > 0 + ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC + LIMIT 4 + """ + ) + fun flowBookShelfAudioPreview(): Flow> + + @Query( + """ + SELECT bookUrl, name, author, origin, originName, + coverUrl, customCoverUrl, durChapterTitle, durChapterTime, + durChapterPos, latestChapterTitle, latestChapterTime, + lastCheckCount, totalChapterNum, durChapterIndex, + type, `group`, `order`, canUpdate, + ifnull(customIntro, intro) as intro, kind + FROM books + WHERE type & ${BookType.audio} = 0 AND type & ${BookType.local} = 0 + AND ((SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0) & `group`) = 0 + ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC + LIMIT 4 + """ + ) + fun flowBookShelfNetNoGroupPreview(): Flow> + + @Query( + """ + SELECT bookUrl, name, author, origin, originName, + coverUrl, customCoverUrl, durChapterTitle, durChapterTime, + durChapterPos, latestChapterTitle, latestChapterTime, + lastCheckCount, totalChapterNum, durChapterIndex, + type, `group`, `order`, canUpdate, + ifnull(customIntro, intro) as intro, kind + FROM books + WHERE type & ${BookType.local} > 0 + AND ((SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0) & `group`) = 0 + ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC + LIMIT 4 + """ + ) + fun flowBookShelfLocalNoGroupPreview(): Flow> + + @Query( + """ + SELECT bookUrl, name, author, origin, originName, + coverUrl, customCoverUrl, durChapterTitle, durChapterTime, + durChapterPos, latestChapterTitle, latestChapterTime, + lastCheckCount, totalChapterNum, durChapterIndex, + type, `group`, `order`, canUpdate, + ifnull(customIntro, intro) as intro, kind + FROM books + WHERE type & ${BookType.image} > 0 + ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC + LIMIT 4 + """ + ) + fun flowBookShelfMangaPreview(): Flow> + + @Query( + """ + SELECT bookUrl, name, author, origin, originName, + coverUrl, customCoverUrl, durChapterTitle, durChapterTime, + durChapterPos, latestChapterTitle, latestChapterTime, + lastCheckCount, totalChapterNum, durChapterIndex, + type, `group`, `order`, canUpdate, + ifnull(customIntro, intro) as intro, kind + FROM books + WHERE type & ${BookType.text} > 0 + ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC + LIMIT 4 + """ + ) + fun flowBookShelfTextPreview(): Flow> + + @Query( + """ + SELECT bookUrl, name, author, origin, originName, + coverUrl, customCoverUrl, durChapterTitle, durChapterTime, + durChapterPos, latestChapterTitle, latestChapterTime, + lastCheckCount, totalChapterNum, durChapterIndex, + type, `group`, `order`, canUpdate, + ifnull(customIntro, intro) as intro, kind + FROM books + WHERE type & ${BookType.updateError} > 0 + ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC + LIMIT 4 + """ + ) + fun flowBookShelfUpdateErrorPreview(): Flow> + + @Query( + """ + SELECT bookUrl, name, author, origin, originName, + coverUrl, customCoverUrl, durChapterTitle, durChapterTime, + durChapterPos, latestChapterTitle, latestChapterTime, + lastCheckCount, totalChapterNum, durChapterIndex, + type, `group`, `order`, canUpdate, + ifnull(customIntro, intro) as intro, kind + FROM books + WHERE durChapterIndex = 0 AND durChapterPos = 0 + ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC + LIMIT 4 + """ + ) + fun flowBookShelfUnreadPreview(): Flow> + + @Query( + """ + SELECT bookUrl, name, author, origin, originName, + coverUrl, customCoverUrl, durChapterTitle, durChapterTime, + durChapterPos, latestChapterTitle, latestChapterTime, + lastCheckCount, totalChapterNum, durChapterIndex, + type, `group`, `order`, canUpdate, + ifnull(customIntro, intro) as intro, kind + FROM books + WHERE totalChapterNum > 0 AND durChapterIndex > 0 AND durChapterIndex < totalChapterNum - 1 + ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC + LIMIT 4 + """ + ) + fun flowBookShelfReadingPreview(): Flow> + + @Query( + """ + SELECT bookUrl, name, author, origin, originName, + coverUrl, customCoverUrl, durChapterTitle, durChapterTime, + durChapterPos, latestChapterTitle, latestChapterTime, + lastCheckCount, totalChapterNum, durChapterIndex, + type, `group`, `order`, canUpdate, + ifnull(customIntro, intro) as intro, kind + FROM books + WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1 + ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC + LIMIT 4 + """ + ) + fun flowBookShelfReadFinishedPreview(): Flow> + + @Query( + """ + SELECT bookUrl, name, author, origin, originName, + coverUrl, customCoverUrl, durChapterTitle, durChapterTime, + durChapterPos, latestChapterTitle, latestChapterTime, + lastCheckCount, totalChapterNum, durChapterIndex, + type, `group`, `order`, canUpdate, + ifnull(customIntro, intro) as intro, kind + FROM books + WHERE (`group` & :groupId) > 0 + ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC + LIMIT 4 + """ + ) + fun flowBookShelfPreviewByUserGroup(groupId: Long): Flow> } diff --git a/app/src/main/java/io/legado/app/data/entities/RssSource.kt b/app/src/main/java/io/legado/app/data/entities/RssSource.kt index e6fe7e9f7..e4f348ce4 100644 --- a/app/src/main/java/io/legado/app/data/entities/RssSource.kt +++ b/app/src/main/java/io/legado/app/data/entities/RssSource.kt @@ -86,12 +86,32 @@ data class RssSource( var loadWithBaseUrl: Boolean = true, /**注入js**/ var injectJs: String? = null, + /**提前预注入js**/ + var preloadJs: String? = null, + /**web形式起始页**/ + var startHtml: String? = null, + var startStyle: String? = null, + var startJs: String? = null, + /**是否输出web网页日志**/ + @ColumnInfo(defaultValue = "0") + var showWebLog: Boolean = false, /*其它规则*/ /**最后更新时间,用于排序**/ @ColumnInfo(defaultValue = "0") var lastUpdateTime: Long = 0, @ColumnInfo(defaultValue = "0") var customOrder: Int = 0, + /**类型 0网页,1图片,2视频**/ + @ColumnInfo(defaultValue = "0") + var type: Int = 0, + /**是否启用预加载**/ + @ColumnInfo(defaultValue = "0") + var preload: Boolean = false, + /**是否优先加载缓存**/ + @ColumnInfo(defaultValue = "0") + var cacheFirst: Boolean = false, + /**搜索url**/ + var searchUrl: String? = null, //用户控制的重定向 @ColumnInfo(defaultValue = "ASK_CROSS_ORIGIN") var redirectPolicy: String = "ASK_CROSS_ORIGIN" @@ -143,6 +163,15 @@ data class RssSource( && equal(variableComment, source.variableComment) && equal(style, source.style) && equal(injectJs, source.injectJs) + && equal(preloadJs, source.preloadJs) + && equal(startHtml, source.startHtml) + && equal(startStyle, source.startStyle) + && equal(startJs, source.startJs) + && showWebLog == source.showWebLog + && type == source.type + && preload == source.preload + && cacheFirst == source.cacheFirst + && equal(searchUrl, source.searchUrl) } private fun equal(a: String?, b: String?): Boolean { diff --git a/app/src/main/java/io/legado/app/help/FontLoader.kt b/app/src/main/java/io/legado/app/help/FontLoader.kt new file mode 100644 index 000000000..256abe9fa --- /dev/null +++ b/app/src/main/java/io/legado/app/help/FontLoader.kt @@ -0,0 +1,35 @@ +package io.legado.app.help + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import io.legado.app.utils.FileDoc +import io.legado.app.utils.listFileDocs +import java.io.File + +fun loadFontFiles(context: Context, folderUri: Uri?): List { + val fontRegex = Regex("(?i).*\\.[ot]tf") + if (folderUri != null) { + val documentFile = DocumentFile.fromTreeUri(context, folderUri) + return documentFile?.let { docFile -> + val fontFiles = mutableListOf() + docFile.listFiles()?.forEach { file -> + if (file.isFile && file.name?.matches(fontRegex) == true) { + fontFiles.add( + FileDoc( + name = file.name ?: "", + isDir = false, + size = file.length(), + lastModified = file.lastModified(), + uri = file.uri + ) + ) + } + } + fontFiles + } ?: emptyList() + } else { + val fontPath = "${context.getExternalFilesDir(null)?.absolutePath}/font" + return File(fontPath).listFileDocs { it.name.matches(fontRegex) } + } +} 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 502d57d99..60831624d 100644 --- a/app/src/main/java/io/legado/app/help/JsExtensions.kt +++ b/app/src/main/java/io/legado/app/help/JsExtensions.kt @@ -1105,7 +1105,7 @@ interface JsExtensions : JsEncodeUtils { * 弹窗提示 */ fun toast(msg: Any?) { - rhinoContext.ensureActive() + rhinoContextOrNull?.ensureActive() appCtx.toastForJs("${getSource()?.getTag()}: ${msg.toString()}") } @@ -1113,7 +1113,7 @@ interface JsExtensions : JsEncodeUtils { * 弹窗提示 停留时间较长 */ fun longToast(msg: Any?) { - rhinoContext.ensureActive() + rhinoContextOrNull?.ensureActive() appCtx.longToastForJs("${getSource()?.getTag()}: ${msg.toString()}") } diff --git a/app/src/main/java/io/legado/app/help/config/PersonalizationThemeConfig.kt b/app/src/main/java/io/legado/app/help/config/PersonalizationThemeConfig.kt new file mode 100644 index 000000000..1d77afc83 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/config/PersonalizationThemeConfig.kt @@ -0,0 +1,144 @@ +package io.legado.app.help.config + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer +import io.legado.app.utils.FileUtils +import io.legado.app.utils.getString +import splitties.init.appCtx +import java.io.File +import java.lang.reflect.Type + +object PersonalizationThemeConfig { + const val configFileName = "personalizationThemeConfig.json" + val configFilePath = FileUtils.getPath(appCtx.filesDir, configFileName) + + private val THEME_GSON: Gson by lazy { + GsonBuilder() + .registerTypeAdapter(Config::class.java, ConfigSerializer()) + .setPrettyPrinting() + .disableHtmlEscaping() + .create() + } + + private val _configList: ArrayList by lazy { + ArrayList(getConfigs() ?: emptyList()) + } + + val configList: List get() = _configList + + fun save() { + val json = THEME_GSON.toJson(_configList) + FileUtils.delete(configFilePath) + FileUtils.createFileIfNotExist(configFilePath).writeText(json) + } + + fun toJson(config: Config): String { + return THEME_GSON.toJson(config) + } + + fun toJson(): String { + return THEME_GSON.toJson(_configList) + } + + fun fromJson(json: String): Boolean { + return kotlin.runCatching { + val configs = THEME_GSON.fromJson(json, Array::class.java) + _configList.clear() + _configList.addAll(configs) + save() + true + }.getOrDefault(false) + } + + fun delConfig(index: Int) { + if (index in _configList.indices) { + _configList.removeAt(index) + save() + } + } + + fun addConfig(json: String): Boolean { + return kotlin.runCatching { + val config = THEME_GSON.fromJson(json, Config::class.java) + addConfig(config) + true + }.getOrDefault(false) + } + + fun addConfig(newConfig: Config) { + _configList.forEachIndexed { index, config -> + if (newConfig.themeName == config.themeName) { + _configList[index] = newConfig + save() + return + } + } + _configList.add(newConfig) + save() + } + + private fun getConfigs(): List? { + val configFile = File(configFilePath) + if (configFile.exists()) { + kotlin.runCatching { + val json = configFile.readText() + return THEME_GSON.fromJson(json, Array::class.java).toList() + } + } + return null + } + + fun applyConfig(config: Config) { + config.appearance.applyToThemeConfig() + } + + fun savePersonalizationTheme(name: String): Config { + val config = Config( + themeName = name, + appearance = ThemeAppearanceConfig.fromCurrent() + ) + addConfig(config) + return config + } + + data class Config( + var themeName: String = "", + var appearance: ThemeAppearanceConfig = ThemeAppearanceConfig() + ) { + val themeColor: Int get() = appearance.themeColor + } +} + +class ConfigSerializer : JsonSerializer, + JsonDeserializer { + + override fun serialize( + src: PersonalizationThemeConfig.Config, + typeOfSrc: Type, + context: JsonSerializationContext + ): JsonElement { + val obj = JsonObject() + obj.addProperty("themeName", src.themeName) + ThemeAppearanceJson.writeTo(obj, src.appearance) + return obj + } + + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): PersonalizationThemeConfig.Config { + val obj = json.asJsonObject + return PersonalizationThemeConfig.Config( + themeName = obj.getString("themeName", "") ?: "", + appearance = ThemeAppearanceJson.readFrom(obj) + ) + } + +} 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 a680f6e8a..2aad1d834 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 @@ -26,10 +26,12 @@ import io.legado.app.utils.getFile import io.legado.app.utils.getMeanColor import io.legado.app.utils.getPrefBoolean 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 @@ -226,6 +228,12 @@ object ReadBookConfig { appCtx.putPrefBoolean(PreferKey.shareLayout, value) } } + + val regexColorRules: ArrayList get() = durConfig.regexColorRules + + fun saveRegexColorRules() { + save() + } /** * 两端对齐 @@ -260,6 +268,36 @@ object ReadBookConfig { config.textFont = value } + var titleFont: String + get() = config.titleFont + set(value) { + config.titleFont = value + } + + var headerFont: String + get() = config.headerFont + set(value) { + config.headerFont = value + } + + var footerFont: String + get() = config.footerFont + set(value) { + config.footerFont = value + } + + var headerFontSize: Int + get() = config.headerFontSize.takeIf { it > 0 } ?: 12 + set(value) { + config.headerFontSize = value + } + + var footerFontSize: Int + get() = config.footerFontSize.takeIf { it > 0 } ?: 12 + set(value) { + config.footerFontSize = value + } + var textBold: Int get() = config.textBold set(value) { @@ -393,6 +431,12 @@ object ReadBookConfig { config.titleBottomSpacing = value } + var titleColor: Int + get() = config.titleColor + set(value) { + config.titleColor = value + } + var paragraphIndent: String get() = config.paragraphIndent set(value) { @@ -417,6 +461,12 @@ object ReadBookConfig { config.underlinePadding = value } + var underlineExtend: Boolean + get() = config.underlineExtend + set(value) { + config.underlineExtend = value + } + var dottedLine: Boolean get() = config.dottedLine set(value) { @@ -544,9 +594,14 @@ object ReadBookConfig { } fun getExportConfig(): Config { - val exportConfig = durConfig.copy() + val exportConfig = durConfig.copy(regexColorRules = ArrayList(durConfig.regexColorRules.map { it.copy() })) if (shareLayout) { exportConfig.textFont = shareConfig.textFont + exportConfig.titleFont = shareConfig.titleFont + exportConfig.headerFont = shareConfig.headerFont + exportConfig.footerFont = shareConfig.footerFont + exportConfig.headerFontSize = shareConfig.headerFontSize + exportConfig.footerFontSize = shareConfig.footerFontSize exportConfig.textBold = shareConfig.textBold exportConfig.textSize = shareConfig.textSize exportConfig.letterSpacing = shareConfig.letterSpacing @@ -556,6 +611,7 @@ object ReadBookConfig { exportConfig.titleSize = shareConfig.titleSize exportConfig.titleTopSpacing = shareConfig.titleTopSpacing exportConfig.titleBottomSpacing = shareConfig.titleBottomSpacing + exportConfig.titleColor = shareConfig.titleColor exportConfig.paddingBottom = shareConfig.paddingBottom exportConfig.paddingLeft = shareConfig.paddingLeft exportConfig.paddingRight = shareConfig.paddingRight @@ -576,7 +632,8 @@ object ReadBookConfig { exportConfig.tipFooterLeft = shareConfig.tipFooterLeft exportConfig.tipFooterMiddle = shareConfig.tipFooterMiddle exportConfig.tipFooterRight = shareConfig.tipFooterRight - exportConfig.tipColor = shareConfig.tipColor + exportConfig.tipHeaderColor = shareConfig.tipHeaderColor + exportConfig.tipFooterColor = shareConfig.tipFooterColor exportConfig.headerMode = shareConfig.headerMode // MD3专有属性 exportConfig.footerMode = shareConfig.footerMode @@ -628,6 +685,20 @@ object ReadBookConfig { 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 @@ -688,6 +759,11 @@ object ReadBookConfig { private var pageAnim: Int = 0,//翻页动画 private var pageAnimEInk: Int = 4, var textFont: String = "",//字体 + var titleFont: String = "",//标题字体 + var headerFont: String = "",//页眉字体 + var footerFont: String = "",//页脚字体 + var headerFontSize: Int = 12,//页眉字号 + var footerFontSize: Int = 12,//页脚字号 var textBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体 var textSize: Int = 20,//文字大小 var textItalic: Boolean = false,// 是否启用斜体 @@ -704,6 +780,7 @@ object ReadBookConfig { var titleSize: Int = 0, var titleTopSpacing: Int = 0, var titleBottomSpacing: Int = 0, + var titleColor: Int = 0, var titleBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体 var titleLineSpacingExtra: Int = 12, var titleLineSpacingSub: Int = 12, @@ -715,6 +792,7 @@ object ReadBookConfig { var underline: Boolean = false, //下划线 var underlinePadding: Int = 10, var underlineHeight: Int = 1, + var underlineExtend: Boolean = false, //下划线延伸 var underlineColor: String = "#3E3D3B", var underlineColorNight: String = "#ADADAD", var dottedLine: Boolean = false, //虚线 @@ -740,10 +818,12 @@ object ReadBookConfig { var tipFooterLeft: Int = ReadTipConfig.chapterTitle, var tipFooterMiddle: Int = ReadTipConfig.none, var tipFooterRight: Int = ReadTipConfig.pageAndTotal, - var tipColor: Int = 0, + var tipHeaderColor: Int = 0, + var tipFooterColor: Int = 0, var tipDividerColor: Int = -1, var headerMode: Int = 0, - var footerMode: Int = 0 + var footerMode: Int = 0, + var regexColorRules: ArrayList = arrayListOf() ) { @Transient @@ -821,6 +901,11 @@ object ReadBookConfig { "pageAnim" to pageAnim, "pageAnimEInk" to pageAnimEInk, "textFont" to textFont, + "titleFont" to titleFont, + "headerFont" to headerFont, + "footerFont" to footerFont, + "headerFontSize" to headerFontSize, + "footerFontSize" to footerFontSize, "textBold" to textBold, "textSize" to textSize, "letterSpacing" to letterSpacing, @@ -830,6 +915,7 @@ object ReadBookConfig { "titleSize" to titleSize, "titleTopSpacing" to titleTopSpacing, "titleBottomSpacing" to titleBottomSpacing, + "titleColor" to titleColor, "paragraphIndent" to paragraphIndent, "paddingBottom" to paddingBottom, "paddingLeft" to paddingLeft, @@ -851,10 +937,12 @@ object ReadBookConfig { "tipFooterLeft" to tipFooterLeft, "tipFooterMiddle" to tipFooterMiddle, "tipFooterRight" to tipFooterRight, - "tipColor" to tipColor, + "tipHeaderColor" to tipHeaderColor, + "tipFooterColor" to tipFooterColor, "tipDividerColor" to tipDividerColor, "headerMode" to headerMode, - "footerMode" to footerMode + "footerMode" to footerMode, + "regexColorRules" to regexColorRules.map { mapOf("name" to it.name, "pattern" to it.pattern, "color" to it.color, "fontPath" to it.fontPath) } ) fun getBgPath(bgIndex: Int): String? { 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 index 7ce3b63ec..f1c847ad4 100644 --- a/app/src/main/java/io/legado/app/help/config/ReadTipConfig.kt +++ b/app/src/main/java/io/legado/app/help/config/ReadTipConfig.kt @@ -85,10 +85,16 @@ object ReadTipConfig { ReadBookConfig.config.footerMode = value } - var tipColor: Int - get() = ReadBookConfig.config.tipColor + var tipHeaderColor: Int + get() = ReadBookConfig.config.tipHeaderColor set(value) { - ReadBookConfig.config.tipColor = value + ReadBookConfig.config.tipHeaderColor = value + } + + var tipFooterColor: Int + get() = ReadBookConfig.config.tipFooterColor + set(value) { + ReadBookConfig.config.tipFooterColor = value } var tipDividerColor: Int diff --git a/app/src/main/java/io/legado/app/help/config/RegexColorRule.kt b/app/src/main/java/io/legado/app/help/config/RegexColorRule.kt new file mode 100644 index 000000000..b81898da9 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/config/RegexColorRule.kt @@ -0,0 +1,8 @@ +package io.legado.app.help.config + +data class RegexColorRule( + var name: String, + var pattern: String, + var color: Int, + var fontPath: String = "" +) diff --git a/app/src/main/java/io/legado/app/help/config/TagColorGenerator.kt b/app/src/main/java/io/legado/app/help/config/TagColorGenerator.kt new file mode 100644 index 000000000..3d12fea6d --- /dev/null +++ b/app/src/main/java/io/legado/app/help/config/TagColorGenerator.kt @@ -0,0 +1,33 @@ +package io.legado.app.help.config + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.core.graphics.ColorUtils +import io.legado.app.ui.config.themeConfig.TagColorPair + +object TagColorGenerator { + + fun generateTagColors(baseColor: Color): List { + val baseHsl = FloatArray(3) + ColorUtils.colorToHSL(baseColor.toArgb(), baseHsl) + val baseHue = baseHsl[0] + + val result = mutableListOf() + + val hueOffsets = listOf(0, 45, 90, 135, 180, 225, 270, 315) + + hueOffsets.forEach { offset -> + val newHue = (baseHue + offset) % 360 + + val textHsl = floatArrayOf(newHue, 0.75f, 0.45f) + val textColor = Color.hsl(textHsl[0], textHsl[1], textHsl[2]).toArgb() + + val bgHsl = floatArrayOf(newHue, 0.35f, 0.90f) + val bgColor = Color.hsl(bgHsl[0], bgHsl[1], bgHsl[2]).toArgb() + + result.add(TagColorPair(textColor, bgColor)) + } + + return result + } +} diff --git a/app/src/main/java/io/legado/app/help/config/ThemeAppearanceConfig.kt b/app/src/main/java/io/legado/app/help/config/ThemeAppearanceConfig.kt new file mode 100644 index 000000000..71f6b4d44 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/config/ThemeAppearanceConfig.kt @@ -0,0 +1,109 @@ +package io.legado.app.help.config + +import io.legado.app.ui.config.themeConfig.ThemeConfig + +data class ThemeAppearanceConfig( + var enableDeepPersonalization: Boolean = false, + var themeColor: Int = 0, + var secondaryThemeColor: Int = 0, + var primaryTextColor: Int = 0, + var secondaryTextColor: Int = 0, + var backgroundColor: Int = 0, + var labelContainerColor: Int = 0, + var bookInfoInputColor: Int = 0, + var appFontPath: String? = null, + var enableContainerBorder: Boolean = false, + var containerBorderWidth: Float = 1f, + var containerBorderStyle: String = "solid", + var containerBorderDashWidth: Float = 4f, + var containerBorderColor: Int = 0, + var enableItemDivider: Boolean = true, + var itemDividerWidth: Float = 1f, + var itemDividerLength: Float = 80f, + var itemDividerColor: Int = 0, + var enableCustomTagColors: Boolean = false, + var customTagColorsJson: String? = null, + var enableBlur: Boolean = false, + var topBarBlurRadius: Int = 24, + var bottomBarBlurRadius: Int = 8, + var topBarBlurAlpha: Int = 73, + var bottomBarBlurAlpha: Int = 40, + var bottomBarLensRadius: Float = 24f +) { + + val hasDeepColorOverrides: Boolean + get() = themeColor != 0 || + secondaryThemeColor != 0 || + primaryTextColor != 0 || + secondaryTextColor != 0 || + backgroundColor != 0 || + labelContainerColor != 0 + + fun applyToThemeConfig(appFontPathOverride: String? = appFontPath) { + ThemeConfig.enableDeepPersonalization = enableDeepPersonalization + ThemeConfig.themeColor = themeColor + ThemeConfig.secondaryThemeColor = secondaryThemeColor + ThemeConfig.primaryTextColor = primaryTextColor + ThemeConfig.secondaryTextColor = secondaryTextColor + ThemeConfig.themeBackgroundColor = backgroundColor + ThemeConfig.labelContainerColor = labelContainerColor + + ThemeConfig.bookInfoInputColor = bookInfoInputColor + + ThemeConfig.appFontPath = appFontPathOverride + + ThemeConfig.enableContainerBorder = enableContainerBorder + ThemeConfig.containerBorderWidth = containerBorderWidth + ThemeConfig.containerBorderStyle = containerBorderStyle.ifBlank { "solid" } + ThemeConfig.containerBorderDashWidth = containerBorderDashWidth + ThemeConfig.containerBorderColor = containerBorderColor + + ThemeConfig.enableItemDivider = enableItemDivider + ThemeConfig.itemDividerWidth = itemDividerWidth + ThemeConfig.itemDividerLength = itemDividerLength + ThemeConfig.itemDividerColor = itemDividerColor + + ThemeConfig.enableCustomTagColors = enableCustomTagColors + ThemeConfig.customTagColorsJson = ThemeAppearanceJson.convertTagColorsFromHex(customTagColorsJson) + + ThemeConfig.enableBlur = enableBlur + ThemeConfig.topBarBlurRadius = topBarBlurRadius + ThemeConfig.bottomBarBlurRadius = bottomBarBlurRadius + ThemeConfig.topBarBlurAlpha = topBarBlurAlpha + ThemeConfig.bottomBarBlurAlpha = bottomBarBlurAlpha + ThemeConfig.bottomBarLensRadius = bottomBarLensRadius + } + + companion object { + fun fromCurrent(): ThemeAppearanceConfig { + return ThemeAppearanceConfig( + enableDeepPersonalization = ThemeConfig.enableDeepPersonalization, + themeColor = ThemeConfig.themeColor, + secondaryThemeColor = ThemeConfig.secondaryThemeColor, + primaryTextColor = ThemeConfig.primaryTextColor, + secondaryTextColor = ThemeConfig.secondaryTextColor, + backgroundColor = ThemeConfig.themeBackgroundColor, + labelContainerColor = ThemeConfig.labelContainerColor, + bookInfoInputColor = ThemeConfig.bookInfoInputColor, + appFontPath = ThemeConfig.appFontPath, + enableContainerBorder = ThemeConfig.enableContainerBorder, + containerBorderWidth = ThemeConfig.containerBorderWidth, + containerBorderStyle = ThemeConfig.containerBorderStyle, + containerBorderDashWidth = ThemeConfig.containerBorderDashWidth, + containerBorderColor = ThemeConfig.containerBorderColor, + enableItemDivider = ThemeConfig.enableItemDivider, + itemDividerWidth = ThemeConfig.itemDividerWidth, + itemDividerLength = ThemeConfig.itemDividerLength, + itemDividerColor = ThemeConfig.itemDividerColor, + enableCustomTagColors = ThemeConfig.enableCustomTagColors, + customTagColorsJson = ThemeConfig.customTagColorsJson, + enableBlur = ThemeConfig.enableBlur, + topBarBlurRadius = ThemeConfig.topBarBlurRadius, + bottomBarBlurRadius = ThemeConfig.bottomBarBlurRadius, + topBarBlurAlpha = ThemeConfig.topBarBlurAlpha, + bottomBarBlurAlpha = ThemeConfig.bottomBarBlurAlpha, + bottomBarLensRadius = ThemeConfig.bottomBarLensRadius + ) + } + } +} diff --git a/app/src/main/java/io/legado/app/help/config/ThemeAppearanceJson.kt b/app/src/main/java/io/legado/app/help/config/ThemeAppearanceJson.kt new file mode 100644 index 000000000..adbafcbd8 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/config/ThemeAppearanceJson.kt @@ -0,0 +1,159 @@ +package io.legado.app.help.config + +import androidx.core.graphics.toColorInt +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import io.legado.app.ui.config.themeConfig.TagColorPair +import io.legado.app.utils.GSON +import io.legado.app.utils.getBoolean +import io.legado.app.utils.getFloat +import io.legado.app.utils.getString + +object ThemeAppearanceJson { + + fun writeTo(obj: JsonObject, src: ThemeAppearanceConfig) { + obj.addProperty("enableDeepPersonalization", src.enableDeepPersonalization) + obj.addProperty("themeColor", colorToHex(src.themeColor)) + obj.addProperty("secondaryThemeColor", colorToHex(src.secondaryThemeColor)) + obj.addProperty("primaryTextColor", colorToHex(src.primaryTextColor)) + obj.addProperty("secondaryTextColor", colorToHex(src.secondaryTextColor)) + obj.addProperty("backgroundColor", colorToHex(src.backgroundColor)) + obj.addProperty("labelContainerColor", colorToHex(src.labelContainerColor)) + obj.addProperty("bookInfoInputColor", colorToHex(src.bookInfoInputColor)) + obj.addProperty("appFontPath", src.appFontPath) + obj.addProperty("enableContainerBorder", src.enableContainerBorder) + obj.addProperty("containerBorderWidth", src.containerBorderWidth) + obj.addProperty("containerBorderStyle", src.containerBorderStyle) + obj.addProperty("containerBorderDashWidth", src.containerBorderDashWidth) + obj.addProperty("containerBorderColor", colorToHex(src.containerBorderColor)) + obj.addProperty("enableItemDivider", src.enableItemDivider) + obj.addProperty("itemDividerWidth", src.itemDividerWidth) + obj.addProperty("itemDividerLength", src.itemDividerLength) + obj.addProperty("itemDividerColor", colorToHex(src.itemDividerColor)) + obj.addProperty("enableCustomTagColors", src.enableCustomTagColors) + obj.addProperty("customTagColorsJson", convertTagColorsToHex(src.customTagColorsJson)) + obj.addProperty("enableBlur", src.enableBlur) + obj.addProperty("topBarBlurRadius", src.topBarBlurRadius) + obj.addProperty("bottomBarBlurRadius", src.bottomBarBlurRadius) + obj.addProperty("topBarBlurAlpha", src.topBarBlurAlpha) + obj.addProperty("bottomBarBlurAlpha", src.bottomBarBlurAlpha) + obj.addProperty("bottomBarLensRadius", src.bottomBarLensRadius) + } + + fun readFrom(obj: JsonObject): ThemeAppearanceConfig { + val appearance = ThemeAppearanceConfig( + themeColor = readColor(obj, "themeColor", "md3Primary"), + secondaryThemeColor = readColor(obj, "secondaryThemeColor", "md3Secondary", "md3OnPrimary"), + primaryTextColor = readColor(obj, "primaryTextColor", "md3OnSurface"), + secondaryTextColor = readColor(obj, "secondaryTextColor", "md3OnPrimaryContainer"), + backgroundColor = readColor(obj, "backgroundColor", "md3Background"), + labelContainerColor = readColor(obj, "labelContainerColor", "md3SurfaceContainerLow"), + bookInfoInputColor = readColor(obj, "bookInfoInputColor"), + appFontPath = obj.getString("appFontPath", null), + enableContainerBorder = obj.getBoolean("enableContainerBorder", false), + containerBorderWidth = obj.getFloat("containerBorderWidth", 1f), + containerBorderStyle = obj.getString("containerBorderStyle", "solid") ?: "solid", + containerBorderDashWidth = obj.getFloat("containerBorderDashWidth", 4f), + containerBorderColor = readColor(obj, "containerBorderColor"), + enableItemDivider = obj.getBoolean("enableItemDivider", true), + itemDividerWidth = obj.getFloat("itemDividerWidth", 1f), + itemDividerLength = obj.getFloat("itemDividerLength", 80f), + itemDividerColor = readColor(obj, "itemDividerColor"), + enableCustomTagColors = obj.getBoolean("enableCustomTagColors", false), + customTagColorsJson = obj.getString("customTagColorsJson", null), + enableBlur = obj.getBoolean("enableBlur", false), + topBarBlurRadius = obj.getInt("topBarBlurRadius", 24), + bottomBarBlurRadius = obj.getInt("bottomBarBlurRadius", 8), + topBarBlurAlpha = obj.getInt("topBarBlurAlpha", 73), + bottomBarBlurAlpha = obj.getInt("bottomBarBlurAlpha", 40), + bottomBarLensRadius = obj.getFloat("bottomBarLensRadius", 24f) + ) + appearance.enableDeepPersonalization = if (obj.has("enableDeepPersonalization")) { + obj.getBoolean("enableDeepPersonalization", false) + } else { + appearance.hasDeepColorOverrides + } + return appearance + } + + fun convertTagColorsFromHex(json: String?): String? { + if (json.isNullOrBlank()) return json + return kotlin.runCatching { + val root = JsonParser.parseString(json) + if (root.isJsonArray) { + val arr = root.asJsonArray + val converted = arr.map { elem -> + val obj = elem.asJsonObject + val textColor = parseColorFromJson(obj.get("textColor")) + val bgColor = parseColorFromJson(obj.get("bgColor")) + """{"textColor":$textColor,"bgColor":$bgColor}""" + } + "[\n ${converted.joinToString(",\n ")}\n]" + } else json + }.getOrNull() ?: json + } + + fun readColor(obj: JsonObject, vararg names: String): Int { + for (name in names) { + val color = obj.getInt(name, Int.MIN_VALUE) + if (color != Int.MIN_VALUE) return color + } + return 0 + } + + fun colorToHex(color: Int): String = "#${String.format("%08X", color)}" + + private fun convertTagColorsToHex(json: String?): String? { + if (json.isNullOrBlank()) return json + return kotlin.runCatching { + val colors = GSON.fromJson(json, Array::class.java) + val converted = colors.map { pair -> + val textHex = colorToHex(pair.textColor) + val bgHex = colorToHex(pair.bgColor) + """{"textColor":"$textHex","bgColor":"$bgHex"}""" + } + "[\n ${converted.joinToString(",\n ")}\n]" + }.getOrNull() ?: json + } + + private fun parseColorFromJson(elem: JsonElement?): Int { + if (elem == null || elem.isJsonNull) return 0 + val prim = elem.asJsonPrimitive + return when { + prim.isNumber -> prim.asNumber.toInt() + prim.isString -> parseColorString(prim.asString) + else -> 0 + } + } + + private fun JsonObject.getInt(name: String, default: Int): Int { + val elem = get(name) + return when { + elem == null || elem.isJsonNull -> default + elem.isJsonPrimitive -> { + val prim = elem.asJsonPrimitive + when { + prim.isNumber -> prim.asNumber.toInt() + prim.isString -> parseColorString(prim.asString) + else -> default + } + } + else -> default + } + } + + private fun parseColorString(colorStr: String): Int { + val text = colorStr.trim() + if (text.isEmpty()) return 0 + return kotlin.runCatching { + if (text.startsWith("#")) { + text.substring(1).toLong(16).toInt() + } else { + text.toLong().toInt() + } + }.getOrElse { + kotlin.runCatching { text.toColorInt() }.getOrDefault(0) + } + } +} diff --git a/app/src/main/java/io/legado/app/help/config/ThemePackConfig.kt b/app/src/main/java/io/legado/app/help/config/ThemePackConfig.kt new file mode 100644 index 000000000..d712ef2cf --- /dev/null +++ b/app/src/main/java/io/legado/app/help/config/ThemePackConfig.kt @@ -0,0 +1,327 @@ +package io.legado.app.help.config + +import android.net.Uri +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import io.legado.app.ui.config.coverConfig.CoverConfig +import io.legado.app.ui.config.mainConfig.MainConfig +import io.legado.app.ui.config.themeConfig.ThemeConfig +import io.legado.app.utils.FileUtils +import io.legado.app.utils.externalFiles +import splitties.init.appCtx +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +object ThemePackConfig { + + private const val CONFIG_FILE = "theme.json" + private const val DIR_NAME = "theme_packs" + private val baseDir get() = File(appCtx.filesDir, DIR_NAME) + + private val PACK_GSON: Gson by lazy { + GsonBuilder() + .registerTypeAdapter(ThemePack::class.java, ThemePackSerializer()) + .setPrettyPrinting() + .disableHtmlEscaping() + .create() + } + + private val _packList = mutableListOf() + val packList: List get() = _packList + + init { + loadAll() + } + + private fun loadAll() { + _packList.clear() + baseDir.mkdirs() + baseDir.listFiles()?.forEach { dir -> + if (dir.isDirectory) { + val configFile = File(dir, CONFIG_FILE) + if (configFile.exists()) { + kotlin.runCatching { + val json = configFile.readText() + val pack = PACK_GSON.fromJson(json, ThemePack::class.java) + if (pack.name.isNotEmpty()) { + pack.folderName = dir.name + _packList.add(pack) + } + } + } + } + } + } + + fun reload() { + loadAll() + } + + fun getPackDir(pack: ThemePack): File { + return File(baseDir, pack.folderName) + } + + fun savePack(pack: ThemePack) { + val dir = getPackDir(pack) + dir.mkdirs() + val configFile = File(dir, CONFIG_FILE) + val json = PACK_GSON.toJson(pack) + FileUtils.createFileIfNotExist(configFile.absolutePath).writeText(json) + } + + fun deletePack(pack: ThemePack) { + val dir = getPackDir(pack) + if (dir.exists()) { + dir.deleteRecursively() + } + _packList.remove(pack) + } + + fun createFromCurrent(name: String): ThemePack { + val folderName = System.currentTimeMillis().toString() + val pack = ThemePack( + name = name, + folderName = folderName, + appearance = ThemeAppearanceConfig.fromCurrent(), + bgImageLight = ThemeConfig.bgImageLight, + bgImageDark = ThemeConfig.bgImageDark, + bgImageBlurring = ThemeConfig.bgImageBlurring, + bgImageNBlurring = ThemeConfig.bgImageNBlurring, + navIconBookshelf = MainConfig.navIconBookshelf, + navIconExplore = MainConfig.navIconExplore, + navIconRss = MainConfig.navIconRss, + navIconMy = MainConfig.navIconMy, + coverTextColor = CoverConfig.coverTextColor, + coverShadowColor = CoverConfig.coverShadowColor, + coverTextColorN = CoverConfig.coverTextColorN, + coverShadowColorN = CoverConfig.coverShadowColorN, + coverShowName = CoverConfig.coverShowName, + coverShowAuthor = CoverConfig.coverShowAuthor, + coverShowNameN = CoverConfig.coverShowNameN, + coverShowAuthorN = CoverConfig.coverShowAuthorN, + coverDefaultColor = CoverConfig.coverDefaultColor, + coverShowShadow = CoverConfig.coverShowShadow, + coverShowStroke = CoverConfig.coverShowStroke, + coverInfoOrientation = CoverConfig.coverInfoOrientation, + defaultCover = CoverConfig.defaultCover, + defaultCoverDark = CoverConfig.defaultCoverDark + ) + + val dir = File(baseDir, folderName) + dir.mkdirs() + + copyResourceFile(pack.appearance.appFontPath, dir, "app_font") + copyResourceFile(pack.bgImageLight, dir, "bg_light") + copyResourceFile(pack.bgImageDark, dir, "bg_dark") + copyResourceFile(pack.navIconBookshelf, dir, "nav_bookshelf") + copyResourceFile(pack.navIconExplore, dir, "nav_explore") + copyResourceFile(pack.navIconRss, dir, "nav_rss") + copyResourceFile(pack.navIconMy, dir, "nav_my") + + copyCoverImages(pack.defaultCover, dir, "cover_day") + copyCoverImages(pack.defaultCoverDark, dir, "cover_night") + + savePack(pack) + _packList.add(pack) + return pack + } + + fun applyPack(pack: ThemePack) { + val dir = getPackDir(pack) + val restoredAppearance = pack.appearance.copy( + appFontPath = restoreResourcePath(dir, "app_font", pack.appearance.appFontPath) + ) + restoredAppearance.applyToThemeConfig() + + ThemeConfig.bgImageLight = restoreResourcePath(dir, "bg_light", pack.bgImageLight) + ThemeConfig.bgImageDark = restoreResourcePath(dir, "bg_dark", pack.bgImageDark) + ThemeConfig.bgImageBlurring = pack.bgImageBlurring + ThemeConfig.bgImageNBlurring = pack.bgImageNBlurring + + MainConfig.navIconBookshelf = restoreResourcePath(dir, "nav_bookshelf", pack.navIconBookshelf) ?: "" + MainConfig.navIconExplore = restoreResourcePath(dir, "nav_explore", pack.navIconExplore) ?: "" + MainConfig.navIconRss = restoreResourcePath(dir, "nav_rss", pack.navIconRss) ?: "" + MainConfig.navIconMy = restoreResourcePath(dir, "nav_my", pack.navIconMy) ?: "" + + CoverConfig.coverTextColor = pack.coverTextColor + CoverConfig.coverShadowColor = pack.coverShadowColor + CoverConfig.coverTextColorN = pack.coverTextColorN + CoverConfig.coverShadowColorN = pack.coverShadowColorN + CoverConfig.coverShowName = pack.coverShowName + CoverConfig.coverShowAuthor = pack.coverShowAuthor + CoverConfig.coverShowNameN = pack.coverShowNameN + CoverConfig.coverShowAuthorN = pack.coverShowAuthorN + CoverConfig.coverDefaultColor = pack.coverDefaultColor + CoverConfig.coverShowShadow = pack.coverShowShadow + CoverConfig.coverShowStroke = pack.coverShowStroke + CoverConfig.coverInfoOrientation = pack.coverInfoOrientation + CoverConfig.defaultCover = restoreCoverImages(dir, "cover_day", pack.defaultCover) + CoverConfig.defaultCoverDark = restoreCoverImages(dir, "cover_night", pack.defaultCoverDark) + } + + fun exportToZip(pack: ThemePack, destFile: File) { + val dir = getPackDir(pack) + if (!dir.exists()) return + ZipOutputStream(FileOutputStream(destFile)).use { zos -> + dir.walkTopDown().forEach { file -> + if (file.isFile) { + val entryName = file.relativeTo(dir).path.replace('\\', '/') + zos.putNextEntry(ZipEntry(entryName)) + FileInputStream(file).use { fis -> + fis.copyTo(zos) + } + zos.closeEntry() + } + } + } + } + + fun importFromZip(zipFile: File): ThemePack? { + val folderName = System.currentTimeMillis().toString() + val dir = File(baseDir, folderName) + dir.mkdirs() + + return kotlin.runCatching { + ZipInputStream(FileInputStream(zipFile)).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + val outFile = resolveZipOutputFile(dir, entry) + if (entry.isDirectory) { + outFile.mkdirs() + } else { + outFile.parentFile?.mkdirs() + FileOutputStream(outFile).use { fos -> + zis.copyTo(fos) + } + } + zis.closeEntry() + entry = zis.nextEntry + } + } + + val configFile = File(dir, CONFIG_FILE) + if (!configFile.exists()) { + dir.deleteRecursively() + return null + } + + val json = configFile.readText() + val pack = PACK_GSON.fromJson(json, ThemePack::class.java) + pack.folderName = folderName + _packList.add(pack) + pack + }.onFailure { + dir.deleteRecursively() + }.getOrNull() + } + + private fun resolveZipOutputFile(dir: File, entry: ZipEntry): File { + val outFile = File(dir, entry.name).canonicalFile + val canonicalDir = dir.canonicalFile + if (outFile.path == canonicalDir.path || + outFile.path.startsWith(canonicalDir.path + File.separator) + ) { + return outFile + } + throw IllegalArgumentException("Invalid theme pack entry: ${entry.name}") + } + + private fun copyResourceFile(sourcePath: String?, destDir: File, prefix: String) { + if (sourcePath.isNullOrEmpty()) return + val sourceUri = Uri.parse(sourcePath) + val sourceName = sourceUri.lastPathSegment ?: sourcePath + val ext = sourceName.substringAfterLast(".", "dat").ifBlank { "dat" } + val destFile = File(destDir, "$prefix.$ext") + + if (sourceUri.scheme == "content") { + appCtx.contentResolver.openInputStream(sourceUri)?.use { input -> + FileOutputStream(destFile).use { output -> + input.copyTo(output) + } + } + return + } + + val sourceFile = File(sourcePath) + if (!sourceFile.exists()) return + sourceFile.copyTo(destFile, overwrite = true) + } + + private fun restoreResourcePath(packDir: File, prefix: String, originalPath: String?): String? { + if (originalPath.isNullOrEmpty()) return null + val candidates = packDir.listFiles { file -> + file.name.startsWith(prefix) && file.isFile + } + if (candidates.isNullOrEmpty()) return null + val sourceFile = candidates.first() + val targetDir = File(appCtx.filesDir, "theme_pack_resources") + targetDir.mkdirs() + val targetFile = File(targetDir, "${System.currentTimeMillis()}_${sourceFile.name}") + sourceFile.copyTo(targetFile, overwrite = true) + return targetFile.absolutePath + } + + private fun copyCoverImages(coverPaths: String, destDir: File, prefix: String) { + val paths = coverPaths.split(",").filter { it.isNotBlank() } + paths.forEachIndexed { index, path -> + copyResourceFile(path, destDir, "${prefix}_$index") + } + } + + private fun restoreCoverImages(packDir: File, prefix: String, originalPaths: String): String { + if (originalPaths.isEmpty()) return "" + val candidates = packDir.listFiles { file -> + file.name.startsWith(prefix + "_") && file.isFile + } + if (candidates.isNullOrEmpty()) return originalPaths + val sortedCandidates = candidates.sortedBy { file -> + file.name.substringAfterLast("_").substringBefore(".").toIntOrNull() ?: 0 + } + val coverDir = File(appCtx.externalFiles, "covers") + coverDir.mkdirs() + val restoredPaths = mutableListOf() + sortedCandidates.forEach { sourceFile -> + val targetFile = File(coverDir, sourceFile.name) + sourceFile.copyTo(targetFile, overwrite = true) + restoredPaths.add(targetFile.absolutePath) + } + return if (restoredPaths.isEmpty()) originalPaths else restoredPaths.joinToString(",") + } + + data class ThemePack( + var name: String = "", + @Transient + var folderName: String = "", + var appearance: ThemeAppearanceConfig = ThemeAppearanceConfig(), + var bgImageLight: String? = null, + var bgImageDark: String? = null, + var bgImageBlurring: Int = 0, + var bgImageNBlurring: Int = 0, + var navIconBookshelf: String = "", + var navIconExplore: String = "", + var navIconRss: String = "", + var navIconMy: String = "", + var coverTextColor: Int = -16777216, + var coverShadowColor: Int = -16777216, + var coverTextColorN: Int = -1, + var coverShadowColorN: Int = -1, + var coverShowName: Boolean = true, + var coverShowAuthor: Boolean = true, + var coverShowNameN: Boolean = true, + var coverShowAuthorN: Boolean = true, + var coverDefaultColor: Boolean = true, + var coverShowShadow: Boolean = false, + var coverShowStroke: Boolean = true, + var coverInfoOrientation: String = "0", + var defaultCover: String = "", + var defaultCoverDark: String = "" + ) { + val themeColor: Int get() = appearance.themeColor + val appFontPath: String? get() = appearance.appFontPath + } +} + diff --git a/app/src/main/java/io/legado/app/help/config/ThemePackSerializer.kt b/app/src/main/java/io/legado/app/help/config/ThemePackSerializer.kt new file mode 100644 index 000000000..cc7530d40 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/config/ThemePackSerializer.kt @@ -0,0 +1,83 @@ +package io.legado.app.help.config + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer +import io.legado.app.utils.getBoolean +import io.legado.app.utils.getInt +import io.legado.app.utils.getString +import java.lang.reflect.Type + +class ThemePackSerializer : JsonSerializer, + JsonDeserializer { + + override fun serialize( + src: ThemePackConfig.ThemePack, + typeOfSrc: Type, + context: JsonSerializationContext + ): JsonElement { + val obj = JsonObject() + obj.addProperty("name", src.name) + ThemeAppearanceJson.writeTo(obj, src.appearance) + obj.addProperty("bgImageLight", src.bgImageLight) + obj.addProperty("bgImageDark", src.bgImageDark) + obj.addProperty("bgImageBlurring", src.bgImageBlurring) + obj.addProperty("bgImageNBlurring", src.bgImageNBlurring) + obj.addProperty("navIconBookshelf", src.navIconBookshelf) + obj.addProperty("navIconExplore", src.navIconExplore) + obj.addProperty("navIconRss", src.navIconRss) + obj.addProperty("navIconMy", src.navIconMy) + obj.addProperty("coverTextColor", ThemeAppearanceJson.colorToHex(src.coverTextColor)) + obj.addProperty("coverShadowColor", ThemeAppearanceJson.colorToHex(src.coverShadowColor)) + obj.addProperty("coverTextColorN", ThemeAppearanceJson.colorToHex(src.coverTextColorN)) + obj.addProperty("coverShadowColorN", ThemeAppearanceJson.colorToHex(src.coverShadowColorN)) + obj.addProperty("coverShowName", src.coverShowName) + obj.addProperty("coverShowAuthor", src.coverShowAuthor) + obj.addProperty("coverShowNameN", src.coverShowNameN) + obj.addProperty("coverShowAuthorN", src.coverShowAuthorN) + obj.addProperty("coverDefaultColor", src.coverDefaultColor) + obj.addProperty("coverShowShadow", src.coverShowShadow) + obj.addProperty("coverShowStroke", src.coverShowStroke) + obj.addProperty("coverInfoOrientation", src.coverInfoOrientation) + obj.addProperty("defaultCover", src.defaultCover) + obj.addProperty("defaultCoverDark", src.defaultCoverDark) + return obj + } + + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): ThemePackConfig.ThemePack { + val obj = json.asJsonObject + return ThemePackConfig.ThemePack( + name = obj.getString("name", "") ?: "", + appearance = ThemeAppearanceJson.readFrom(obj), + bgImageLight = obj.getString("bgImageLight", null), + bgImageDark = obj.getString("bgImageDark", null), + bgImageBlurring = obj.getInt("bgImageBlurring", 0), + bgImageNBlurring = obj.getInt("bgImageNBlurring", 0), + navIconBookshelf = obj.getString("navIconBookshelf", "") ?: "", + navIconExplore = obj.getString("navIconExplore", "") ?: "", + navIconRss = obj.getString("navIconRss", "") ?: "", + navIconMy = obj.getString("navIconMy", "") ?: "", + coverTextColor = ThemeAppearanceJson.readColor(obj, "coverTextColor").takeIf { it != 0 } ?: -16777216, + coverShadowColor = ThemeAppearanceJson.readColor(obj, "coverShadowColor").takeIf { it != 0 } ?: -16777216, + coverTextColorN = ThemeAppearanceJson.readColor(obj, "coverTextColorN").takeIf { it != 0 } ?: -1, + coverShadowColorN = ThemeAppearanceJson.readColor(obj, "coverShadowColorN").takeIf { it != 0 } ?: -1, + coverShowName = obj.getBoolean("coverShowName", true), + coverShowAuthor = obj.getBoolean("coverShowAuthor", true), + coverShowNameN = obj.getBoolean("coverShowNameN", true), + coverShowAuthorN = obj.getBoolean("coverShowAuthorN", true), + coverDefaultColor = obj.getBoolean("coverDefaultColor", true), + coverShowShadow = obj.getBoolean("coverShowShadow", false), + coverShowStroke = obj.getBoolean("coverShowStroke", true), + coverInfoOrientation = obj.getString("coverInfoOrientation", "0") ?: "0", + defaultCover = obj.getString("defaultCover", "") ?: "", + defaultCoverDark = obj.getString("defaultCoverDark", "") ?: "" + ) + } +} 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 4b58081f3..68588a2d9 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 @@ -14,6 +14,7 @@ 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.PersonalizationThemeConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.coroutine.Coroutine import io.legado.app.model.BookCover @@ -83,6 +84,7 @@ object Backup { ReadBookConfig.configFileName, ReadBookConfig.shareConfigFileName, OldThemeConfig.configFileName, + PersonalizationThemeConfig.configFileName, BookCover.configFileName, "config.xml" ) @@ -181,6 +183,8 @@ object Backup { FileUtils.createFileIfNotExist(backupPath + File.separator + BookCover.configFileName) .writeText(GSON.toJson(it)) } + FileUtils.createFileIfNotExist(backupPath + File.separator + PersonalizationThemeConfig.configFileName) + .writeText(PersonalizationThemeConfig.toJson()) currentCoroutineContext().ensureActive() appCtx.getSharedPreferences(backupPath, "config")?.let { sp -> sp.edit(commit = true) { diff --git a/app/src/main/java/io/legado/app/help/storage/BackupConfig.kt b/app/src/main/java/io/legado/app/help/storage/BackupConfig.kt index 3c0ad84ef..b3b57af3e 100644 --- a/app/src/main/java/io/legado/app/help/storage/BackupConfig.kt +++ b/app/src/main/java/io/legado/app/help/storage/BackupConfig.kt @@ -23,6 +23,7 @@ object BackupConfig { private const val readConfigKey = "readConfig" private const val themeConfigKey = "themeConfig" private const val coverConfigKey = "coverConfig" + private const val personalizationThemeConfigKey = "personalizationThemeConfig" private const val localBookKey = "localBook" //配置忽略key @@ -30,6 +31,7 @@ object BackupConfig { readConfigKey, PreferKey.themeMode, themeConfigKey, + personalizationThemeConfigKey, coverConfigKey, PreferKey.bookshelfLayout, PreferKey.showRss, @@ -42,6 +44,7 @@ object BackupConfig { appCtx.getString(R.string.read_config), appCtx.getString(R.string.theme_mode), appCtx.getString(R.string.theme_config), + "深度个性化主题", appCtx.getString(R.string.cover_config), appCtx.getString(R.string.bookshelf_layout), appCtx.getString(R.string.show_rss), @@ -84,15 +87,9 @@ object BackupConfig { private val themePrefKeys = arrayOf( PreferKey.cPrimary, - PreferKey.cAccent, - PreferKey.cBackground, - PreferKey.cBBackground, + PreferKey.cNPrimary, PreferKey.bgImage, PreferKey.bgImageBlurring, - PreferKey.cNPrimary, - PreferKey.cNAccent, - PreferKey.cNBackground, - PreferKey.cNBBackground, PreferKey.bgImageN, PreferKey.bgImageNBlurring ) @@ -128,6 +125,8 @@ object BackupConfig { get() = ignoreConfig[themeConfigKey] == true private val ignoreCoverConfig: Boolean get() = ignoreConfig[coverConfigKey] == true + val ignorePersonalizationThemeConfig: Boolean + get() = ignoreConfig[personalizationThemeConfigKey] == true private val ignoreBookshelfLayout: Boolean get() = ignoreConfig[PreferKey.bookshelfLayout] == true private val ignoreShowRss: Boolean 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 4d7793532..6b6f8e3d5 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,6 +34,7 @@ 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.PersonalizationThemeConfig import io.legado.app.help.config.ReadBookConfig import io.legado.app.model.BookCover import io.legado.app.model.localBook.LocalBook @@ -233,6 +234,16 @@ object Restore { }?.onFailure { AppLog.put("恢复封面规则出错\n${it.localizedMessage}", it) } + if (!BackupConfig.ignorePersonalizationThemeConfig) { + File(path, PersonalizationThemeConfig.configFileName).takeIf { + it.exists() + }?.runCatching { + val json = readText() + PersonalizationThemeConfig.fromJson(json) + }?.onFailure { + AppLog.put("恢复个性化主题出错\n${it.localizedMessage}", it) + } + } if (!BackupConfig.ignoreReadConfig) { //恢复阅读界面配置 File(path, ReadBookConfig.configFileName).takeIf { diff --git a/app/src/main/java/io/legado/app/help/update/AppUpdateGitHub.kt b/app/src/main/java/io/legado/app/help/update/AppUpdateGitHub.kt index 6377aa2eb..6c1f8b596 100644 --- a/app/src/main/java/io/legado/app/help/update/AppUpdateGitHub.kt +++ b/app/src/main/java/io/legado/app/help/update/AppUpdateGitHub.kt @@ -24,9 +24,9 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface { private suspend fun getLatestRelease(): List { val url = if (checkVariant == AppVariant.OFFICIAL) - "https://api.github.com/repos/HapeLee/legado-with-MD3/releases/latest" + "https://api.github.com/repos/325506/legado-with-MD3-DIY/releases/latest" else - "https://api.github.com/repos/HapeLee/legado-with-MD3/releases" + "https://api.github.com/repos/325506/legado-with-MD3-DIY/releases" val res = okHttpClient.newCallResponse { url(url) } if (!res.isSuccessful) throw NoStackTraceException("获取新版本出错(${res.code})") @@ -65,7 +65,7 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface { } suspend fun getReleaseByTag(tag: String): AppUpdate.UpdateInfo? { - val url = "https://api.github.com/repos/HapeLee/legado-with-MD3/releases/tags/$tag" + val url = "https://api.github.com/repos/325506/legado-with-MD3-DIY/releases/tags/$tag" val res = okHttpClient.newCallResponse { url(url) } if (!res.isSuccessful) return null @@ -86,7 +86,7 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface { val currentVersion = AppConst.appInfo.versionName val releases = getLatestRelease() - val filtered = if (checkVariant == AppVariant.ALL) { + val filtered = if (checkVariant == AppVariant.ALL || checkVariant == AppVariant.UNKNOWN) { releases } else { releases.filter { it.appVariant == checkVariant } @@ -153,8 +153,8 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface { companion object { fun parse(version: String): SemVer { - val regex = Regex("""(\d+)\.(\d+)\.(\d+)(?:-([\w.]+))?""") - val match = regex.matchEntire(version) + val regex = Regex("""(\d+)\.(\d+)\.(\d+)(?:[-_]([\w.]+))?""") + val match = regex.find(version) ?: throw IllegalArgumentException("Invalid version: $version") val (maj, min, pat, pre) = match.destructured return SemVer(maj.toInt(), min.toInt(), pat.toInt(), pre.ifBlank { null }) diff --git a/app/src/main/java/io/legado/app/help/webView/WebJsExtensions.kt b/app/src/main/java/io/legado/app/help/webView/WebJsExtensions.kt index d6913cf7c..c39af2c9d 100644 --- a/app/src/main/java/io/legado/app/help/webView/WebJsExtensions.kt +++ b/app/src/main/java/io/legado/app/help/webView/WebJsExtensions.kt @@ -9,6 +9,8 @@ import io.legado.app.data.appDb import io.legado.app.data.entities.BaseSource import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter +import io.legado.app.exception.NoStackTraceException +import io.legado.app.help.CacheManager import io.legado.app.help.coroutine.Coroutine import io.legado.app.model.AudioPlay import io.legado.app.model.ReadBook @@ -17,22 +19,25 @@ import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext import io.legado.app.ui.rss.read.RssJsExtensions import io.legado.app.utils.GSON -import io.legado.app.utils.escapeForJs import io.legado.app.utils.fromJsonObject +import io.legado.app.utils.isJsonObject import java.lang.ref.WeakReference import java.util.UUID @Suppress("unused") class WebJsExtensions( - source: BaseSource, activity: AppCompatActivity, - private val webView: WebView, - private val bookType: Int = 0, + source: BaseSource, + activity: AppCompatActivity?, + webView: WebView, + bookType: Int = 0, callback: Callback? = null ) : RssJsExtensions(activity, source) { private val callbackRef: WeakReference = WeakReference(callback) + private val webViewRef: WeakReference = WeakReference(webView) interface Callback { fun upConfig(config: String) + fun onNavigateToArticles(sortUrl: String? = null) } @JavascriptInterface @@ -40,6 +45,16 @@ class WebJsExtensions( callbackRef.get()?.upConfig(config) } + @JavascriptInterface + fun navigateToArticles() { + callbackRef.get()?.onNavigateToArticles(null) + } + + @JavascriptInterface + fun navigateToArticles(sortUrl: String?) { + callbackRef.get()?.onNavigateToArticles(sortUrl) + } + private val bookAndChapter by lazy { var book: Book? = null var chapter: BookChapter? = null @@ -63,154 +78,201 @@ class WebJsExtensions( private val book: Book? get() = bookAndChapter.first private val chapter: BookChapter? get() = bookAndChapter.second - /** - * 由软件主动注入的js函数调用 - */ + val analyzeRule by lazy { + AnalyzeRule(book, source = getSource()).setChapter(chapter) + } + @JavascriptInterface - fun request(funName: String, jsParam: Array, id: String) { + fun request(funName: String, jsParam: Array, id: String) { val activity = activityRef.get() ?: return Coroutine.async(activity.lifecycleScope) { + val params = Array(6) { i -> jsParam.getOrNull(i) } + val p0 = params[0] + val p1 = params[1] + val p2 = params[2] + val p3 = params[3] + val p4 = params[4] + val p5 = params[5] when (funName) { - "run" -> AnalyzeRule(book, getSource()).run { - setCoroutineContext(coroutineContext) - setChapter(chapter) - evalJS(jsParam[0]).toString() + "run" -> { + analyzeRule.setCoroutineContext(coroutineContext) + .evalJS( + p0 ?: throw NoStackTraceException("error null") + ).toString() } "ajaxAwait" -> { - ajax(jsParam[0], jsParam[1].toIntOrNull()).toString() + ajax( + p0 ?: throw NoStackTraceException("error url null"), + p1?.toLongOrNull() + ).toString() } "connectAwait" -> { - connect(jsParam[0], jsParam[1], jsParam[2].toIntOrNull()) + connect( + p0 ?: throw NoStackTraceException("error url null"), + p1, + p2?.toIntOrNull() + ) } "getAwait" -> { - get(jsParam[0], jsParam[1], jsParam[2].toIntOrNull()) + get( + p0 ?: throw NoStackTraceException("error url null"), + p1 ?: throw NoStackTraceException("error header null"), + p2?.toIntOrNull() + ) } "headAwait" -> { - head(jsParam[0], jsParam[1], jsParam[2].toIntOrNull()) + head( + p0 ?: throw NoStackTraceException("error url null"), + p1 ?: throw NoStackTraceException("error header null"), + p2?.toIntOrNull() + ) } "postAwait" -> { - post(jsParam[0], jsParam[1], jsParam[2], jsParam[3].toIntOrNull()) + post( + p0 ?: throw NoStackTraceException("error url null"), + p1 ?: throw NoStackTraceException("error body null"), + p2 ?: throw NoStackTraceException("error header null"), + p3?.toIntOrNull() + ) } "webViewAwait" -> { - webView(jsParam[0], jsParam[1], jsParam[2], jsParam[3].toBoolean()).toString() + webView( + p0, + p1, + p2, + p3.toBoolean() + ).toString() } "webViewGetSourceAwait" -> { webViewGetSource( - jsParam[0], - jsParam[1], - jsParam[2], - jsParam[3], - jsParam[4].toBoolean(), - jsParam[5].toLongOrNull() ?: 0 + p0, + p1, + p2, + p3 ?: throw NoStackTraceException("error sourceRegex null"), + p4.toBoolean(), + p5?.toLongOrNull() ?: 0 ).toString() } "decryptStrAwait" -> { - createSymmetricCrypto(jsParam[0], jsParam[1], jsParam[2]).decryptStr(jsParam[3]) + createSymmetricCrypto( + p0 ?: throw NoStackTraceException("error transformation null"), + p1 ?: throw NoStackTraceException("error key null"), + p2 + ).decryptStr(p3 ?: throw NoStackTraceException("error data null")) } "encryptBase64Await" -> { createSymmetricCrypto( - jsParam[0], - jsParam[1], - jsParam[2] - ).encryptBase64(jsParam[3]) + p0 ?: throw NoStackTraceException("error transformation null"), + p1 ?: throw NoStackTraceException("error key null"), + p2 + ).encryptBase64(p3 ?: throw NoStackTraceException("error data null")) } "encryptHexAwait" -> { - createSymmetricCrypto(jsParam[0], jsParam[1], jsParam[2]).encryptHex(jsParam[3]) + createSymmetricCrypto( + p0 ?: throw NoStackTraceException("error transformation null"), + p1 ?: throw NoStackTraceException("error key null"), + p2 + ).encryptHex(p3 ?: throw NoStackTraceException("error data null")) } "createSignHexAwait" -> { - createSign(jsParam[0]).setPublicKey(jsParam[1]).setPrivateKey(jsParam[2]) - .signHex(jsParam[3]) + createSign(p0 ?: throw NoStackTraceException("error algorithm null")) + .setPublicKey(p1 ?: throw NoStackTraceException("error publicKey null")) + .setPrivateKey(p2 ?: throw NoStackTraceException("error privateKey null")) + .signHex(p3 ?: throw NoStackTraceException("error data null")) } "downloadFileAwait" -> { - downloadFile(jsParam[0]) + downloadFile(p0 ?: throw NoStackTraceException("error url null")) } "readTxtFileAwait" -> { - readTxtFile(jsParam[0]) + readTxtFile(p0 ?: throw NoStackTraceException("error path null")) } "importScriptAwait" -> { - importScript(jsParam[0]) + importScript(p0 ?: throw NoStackTraceException("error path null")) } - "getStringAwait" -> AnalyzeRule(book, getSource()).run { - setCoroutineContext(coroutineContext) - setChapter(chapter) - getString(jsParam[0], jsParam[1]) + "getStringAwait" -> { + analyzeRule.setCoroutineContext(coroutineContext) + .getString(p0, p1) } - else -> "error funName" + else -> throw NoStackTraceException("error funName") } }.onSuccess { data -> - webView.evaluateJavascript( - "window.$JSBridgeResult('$id', '${data.escapeForJs()}', null);", - null - ) + CacheManager.putMemory(id, data) + webViewRef.get()?.evaluateJavascript("window.$JSBridgeResult('$id', true);", null) }.onError { - webView.evaluateJavascript( - "window.$JSBridgeResult('$id', null, '${it.localizedMessage?.escapeForJs()}');", - null - ) + CacheManager.putMemory(id, it.localizedMessage ?: "err") + webViewRef.get()?.evaluateJavascript("window.$JSBridgeResult('$id', false);", null) } } + @JavascriptInterface fun toast(msg: String?) { super.toast(msg) } + @JavascriptInterface fun longToast(msg: String?) { super.longToast(msg) } + + @JavascriptInterface + override fun open(name: String, url: String?, title: String?, origin: String?) { + when (name) { + "sort" -> { + val sortUrl = if (!url.isNullOrBlank()) { + if (url.isJsonObject()) url else title?.let { "{\"$it\":\"$url\"}" } ?: url + } else null + callbackRef.get()?.onNavigateToArticles(sortUrl) + } + else -> super.open(name, url, title, origin) + } + } + @JavascriptInterface fun log(msg: String?): String { return super.log(msg).toString() } + @JavascriptInterface - fun ajax(url: String): String? { - return super.ajax(url, 9000) + @JvmOverloads + fun ajax(url: String, callTimeout: Int = 9000): String? { + return super.ajax(url, callTimeout.toLong()) } + @JavascriptInterface - fun ajax(url: String, callTimeout: Int?): String? { - return super.ajax(url, callTimeout?.toLong()) - } - @JavascriptInterface - fun connect(urlStr: String?): String { - if (urlStr.isNullOrEmpty()) return "error empty url" - return super.connect(urlStr, null, 9000).toString() - } - @JavascriptInterface - fun connect(urlStr: String, header: String): String { - return super.connect(urlStr, header, 9000).toString() - } - @JavascriptInterface - fun connect(urlStr: String, header: String, callTimeout: Int?): String { + @JvmOverloads + fun connect(urlStr: String, header: String? = null, callTimeout: Int? = 9000): String { return super.connect(urlStr, header, callTimeout?.toLong()).toString() } + @JavascriptInterface - fun get(urlStr: String, headers: String): String { - val headerMap = GSON.fromJsonObject>(headers).getOrNull() ?: emptyMap() - return super.get(urlStr, headerMap, 9000).body() + @JvmOverloads + fun get(urlStr: String, header: String, timeout: Int? = 9000): String { + val headers = GSON.fromJsonObject>(header).getOrNull() ?: emptyMap() + return super.get(urlStr, headers, timeout).body() } + @JavascriptInterface - fun get(urlStr: String, headers: String, timeout: Int?): String { - val headerMap = GSON.fromJsonObject>(headers).getOrNull() ?: emptyMap() - return super.get(urlStr, headerMap, timeout).body() + @JvmOverloads + fun post(urlStr: String, body: String, header: String, timeout: Int? = 9000): String { + val headers = GSON.fromJsonObject>(header).getOrNull() ?: emptyMap() + return super.post(urlStr, body, headers, timeout).body() } + @JavascriptInterface - fun post(urlStr: String, body: String, headers: String): String { - val headerMap = GSON.fromJsonObject>(headers).getOrNull() ?: emptyMap() - return super.post(urlStr, body, headerMap, 9000).body() + @JvmOverloads + fun head(urlStr: String, header: String, timeout: Int? = 9000): String { + val headers = GSON.fromJsonObject>(header).getOrNull() ?: emptyMap() + return GSON.toJson(super.head(urlStr, headers, timeout).headers()) } + @JavascriptInterface - fun post(urlStr: String, body: String, headers: String, timeout: Int?): String { - val headerMap = GSON.fromJsonObject>(headers).getOrNull() ?: emptyMap() - return super.post(urlStr, body, headerMap, timeout).body() + fun getStringList(rule: String?, mContent: String? = null, isUrl: Boolean = false): List? { + return super.getStringList(rule, mContent, isUrl) } + @JavascriptInterface - fun head(urlStr: String, headers: String): String { - val headerMap = GSON.fromJsonObject>(headers).getOrNull() ?: emptyMap() - return GSON.toJson(super.head(urlStr, headerMap, 9000).headers()) - } - @JavascriptInterface - fun head(urlStr: String, headers: String, timeout: Int?): String { - val headerMap = GSON.fromJsonObject>(headers).getOrNull() ?: emptyMap() - return GSON.toJson(super.head(urlStr, headerMap, timeout).headers()) + fun getString(ruleStr: String?, mContent: String? = null, isUrl: Boolean = false): String { + return super.getString(ruleStr, mContent, isUrl) } companion object { @@ -218,6 +280,7 @@ class WebJsExtensions( val letters = "abcdefghijklmnopqrstuvwxyz" return letters.random() } + val uuid by lazy { UUID.randomUUID().toString().replace('-', getRandomLetter()).chunked(6) } @@ -234,9 +297,12 @@ class WebJsExtensions( "" } + val getInjectionString = "try{var cache=$nameCache,source=$nameSource,java=$nameJava;}catch(e){}" + val JS_INJECTION by lazy { """ const requestId = n => 'req_' + n + '_' + Date.now() + '_' + Math.random().toString(36).slice(-3); + const params = a => a.map(p => p != null && typeof p.toString === 'function' ? p.toString() : null); const JSBridgeCallbacks = {}; const java = window.$nameJava; delete window.$nameJava; @@ -248,122 +314,124 @@ class WebJsExtensions( return new Promise((resolve, reject) => { const id = requestId("run"); JSBridgeCallbacks[id] = { resolve, reject }; - java?.request("run", [String(jsCode)], id); + java.request("run", [String(jsCode)], id); }); }; - function ajaxAwait(url, callTimeout) { + function ajaxAwait(...args) { return new Promise((resolve, reject) => { const id = requestId("ajaxAwait"); JSBridgeCallbacks[id] = { resolve, reject }; - java?.request("ajaxAwait", [String(url), String(callTimeout)], id); + java.request("ajaxAwait", params(args), id); }); }; - function connectAwait(url, header, callTimeout) { + function connectAwait(...args) { return new Promise((resolve, reject) => { const id = requestId("connectAwait"); JSBridgeCallbacks[id] = { resolve, reject }; - java?.request("connectAwait", [String(url), String(header), String(callTimeout)], id); + java.request("connectAwait", params(args), id); }); }; - function getAwait(url, header, callTimeout) { + function getAwait(...args) { return new Promise((resolve, reject) => { const id = requestId("getAwait"); JSBridgeCallbacks[id] = { resolve, reject }; - java?.request("getAwait", [String(url), String(header), String(callTimeout)], id); + java.request("getAwait", params(args), id); }); }; - function headAwait(url, header, callTimeout) { + function headAwait(...args) { return new Promise((resolve, reject) => { const id = requestId("headAwait"); JSBridgeCallbacks[id] = { resolve, reject }; - java?.request("headAwait", [String(url), String(header), String(callTimeout)], id); + java.request("headAwait", params(args), id); }); }; - function postAwait(url, body, header, callTimeout) { + function postAwait(...args) { return new Promise((resolve, reject) => { const id = requestId("postAwait"); JSBridgeCallbacks[id] = { resolve, reject }; - java?.request("postAwait", [String(url), String(body), String(header), String(callTimeout)], id); + java.request("postAwait", params(args), id); }); }; - function webViewAwait(html, url, js, cacheFirst) { + function webViewAwait(...args) { return new Promise((resolve, reject) => { const id = requestId("webViewAwait"); JSBridgeCallbacks[id] = { resolve, reject }; - java?.request("webViewAwait", [String(html), String(url), String(js), String(cacheFirst)], id); + java.request("webViewAwait", params(args), id); }); }; - function webViewGetSourceAwait(html, url, js, sourceRegex, cacheFirst, delayTime) { + function webViewGetSourceAwait(...args) { return new Promise((resolve, reject) => { const id = requestId("webViewGetSourceAwait"); JSBridgeCallbacks[id] = { resolve, reject }; - java?.request("webViewGetSourceAwait", [String(html), String(url), String(js), String(sourceRegex), String(cacheFirst), String(delayTime)], id); + java.request("webViewGetSourceAwait", params(args), id); }); } - function decryptStrAwait(transformation, key, iv, data) { + function decryptStrAwait(...args) { return new Promise((resolve, reject) => { const id = requestId("decryptStrAwait"); JSBridgeCallbacks[id] = { resolve, reject }; - java?.request("decryptStrAwait", [String(transformation), String(key), String(iv), String(data)], id); + java.request("decryptStrAwait", params(args), id); }); }; - function encryptBase64Await(transformation, key, iv, data) { + function encryptBase64Await(...args) { return new Promise((resolve, reject) => { const id = requestId("encryptBase64Await"); JSBridgeCallbacks[id] = { resolve, reject }; - java?.request("encryptBase64Await", [String(transformation), String(key), String(iv), String(data)], id); + java.request("encryptBase64Await", params(args), id); }); }; - function encryptHexAwait(transformation, key, iv, data) { + function encryptHexAwait(...args) { return new Promise((resolve, reject) => { const id = requestId("encryptHexAwait"); JSBridgeCallbacks[id] = { resolve, reject }; - java?.request("encryptHexAwait", [String(transformation), String(key), String(iv), String(data)], id); + java.request("encryptHexAwait", params(args), id); }); }; - function createSignHexAwait(algorithm, publicKey, privateKey, data) { + function createSignHexAwait(...args) { return new Promise((resolve, reject) => { const id = requestId("createSignHexAwait"); JSBridgeCallbacks[id] = { resolve, reject }; - java?.request("createSignHexAwait", [String(algorithm), String(publicKey), String(privateKey), String(data)], id); + java.request("createSignHexAwait", params(args), id); }); }; function downloadFileAwait(url) { return new Promise((resolve, reject) => { const id = requestId("downloadFileAwait"); JSBridgeCallbacks[id] = { resolve, reject }; - java?.request("downloadFileAwait", [String(url)], id); + java.request("downloadFileAwait", [String(url)], id); }); }; function readTxtFileAwait(path) { return new Promise((resolve, reject) => { const id = requestId("readTxtFileAwait"); JSBridgeCallbacks[id] = { resolve, reject }; - java?.request("readTxtFileAwait", [String(path)], id); + java.request("readTxtFileAwait", [String(path)], id); }); }; function importScriptAwait(url) { return new Promise((resolve, reject) => { const id = requestId("importScriptAwait"); JSBridgeCallbacks[id] = { resolve, reject }; - java?.request("importScriptAwait", [String(url)], id); + java.request("importScriptAwait", [String(url)], id); }); }; - function getStringAwait(ruleStr, mContent) { + function getStringAwait(...args) { return new Promise((resolve, reject) => { const id = requestId("getStringAwait"); JSBridgeCallbacks[id] = { resolve, reject }; - java?.request("getStringAwait", [String(ruleStr), String(mContent)], id); + java.request("getStringAwait", params(args), id); }); }; - window.$JSBridgeResult = function(requestId, result, error) { - if (JSBridgeCallbacks[requestId]) { - if (error) { - JSBridgeCallbacks[requestId].reject(error); + window.$JSBridgeResult = function(id, success) { + const callBack = JSBridgeCallbacks[id]; + if (callBack) { + const result = cache.getFromMemory(id); + if (success) { + callBack.resolve(result); } else { - JSBridgeCallbacks[requestId].resolve(result); + callBack.reject(result); } - delete JSBridgeCallbacks[requestId]; + delete JSBridgeCallbacks[id]; } };""".trimIndent() } @@ -374,21 +442,25 @@ class WebJsExtensions( const JSBridgeCallbacks = {}; const java = window.$nameJava; delete window.$nameJava; + const cache = window.$nameCache; + delete window.$nameCache; function run(jsCode) { return new Promise((resolve, reject) => { const id = requestId("run"); JSBridgeCallbacks[id] = { resolve, reject }; - java?.request("run", [String(jsCode)], id); + java.request("run", [String(jsCode)], id); }); }; - window.$JSBridgeResult = function(requestId, result, error) { - if (JSBridgeCallbacks[requestId]) { - if (error) { - JSBridgeCallbacks[requestId].reject(error); + window.$JSBridgeResult = function(id, success) { + const callBack = JSBridgeCallbacks[id]; + if (callBack) { + const result = cache.getFromMemory(id); + if (success) { + callBack.resolve(result); } else { - JSBridgeCallbacks[requestId].resolve(result); + callBack.reject(result); } - delete JSBridgeCallbacks[requestId]; + delete JSBridgeCallbacks[id]; } };""".trimIndent() } @@ -416,4 +488,4 @@ class WebJsExtensions( })();""".trimIndent() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/model/AudioPlay.kt b/app/src/main/java/io/legado/app/model/AudioPlay.kt index a7cd36e93..d895e833a 100644 --- a/app/src/main/java/io/legado/app/model/AudioPlay.kt +++ b/app/src/main/java/io/legado/app/model/AudioPlay.kt @@ -59,6 +59,7 @@ object AudioPlay : CoroutineScope by MainScope() { var durChapterPos = 0 var durChapter: BookChapter? = null var durPlayUrl = "" + var durLyric: String? = null var durAudioSize = 0 var inBookshelf = false var bookSource: BookSource? = null @@ -153,14 +154,23 @@ object AudioPlay : CoroutineScope by MainScope() { .onSuccess { content -> if (content.isEmpty()) { appCtx.toastOnUi("未获取到资源链接") + // 加载失败时清空歌词 + durLyric = null + callback?.upLyric(null) } else { contentLoadFinish(chapter, content) } - }.onError { - AppLog.put("获取资源链接出错\n$it", it, true) + }.onError { error -> + AppLog.put("获取资源链接出错\n$error", error, true) upLoading(false) + // 加载失败时清空歌词 + durLyric = null + callback?.upLyric(null) }.onCancel { removeLoading(index) + // 取消加载时清空歌词 + durLyric = null + callback?.upLyric(null) }.onFinally { removeLoading(index) } @@ -177,7 +187,10 @@ object AudioPlay : CoroutineScope by MainScope() { private fun contentLoadFinish(chapter: BookChapter, content: String) { if (chapter.index == book?.durChapterIndex) { durPlayUrl = content + durLyric = chapter.getVariable("lyric").takeIf { it.isNotBlank() } upPlayUrl() + // 重新加载歌词,确保循环播放时歌词进度重置 + callback?.upLyric(durLyric) } } @@ -397,6 +410,8 @@ object AudioPlay : CoroutineScope by MainScope() { fun register(context: Context) { activityContext = context callback = context as CallBack + // 主动更新歌词状态,确保重新进入时歌词控件能正确显示 + callback?.upLyric(durLyric) } fun unregister(context: Context) { @@ -418,6 +433,8 @@ object AudioPlay : CoroutineScope by MainScope() { interface CallBack { fun upLoading(loading: Boolean) + fun upLyric(lyric: String?) + fun upLyricP(position: Int) } diff --git a/app/src/main/java/io/legado/app/model/Debug.kt b/app/src/main/java/io/legado/app/model/Debug.kt index a0748328a..f113bd2be 100644 --- a/app/src/main/java/io/legado/app/model/Debug.kt +++ b/app/src/main/java/io/legado/app/model/Debug.kt @@ -138,6 +138,90 @@ object Debug { } } + fun startDebug(scope: CoroutineScope, rssSource: RssSource, key: String) { + cancelDebug() + debugSource = rssSource.sourceUrl + startTime = System.currentTimeMillis() + when { + key.contains("@js:") -> { + val ruleContent = rssSource.ruleContent + if (ruleContent.isNullOrEmpty()) { + log(debugSource, "⇒内容规则为空,默认获取整个网页", state = 1000) + } else { + val rssArticle = RssArticle() + rssArticle.origin = rssSource.sourceUrl + rssArticle.link = key + log(debugSource, "⇒开始解析@js:链接:$key") + rssContentDebug(scope, rssArticle, ruleContent, rssSource) + } + } + + key.contains("::") -> { + val name = key.substringBefore("::") + val url = key.substringAfter("::") + log(debugSource, "⇒开始访问分类页:$url") + log(debugSource, "︾开始解析分类页") + rssSortDebug(scope, rssSource, name, url) + } + + key.isAbsUrl() -> { + val ruleContent = rssSource.ruleContent + if (!rssSource.ruleArticles.isNullOrBlank() && rssSource.ruleDescription.isNullOrBlank()) { + if (ruleContent.isNullOrEmpty()) { + log(debugSource, "⇒内容规则为空,默认获取整个网页", state = 1000) + } else { + val rssArticle = RssArticle() + rssArticle.origin = rssSource.sourceUrl + rssArticle.link = key + log(debugSource, "⇒开始访问内容页:$key") + rssContentDebug(scope, rssArticle, ruleContent, rssSource) + } + } else { + log(debugSource, "⇒存在描述规则,不解析内容页") + log(debugSource, "︽解析完成", state = 1000) + } + } + + else -> { + val searchUrl = rssSource.searchUrl + if (searchUrl.isNullOrEmpty()) { + log(debugSource, "⇒搜索URL为空", state = -1) + return + } + log(debugSource, "⇒开始搜索关键字:$key") + log(debugSource, "︾开始解析搜索页") + rssSortDebug(scope, rssSource, "搜索", searchUrl, key) + } + } + } + + private fun rssSortDebug(scope: CoroutineScope, rssSource: RssSource, name: String, url: String, key: String? = null) { + Rss.getArticles(scope, name, url, rssSource, 1, key) + .onSuccess { + if (it.first.isEmpty()) { + log(debugSource, "⇒列表页解析成功,为空") + log(debugSource, "︽解析完成", state = 1000) + } else { + val ruleContent = rssSource.ruleContent + if (!rssSource.ruleArticles.isNullOrBlank() && rssSource.ruleDescription.isNullOrBlank()) { + log(debugSource, "︽列表页解析完成") + log(debugSource, showTime = false) + if (ruleContent.isNullOrEmpty()) { + log(debugSource, "⇒内容规则为空,默认获取整个网页", state = 1000) + } else { + rssContentDebug(scope, it.first[0], ruleContent, rssSource) + } + } else { + log(debugSource, "⇒存在描述规则,不解析内容页") + log(debugSource, "︽解析完成", state = 1000) + } + } + } + .onError { + log(debugSource, it.stackTraceStr, state = -1) + } + } + private fun rssContentDebug( scope: CoroutineScope, rssArticle: RssArticle, diff --git a/app/src/main/java/io/legado/app/model/rss/Rss.kt b/app/src/main/java/io/legado/app/model/rss/Rss.kt index 519a1f0ee..c9778397b 100644 --- a/app/src/main/java/io/legado/app/model/rss/Rss.kt +++ b/app/src/main/java/io/legado/app/model/rss/Rss.kt @@ -24,10 +24,11 @@ object Rss { sortUrl: String, rssSource: RssSource, page: Int, + searchKey: String? = null, context: CoroutineContext = Dispatchers.IO ): Coroutine, String?>> { return Coroutine.async(scope, context) { - getArticlesAwait(sortName, sortUrl, rssSource, page) + getArticlesAwait(sortName, sortUrl, rssSource, page, searchKey) } } @@ -36,8 +37,13 @@ object Rss { sortUrl: String, rssSource: RssSource, page: Int, + searchKey: String? = null ): Pair, String?> { val ruleData = RuleData() + if (!searchKey.isNullOrBlank()) { + ruleData.putVariable("searchKey", searchKey) + ruleData.putVariable("key", searchKey) + } val analyzeUrl = AnalyzeUrl( sortUrl, page = page, @@ -100,4 +106,4 @@ object Rss { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/model/webBook/BookContent.kt b/app/src/main/java/io/legado/app/model/webBook/BookContent.kt index 15a60ffba..a831c446d 100644 --- a/app/src/main/java/io/legado/app/model/webBook/BookContent.kt +++ b/app/src/main/java/io/legado/app/model/webBook/BookContent.kt @@ -211,9 +211,9 @@ object BookContent { if (!book.isAudio && !book.isVideo) { //音频和视频获取的是链接,不需要html格式化 && !book.isVideo val useHtmlMap = mutableMapOf() if (AppConfig.adaptSpecialStyle) { - content = AppPattern.useHtmlRegex.replace(content) { matchResult -> + content = AppPattern.useHtmlRegex.replace(content) { val placeholder = "{usehtml_${useHtmlMap.size}}" - useHtmlMap[placeholder] = matchResult.value + useHtmlMap[placeholder] = it.value placeholder } } @@ -225,6 +225,13 @@ object BookContent { content = content.replace(placeholder, originalContent) } } + //获取副文(歌词等) + contentRule.subContent?.takeIf { it.isNotBlank() }?.let { + val subContent = analyzeRule.getString(it, unescape = false) + if (subContent.isNotBlank()) { + chapter.putVariable("lyric", subContent) + } + } //获取下一页链接 if (getNextPageUrl) { val nextUrlRule = contentRule.nextContentUrl diff --git a/app/src/main/java/io/legado/app/ui/about/AboutScreen.kt b/app/src/main/java/io/legado/app/ui/about/AboutScreen.kt index e45a6156a..50bda48ea 100644 --- a/app/src/main/java/io/legado/app/ui/about/AboutScreen.kt +++ b/app/src/main/java/io/legado/app/ui/about/AboutScreen.kt @@ -34,6 +34,7 @@ import io.legado.app.ui.widget.components.SplicedColumnGroup import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton import io.legado.app.ui.widget.components.card.TextCard import io.legado.app.ui.widget.components.settingItem.SettingItem +import io.legado.app.ui.widget.components.SettingItemWithDivider import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults @@ -132,56 +133,56 @@ fun AboutScreen( modifier = Modifier.padding(horizontal = 16.dp), title = "" ){ - SettingItem( - title = stringResource(R.string.contributors), - description = null, - option = null, - onClick = { - onOpenUrl("https://github.com/gedoor/legado/graphs/contributors") - } - ) - SettingItem( - title = stringResource(R.string.privacy_policy), - description = null, - option = null, - onClick = { - onShowMdFile("隐私政策", "privacyPolicy.md") - } - ) - SettingItem( - title = stringResource(R.string.license), - description = null, - option = null, - onClick = { - onShowMdFile("许可证", "LICENSE.md") - } - ) - SettingItem( - title = stringResource(R.string.disclaimer), - description = null, - option = null, - onClick = { - onShowMdFile("免责声明", "disclaimer.md") - } - ) - SettingItem( - title = stringResource(R.string.crash_log), - description = null, - option = null, - onClick = onShowCrashLogs - ) - SettingItem( - title = stringResource(R.string.save_log), - description = null, - option = null, - onClick = onSaveLog - ) - SettingItem( - title = stringResource(R.string.create_heap_dump), - description = null, - option = null, - onClick = onCreateHeapDump - ) + SettingItemWithDivider { + SettingItem( + title = stringResource(R.string.contributors), + onClick = { + onOpenUrl("https://github.com/gedoor/legado/graphs/contributors") + } + ) + } + SettingItemWithDivider { + SettingItem( + title = stringResource(R.string.privacy_policy), + onClick = { + onShowMdFile("隐私政策", "privacyPolicy.md") + } + ) + } + SettingItemWithDivider { + SettingItem( + title = stringResource(R.string.license), + onClick = { + onShowMdFile("许可证", "LICENSE.md") + } + ) + } + SettingItemWithDivider { + SettingItem( + title = stringResource(R.string.disclaimer), + onClick = { + onShowMdFile("免责声明", "disclaimer.md") + } + ) + } + SettingItemWithDivider { + SettingItem( + title = stringResource(R.string.crash_log), + onClick = onShowCrashLogs + ) + } + SettingItemWithDivider { + SettingItem( + title = stringResource(R.string.save_log), + onClick = onSaveLog + ) + } + SettingItemWithDivider { + SettingItem( + title = stringResource(R.string.create_heap_dump), + onClick = onCreateHeapDump + ) + } } } } diff --git a/app/src/main/java/io/legado/app/ui/about/CrashReportScreen.kt b/app/src/main/java/io/legado/app/ui/about/CrashReportScreen.kt index b532985d4..f8ce37111 100644 --- a/app/src/main/java/io/legado/app/ui/about/CrashReportScreen.kt +++ b/app/src/main/java/io/legado/app/ui/about/CrashReportScreen.kt @@ -75,7 +75,7 @@ fun CrashReportScreen( .fillMaxWidth() .verticalScroll(rememberScrollState()) ) { - Text( + AppText( text = displayText, style = LegadoTheme.typography.bodySmall, fontFamily = FontFamily.Monospace diff --git a/app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt b/app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt index 2fc5822bf..c40d619f3 100644 --- a/app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt @@ -25,10 +25,13 @@ import androidx.lifecycle.lifecycleScope import androidx.transition.TransitionManager import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColorsOptions +import androidx.constraintlayout.widget.ConstraintLayout import com.google.android.material.color.MaterialColors import com.google.android.material.slider.Slider import com.google.android.material.transition.platform.MaterialContainerTransform import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback +import android.graphics.Typeface +import com.dirror.lyricviewx.OnPlayClickListener import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.BookType @@ -43,6 +46,7 @@ import io.legado.app.databinding.ActivityAudioPlayBinding import io.legado.app.help.book.isAudio import io.legado.app.help.book.removeType import io.legado.app.help.config.AppConfig +import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.model.AudioPlay import io.legado.app.model.BookCover import io.legado.app.model.SourceCallBack @@ -55,6 +59,7 @@ import io.legado.app.ui.login.SourceLoginActivity import io.legado.app.utils.StartActivityContract import io.legado.app.utils.ToolbarUtils.setAllIconsColor import io.legado.app.utils.applyNavigationBarPadding +import io.legado.app.utils.dpToPx import io.legado.app.utils.gone import io.legado.app.utils.observeEvent import io.legado.app.utils.observeEventSticky @@ -96,6 +101,9 @@ class AudioPlayActivity : private var secondaryContainerFinalColor: Int = 0 private var currentJob: Job? = null private var wrappedContext: Context? = null + private val lyricViewX by lazy { binding.lyricViewX } + private var lyricOn = false + private var oldLyric: String? = null private val progressTimeFormat by lazy { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { @@ -227,6 +235,21 @@ class AudioPlayActivity : } private fun initView() { + // 应用深度个性化中设置的字体 + ThemeConfig.appFontPath?.let { + try { + val typeface = Typeface.createFromFile(it) + // 为所有文本控件设置字体 + binding.tvTitle.typeface = typeface + binding.tvSubTitle.typeface = typeface + binding.tvDurTime.typeface = typeface + binding.tvAllTime.typeface = typeface + binding.tvSpeed.typeface = typeface + binding.tvTimer.typeface = typeface + } catch (e: Exception) { + e.printStackTrace() + } + } binding.ivTimer.isEnabled = false binding.ivFastForward.isEnabled = false @@ -279,7 +302,10 @@ class AudioPlayActivity : override fun onStopTrackingTouch(slider: Slider) { adjustProgress = false - AudioPlay.adjustProgress(slider.value.toInt()) + val progress = slider.value.toInt() + AudioPlay.adjustProgress(progress) + // 更新歌词进度 + upLyricP(progress) } }) @@ -503,6 +529,8 @@ class AudioPlayActivity : binding.playerProgress.trackActiveTintList = ColorStateList.valueOf(color) binding.tvDurTime.setTextColor(color) binding.tvAllTime.setTextColor(color) + // 更新歌词高亮颜色 + binding.lyricViewX.setCurrentColor(color) } } @@ -671,6 +699,8 @@ class AudioPlayActivity : val safeValue = progress.toFloat().coerceIn(slider.valueFrom, slider.valueTo) if (!adjustProgress) slider.value = safeValue binding.tvDurTime.text = progressTimeFormat.format(progress.toLong()) + // 更新歌词进度 + upLyricP(progress) } observeEventSticky(EventBus.AUDIO_BUFFER_PROGRESS) { @@ -702,6 +732,56 @@ class AudioPlayActivity : } } + override fun upLyric(lyric: String?) { + if (oldLyric == lyric) return + oldLyric = lyric + if (lyric.isNullOrBlank()) { + // 没有歌词时,显示标题区域,隐藏歌词 + binding.lyricViewX.gone() + binding.llTitle.visible() + return + } + // 有歌词时,隐藏标题区域,显示歌词 + binding.llTitle.gone() + // 先隐藏歌词视图,避免显示旧歌词 + binding.lyricViewX.gone() + lyricViewX.loadLyric(lyric) + binding.lyricViewX.visible() + if (lyricOn) { + upLyricP(AudioPlay.durChapterPos) + } else { + lyricOn = true + lyricViewX.apply { + setNormalTextSize(50F) + setCurrentTextSize(60F) + // 加载深度个性化中设置的字体 + ThemeConfig.appFontPath?.let { + try { + val typeface = Typeface.createFromFile(it) + // LyricViewX 1.3.2 版本可能不支持 setTypeface 方法 + // 暂时注释掉,后续可以通过 XML 或其他方式设置 + // setTypeface(typeface) + } catch (e: Exception) { + e.printStackTrace() + } + } + setDraggable(true, object : OnPlayClickListener { + override fun onPlayClick(time: Long): Boolean { + AudioPlay.adjustProgress(time.toInt()) + return true + } + }) + } + lyricViewX.postDelayed({ + upLyricP(AudioPlay.durChapterPos) + }, 100) + } + } + + override fun upLyricP(position: Int) { + lyricViewX.updateTime(position.toLong(), false) + } + override fun addToBookshelf(book: Book, toc: List) { viewModel.addToBookshelf(book, toc) { toastOnUi("已添加到书架") diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt index a1a9a3456..c734a27e3 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt @@ -72,6 +72,8 @@ import io.legado.app.constant.BookType import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.help.book.isLocal +import io.legado.app.help.config.AppConfig +import io.legado.app.model.BookCover import io.legado.app.ui.about.AppLogSheet import io.legado.app.ui.config.coverConfig.CoverConfig import io.legado.app.ui.theme.LegadoTheme @@ -402,6 +404,9 @@ private fun BookInfoTransparentTopAppBar( @Composable private fun rememberBookInfoColorTheme(book: Book?): ThemeOverrideState? { + val useDefaultCover = AppConfig.useDefaultCover || book?.customCoverUrl == "use_default_cover" + if (useDefaultCover) return null + val imageLoader = koinInject() var shouldExtractColor by remember(book?.bookUrl) { mutableStateOf(false) } @@ -413,7 +418,12 @@ private fun rememberBookInfoColorTheme(book: Book?): ThemeOverrideState? { } } - val coverPath = if (shouldExtractColor) book?.getDisplayCover() else null + val isNight = AppConfig.isNightTheme + + val coverPath = if (shouldExtractColor) { + book?.getDisplayCover() + } else null + val sourceOrigin = if (shouldExtractColor) book?.origin else null val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) { @@ -469,8 +479,15 @@ private fun BookInfoTopBarActions( @Composable private fun BookInfoBackdrop(book: Book) { - val cover = book.getDisplayCover() - val sourceOrigin = book.origin + val useDefaultCover = AppConfig.useDefaultCover || book.customCoverUrl == "use_default_cover" + val isNight = AppConfig.isNightTheme + + val cover = if (useDefaultCover) { + BookCover.getRandomDefaultPath(book.bookUrl, isNight) + } else { + book.getDisplayCover() + } + val sourceOrigin = if (!useDefaultCover) book.origin else null val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi val context = LocalContext.current val imageLoader = koinInject() 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 04efbf699..55f34ce0c 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 @@ -709,19 +709,30 @@ class BookInfoViewModel( private fun refreshMeta(book: Book) { execute { - val kinds = book.getKindList().toMutableList() + val allKinds = book.getKindList() + val customKinds = allKinds.filter { it.startsWith("#") } + val sourceKinds = allKinds.filter { !it.startsWith("#") }.toMutableList() if (book.isLocal) { val size = FileDoc.fromFile(book.bookUrl).size if (size > 0) { - kinds.add(ConvertUtils.formatFileSize(size)) + sourceKinds.add(ConvertUtils.formatFileSize(size)) } } + val mergedKinds = (customKinds + sourceKinds).distinct() val userGroupIds = appDb.bookGroupDao.idsSum val groupAnd = userGroupIds and book.group val hasCustomGroup = book.group > 0L && groupAnd != 0L val groupNames = appDb.bookGroupDao.getGroupNames(book.group).joinToString(",") val normalizedGroupNames = groupNames.ifBlank { null } - Triple(kinds.toList(), normalizedGroupNames, hasCustomGroup) + val persistedCustomKinds = currentKindLabels.filter { it.startsWith("#") } + val finalKinds = if (persistedCustomKinds.isNotEmpty()) { + (persistedCustomKinds + sourceKinds).distinct() + } else { + mergedKinds + } + book.kind = finalKinds.joinToString(",") + appDb.bookDao.update(book) + Triple(finalKinds, normalizedGroupNames, hasCustomGroup) }.onSuccess { currentKindLabels = it.first currentGroupNames = it.second diff --git a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt index 8ad1157be..ee05f7119 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt @@ -3,6 +3,7 @@ package io.legado.app.ui.book.info.edit import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -18,6 +19,8 @@ import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.ImageSearch import androidx.compose.material.icons.filled.Replay @@ -27,6 +30,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -35,6 +40,7 @@ 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.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -52,6 +58,7 @@ import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults +import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.utils.SelectImageContract import io.legado.app.utils.launch import io.legado.app.utils.showDialogFragment @@ -174,10 +181,17 @@ fun BookInfoEditContent( onCheckedChange = { viewModel.onFixedTypeChange(it) } ) Spacer(modifier = Modifier.height(16.dp)) + val bookInfoInputColor = ThemeConfig.bookInfoInputColor + val inputBackgroundColor = if (bookInfoInputColor != 0) { + Color(bookInfoInputColor) + } else { + Color.Unspecified + } AppTextField( value = uiState.name, onValueChange = { viewModel.onNameChange(it) }, label = "书名", + backgroundColor = inputBackgroundColor, modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(8.dp)) @@ -185,6 +199,7 @@ fun BookInfoEditContent( value = uiState.author, onValueChange = { viewModel.onAuthorChange(it) }, label = "作者", + backgroundColor = inputBackgroundColor, modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(8.dp)) @@ -192,13 +207,21 @@ fun BookInfoEditContent( value = uiState.coverUrl ?: "", onValueChange = { viewModel.onCoverUrlChange(it) }, label = "封面链接", + backgroundColor = inputBackgroundColor, modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(8.dp)) + KindEditor( + kindList = uiState.kindList, + onKindListChange = { viewModel.onKindListChange(it) }, + backgroundColor = inputBackgroundColor + ) + Spacer(modifier = Modifier.height(8.dp)) AppTextField( value = uiState.intro ?: "", onValueChange = { viewModel.onIntroChange(it) }, label = "简介", + backgroundColor = inputBackgroundColor, modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(8.dp)) @@ -206,6 +229,7 @@ fun BookInfoEditContent( value = uiState.remark ?: "", onValueChange = { viewModel.onRemarkChange(it) }, label = "备注", + backgroundColor = inputBackgroundColor, modifier = Modifier.fillMaxWidth() ) } @@ -228,6 +252,13 @@ fun BookTypeDropdown( textFieldState.setTextAndPlaceCursorAtEnd(selectedType) } + val bookInfoInputColor = ThemeConfig.bookInfoInputColor + val inputBackgroundColor = if (bookInfoInputColor != 0) { + Color(bookInfoInputColor) + } else { + Color.Unspecified + } + ExposedDropdownMenuBox( modifier = Modifier.padding(horizontal = 8.dp), expanded = expanded, @@ -238,6 +269,7 @@ fun BookTypeDropdown( readOnly = true, lineLimits = TextFieldLineLimits.SingleLine, label = "书籍类型", + backgroundColor = inputBackgroundColor, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, @@ -265,3 +297,90 @@ fun BookTypeDropdown( } } } + +@Composable +fun KindEditor( + kindList: List, + onKindListChange: (List) -> Unit, + backgroundColor: Color +) { + var newKindText by remember { mutableStateOf("") } + + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + AppTextField( + value = newKindText, + onValueChange = { newKindText = it }, + label = "标签", + backgroundColor = backgroundColor, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + MediumOutlinedIconButton( + onClick = { + val trimmed = newKindText.trim() + val tag = if (trimmed.startsWith("#")) trimmed else "#$trimmed" + if (tag.isNotBlank() && tag !in kindList) { + val sortedList = (kindList + tag).sortedWith( + compareBy { !it.startsWith("#") }.thenBy { it } + ) + onKindListChange(sortedList) + newKindText = "" + } + }, + imageVector = Icons.Default.Add + ) + } + Spacer(modifier = Modifier.height(4.dp)) + if (kindList.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + kindList.forEach { kind -> + Row( + verticalAlignment = Alignment.CenterVertically + ) { + AppTextField( + value = kind, + onValueChange = { newValue -> + val index = kindList.indexOf(kind) + if (index >= 0) { + val newList = kindList.toMutableList() + newList[index] = newValue + onKindListChange(newList) + } + }, + label = null, + backgroundColor = backgroundColor, + singleLine = true, + maxLines = 1, + modifier = Modifier.width(80.dp) + ) + IconButton( + onClick = { + onKindListChange(kindList - kind) + }, + modifier = Modifier.padding(0.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "删除标签", + tint = Color.Red.copy(alpha = 0.7f), + modifier = Modifier.padding(4.dp) + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt index dba5538fb..259ccade1 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt @@ -19,6 +19,7 @@ import io.legado.app.model.ReadBook import io.legado.app.utils.FileUtils import io.legado.app.utils.MD5Utils import io.legado.app.utils.inputStream +import io.legado.app.utils.splitNotBlank import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -31,6 +32,7 @@ data class BookInfoEditUiState( val coverUrl: String? = null, val intro: String? = null, val remark: String? = null, + val kindList: List = emptyList(), val selectedType: String = "文本", val bookTypes: List = listOf("文本", "音频", "图片"), val fixedType: Boolean = false, @@ -57,6 +59,7 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio coverUrl = it.getDisplayCover(), intro = it.getDisplayIntro(), remark = it.remark, + kindList = it.kind?.split(",", "\n")?.filter { kind -> kind.isNotBlank() }.orEmpty(), selectedType = _uiState.value.bookTypes[selectedTypeIndex], fixedType = it.config.fixedType, book = it @@ -85,6 +88,10 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio _uiState.value = _uiState.value.copy(remark = remark) } + fun onKindListChange(kindList: List) { + _uiState.value = _uiState.value.copy(kindList = kindList) + } + fun onBookTypeChange(bookType: String) { _uiState.value = _uiState.value.copy(selectedType = bookType) } @@ -116,6 +123,7 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio book.config.fixedType = currentState.fixedType book.customCoverUrl = if (currentState.coverUrl == book.coverUrl) null else currentState.coverUrl book.customIntro = if (currentState.intro == book.intro) null else currentState.intro + book.kind = currentState.kindList.joinToString(",") BookHelp.updateCacheFolder(oldBook, book) if (ReadBook.book?.bookUrl == book.bookUrl) { 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 index 799423122..02461dee8 100644 --- 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 @@ -86,8 +86,12 @@ 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.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_COLOR +import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TIP_HEADER_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_DIVIDER_COLOR +import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TITLE_COLOR +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.ToolButtonConfigDialog import io.legado.app.ui.book.read.config.UnderlineConfigDialog.Companion.U_COLOR import io.legado.app.ui.book.read.page.ContentTextView @@ -95,6 +99,7 @@ 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.TextChapterLayout import io.legado.app.ui.book.read.page.provider.LayoutProgressListener import io.legado.app.ui.book.searchContent.SearchContentActivity import io.legado.app.ui.book.searchContent.SearchResult @@ -1698,8 +1703,14 @@ class ReadBookActivity : BaseReadBookActivity(), } } - TIP_COLOR -> { - ReadTipConfig.tipColor = color + 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)) } @@ -1710,6 +1721,24 @@ class ReadBookActivity : BaseReadBookActivity(), 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) 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 index 38b4383a1..30ff24826 100644 --- 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 @@ -107,6 +107,10 @@ class FontConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_font_conf callBack2?.showShadowSet() } + binding.btnRegexColor.setOnClickListener { + RegexColorConfigDialog().show(childFragmentManager, "regexColorConfig") + } + binding.btnSelectFonts.setOnClickListener { callBack2?.showFontSelect() } 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 index e85dbf67c..a352c76e7 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -50,6 +51,7 @@ class FontSelectDialog : BaseBottomSheetDialogFragment(R.layout.dialog_font_sele val curFontPath = callBack?.curFontPath ?: "" FontAdapter(requireContext(), curFontPath) } + private var allFontItems: List = emptyList() private val selectFontDir = registerForActivityResult(HandleFileContract()) { it.uri?.let { uri -> @@ -76,6 +78,22 @@ class FontSelectDialog : BaseBottomSheetDialogFragment(R.layout.dialog_font_sele 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() { @@ -135,6 +153,7 @@ class FontSelectDialog : BaseBottomSheetDialogFragment(R.layout.dialog_font_sele } ?: ArrayList() mergeFontItems(fontItems, getLocalFonts()) }.onSuccess { + allFontItems = it adapter.setItems(it) }.onError { AppLog.put("加载字体文件失败\n${it.localizedMessage}", it) @@ -176,8 +195,10 @@ class FontSelectDialog : BaseBottomSheetDialogFragment(R.layout.dialog_font_sele callBack?.selectFont("") } + var explicitCallback: CallBack? = null + private val callBack: CallBack? - get() = (parentFragment as? CallBack) ?: (activity as? CallBack) + get() = explicitCallback ?: (parentFragment as? CallBack) ?: (activity as? CallBack) inner class FontAdapter(context: Context, curFilePath: String) : RecyclerAdapter(context) { 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 index 085e514b4..b778c0ad9 100644 --- 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 @@ -11,23 +11,32 @@ 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_COLOR 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) { +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) { - upTvTipColor() + upTvHeaderColor() + upTvFooterColor() upTvTipDividerColor() } observeEvent>(EventBus.UP_CONFIG) { list -> @@ -59,17 +68,28 @@ class InfoConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_info callBack?.showPaddingConfig() dismissAllowingStateLoss() } - upTvTipColor() + upTvHeaderColor() + upTvFooterColor() upTvTipDividerColor() + binding.scvHeaderFontSize.progress = ReadBookConfig.headerFontSize } - private fun upTvTipColor() { - val tipColor = if (ReadTipConfig.tipColor == 0) { + private fun upTvHeaderColor() { + val tipColor = if (ReadTipConfig.tipHeaderColor == 0) { ReadBookConfig.textColor } else { - ReadTipConfig.tipColor + ReadTipConfig.tipHeaderColor } - binding.btnTipColor.color = tipColor + 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() { @@ -171,19 +191,36 @@ class InfoConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_info postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) } } - btnTipColor.setOnClickListener { + btnHeaderColor.setOnClickListener { context?.selector(items = ReadTipConfig.tipColorNames) { _, i -> when (i) { 0 -> { - ReadTipConfig.tipColor = 0 - upTvTipColor() + ReadTipConfig.tipHeaderColor = 0 + upTvHeaderColor() postEvent(EventBus.UP_CONFIG, arrayListOf(2)) } 1 -> ColorPickerDialog.newBuilder() .setShowAlphaSlider(false) .setDialogType(ColorPickerDialog.TYPE_CUSTOM) - .setDialogId(TIP_COLOR) + .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()) } } @@ -205,6 +242,14 @@ class InfoConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_info } } } + 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 { 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 new file mode 100644 index 000000000..49bace693 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/read/config/RegexColorConfigDialog.kt @@ -0,0 +1,204 @@ +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/TipConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/TipConfigDialog.kt index 1b992e482..4dae1af7e 100644 --- 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 @@ -19,17 +19,27 @@ import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding -class TipConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_tip_config) { +class TipConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_tip_config), FontSelectDialog.CallBack { companion object { - const val TIP_COLOR = 7897 + 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() } @@ -41,6 +51,10 @@ class TipConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_tip_config 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() { @@ -53,16 +67,40 @@ class TipConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_tip_config } 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 { 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 index 7d60dd0ca..6f26ed132 100644 --- 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 @@ -10,6 +10,7 @@ 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 @@ -39,6 +40,7 @@ class UnderlineConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_unde binding.swUnderline.isChecked = underline binding.swDottedline.isChecked = dottedLine binding.swDottedline.isEnabled = underline + binding.swUnderlineExtend.isChecked = underlineExtend binding.swUnderline.addOnCheckedChangeListener { _, isChecked -> underline = isChecked @@ -55,6 +57,11 @@ class UnderlineConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_unde 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 = { 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 eeebdcb23..b3901e4c9 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 @@ -8,6 +8,7 @@ import android.view.LayoutInflater import android.widget.FrameLayout import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toDrawable +import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone @@ -30,9 +31,11 @@ import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.applyStatusBarPadding import io.legado.app.utils.dpToPx import io.legado.app.utils.gone +import io.legado.app.utils.isContentScheme import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat import io.legado.app.utils.statusBarHeight import splitties.views.backgroundColor +import java.io.File import java.util.Date /** @@ -91,8 +94,11 @@ class PageView(context: Context) : FrameLayout(context) { upTipStyle() ReadBookConfig.let { val textColor = it.textColor - val tipColor = with(ReadTipConfig) { - if (tipColor == 0) textColor else tipColor + val headerColor = with(ReadTipConfig) { + if (tipHeaderColor == 0) textColor else tipHeaderColor + } + val footerColor = with(ReadTipConfig) { + if (tipFooterColor == 0) textColor else tipFooterColor } val tipDividerColor = with(ReadTipConfig) { when (tipDividerColor) { @@ -101,12 +107,12 @@ class PageView(context: Context) : FrameLayout(context) { else -> tipDividerColor } } - tvHeaderLeft.setColor(tipColor) - tvHeaderMiddle.setColor(tipColor) - tvHeaderRight.setColor(tipColor) - tvFooterLeft.setColor(tipColor) - tvFooterMiddle.setColor(tipColor) - tvFooterRight.setColor(tipColor) + tvHeaderLeft.setColor(headerColor) + tvHeaderMiddle.setColor(headerColor) + tvHeaderRight.setColor(headerColor) + tvFooterLeft.setColor(footerColor) + tvFooterMiddle.setColor(footerColor) + tvFooterRight.setColor(footerColor) vwTopDivider.backgroundColor = tipDividerColor vwBottomDivider.backgroundColor = tipDividerColor upStatusBar() @@ -131,6 +137,24 @@ class PageView(context: Context) : FrameLayout(context) { upBattery(battery) } + private fun loadTypeface(fontPath: String): Typeface? { + return runCatching { + when { + fontPath.isContentScheme() -> { + context.contentResolver + .openFileDescriptor(fontPath.toUri(), "r") + ?.use { + Typeface.Builder(it.fileDescriptor).build() + } + } + fontPath.isNotEmpty() -> { + Typeface.Builder(File(fontPath)).build() + } + else -> null + } + }.getOrNull() + } + /** * 显示状态栏时隐藏header */ @@ -208,106 +232,108 @@ class PageView(context: Context) : FrameLayout(context) { tvFooterRight.batteryMode = BatteryView.BatteryMode.NO_BATTERY } } + val tipTypeface = loadTypeface(ReadBookConfig.headerFont) ?: ChapterProvider.typeface + val tipTextSize = ReadBookConfig.headerFontSize.toFloat() tvTitle = getTipView(ReadTipConfig.chapterTitle)?.apply { tag = ReadTipConfig.chapterTitle - typeface = ChapterProvider.typeface - textSize = 12f + typeface = tipTypeface + textSize = tipTextSize batteryMode = BatteryView.BatteryMode.NO_BATTERY } tvTitleArrow = getTipView(ReadTipConfig.chapterTitleArrow)?.apply { tag = ReadTipConfig.chapterTitleArrow typeface = Typeface.DEFAULT - textSize = 12f + textSize = tipTextSize batteryMode = BatteryView.BatteryMode.ARROW } tvTitleArrowClassic = getTipView(ReadTipConfig.chapterTitleArrowClassic)?.apply { tag = ReadTipConfig.chapterTitleArrowClassic - typeface = ChapterProvider.typeface - textSize = 12f + typeface = tipTypeface + textSize = tipTextSize batteryMode = BatteryView.BatteryMode.ARROW } tvTime = getTipView(ReadTipConfig.time)?.apply { tag = ReadTipConfig.time - typeface = ChapterProvider.typeface - textSize = 12f + typeface = tipTypeface + textSize = tipTextSize batteryMode = BatteryView.BatteryMode.NO_BATTERY } tvBattery = getTipView(ReadTipConfig.battery)?.apply { tag = ReadTipConfig.battery typeface = Typeface.DEFAULT - textSize = 11f + textSize = tipTextSize batteryMode = BatteryView.BatteryMode.OUTER } tvBatteryClassic = getTipView(ReadTipConfig.batteryClassic)?.apply { tag = ReadTipConfig.batteryClassic - textSize = 11f + textSize = tipTextSize batteryMode = BatteryView.BatteryMode.CLASSIC } tvBatteryInside = getTipView(ReadTipConfig.batteryInside)?.apply { tag = ReadTipConfig.batteryInside typeface = Typeface.DEFAULT - textSize = 11f + textSize = tipTextSize batteryMode = BatteryView.BatteryMode.INNER } tvBatteryIcon = getTipView(ReadTipConfig.batteryIcon)?.apply { tag = ReadTipConfig.batteryIcon typeface = Typeface.DEFAULT - textSize = 11f + textSize = tipTextSize batteryMode = BatteryView.BatteryMode.ICON } tvPage = getTipView(ReadTipConfig.page)?.apply { tag = ReadTipConfig.page - typeface = ChapterProvider.typeface - textSize = 12f + typeface = tipTypeface + textSize = tipTextSize batteryMode = BatteryView.BatteryMode.NO_BATTERY } tvTotalProgress = getTipView(ReadTipConfig.totalProgress)?.apply { tag = ReadTipConfig.totalProgress batteryMode = BatteryView.BatteryMode.NO_BATTERY - typeface = ChapterProvider.typeface - textSize = 12f + typeface = tipTypeface + textSize = tipTextSize } tvTotalProgress1 = getTipView(ReadTipConfig.totalProgress1)?.apply { tag = ReadTipConfig.totalProgress1 batteryMode = BatteryView.BatteryMode.NO_BATTERY - typeface = ChapterProvider.typeface - textSize = 12f + typeface = tipTypeface + textSize = tipTextSize } tvPageAndTotal = getTipView(ReadTipConfig.pageAndTotal)?.apply { tag = ReadTipConfig.pageAndTotal batteryMode = BatteryView.BatteryMode.NO_BATTERY - typeface = ChapterProvider.typeface - textSize = 12f + typeface = tipTypeface + textSize = tipTextSize } tvBookName = getTipView(ReadTipConfig.bookName)?.apply { tag = ReadTipConfig.bookName batteryMode = BatteryView.BatteryMode.NO_BATTERY - typeface = ChapterProvider.typeface - textSize = 12f + typeface = tipTypeface + textSize = tipTextSize } tvTimeBattery = getTipView(ReadTipConfig.timeBattery)?.apply { tag = ReadTipConfig.timeBattery typeface = Typeface.DEFAULT - textSize = 11f + textSize = tipTextSize batteryMode = BatteryView.BatteryMode.TIME } tvTimeBatteryClassic = getTipView(ReadTipConfig.timeBatteryClassic)?.apply { tag = ReadTipConfig.timeBatteryClassic - typeface = ChapterProvider.typeface - textSize = 11f + typeface = tipTypeface + textSize = tipTextSize batteryMode = BatteryView.BatteryMode.CLASSIC } tvBatteryP = getTipView(ReadTipConfig.batteryPercentage)?.apply { tag = ReadTipConfig.batteryPercentage batteryMode = BatteryView.BatteryMode.NO_BATTERY - typeface = ChapterProvider.typeface - textSize = 12f + typeface = tipTypeface + textSize = tipTextSize } tvTimeBatteryP = getTipView(ReadTipConfig.timeBatteryPercentage)?.apply { tag = ReadTipConfig.timeBatteryPercentage batteryMode = BatteryView.BatteryMode.NO_BATTERY - typeface = ChapterProvider.typeface - textSize = 12f + typeface = tipTypeface + textSize = tipTextSize } } 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 1ee520669..7fad78178 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 @@ -73,6 +73,8 @@ data class TextLine( fun addColumn(column: BaseColumn) { if (column !is TextColumn) { onlyTextColumn = false + } else if (column.color != null) { + onlyTextColumn = false } column.textLine = this textColumns.add(column) @@ -189,6 +191,8 @@ data class TextLine( } val textColor = if (isReadAloud) { ReadBookConfig.textAccentColor + } else if (isTitle && ReadBookConfig.titleColor != 0) { + ReadBookConfig.titleColor } else { ReadBookConfig.textColor } @@ -229,7 +233,17 @@ data class TextLine( null val lineY = height + (ReadBookConfig.durConfig.underlinePadding - 10).dpToPx() - canvas.drawLine(lineStart + indentWidth, lineY, lineEnd, lineY, paint) + val startX = if (ReadBookConfig.underlineExtend) { + ChapterProvider.paddingLeft.toFloat() + } else { + lineStart + indentWidth + } + val endX = if (ReadBookConfig.underlineExtend) { + (ChapterProvider.paddingLeft + ChapterProvider.visibleWidth).toFloat() + } else { + lineEnd + } + canvas.drawLine(startX, lineY, endX, lineY, paint) } diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextPage.kt b/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextPage.kt index 1303ea213..561df6456 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextPage.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/page/entities/TextPage.kt @@ -368,9 +368,14 @@ data class TextPage( } fun upRenderHeight() { - renderHeight = ceil(lines.last().lineBottom).toInt() + val underlineExtraHeight = if (ReadBookConfig.underline) { + (ReadBookConfig.durConfig.underlinePadding + ReadBookConfig.underlineHeight).dpToPx().toInt() + } else { + 0 + } + renderHeight = ceil(lines.last().lineBottom).toInt() + underlineExtraHeight if (leftLineSize > 0 && leftLineSize != lines.size) { - val leftHeight = ceil(lines[leftLineSize - 1].lineBottom).toInt() + val leftHeight = ceil(lines[leftLineSize - 1].lineBottom).toInt() + underlineExtraHeight renderHeight = max(renderHeight, leftHeight) } } 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 53798199f..7330c1291 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 @@ -1,22 +1,26 @@ package io.legado.app.ui.book.read.page.entities.column 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, ) : TextBaseColumn { override var textLine: TextLine = emptyTextLine @@ -47,19 +51,44 @@ data class TextColumn( } else { ChapterProvider.contentPaint } - val textColor = if (!textLine.useUnderline && (textLine.isReadAloud || isSearchResult)) { + val textColor = color ?: if (!textLine.useUnderline && (textLine.isReadAloud || isSearchResult)) { ReadBookConfig.textAccentColor + } else if (textLine.isTitle && ReadBookConfig.titleColor != 0) { + ReadBookConfig.titleColor } else { ReadBookConfig.textColor } - val originalSize = textPaint.textSize - textLine.titleTextSize?.let { - textPaint.textSize = it + val needRestoreSize = textLine.titleTextSize != null + val needRestoreColor = textPaint.color != textColor + val customTypeface = fontPath?.let { getTypeface(it) } + val needRestoreTypeface = customTypeface != null + if (needRestoreSize) { + val originalSize = textPaint.textSize + textPaint.textSize = textLine.titleTextSize!! + if (needRestoreColor) textPaint.color = textColor + if (needRestoreTypeface) textPaint.typeface = customTypeface + val y = textLine.lineBase - textLine.lineTop + drawText(canvas, y, textPaint) + textPaint.textSize = originalSize + } else if (needRestoreColor || needRestoreTypeface) { + val originalColor = textPaint.color + val originalTypeface = textPaint.typeface + if (needRestoreColor) textPaint.color = textColor + if (needRestoreTypeface) textPaint.typeface = customTypeface + val y = textLine.lineBase - textLine.lineTop + drawText(canvas, y, textPaint) + if (needRestoreColor) textPaint.color = originalColor + if (needRestoreTypeface) textPaint.typeface = originalTypeface + } else { + val y = textLine.lineBase - textLine.lineTop + drawText(canvas, y, textPaint) } - if (textPaint.color != textColor) { - textPaint.color = textColor + if (selected) { + canvas.drawRect(start, 0f, end, textLine.height, view.selectedPaint) } - val y = textLine.lineBase - textLine.lineTop + } + + private fun drawText(canvas: Canvas, y: Float, textPaint: android.text.TextPaint) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { val letterSpacing = textPaint.letterSpacing * textPaint.textSize val letterSpacingHalf = letterSpacing * 0.5f @@ -67,10 +96,32 @@ data class TextColumn( } else { canvas.drawText(charData, start, y, textPaint) } - if (selected) { - canvas.drawRect(start, 0f, end, textLine.height, view.selectedPaint) + } + + 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() + } } - textPaint.textSize = originalSize } } diff --git a/app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt b/app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt index 95245a13b..27b747617 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt @@ -934,30 +934,49 @@ object ChapterProvider { @SuppressLint("UseKtx") private fun getPaints(typeface: Typeface?): Pair { + val titleTypeface = runCatching { + val titleFontPath = ReadBookConfig.titleFont + when { + titleFontPath.isContentScheme() -> { + appCtx.contentResolver + .openFileDescriptor(titleFontPath.toUri(), "r")!! + .use { + Typeface.Builder(it.fileDescriptor).build() + } + } + titleFontPath.isNotEmpty() -> { + Typeface.Builder(File(titleFontPath)).build() + } + else -> typeface + } + }.getOrNull() ?: typeface + val bold = Typeface.create(typeface, Typeface.BOLD) val normal = Typeface.create(typeface, Typeface.NORMAL) - val titleFont = when (ReadBookConfig.titleBold) { + val titleBoldTypeface = Typeface.create(titleTypeface, Typeface.BOLD) + val titleNormalTypeface = Typeface.create(titleTypeface, Typeface.NORMAL) + val titleFontTypeface = when (ReadBookConfig.titleBold) { 1 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) - Typeface.create(typeface, 900, false) + Typeface.create(titleTypeface, 900, false) else - bold + titleBoldTypeface 2 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) - Typeface.create(typeface, 300, false) + Typeface.create(titleTypeface, 300, false) else - normal + titleNormalTypeface 0 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) - Typeface.create(typeface, 400, false) + Typeface.create(titleTypeface, 400, false) else - normal + titleNormalTypeface in 100..900 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) - Typeface.create(typeface, ReadBookConfig.titleBold, false) + Typeface.create(titleTypeface, ReadBookConfig.titleBold, false) else - normal + titleNormalTypeface - else -> normal + else -> titleNormalTypeface } val textFont = when (ReadBookConfig.textBold) { @@ -989,7 +1008,7 @@ object ChapterProvider { val tPaint = TextPaint() tPaint.color = ReadBookConfig.textColor tPaint.letterSpacing = ReadBookConfig.letterSpacing - tPaint.typeface = titleFont + tPaint.typeface = titleFontTypeface if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && ReadBookConfig.titleBold in 100..900) tPaint.setFontVariationSettings("'wght' ${ReadBookConfig.titleBold}") tPaint.textSize = with(ReadBookConfig) { textSize + titleSize }.toFloat().spToPx() 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 c71b48c9b..23367e6d0 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 @@ -69,6 +69,14 @@ class TextChapterLayout( private val bookContent: BookContent, ) { + companion object { + private val regexCache = mutableMapOf() + + fun invalidateRegexCache() { + regexCache.clear() + } + } + @Volatile private var listener: LayoutProgressListener? = textChapter @@ -864,6 +872,7 @@ class TextChapterLayout( ) { val widthsArray = allocateFloatArray(text.length) textPaint.getTextWidthsCompat(text, widthsArray) + val colorMap = applyRegexColorRules(text) val layout = if (useZhLayout) { val (words, widths) = measureTextSplit(text, widthsArray) val indentSize = if (isFirstLine) paragraphIndent.length else 0 @@ -915,12 +924,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 + desiredWidth, widths, srcList, clickList, lineWordStyles ) } @@ -938,7 +950,7 @@ class TextChapterLayout( } addCharsToLineNatural( book, absStartX, textLine, words, - startX, !isTitle && lineIndex == 0, widths, srcList, clickList + startX, !isTitle && lineIndex == 0, widths, srcList, clickList, lineWordStyles ) } else -> { @@ -951,13 +963,13 @@ class TextChapterLayout( val startX = (visibleWidth - desiredWidth) / 2 addCharsToLineNatural( book, absStartX, textLine, words, - startX, false, widths, srcList, clickList + startX, false, widths, srcList, clickList, lineWordStyles ) } else { //中间行 addCharsToLineMiddle( book, absStartX, textLine, words, textPaint, - desiredWidth, 0f, widths, srcList, clickList + desiredWidth, 0f, widths, srcList, clickList, lineWordStyles ) } } @@ -1007,17 +1019,17 @@ class TextChapterLayout( textLine: TextLine, words: List, textPaint: TextPaint, - /**自然排版长度**/ desiredWidth: Float, textWidths: List, srcList: LinkedList?, - clickList: LinkedList? + clickList: LinkedList?, + wordStyles: List? = null ) { var x = 0f if (!textFullJustify) { addCharsToLineNatural( book, absStartX, textLine, words, - x, true, textWidths, srcList, clickList + x, true, textWidths, srcList, clickList, wordStyles ) return } @@ -1038,9 +1050,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) addCharsToLineMiddle( book, absStartX, textLine, text1, textPaint, - desiredWidth, x, textWidths1, srcList, clickList + desiredWidth, x, textWidths1, srcList, clickList, wordStyles1 ) } } @@ -1054,18 +1067,17 @@ class TextChapterLayout( textLine: TextLine, words: List, textPaint: TextPaint, - /**自然排版长度**/ desiredWidth: Float, - /**起始x坐标**/ startX: Float, textWidths: List, srcList: LinkedList?, - clickList: LinkedList? + clickList: LinkedList?, + wordStyles: List? = null ) { if (!textFullJustify) { addCharsToLineNatural( book, absStartX, textLine, words, - startX, false, textWidths, srcList, clickList + startX, false, textWidths, srcList, clickList, wordStyles ) return } @@ -1086,7 +1098,9 @@ class TextChapterLayout( } addCharToLine( book, absStartX, textLine, char, - x, x1, index + 1 == words.size, srcList, clickList + x, x1, index + 1 == words.size, srcList, clickList, + wordStyles?.getOrNull(index)?.color, + wordStyles?.getOrNull(index)?.fontPath ) x = x1 } @@ -1102,7 +1116,9 @@ class TextChapterLayout( val x1 = if (index != words.lastIndex) (x + cw + d) else (x + cw) addCharToLine( book, absStartX, textLine, char, - x, x1, index + 1 == words.size, srcList, clickList + x, x1, index + 1 == words.size, srcList, clickList, + wordStyles?.getOrNull(index)?.color, + wordStyles?.getOrNull(index)?.fontPath ) x = x1 } @@ -1122,7 +1138,8 @@ class TextChapterLayout( hasIndent: Boolean, textWidths: List, srcList: LinkedList?, - clickList: LinkedList? + clickList: LinkedList?, + wordStyles: List? = null ) { val indentLength = paragraphIndent.length var x = startX @@ -1140,7 +1157,9 @@ class TextChapterLayout( x1, index + 1 == words.size, srcList, - clickList + clickList, + wordStyles?.getOrNull(index)?.color, + wordStyles?.getOrNull(index)?.fontPath ) x = x1 if (hasIndent && index == indentLength - 1) { @@ -1162,7 +1181,9 @@ class TextChapterLayout( xEnd: Float, isLineEnd: Boolean, srcList: LinkedList?, - clickList: LinkedList? + clickList: LinkedList?, + color: Int? = null, + fontPath: String? = null ) { val column = when { !srcList.isNullOrEmpty() && (char == srcReplaceChar || char == reviewChar) -> { @@ -1176,19 +1197,14 @@ class TextChapterLayout( click = click ) } -// isLineEnd && char == ChapterProvider.reviewChar -> { -// ReviewColumn( -// start = absStartX + xStart, -// end = absStartX + xEnd, -// count = 10 -// ) -// } else -> { TextColumn( start = absStartX + xStart, end = absStartX + xEnd, - charData = char + charData = char, + color = color, + fontPath = fontPath ) } } @@ -1289,4 +1305,78 @@ class TextChapterLayout( return code == 8203 || code == 8204 || code == 8205 || code == 8288 } + private data class RegexMatchResult( + val colorArray: IntArray, + val fontPathArray: Array + ) + + private fun applyRegexColorRules(text: String): RegexMatchResult? { + val rules = ReadBookConfig.regexColorRules + if (rules.isEmpty()) return null + var hasMatch = false + for (rule in rules) { + try { + val regex = regexCache.getOrPut(rule.pattern) { Regex(rule.pattern) } + if (regex.containsMatchIn(text)) { + hasMatch = true + break + } + } catch (_: Exception) { + } + } + if (!hasMatch) return null + val colorArray = IntArray(text.length) { -1 } + val fontPathArray = arrayOfNulls(text.length) + for (rule in rules) { + try { + val regex = regexCache.getOrPut(rule.pattern) { Regex(rule.pattern) } + val matches = regex.findAll(text) + for (match in matches) { + for (i in match.range) { + colorArray[i] = rule.color + if (rule.fontPath.isNotEmpty()) { + fontPathArray[i] = rule.fontPath + } + } + } + } catch (_: Exception) { + } + } + return RegexMatchResult(colorArray, fontPathArray) + } + + private data class WordStyle( + val color: Int?, + val fontPath: String? + ) + + 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 + } + return wordStyles + } + } diff --git a/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt index 631161d3d..1844242da 100644 --- a/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt @@ -342,6 +342,7 @@ class BookSourceEditActivity : contentEntities.clear() contentEntities.apply { add(EditEntity("content", cr.content, R.string.rule_book_content)) + add(EditEntity("subContent", cr.subContent, R.string.rule_sub_content)) add(EditEntity("title", cr.title, R.string.rule_chapter_name)) add(EditEntity("nextContentUrl", cr.nextContentUrl, R.string.rule_next_content)) add(EditEntity("webJs", cr.webJs, R.string.rule_web_js)) @@ -530,6 +531,7 @@ class BookSourceEditActivity : it.value = it.value?.takeIf { s -> s.isNotBlank() } when (it.key) { "content" -> contentRule.content = viewModel.ruleComplete(it.value) + "subContent" -> contentRule.subContent = viewModel.ruleComplete(it.value) "title" -> contentRule.title = viewModel.ruleComplete(it.value) "nextContentUrl" -> contentRule.nextContentUrl = viewModel.ruleComplete(it.value, type = 2) diff --git a/app/src/main/java/io/legado/app/ui/config/PrefDelegate.kt b/app/src/main/java/io/legado/app/ui/config/PrefDelegate.kt index 167fd886d..0827dd6c4 100644 --- a/app/src/main/java/io/legado/app/ui/config/PrefDelegate.kt +++ b/app/src/main/java/io/legado/app/ui/config/PrefDelegate.kt @@ -7,10 +7,12 @@ import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import io.legado.app.utils.defaultSharedPreferences import io.legado.app.utils.getPrefBoolean +import io.legado.app.utils.getPrefFloat import io.legado.app.utils.getPrefInt import io.legado.app.utils.getPrefLong import io.legado.app.utils.getPrefString import io.legado.app.utils.putPrefBoolean +import io.legado.app.utils.putPrefFloat import io.legado.app.utils.putPrefInt import io.legado.app.utils.putPrefLong import io.legado.app.utils.putPrefString @@ -63,6 +65,7 @@ fun prefDelegate( defaultValue is Int -> appCtx.getPrefInt(key, defaultValue) as T defaultValue is Boolean -> appCtx.getPrefBoolean(key, defaultValue) as T defaultValue is Long -> appCtx.getPrefLong(key, defaultValue) as T + defaultValue is Float -> appCtx.getPrefFloat(key, defaultValue) as T else -> defaultValue } } @@ -78,6 +81,7 @@ fun prefDelegate( is Int -> appCtx.putPrefInt(key, value) is Boolean -> appCtx.putPrefBoolean(key, value) is Long -> appCtx.putPrefLong(key, value) + is Float -> appCtx.putPrefFloat(key, value) } _value.value = value onValueChange?.invoke(value) diff --git a/app/src/main/java/io/legado/app/ui/config/bookshelfConfig/BookshelfConfig.kt b/app/src/main/java/io/legado/app/ui/config/bookshelfConfig/BookshelfConfig.kt index 33eca4350..23e5edaf9 100644 --- a/app/src/main/java/io/legado/app/ui/config/bookshelfConfig/BookshelfConfig.kt +++ b/app/src/main/java/io/legado/app/ui/config/bookshelfConfig/BookshelfConfig.kt @@ -53,6 +53,16 @@ object BookshelfConfig { */ var showLastUpdateTime by prefDelegate(PreferKey.showLastUpdateTime, false) + /** + * 是否在列表中显示书籍简介 + */ + var showBookIntro by prefDelegate(PreferKey.showBookIntro, false) + + /** + * 列表模式下简介显示行数 (0为显示全部) + */ + var bookshelfIntroMaxLines by prefDelegate(PreferKey.bookshelfIntroMaxLines, 0) + /** * 是否显示等待更新的书籍数量 */ diff --git a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverManageSheet.kt b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverManageSheet.kt index f9cee8f4c..c6a3a3ebe 100644 --- a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverManageSheet.kt +++ b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverManageSheet.kt @@ -30,6 +30,7 @@ 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.unit.dp @@ -86,7 +87,8 @@ fun CoverManageSheet( items(coverList) { path -> Box { NormalCard( - cornerRadius = 12.dp + cornerRadius = 12.dp, + containerColor = Color.Transparent ) { AsyncImage( model = path, diff --git a/app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeScreen.kt b/app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeScreen.kt new file mode 100644 index 000000000..d4546b037 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeScreen.kt @@ -0,0 +1,391 @@ +package io.legado.app.ui.config.customTheme + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.draw.clip +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +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.Modifier +import androidx.compose.ui.graphics.Color +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 com.google.android.material.color.DynamicColors +import com.google.android.material.color.DynamicColorsOptions +import io.legado.app.R +import io.legado.app.lib.theme.ThemeStore +import io.legado.app.lib.theme.primaryColor +import io.legado.app.ui.config.themeConfig.ThemeConfig +import io.legado.app.ui.theme.adaptiveContentPadding +import io.legado.app.ui.widget.components.AppScaffold +import io.legado.app.ui.widget.components.SplicedColumnGroup +import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton +import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem +import io.legado.app.ui.widget.components.dialog.ColorPickerSheet +import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem +import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem +import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar +import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomThemeScreen( + onBackClick: () -> Unit, + onNavigateToThemePack: () -> Unit +) { + val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() + var showColorPicker by remember { mutableStateOf(false) } + var currentColorKey by remember { mutableStateOf("themeColor") } + val context = LocalContext.current + + val enableDeepPersonalization = ThemeConfig.enableDeepPersonalization + + val themeColor = ThemeConfig.themeColor + val secondaryThemeColor = ThemeConfig.secondaryThemeColor + val primaryTextColor = ThemeConfig.primaryTextColor + val secondaryTextColor = ThemeConfig.secondaryTextColor + val themeBackgroundColor = ThemeConfig.themeBackgroundColor + val labelContainerColor = ThemeConfig.labelContainerColor + + val primaryColor = MaterialTheme.colorScheme.primary + + // 自定义主题 seed color + var showSeedColorPicker by remember { mutableStateOf(false) } + var pickNightSeedColor by remember { mutableStateOf(false) } + val primaryColorValue = remember { mutableIntStateOf(ThemeConfig.cPrimary) } + val nightPrimaryColorValue = remember { mutableIntStateOf(ThemeConfig.cNPrimary) } + + AppScaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + GlassMediumFlexibleTopAppBar( + title = stringResource(R.string.custom_theme), + scrollBehavior = scrollBehavior, + navigationIcon = { + TopBarNavigationButton(onClick = onBackClick) + } + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = adaptiveContentPadding( + top = paddingValues.calculateTopPadding(), + bottom = 120.dp + ) + ) { + // Master switch: ON = deep color overrides, OFF = seed color mode + item { + SplicedColumnGroup { + SwitchSettingItem( + title = "使用色板生成颜色", + checked = !enableDeepPersonalization, + onCheckedChange = { ThemeConfig.enableDeepPersonalization = !it } + ) + } + } + + // Color settings vs Seed color toggle based on enableDeepPersonalization + if (enableDeepPersonalization) { + item { + SplicedColumnGroup(title = stringResource(R.string.color_setting)) { + // Primary colors + ClickableSettingItem( + title = "主题色", + option = if (themeColor != 0) "#${Integer.toHexString(themeColor).uppercase()}" else stringResource(R.string.click_to_select), + onClick = { + currentColorKey = "themeColor" + showColorPicker = true + }, + trailingContent = { + if (themeColor != 0) { + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(Color(themeColor)) + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + CircleShape + ) + ) + } + } + ) + + ClickableSettingItem( + title = "次要主题色", + option = if (secondaryThemeColor != 0) "#${Integer.toHexString(secondaryThemeColor).uppercase()}" else stringResource(R.string.click_to_select), + onClick = { + currentColorKey = "secondaryThemeColor" + showColorPicker = true + }, + trailingContent = { + if (secondaryThemeColor != 0) { + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(Color(secondaryThemeColor)) + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + CircleShape + ) + ) + } + } + ) + + ClickableSettingItem( + title = "主要字体色", + option = if (primaryTextColor != 0) "#${Integer.toHexString(primaryTextColor).uppercase()}" else stringResource(R.string.click_to_select), + onClick = { + currentColorKey = "primaryTextColor" + showColorPicker = true + }, + trailingContent = { + if (primaryTextColor != 0) { + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(Color(primaryTextColor)) + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + CircleShape + ) + ) + } + } + ) + + ClickableSettingItem( + title = "次要字体色", + option = if (secondaryTextColor != 0) "#${Integer.toHexString(secondaryTextColor).uppercase()}" else stringResource(R.string.click_to_select), + onClick = { + currentColorKey = "secondaryTextColor" + showColorPicker = true + }, + trailingContent = { + if (secondaryTextColor != 0) { + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(Color(secondaryTextColor)) + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + CircleShape + ) + ) + } + } + ) + + ClickableSettingItem( + title = "背景色", + option = if (themeBackgroundColor != 0) "#${Integer.toHexString(themeBackgroundColor).uppercase()}" else stringResource(R.string.click_to_select), + onClick = { + currentColorKey = "themeBackgroundColor" + showColorPicker = true + }, + trailingContent = { + if (themeBackgroundColor != 0) { + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(Color(themeBackgroundColor)) + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + CircleShape + ) + ) + } + } + ) + + ClickableSettingItem( + title = "标签容器色", + option = if (labelContainerColor != 0) "#${Integer.toHexString(labelContainerColor).uppercase()}" else stringResource(R.string.click_to_select), + onClick = { + currentColorKey = "labelContainerColor" + showColorPicker = true + }, + trailingContent = { + if (labelContainerColor != 0) { + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(Color(labelContainerColor)) + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + CircleShape + ) + ) + } + } + ) + } + } + } else { + item { + SplicedColumnGroup(title = stringResource(R.string.custom_theme)) { + ClickableSettingItem( + title = stringResource(R.string.seed_color), + description = stringResource(R.string.day), + option = formatColorOption(primaryColorValue.intValue) + ?: stringResource(R.string.click_to_select), + onClick = { + pickNightSeedColor = false + showSeedColorPicker = true + }, + trailingContent = { ColorSwatch(colorValue = primaryColorValue.intValue) } + ) + ClickableSettingItem( + title = stringResource(R.string.seed_color), + description = stringResource(R.string.night), + option = formatColorOption(nightPrimaryColorValue.intValue) + ?: stringResource(R.string.click_to_select), + onClick = { + pickNightSeedColor = true + showSeedColorPicker = true + }, + trailingContent = { ColorSwatch(colorValue = nightPrimaryColorValue.intValue) } + ) + DropdownListSettingItem( + title = stringResource(R.string.palette_style), + selectedValue = ThemeConfig.paletteStyle, + displayEntries = stringArrayResource(R.array.paletteStyle), + entryValues = stringArrayResource(R.array.paletteStyle_value), + onValueChange = { ThemeConfig.paletteStyle = it } + ) + DropdownListSettingItem( + title = stringResource(R.string.preferred_contrast), + selectedValue = ThemeConfig.customContrast, + displayEntries = stringArrayResource(R.array.customContrast), + entryValues = stringArrayResource(R.array.customContrast_value), + onValueChange = { ThemeConfig.customContrast = it } + ) + DropdownListSettingItem( + title = stringResource(R.string.material_version), + selectedValue = ThemeConfig.materialVersion, + displayEntries = stringArrayResource(R.array.materialVersion), + entryValues = stringArrayResource(R.array.materialVersion_value), + onValueChange = { ThemeConfig.materialVersion = it } + ) + } + } + } + + // Theme Pack entry + item { + SplicedColumnGroup { + ClickableSettingItem( + title = stringResource(R.string.theme_pack), + description = stringResource(R.string.theme_pack_s), + onClick = onNavigateToThemePack + ) + } + } + + } + + // Deep personalization color picker + ColorPickerSheet( + show = showColorPicker, + initialColor = when (currentColorKey) { + "themeColor" -> themeColor + "secondaryThemeColor" -> secondaryThemeColor + "primaryTextColor" -> primaryTextColor + "secondaryTextColor" -> secondaryTextColor + "themeBackgroundColor" -> themeBackgroundColor + "labelContainerColor" -> labelContainerColor + else -> 0 + }, + onDismissRequest = { showColorPicker = false }, + onColorSelected = { + when (currentColorKey) { + "themeColor" -> ThemeConfig.themeColor = it + "secondaryThemeColor" -> ThemeConfig.secondaryThemeColor = it + "primaryTextColor" -> ThemeConfig.primaryTextColor = it + "secondaryTextColor" -> ThemeConfig.secondaryTextColor = it + "themeBackgroundColor" -> ThemeConfig.themeBackgroundColor = it + "labelContainerColor" -> ThemeConfig.labelContainerColor = it + } + } + ) + + // Seed color picker (from ThemeConfigScreen) + ColorPickerSheet( + show = showSeedColorPicker, + initialColor = if (pickNightSeedColor) { + nightPrimaryColorValue.value + } else { + primaryColorValue.value + }, + onDismissRequest = { showSeedColorPicker = false }, + onColorSelected = { color -> + if (pickNightSeedColor) { + nightPrimaryColorValue.intValue = color + ThemeConfig.cNPrimary = color + } else { + primaryColorValue.intValue = color + ThemeConfig.cPrimary = color + ThemeStore.editTheme(context) + .primaryColor(color) + .apply() + DynamicColors.applyToActivitiesIfAvailable( + context.applicationContext as android.app.Application, + DynamicColorsOptions.Builder() + .setContentBasedSource(context.primaryColor) + .build() + ) + } + } + ) + + } +} + +private fun formatColorOption(colorValue: Int): String? { + if (colorValue == 0) return null + return "#${Integer.toHexString(colorValue).uppercase()}" +} + +@Composable +private fun ColorSwatch(colorValue: Int) { + if (colorValue == 0) return + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(Color(colorValue)) + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + CircleShape + ) + ) +} diff --git a/app/src/main/java/io/legado/app/ui/config/mainConfig/MainConfig.kt b/app/src/main/java/io/legado/app/ui/config/mainConfig/MainConfig.kt index d035bde58..f982eada5 100644 --- a/app/src/main/java/io/legado/app/ui/config/mainConfig/MainConfig.kt +++ b/app/src/main/java/io/legado/app/ui/config/mainConfig/MainConfig.kt @@ -21,4 +21,8 @@ object MainConfig { var autoRefreshBook by prefDelegate(PreferKey.autoRefresh, false) var autoCheckNewBackup by prefDelegate(PreferKey.autoCheckNewBackup, true) var showStatusBar by prefDelegate(PreferKey.showStatusBar, true) + var navIconBookshelf by prefDelegate(PreferKey.navIconBookshelf, "") + var navIconExplore by prefDelegate(PreferKey.navIconExplore, "") + var navIconRss by prefDelegate(PreferKey.navIconRss, "") + var navIconMy by prefDelegate(PreferKey.navIconMy, "") } diff --git a/app/src/main/java/io/legado/app/ui/config/personalizationConfig/FontSelectScreen.kt b/app/src/main/java/io/legado/app/ui/config/personalizationConfig/FontSelectScreen.kt new file mode 100644 index 000000000..63a248a88 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/personalizationConfig/FontSelectScreen.kt @@ -0,0 +1,185 @@ +package io.legado.app.ui.config.personalizationConfig + +import android.content.Intent +import android.graphics.Typeface +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +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.input.nestedscroll.nestedScroll +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 androidx.core.content.res.ResourcesCompat +import io.legado.app.R +import io.legado.app.constant.PreferKey +import io.legado.app.help.loadFontFiles +import io.legado.app.ui.config.themeConfig.ThemeConfig +import io.legado.app.ui.theme.adaptiveContentPadding +import io.legado.app.ui.widget.components.AppScaffold +import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton +import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar +import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults +import io.legado.app.utils.FileDoc +import io.legado.app.utils.getPrefString +import io.legado.app.utils.putPrefString +import io.legado.app.utils.takePersistablePermissionSafely + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FontSelectScreen( + onBackClick: () -> Unit +) { + val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() + val context = LocalContext.current + var fontItems by remember { mutableStateOf>(emptyList()) } + var selectedFolderUri by remember { mutableStateOf(null) } + + // 加载字体文件 + fun loadFonts() { + fontItems = loadFontFiles(context, selectedFolderUri) + } + + // 从偏好设置中加载保存的字体文件夹路径 + remember { + val savedFontPath = context.getPrefString(PreferKey.fontFolder) + if (!savedFontPath.isNullOrEmpty()) { + selectedFolderUri = Uri.parse(savedFontPath) + } + loadFonts() + } + + // 文件夹选择启动器 + val selectFolderLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree() + ) { uri -> + if (uri != null) { + selectedFolderUri = uri + // 获取持久化权限 + uri.takePersistablePermissionSafely(context, Intent.FLAG_GRANT_READ_URI_PERMISSION) + // 保存选择的文件夹路径到偏好设置 + context.putPrefString(PreferKey.fontFolder, uri.toString()) + loadFonts() + } + } + + AppScaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + GlassMediumFlexibleTopAppBar( + title = stringResource(R.string.font_setting), + scrollBehavior = scrollBehavior, + navigationIcon = { + TopBarNavigationButton(onClick = onBackClick) + } + ) + } + ) { paddingValues -> + if (fontItems.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "没有字体文件", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(bottom = 16.dp) + ) + Button(onClick = { selectFolderLauncher.launch(null) }) { + Text(text = stringResource(R.string.select_font)) + } + } + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxSize(), + contentPadding = adaptiveContentPadding( + top = paddingValues.calculateTopPadding(), + bottom = 120.dp + ), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(fontItems) { + FontItem(it) { + // 选择字体的逻辑 + ThemeConfig.appFontPath = it.uri.toString() + } + } + } + } + } +} + +@Composable +fun FontItem(fontDoc: FileDoc, onFontSelected: (FileDoc) -> Unit) { + val context = LocalContext.current + + Card( + modifier = Modifier.fillMaxWidth().height(120.dp), + onClick = { + onFontSelected(fontDoc) + }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + // 使用AndroidView显示使用对应字体的文本 + AndroidView( + factory = { ctx -> + android.widget.TextView(ctx).apply { + text = fontDoc.name + textSize = 16f + // 设置垂直和水平居中 + gravity = android.view.Gravity.CENTER + // 设置最大行数为2,超过省略 + 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 + }.onFailure { + // 字体加载失败,使用默认字体 + } + } + }, + modifier = Modifier.fillMaxSize() + ) + } +} + diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt index 6eb8affa7..62d54bde3 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt @@ -5,6 +5,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -90,7 +91,10 @@ fun BackgroundImageManageSheet( .aspectRatio(16f / 9f) .padding(16.dp) ) { - Box(contentAlignment = Alignment.Center) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { AppIcon( imageVector = Icons.Default.Add, contentDescription = "Add", diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/LabelColorManageSheet.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/LabelColorManageSheet.kt new file mode 100644 index 000000000..997fc02db --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/LabelColorManageSheet.kt @@ -0,0 +1,153 @@ +package io.legado.app.ui.config.themeConfig + +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.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.toMutableStateList +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.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.core.graphics.ColorUtils +import io.legado.app.help.config.TagColorGenerator +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.button.MediumOutlinedIconButton +import io.legado.app.ui.widget.components.button.SmallIconButton +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.dialog.ColorPickerSheet +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LabelColorManageSheet( + show: Boolean, + onDismissRequest: () -> Unit +) { + val primaryColor = LegadoTheme.colorScheme.primary + val themeColor = ThemeConfig.themeColor + val tagColors = remember(show) { + ThemeConfig.getCustomTagColors().toMutableStateList() + } + var showColorPicker by remember { mutableStateOf(false) } + var editingIndex by remember { mutableIntStateOf(-1) } + var editingTextColor by remember { mutableIntStateOf(0) } + + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = "管理标签颜色", + startAction = { + MediumOutlinedIconButton( + onClick = { + val baseColor = if (themeColor != 0) Color(themeColor) else primaryColor + val generatedColors = TagColorGenerator.generateTagColors(baseColor) + tagColors.clear() + tagColors.addAll(generatedColors) + ThemeConfig.saveCustomTagColors(tagColors) + }, + imageVector = Icons.Default.AutoAwesome + ) + }, + endAction = { + MediumOutlinedIconButton( + onClick = { + tagColors.add(TagColorPair(0, 0)) + editingIndex = tagColors.size - 1 + editingTextColor = 0 + showColorPicker = true + }, + imageVector = Icons.Default.Add + ) + } + ) { + LazyColumn( + modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(tagColors.size) { index -> + val colorPair = tagColors[index] + val label = "标签 ${index + 1}" + NormalCard( + modifier = Modifier.fillMaxWidth(), + containerColor = LegadoTheme.colorScheme.onSheetContent + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextCard( + text = label, + backgroundColor = if (colorPair.bgColor != 0) Color(colorPair.bgColor) else LegadoTheme.colorScheme.surfaceContainerHighest, + contentColor = if (colorPair.textColor != 0) Color(colorPair.textColor) else LegadoTheme.colorScheme.primary, + cornerRadius = 4.dp, + horizontalPadding = 8.dp, + verticalPadding = 4.dp, + textStyle = LegadoTheme.typography.labelSmall + ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + SmallIconButton( + onClick = { + editingIndex = index + editingTextColor = colorPair.textColor + showColorPicker = true + }, + imageVector = Icons.Default.Edit + ) + SmallIconButton( + onClick = { + tagColors.removeAt(index) + ThemeConfig.saveCustomTagColors(tagColors) + }, + imageVector = Icons.Default.Delete + ) + } + } + } + } + } + } + + if (showColorPicker && editingIndex in tagColors.indices) { + ColorPickerSheet( + show = true, + initialColor = editingTextColor, + onDismissRequest = { showColorPicker = false }, + onColorSelected = { selectedColor -> + val hsl = FloatArray(3) + ColorUtils.colorToHSL(selectedColor, hsl) + hsl[1] = (hsl[1] * 0.4f).coerceAtMost(0.35f) + hsl[2] = 0.90f + val bgColor = Color.hsl(hsl[0], hsl[1], hsl[2]).toArgb() + tagColors[editingIndex] = TagColorPair( + textColor = selectedColor, + bgColor = bgColor + ) + ThemeConfig.saveCustomTagColors(tagColors) + showColorPicker = false + } + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/NavIconManageSheet.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/NavIconManageSheet.kt new file mode 100644 index 000000000..f9f418c45 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/NavIconManageSheet.kt @@ -0,0 +1,148 @@ +package io.legado.app.ui.config.themeConfig + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +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.aspectRatio +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.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3Api +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import io.legado.app.ui.config.mainConfig.MainConfig +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.button.SmallTonalIconButton +import io.legado.app.ui.widget.components.card.NormalCard +import io.legado.app.ui.widget.components.icon.AppIcon +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.text.AppText +import java.io.File + +private data class NavIconDestination( + val key: String, + val label: String, + val path: String, + val onSetPath: (String) -> Unit, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NavIconManageSheet( + show: Boolean, + onDismissRequest: () -> Unit +) { + val context = LocalContext.current + var activeDest by remember { mutableStateOf(null) } + + val destinations = listOf( + NavIconDestination("bookshelf", "书架", MainConfig.navIconBookshelf) { MainConfig.navIconBookshelf = it }, + NavIconDestination("explore", "发现", MainConfig.navIconExplore) { MainConfig.navIconExplore = it }, + NavIconDestination("rss", "订阅", MainConfig.navIconRss) { MainConfig.navIconRss = it }, + NavIconDestination("my", "我的", MainConfig.navIconMy) { MainConfig.navIconMy = it }, + ) + + val selectImage = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri -> + val dest = activeDest ?: return@rememberLauncherForActivityResult + uri?.let { + val inputStream = context.contentResolver.openInputStream(it) + val iconDir = File(context.filesDir, "nav_icons") + iconDir.mkdirs() + val destFile = File(iconDir, "$dest.png") + inputStream?.use { input -> + destFile.outputStream().use { output -> + input.copyTo(output) + } + } + destinations.firstOrNull { it.key == dest }?.onSetPath?.invoke(destFile.absolutePath) + } + activeDest = null + } + + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = "导航栏图标", + ) { + Column(modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + destinations.forEach { dest -> + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + NormalCard( + onClick = { + activeDest = dest.key + selectImage.launch("image/png") + }, + cornerRadius = 12.dp, + containerColor = LegadoTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + ) { + if (dest.path.isNotEmpty()) { + Box(modifier = Modifier.fillMaxSize()) { + AsyncImage( + model = dest.path, + contentDescription = dest.label, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit + ) + SmallTonalIconButton( + onClick = { dest.onSetPath("") }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(24.dp), + imageVector = Icons.Default.Close + ) + } + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + AppIcon( + imageVector = Icons.Default.Add, + contentDescription = "添加${dest.label}图标", + modifier = Modifier.size(32.dp), + tint = LegadoTheme.colorScheme.primary + ) + } + } + } + AppText( + text = dest.label, + modifier = Modifier.padding(top = 8.dp), + ) + } + } + } + } + } +} 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 3dc7eb863..f22e567bb 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 @@ -3,7 +3,16 @@ package io.legado.app.ui.config.themeConfig import io.legado.app.constant.EventBus import io.legado.app.constant.PreferKey import io.legado.app.ui.config.prefDelegate +import io.legado.app.utils.GSON +import io.legado.app.utils.getPrefString import io.legado.app.utils.postEvent +import io.legado.app.utils.putPrefString +import splitties.init.appCtx + +data class TagColorPair( + val textColor: Int = 0, + val bgColor: Int = 0 +) object ThemeConfig { @@ -17,6 +26,16 @@ object ThemeConfig { var enableProgressiveBlur by prefDelegate(PreferKey.enableProgressiveBlur, false) + var topBarBlurRadius by prefDelegate(PreferKey.topBarBlurRadius, 24) + + var bottomBarBlurRadius by prefDelegate(PreferKey.bottomBarBlurRadius, 8) + + var topBarBlurAlpha by prefDelegate(PreferKey.topBarBlurAlpha, 73) + + var bottomBarBlurAlpha by prefDelegate(PreferKey.bottomBarBlurAlpha, 40) + + var bottomBarLensRadius by prefDelegate(PreferKey.bottomBarLensRadius, 24f) + var useFlexibleTopAppBar by prefDelegate(PreferKey.useFlexibleTopAppBar, true) var paletteStyle by prefDelegate(PreferKey.paletteStyle, "tonalSpot") @@ -56,9 +75,47 @@ object ThemeConfig { postEvent(EventBus.RECREATE, "") } - var cPrimary by prefDelegate(PreferKey.cPrimary, 0) { - postEvent(EventBus.RECREATE, "") - } + var appFontPath: String? + get() = appCtx.getPrefString(PreferKey.appFontPath) + set(value) { + appCtx.putPrefString(PreferKey.appFontPath, value) + postEvent(EventBus.RECREATE, "") + } + + var cPrimary by prefDelegate(PreferKey.cPrimary, 0) + + var enableDeepPersonalization by prefDelegate(PreferKey.enableDeepPersonalization, false) + + var themeColor by prefDelegate(PreferKey.themeColor, 0) + + var secondaryThemeColor by prefDelegate(PreferKey.secondaryThemeColor, 0) + + var primaryTextColor by prefDelegate(PreferKey.primaryTextColor, 0) + + var secondaryTextColor by prefDelegate(PreferKey.secondaryTextColor, 0) + + var themeBackgroundColor by prefDelegate(PreferKey.themeBackgroundColor, 0) + + var labelContainerColor by prefDelegate(PreferKey.labelContainerColor, 0) + + var enableContainerBorder by prefDelegate(PreferKey.enableContainerBorder, false) + + var containerBorderWidth by prefDelegate(PreferKey.containerBorderWidth, 1f) + + var containerBorderStyle by prefDelegate(PreferKey.containerBorderStyle, "solid") + + var containerBorderColor by prefDelegate(PreferKey.containerBorderColor, 0) + + var containerBorderDashWidth by prefDelegate(PreferKey.containerBorderDashWidth, 4f) + + var enableItemDivider by prefDelegate(PreferKey.enableItemDivider, false) + + var itemDividerWidth by prefDelegate(PreferKey.itemDividerWidth, 1f) + + var itemDividerLength by prefDelegate(PreferKey.itemDividerLength, 80f) + var itemDividerColor by prefDelegate(PreferKey.itemDividerColor, 0) + + var bookInfoInputColor by prefDelegate(PreferKey.bookInfoInputColor, 0) var cNPrimary by prefDelegate(PreferKey.cNPrimary, 0) { postEvent(EventBus.RECREATE, "") @@ -70,6 +127,28 @@ object ThemeConfig { var launcherIcon by prefDelegate(PreferKey.launcherIcon, "ic_launcher") + var enableCustomTagColors by prefDelegate(PreferKey.enableCustomTagColors, false) + + var customTagColorsJson: String? + get() = appCtx.getPrefString(PreferKey.customTagColors) + set(value) { + appCtx.putPrefString(PreferKey.customTagColors, value) + } + + fun getCustomTagColors(): List { + return try { + val json = customTagColorsJson + if (json.isNullOrBlank()) emptyList() + else GSON.fromJson(json, Array::class.java).toList() + } catch (e: Exception) { + emptyList() + } + } + + fun saveCustomTagColors(colors: List) { + customTagColorsJson = GSON.toJson(colors) + } + var showDiscovery by prefDelegate(PreferKey.showDiscovery, true) var showRss by prefDelegate(PreferKey.showRss, true) 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 afa26ce1f..f1c84a71a 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 @@ -2,8 +2,13 @@ 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.net.Uri import android.os.Handler import android.os.Looper +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.BorderStroke @@ -26,18 +31,23 @@ 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 import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.BrightnessMedium import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.LightMode import androidx.compose.material3.ButtonGroupDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -68,8 +78,11 @@ import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColorsOptions import io.legado.app.R import io.legado.app.base.AppContextWrapper +import io.legado.app.constant.PreferKey +import io.legado.app.ui.config.mainConfig.MainConfig import io.legado.app.constant.EventBus import io.legado.app.help.LauncherIconHelp +import io.legado.app.help.loadFontFiles import io.legado.app.help.config.AppConfig import io.legado.app.help.config.OldThemeConfig import io.legado.app.lib.theme.ThemeStore @@ -81,8 +94,10 @@ import io.legado.app.ui.theme.adaptiveContentPadding import io.legado.app.ui.widget.components.AppScaffold import io.legado.app.ui.widget.components.AppTextField import io.legado.app.ui.widget.components.SplicedColumnGroup +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton +import io.legado.app.ui.widget.components.button.SmallIconButton import io.legado.app.ui.widget.components.dialog.ColorPickerSheet import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem @@ -91,8 +106,12 @@ import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults +import io.legado.app.utils.FileDoc +import io.legado.app.utils.getPrefString import io.legado.app.utils.postEvent +import io.legado.app.utils.putPrefString import io.legado.app.utils.restart +import io.legado.app.utils.takePersistablePermissionSafely import io.legado.app.utils.toastOnUi import org.koin.androidx.compose.koinViewModel import top.yukonga.miuix.kmp.theme.MiuixTheme @@ -104,6 +123,7 @@ import top.yukonga.miuix.kmp.basic.Text as MiuixText @Composable fun ThemeConfigScreen( onBackClick: () -> Unit, + onNavigateToCustomTheme: () -> Unit, viewModel: ThemeConfigViewModel = koinViewModel() ) { val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() @@ -114,16 +134,35 @@ fun ThemeConfigScreen( var selectedTheme by remember { mutableStateOf(ThemeConfig.appTheme) } var useMiuixMonet by remember { mutableStateOf(ThemeConfig.useMiuixMonet) } var showRestartDialog by remember { mutableStateOf(false) } - var showColorPicker by remember { mutableStateOf(false) } - var pickNightSeedColor by remember { mutableStateOf(false) } var showLauncherIconPicker by remember { mutableStateOf(false) } - var showThemeListDialog by remember { mutableStateOf(false) } - var saveThemeKey by remember { mutableStateOf(null) } - var themeName by remember { mutableStateOf("") } + var showBorderColorPicker by remember { mutableStateOf(false) } + var currentBorderColorKey by remember { mutableStateOf("containerBorderColor") } + var showNavIconSheet by remember { mutableStateOf(false) } + var showFontSheet by remember { mutableStateOf(false) } + var fontItems by remember { mutableStateOf>(emptyList()) } + var fontFolderUri by remember { mutableStateOf(null) } + + fun loadFonts() { + fontItems = loadFontFiles(context, fontFolderUri) + } + remember { + val saved = context.getPrefString(PreferKey.fontFolder) + if (!saved.isNullOrEmpty()) fontFolderUri = Uri.parse(saved) + loadFonts() + } + + val fontFolderLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocumentTree() + ) { uri -> + if (uri != null) { + fontFolderUri = uri + uri.takePersistablePermissionSafely(context, Intent.FLAG_GRANT_READ_URI_PERMISSION) + context.putPrefString(PreferKey.fontFolder, uri.toString()) + loadFonts() + } + } val fontScaleValue = remember { mutableFloatStateOf(ThemeConfig.fontScale.toFloat()) } - val primaryColorValue = remember { mutableIntStateOf(ThemeConfig.cPrimary) } - val nightPrimaryColorValue = remember { mutableIntStateOf(ThemeConfig.cNPrimary) } AppScaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -155,428 +194,533 @@ fun ThemeConfigScreen( else -> isSystemInDarkTheme() } - if (!isMiuixEngine) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth() - ) { - ThemeCard( - context = context, - value = selectedTheme, - isDark = isDarkTheme, - isAmoled = ThemeConfig.isPureBlack, - paletteStyle = ThemeConfig.paletteStyle, - customLightSeedColor = primaryColorValue.intValue, - customNightSeedColor = nightPrimaryColorValue.intValue - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - val themeItems = stringArrayResource(R.array.themes_item) - val themeValues = stringArrayResource(R.array.themes_value) - val themes = remember(themeItems, themeValues) { - themeItems.zip(themeValues).toList() - } - - if (isMiuixEngine) { - MiuixCard( - cornerRadius = 16.dp, - insideMargin = PaddingValues(16.dp), - colors = MiuixCardDefaults.defaultColors( - color = MiuixTheme.colorScheme.primaryVariant, - contentColor = MiuixTheme.colorScheme.onPrimary - ) - ) { - MiuixText("Miuix 目前为测试主题,且不对基于View的界面生效!") - } - } - - - SplicedColumnGroup(title = stringResource(R.string.theme)) { - if (isMiuixEngine) { - DropdownListSettingItem( - title = stringResource(R.string.theme_mode), - selectedValue = selectedThemeMode, - displayEntries = stringArrayResource(R.array.theme_mode), - entryValues = stringArrayResource(R.array.theme_mode_v), - onValueChange = { mode -> - selectedThemeMode = mode - ThemeConfig.themeMode = mode - OldThemeConfig.applyDayNight(context) - } - ) - - SwitchSettingItem( - title = stringResource(R.string.miuix_monet), - description = stringResource(R.string.miuix_monet_summary), - checked = useMiuixMonet, - onCheckedChange = { - useMiuixMonet = it - ThemeConfig.useMiuixMonet = it - if (it && selectedTheme != "0" && selectedTheme != "12") { - selectedTheme = "0" - ThemeConfig.appTheme = "0" - } - } - ) - - if (useMiuixMonet) { - SwitchSettingItem( - title = stringResource(R.string.dynamic_colors), - description = stringResource(R.string.dynamic_colors_summary), - checked = selectedTheme == "0", - onCheckedChange = { checked -> - val newTheme = if (checked) "0" else "12" - val oldTheme = selectedTheme - selectedTheme = newTheme - ThemeConfig.appTheme = newTheme - if (oldTheme != newTheme) { - showRestartDialog = true - } - } - ) - } - } else { - ThemeModeSelector( - selectedMode = selectedThemeMode, - onModeSelected = { mode -> - selectedThemeMode = mode - ThemeConfig.themeMode = mode - OldThemeConfig.applyDayNight(context) - } - ) - } - if (!isMiuixEngine) { - Spacer(modifier = Modifier.height(16.dp)) - - ThemeColorSelector( - context = context, - themes = themes, - selectedTheme = selectedTheme, - isDark = isDarkTheme, - isAmoled = ThemeConfig.isPureBlack, - paletteStyle = ThemeConfig.paletteStyle, - customLightSeedColor = primaryColorValue.intValue, - customNightSeedColor = nightPrimaryColorValue.intValue, - onThemeSelected = { theme -> - if (theme == "13") { - val hasLightBg = !ThemeConfig.bgImageLight.isNullOrEmpty() - val hasDarkBg = !ThemeConfig.bgImageDark.isNullOrEmpty() - if (!hasLightBg || !hasDarkBg) { - context.toastOnUi(R.string.transparent_theme_alarm) - return@ThemeColorSelector - } else { - AppConfig.containerOpacity = 0 - } - } - val oldTheme = selectedTheme - selectedTheme = theme - ThemeConfig.appTheme = theme - val isDynamicSwitch = (oldTheme == "12" || theme == "12") - if (isDynamicSwitch) { - showRestartDialog = true - } else { - postEvent(EventBus.RECREATE, "") - } - } - ) - } - } - - SplicedColumnGroup { - DropdownListSettingItem( - title = stringResource(R.string.compose_engine), - selectedValue = ThemeConfig.composeEngine, - displayEntries = stringArrayResource(R.array.composeEngine), - entryValues = stringArrayResource(R.array.composeEngine_value), - onValueChange = { - ThemeConfig.composeEngine = it - } - ) - ClickableSettingItem( - title = stringResource(R.string.change_icon), - description = stringResource(R.string.change_icon_summary), - onClick = { showLauncherIconPicker = true } - ) - SwitchSettingItem( - title = stringResource(R.string.predictive_back), - description = stringResource(R.string.predictive_back_summary), - checked = ThemeConfig.isPredictiveBackEnabled, - onCheckedChange = { - ThemeConfig.isPredictiveBackEnabled = it - context.toastOnUi(R.string.restart_to_apply) - } - ) - SliderSettingItem( - title = stringResource(R.string.font_scale), - description = stringResource( - R.string.font_scale_summary, - AppContextWrapper.getFontScale(context) - ), - value = fontScaleValue.floatValue, - defaultValue = 10f, - valueRange = 8f..16f, - steps = 7, - onValueChange = { value -> - fontScaleValue.floatValue = value - ThemeConfig.fontScale = value.toInt() - } - ) - } - - val showCustomThemeOptions = selectedTheme == "12" && (!isMiuixEngine || useMiuixMonet) - if (showCustomThemeOptions) { - SplicedColumnGroup(title = stringResource(R.string.custom_theme)) { - ClickableSettingItem( - title = stringResource(R.string.seed_color), - description = stringResource(R.string.day), - option = formatColorOption(primaryColorValue.intValue) - ?: stringResource(R.string.click_to_select), - onClick = { - pickNightSeedColor = false - showColorPicker = true - }, - trailingContent = { ColorSwatch(colorValue = primaryColorValue.intValue) } - ) - ClickableSettingItem( - title = stringResource(R.string.seed_color), - description = stringResource(R.string.night), - option = formatColorOption(nightPrimaryColorValue.intValue) - ?: stringResource(R.string.click_to_select), - onClick = { - pickNightSeedColor = true - showColorPicker = true - }, - trailingContent = { ColorSwatch(colorValue = nightPrimaryColorValue.intValue) } - ) - DropdownListSettingItem( - title = stringResource(R.string.palette_style), - selectedValue = ThemeConfig.paletteStyle, - displayEntries = stringArrayResource(R.array.paletteStyle), - entryValues = stringArrayResource(R.array.paletteStyle_value), - onValueChange = { ThemeConfig.paletteStyle = it } - ) - DropdownListSettingItem( - title = stringResource(R.string.preferred_contrast), - selectedValue = ThemeConfig.customContrast, - displayEntries = stringArrayResource(R.array.customContrast), - entryValues = stringArrayResource(R.array.customContrast_value), - onValueChange = { ThemeConfig.customContrast = it } - ) - DropdownListSettingItem( - title = stringResource(R.string.material_version), - selectedValue = ThemeConfig.materialVersion, - displayEntries = stringArrayResource(R.array.materialVersion), - entryValues = stringArrayResource(R.array.materialVersion_value), - onValueChange = { ThemeConfig.materialVersion = it } - ) - } - } - - SplicedColumnGroup(title = stringResource(R.string.main_activity)) { - SwitchSettingItem( - title = stringResource(R.string.show_discovery), - checked = ThemeConfig.showDiscovery, - onCheckedChange = { ThemeConfig.showDiscovery = it } - ) - SwitchSettingItem( - title = stringResource(R.string.show_rss), - checked = ThemeConfig.showRss, - onCheckedChange = { ThemeConfig.showRss = it } - ) - SwitchSettingItem( - title = stringResource(R.string.show_status), - checked = ThemeConfig.showStatusBar, - onCheckedChange = { ThemeConfig.showStatusBar = it } - ) - //TODO:这个可以不要了,在删掉原来的设置页以后删 - SwitchSettingItem( - title = stringResource(R.string.show_swipe_animation), - checked = ThemeConfig.swipeAnimation, - onCheckedChange = { ThemeConfig.swipeAnimation = it } - ) - SwitchSettingItem( - title = stringResource(R.string.show_bottom_nav), - description = stringResource(R.string.be_swiped), - checked = ThemeConfig.showBottomView, - onCheckedChange = { ThemeConfig.showBottomView = it } - ) - SwitchSettingItem( - title = stringResource(R.string.floating_bottom_bar), - description = stringResource(R.string.floating_bottom_bar_summary), - checked = ThemeConfig.useFloatingBottomBar, - onCheckedChange = { ThemeConfig.useFloatingBottomBar = it } - ) - AnimatedVisibility(visible = ThemeConfig.useFloatingBottomBar) { - SwitchSettingItem( - title = stringResource(R.string.floating_bottom_bar_liquid_glass), - description = stringResource(R.string.floating_bottom_bar_liquid_glass_summary), - checked = ThemeConfig.useFloatingBottomBarLiquidGlass, - onCheckedChange = { ThemeConfig.useFloatingBottomBarLiquidGlass = it } - ) - } - DropdownListSettingItem( - title = stringResource(R.string.tabletInterface), - selectedValue = ThemeConfig.tabletInterface, - displayEntries = stringArrayResource(R.array.tabletInterface), - entryValues = stringArrayResource(R.array.tabletInterface_value), - onValueChange = { ThemeConfig.tabletInterface = it } - ) - DropdownListSettingItem( - title = stringResource(R.string.nav_label_mode), - selectedValue = ThemeConfig.labelVisibilityMode, - displayEntries = stringArrayResource(R.array.label_vis_mode), - entryValues = stringArrayResource(R.array.label_vis_mode_value), - onValueChange = { ThemeConfig.labelVisibilityMode = it } - ) - DropdownListSettingItem( - title = stringResource(R.string.default_home_page), - selectedValue = ThemeConfig.defaultHomePage, - displayEntries = stringArrayResource(R.array.default_home_page), - entryValues = stringArrayResource(R.array.default_home_page_value), - onValueChange = { ThemeConfig.defaultHomePage = it } - ) - } - - SplicedColumnGroup(title = stringResource(R.string.share)) { - ClickableSettingItem( - title = stringResource(R.string.theme_list), - description = stringResource(R.string.theme_list_summary), - onClick = { showThemeListDialog = true } - ) - ClickableSettingItem( - title = stringResource(R.string.save_theme_config), - description = stringResource(R.string.save_day_theme_summary), - onClick = { - saveThemeKey = "saveDayTheme" - themeName = "" - } - ) - } - - SplicedColumnGroup(title = stringResource(R.string.compose_related)) { - if (!isMiuixEngine) { - SwitchSettingItem( - title = stringResource(R.string.pure_black), - checked = ThemeConfig.isPureBlack, - onCheckedChange = { ThemeConfig.isPureBlack = it } - ) - SwitchSettingItem( - title = stringResource(R.string.use_flexible_top_bar), - checked = ThemeConfig.useFlexibleTopAppBar, - onCheckedChange = { ThemeConfig.useFlexibleTopAppBar = it } - ) - } - SwitchSettingItem( - title = stringResource(R.string.is_blur_enable), - checked = ThemeConfig.enableBlur, - onCheckedChange = { - ThemeConfig.enableBlur = it - if (!it) ThemeConfig.enableProgressiveBlur = false - } - ) - AnimatedVisibility(visible = ThemeConfig.enableBlur) { - SwitchSettingItem( - title = stringResource(R.string.is_blur_progressive_enable), - checked = ThemeConfig.enableProgressiveBlur, - onCheckedChange = { ThemeConfig.enableProgressiveBlur = it } - ) - } - AnimatedVisibility(visible = !isMiuixEngine && !ThemeConfig.enableBlur) { Column( - verticalArrangement = Arrangement.spacedBy(2.dp) + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() ) { - SliderSettingItem( - title = stringResource(R.string.top_bar_opacity), - description = stringResource( - R.string.top_bar_opacity_summary, - ThemeConfig.topBarOpacity - ), - value = ThemeConfig.topBarOpacity.toFloat(), - defaultValue = 100f, - valueRange = 0f..100f, - steps = 99, - onValueChange = { ThemeConfig.topBarOpacity = it.toInt() } - ) - SliderSettingItem( - title = stringResource(R.string.bottom_bar_opacity), - description = stringResource( - R.string.bottom_bar_opacity_summary, - ThemeConfig.bottomBarOpacity - ), - value = ThemeConfig.bottomBarOpacity.toFloat(), - defaultValue = 100f, - valueRange = 0f..100f, - steps = 99, - onValueChange = { ThemeConfig.bottomBarOpacity = it.toInt() } + ThemeCard( + context = context, + value = selectedTheme, + isDark = isDarkTheme, + isAmoled = ThemeConfig.isPureBlack, + paletteStyle = ThemeConfig.paletteStyle, + customLightSeedColor = ThemeConfig.cPrimary, + customNightSeedColor = ThemeConfig.cNPrimary ) } } - if (!isMiuixEngine) { - SliderSettingItem( - title = stringResource(R.string.container_opacity), - description = stringResource( - R.string.container_opacity_summary, - ThemeConfig.containerOpacity - ), - value = ThemeConfig.containerOpacity.toFloat(), - defaultValue = 100f, - valueRange = 0f..100f, - steps = 99, - onValueChange = { ThemeConfig.containerOpacity = it.toInt() } - ) + + Spacer(modifier = Modifier.height(16.dp)) + + val themeItems = stringArrayResource(R.array.themes_item) + val themeValues = stringArrayResource(R.array.themes_value) + val themes = remember(themeItems, themeValues) { + themeItems.zip(themeValues).toList() } - } - SplicedColumnGroup(title = stringResource(R.string.day)) { - val hasLightBg = !ThemeConfig.bgImageLight.isNullOrBlank() - ClickableSettingItem( - title = stringResource(R.string.background_image), - description = if (hasLightBg) stringResource(R.string.click_to_delete) else stringResource( - R.string.select_image - ), - onClick = { manageKey = false } - ) + if (isMiuixEngine) { + MiuixCard( + cornerRadius = 16.dp, + insideMargin = PaddingValues(16.dp), + colors = MiuixCardDefaults.defaultColors( + color = MiuixTheme.colorScheme.primaryVariant, + contentColor = MiuixTheme.colorScheme.onPrimary + ) + ) { + MiuixText("Miuix 目前为测试主题,且不对基于View的界面生效!") + } + } - if (hasLightBg) { - SliderSettingItem( - title = stringResource(R.string.background_image_blurring), - value = ThemeConfig.bgImageBlurring.toFloat(), - defaultValue = 0f, - valueRange = 0f..100f, - steps = 99, + + SplicedColumnGroup(title = stringResource(R.string.theme)) { + if (isMiuixEngine) { + DropdownListSettingItem( + title = stringResource(R.string.theme_mode), + selectedValue = selectedThemeMode, + displayEntries = stringArrayResource(R.array.theme_mode), + entryValues = stringArrayResource(R.array.theme_mode_v), + onValueChange = { mode -> + selectedThemeMode = mode + ThemeConfig.themeMode = mode + OldThemeConfig.applyDayNight(context) + } + ) + + SwitchSettingItem( + title = stringResource(R.string.miuix_monet), + description = stringResource(R.string.miuix_monet_summary), + checked = useMiuixMonet, + onCheckedChange = { + useMiuixMonet = it + ThemeConfig.useMiuixMonet = it + if (it && selectedTheme != "0" && selectedTheme != "12") { + selectedTheme = "0" + ThemeConfig.appTheme = "0" + } + } + ) + + if (useMiuixMonet) { + SwitchSettingItem( + title = stringResource(R.string.dynamic_colors), + description = stringResource(R.string.dynamic_colors_summary), + checked = selectedTheme == "0", + onCheckedChange = { checked -> + val newTheme = if (checked) "0" else "12" + val oldTheme = selectedTheme + selectedTheme = newTheme + ThemeConfig.appTheme = newTheme + if (oldTheme != newTheme) { + showRestartDialog = true + } + } + ) + } + } else { + ThemeModeSelector( + selectedMode = selectedThemeMode, + onModeSelected = { mode -> + selectedThemeMode = mode + ThemeConfig.themeMode = mode + OldThemeConfig.applyDayNight(context) + } + ) + } + + if (!isMiuixEngine) { + Spacer(modifier = Modifier.height(16.dp)) + + ThemeColorSelector( + context = context, + themes = themes, + selectedTheme = selectedTheme, + isDark = isDarkTheme, + isAmoled = ThemeConfig.isPureBlack, + paletteStyle = ThemeConfig.paletteStyle, + customLightSeedColor = ThemeConfig.cPrimary, + customNightSeedColor = ThemeConfig.cNPrimary, + onThemeSelected = { theme -> + if (theme == "13") { + val hasLightBg = !ThemeConfig.bgImageLight.isNullOrEmpty() + val hasDarkBg = !ThemeConfig.bgImageDark.isNullOrEmpty() + if (!hasLightBg || !hasDarkBg) { + context.toastOnUi(R.string.transparent_theme_alarm) + return@ThemeColorSelector + } else { + AppConfig.containerOpacity = 0 + } + } + val oldTheme = selectedTheme + selectedTheme = theme + ThemeConfig.appTheme = theme + val isDynamicSwitch = (oldTheme == "12" || theme == "12") + if (isDynamicSwitch) { + showRestartDialog = true + } else { + postEvent(EventBus.RECREATE, "") + } + } + ) + } + } + + SplicedColumnGroup { + ClickableSettingItem( + title = stringResource(R.string.font_setting), + onClick = { showFontSheet = true } + ) + if (selectedTheme == "12" && (!isMiuixEngine || useMiuixMonet)) { + ClickableSettingItem( + title = stringResource(R.string.custom_theme), + onClick = onNavigateToCustomTheme + ) + } + DropdownListSettingItem( + title = stringResource(R.string.compose_engine), + selectedValue = ThemeConfig.composeEngine, + displayEntries = stringArrayResource(R.array.composeEngine), + entryValues = stringArrayResource(R.array.composeEngine_value), onValueChange = { - ThemeConfig.bgImageBlurring = it.toInt() + ThemeConfig.composeEngine = it + } + ) + ClickableSettingItem( + title = stringResource(R.string.change_icon), + description = stringResource(R.string.change_icon_summary), + onClick = { showLauncherIconPicker = true } + ) + SwitchSettingItem( + title = stringResource(R.string.predictive_back), + description = stringResource(R.string.predictive_back_summary), + checked = ThemeConfig.isPredictiveBackEnabled, + onCheckedChange = { + ThemeConfig.isPredictiveBackEnabled = it + context.toastOnUi(R.string.restart_to_apply) + } + ) + SliderSettingItem( + title = stringResource(R.string.font_scale), + description = stringResource( + R.string.font_scale_summary, + AppContextWrapper.getFontScale(context) + ), + value = fontScaleValue.floatValue, + defaultValue = 10f, + valueRange = 8f..16f, + steps = 7, + onValueChange = { value -> + fontScaleValue.floatValue = value + ThemeConfig.fontScale = value.toInt() } ) } - } + + SplicedColumnGroup(title = stringResource(R.string.main_activity)) { + SwitchSettingItem( + title = stringResource(R.string.show_discovery), + checked = ThemeConfig.showDiscovery, + onCheckedChange = { ThemeConfig.showDiscovery = it } + ) + SwitchSettingItem( + title = stringResource(R.string.show_rss), + checked = ThemeConfig.showRss, + onCheckedChange = { ThemeConfig.showRss = it } + ) + SwitchSettingItem( + title = stringResource(R.string.show_status), + checked = ThemeConfig.showStatusBar, + onCheckedChange = { ThemeConfig.showStatusBar = it } + ) + //TODO:这个可以不要了,在删掉原来的设置页以后删 + SwitchSettingItem( + title = stringResource(R.string.show_swipe_animation), + checked = ThemeConfig.swipeAnimation, + onCheckedChange = { ThemeConfig.swipeAnimation = it } + ) + SwitchSettingItem( + title = stringResource(R.string.show_bottom_nav), + description = stringResource(R.string.be_swiped), + checked = ThemeConfig.showBottomView, + onCheckedChange = { ThemeConfig.showBottomView = it } + ) + SwitchSettingItem( + title = stringResource(R.string.floating_bottom_bar), + description = stringResource(R.string.floating_bottom_bar_summary), + checked = ThemeConfig.useFloatingBottomBar, + onCheckedChange = { ThemeConfig.useFloatingBottomBar = it } + ) + AnimatedVisibility(visible = ThemeConfig.useFloatingBottomBar) { + SwitchSettingItem( + title = stringResource(R.string.floating_bottom_bar_liquid_glass), + description = stringResource(R.string.floating_bottom_bar_liquid_glass_summary), + checked = ThemeConfig.useFloatingBottomBarLiquidGlass, + onCheckedChange = { ThemeConfig.useFloatingBottomBarLiquidGlass = it } + ) + SliderSettingItem( + title = "底栏模糊强度", + description = "控制液态玻璃的扭曲程度", + value = ThemeConfig.bottomBarLensRadius, + defaultValue = 24f, + valueRange = 0f..50f, + onValueChange = { ThemeConfig.bottomBarLensRadius = it } + ) + } + DropdownListSettingItem( + title = stringResource(R.string.tabletInterface), + selectedValue = ThemeConfig.tabletInterface, + displayEntries = stringArrayResource(R.array.tabletInterface), + entryValues = stringArrayResource(R.array.tabletInterface_value), + onValueChange = { ThemeConfig.tabletInterface = it } + ) + DropdownListSettingItem( + title = stringResource(R.string.nav_label_mode), + selectedValue = ThemeConfig.labelVisibilityMode, + displayEntries = stringArrayResource(R.array.label_vis_mode), + entryValues = stringArrayResource(R.array.label_vis_mode_value), + onValueChange = { ThemeConfig.labelVisibilityMode = it } + ) + DropdownListSettingItem( + title = stringResource(R.string.default_home_page), + selectedValue = ThemeConfig.defaultHomePage, + displayEntries = stringArrayResource(R.array.default_home_page), + entryValues = stringArrayResource(R.array.default_home_page_value), + onValueChange = { ThemeConfig.defaultHomePage = it } + ) + } + + SplicedColumnGroup(title = stringResource(R.string.compose_related)) { + if (!isMiuixEngine) { + SwitchSettingItem( + title = stringResource(R.string.pure_black), + checked = ThemeConfig.isPureBlack, + onCheckedChange = { ThemeConfig.isPureBlack = it } + ) + SwitchSettingItem( + title = stringResource(R.string.use_flexible_top_bar), + checked = ThemeConfig.useFlexibleTopAppBar, + onCheckedChange = { ThemeConfig.useFlexibleTopAppBar = it } + ) + } + SwitchSettingItem( + title = stringResource(R.string.is_blur_enable), + checked = ThemeConfig.enableBlur, + onCheckedChange = { + ThemeConfig.enableBlur = it + if (!it) ThemeConfig.enableProgressiveBlur = false + } + ) + AnimatedVisibility(visible = ThemeConfig.enableBlur) { + SwitchSettingItem( + title = stringResource(R.string.is_blur_progressive_enable), + checked = ThemeConfig.enableProgressiveBlur, + onCheckedChange = { ThemeConfig.enableProgressiveBlur = it } + ) + } + if (ThemeConfig.enableBlur) { + SliderSettingItem( + title = "顶栏模糊半径", + description = "模糊半径越大,系统运行越卡顿", + value = ThemeConfig.topBarBlurRadius.toFloat(), + defaultValue = 24f, + valueRange = 0f..30f, + onValueChange = { ThemeConfig.topBarBlurRadius = it.toInt() } + ) + SliderSettingItem( + title = "底栏模糊半径", + description = "模糊半径越大,系统运行越卡顿", + value = ThemeConfig.bottomBarBlurRadius.toFloat(), + defaultValue = 8f, + valueRange = 0f..10f, + onValueChange = { ThemeConfig.bottomBarBlurRadius = it.toInt() } + ) + SliderSettingItem( + title = "顶栏模糊透明度", + value = ThemeConfig.topBarBlurAlpha.toFloat(), + defaultValue = 73f, + valueRange = 0f..100f, + onValueChange = { ThemeConfig.topBarBlurAlpha = it.toInt() } + ) + SliderSettingItem( + title = "底栏模糊透明度", + value = ThemeConfig.bottomBarBlurAlpha.toFloat(), + defaultValue = 40f, + valueRange = 0f..100f, + onValueChange = { ThemeConfig.bottomBarBlurAlpha = it.toInt() } + ) + } + AnimatedVisibility(visible = !isMiuixEngine && !ThemeConfig.enableBlur) { + Column( + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + SliderSettingItem( + title = stringResource(R.string.top_bar_opacity), + description = stringResource( + R.string.top_bar_opacity_summary, + ThemeConfig.topBarOpacity + ), + value = ThemeConfig.topBarOpacity.toFloat(), + defaultValue = 100f, + valueRange = 0f..100f, + steps = 99, + onValueChange = { ThemeConfig.topBarOpacity = it.toInt() } + ) + SliderSettingItem( + title = stringResource(R.string.bottom_bar_opacity), + description = stringResource( + R.string.bottom_bar_opacity_summary, + ThemeConfig.bottomBarOpacity + ), + value = ThemeConfig.bottomBarOpacity.toFloat(), + defaultValue = 100f, + valueRange = 0f..100f, + steps = 99, + onValueChange = { ThemeConfig.bottomBarOpacity = it.toInt() } + ) + } + } + if (!isMiuixEngine) { + SliderSettingItem( + title = stringResource(R.string.container_opacity), + description = stringResource( + R.string.container_opacity_summary, + ThemeConfig.containerOpacity + ), + value = ThemeConfig.containerOpacity.toFloat(), + defaultValue = 100f, + valueRange = 0f..100f, + steps = 99, + onValueChange = { ThemeConfig.containerOpacity = it.toInt() } + ) + } + } + + SplicedColumnGroup(title = stringResource(R.string.day)) { + val hasLightBg = !ThemeConfig.bgImageLight.isNullOrBlank() + ClickableSettingItem( + title = stringResource(R.string.background_image), + description = if (hasLightBg) stringResource(R.string.click_to_delete) else stringResource( + R.string.select_image + ), + onClick = { manageKey = false } + ) + + if (hasLightBg) { + SliderSettingItem( + title = stringResource(R.string.background_image_blurring), + value = ThemeConfig.bgImageBlurring.toFloat(), + defaultValue = 0f, + valueRange = 0f..100f, + steps = 99, + onValueChange = { + ThemeConfig.bgImageBlurring = it.toInt() + } + ) + } + } SplicedColumnGroup(title = stringResource(R.string.night)) { - val hasDarkBg = !ThemeConfig.bgImageDark.isNullOrBlank() - ClickableSettingItem( - title = stringResource(R.string.background_image), - description = if (hasDarkBg) stringResource(R.string.click_to_delete) else stringResource( - R.string.select_image - ), - onClick = { manageKey = true } - ) - - if (hasDarkBg) { - SliderSettingItem( - title = stringResource(R.string.background_image_blurring), - value = ThemeConfig.bgImageNBlurring.toFloat(), - defaultValue = 0f, - valueRange = 0f..100f, - steps = 99, - onValueChange = { - ThemeConfig.bgImageNBlurring = it.toInt() - } + val hasDarkBg = !ThemeConfig.bgImageDark.isNullOrBlank() + ClickableSettingItem( + title = stringResource(R.string.background_image), + description = if (hasDarkBg) stringResource(R.string.click_to_delete) else stringResource( + R.string.select_image + ), + onClick = { manageKey = true } ) + + if (hasDarkBg) { + SliderSettingItem( + title = stringResource(R.string.background_image_blurring), + value = ThemeConfig.bgImageNBlurring.toFloat(), + defaultValue = 0f, + valueRange = 0f..100f, + steps = 99, + onValueChange = { + ThemeConfig.bgImageNBlurring = it.toInt() + } + ) + } } + } + + // Border settings + item { + SplicedColumnGroup(title = "容器设置") { + SwitchSettingItem( + title = "显示容器边框", + checked = ThemeConfig.enableContainerBorder, + onCheckedChange = { ThemeConfig.enableContainerBorder = it } + ) + if (ThemeConfig.enableContainerBorder) { + SliderSettingItem( + title = "边框粗细", + description = "${ThemeConfig.containerBorderWidth}dp", + value = ThemeConfig.containerBorderWidth, + defaultValue = 1f, + valueRange = 0f..5f, + steps = 49, + onValueChange = { ThemeConfig.containerBorderWidth = it } + ) + DropdownListSettingItem( + title = "边框样式", + selectedValue = ThemeConfig.containerBorderStyle, + displayEntries = arrayOf("实线", "虚线"), + entryValues = arrayOf("solid", "dashed"), + onValueChange = { ThemeConfig.containerBorderStyle = it } + ) + if (ThemeConfig.containerBorderStyle == "dashed") { + SliderSettingItem( + title = "虚线间隔", + description = "${ThemeConfig.containerBorderDashWidth}dp", + value = ThemeConfig.containerBorderDashWidth, + defaultValue = 4f, + valueRange = 1f..10f, + steps = 89, + onValueChange = { ThemeConfig.containerBorderDashWidth = it } + ) + } + ClickableSettingItem( + title = "边框颜色", + option = if (ThemeConfig.containerBorderColor != 0) "#${Integer.toHexString(ThemeConfig.containerBorderColor).uppercase()}" else stringResource(R.string.click_to_select), + onClick = { + currentBorderColorKey = "containerBorderColor" + showBorderColorPicker = true + }, + trailingContent = { + if (ThemeConfig.containerBorderColor != 0) { + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(Color(ThemeConfig.containerBorderColor)) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape) + ) + } + } + ) + } + SwitchSettingItem( + title = "显示分割线", + checked = ThemeConfig.enableItemDivider, + onCheckedChange = { ThemeConfig.enableItemDivider = it } + ) + if (ThemeConfig.enableItemDivider) { + SliderSettingItem( + title = "分割线粗细", + description = "${ThemeConfig.itemDividerWidth}dp", + value = ThemeConfig.itemDividerWidth, + defaultValue = 1f, + valueRange = 0f..5f, + steps = 49, + onValueChange = { ThemeConfig.itemDividerWidth = it } + ) + SliderSettingItem( + title = "分割线长度", + description = "${ThemeConfig.itemDividerLength.toInt()}%", + value = ThemeConfig.itemDividerLength, + defaultValue = 80f, + valueRange = 30f..100f, + steps = 14, + onValueChange = { ThemeConfig.itemDividerLength = it } + ) + ClickableSettingItem( + title = "分割线颜色", + option = if (ThemeConfig.itemDividerColor != 0) "#${Integer.toHexString(ThemeConfig.itemDividerColor).uppercase()}" else stringResource(R.string.click_to_select), + onClick = { + currentBorderColorKey = "itemDividerColor" + showBorderColorPicker = true + }, + trailingContent = { + if (ThemeConfig.itemDividerColor != 0) { + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(Color(ThemeConfig.itemDividerColor)) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape) + ) + } + } + ) + } + } + } + + // Nav icon settings + item { + SplicedColumnGroup(title = "导航栏图标设置") { + val customCount = listOf( + MainConfig.navIconBookshelf, + MainConfig.navIconExplore, + MainConfig.navIconRss, + MainConfig.navIconMy + ).count { it.isNotEmpty() } + ClickableSettingItem( + title = "导航栏图标", + description = if (customCount > 0) "已设置 $customCount 个自定义图标" else "使用默认图标", + onClick = { showNavIconSheet = true } + ) } } } @@ -610,34 +754,13 @@ fun ThemeConfigScreen( ) } + if (showNavIconSheet) { + NavIconManageSheet( + show = true, + onDismissRequest = { showNavIconSheet = false } + ) + } - ColorPickerSheet( - show = showColorPicker, - initialColor = if (pickNightSeedColor) { - nightPrimaryColorValue.value - } else { - primaryColorValue.value - }, - onDismissRequest = { showColorPicker = false }, - onColorSelected = { color -> - if (pickNightSeedColor) { - nightPrimaryColorValue.value = color - ThemeConfig.cNPrimary = color - } else { - primaryColorValue.value = color - ThemeConfig.cPrimary = color - ThemeStore.editTheme(context) - .primaryColor(color) - .apply() - DynamicColors.applyToActivitiesIfAvailable( - context.applicationContext as android.app.Application, - DynamicColorsOptions.Builder() - .setContentBasedSource(context.primaryColor) - .build() - ) - } - } - ) LauncherIconPickerSheet( show = showLauncherIconPicker, @@ -649,36 +772,106 @@ fun ThemeConfigScreen( } ) - ThemeListDialog( - show = showThemeListDialog, - onDismissRequest = { showThemeListDialog = false } + ColorPickerSheet( + show = showBorderColorPicker, + initialColor = when (currentBorderColorKey) { + "containerBorderColor" -> ThemeConfig.containerBorderColor + "itemDividerColor" -> ThemeConfig.itemDividerColor + else -> 0 + }, + onDismissRequest = { showBorderColorPicker = false }, + onColorSelected = { + when (currentBorderColorKey) { + "containerBorderColor" -> ThemeConfig.containerBorderColor = it + "itemDividerColor" -> ThemeConfig.itemDividerColor = it + } + } ) - AppAlertDialog( - data = saveThemeKey, - onDismissRequest = { saveThemeKey = null }, - title = stringResource(R.string.theme_name), - content = { - AppTextField( - value = themeName, - onValueChange = { themeName = it }, - label = "name", - singleLine = true, - modifier = Modifier.fillMaxWidth(), - backgroundColor = LegadoTheme.colorScheme.surface + AppModalBottomSheet( + show = showFontSheet, + onDismissRequest = { showFontSheet = false }, + title = stringResource(R.string.font_setting), + startAction = { + SmallIconButton( + imageVector = Icons.Default.Delete, + contentDescription = "清除", + onClick = { + fontFolderUri = null + context.putPrefString(PreferKey.fontFolder, "") + ThemeConfig.appFontPath = null + fontItems = emptyList() + showFontSheet = false + } ) }, - confirmText = stringResource(android.R.string.ok), - onConfirm = { key -> - when (key) { - "saveDayTheme" -> OldThemeConfig.saveDayTheme(context, themeName) - "saveNightTheme" -> OldThemeConfig.saveNightTheme(context, themeName) - } - saveThemeKey = null + endAction = { + SmallIconButton( + imageVector = Icons.Default.Add, + contentDescription = "选择文件夹", + onClick = { fontFolderLauncher.launch(null) } + ) }, - dismissText = stringResource(android.R.string.cancel), - onDismiss = { saveThemeKey = null } + content = { + if (fontItems.isEmpty()) { + Box( + modifier = Modifier.fillMaxWidth().height(120.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "没有字体文件", + 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 { + Card( + modifier = Modifier.fillMaxWidth().height(100.dp), + onClick = { + ThemeConfig.appFontPath = fontDoc.uri.toString() + showFontSheet = false + }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + AndroidView( + factory = { ctx -> + android.widget.TextView(ctx).apply { + text = fontDoc.name + textSize = 14f + 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() + ) + } + } + } + } + } + } ) + } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -961,27 +1154,6 @@ fun ThemeCard( } } -private fun formatColorOption(colorValue: Int): String? { - if (colorValue == 0) return null - return "#${Integer.toHexString(colorValue).uppercase()}" -} - -@Composable -private fun ColorSwatch(colorValue: Int) { - if (colorValue == 0) return - Box( - modifier = Modifier - .size(28.dp) - .clip(CircleShape) - .background(Color(colorValue)) - .border( - 1.dp, - MaterialTheme.colorScheme.outlineVariant, - CircleShape - ) - ) -} - data class ThemeColorPalette( val primary: Color, val secondary: Color, @@ -1065,3 +1237,4 @@ private fun getThemeColors( onSurfaceVariant = colorScheme.onSurfaceVariant ) } + diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeListDialog.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeListDialog.kt deleted file mode 100644 index ee0ac0175..000000000 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeListDialog.kt +++ /dev/null @@ -1,161 +0,0 @@ -package io.legado.app.ui.config.themeConfig - -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.FileDownload -import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -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.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.core.graphics.toColorInt -import io.legado.app.R -import io.legado.app.help.config.OldThemeConfig -import io.legado.app.lib.theme.primaryColor -import io.legado.app.ui.theme.LegadoTheme -import io.legado.app.ui.widget.components.alert.AppAlertDialog -import io.legado.app.ui.widget.components.button.MediumIconButton -import io.legado.app.ui.widget.components.card.GlassCard -import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet -import io.legado.app.ui.widget.components.text.AppText -import io.legado.app.utils.GSON -import io.legado.app.utils.getClipText -import io.legado.app.utils.share -import io.legado.app.utils.toastOnUi - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ThemeListDialog( - show: Boolean, - onDismissRequest: () -> Unit -) { - val context = LocalContext.current - var listVersion by remember { mutableIntStateOf(0) } - var deleteIndex by remember { mutableStateOf(null) } - val themeList = remember(listVersion) { OldThemeConfig.configList.toList() } - - AppModalBottomSheet( - show = show, - onDismissRequest = onDismissRequest, - title = stringResource(R.string.theme_list), - endAction = { - MediumIconButton( - onClick = { - val clipText = context.getClipText() - if (clipText != null && OldThemeConfig.addConfig(clipText)) { - listVersion++ - } else { - context.toastOnUi("Import failed") - } - }, - imageVector = Icons.Default.FileDownload - ) - } - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 24.dp) - ) { - LazyColumn( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - itemsIndexed(themeList, key = { _, item -> item.themeName }) { index, item -> - GlassCard( - onClick = { - OldThemeConfig.applyConfig(context, item) - }, - modifier = Modifier - .fillMaxWidth(), - cornerRadius = 20.dp, - containerColor = if (item.primaryColor.toColorInt() == context.primaryColor) { - MaterialTheme.colorScheme.secondaryContainer - } else { - MaterialTheme.colorScheme.surfaceContainer - } - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .padding(end = 12.dp) - .background( - color = Color( - item.primaryColor.toColorInt() - ), - shape = MaterialTheme.shapes.large - ) - .padding(8.dp) - ) - AppText( - text = item.themeName, - style = LegadoTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) - ) - IconButton( - onClick = { - val json = GSON.toJson(item) - context.share(json, "主题分享") - } - ) { - Icon( - imageVector = Icons.Default.Share, - contentDescription = stringResource(R.string.share) - ) - } - IconButton(onClick = { deleteIndex = index }) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.delete) - ) - } - } - } - } - } - } - } - - AppAlertDialog( - data = deleteIndex, - onDismissRequest = { deleteIndex = null }, - title = stringResource(R.string.delete), - confirmText = stringResource(android.R.string.ok), - onConfirm = { index -> - OldThemeConfig.delConfig(index) - listVersion++ - deleteIndex = null - }, - dismissText = stringResource(android.R.string.cancel), - onDismiss = { deleteIndex = null } - ) -} diff --git a/app/src/main/java/io/legado/app/ui/config/themePack/ThemePackScreen.kt b/app/src/main/java/io/legado/app/ui/config/themePack/ThemePackScreen.kt new file mode 100644 index 000000000..7664515b2 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/themePack/ThemePackScreen.kt @@ -0,0 +1,372 @@ +package io.legado.app.ui.config.themePack + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +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.height +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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import io.legado.app.R +import io.legado.app.help.config.ThemePackConfig +import io.legado.app.ui.theme.adaptiveContentPadding +import io.legado.app.ui.widget.components.AppScaffold +import io.legado.app.ui.widget.components.SplicedColumnGroup +import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem +import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar +import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults +import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton +import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ThemePackScreen( + onBackClick: () -> Unit +) { + val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() + val context = LocalContext.current + val scope = rememberCoroutineScope() + var listVersion by remember { mutableIntStateOf(0) } + var showAddDialog by remember { mutableStateOf(false) } + var newThemeName by remember { mutableStateOf("") } + var deleteTarget by remember { mutableStateOf(null) } + var applyTarget by remember { mutableStateOf(null) } + var exportingPack by remember { mutableStateOf(null) } + + val packList = remember(listVersion) { ThemePackConfig.packList.toList() } + + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/zip") + ) { uri -> + uri?.let { + val pack = exportingPack ?: return@let + exportingPack = null + scope.launch(Dispatchers.IO) { + kotlin.runCatching { + val tempFile = File(context.cacheDir, "theme_export_${pack.name}.zip") + ThemePackConfig.exportToZip(pack, tempFile) + context.contentResolver.openOutputStream(uri)?.use { os -> + tempFile.inputStream().use { it.copyTo(os) } + } + tempFile.delete() + withContext(Dispatchers.Main) { + context.toastOnUi("导出成功") + } + }.onFailure { + withContext(Dispatchers.Main) { + context.toastOnUi("导出失败: ${it.message}") + } + } + } + } + } + + val importLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + uri?.let { + scope.launch(Dispatchers.IO) { + kotlin.runCatching { + val tempFile = File(context.cacheDir, "theme_import_${System.currentTimeMillis()}.zip") + context.contentResolver.openInputStream(it)?.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + val pack = ThemePackConfig.importFromZip(tempFile) + tempFile.delete() + if (pack != null) { + listVersion++ + withContext(Dispatchers.Main) { + context.toastOnUi("导入成功: ${pack.name}") + } + } else { + withContext(Dispatchers.Main) { + context.toastOnUi("导入失败: 无效的主题包") + } + } + }.onFailure { + withContext(Dispatchers.Main) { + context.toastOnUi("导入失败: ${it.message}") + } + } + } + } + } + + AppScaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + GlassMediumFlexibleTopAppBar( + title = stringResource(R.string.theme_pack), + scrollBehavior = scrollBehavior, + navigationIcon = { + TopBarNavigationButton(onClick = onBackClick) + } + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = adaptiveContentPadding( + top = paddingValues.calculateTopPadding(), + bottom = 120.dp + ) + ) { + item { + SplicedColumnGroup { + ClickableSettingItem( + title = "新增主题", + description = "保存当前设置为新主题", + onClick = { + newThemeName = "" + showAddDialog = true + } + ) + ClickableSettingItem( + title = "导入主题", + description = "从zip包导入主题", + onClick = { + importLauncher.launch(arrayOf("application/zip", "application/octet-stream")) + } + ) + } + } + + if (packList.isNotEmpty()) { + item { + AppText( + text = "主题列表", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp) + ) + } + + items(packList, key = { it.folderName }) { pack -> + ThemePackItem( + pack = pack, + onApply = { applyTarget = pack }, + onExport = { exportingPack = pack }, + onDelete = { deleteTarget = pack } + ) + } + } + } + } + + AppAlertDialog( + data = if (showAddDialog) "add" else null, + onDismissRequest = { showAddDialog = false }, + title = "新增主题", + confirmText = "保存", + onConfirm = { + if (newThemeName.isNotBlank()) { + scope.launch(Dispatchers.IO) { + ThemePackConfig.createFromCurrent(newThemeName) + listVersion++ + } + showAddDialog = false + } + }, + dismissText = "取消", + onDismiss = { showAddDialog = false }, + content = { + io.legado.app.ui.widget.components.AppTextField( + value = newThemeName, + onValueChange = { newThemeName = it }, + placeholder = { AppText(text = "请输入主题名称") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + ) + + AppAlertDialog( + data = applyTarget, + onDismissRequest = { applyTarget = null }, + title = "应用主题", + confirmText = "应用", + onConfirm = { pack -> + scope.launch(Dispatchers.IO) { + ThemePackConfig.applyPack(pack) + withContext(Dispatchers.Main) { + context.toastOnUi("主题已应用,请重启生效") + } + } + applyTarget = null + }, + dismissText = "取消", + onDismiss = { applyTarget = null }, + text = "确定应用主题「${applyTarget?.name}」?应用后需要重启才能完全生效。" + ) + + AppAlertDialog( + data = deleteTarget, + onDismissRequest = { deleteTarget = null }, + title = "删除主题", + confirmText = "删除", + onConfirm = { pack -> + scope.launch(Dispatchers.IO) { + ThemePackConfig.deletePack(pack) + listVersion++ + } + deleteTarget = null + }, + dismissText = "取消", + onDismiss = { deleteTarget = null }, + text = "确定删除主题「${deleteTarget?.name}」?此操作不可恢复。" + ) + + LaunchedEffect(exportingPack) { + exportingPack?.let { pack -> + kotlin.runCatching { + exportLauncher.launch("${pack.name}.zip") + }.onFailure { + exportingPack = null + context.toastOnUi("导出失败: ${it.message}") + } + } + } +} + +@Composable +private fun ThemePackItem( + pack: ThemePackConfig.ThemePack, + onApply: () -> Unit, + onExport: () -> Unit, + onDelete: () -> Unit +) { + GlassCard( + onClick = onApply, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + cornerRadius = 20.dp + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + val packDir = remember { ThemePackConfig.getPackDir(pack) } + val bgFile = remember(pack.folderName) { + packDir.listFiles()?.firstOrNull { + it.isFile && (it.name.startsWith("bg_light") || it.name.startsWith("bg_dark")) + } + } + val coverFile = remember(pack.folderName) { + packDir.listFiles()?.firstOrNull { + it.isFile && it.name.startsWith("cover_day_") + } + } + val previewFile = bgFile ?: coverFile + + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)) + .background( + if (pack.themeColor != 0) Color(pack.themeColor) + else MaterialTheme.colorScheme.primary + ) + ) { + if (previewFile != null) { + AsyncImage( + model = previewFile, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + AppText( + text = pack.name, + style = MaterialTheme.typography.bodyLarge + ) + val hasCustomIcon = pack.navIconBookshelf.isNotEmpty() || + pack.navIconExplore.isNotEmpty() || + pack.navIconRss.isNotEmpty() || + pack.navIconMy.isNotEmpty() + val hasCover = pack.defaultCover.isNotEmpty() || pack.defaultCoverDark.isNotEmpty() + val features = mutableListOf() + if (pack.appFontPath != null) features.add("应用字体") + if (pack.bgImageLight != null || pack.bgImageDark != null) features.add("应用背景") + if (hasCover) features.add("封面图片") + if (hasCustomIcon) features.add("图标") + if (features.isNotEmpty()) { + AppText( + text = features.joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + IconButton(onClick = onExport) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "导出", + modifier = Modifier.size(20.dp) + ) + } + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "删除", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } +} 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 c68117d79..cf44fdc17 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 @@ -61,8 +61,11 @@ import io.legado.app.ui.config.backupConfig.BackupConfigScreen import io.legado.app.ui.config.coverConfig.CoverConfigScreen import io.legado.app.ui.config.mainConfig.MainConfig import io.legado.app.ui.config.otherConfig.OtherConfigScreen +import io.legado.app.ui.config.personalizationConfig.FontSelectScreen +import io.legado.app.ui.config.customTheme.CustomThemeScreen import io.legado.app.ui.config.readConfig.ReadConfigScreen import io.legado.app.ui.config.themeConfig.ThemeConfigScreen +import io.legado.app.ui.config.themePack.ThemePackScreen import io.legado.app.ui.rss.article.MainRouteRssSort import io.legado.app.ui.rss.article.RssSortRouteScreen import io.legado.app.ui.rss.read.MainRouteRssRead @@ -97,6 +100,7 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { private const val ROUTE_SETTINGS_COVER = "settings/cover" private const val ROUTE_SETTINGS_THEME = "settings/theme" private const val ROUTE_SETTINGS_BACKUP = "settings/backup" + private const val ROUTE_SETTINGS_CUSTOM_THEME = "settings/custom_theme" private const val ROUTE_IMPORT_LOCAL = "import/local" private const val ROUTE_IMPORT_REMOTE = "import/remote" private const val ROUTE_CACHE = "cache" @@ -278,6 +282,12 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { @Serializable private data object MainRouteSettingsBackup : MainRoute + @Serializable + private data object MainRouteSettingsCustomTheme : MainRoute + + @Serializable + private data object MainRouteSettingsThemePack : MainRoute + @Serializable private data object MainRouteImportLocal : MainRoute @@ -543,13 +553,27 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { } entry { - ThemeConfigScreen(onBackClick = { navigateBack(backStack) }) + ThemeConfigScreen( + onBackClick = { navigateBack(backStack) }, + onNavigateToCustomTheme = { backStack.add(MainRouteSettingsCustomTheme) } + ) } entry { BackupConfigScreen(onBackClick = { navigateBack(backStack) }) } + entry { + CustomThemeScreen( + onBackClick = { navigateBack(backStack) }, + onNavigateToThemePack = { backStack.add(MainRouteSettingsThemePack) } + ) + } + + entry { + ThemePackScreen(onBackClick = { navigateBack(backStack) }) + } + entry { ImportBookScreen( onBackClick = { navigateBack(backStack) } @@ -599,7 +623,6 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { } DisposableEffect(lifecycleOwner, searchViewModel) { - searchViewModel.onIntent(SearchIntent.ResumeEngine) val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_RESUME -> { @@ -616,7 +639,6 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) - searchViewModel.onIntent(SearchIntent.PauseEngine) } } @@ -645,15 +667,25 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { initialSortUrl = route.sortUrl, onBackClick = { navigateBack(backStack) }, onOpenRead = { title, origin, link, openUrl -> - navigateToRoute( - backStack, - MainRouteRssRead( - title = title, - origin = origin, - link = link, - openUrl = openUrl + if (link?.contains("@js:") == true) { + navigateToRoute( + backStack, + MainRouteRssSort( + sourceUrl = origin, + sortUrl = link + ) ) - ) + } else { + navigateToRoute( + backStack, + MainRouteRssRead( + title = title, + origin = origin, + link = link, + openUrl = openUrl + ) + ) + } } ) } @@ -668,7 +700,18 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { ) } - entry { route -> + entry( + metadata = NavDisplay.transitionSpec { + fadeIn(animationSpec = tween(300)) togetherWith + fadeOut(animationSpec = tween(300)) + } + NavDisplay.popTransitionSpec { + fadeIn(animationSpec = tween(300)) togetherWith + fadeOut(animationSpec = tween(300)) + } + NavDisplay.predictivePopTransitionSpec { _ -> + fadeIn(animationSpec = tween(300)) togetherWith + fadeOut(animationSpec = tween(300)) + } + ) { route -> val bookInfoViewModel = koinViewModel() BookInfoRouteScreen( bookUrl = route.bookUrl, @@ -734,7 +777,9 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { MainRouteSettingsRead, MainRouteSettingsCover, MainRouteSettingsTheme, - MainRouteSettingsBackup -> { + MainRouteSettingsBackup, + MainRouteSettingsCustomTheme, + MainRouteSettingsThemePack -> { backStack.clear() backStack.add(MainRouteHome) backStack.add(MainRouteSettings) @@ -969,6 +1014,16 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { private fun resolveStartRoute(route: String?): MainRoute { return when (route) { + "main" -> MainRouteHome + "settings" -> MainRouteSettings + "settings/other" -> MainRouteSettingsOther + "settings/read" -> MainRouteSettingsRead + "settings/cover" -> MainRouteSettingsCover + "settings/theme" -> MainRouteSettingsTheme + "settings/backup" -> MainRouteSettingsBackup + "settings/custom_theme" -> MainRouteSettingsCustomTheme + "import/local" -> MainRouteImportLocal + "import/remote" -> MainRouteImportRemote ROUTE_MAIN -> MainRouteHome ROUTE_SETTINGS -> MainRouteSettings ROUTE_SETTINGS_OTHER -> MainRouteSettingsOther diff --git a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt index 9ff86f367..c2af0ae83 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState @@ -57,12 +58,17 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.graphics.Color 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 androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter import io.legado.app.R +import io.legado.app.ui.config.mainConfig.MainConfig +import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.main.bookshelf.BookshelfScreen import io.legado.app.ui.main.bookshelf.BookshelfViewModel import io.legado.app.ui.main.explore.ExploreScreen @@ -149,7 +155,11 @@ fun MainScreen( } val hazeState = remember { HazeState() } - val floatingBarSurfaceColor = MaterialTheme.colorScheme.surface + val floatingBarSurfaceColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) { + Color(ThemeConfig.secondaryThemeColor) + } else { + MaterialTheme.colorScheme.surface + } val floatingBarBackdrop = rememberLayerBackdrop { drawRect(floatingBarSurfaceColor) drawContent() @@ -270,7 +280,13 @@ fun MainScreen( } }, label = if (labelVisibilityMode != "unlabeled") { - { AppText(stringResource(destination.labelId)) } + val hasCustomIcon = when (destination) { + MainDestination.Bookshelf -> MainConfig.navIconBookshelf.isNotEmpty() + MainDestination.Explore -> MainConfig.navIconExplore.isNotEmpty() + MainDestination.Rss -> MainConfig.navIconRss.isNotEmpty() + MainDestination.My -> MainConfig.navIconMy.isNotEmpty() + } + if (hasCustomIcon) null else {{ AppText(stringResource(destination.labelId)) }} } else null ) } @@ -280,88 +296,37 @@ fun MainScreen( AppScaffold( modifier = Modifier.weight(1f), bottomBar = { - if (!useRail && mainUiState.showBottomView) { - if (useFloatingBottomBar) { - Box(modifier = Modifier.fillMaxWidth()) { - FloatingBottomBar( - modifier = Modifier - .align(Alignment.BottomCenter) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = {} - ) - .padding( - start = 16.dp, - end = 16.dp, - bottom = 12.dp + WindowInsets.navigationBars - .asPaddingValues() - .calculateBottomPadding() - ), - selectedIndex = { pagerState.targetPage }, - onSelected = { index -> - coroutineScope.launch { - pagerState.animateScrollToPage(index) - } + if (!useRail && mainUiState.showBottomView && !useFloatingBottomBar) { + AppNavigationBar() { + destinations.forEachIndexed { index, destination -> + val selected = pagerState.targetPage == index + val customIconPath = when (destination) { + MainDestination.Bookshelf -> MainConfig.navIconBookshelf + MainDestination.Explore -> MainConfig.navIconExplore + MainDestination.Rss -> MainConfig.navIconRss + MainDestination.My -> MainConfig.navIconMy + } + AppNavigationBarItem( + selected = selected, + onClick = { + coroutineScope.launch { pagerState.animateScrollToPage(index) } }, - backdrop = floatingBarBackdrop, - tabsCount = destinations.size, - isBlurEnabled = useLiquidGlass - ) { - destinations.forEachIndexed { index, destination -> - val selected = pagerState.targetPage == index - FloatingBottomBarItem( - onClick = { - coroutineScope.launch { - pagerState.animateScrollToPage(index) - } - }, - modifier = Modifier.defaultMinSize(minWidth = 76.dp) - ) { - NavigationIcon( - destination = destination, - selected = selected - ) - if (showLabel && (alwaysShowLabel || selected)) { - AppText( - text = stringResource(destination.labelId), - style = MaterialTheme.typography.labelSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } - } - } - } else { - AppNavigationBar( - modifier = Modifier - .regularHazeEffect(state = hazeState) - ) { - destinations.forEachIndexed { index, destination -> - val selected = pagerState.targetPage == index - AppNavigationBarItem( - selected = selected, - onClick = { - coroutineScope.launch { pagerState.animateScrollToPage(index) } - }, - labelString = stringResource(destination.labelId), - iconVector = AppIcons.mainDestination(destination, selected), - m3Icon = { - NavigationIcon( - destination = destination, - selected = selected - ) - }, - m3IndicatorColor = GlassDefaults.glassColor( - noBlurColor = MaterialTheme.colorScheme.secondaryContainer, - blurAlpha = GlassDefaults.ThickBlurAlpha - ), - m3ShowLabel = showLabel, - m3AlwaysShowLabel = alwaysShowLabel - ) - } + labelString = stringResource(destination.labelId), + iconVector = AppIcons.mainDestination(destination, selected), + m3Icon = { + NavigationIcon( + destination = destination, + selected = selected + ) + }, + m3IndicatorColor = GlassDefaults.glassColor( + noBlurColor = MaterialTheme.colorScheme.secondaryContainer, + blurAlpha = GlassDefaults.ThickBlurAlpha + ), + m3ShowLabel = showLabel && !customIconPath.isNotEmpty(), + m3AlwaysShowLabel = alwaysShowLabel && !customIconPath.isNotEmpty(), + useCustomIcon = customIconPath.isNotEmpty() + ) } } } @@ -369,60 +334,136 @@ fun MainScreen( contentWindowInsets = WindowInsets(0) ) { _ -> Box( - modifier = Modifier - .hazeSource(hazeState) - .then( - if (useFloatingBottomBar) { - Modifier.layerBackdrop(floatingBarBackdrop) + modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier.then( + if (useLiquidGlass) { + Modifier + .hazeSource(hazeState) + .layerBackdrop(floatingBarBackdrop) } else { Modifier } ) - ) { - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - userScrollEnabled = true, - beyondViewportPageCount = 1 - ) { page -> - val destination = destinations.getOrNull(page) ?: return@HorizontalPager - when (destination) { - MainDestination.Bookshelf -> BookshelfScreen( - onBookClick = { book -> - context.startActivityForBook(book) - }, - onBookLongClick = { book -> - onNavigateToBookInfo(book.name, book.author, book.bookUrl) - }, - onNavigateToSearch = { query -> onNavigateToSearch(query) }, - onNavigateToRemoteImport = onNavigateToRemoteImport, - onNavigateToLocalImport = onNavigateToLocalImport, - onNavigateToCache = onNavigateToCache, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - ) + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + userScrollEnabled = true, + beyondViewportPageCount = 1 + ) { page -> + val destination = destinations.getOrNull(page) ?: return@HorizontalPager + when (destination) { + MainDestination.Bookshelf -> BookshelfScreen( + onBookClick = { book -> + context.startActivityForBook(book) + }, + onBookLongClick = { book -> + onNavigateToBookInfo(book.name, book.author, book.bookUrl) + }, + onNavigateToSearch = { query -> onNavigateToSearch(query) }, + onNavigateToRemoteImport = onNavigateToRemoteImport, + onNavigateToLocalImport = onNavigateToLocalImport, + onNavigateToCache = onNavigateToCache, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) - MainDestination.Explore -> ExploreScreen( - onOpenExploreShow = onNavigateToExploreShow - ) - MainDestination.Rss -> RssScreen( - onOpenSort = { sourceUrl, sortUrl, key -> - onNavigateToRssSort(sourceUrl, sortUrl, key) + MainDestination.Explore -> ExploreScreen( + onOpenExploreShow = onNavigateToExploreShow + ) + MainDestination.Rss -> RssScreen( + onOpenSort = { sourceUrl, sortUrl, key -> + onNavigateToRssSort(sourceUrl, sortUrl, key) + }, + onOpenRead = { title, origin, link, openUrl -> + onNavigateToRssRead(title, origin, link, openUrl) + } + ) + MainDestination.My -> MyScreen( + onOpenSettings = onOpenSettings, + onNavigate = { event -> + if (event == PrefClickEvent.OpenBookCacheManage) { + onNavigateToBookCacheManage() + } else { + viewModel.onPrefClickEvent(event) + } + } + ) + } + } + } + + if (!useRail && mainUiState.showBottomView && useFloatingBottomBar) { + Box(modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + ) { + FloatingBottomBar( + modifier = Modifier + .align(Alignment.BottomCenter) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {} + ) + .padding( + start = 16.dp, + end = 16.dp, + bottom = 12.dp + WindowInsets.navigationBars + .asPaddingValues() + .calculateBottomPadding() + ), + selectedIndex = { pagerState.targetPage }, + onSelected = { index -> + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } }, - onOpenRead = { title, origin, link, openUrl -> - onNavigateToRssRead(title, origin, link, openUrl) - } - ) - MainDestination.My -> MyScreen( - onOpenSettings = onOpenSettings, - onNavigate = { event -> - if (event == PrefClickEvent.OpenBookCacheManage) { - onNavigateToBookCacheManage() - } else { - viewModel.onPrefClickEvent(event) + backdrop = floatingBarBackdrop, + tabsCount = destinations.size, + isBlurEnabled = useLiquidGlass, + hasCustomIcons = destinations.any { dest -> + when (dest) { + MainDestination.Bookshelf -> MainConfig.navIconBookshelf.isNotEmpty() + MainDestination.Explore -> MainConfig.navIconExplore.isNotEmpty() + MainDestination.Rss -> MainConfig.navIconRss.isNotEmpty() + MainDestination.My -> MainConfig.navIconMy.isNotEmpty() } } - ) + ) { + destinations.forEachIndexed { index, destination -> + val selected = pagerState.targetPage == index + val hasCustomIcon = when (destination) { + MainDestination.Bookshelf -> MainConfig.navIconBookshelf.isNotEmpty() + MainDestination.Explore -> MainConfig.navIconExplore.isNotEmpty() + MainDestination.Rss -> MainConfig.navIconRss.isNotEmpty() + MainDestination.My -> MainConfig.navIconMy.isNotEmpty() + } + FloatingBottomBarItem( + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + }, + modifier = Modifier.defaultMinSize(minWidth = 76.dp) + ) { + NavigationIcon( + destination = destination, + selected = selected + ) + if (!hasCustomIcon && showLabel && (alwaysShowLabel || selected)) { + AppText( + text = stringResource(destination.labelId), + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } } } } @@ -474,6 +515,33 @@ private fun NavigationIcon( selected: Boolean, modifier: Modifier = Modifier ) { - val icon = AppIcons.mainDestination(destination, selected) - AppIcon(icon, contentDescription = null, modifier = modifier) + val customIconPath = when (destination) { + MainDestination.Bookshelf -> MainConfig.navIconBookshelf + MainDestination.Explore -> MainConfig.navIconExplore + MainDestination.Rss -> MainConfig.navIconRss + MainDestination.My -> MainConfig.navIconMy + } + if (customIconPath.isNotEmpty()) { + val context = LocalContext.current + val bitmap = remember(customIconPath) { + kotlin.runCatching { + android.graphics.BitmapFactory.decodeFile(customIconPath) + }.getOrNull() + } + if (bitmap != null) { + Image( + painter = remember(bitmap) { + BitmapPainter(bitmap.asImageBitmap()) + }, + contentDescription = null, + modifier = modifier.size(40.dp) + ) + } else { + val icon = AppIcons.mainDestination(destination, selected) + AppIcon(icon, contentDescription = null, modifier = modifier) + } + } else { + val icon = AppIcons.mainDestination(destination, selected) + AppIcon(icon, contentDescription = null, modifier = modifier) + } } diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookGroupUi.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookGroupUi.kt new file mode 100644 index 000000000..4b67bad34 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookGroupUi.kt @@ -0,0 +1,25 @@ +package io.legado.app.ui.main.bookshelf + +import androidx.compose.runtime.Stable +import io.legado.app.data.entities.BookGroup + +@Stable +data class BookGroupUi( + val groupId: Long, + val groupName: String, + val cover: String?, + val order: Int, + val enableRefresh: Boolean, + val show: Boolean, + val bookSort: Int +) + +fun BookGroup.toBookGroupUi() = BookGroupUi( + groupId = groupId, + groupName = groupName, + cover = cover, + order = order, + enableRefresh = enableRefresh, + show = show, + bookSort = bookSort +) diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt index 1ae723793..8ff17b66f 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt @@ -5,10 +5,12 @@ import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.aspectRatio @@ -24,19 +26,29 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import io.legado.app.R import io.legado.app.constant.BookType -import io.legado.app.data.entities.BookGroup import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig +import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.NormalCard @@ -44,6 +56,7 @@ import io.legado.app.ui.widget.components.card.TextCard import io.legado.app.ui.widget.components.cover.CoilBookCover import io.legado.app.ui.widget.components.cover.BookshelfCover import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.utils.splitNotBlank import io.legado.app.utils.toTimeAgo /** @@ -63,24 +76,62 @@ fun BookshelfItem( titleEnd: @Composable (() -> Unit)? = null, subTitle: String? = null, desc: String? = null, + descMaxLines: Int = 1, extra: @Composable (RowScope.() -> Unit)? = null, + bottomContent: @Composable (() -> Unit)? = null, titleSmallFont: Boolean = false, titleCenter: Boolean = true, titleMaxLines: Int = 2, coverShadow: Boolean = false, + titleColor: Color? = null, + descAnnotated: AnnotatedString? = null, onClick: () -> Unit, onLongClick: (() -> Unit)? ) { - val selectedColor = if (isSelected) { - LegadoTheme.colorScheme.secondaryContainer + val containerColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) { + Color(ThemeConfig.secondaryThemeColor) + } else { + LegadoTheme.colorScheme.cardContainer + } + + val borderWidth = ThemeConfig.containerBorderWidth.dp + val borderColor = if (ThemeConfig.containerBorderColor != 0) { + Color(ThemeConfig.containerBorderColor) + } else { + LegadoTheme.colorScheme.outline + } + val borderStyle = ThemeConfig.containerBorderStyle + val enableBorder = ThemeConfig.enableDeepPersonalization && ThemeConfig.enableContainerBorder + + val borderModifier = if (enableBorder && borderStyle == "solid") { + Modifier.border(borderWidth, borderColor, MaterialTheme.shapes.small) + } else if (enableBorder) { + val dashWidth = ThemeConfig.containerBorderDashWidth + val pathEffect = when (borderStyle) { + "dashed" -> PathEffect.dashPathEffect(floatArrayOf(dashWidth, dashWidth)) + "dotted" -> PathEffect.dashPathEffect(floatArrayOf(dashWidth / 2, dashWidth)) + else -> null + } + Modifier.drawWithContent { + drawContent() + val cornerRadius = CornerRadius(8.dp.toPx(), 8.dp.toPx()) + val halfStroke = borderWidth.toPx() / 2 + drawRoundRect( + color = borderColor, + topLeft = Offset(halfStroke, halfStroke), + size = Size(size.width - borderWidth.toPx(), size.height - borderWidth.toPx()), + cornerRadius = cornerRadius, + style = Stroke(width = borderWidth.toPx(), pathEffect = pathEffect) + ) + } } else { LegadoTheme.colorScheme.surface.copy(alpha = 0f) + Modifier } + if (isGrid) { Box( modifier = modifier - .clip(MaterialTheme.shapes.small) - .background(selectedColor) .combinedClickable( onClick = onClick, onLongClick = onLongClick @@ -99,7 +150,7 @@ fun BookshelfItem( MaterialTheme.shapes.extraSmall ) else Modifier ) - .clip(MaterialTheme.shapes.extraSmall) // 先阴影后裁剪 + .clip(MaterialTheme.shapes.extraSmall) ) { cover(Modifier.fillMaxSize()) if (gridStyle == 1) { @@ -146,14 +197,18 @@ fun BookshelfItem( } } } else { - // 列表布局 Column { NormalCard( modifier = modifier .fillMaxWidth() - .padding(all = 4.dp), + .padding(all = 4.dp) + .then(borderModifier), cornerRadius = 8.dp, - containerColor = selectedColor, + containerColor = if (isSelected) { + LegadoTheme.colorScheme.secondaryContainer + } else { + containerColor + }, onClick = onClick, onLongClick = onLongClick ) { @@ -177,7 +232,11 @@ fun BookshelfItem( ) { AppText( text = title, - style = LegadoTheme.typography.titleMediumEmphasized, + style = if (titleColor != null) { + LegadoTheme.typography.titleMediumEmphasized.copy(color = titleColor) + } else { + LegadoTheme.typography.titleMediumEmphasized + }, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) @@ -193,13 +252,22 @@ fun BookshelfItem( ) } if (!isCompact) { - desc?.let { + if (descAnnotated != null) { AppText( - text = it, + text = descAnnotated, style = LegadoTheme.typography.labelSmallEmphasized, - maxLines = 1, + maxLines = descMaxLines, overflow = TextOverflow.Ellipsis ) + } else { + desc?.let { + AppText( + text = it, + style = LegadoTheme.typography.labelSmallEmphasized, + maxLines = descMaxLines, + overflow = TextOverflow.Ellipsis + ) + } } } extra?.let { @@ -210,6 +278,7 @@ fun BookshelfItem( } } } + bottomContent?.invoke() } if (BookshelfConfig.bookshelfShowDivider) HorizontalDivider( @@ -232,7 +301,6 @@ fun BookGroupCover( modifier = modifier .aspectRatio(5f / 7f) .clip(RoundedCornerShape(4.dp)) - .background(LegadoTheme.colorScheme.surfaceContainer) ) { if (!coverPath.isNullOrBlank()) { CoilBookCover( @@ -326,7 +394,7 @@ fun BookGroupCover( @Composable fun BookGroupItemGrid( - group: BookGroup, + group: BookGroupUi, previewBooks: List, countText: String? = null, gridStyle: Int = 0, @@ -363,7 +431,7 @@ fun BookGroupItemGrid( @Composable fun BookGroupItemList( - group: BookGroup, + group: BookGroupUi, previewBooks: List, countText: String? = null, isCompact: Boolean = false, @@ -375,13 +443,27 @@ fun BookGroupItemList( onClick: () -> Unit, onLongClick: (() -> Unit)? ) { + val firstBookName = previewBooks.firstOrNull()?.name + val primaryColor = MaterialTheme.colorScheme.primary + val descAnnotated = if (firstBookName != null) { + buildAnnotatedString { + append("最近阅读:") + withStyle(SpanStyle(color = primaryColor, fontWeight = FontWeight.Medium)) { + append("《$firstBookName》") + } + } + } else { + null + } BookshelfItem( isGrid = false, gridStyle = 0, isCompact = isCompact, cover = { BookGroupCover(books = previewBooks, coverPath = group.cover, modifier = it) }, title = group.groupName, + titleColor = primaryColor, subTitle = countText, + descAnnotated = descAnnotated, titleSmallFont = titleSmallFont, titleCenter = titleCenter, titleMaxLines = titleMaxLines, @@ -485,6 +567,52 @@ fun BookItem( book.author }, desc = book.durChapterTitle ?: "", + bottomContent = if (layoutMode == 0 && BookshelfConfig.showBookIntro) { + { + val kindList = book.kind?.splitNotBlank(",", "\n")?.filter { it.isNotBlank() } + val intro = book.intro?.takeIf { it.isNotBlank() } + val customTagColors = if (ThemeConfig.enableCustomTagColors) ThemeConfig.getCustomTagColors() else emptyList() + if (!kindList.isNullOrEmpty()) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + kindList.forEachIndexed { index, label -> + val colorPair = if (customTagColors.isNotEmpty()) { + customTagColors[index % customTagColors.size] + } else { + null + } + TextCard( + text = label, + backgroundColor = if (colorPair != null && colorPair.bgColor != 0) Color(colorPair.bgColor) else LegadoTheme.colorScheme.surfaceContainerHighest, + contentColor = if (colorPair != null && colorPair.textColor != 0) Color(colorPair.textColor) else LegadoTheme.colorScheme.primary, + cornerRadius = 4.dp, + horizontalPadding = 6.dp, + verticalPadding = 2.dp, + textStyle = LegadoTheme.typography.labelSmall + ) + } + } + } + if (intro != null) { + val maxLines = if (BookshelfConfig.bookshelfIntroMaxLines == 0) Int.MAX_VALUE else BookshelfConfig.bookshelfIntroMaxLines + AppText( + text = intro, + style = LegadoTheme.typography.bodySmall, + color = LegadoTheme.colorScheme.onSurfaceVariant, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp) + ) + } + } + } else null, extra = { if (BookshelfConfig.showLastUpdateTime && !book.isLocal) { AppText( diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookShelfItem.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookShelfItem.kt index 11542ffe3..19e02b17f 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookShelfItem.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookShelfItem.kt @@ -25,7 +25,9 @@ data class BookShelfItem( val type: Int, val group: Long, val order: Int, - val canUpdate: Boolean = true + val canUpdate: Boolean = true, + val intro: String? = null, + val kind: String? = null ) { fun getDisplayCover() = if (customCoverUrl.isNullOrEmpty()) coverUrl else customCoverUrl diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfConfigSheet.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfConfigSheet.kt index 2b864e1ee..e412db2f2 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfConfigSheet.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfConfigSheet.kt @@ -12,6 +12,10 @@ import androidx.compose.foundation.verticalScroll 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 +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringArrayResource @@ -19,8 +23,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import io.legado.app.R import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig +import io.legado.app.ui.config.themeConfig.LabelColorManageSheet +import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.settingItem.CompactClickableSettingItem import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem import io.legado.app.ui.widget.components.settingItem.CompactSliderSettingItem import io.legado.app.ui.widget.components.settingItem.CompactSwitchSettingItem @@ -33,6 +40,7 @@ fun BookshelfConfigSheet( ) { val configuration = LocalConfiguration.current val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + var showLabelColorManage by remember { mutableStateOf(false) } AppModalBottomSheet( title = stringResource(R.string.bookshelf_layout), @@ -234,6 +242,38 @@ fun BookshelfConfigSheet( onCheckedChange = { BookshelfConfig.showLastUpdateTime = it } ) + CompactSwitchSettingItem( + title = "列表模式显示更多", + checked = BookshelfConfig.showBookIntro, + color = MaterialTheme.colorScheme.surface, + onCheckedChange = { BookshelfConfig.showBookIntro = it } + ) + + AnimatedVisibility(visible = BookshelfConfig.showBookIntro) { + CompactSliderSettingItem( + title = "简介行数", + description = if (BookshelfConfig.bookshelfIntroMaxLines == 0) "显示全部简介" else "显示 ${BookshelfConfig.bookshelfIntroMaxLines} 行简介", + value = BookshelfConfig.bookshelfIntroMaxLines.toFloat(), + valueRange = 0f..10f, + steps = 10, + onValueChange = { BookshelfConfig.bookshelfIntroMaxLines = it.toInt() } + ) + CompactSwitchSettingItem( + title = "自定义标签颜色", + checked = ThemeConfig.enableCustomTagColors, + color = MaterialTheme.colorScheme.surface, + onCheckedChange = { ThemeConfig.enableCustomTagColors = it } + ) + + AnimatedVisibility(visible = ThemeConfig.enableCustomTagColors) { + CompactClickableSettingItem( + title = "管理标签颜色", + color = MaterialTheme.colorScheme.surface, + onClick = { showLabelColorManage = true } + ) + } + } + CompactSwitchSettingItem( title = stringResource(R.string.show_wait_up_count), checked = BookshelfConfig.showWaitUpCount, @@ -265,5 +305,10 @@ fun BookshelfConfigSheet( onValueChange = { BookshelfConfig.bookshelfRefreshingLimit = it.toInt() } ) } + + LabelColorManageSheet( + show = showLabelColorManage, + onDismissRequest = { showLabelColorManage = false } + ) } } diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt index 9f55e8791..6dfb4dd86 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt @@ -76,8 +76,8 @@ import androidx.compose.ui.window.Dialog import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.legado.app.R import io.legado.app.base.BaseRuleEvent -import io.legado.app.data.entities.BookGroup import io.legado.app.ui.about.AppLogSheet +import io.legado.app.data.entities.BookGroup import io.legado.app.ui.book.info.GroupSelectSheet import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig import io.legado.app.ui.main.bookCoverSharedElementKey @@ -212,17 +212,6 @@ fun BookshelfScreen( val latestGroups by rememberUpdatedState(uiState.groups) val latestSelectedGroupId by rememberUpdatedState(uiState.selectedGroupId) - LaunchedEffect(uiState.groups, uiState.isSearch) { - if (!uiState.isSearch && uiState.groups.isNotEmpty()) { - val savedGroupId = BookshelfConfig.saveTabPosition - val savedGroupIndex = uiState.groups.indexOfFirst { it.groupId == savedGroupId } - if (savedGroupIndex >= 0 && savedGroupIndex != pagerState.currentPage) { - viewModel.changeGroup(savedGroupId) - pagerState.scrollToPage(savedGroupIndex) - } - } - } - LaunchedEffect(pagerState) { snapshotFlow { pagerState.settledPage } .distinctUntilChanged() @@ -709,10 +698,6 @@ fun BookshelfScreen( paddingValues = paddingValues, books = uiState.items, uiState = uiState, - bookshelfLayoutMode = bookshelfLayoutMode, - bookshelfLayoutGrid = bookshelfLayoutGrid, - bookshelfLayoutList = bookshelfLayoutList, - isEditMode = isEditMode, selectedBookUrls = selectedBookUrls, canReorderBooks = false, onToggleBookSelection = { toggleBookSelection(it.bookUrl) }, @@ -725,6 +710,7 @@ fun BookshelfScreen( onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) }, onBookClick = onBookClick, onBookLongClick = onBookLongClick, + isCurrentPage = true, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, ) @@ -732,17 +718,15 @@ fun BookshelfScreen( HorizontalPager( state = pagerState, modifier = Modifier.fillMaxSize(), - beyondViewportPageCount = 1, + beyondViewportPageCount = 2, key = { if (it < uiState.groups.size) uiState.groups[it].groupId else it } ) { pageIndex -> val group = uiState.groups.getOrNull(pageIndex) if (group != null) { val isSelectedGroup = group.groupId == uiState.selectedGroupId - val books = if (isSelectedGroup) { - uiState.items - } else { - emptyList() - } + val books = uiState.allGroupBooks[group.groupId] + ?: if (isSelectedGroup) uiState.items + else emptyList() val canReorderBooks = isEditMode && !uiState.isSearch && (group.bookSort.takeIf { it >= 0 } @@ -752,10 +736,6 @@ fun BookshelfScreen( paddingValues = paddingValues, books = books, uiState = uiState, - bookshelfLayoutMode = bookshelfLayoutMode, - bookshelfLayoutGrid = bookshelfLayoutGrid, - bookshelfLayoutList = bookshelfLayoutList, - isEditMode = isEditMode, selectedBookUrls = selectedBookUrls, canReorderBooks = canReorderBooks, onToggleBookSelection = { toggleBookSelection(it.bookUrl) }, @@ -788,6 +768,7 @@ fun BookshelfScreen( onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) }, onBookClick = onBookClick, onBookLongClick = onBookLongClick, + isCurrentPage = isSelectedGroup, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, ) @@ -1022,10 +1003,6 @@ fun BookshelfPage( paddingValues: PaddingValues, books: List, uiState: BookshelfUiState, - bookshelfLayoutMode: Int, - bookshelfLayoutGrid: Int, - bookshelfLayoutList: Int, - isEditMode: Boolean, selectedBookUrls: Set, canReorderBooks: Boolean, onToggleBookSelection: (BookShelfItem) -> Unit, @@ -1038,10 +1015,12 @@ fun BookshelfPage( onGlobalSearch: () -> Unit, onBookClick: (BookShelfItem) -> Unit, onBookLongClick: (BookShelfItem) -> Unit, + isCurrentPage: Boolean = true, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, ) { if (books.isEmpty()) { + if (!isCurrentPage) return if (uiState.isSearch) { EmptyMessage( modifier = Modifier @@ -1068,6 +1047,14 @@ fun BookshelfPage( return } + val configuration = LocalConfiguration.current + val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + val bookshelfLayoutMode = + if (isLandscape) BookshelfConfig.bookshelfLayoutModeLandscape else BookshelfConfig.bookshelfLayoutModePortrait + val bookshelfLayoutGrid = + if (isLandscape) BookshelfConfig.bookshelfLayoutGridLandscape else BookshelfConfig.bookshelfLayoutGridPortrait + val bookshelfLayoutList = + if (isLandscape) BookshelfConfig.bookshelfLayoutListLandscape else BookshelfConfig.bookshelfLayoutListPortrait val columns = if (bookshelfLayoutMode == 0) bookshelfLayoutList else bookshelfLayoutGrid val isGridMode = bookshelfLayoutMode != 0 val totalHorizontalPadding = @@ -1148,7 +1135,7 @@ fun BookshelfPage( animatedVisibilityScope = animatedVisibilityScope, sharedCoverKey = bookCoverSharedElementKey(book.bookUrl), onClick = { - if (isEditMode) { + if (uiState.isEditMode) { onToggleBookSelection(book) } else { onBookClick(book) @@ -1158,7 +1145,7 @@ fun BookshelfPage( null } else { { - if (isEditMode) { + if (uiState.isEditMode) { onToggleBookSelection(book) } else { onBookLongClick(book) diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt index 088011e13..2f421995e 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt @@ -1,5 +1,6 @@ package io.legado.app.ui.main.bookshelf +import androidx.compose.runtime.Stable import io.legado.app.data.entities.BookGroup import io.legado.app.ui.widget.components.list.ListUiState import kotlinx.collections.immutable.ImmutableList @@ -9,8 +10,9 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentSetOf +@Stable data class BookshelfGroupSelectorState( - val groups: ImmutableList = persistentListOf(), + val groups: ImmutableList = persistentListOf(), val selectedGroupIndex: Int = 0, val selectedGroupId: Long = BookGroup.IdAll ) @@ -27,14 +29,15 @@ sealed interface BookshelfOverlay { data object BatchDownloadConfirmDialog : BookshelfOverlay } +@Stable data class BookshelfUiState( override val items: ImmutableList = persistentListOf(), override val selectedIds: ImmutableSet = persistentSetOf(), override val searchKey: String = "", override val isSearch: Boolean = false, override val isLoading: Boolean = false, - val groups: ImmutableList = persistentListOf(), - val allGroups: ImmutableList = persistentListOf(), + val groups: ImmutableList = persistentListOf(), + val allGroups: ImmutableList = persistentListOf(), val groupPreviews: ImmutableMap> = persistentMapOf(), val groupBookCounts: ImmutableMap = persistentMapOf(), val currentGroupBookCount: Int = 0, @@ -56,5 +59,6 @@ data class BookshelfUiState( val subtitle: String? = null, val currentGroupName: String? = null, val draggingBooks: ImmutableList? = null, - val pendingSavedBooks: ImmutableList? = null + val pendingSavedBooks: ImmutableList? = null, + val allGroupBooks: ImmutableMap> = persistentMapOf() ) : ListUiState diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt index 963b9ac8d..1cc758d49 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt @@ -73,10 +73,13 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn @@ -158,10 +161,6 @@ class BookshelfViewModel( val allGroupsFlow: StateFlow> = bookGroupRepository.flowAll() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - private val allBooksFlow = appDb.bookDao.flowBookShelf() - .distinctUntilChanged() - .flowOn(Dispatchers.Default) - private data class GroupPreviewState( val previews: ImmutableMap>, val counts: ImmutableMap, @@ -173,7 +172,7 @@ class BookshelfViewModel( groupIdFlow ) { groups, selectedGroupId -> BookshelfGroupSelectorState( - groups = groups.toImmutableList(), + groups = groups.map { it.toBookGroupUi() }.toImmutableList(), selectedGroupIndex = groups.indexOfFirst { it.groupId == selectedGroupId } .coerceAtLeast(0), selectedGroupId = selectedGroupId @@ -197,6 +196,25 @@ class BookshelfViewModel( } }.distinctUntilChanged().flowOn(Dispatchers.Default) + @OptIn(ExperimentalCoroutinesApi::class) + private val allGroupBooksFlow: StateFlow>> = combine( + groupsFlow, sortConfigFlow + ) { groups, sortConfig -> + groups to sortConfig + }.flatMapLatest { (groups, sortConfig) -> + if (groups.isEmpty()) { + flowOf(emptyMap()) + } else { + val flows = groups.map { group -> + appDb.bookDao.flowBookShelfByGroup(group.groupId).map { books -> + group.groupId to sortBooks(books, group, sortConfig) + } + } + combine(flows) { it.toMap() } + } + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) + private val visibleBooksFlow = combine( booksFlow, searchKeyFlow, @@ -213,15 +231,45 @@ class BookshelfViewModel( selectedBookUrls.intersect(visibleBookUrls) }.distinctUntilChanged() - private val groupPreviewsFlow = - combine( - groupsFlow, - allBooksFlow, - bookGroupStyleFlow, - sortConfigFlow - ) { groups, allBooks, bookGroupStyle, sortConfig -> - buildGroupPreviewState(groups, allBooks, bookGroupStyle, sortConfig) - }.distinctUntilChanged().flowOn(Dispatchers.Default) + @OptIn(ExperimentalCoroutinesApi::class) + private val groupPreviewsFlow = combine( + groupsFlow, + bookGroupStyleFlow, + appDb.bookDao.flowSystemGroupCounts() + ) { groups, bookGroupStyle, systemCounts -> + Triple(groups, bookGroupStyle, systemCounts.associate { it.groupId to it.count }) + }.flatMapLatest { (groups, bookGroupStyle, systemCountsMap) -> + if (bookGroupStyle !in 2..3) { + appDb.bookDao.flowAllBookShelfCount().map { count -> + GroupPreviewState(persistentMapOf(), persistentMapOf(), count) + } + } else if (groups.isEmpty()) { + flowOf(GroupPreviewState(persistentMapOf(), persistentMapOf(), 0)) + } else { + val groupFlows = groups.map { group -> + val countFlow: Flow = if (group.groupId > 0) { + appDb.bookDao.flowUserGroupBookCount(group.groupId) + } else { + flowOf(systemCountsMap[group.groupId] ?: 0) + } + val previewFlow = appDb.bookDao.flowGroupPreview(group.groupId) + combine(countFlow, previewFlow) { count, preview -> + Triple(group.groupId, count, preview) + } + } + combine(groupFlows) { results -> + var previews = persistentMapOf>() + var counts = persistentMapOf() + var allBookCount = 0 + results.forEach { (groupId, count, preview) -> + counts = counts.put(groupId, count) + previews = previews.put(groupId, preview.toImmutableList()) + if (groupId == BookGroup.IdAll) allBookCount = count + } + GroupPreviewState(previews, counts, allBookCount) + } + } + }.distinctUntilChanged().flowOn(Dispatchers.Default) private val coreInternalStateFlow = combine( groupIdFlow, @@ -309,16 +357,28 @@ class BookshelfViewModel( } private val dataStateFlow = combine( - booksFlow, - groupsFlow, - allGroupsFlow, - groupPreviewsFlow, - internalStateFlow - ) { books, groups, allGroups, previews, internal -> - BookshelfDataState(books, groups, allGroups, previews, internal) + combine( + booksFlow, + groupsFlow, + allGroupsFlow, + groupPreviewsFlow, + internalStateFlow + ) { books, groups, allGroups, previews, internal -> + BookshelfDataCore(books, groups, allGroups, previews, internal) + }, + allGroupBooksFlow + ) { core, allGroupBooks -> + BookshelfDataState( + books = core.books, + groups = core.groups.map { it.toBookGroupUi() }, + allGroups = core.allGroups.map { it.toBookGroupUi() }, + previews = core.previews, + internal = core.internal, + allGroupBooks = allGroupBooks + ) } - private data class BookshelfDataState( + private data class BookshelfDataCore( val books: List, val groups: List, val allGroups: List, @@ -326,6 +386,15 @@ class BookshelfViewModel( val internal: InternalState ) + private data class BookshelfDataState( + val books: List, + val groups: List, + val allGroups: List, + val previews: GroupPreviewState, + val internal: InternalState, + val allGroupBooks: Map> + ) + val uiState: StateFlow = combine( dataStateFlow, interactionStateFlow @@ -383,7 +452,8 @@ class BookshelfViewModel( }, currentGroupName = currentGroupName, draggingBooks = interaction.draggingBooks?.toImmutableList(), - pendingSavedBooks = interaction.pendingSavedBooks?.toImmutableList() + pendingSavedBooks = interaction.pendingSavedBooks?.toImmutableList(), + allGroupBooks = data.allGroupBooks.mapValues { it.value.toImmutableList() }.toImmutableMap() ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BookshelfUiState()) @@ -495,93 +565,6 @@ class BookshelfViewModel( } } - private fun buildGroupPreviewState( - groups: List, - allBooks: List, - bookGroupStyle: Int, - sortConfig: BookshelfSortConfig - ): GroupPreviewState { - if (bookGroupStyle !in 2..3) { - return GroupPreviewState(persistentMapOf(), persistentMapOf(), allBooks.size) - } - - val buckets = HashMap>(groups.size) - groups.forEach { group -> - buckets[group.groupId] = ArrayList() - } - - val userGroups = groups.filter { it.groupId > 0 } - val sumUserGroupIds = userGroups.sumOf { it.groupId } - - fun add(groupId: Long, book: BookShelfItem) { - buckets[groupId]?.add(book) - } - - allBooks.forEach { book -> - add(BookGroup.IdAll, book) - if (book.isRootGroupBook(sumUserGroupIds)) add(BookGroup.IdRoot, book) - if (book.isLocal) add(BookGroup.IdLocal, book) - if (book.isAudio) add(BookGroup.IdAudio, book) - if (book.isNetNoneGroupBook(sumUserGroupIds)) add(BookGroup.IdNetNone, book) - if (book.isLocalNoneGroupBook(sumUserGroupIds)) add(BookGroup.IdLocalNone, book) - if (book.isImage) add(BookGroup.IdManga, book) - if ((book.type and BookType.text) > 0) add(BookGroup.IdText, book) - if ((book.type and BookType.updateError) > 0) add(BookGroup.IdError, book) - if (book.durChapterIndex == 0 && book.durChapterPos == 0) { - add(BookGroup.IdUnread, book) - } - if (book.totalChapterNum > 0 && - book.durChapterIndex > 0 && - book.durChapterIndex < book.totalChapterNum - 1 - ) { - add(BookGroup.IdReading, book) - } - if (book.totalChapterNum > 0 && book.durChapterIndex >= book.totalChapterNum - 1) { - add(BookGroup.IdReadFinished, book) - } - userGroups.forEach { group -> - if ((book.group and group.groupId) != 0L) { - add(group.groupId, book) - } - } - } - - val previews = HashMap>(groups.size) - val counts = HashMap(groups.size) - groups.forEach { group -> - val groupBooks = buckets[group.groupId].orEmpty() - counts[group.groupId] = groupBooks.size - previews[group.groupId] = buildGroupPreview(sortBooks(groupBooks, group, sortConfig)) - } - return GroupPreviewState(previews.toImmutableMap(), counts.toImmutableMap(), allBooks.size) - } - - private fun BookShelfItem.isRootGroupBook(sumUserGroupIds: Long): Boolean { - return (type and BookType.text) > 0 && - (type and BookType.local) == 0 && - (sumUserGroupIds and group) == 0L - } - - private fun BookShelfItem.isNetNoneGroupBook(sumUserGroupIds: Long): Boolean { - return (type and BookType.audio) == 0 && - (type and BookType.local) == 0 && - (sumUserGroupIds and group) == 0L - } - - private fun BookShelfItem.isLocalNoneGroupBook(sumUserGroupIds: Long): Boolean { - return (type and BookType.local) > 0 && - (sumUserGroupIds and group) == 0L - } - - private fun buildGroupPreview(sortedBooks: List): ImmutableList { - val booksWithCover = sortedBooks.filter { it.getDisplayCover() != null } - return if (booksWithCover.size >= 4) { - booksWithCover.take(4) - } else { - (booksWithCover + sortedBooks.filter { it.getDisplayCover() == null }).take(4) - }.toImmutableList() - } - fun changeGroup(groupId: Long) { if (groupIdFlow.value != groupId) { groupIdFlow.value = groupId diff --git a/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt b/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt index 76334bb25..c3da15a31 100644 --- a/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement @@ -57,6 +58,7 @@ import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.book.search.SearchScope import io.legado.app.ui.book.source.edit.BookSourceEditActivity import io.legado.app.ui.login.SourceLoginActivity +import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme.composeEngine import io.legado.app.ui.theme.ThemeResolver @@ -286,12 +288,22 @@ fun ExploreScreen( .align(Alignment.TopStart) .padding(top = paddingValues.calculateTopPadding() + 4.dp, start = 8.dp) ) { item -> + val enableBorder = ThemeConfig.enableDeepPersonalization && ThemeConfig.enableContainerBorder + val borderWidth = ThemeConfig.containerBorderWidth.dp + val borderColor = if (ThemeConfig.containerBorderColor != 0) { + Color(ThemeConfig.containerBorderColor) + } else { + LegadoTheme.colorScheme.outline + } TextCard( text = item.bookSourceName, textStyle = LegadoTheme.typography.labelMediumEmphasized, cornerRadius = 12.dp, horizontalPadding = 12.dp, verticalPadding = 8.dp, + border = if (enableBorder) { + BorderStroke(borderWidth, borderColor) + } else null, onClick = { scope.launch { val index = @@ -362,12 +374,23 @@ fun ExploreSourceHeader( label = "CardColor" ) + val enableBorder = ThemeConfig.enableDeepPersonalization && ThemeConfig.enableContainerBorder + val borderWidth = ThemeConfig.containerBorderWidth.dp + val borderColor = if (ThemeConfig.containerBorderColor != 0) { + Color(ThemeConfig.containerBorderColor) + } else { + LegadoTheme.colorScheme.outline + } + GlassCard( modifier = modifier .fillMaxWidth() .padding(vertical = 4.dp), cornerRadius = 12.dp, - containerColor = containerColor + containerColor = containerColor, + border = if (enableBorder) { + BorderStroke(borderWidth, borderColor) + } else null ) { ListItem( modifier = Modifier diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt index 03be5cf2b..3cecfb9a2 100644 --- a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt +++ b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt @@ -1,6 +1,7 @@ package io.legado.app.ui.rss.article 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 @@ -64,6 +65,9 @@ import io.legado.app.ui.widget.components.EmptyMessage import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.cover.buildCoverImageRequest import io.legado.app.utils.toastOnUi +import io.legado.app.ui.config.themeConfig.ThemeConfig +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn @@ -92,6 +96,7 @@ fun RssArticlesPage( rssUrl: String?, rssSource: RssSource?, viewModel: RssArticlesViewModel, + searchKey: String? = null, onRead: (RssArticle) -> Unit, paddingValues: PaddingValues = PaddingValues(0.dp), modifier: Modifier = Modifier @@ -99,9 +104,10 @@ fun RssArticlesPage( val context = LocalContext.current val layout = remember(articleStyle) { articleStyle.toRssArticleLayout() } val loadState by viewModel.loadState.collectAsStateWithLifecycle() + val isPreload = remember(rssSource) { rssSource?.preload == true } - LaunchedEffect(sortName, sortUrl) { - viewModel.init(sortName, sortUrl) + LaunchedEffect(sortName, sortUrl, searchKey) { + viewModel.init(sortName, sortUrl, searchKey) } val articleFlow = remember(rssUrl, sortName) { @@ -117,7 +123,11 @@ fun RssArticlesPage( val articles by articleFlow.collectAsStateWithLifecycle(initialValue = emptyList()) LaunchedEffect(rssSource?.sourceUrl, sortName, sortUrl) { - rssSource?.let(viewModel::loadArticles) + rssSource?.let { source -> + if (isPreload) { + viewModel.loadArticles(source) + } + } } LaunchedEffect(loadState.errorMessage) { @@ -142,6 +152,7 @@ fun RssArticlesPage( LoadMoreDetector( state = listState, enabled = loadState.canLoadMore, + preloadThreshold = if (isPreload) 5 else 3, onLoadMore = { rssSource?.let(viewModel::loadMore) } ) LazyColumn( @@ -174,6 +185,7 @@ fun RssArticlesPage( GridLoadMoreDetector( state = gridState, enabled = loadState.canLoadMore, + preloadThreshold = if (isPreload) 5 else 3, onLoadMore = { rssSource?.let(viewModel::loadMore) } ) LazyVerticalGrid( @@ -208,6 +220,7 @@ fun RssArticlesPage( StaggeredLoadMoreDetector( state = staggeredState, enabled = loadState.canLoadMore, + preloadThreshold = if (isPreload) 5 else 3, onLoadMore = { rssSource?.let(viewModel::loadMore) } ) LazyVerticalStaggeredGrid( @@ -244,13 +257,14 @@ fun RssArticlesPage( private fun LoadMoreDetector( state: LazyListState, enabled: Boolean, + preloadThreshold: Int = 3, onLoadMore: () -> Unit ) { LaunchedEffect(state, enabled) { snapshotFlow { val info = state.layoutInfo val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 - enabled && info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - 3 + enabled && info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - preloadThreshold }.collect { shouldLoad -> if (shouldLoad) onLoadMore() } @@ -261,13 +275,14 @@ private fun LoadMoreDetector( private fun GridLoadMoreDetector( state: LazyGridState, enabled: Boolean, + preloadThreshold: Int = 3, onLoadMore: () -> Unit ) { LaunchedEffect(state, enabled) { snapshotFlow { val info = state.layoutInfo val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 - enabled && info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - 3 + enabled && info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - preloadThreshold }.collect { shouldLoad -> if (shouldLoad) onLoadMore() } @@ -278,13 +293,14 @@ private fun GridLoadMoreDetector( private fun StaggeredLoadMoreDetector( state: LazyStaggeredGridState, enabled: Boolean, + preloadThreshold: Int = 3, onLoadMore: () -> Unit ) { LaunchedEffect(state, enabled) { snapshotFlow { val info = state.layoutInfo val lastVisible = info.visibleItemsInfo.maxOfOrNull { it.index } ?: -1 - enabled && info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - 3 + enabled && info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - preloadThreshold }.collect { shouldLoad -> if (shouldLoad) onLoadMore() } @@ -340,11 +356,22 @@ private fun RssArticleItem( RssArticleLayout.Waterfall -> 3 } + // 边框设置 + val borderModifier = if (ThemeConfig.enableContainerBorder) { + Modifier.border( + width = Dp(ThemeConfig.containerBorderWidth), + color = Color(ThemeConfig.containerBorderColor), + shape = RoundedCornerShape(12.dp) + ) + } else { + Modifier + } + GlassCard( onClick = { onClick(article) }, cornerRadius = 12.dp, containerColor = containerColor, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().then(borderModifier) ) { when (layout) { RssArticleLayout.List -> { diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt index f1824b2f1..edb1d02a8 100644 --- a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt @@ -24,21 +24,24 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application private var nextPageUrl: String? = null var sortName: String = "" var sortUrl: String = "" + var searchKey: String? = null var page = 1 fun init(bundle: Bundle?) { bundle?.let { init( sortName = it.getString("sortName") ?: "", - sortUrl = it.getString("sortUrl") ?: "" + sortUrl = it.getString("sortUrl") ?: "", + searchKey = it.getString("searchKey") ) } } - fun init(sortName: String, sortUrl: String) { - if (this.sortName == sortName && this.sortUrl == sortUrl) return + fun init(sortName: String, sortUrl: String, searchKey: String? = null) { + if (this.sortName == sortName && this.sortUrl == sortUrl && this.searchKey == searchKey) return this.sortName = sortName this.sortUrl = sortUrl + this.searchKey = searchKey page = 1 nextPageUrl = null _loadState.value = RssArticlesLoadState() @@ -53,7 +56,7 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application ) page = 1 order = System.currentTimeMillis() - Rss.getArticles(viewModelScope, sortName, sortUrl, rssSource, page).onSuccess(IO) { + Rss.getArticles(viewModelScope, sortName, sortUrl, rssSource, page, searchKey).onSuccess(IO) { nextPageUrl = it.second val articles = it.first articles.forEach { rssArticle -> @@ -97,7 +100,7 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application ) page++ - Rss.getArticles(viewModelScope, sortName, pageUrl, rssSource, page).onSuccess(IO) { + Rss.getArticles(viewModelScope, sortName, pageUrl, rssSource, page, searchKey).onSuccess(IO) { nextPageUrl = it.second loadMoreSuccess(it.first) }.onError { diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssStartPage.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssStartPage.kt new file mode 100644 index 000000000..81807cda2 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/rss/article/RssStartPage.kt @@ -0,0 +1,274 @@ +package io.legado.app.ui.rss.article + +import android.annotation.SuppressLint +import android.webkit.ConsoleMessage +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.LocalActivity +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.LaunchedEffect +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.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import io.legado.app.data.entities.RssSource +import io.legado.app.help.WebCacheManager +import io.legado.app.help.config.AppConfig +import io.legado.app.help.http.CookieManager +import io.legado.app.help.webView.WebJsExtensions +import java.io.ByteArrayInputStream + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun RssStartPage( + rssSource: RssSource?, + onNavigateToArticles: (sortUrl: String?) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val activity = LocalActivity.current as? AppCompatActivity + var progress by remember { mutableIntStateOf(0) } + var isLoading by remember { mutableStateOf(true) } + var webViewRef by remember { mutableStateOf(null) } + + val startHtml = rssSource?.startHtml + val startStyle = rssSource?.startStyle ?: rssSource?.style + val startJs = rssSource?.startJs + val preloadJs = rssSource?.preloadJs + val hasPreloadJs = !preloadJs.isNullOrBlank() + + LaunchedEffect(startHtml) { + if (startHtml.isNullOrBlank()) { + onNavigateToArticles(null) + } + } + + if (startHtml.isNullOrBlank()) { + return + } + + DisposableEffect(Unit) { + onDispose { + webViewRef?.destroy() + } + } + + Box( + modifier = modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.statusBars) + ) { + if (isLoading && progress < 100) { + LinearProgressIndicator( + progress = { progress / 100f }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) + } + + AndroidView( + factory = { ctx -> + WebView(ctx).apply { + webViewRef = this + + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + useWideViewPort = true + loadWithOverviewMode = true + userAgentString = AppConfig.userAgent + cacheMode = if (rssSource?.cacheFirst == true) { + WebSettings.LOAD_CACHE_ELSE_NETWORK + } else { + WebSettings.LOAD_DEFAULT + } + } + + CookieManager.applyToWebView(rssSource?.sourceUrl.orEmpty()) + + if (hasPreloadJs && activity != null && rssSource != null) { + val webJsExtensions = WebJsExtensions( + rssSource, + activity, + this, + callback = object : WebJsExtensions.Callback { + override fun upConfig(config: String) {} + override fun onNavigateToArticles(sortUrl: String?) { + activity.runOnUiThread { + onNavigateToArticles(sortUrl) + } + } + } + ) + addJavascriptInterface(webJsExtensions, WebJsExtensions.nameJava) + addJavascriptInterface(rssSource, WebJsExtensions.nameSource) + addJavascriptInterface(WebCacheManager, WebJsExtensions.nameCache) + } + + addJavascriptInterface(object { + @android.webkit.JavascriptInterface + fun navigateToArticles() { + activity?.runOnUiThread { + onNavigateToArticles(null) + } + } + + @android.webkit.JavascriptInterface + fun getSourceUrl(): String { + return rssSource?.sourceUrl.orEmpty() + } + + @android.webkit.JavascriptInterface + fun getSourceName(): String { + return rssSource?.sourceName.orEmpty() + } + }, "AndroidBridge") + + var jsInjected = false + + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + isLoading = false + } + + override fun shouldInterceptRequest( + view: WebView?, + request: WebResourceRequest + ): WebResourceResponse? { + val url = request.url.toString() + if (!jsInjected && url == WebJsExtensions.nameUrl) { + jsInjected = true + val jsContent = buildString { + append("(() => {") + append(WebJsExtensions.JS_INJECTION) + if (!preloadJs.isNullOrBlank()) { + append("\n") + append(preloadJs) + } + append("\n})();") + } + return WebResourceResponse( + "text/javascript", + "utf-8", + ByteArrayInputStream(jsContent.toByteArray()) + ) + } + return super.shouldInterceptRequest(view, request) + } + } + + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + progress = newProgress + } + + override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + if (rssSource?.showWebLog == true) { + consoleMessage?.message()?.let { message -> + io.legado.app.constant.AppLog.put("StartPage: $message") + } + } + return true + } + } + + val htmlContent = buildStartPageHtml(startHtml, startStyle, startJs, hasPreloadJs) + loadDataWithBaseURL( + rssSource?.sourceUrl, + htmlContent, + "text/html", + "utf-8", + null + ) + } + }, + modifier = Modifier.fillMaxSize() + ) + } +} + +private fun buildStartPageHtml( + html: String, + style: String?, + startJs: String?, + hasPreloadJs: Boolean +): String { + val styleContent = style?.takeIf { it.isNotBlank() }?.let { + "" + } ?: "" + + val jsScript = startJs?.takeIf { it.isNotBlank() }?.let { + "" + } ?: "" + + val preloadScript = if (hasPreloadJs) { + WebJsExtensions.JS_URL + } else { + "" + } + + val htmlBuilder = StringBuilder() + + val headIndex = html.indexOf("") + if (headIndex >= 0) { + htmlBuilder.append(html, 0, headIndex + 6) + htmlBuilder.append(preloadScript) + htmlBuilder.append(html, headIndex + 6, html.length) + } else { + htmlBuilder.append("") + htmlBuilder.append(preloadScript) + htmlBuilder.append("") + htmlBuilder.append(html) + } + + val result = htmlBuilder.toString() + + val styleEndIndex = result.indexOf("") + return if (styleEndIndex >= 0) { + val insertIndex = styleEndIndex + 8 + buildString { + append(result, 0, insertIndex) + append(styleContent) + append(result, insertIndex, result.length) + append(jsScript) + } + } else { + val headEndIndex = result.indexOf("") + if (headEndIndex >= 0) { + buildString { + append(result, 0, headEndIndex) + append(styleContent) + append(result, headEndIndex, result.length) + append(jsScript) + } + } else { + buildString { + append(styleContent) + append(result) + append(jsScript) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt b/app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt index c16ef87f9..44fe70f59 100644 --- a/app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt +++ b/app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt @@ -9,6 +9,7 @@ import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.RssReadRecord import io.legado.app.data.entities.RssSource import io.legado.app.help.JsExtensions +import io.legado.app.model.analyzeRule.AnalyzeRule import io.legado.app.ui.association.AddToBookshelfDialog import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.login.SourceLoginActivity @@ -72,10 +73,19 @@ open class RssJsExtensions(activity: AppCompatActivity?, source: BaseSource?) : activityRef.get()?.showDialogFragment(PhotoDialog(src, getSource()?.getKey())) } + @JvmOverloads + fun getStringList(rule: String?, mContent: Any? = null, isUrl: Boolean = false): List? { + return AnalyzeRule(null, source = getSource()).getStringList(rule, mContent, isUrl) + } + + @JvmOverloads + fun getString(ruleStr: String?, mContent: Any? = null, isUrl: Boolean = false): String { + return AnalyzeRule(null, source = getSource()).getString(ruleStr, mContent, isUrl) + } @JavascriptInterface @JvmOverloads - fun open(name: String, url: String? = null, title: String? = null, origin: String? = null) { + open fun open(name: String, url: String? = null, title: String? = null, origin: String? = null) { val activity = activityRef.get() ?: return activity.lifecycleScope.launch(IO) { val source = getSource() ?: return@launch @@ -151,7 +161,7 @@ open class RssJsExtensions(activity: AppCompatActivity?, source: BaseSource?) : origin = sourceUrl, readTime = System.currentTimeMillis() ) - appDb.rssReadRecordDao.insertRecord(rssReadRecord) //留下历史记录 + appDb.rssReadRecordDao.insertRecord(rssReadRecord) withContext(Main) { activity.startActivity( MainActivity.createRssReadIntent( diff --git a/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugActivity.kt b/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugActivity.kt index 46131d471..3c9cbb243 100644 --- a/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugActivity.kt +++ b/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugActivity.kt @@ -1,16 +1,17 @@ package io.legado.app.ui.rss.source.debug +import android.annotation.SuppressLint import android.os.Bundle import android.view.Menu import android.view.MenuItem -import android.widget.SearchView +import android.view.View +import androidx.appcompat.widget.SearchView import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.databinding.ActivitySourceDebugBinding -//import io.legado.app.lib.theme.accentColor -//import io.legado.app.lib.theme.primaryColor +import io.legado.app.help.source.sortUrls import io.legado.app.ui.widget.dialog.TextDialog import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.gone @@ -19,6 +20,8 @@ import io.legado.app.utils.toastOnUi import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible import kotlinx.coroutines.launch +import splitties.views.onClick +import splitties.views.onLongClick class RssSourceDebugActivity : VMBaseActivity() { @@ -28,10 +31,12 @@ class RssSourceDebugActivity : VMBaseActivity lifecycleScope.launch { adapter.addItem(msg) @@ -41,7 +46,7 @@ class RssSourceDebugActivity : VMBaseActivity(R.id.search_view).gone() + val searchView = binding.titleBar.findViewById(R.id.search_view) + searchView.visible() + searchView.isSubmitButtonEnabled = true + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + searchView.clearFocus() + startDebug(query ?: "") + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + return false + } + }) } - private fun startDebug() { + @SuppressLint("SetTextI18n") + private fun initHelpView() { + binding.help.visible() + binding.textMy.onClick { + binding.titleBar.findViewById(R.id.search_view).setQuery(binding.textMy.text, true) + } + binding.textXt.onClick { + binding.titleBar.findViewById(R.id.search_view).setQuery(binding.textXt.text, true) + } + binding.textFx.onClick { + if (!binding.textFx.text.startsWith("ERROR:")) { + binding.titleBar.findViewById(R.id.search_view).setQuery(binding.textFx.text, true) + } + } + binding.textContent.onClick { + val query = binding.titleBar.findViewById(R.id.search_view).query + if (!query.isNullOrBlank()) { + binding.titleBar.findViewById(R.id.search_view).setQuery(query, true) + } + } + initSortKinds() + } + + private fun initSortKinds() { + lifecycleScope.launch { + val sortKinds = viewModel.rssSource?.sortUrls()?.filter { + it.second.isNotBlank() + } + sortKinds?.firstOrNull()?.let { + binding.textFx.text = "${it.first}::${it.second}" + if (it.first.startsWith("ERROR:")) { + adapter.addItem("获取发现出错\n${it.second}") + binding.titleBar.findViewById(R.id.search_view).clearFocus() + return@launch + } + } + } + } + + private fun startDebug(key: String) { adapter.clearItems() + binding.help.gone() // 关闭调试选项面板 viewModel.rssSource?.let { binding.rotateLoading.visible() - viewModel.startDebug(it) + viewModel.startDebug(it, key) } ?: toastOnUi(R.string.error_no_source) } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugModel.kt b/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugModel.kt index b992ab8ca..ecb69e477 100644 --- a/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugModel.kt +++ b/app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugModel.kt @@ -34,6 +34,15 @@ class RssSourceDebugModel(application: Application) : BaseViewModel(application) } } + fun startDebug(source: RssSource, key: String) { + execute { + Debug.callback = this@RssSourceDebugModel + Debug.startDebug(this, source, key) + }.onStart { + // 可以在这里添加开始回调 + } + } + override fun printLog(state: Int, msg: String) { when (state) { 10 -> listSrc = msg diff --git a/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt b/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt index 1bd246ffa..2b1cb23a0 100644 --- a/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt +++ b/app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt @@ -57,6 +57,7 @@ class RssSourceEditActivity : } private val adapter by lazy { RssSourceEditAdapter() } private val sourceEntities: ArrayList = ArrayList() + private val startEntities: ArrayList = ArrayList() private val listEntities: ArrayList = ArrayList() private val webViewEntities: ArrayList = ArrayList() private val selectDoc = registerForActivityResult(HandleFileContract()) { @@ -156,6 +157,8 @@ class RssSourceEditActivity : ErrorCorrectionLevel.L ) + R.id.menu_log -> showDialogFragment() + R.id.menu_help -> showHelp("ruleHelp") } return super.onCompatOptionsItemSelected(item) @@ -165,6 +168,9 @@ class RssSourceEditActivity : binding.tabLayout.addTab(binding.tabLayout.newTab().apply { setText(R.string.source_tab_base) }) + binding.tabLayout.addTab(binding.tabLayout.newTab().apply { + setText(R.string.source_tab_start) + }) binding.tabLayout.addTab(binding.tabLayout.newTab().apply { setText(R.string.source_tab_list) }) @@ -199,8 +205,9 @@ class RssSourceEditActivity : private fun setEditEntities(tabPosition: Int?) { when (tabPosition) { - 1 -> adapter.editEntities = listEntities - 2 -> adapter.editEntities = webViewEntities + 1 -> adapter.editEntities = startEntities + 2 -> adapter.editEntities = listEntities + 3 -> adapter.editEntities = webViewEntities else -> adapter.editEntities = sourceEntities } binding.recyclerView.scrollToPosition(0) @@ -212,6 +219,21 @@ class RssSourceEditActivity : binding.cbIsEnable.isChecked = rs.enabled binding.cbSingleUrl.isChecked = rs.singleUrl binding.cbIsEnableCookie.isChecked = rs.enabledCookieJar == true + binding.cbIsEnablePreload.isChecked = rs.preload + val typeCount = binding.spType.adapter?.count ?: 0 + if (typeCount > 0 && rs.type !in 0.. 0) { + binding.spType.setSelection(rs.type) + } + val styleCount = binding.lyType.adapter?.count ?: 0 + if (styleCount > 0 && rs.articleStyle !in 0.. 0) { + binding.lyType.setSelection(rs.articleStyle) + } } sourceEntities.clear() sourceEntities.apply { @@ -220,6 +242,7 @@ class RssSourceEditActivity : add(EditEntity("sourceIcon", rs.sourceIcon, R.string.source_icon)) add(EditEntity("sourceGroup", rs.sourceGroup, R.string.source_group)) add(EditEntity("sourceComment", rs.sourceComment, R.string.comment)) + add(EditEntity("searchUrl", rs.searchUrl, R.string.r_search_url)) add(EditEntity("sortUrl", rs.sortUrl, R.string.sort_url)) add(EditEntity("loginUrl", rs.loginUrl, R.string.login_url)) add(EditEntity("loginUi", rs.loginUi, R.string.login_ui)) @@ -230,6 +253,13 @@ class RssSourceEditActivity : add(EditEntity("concurrentRate", rs.concurrentRate, R.string.concurrent_rate)) add(EditEntity("jsLib", rs.jsLib, "jsLib")) } + startEntities.clear() + startEntities.apply { + add(EditEntity("startHtml", rs.startHtml, R.string.r_startHtml)) + add(EditEntity("startStyle", rs.startStyle, R.string.r_startStyle)) + add(EditEntity("startJs", rs.startJs, R.string.r_startJs)) + add(EditEntity("preloadJs", rs.preloadJs, R.string.r_preloadJs)) + } listEntities.clear() listEntities.apply { add(EditEntity("ruleArticles", rs.ruleArticles, R.string.r_articles)) @@ -258,6 +288,22 @@ class RssSourceEditActivity : EditEntity.ViewType.checkBox ) ) + add( + EditEntity( + "showWebLog", + rs.showWebLog.toString(), + R.string.load_with_web_log, + EditEntity.ViewType.checkBox + ) + ) + add( + EditEntity( + "cacheFirst", + rs.cacheFirst.toString(), + R.string.cache_first, + EditEntity.ViewType.checkBox + ) + ) add(EditEntity("ruleContent", rs.ruleContent, R.string.r_content)) add(EditEntity("style", rs.style, R.string.r_style)) add(EditEntity("injectJs", rs.injectJs, R.string.r_inject_js)) @@ -280,6 +326,9 @@ class RssSourceEditActivity : source.enabled = binding.cbIsEnable.isChecked source.singleUrl = binding.cbSingleUrl.isChecked source.enabledCookieJar = binding.cbIsEnableCookie.isChecked + source.preload = binding.cbIsEnablePreload.isChecked + source.type = binding.spType.selectedItemPosition + source.articleStyle = binding.lyType.selectedItemPosition sourceEntities.forEach { it.value = it.value?.takeIf { s -> s.isNotBlank() } when (it.key) { @@ -295,10 +344,20 @@ class RssSourceEditActivity : "header" -> source.header = it.value "variableComment" -> source.variableComment = it.value "concurrentRate" -> source.concurrentRate = it.value + "searchUrl" -> source.searchUrl = it.value "sortUrl" -> source.sortUrl = it.value "jsLib" -> source.jsLib = it.value } } + startEntities.forEach { + it.value = it.value?.takeIf { s -> s.isNotBlank() } + when (it.key) { + "startHtml" -> source.startHtml = it.value + "startStyle" -> source.startStyle = it.value + "startJs" -> source.startJs = it.value + "preloadJs" -> source.preloadJs = it.value + } + } listEntities.forEach { it.value = it.value?.takeIf { s -> s.isNotBlank() } when (it.key) { @@ -327,6 +386,8 @@ class RssSourceEditActivity : when (it.key) { "enableJs" -> source.enableJs = it.value.isTrue() "loadWithBaseUrl" -> source.loadWithBaseUrl = it.value.isTrue() + "showWebLog" -> source.showWebLog = it.value.isTrue() + "cacheFirst" -> source.cacheFirst = it.value.isTrue() "ruleContent" -> source.ruleContent = viewModel.ruleComplete(it.value, source.ruleArticles) diff --git a/app/src/main/java/io/legado/app/ui/theme/AppBackground.kt b/app/src/main/java/io/legado/app/ui/theme/AppBackground.kt index 694ba71f7..2771a5b43 100644 --- a/app/src/main/java/io/legado/app/ui/theme/AppBackground.kt +++ b/app/src/main/java/io/legado/app/ui/theme/AppBackground.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import io.legado.app.ui.config.themeConfig.ThemeConfig +import org.koin.compose.koinInject @Composable fun AppBackground( @@ -29,10 +30,10 @@ fun AppBackground( AsyncImage( model = bgImagePath, contentDescription = null, + imageLoader = koinInject(), modifier = Modifier .fillMaxSize() .blur(blur.dp), - //TODO:低版本安卓使用coil-transformations contentScale = ContentScale.Crop ) } 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 a6320aa2b..dabcfd67f 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 @@ -7,17 +7,145 @@ import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MotionScheme import androidx.compose.material3.Shapes import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.font.FontFamily +import android.graphics.Typeface +import android.net.Uri import io.legado.app.ui.config.themeConfig.ThemeConfig -import io.legado.app.ui.theme.LegadoTheme import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.ThemeController +import io.legado.app.ui.theme.CustomColorScheme +import io.legado.app.ui.theme.ThemeResolver + +// 用户自定义的颜色集合 +data class UserColorPalette( + val primaryColor: Color, // 用户定义的主题色 + val secondaryColor: Color, // 用户定义的次要主题色 + val backgroundColor: Color, // 用户定义的背景色 + val primaryFontColor: Color, // 用户定义的主要字体色 + val secondaryFontColor: Color, // 用户定义的次要字体色 + val labelContainerColor: Color // 用户定义的标签容器色 +) + +// 将用户自定义颜色映射到 Material Theme 的 ColorScheme +fun generateColorScheme(userPalette: UserColorPalette, isDark: Boolean): androidx.compose.material3.ColorScheme { + return if (isDark) { + darkColorScheme( + primary = userPalette.primaryColor, + onPrimary = userPalette.primaryFontColor, // 关键映射:主要字体色 -> onPrimary + primaryContainer = userPalette.labelContainerColor, // 关键映射:标签容器色 -> primaryContainer + onPrimaryContainer = userPalette.primaryFontColor, + + secondary = userPalette.secondaryColor, + onSecondary = userPalette.secondaryFontColor, + secondaryContainer = userPalette.labelContainerColor, + onSecondaryContainer = userPalette.secondaryFontColor, + + tertiary = userPalette.secondaryColor, // 可以将次要主题色也用作第三色 + onTertiary = userPalette.secondaryFontColor, + + background = userPalette.backgroundColor, + surface = userPalette.backgroundColor, + onBackground = userPalette.primaryFontColor, + onSurface = userPalette.primaryFontColor, // 关键映射:主要字体色 -> onSurface + surfaceVariant = userPalette.labelContainerColor, + onSurfaceVariant = userPalette.secondaryFontColor, // 关键映射:次要字体色 -> onSurfaceVariant + + // 其他颜色 + error = Color(0xFFB3261E), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFF9DEDC), + onErrorContainer = Color(0xFF410E0B), + outline = Color(0xFF79747E), + outlineVariant = Color(0xFFC4C7C5), + scrim = Color(0xFF000000), + + // 表面颜色变体 + surfaceBright = userPalette.backgroundColor, + surfaceDim = userPalette.backgroundColor, + surfaceContainer = userPalette.backgroundColor, + surfaceContainerHigh = userPalette.backgroundColor, + surfaceContainerHighest = userPalette.backgroundColor, + surfaceContainerLow = userPalette.labelContainerColor, + surfaceContainerLowest = userPalette.backgroundColor, + + // 固定颜色 + primaryFixed = userPalette.primaryColor, + primaryFixedDim = userPalette.primaryColor.copy(alpha = 0.8f), + onPrimaryFixed = userPalette.primaryFontColor, + onPrimaryFixedVariant = userPalette.primaryFontColor, + secondaryFixed = userPalette.secondaryColor, + secondaryFixedDim = userPalette.secondaryColor.copy(alpha = 0.8f), + onSecondaryFixed = userPalette.secondaryFontColor, + onSecondaryFixedVariant = userPalette.secondaryFontColor, + tertiaryFixed = userPalette.secondaryColor, + tertiaryFixedDim = userPalette.secondaryColor.copy(alpha = 0.8f), + onTertiaryFixed = userPalette.secondaryFontColor, + onTertiaryFixedVariant = userPalette.secondaryFontColor + ) + } else { + lightColorScheme( + primary = userPalette.primaryColor, + onPrimary = userPalette.primaryFontColor, + primaryContainer = userPalette.labelContainerColor, + onPrimaryContainer = userPalette.primaryFontColor, + + secondary = userPalette.secondaryColor, + onSecondary = userPalette.secondaryFontColor, + secondaryContainer = userPalette.labelContainerColor, + onSecondaryContainer = userPalette.secondaryFontColor, + + tertiary = userPalette.secondaryColor, + onTertiary = userPalette.secondaryFontColor, + + background = userPalette.backgroundColor, + surface = userPalette.backgroundColor, + onBackground = userPalette.primaryFontColor, + onSurface = userPalette.primaryFontColor, + surfaceVariant = userPalette.labelContainerColor, + onSurfaceVariant = userPalette.secondaryFontColor, + + // 其他颜色 + error = Color(0xFFB3261E), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFF9DEDC), + onErrorContainer = Color(0xFF410E0B), + outline = Color(0xFF79747E), + outlineVariant = Color(0xFFC4C7C5), + scrim = Color(0xFF000000), + + // 表面颜色变体 + surfaceBright = userPalette.backgroundColor, + surfaceDim = userPalette.backgroundColor, + surfaceContainer = userPalette.backgroundColor, + surfaceContainerHigh = userPalette.backgroundColor, + surfaceContainerHighest = userPalette.backgroundColor, + surfaceContainerLow = userPalette.labelContainerColor, + surfaceContainerLowest = userPalette.backgroundColor, + + // 固定颜色 + primaryFixed = userPalette.primaryColor, + primaryFixedDim = userPalette.primaryColor.copy(alpha = 0.8f), + onPrimaryFixed = userPalette.primaryFontColor, + onPrimaryFixedVariant = userPalette.primaryFontColor, + secondaryFixed = userPalette.secondaryColor, + secondaryFixedDim = userPalette.secondaryColor.copy(alpha = 0.8f), + onSecondaryFixed = userPalette.secondaryFontColor, + onSecondaryFixedVariant = userPalette.secondaryFontColor, + tertiaryFixed = userPalette.secondaryColor, + tertiaryFixedDim = userPalette.secondaryColor.copy(alpha = 0.8f), + onTertiaryFixed = userPalette.secondaryFontColor, + onTertiaryFixedVariant = userPalette.secondaryFontColor + ) + } +} @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -35,12 +163,47 @@ fun AppTheme( val useMiuixMonet = ThemeConfig.useMiuixMonet val customPrimary = ThemeConfig.cPrimary val customNightPrimary = ThemeConfig.cNPrimary + val appFontPath = ThemeConfig.appFontPath + + val enableDeepPersonalization = ThemeConfig.enableDeepPersonalization + val themeColor = ThemeConfig.themeColor + val secondaryThemeColor = ThemeConfig.secondaryThemeColor + val primaryTextColor = ThemeConfig.primaryTextColor + val secondaryTextColor = ThemeConfig.secondaryTextColor + val themeBackgroundColor = ThemeConfig.themeBackgroundColor + val customLabelContainerColor = ThemeConfig.labelContainerColor + val colorSchemeMode = ThemeResolver.resolveColorSchemeMode(themeModeValue) val miuixColorSchemeMode = remember(themeModeValue, useMiuixMonet) { ThemeResolver.resolveMiuixColorSchemeMode(themeModeValue, useMiuixMonet) } - val paletteStyle = - remember(paletteStyleValue) { ThemeResolver.resolvePaletteStyle(paletteStyleValue) } + + // 加载自定义字体 + val customFontFamily = remember(ThemeConfig.appFontPath, context) { + if (!ThemeConfig.appFontPath.isNullOrEmpty()) { + try { + val uri = Uri.parse(ThemeConfig.appFontPath) + val typeface: Typeface? = if (uri.scheme == "content") { + context.contentResolver.openFileDescriptor(uri, "r")?.use { + Typeface.Builder(it.fileDescriptor).build() + } + } else { + Typeface.createFromFile(uri.path) + } + typeface?.let { + FontFamily(it) + } + } catch (e: Exception) { + null + } + } else { + null + } + } + + val paletteStyle = remember(paletteStyleValue) { + ThemeResolver.resolvePaletteStyle(paletteStyleValue) + } val colorScheme = remember( @@ -50,32 +213,53 @@ fun AppTheme( isPureBlack, customPrimary, customNightPrimary, + enableDeepPersonalization, + themeColor, + secondaryThemeColor, + primaryTextColor, + secondaryTextColor, + themeBackgroundColor, + customLabelContainerColor, paletteStyleValue, materialVersion ) { - val customSeedColor = if (darkTheme) customNightPrimary else customPrimary - ThemeEngine.getColorScheme( - context = context, - mode = appThemeMode, - darkTheme = darkTheme, - isAmoled = isPureBlack, - paletteStyle = paletteStyleValue, - materialVersion = materialVersion, - customSeedColor = customSeedColor - ) + if (enableDeepPersonalization && + (themeColor != 0 || + secondaryThemeColor != 0 || + primaryTextColor != 0 || + secondaryTextColor != 0 || + themeBackgroundColor != 0 || + customLabelContainerColor != 0)) { + val userPalette = UserColorPalette( + primaryColor = if (themeColor != 0) Color(themeColor) else Color(0xFF6750A4), + secondaryColor = if (secondaryThemeColor != 0) Color(secondaryThemeColor) else Color(0xFF625B71), + backgroundColor = if (themeBackgroundColor != 0) Color(themeBackgroundColor) else Color(0xFFFEF7FF), + primaryFontColor = if (primaryTextColor != 0) Color(primaryTextColor) else Color(0xFF1C1B1F), + secondaryFontColor = if (secondaryTextColor != 0) Color(secondaryTextColor) else Color(0xFF49454F), + labelContainerColor = if (customLabelContainerColor != 0) Color(customLabelContainerColor) else Color(0xFFF7F2FA) + ) + generateColorScheme(userPalette, darkTheme) + } else { + val customSeedColor = if (darkTheme) customNightPrimary else customPrimary + ThemeEngine.getColorScheme( + context = context, + mode = appThemeMode, + darkTheme = darkTheme, + isAmoled = isPureBlack, + paletteStyle = paletteStyleValue, + materialVersion = materialVersion, + customSeedColor = customSeedColor + ) + } } - val customSeedColor = remember( - darkTheme, - customPrimary, - customNightPrimary, - colorScheme.primary - ) { - val seed = if (darkTheme) customNightPrimary else customPrimary - if (seed != 0) Color(seed) else colorScheme.primary - } - val themeSeedColor = remember(appThemeMode, customSeedColor, colorScheme.primary) { - if (appThemeMode == AppThemeMode.Custom) customSeedColor else colorScheme.primary + val themeSeedColor = remember(appThemeMode, colorScheme.primary) { + if (appThemeMode == AppThemeMode.Custom) { + val seed = if (darkTheme) customNightPrimary else customPrimary + if (seed != 0) Color(seed) else colorScheme.primary + } else { + colorScheme.primary + } } val miuixPaletteStyle = remember(paletteStyleValue) { ThemeResolver.resolveMiuixPaletteStyle(paletteStyleValue) @@ -111,7 +295,9 @@ fun AppTheme( themeColors.useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ) { - colorResource(id = android.R.color.system_accent1_500) + // colorResource 只能在 @Composable 上下文中调用 + // 这里使用一个固定颜色作为替代 + Color(0xFF6750A4) } else { themeSeedColor } @@ -142,13 +328,57 @@ fun AppTheme( MiuixTheme(controller = controller) { val miuixStyles = MiuixTheme.textStyles - val legadoTypography = remember(miuixStyles) { - miuixStylesToM3Typography(miuixStyles).toLegadoTypography() + val legadoTypography = remember(miuixStyles, customFontFamily) { + val typography = miuixStylesToM3Typography(miuixStyles) + val baseLegadoTypography = typography.toLegadoTypography() + if (customFontFamily != null) { + baseLegadoTypography.copy( + headlineLarge = baseLegadoTypography.headlineLarge.copy(fontFamily = customFontFamily), + headlineLargeEmphasized = baseLegadoTypography.headlineLargeEmphasized.copy(fontFamily = customFontFamily), + headlineMedium = baseLegadoTypography.headlineMedium.copy(fontFamily = customFontFamily), + headlineMediumEmphasized = baseLegadoTypography.headlineMediumEmphasized.copy(fontFamily = customFontFamily), + headlineSmall = baseLegadoTypography.headlineSmall.copy(fontFamily = customFontFamily), + headlineSmallEmphasized = baseLegadoTypography.headlineSmallEmphasized.copy(fontFamily = customFontFamily), + titleLarge = baseLegadoTypography.titleLarge.copy(fontFamily = customFontFamily), + titleLargeEmphasized = baseLegadoTypography.titleLargeEmphasized.copy(fontFamily = customFontFamily), + titleMedium = baseLegadoTypography.titleMedium.copy(fontFamily = customFontFamily), + titleMediumEmphasized = baseLegadoTypography.titleMediumEmphasized.copy(fontFamily = customFontFamily), + titleSmall = baseLegadoTypography.titleSmall.copy(fontFamily = customFontFamily), + titleSmallEmphasized = baseLegadoTypography.titleSmallEmphasized.copy(fontFamily = customFontFamily), + bodyLarge = baseLegadoTypography.bodyLarge.copy(fontFamily = customFontFamily), + bodyLargeEmphasized = baseLegadoTypography.bodyLargeEmphasized.copy(fontFamily = customFontFamily), + bodyMedium = baseLegadoTypography.bodyMedium.copy(fontFamily = customFontFamily), + bodyMediumEmphasized = baseLegadoTypography.bodyMediumEmphasized.copy(fontFamily = customFontFamily), + bodySmall = baseLegadoTypography.bodySmall.copy(fontFamily = customFontFamily), + bodySmallEmphasized = baseLegadoTypography.bodySmallEmphasized.copy(fontFamily = customFontFamily), + labelLarge = baseLegadoTypography.labelLarge.copy(fontFamily = customFontFamily), + labelLargeEmphasized = baseLegadoTypography.labelLargeEmphasized.copy(fontFamily = customFontFamily), + labelMedium = baseLegadoTypography.labelMedium.copy(fontFamily = customFontFamily), + labelMediumEmphasized = baseLegadoTypography.labelMediumEmphasized.copy(fontFamily = customFontFamily), + labelSmall = baseLegadoTypography.labelSmall.copy(fontFamily = customFontFamily), + labelSmallEmphasized = baseLegadoTypography.labelSmallEmphasized.copy(fontFamily = customFontFamily) + ) + } else { + baseLegadoTypography + } } val miuixColorScheme = MiuixTheme.colorScheme val mappedColorScheme = remember(miuixColorScheme) { + val customBgColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.themeBackgroundColor != 0) { + Color(ThemeConfig.themeBackgroundColor) + } else { + miuixColorScheme.background + } + val customFontColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.primaryTextColor != 0) { + Color(ThemeConfig.primaryTextColor) + } else { + miuixColorScheme.onSurface + } + val customTopBarColor = miuixColorScheme.surface + val customNavBarColor = miuixColorScheme.surface + LegadoColorScheme( primary = miuixColorScheme.primary, onPrimary = miuixColorScheme.onPrimary, @@ -166,13 +396,13 @@ fun AppTheme( tertiaryContainer = miuixColorScheme.primaryContainer, onTertiaryContainer = miuixColorScheme.primaryVariant, - background = miuixColorScheme.background, - onBackground = miuixColorScheme.onBackground, + background = customBgColor, + onBackground = customFontColor, surface = miuixColorScheme.surface, - onSurface = miuixColorScheme.onSurface, + onSurface = customFontColor, surfaceVariant = miuixColorScheme.surfaceVariant, - onSurfaceVariant = miuixColorScheme.onSurfaceSecondary, + onSurfaceVariant = customFontColor, surfaceTint = miuixColorScheme.primary, inverseSurface = miuixColorScheme.onSurface, inverseOnSurface = miuixColorScheme.surface, @@ -191,8 +421,7 @@ fun AppTheme( surfaceContainer = miuixColorScheme.surfaceContainer, surfaceContainerHigh = miuixColorScheme.surfaceContainerHigh, surfaceContainerHighest = miuixColorScheme.surfaceContainerHighest, - surfaceContainerLow = miuixColorScheme.secondaryContainer.copy(alpha = 0.32f) - .compositeOver(miuixColorScheme.surface), + surfaceContainerLow = miuixColorScheme.secondaryContainer.copy(alpha = 0.32f), surfaceContainerLowest = miuixColorScheme.background, primaryFixed = miuixColorScheme.primaryContainer, @@ -208,10 +437,9 @@ fun AppTheme( onTertiaryFixed = miuixColorScheme.onTertiaryContainer, onTertiaryFixedVariant = miuixColorScheme.onTertiaryContainer, - cardContainer = miuixColorScheme.primaryContainer.copy(alpha = 0.32f) - .compositeOver(miuixColorScheme.surfaceContainer), + cardContainer = miuixColorScheme.disabledPrimary, onCardContainer = miuixColorScheme.primary, - onSheetContent = miuixColorScheme.surface.copy(alpha = 0.5f), + onSheetContent = miuixColorScheme.surface.copy(alpha = 0.5f) ) } @@ -223,17 +451,86 @@ fun AppTheme( } } } else { - val materialTypography = remember { Typography() } + val materialTypography = remember(customFontFamily) { + if (customFontFamily != null) { + Typography( + headlineLarge = Typography().headlineLarge.copy(fontFamily = customFontFamily), + headlineMedium = Typography().headlineMedium.copy(fontFamily = customFontFamily), + headlineSmall = Typography().headlineSmall.copy(fontFamily = customFontFamily), + titleLarge = Typography().titleLarge.copy(fontFamily = customFontFamily), + titleMedium = Typography().titleMedium.copy(fontFamily = customFontFamily), + titleSmall = Typography().titleSmall.copy(fontFamily = customFontFamily), + bodyLarge = Typography().bodyLarge.copy(fontFamily = customFontFamily), + bodyMedium = Typography().bodyMedium.copy(fontFamily = customFontFamily), + bodySmall = Typography().bodySmall.copy(fontFamily = customFontFamily), + labelLarge = Typography().labelLarge.copy(fontFamily = customFontFamily), + labelMedium = Typography().labelMedium.copy(fontFamily = customFontFamily), + labelSmall = Typography().labelSmall.copy(fontFamily = customFontFamily) + ) + } else { + Typography() + } + } MaterialExpressiveTheme( colorScheme = colorScheme, typography = materialTypography, motionScheme = MotionScheme.expressive(), shapes = Shapes() ) { - val legadoTypography = remember(materialTypography) { - materialTypography.toLegadoTypography() + val legadoTypography = remember(materialTypography, customFontFamily) { + val baseLegadoTypography = materialTypography.toLegadoTypography() + if (customFontFamily != null) { + baseLegadoTypography.copy( + headlineLarge = baseLegadoTypography.headlineLarge.copy(fontFamily = customFontFamily), + headlineLargeEmphasized = baseLegadoTypography.headlineLargeEmphasized.copy(fontFamily = customFontFamily), + headlineMedium = baseLegadoTypography.headlineMedium.copy(fontFamily = customFontFamily), + headlineMediumEmphasized = baseLegadoTypography.headlineMediumEmphasized.copy(fontFamily = customFontFamily), + headlineSmall = baseLegadoTypography.headlineSmall.copy(fontFamily = customFontFamily), + headlineSmallEmphasized = baseLegadoTypography.headlineSmallEmphasized.copy(fontFamily = customFontFamily), + titleLarge = baseLegadoTypography.titleLarge.copy(fontFamily = customFontFamily), + titleLargeEmphasized = baseLegadoTypography.titleLargeEmphasized.copy(fontFamily = customFontFamily), + titleMedium = baseLegadoTypography.titleMedium.copy(fontFamily = customFontFamily), + titleMediumEmphasized = baseLegadoTypography.titleMediumEmphasized.copy(fontFamily = customFontFamily), + titleSmall = baseLegadoTypography.titleSmall.copy(fontFamily = customFontFamily), + titleSmallEmphasized = baseLegadoTypography.titleSmallEmphasized.copy(fontFamily = customFontFamily), + bodyLarge = baseLegadoTypography.bodyLarge.copy(fontFamily = customFontFamily), + bodyLargeEmphasized = baseLegadoTypography.bodyLargeEmphasized.copy(fontFamily = customFontFamily), + bodyMedium = baseLegadoTypography.bodyMedium.copy(fontFamily = customFontFamily), + bodyMediumEmphasized = baseLegadoTypography.bodyMediumEmphasized.copy(fontFamily = customFontFamily), + bodySmall = baseLegadoTypography.bodySmall.copy(fontFamily = customFontFamily), + bodySmallEmphasized = baseLegadoTypography.bodySmallEmphasized.copy(fontFamily = customFontFamily), + labelLarge = baseLegadoTypography.labelLarge.copy(fontFamily = customFontFamily), + labelLargeEmphasized = baseLegadoTypography.labelLargeEmphasized.copy(fontFamily = customFontFamily), + labelMedium = baseLegadoTypography.labelMedium.copy(fontFamily = customFontFamily), + labelMediumEmphasized = baseLegadoTypography.labelMediumEmphasized.copy(fontFamily = customFontFamily), + labelSmall = baseLegadoTypography.labelSmall.copy(fontFamily = customFontFamily), + labelSmallEmphasized = baseLegadoTypography.labelSmallEmphasized.copy(fontFamily = customFontFamily) + ) + } else { + baseLegadoTypography + } + } + val semanticColors = remember(colorScheme) { + val customBgColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.themeBackgroundColor != 0) { + Color(ThemeConfig.themeBackgroundColor) + } else { + colorScheme.background + } + val customFontColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.primaryTextColor != 0) { + Color(ThemeConfig.primaryTextColor) + } else { + colorScheme.onSurface + } + val customTopBarColor = colorScheme.surface + val customNavBarColor = colorScheme.surface + + colorScheme.toLegadoColorScheme( + customBgColor = customBgColor, + customFontColor = customFontColor, + customTopBarColor = customTopBarColor, + customNavBarColor = customNavBarColor + ) } - val semanticColors = remember(colorScheme) { colorScheme.toLegadoColorScheme() } CompositionLocalProvider( LocalLegadoTypography provides legadoTypography, diff --git a/app/src/main/java/io/legado/app/ui/theme/HazeStyle.kt b/app/src/main/java/io/legado/app/ui/theme/HazeStyle.kt index 8eb331326..55973c9a6 100644 --- a/app/src/main/java/io/legado/app/ui/theme/HazeStyle.kt +++ b/app/src/main/java/io/legado/app/ui/theme/HazeStyle.kt @@ -1,5 +1,6 @@ package io.legado.app.ui.theme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.chrisbanes.haze.HazeProgressive @@ -9,6 +10,9 @@ import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.theme.hazeStyle.HazeLegado +import io.legado.app.ui.theme.ThemeResolver +import io.legado.app.ui.widget.components.GlassDefaults +import top.yukonga.miuix.kmp.theme.MiuixTheme /** * 自动感知全局配置的 HazeSource @@ -27,14 +31,19 @@ fun Modifier.responsiveHazeEffect( ): Modifier { val enableBlur = ThemeConfig.enableBlur val enableProgressiveBlur = ThemeConfig.enableProgressiveBlur + val composeEngine = LegadoTheme.composeEngine + val containerColor = GlassDefaults.secondaryColorOr { + if (ThemeResolver.isMiuixEngine(composeEngine)) MiuixTheme.colorScheme.surface + else MaterialTheme.colorScheme.surface + } if (!enableBlur) return this - val style = if (enableProgressiveBlur) { - HazeLegado.ultraThin() - } else { - HazeLegado.regular() - } + val style = HazeLegado.custom( + containerColor = containerColor, + blurRadius = ThemeConfig.topBarBlurRadius, + blurAlpha = ThemeConfig.topBarBlurAlpha + ) return this.hazeEffect( state = state, @@ -57,10 +66,15 @@ fun Modifier.responsiveHazeEffectFixedStyle( state: HazeState ): Modifier { val enableBlur = ThemeConfig.enableBlur + val composeEngine = LegadoTheme.composeEngine + val containerColor = GlassDefaults.secondaryColorOr { + if (ThemeResolver.isMiuixEngine(composeEngine)) MiuixTheme.colorScheme.surface + else MaterialTheme.colorScheme.surface + } if (!enableBlur) return this - val style = HazeLegado.ultraThinPlus() + val style = HazeLegado.ultraThinPlus(containerColor = containerColor) return this.hazeEffect( state = state, @@ -80,10 +94,17 @@ fun Modifier.responsiveHazeEffectFixedStyle( @OptIn(ExperimentalHazeMaterialsApi::class) @Composable fun Modifier.regularHazeEffect(state: HazeState): Modifier { - if (!ThemeConfig.enableBlur) return this + val enableBlur = ThemeConfig.enableBlur + val composeEngine = LegadoTheme.composeEngine + val containerColor = GlassDefaults.secondaryColorOr { + if (ThemeResolver.isMiuixEngine(composeEngine)) MiuixTheme.colorScheme.surface + else MaterialTheme.colorScheme.surface + } + + if (!enableBlur) return this return this.hazeEffect( state = state, - style = HazeLegado.ultraThin() + style = HazeLegado.ultraThin(containerColor = containerColor) ) } diff --git a/app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt b/app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt index eb569123f..c950b9929 100644 --- a/app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt +++ b/app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt @@ -17,7 +17,12 @@ import top.yukonga.miuix.kmp.theme.ColorSchemeMode import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.ThemeController -fun ColorScheme.toLegadoColorScheme(): LegadoColorScheme { +fun ColorScheme.toLegadoColorScheme( + customBgColor: Color = background, + customFontColor: Color = onSurface, + customTopBarColor: Color = surface, + customNavBarColor: Color = surface +): LegadoColorScheme { return LegadoColorScheme( primary = primary, onPrimary = onPrimary, @@ -32,10 +37,10 @@ fun ColorScheme.toLegadoColorScheme(): LegadoColorScheme { onTertiary = onTertiary, tertiaryContainer = tertiaryContainer, onTertiaryContainer = onTertiaryContainer, - background = background, - onBackground = onBackground, + background = customBgColor, + onBackground = customFontColor, surface = surface, - onSurface = onSurface, + onSurface = customFontColor, surfaceVariant = surfaceVariant, onSurfaceVariant = onSurfaceVariant, surfaceTint = surfaceTint, @@ -73,6 +78,15 @@ fun ColorScheme.toLegadoColorScheme(): LegadoColorScheme { ) } +fun ColorScheme.toLegadoColorScheme(): LegadoColorScheme { + return toLegadoColorScheme( + customBgColor = background, + customFontColor = onSurface, + customTopBarColor = surface, + customNavBarColor = surface + ) +} + @Composable fun ProvideColorSchemeOverride( colorScheme: ColorScheme, diff --git a/app/src/main/java/io/legado/app/ui/theme/Typography.kt b/app/src/main/java/io/legado/app/ui/theme/Typography.kt index 327d22bd3..93ff284d1 100644 --- a/app/src/main/java/io/legado/app/ui/theme/Typography.kt +++ b/app/src/main/java/io/legado/app/ui/theme/Typography.kt @@ -33,20 +33,6 @@ fun miuixStylesToM3Typography(miuixStyles: TextStyles): Typography { labelLarge = miuixStyles.footnote1.copy(fontSize = 14.sp), // 14.sp labelMedium = miuixStyles.footnote1, // 13.sp labelSmall = miuixStyles.footnote2, // 11.sp - - bodyLargeEmphasized = miuixStyles.paragraph.copy(fontWeight = FontWeight.Medium), - bodyMediumEmphasized = miuixStyles.body1.copy(fontWeight = FontWeight.Medium), - bodySmallEmphasized = miuixStyles.body2.copy( - fontSize = 12.sp, - fontWeight = FontWeight.Medium - ), - - labelLargeEmphasized = miuixStyles.button.copy( - fontSize = 14.sp, - fontWeight = FontWeight.Medium - ), - labelMediumEmphasized = miuixStyles.footnote1.copy(fontWeight = FontWeight.Medium), - labelSmallEmphasized = miuixStyles.footnote2.copy(fontWeight = FontWeight.Medium) ) } @@ -54,28 +40,28 @@ fun miuixStylesToM3Typography(miuixStyles: TextStyles): Typography { fun Typography.toLegadoTypography(): LegadoTypography { return LegadoTypography( headlineLarge = headlineLarge, - headlineLargeEmphasized = headlineLargeEmphasized, + headlineLargeEmphasized = headlineLarge.copy(fontWeight = FontWeight.Medium), headlineMedium = headlineMedium, - headlineMediumEmphasized = headlineMediumEmphasized, + headlineMediumEmphasized = headlineMedium.copy(fontWeight = FontWeight.Medium), headlineSmall = headlineSmall, - headlineSmallEmphasized = headlineSmallEmphasized, + headlineSmallEmphasized = headlineSmall.copy(fontWeight = FontWeight.Medium), titleLarge = titleLarge, - titleLargeEmphasized = titleLargeEmphasized, + titleLargeEmphasized = titleLarge.copy(fontWeight = FontWeight.Medium), titleMedium = titleMedium, - titleMediumEmphasized = titleMediumEmphasized, + titleMediumEmphasized = titleMedium.copy(fontWeight = FontWeight.Medium), titleSmall = titleSmall, - titleSmallEmphasized = titleSmallEmphasized, + titleSmallEmphasized = titleSmall.copy(fontWeight = FontWeight.Medium), bodyLarge = bodyLarge, - bodyLargeEmphasized = bodyLargeEmphasized, + bodyLargeEmphasized = bodyLarge.copy(fontWeight = FontWeight.Medium), bodyMedium = bodyMedium, - bodyMediumEmphasized = bodyMediumEmphasized, + bodyMediumEmphasized = bodyMedium.copy(fontWeight = FontWeight.Medium), bodySmall = bodySmall, - bodySmallEmphasized = bodySmallEmphasized, + bodySmallEmphasized = bodySmall.copy(fontWeight = FontWeight.Medium), labelLarge = labelLarge, - labelLargeEmphasized = labelLargeEmphasized, + labelLargeEmphasized = labelLarge.copy(fontWeight = FontWeight.Medium), labelMedium = labelMedium, - labelMediumEmphasized = labelMediumEmphasized, + labelMediumEmphasized = labelMedium.copy(fontWeight = FontWeight.Medium), labelSmall = labelSmall, - labelSmallEmphasized = labelSmallEmphasized + labelSmallEmphasized = labelSmall.copy(fontWeight = FontWeight.Medium) ) -} +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/theme/hazeStyle/HazeLegado.kt b/app/src/main/java/io/legado/app/ui/theme/hazeStyle/HazeLegado.kt index 3fbfd6a15..4442e8017 100644 --- a/app/src/main/java/io/legado/app/ui/theme/hazeStyle/HazeLegado.kt +++ b/app/src/main/java/io/legado/app/ui/theme/hazeStyle/HazeLegado.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.luminance import androidx.compose.ui.unit.dp import dev.chrisbanes.haze.HazeStyle import dev.chrisbanes.haze.HazeTint +import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.theme.LegadoTheme.composeEngine import io.legado.app.ui.theme.ThemeResolver import top.yukonga.miuix.kmp.theme.MiuixTheme @@ -30,8 +31,8 @@ object HazeLegado { containerColor: Color = if (ThemeResolver.isMiuixEngine(composeEngine)) MiuixTheme.colorScheme.surface else MaterialTheme.colorScheme.surface, ): HazeStyle = hazeLegado( containerColor = containerColor, - lightAlpha = 0.35f, - darkAlpha = 0.55f, + lightAlpha = ThemeConfig.topBarBlurAlpha / 100f * 0.35f / 0.73f, + darkAlpha = ThemeConfig.topBarBlurAlpha / 100f * 0.55f / 0.8f, ) @Composable @@ -40,16 +41,30 @@ object HazeLegado { containerColor: Color = if (ThemeResolver.isMiuixEngine(composeEngine)) MiuixTheme.colorScheme.surface else MaterialTheme.colorScheme.surface, ): HazeStyle = hazeLegado( containerColor = containerColor, - lightAlpha = 0.73f, - darkAlpha = 0.8f, + lightAlpha = ThemeConfig.topBarBlurAlpha / 100f, + darkAlpha = ThemeConfig.topBarBlurAlpha / 100f, + ) + + @Composable + @ReadOnlyComposable + fun custom( + containerColor: Color = if (ThemeResolver.isMiuixEngine(composeEngine)) MiuixTheme.colorScheme.surface else MaterialTheme.colorScheme.surface, + blurRadius: Int = ThemeConfig.topBarBlurRadius, + blurAlpha: Int = ThemeConfig.topBarBlurAlpha, + ): HazeStyle = hazeLegado( + containerColor = containerColor, + blurRadius = blurRadius, + lightAlpha = blurAlpha / 100f, + darkAlpha = blurAlpha / 100f, ) private fun hazeLegado( containerColor: Color, + blurRadius: Int = 24, lightAlpha: Float, darkAlpha: Float, ): HazeStyle = HazeStyle( - blurRadius = 24.dp, + blurRadius = blurRadius.dp, backgroundColor = containerColor, tint = HazeTint( containerColor.copy(alpha = if (containerColor.luminance() >= 0.5) lightAlpha else darkAlpha), diff --git a/app/src/main/java/io/legado/app/ui/widget/SimpleCounterView.kt b/app/src/main/java/io/legado/app/ui/widget/SimpleCounterView.kt index a8079a0ae..21bc6036e 100644 --- a/app/src/main/java/io/legado/app/ui/widget/SimpleCounterView.kt +++ b/app/src/main/java/io/legado/app/ui/widget/SimpleCounterView.kt @@ -51,7 +51,7 @@ class SimpleCounterView @JvmOverloads constructor( var max: Int get() = _max set(value) { - _max = value + _max = value.coerceAtLeast(1) _progress = _progress.coerceAtMost(_max) updateValue() } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/AppNavigationBar.kt b/app/src/main/java/io/legado/app/ui/widget/components/AppNavigationBar.kt index f20eb06a0..2eb5e2f03 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/AppNavigationBar.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/AppNavigationBar.kt @@ -1,18 +1,34 @@ package io.legado.app.ui.widget.components +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.BottomAppBarDefaults import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.LocalHazeState import io.legado.app.ui.theme.ThemeResolver +import io.legado.app.ui.theme.regularHazeEffect import top.yukonga.miuix.kmp.basic.NavigationBarDisplayMode import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.basic.NavigationBar as MiuixNavigationBar @@ -26,29 +42,43 @@ fun AppNavigationBar( ) { val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine) val opacity = (ThemeConfig.bottomBarOpacity.coerceIn(0, 100)) / 100f + val hazeState = LocalHazeState.current + val hazeModifier = if (hazeState != null) { + Modifier.regularHazeEffect(hazeState) + } else { + Modifier + } if (isMiuix) { - val baseColor = GlassDefaults.glassColor( - noBlurColor = MiuixTheme.colorScheme.surface, - blurAlpha = GlassDefaults.DefaultBlurAlpha - ) + val baseColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) { + Color(ThemeConfig.secondaryThemeColor) + } else { + GlassDefaults.glassColor( + noBlurColor = MiuixTheme.colorScheme.surface, + blurAlpha = GlassDefaults.TransparentAlpha + ) + } val finalColor = baseColor.copy(alpha = (baseColor.alpha * opacity).coerceIn(0f, 1f)) MiuixNavigationBar( - modifier = modifier, + modifier = modifier.then(hazeModifier), color = finalColor, mode = miuixMode, content = content ) } else { - val baseColor = GlassDefaults.glassColor( - noBlurColor = BottomAppBarDefaults.containerColor, - blurAlpha = GlassDefaults.DefaultBlurAlpha - ) + val baseColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) { + Color(ThemeConfig.secondaryThemeColor) + } else { + GlassDefaults.glassColor( + noBlurColor = BottomAppBarDefaults.containerColor, + blurAlpha = GlassDefaults.TransparentAlpha + ) + } val finalColor = baseColor.copy(alpha = (baseColor.alpha * opacity).coerceIn(0f, 1f)) NavigationBar( - modifier = modifier, + modifier = modifier.then(hazeModifier), containerColor = finalColor, content = content ) @@ -66,10 +96,35 @@ fun RowScope.AppNavigationBarItem( m3IndicatorColor: Color, m3ShowLabel: Boolean, m3AlwaysShowLabel: Boolean, + useCustomIcon: Boolean = false, ) { val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine) + val useCustomIconBox = useCustomIcon && ThemeConfig.useFloatingBottomBar - if (isMiuix) { + if (useCustomIconBox) { + Box( + modifier = modifier + .weight(1f) + .fillMaxHeight() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false, radius = 32.dp), + onClick = onClick + ), + contentAlignment = Alignment.Center + ) { + if (selected) { + Box( + modifier = Modifier + .width(64.dp) + .height(32.dp) + .clip(RoundedCornerShape(16.dp)) + .background(m3IndicatorColor) + ) + } + m3Icon() + } + } else if (isMiuix) { MiuixNavigationBarItem( selected = selected, onClick = onClick, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt b/app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt index f77d0d20c..e09e055b6 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt @@ -15,9 +15,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LocalHazeState @@ -70,65 +75,110 @@ fun AppScaffold( FabPosition.Center -> MiuixFabPosition.Center else -> MiuixFabPosition.End } - MiuixScaffold( - modifier = modifier, - topBar = { - topBar(hazeState) - }, - bottomBar = bottomBar, - snackbarHost = snackbarHost, - floatingActionButton = floatingActionButton, - floatingActionButtonPosition = miuixFabPosition, - containerColor = miuixContainerColor, - contentWindowInsets = contentWindowInsets - ) { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .responsiveHazeSource(hazeState) - .then( - if (contentDrawsBehindBars) Modifier - else Modifier.padding(paddingValues) + Box(modifier = modifier.fillMaxSize()) { + BackgroundImageContent(isDark = isDark, hazeState = hazeState) + MiuixScaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + topBar(hazeState) + }, + bottomBar = bottomBar, + snackbarHost = snackbarHost, + floatingActionButton = floatingActionButton, + floatingActionButtonPosition = miuixFabPosition, + containerColor = miuixContainerColor, + contentWindowInsets = contentWindowInsets + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .responsiveHazeSource(hazeState) + .then( + if (contentDrawsBehindBars) Modifier + else Modifier.padding(paddingValues) + ) + ) { + content( + if (contentDrawsBehindBars) paddingValues + else PaddingValues(0.dp) ) - ) { - content( - if (contentDrawsBehindBars) paddingValues - else PaddingValues(0.dp) - ) + } } } } else -> { - Scaffold( - modifier = modifier, - topBar = { - topBar(hazeState) - }, - bottomBar = bottomBar, - snackbarHost = snackbarHost, - floatingActionButton = floatingActionButton, - floatingActionButtonPosition = floatingActionButtonPosition, - containerColor = containerColor, - contentColor = contentColor, - contentWindowInsets = contentWindowInsets - ) { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .responsiveHazeSource(hazeState) - .then( - if (contentDrawsBehindBars) Modifier - else Modifier.padding(paddingValues) + Box(modifier = modifier.fillMaxSize()) { + BackgroundImageContent(isDark = isDark, hazeState = hazeState) + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + topBar(hazeState) + }, + bottomBar = bottomBar, + snackbarHost = snackbarHost, + floatingActionButton = floatingActionButton, + floatingActionButtonPosition = floatingActionButtonPosition, + containerColor = containerColor, + contentColor = contentColor, + contentWindowInsets = contentWindowInsets + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .responsiveHazeSource(hazeState) + .then( + if (contentDrawsBehindBars) Modifier + else Modifier.padding(paddingValues) + ) + ) { + content( + if (contentDrawsBehindBars) paddingValues + else PaddingValues(0.dp) ) - ) { - content( - if (contentDrawsBehindBars) paddingValues - else PaddingValues(0.dp) - ) + } } } } } } } + + +@Composable +private fun BackgroundImageContent( + isDark: Boolean, + hazeState: HazeState +) { + val hasImageBg = ThemeConfig.hasImageBg(isDark) + val bgImagePath = if (isDark) ThemeConfig.bgImageDark else ThemeConfig.bgImageLight + val blur = if (isDark) { + ThemeConfig.bgImageNBlurring + } else { + ThemeConfig.bgImageBlurring + } + + if (hasImageBg && !bgImagePath.isNullOrBlank()) { + if (ThemeConfig.enableBlur) { + AsyncImage( + model = bgImagePath, + contentDescription = null, + imageLoader = org.koin.compose.koinInject(), + modifier = Modifier + .fillMaxSize() + .hazeSource(hazeState), + contentScale = ContentScale.Crop + ) + } else { + AsyncImage( + model = bgImagePath, + contentDescription = null, + imageLoader = org.koin.compose.koinInject(), + modifier = Modifier + .fillMaxSize() + .blur(blur.dp), + contentScale = ContentScale.Crop + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/FloatingBottomBar.kt b/app/src/main/java/io/legado/app/ui/widget/components/FloatingBottomBar.kt index 3ce535f5c..6c6d1196f 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/FloatingBottomBar.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/FloatingBottomBar.kt @@ -65,6 +65,7 @@ import com.kyant.backdrop.shadow.Shadow import com.kyant.capsule.ContinuousCapsule import io.legado.app.ui.animation.DampedDragAnimation import io.legado.app.ui.animation.InteractiveHighlight +import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.theme.LegadoTheme import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -110,12 +111,19 @@ fun FloatingBottomBar( backdrop: Backdrop, tabsCount: Int, isBlurEnabled: Boolean = true, + hasCustomIcons: Boolean = false, content: @Composable RowScope.() -> Unit ) { val isInLightTheme = !LegadoTheme.isDark - val accentColor = LegadoTheme.colorScheme.primary - val containerColor = if (isBlurEnabled) { - LegadoTheme.colorScheme.surfaceContainer.copy(alpha = 0.4f) + val accentColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.themeColor != 0) { + Color(ThemeConfig.themeColor) + } else { + LegadoTheme.colorScheme.primary + } + val containerColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) { + Color(ThemeConfig.secondaryThemeColor).copy(alpha = if (isBlurEnabled) ThemeConfig.bottomBarBlurAlpha / 100f else 1f) + } else if (isBlurEnabled) { + LegadoTheme.colorScheme.surfaceContainer.copy(alpha = ThemeConfig.bottomBarBlurAlpha / 100f) } else { LegadoTheme.colorScheme.surfaceContainer } @@ -250,8 +258,8 @@ fun FloatingBottomBar( effects = { if (isBlurEnabled) { vibrancy() - blur(8f.dp.toPx()) - lens(24f.dp.toPx(), 24f.dp.toPx()) + blur(ThemeConfig.bottomBarBlurRadius.toFloat().dp.toPx()) + lens(ThemeConfig.bottomBarLensRadius.dp.toPx(), ThemeConfig.bottomBarLensRadius.dp.toPx()) } }, highlight = { @@ -307,8 +315,8 @@ fun FloatingBottomBar( if (isBlurEnabled) { val progress = dampedDragAnimation.pressProgress vibrancy() - blur(8f.dp.toPx()) - lens(24f.dp.toPx() * progress, 24f.dp.toPx() * progress) + blur(ThemeConfig.bottomBarBlurRadius.toFloat().dp.toPx()) + lens(ThemeConfig.bottomBarLensRadius.dp.toPx() * progress, ThemeConfig.bottomBarLensRadius.dp.toPx() * progress) } }, highlight = { @@ -331,7 +339,10 @@ fun FloatingBottomBar( ) .height(56.dp) .padding(horizontal = 4.dp) - .graphicsLayer(colorFilter = ColorFilter.tint(accentColor)), + .then( + if (hasCustomIcons) Modifier + else Modifier.graphicsLayer(colorFilter = ColorFilter.tint(accentColor)) + ), verticalAlignment = Alignment.CenterVertically, content = content ) diff --git a/app/src/main/java/io/legado/app/ui/widget/components/GlassDefaults.kt b/app/src/main/java/io/legado/app/ui/widget/components/GlassDefaults.kt index d83c13419..6e40d0d19 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/GlassDefaults.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/GlassDefaults.kt @@ -20,7 +20,15 @@ object GlassDefaults { } } - // 默认的玻璃效果透明度值,方便全局修改 + @Composable + fun secondaryColorOr(fallback: @Composable () -> Color): Color { + return if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) { + Color(ThemeConfig.secondaryThemeColor) + } else { + fallback() + } + } + val DefaultBlurAlpha = 0.36f val ThickBlurAlpha = 0.72f val TransparentAlpha = 0f diff --git a/app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt b/app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt index 0157b5aac..4e6f64d26 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt @@ -1,35 +1,67 @@ package io.legado.app.ui.widget.components import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember 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.config.themeConfig.ThemeConfig import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.ThemeResolver +import io.legado.app.ui.widget.components.divider.SettingItemDivider import io.legado.app.ui.widget.components.title.AdaptiveTitle import top.yukonga.miuix.kmp.basic.Card as MiuixCard -/** - * Settings Group Container by https://github.com/wxxsfxyzm/InstallerX-Revived - * Edit by @Kudomaga - * @param title the title of the group - * @param content a list of composable that will be displayed in the group - * @param modifier Modifier - */ +val LocalSplicedColumnGroupState = compositionLocalOf { SplicedColumnGroupState( + enableItemDivider = false, + currentIndex = { 0 }, + incrementIndex = { } +) } + +data class SplicedColumnGroupState( + val enableItemDivider: Boolean, + val currentIndex: () -> Int, + val incrementIndex: () -> Unit +) + @Composable fun SplicedColumnGroup( modifier: Modifier = Modifier, title: String = "", - content: @Composable ColumnScope.() -> Unit, + items: @Composable ColumnScope.() -> Unit, ) { val composeEngine = LegadoTheme.composeEngine + val enableBorder = ThemeConfig.enableContainerBorder + val borderWidth = ThemeConfig.containerBorderWidth.dp + val borderColor = if (ThemeConfig.containerBorderColor != 0) { + Color(ThemeConfig.containerBorderColor) + } else { + MaterialTheme.colorScheme.outline + } + val enableItemDivider = ThemeConfig.enableItemDivider + val currentIndex = remember { mutableIntStateOf(0) } + + val groupState = remember { + SplicedColumnGroupState( + enableItemDivider = enableItemDivider, + currentIndex = { currentIndex.intValue }, + incrementIndex = { currentIndex.intValue++ } + ) + } + Column(modifier = modifier.padding(top = 8.dp, bottom = 8.dp)) { if (title.isNotEmpty()) { AdaptiveTitle( @@ -38,28 +70,70 @@ fun SplicedColumnGroup( ) } - if (ThemeResolver.isMiuixEngine(composeEngine)) { - MiuixCard { + CompositionLocalProvider(LocalSplicedColumnGroupState provides groupState) { + if (ThemeResolver.isMiuixEngine(composeEngine)) { + MiuixCard { + Column( + modifier = Modifier + .fillMaxWidth() + .animateContentSize() + .clip(RoundedCornerShape(16.dp)) + ) { + currentIndex.intValue = 0 + items() + } + } + } else { Column( modifier = Modifier .fillMaxWidth() .animateContentSize() .clip(RoundedCornerShape(16.dp)) + .then( + if (enableBorder) { + Modifier.border( + width = borderWidth, + color = borderColor, + shape = RoundedCornerShape(16.dp) + ) + } else { + Modifier + } + ), + verticalArrangement = if (enableBorder) { + Arrangement.spacedBy(0.dp) + } else { + Arrangement.spacedBy(2.dp) + } ) { - content() + currentIndex.intValue = 0 + items() } } - - } else { - Column( - modifier = Modifier - .fillMaxWidth() - .animateContentSize() - .clip(RoundedCornerShape(16.dp)), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - content() - } } } +} + +@Composable +fun SplicedColumnDivider() { + val groupState = LocalSplicedColumnGroupState.current + if (groupState.enableItemDivider && groupState.currentIndex() > 0) { + SettingItemDivider() + } + groupState.incrementIndex() +} + +@Composable +fun SettingItemWithDivider( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + val groupState = LocalSplicedColumnGroupState.current + if (groupState.enableItemDivider && groupState.currentIndex() > 0) { + SettingItemDivider() + } + Column(modifier = modifier.fillMaxWidth()) { + groupState.incrementIndex() + content() + } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt b/app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt index 77df4530c..8c519c25b 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt @@ -2,6 +2,7 @@ package io.legado.app.ui.widget.components.card import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.shape.RoundedCornerShape @@ -40,7 +41,26 @@ private fun BaseCard( ) { val resolvedContainerColor = (containerColor ?: LegadoTheme.colorScheme.secondaryContainer) .let { it.copy(alpha = it.alpha * alpha) } - if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) { + val isTransparent = containerColor == Color.Transparent + val shape = RoundedCornerShape(cornerRadius) + + if (isTransparent) { + val clickableModifier = if (onClick != null || onLongClick != null) { + modifier + .clip(shape) + .combinedClickable( + onClick = { onClick?.invoke() }, + onLongClick = onLongClick + ) + } else { + modifier.clip(shape) + } + Box( + modifier = clickableModifier + ) { + Column(content = content) + } + } else if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) { val colors = MiuixCardDefaults.defaultColors( color = resolvedContainerColor, contentColor = contentColor ?: LegadoTheme.colorScheme.onSurface diff --git a/app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt b/app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt index c0f2c37e0..67cda3972 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt @@ -1,5 +1,6 @@ package io.legado.app.ui.widget.components.card +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding @@ -35,7 +36,8 @@ fun TextCard( verticalPadding: Dp = 4.dp, iconSize: Dp = 14.dp, spacing: Dp = 4.dp, - textStyle: TextStyle = LegadoTheme.typography.labelSmallEmphasized + textStyle: TextStyle = LegadoTheme.typography.labelSmallEmphasized, + border: BorderStroke? = null ) { val defaultBackground = LegadoTheme.colorScheme.surfaceContainer val defaultContent = LegadoTheme.colorScheme.onSurface @@ -48,7 +50,8 @@ fun TextCard( cornerRadius = cornerRadius, containerColor = finalBackgroundColor, contentColor = finalContentColor, - onClick = onClick + onClick = onClick, + border = border ) { Row( modifier = Modifier.padding( diff --git a/app/src/main/java/io/legado/app/ui/widget/components/divider/SettingItemDivider.kt b/app/src/main/java/io/legado/app/ui/widget/components/divider/SettingItemDivider.kt new file mode 100644 index 000000000..85956b4e2 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/divider/SettingItemDivider.kt @@ -0,0 +1,45 @@ +package io.legado.app.ui.widget.components.divider + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +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.unit.Dp +import androidx.compose.ui.unit.dp +import io.legado.app.ui.config.themeConfig.ThemeConfig + +@Composable +fun SettingItemDivider( + modifier: Modifier = Modifier +) { + if (!ThemeConfig.enableItemDivider) return + + val thickness = ThemeConfig.itemDividerWidth.dp + val lengthPercent = ThemeConfig.itemDividerLength / 100f + val dividerColor = if (ThemeConfig.itemDividerColor != 0) { + Color(ThemeConfig.itemDividerColor) + } else { + Color.Gray.copy(alpha = 0.3f) + } + + Box( + modifier = modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxWidth(lengthPercent) + .height(thickness) + .clip(CircleShape) + .background(dividerColor) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItem.kt index acc69d799..c97e55a01 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItem.kt @@ -1,5 +1,6 @@ package io.legado.app.ui.widget.components.explore +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -16,6 +17,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import io.legado.app.data.entities.rule.ExploreKind +import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.text.AppText @@ -38,13 +40,24 @@ fun ExploreKindItem( val cornerRadius = 12.dp + val enableBorder = ThemeConfig.enableDeepPersonalization && ThemeConfig.enableContainerBorder + val borderWidth = (ThemeConfig.containerBorderWidth - 0.8f).coerceAtLeast(0.1f).dp + val borderColor = if (ThemeConfig.containerBorderColor != 0) { + androidx.compose.ui.graphics.Color(ThemeConfig.containerBorderColor) + } else { + LegadoTheme.colorScheme.outline + } + if (isClickable) { GlassCard( onClick = onClick, cornerRadius = cornerRadius, containerColor = backgroundColor, contentColor = LegadoTheme.colorScheme.onSurface, - modifier = modifier + modifier = modifier, + border = if (enableBorder) { + BorderStroke(borderWidth, borderColor) + } else null ) { KindText( text = displayText, @@ -58,7 +71,9 @@ fun ExploreKindItem( containerColor = backgroundColor, contentColor = LegadoTheme.colorScheme.primary, modifier = modifier, - border = CardDefaults.outlinedCardBorder() + border = if (enableBorder) { + BorderStroke(borderWidth, borderColor) + } else BorderStroke(1.dp, LegadoTheme.colorScheme.outline) ) { KindText( text = displayText, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt index bfc9327fc..bb5f9df0e 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt @@ -39,6 +39,7 @@ import io.legado.app.App import io.legado.app.data.entities.rule.ExploreKind import io.legado.app.ui.widget.components.explore.ExploreKindUiUseCase import io.legado.app.help.source.getExploreInfoMap +import io.legado.app.ui.config.themeConfig.ThemeConfig 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 @@ -321,7 +322,11 @@ private fun ExploreKindCompactTextField( val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() val shape = RoundedCornerShape(10.dp) - val borderColor = if (isFocused) { + val enableBorder = ThemeConfig.enableDeepPersonalization && ThemeConfig.enableContainerBorder + val borderWidth = ThemeConfig.containerBorderWidth.dp + val borderColor = if (ThemeConfig.containerBorderColor != 0) { + Color(ThemeConfig.containerBorderColor) + } else if (isFocused) { LegadoTheme.colorScheme.primary } else { Color.Transparent @@ -338,7 +343,7 @@ private fun ExploreKindCompactTextField( .height(34.dp) .clip(shape) .background(backgroundColor) - .border(width = 1.dp, color = borderColor, shape = shape), + .border(width = borderWidth, color = borderColor, shape = shape), decorationBox = { innerTextField -> Box( modifier = Modifier diff --git a/app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt b/app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt index cc61c75f5..1c19d5953 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt @@ -96,7 +96,7 @@ object AppIcons { val Replay: ImageVector @Composable - get() = if (isMiuix) MiuixIcons.Refresh ?: Icons.Default.Replay else Icons.Default.Replay + get() = if (isMiuix) MiuixIcons.Refresh else Icons.Default.Replay @Composable fun mainDestination(destination: MainDestination, selected: Boolean): ImageVector { diff --git a/app/src/main/java/io/legado/app/ui/widget/components/settingItem/ClickableSettingItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/ClickableSettingItem.kt index 5ae9f98e9..8703f3a69 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/settingItem/ClickableSettingItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/ClickableSettingItem.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.ThemeResolver +import io.legado.app.ui.widget.components.SplicedColumnDivider import top.yukonga.miuix.kmp.basic.BasicComponentDefaults import top.yukonga.miuix.kmp.preference.ArrowPreference @@ -24,6 +25,8 @@ fun ClickableSettingItem( onClick: () -> Unit ) { val composeEngine = LegadoTheme.composeEngine + SplicedColumnDivider() + if (ThemeResolver.isMiuixEngine(composeEngine)) { ArrowPreference( title = title, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/settingItem/CompactSettingItems.kt b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/CompactSettingItems.kt index 9d4dc45e3..851ed60ba 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/settingItem/CompactSettingItems.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/CompactSettingItems.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -32,6 +33,8 @@ import io.legado.app.ui.widget.components.ValueStepper import io.legado.app.ui.widget.components.card.TextCard import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import top.yukonga.miuix.kmp.basic.BasicComponent +import top.yukonga.miuix.kmp.basic.BasicComponentDefaults +import top.yukonga.miuix.kmp.preference.ArrowPreference import top.yukonga.miuix.kmp.preference.SwitchPreference import top.yukonga.miuix.kmp.preference.WindowDropdownPreference import top.yukonga.miuix.kmp.basic.Slider as MiuixSlider @@ -251,3 +254,38 @@ fun CompactSwitchSettingItem( ) } } + +@Composable +fun CompactClickableSettingItem( + title: String, + description: String? = null, + imageVector: ImageVector? = null, + color: Color? = MaterialTheme.colorScheme.surface, + shape: Shape = MaterialTheme.shapes.small, + onClick: () -> Unit +) { + if (ThemeResolver.isMiuixEngine(composeEngine)) { + ArrowPreference( + title = title, + summary = description, + insideMargin = BasicComponentDefaults.InsideMargin, + onClick = onClick + ) + } else { + SettingItem( + title = title, + description = description, + imageVector = imageVector, + color = color, + shape = shape, + trailingContent = { + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + onClick = onClick + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/settingItem/InputSettingItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/InputSettingItem.kt index fa03749da..9fd5b5e02 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/settingItem/InputSettingItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/InputSettingItem.kt @@ -21,6 +21,7 @@ import io.legado.app.R import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow +import io.legado.app.ui.widget.components.SplicedColumnDivider import io.legado.app.ui.widget.components.text.AppText import top.yukonga.miuix.kmp.basic.BasicComponent import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField @@ -46,6 +47,7 @@ fun InputSettingItem( } val composeEngine = LegadoTheme.composeEngine + SplicedColumnDivider() if (ThemeResolver.isMiuixEngine(composeEngine)) { Column(modifier = Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/io/legado/app/ui/widget/components/settingItem/ListSettingItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/ListSettingItem.kt index dfd2188a9..0011e4821 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/settingItem/ListSettingItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/ListSettingItem.kt @@ -10,6 +10,7 @@ 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.ThemeResolver +import io.legado.app.ui.widget.components.SplicedColumnDivider import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import top.yukonga.miuix.kmp.basic.SpinnerEntry import top.yukonga.miuix.kmp.preference.OverlaySpinnerPreference @@ -25,6 +26,7 @@ fun DropdownListSettingItem( onValueChange: (String) -> Unit ) { val composeEngine = LegadoTheme.composeEngine + SplicedColumnDivider() if (ThemeResolver.isMiuixEngine(composeEngine)) { val selectedIndex = entryValues.indexOf(selectedValue).coerceAtLeast(0) diff --git a/app/src/main/java/io/legado/app/ui/widget/components/settingItem/SliderSettingItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/SliderSettingItem.kt index 66c238c07..57be5509d 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/settingItem/SliderSettingItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/SliderSettingItem.kt @@ -1,8 +1,7 @@ -package io.legado.app.ui.widget.components.settingItem +package io.legado.app.ui.widget.components.settingItem import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -20,7 +19,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.TextField 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 @@ -30,11 +31,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt import io.legado.app.R 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.button.ConfirmDismissButtonsRow +import io.legado.app.ui.widget.components.SplicedColumnDivider import io.legado.app.ui.widget.components.text.AppText import top.yukonga.miuix.kmp.basic.BasicComponent import top.yukonga.miuix.kmp.basic.Slider as MiuixSlider @@ -43,6 +46,7 @@ import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField @Composable fun SliderSettingItem( title: String, + color: Color? = null, value: Float, defaultValue: Float, valueRange: ClosedFloatingPointRange, @@ -53,7 +57,33 @@ fun SliderSettingItem( var expanded by remember { mutableStateOf(false) } var isInputMode by remember { mutableStateOf(false) } - val textFieldState = rememberTextFieldState(initialText = value.toInt().toString()) + var sliderValue by remember(value) { mutableFloatStateOf(value) } + val textFieldState = rememberTextFieldState() + + LaunchedEffect(value) { + sliderValue = value + } + + LaunchedEffect(isInputMode) { + if (isInputMode) { + textFieldState.edit { + replace(0, length, value.toString()) + } + } + } + + fun commitValue() { + if (isInputMode) { + textFieldState.text.toString().toFloatOrNull()?.let { num -> + val rounded = (num * 10).roundToInt() / 10f + onValueChange(rounded.coerceIn(valueRange)) + } + } else if (sliderValue != value) { + onValueChange(sliderValue) + } + } + + SplicedColumnDivider() if (ThemeResolver.isMiuixEngine(composeEngine)) { @@ -64,7 +94,12 @@ fun SliderSettingItem( BasicComponent( title = title, summary = description, - onClick = { expanded = !expanded } + onClick = { + if (expanded) { + commitValue() + } + expanded = !expanded + } ) AnimatedVisibility(visible = expanded) { @@ -87,32 +122,20 @@ fun SliderSettingItem( valueRange.endInclusive.toInt() ), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - inputTransformation = { - val newText = asCharSequence().toString() - newText.toFloatOrNull()?.let { num -> - onValueChange( - num.coerceIn( - valueRange.start, - valueRange.endInclusive - ) - ) - } - } + modifier = Modifier.fillMaxWidth() ) } else { MiuixSlider( - value = value, + value = sliderValue, onValueChange = { - onValueChange(it) + sliderValue = (it * 10).roundToInt() / 10f textFieldState.edit { - replace( - 0, - length, - it.toInt().toString() - ) + replace(0, length, sliderValue.toString()) } }, + onValueChangeFinished = { + onValueChange(sliderValue.coerceIn(valueRange)) + }, valueRange = valueRange, steps = steps, modifier = Modifier.fillMaxWidth() @@ -147,10 +170,14 @@ fun SliderSettingItem( } else { SettingItem( title = title, - color = MaterialTheme.colorScheme.surfaceContainerLow, option = description, expanded = expanded, - onExpandChange = { expanded = it }, + onExpandChange = { + if (expanded) { + commitValue() + } + expanded = it + }, expandContent = { Row(verticalAlignment = Alignment.CenterVertically) { AnimatedContent( @@ -179,32 +206,20 @@ fun SliderSettingItem( bottom = 4.dp, start = 12.dp, end = 12.dp - ), - inputTransformation = { - val newText = asCharSequence().toString() - newText.toFloatOrNull()?.let { num -> - onValueChange( - num.coerceIn( - valueRange.start, - valueRange.endInclusive - ) - ) - } - } + ) ) } else { Slider( - value = value, + value = sliderValue, onValueChange = { - onValueChange(it) + sliderValue = (it * 10).roundToInt() / 10f textFieldState.edit { - replace( - 0, - length, - it.toInt().toString() - ) + replace(0, length, sliderValue.toString()) } }, + onValueChangeFinished = { + onValueChange(sliderValue.coerceIn(valueRange)) + }, valueRange = valueRange, steps = steps, modifier = Modifier.fillMaxWidth() diff --git a/app/src/main/java/io/legado/app/ui/widget/components/settingItem/SwitchSettingItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/SwitchSettingItem.kt index 4633a2784..afce23b7a 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/settingItem/SwitchSettingItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/SwitchSettingItem.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.widget.components.AdaptiveSwitch +import io.legado.app.ui.widget.components.SplicedColumnDivider import top.yukonga.miuix.kmp.preference.SwitchPreference @@ -20,6 +21,7 @@ fun SwitchSettingItem( onCheckedChange: (Boolean) -> Unit ) { val composeEngine = LegadoTheme.composeEngine + SplicedColumnDivider() if (ThemeResolver.isMiuixEngine(composeEngine)) { SwitchPreference( diff --git a/app/src/main/java/io/legado/app/ui/widget/components/topbar/GlassMediumFlexibleTopAppBar.kt b/app/src/main/java/io/legado/app/ui/widget/components/topbar/GlassMediumFlexibleTopAppBar.kt index 1b88a91a3..f063a8660 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/topbar/GlassMediumFlexibleTopAppBar.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/topbar/GlassMediumFlexibleTopAppBar.kt @@ -46,15 +46,31 @@ fun GlassMediumFlexibleTopAppBar( val isMiuix = ThemeResolver.isMiuixEngine(composeEngine) val containerColor = if (!isMiuix) { - GlassTopAppBarDefaults.containerColor() + if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) { + Color(ThemeConfig.secondaryThemeColor) + } else { + GlassTopAppBarDefaults.containerColor() + } } else { - GlassTopAppBarDefaults.getMiuixAppBarColor() + if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) { + Color(ThemeConfig.secondaryThemeColor) + } else { + GlassTopAppBarDefaults.getMiuixAppBarColor() + } } val scrolledColor = if (!isMiuix) { - GlassTopAppBarDefaults.scrolledContainerColor() + if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) { + Color(ThemeConfig.secondaryThemeColor) + } else { + GlassTopAppBarDefaults.scrolledContainerColor() + } } else { - GlassTopAppBarDefaults.getMiuixAppBarColor() + if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) { + Color(ThemeConfig.secondaryThemeColor) + } else { + GlassTopAppBarDefaults.getMiuixAppBarColor() + } } val animatedColor = if (!isMiuix) { @@ -65,7 +81,7 @@ fun GlassMediumFlexibleTopAppBar( } val finalModifier = if (hazeState != null) { - modifier.responsiveHazeEffect(state = hazeState) + modifier.background(color = animatedColor).responsiveHazeEffect(state = hazeState) } else { modifier.background(color = animatedColor) } @@ -152,10 +168,17 @@ fun GlassMediumFlexibleTopAppBar( object GlassTopAppBarDefaults { @Composable - fun getMiuixAppBarColor() = GlassDefaults.glassColor( - noBlurColor = MiuixTheme.colorScheme.surface, - blurAlpha = GlassDefaults.TransparentAlpha - ) + fun getMiuixAppBarColor(): Color { + val baseColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) { + Color(ThemeConfig.secondaryThemeColor) + } else { + MiuixTheme.colorScheme.surface + } + return GlassDefaults.glassColor( + noBlurColor = baseColor, + blurAlpha = GlassDefaults.TransparentAlpha + ) + } @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -174,16 +197,26 @@ object GlassTopAppBarDefaults { @Composable fun glassColors(): TopAppBarColors { + val containerBaseColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) { + Color(ThemeConfig.secondaryThemeColor) + } else { + MaterialTheme.colorScheme.surface + } val containerColor = GlassDefaults.glassColor( - noBlurColor = MaterialTheme.colorScheme.surface, + noBlurColor = containerBaseColor, blurAlpha = GlassDefaults.TransparentAlpha ) - val scrolledContainerColor = if (ThemeConfig.enableBlur) { - MaterialTheme.colorScheme.surface.copy(alpha = GlassDefaults.TransparentAlpha) + val scrolledBaseColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) { + Color(ThemeConfig.secondaryThemeColor) } else { MaterialTheme.colorScheme.surfaceContainer } + val scrolledContainerColor = if (ThemeConfig.enableBlur) { + scrolledBaseColor.copy(alpha = GlassDefaults.TransparentAlpha) + } else { + scrolledBaseColor + } return TopAppBarDefaults.topAppBarColors( containerColor = applyTopBarOpacity(containerColor), @@ -193,20 +226,30 @@ object GlassTopAppBarDefaults { @Composable fun containerColor(): Color { - val baseColor = GlassDefaults.glassColor( - noBlurColor = MaterialTheme.colorScheme.surface, + val baseColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) { + Color(ThemeConfig.secondaryThemeColor) + } else { + MaterialTheme.colorScheme.surface + } + val glassColor = GlassDefaults.glassColor( + noBlurColor = baseColor, blurAlpha = GlassDefaults.TransparentAlpha ) - return applyTopBarOpacity(baseColor) + return applyTopBarOpacity(glassColor) } @Composable fun scrolledContainerColor(): Color { - val baseColor = GlassDefaults.glassColor( - noBlurColor = MaterialTheme.colorScheme.surfaceContainer, + val baseColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) { + Color(ThemeConfig.secondaryThemeColor) + } else { + MaterialTheme.colorScheme.surfaceContainer + } + val glassColor = GlassDefaults.glassColor( + noBlurColor = baseColor, blurAlpha = GlassDefaults.TransparentAlpha ) - return applyTopBarOpacity(baseColor) + return applyTopBarOpacity(glassColor) } @Composable diff --git a/app/src/main/java/io/legado/app/ui/widget/dialog/BottomWebViewDialog.kt b/app/src/main/java/io/legado/app/ui/widget/dialog/BottomWebViewDialog.kt index b4da7f7e8..e58c95a71 100644 --- a/app/src/main/java/io/legado/app/ui/widget/dialog/BottomWebViewDialog.kt +++ b/app/src/main/java/io/legado/app/ui/widget/dialog/BottomWebViewDialog.kt @@ -604,6 +604,9 @@ class BottomWebViewDialog() : BottomSheetDialogFragment(R.layout.dialog_web_view } } + override fun onNavigateToArticles(sortUrl: String?) { + } + @Suppress("unused") private class JSInterface(dialog: BottomWebViewDialog) { private val dialogRef: WeakReference = WeakReference(dialog) diff --git a/app/src/main/java/io/legado/app/ui/widget/image/CoverImageView.kt b/app/src/main/java/io/legado/app/ui/widget/image/CoverImageView.kt index 516918ce9..bac060ab5 100644 --- a/app/src/main/java/io/legado/app/ui/widget/image/CoverImageView.kt +++ b/app/src/main/java/io/legado/app/ui/widget/image/CoverImageView.kt @@ -11,6 +11,7 @@ import android.text.TextPaint import android.util.AttributeSet import android.view.ViewGroup import androidx.appcompat.widget.AppCompatImageView +import androidx.core.graphics.withSave import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import com.bumptech.glide.load.DataSource @@ -37,6 +38,9 @@ class CoverImageView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : AppCompatImageView(context, attrs) { + init { + setBackgroundColor(Color.TRANSPARENT) + } private var filletPath = Path() private var viewWidth: Float = 0f private var viewHeight: Float = 0f diff --git a/app/src/main/java/io/legado/app/utils/BitmapUtils.kt b/app/src/main/java/io/legado/app/utils/BitmapUtils.kt index 98d0b28c5..b9962035e 100644 --- a/app/src/main/java/io/legado/app/utils/BitmapUtils.kt +++ b/app/src/main/java/io/legado/app/utils/BitmapUtils.kt @@ -47,6 +47,7 @@ object BitmapUtils { BitmapFactory.decodeFileDescriptor(fis.fd, null, op) op.inSampleSize = calculateInSampleSize(op, width, height) op.inJustDecodeBounds = false + op.inPreferredConfig = Config.ARGB_8888 BitmapFactory.decodeFileDescriptor(fis.fd, null, op) } } 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 690e46674..139b8a707 100644 --- a/app/src/main/java/io/legado/app/utils/ContextExtensions.kt +++ b/app/src/main/java/io/legado/app/utils/ContextExtensions.kt @@ -204,6 +204,19 @@ fun Context.getPrefLong(key: String, defValue: Long = 0L): Long { fun Context.putPrefLong(key: String, value: Long) = defaultSharedPreferences.edit { putLong(key, value) } +fun Context.getPrefFloat(key: String, defValue: Float = 0f): Float { + return try { + defaultSharedPreferences.getFloat(key, defValue) + } catch (e: ClassCastException) { + val value = defaultSharedPreferences.getInt(key, defValue.toInt()).toFloat() + defaultSharedPreferences.edit { putFloat(key, value) } + value + } +} + +fun Context.putPrefFloat(key: String, value: Float) = + defaultSharedPreferences.edit { putFloat(key, value) } + fun Context.getPrefString(key: String, defValue: String? = null) = defaultSharedPreferences.getString(key, defValue) diff --git a/app/src/main/java/io/legado/app/utils/JsonExtensions.kt b/app/src/main/java/io/legado/app/utils/JsonExtensions.kt index 1e9a0289d..e1e622b3a 100644 --- a/app/src/main/java/io/legado/app/utils/JsonExtensions.kt +++ b/app/src/main/java/io/legado/app/utils/JsonExtensions.kt @@ -18,3 +18,51 @@ fun ReadContext.readInt(path: String): Int? = this.read(path, Int::class.java) fun ReadContext.readLong(path: String): Long? = this.read(path, Long::class.java) +fun com.google.gson.JsonObject.getString(name: String, default: String? = null): String? { + val elem = get(name) + return when { + elem == null || elem.isJsonNull -> default + elem.isJsonPrimitive -> { + val prim = elem.asJsonPrimitive + if (prim.isString || prim.isNumber) prim.asString else default + } + else -> default + } +} + +fun com.google.gson.JsonObject.getInt(name: String, default: Int = 0): Int { + val elem = get(name) + return when { + elem == null || elem.isJsonNull -> default + elem.isJsonPrimitive -> { + val prim = elem.asJsonPrimitive + if (prim.isNumber) prim.asNumber.toInt() else default + } + else -> default + } +} + +fun com.google.gson.JsonObject.getFloat(name: String, default: Float = 0f): Float { + val elem = get(name) + return when { + elem == null || elem.isJsonNull -> default + elem.isJsonPrimitive -> { + val prim = elem.asJsonPrimitive + if (prim.isNumber) prim.asNumber.toFloat() else default + } + else -> default + } +} + +fun com.google.gson.JsonObject.getBoolean(name: String, default: Boolean = false): Boolean { + val elem = get(name) + return when { + elem == null || elem.isJsonNull -> default + elem.isJsonPrimitive -> { + val prim = elem.asJsonPrimitive + if (prim.isBoolean) prim.asBoolean else default + } + else -> default + } +} + diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml index 14116a078..1fcf84b59 100644 --- a/app/src/main/res/drawable/ic_add.xml +++ b/app/src/main/res/drawable/ic_add.xml @@ -1,4 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add_online.xml b/app/src/main/res/drawable/ic_add_online.xml index e735380d4..5cd450c96 100644 --- a/app/src/main/res/drawable/ic_add_online.xml +++ b/app/src/main/res/drawable/ic_add_online.xml @@ -1,4 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrange.xml b/app/src/main/res/drawable/ic_arrange.xml index dc7b691b0..5e27a0089 100644 --- a/app/src/main/res/drawable/ic_arrange.xml +++ b/app/src/main/res/drawable/ic_arrange.xml @@ -1,4 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_down.xml b/app/src/main/res/drawable/ic_arrow_down.xml index 9b47238f5..5017a026d 100644 --- a/app/src/main/res/drawable/ic_arrow_down.xml +++ b/app/src/main/res/drawable/ic_arrow_down.xml @@ -1,3 +1,3 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_arrow_right.xml b/app/src/main/res/drawable/ic_arrow_right.xml index 1433fe33f..09c4b0e80 100644 --- a/app/src/main/res/drawable/ic_arrow_right.xml +++ b/app/src/main/res/drawable/ic_arrow_right.xml @@ -1,3 +1,3 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_cfg_about.xml b/app/src/main/res/drawable/ic_cfg_about.xml index d9f6639bb..db8c1748b 100644 --- a/app/src/main/res/drawable/ic_cfg_about.xml +++ b/app/src/main/res/drawable/ic_cfg_about.xml @@ -1,3 +1,3 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_cfg_backup.xml b/app/src/main/res/drawable/ic_cfg_backup.xml index e932d44ea..826ee9315 100644 --- a/app/src/main/res/drawable/ic_cfg_backup.xml +++ b/app/src/main/res/drawable/ic_cfg_backup.xml @@ -1,3 +1,3 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_cfg_other.xml b/app/src/main/res/drawable/ic_cfg_other.xml index 057fc436a..4947b06c1 100644 --- a/app/src/main/res/drawable/ic_cfg_other.xml +++ b/app/src/main/res/drawable/ic_cfg_other.xml @@ -1,3 +1,3 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_cfg_replace.xml b/app/src/main/res/drawable/ic_cfg_replace.xml index 839b89ab4..00d092957 100644 --- a/app/src/main/res/drawable/ic_cfg_replace.xml +++ b/app/src/main/res/drawable/ic_cfg_replace.xml @@ -1,3 +1,3 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_cfg_source.xml b/app/src/main/res/drawable/ic_cfg_source.xml index 2e006720a..204fe4045 100644 --- a/app/src/main/res/drawable/ic_cfg_source.xml +++ b/app/src/main/res/drawable/ic_cfg_source.xml @@ -1,3 +1,3 @@ - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_cfg_theme.xml b/app/src/main/res/drawable/ic_cfg_theme.xml index ba78b7cb4..a6a1db2f3 100644 --- a/app/src/main/res/drawable/ic_cfg_theme.xml +++ b/app/src/main/res/drawable/ic_cfg_theme.xml @@ -1,3 +1,3 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_cfg_web.xml b/app/src/main/res/drawable/ic_cfg_web.xml index 2d6afd37a..34bf86847 100644 --- a/app/src/main/res/drawable/ic_cfg_web.xml +++ b/app/src/main/res/drawable/ic_cfg_web.xml @@ -1,10 +1,10 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_code.xml b/app/src/main/res/drawable/ic_code.xml new file mode 100644 index 000000000..473a35e7d --- /dev/null +++ b/app/src/main/res/drawable/ic_code.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_groups.xml b/app/src/main/res/drawable/ic_groups.xml index f892190ff..f323aabdb 100644 --- a/app/src/main/res/drawable/ic_groups.xml +++ b/app/src/main/res/drawable/ic_groups.xml @@ -1,4 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_help.xml b/app/src/main/res/drawable/ic_help.xml index 8e15bd9b5..440cbd4c4 100644 --- a/app/src/main/res/drawable/ic_help.xml +++ b/app/src/main/res/drawable/ic_help.xml @@ -1,3 +1,3 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_more.xml b/app/src/main/res/drawable/ic_more.xml index 4615ea17b..a751b36bb 100644 --- a/app/src/main/res/drawable/ic_more.xml +++ b/app/src/main/res/drawable/ic_more.xml @@ -1,3 +1,3 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml index 53ab82289..fbc88ca7d 100644 --- a/app/src/main/res/drawable/ic_search.xml +++ b/app/src/main/res/drawable/ic_search.xml @@ -1,3 +1,3 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_title_font.xml b/app/src/main/res/drawable/ic_title_font.xml new file mode 100644 index 000000000..4084e355f --- /dev/null +++ b/app/src/main/res/drawable/ic_title_font.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_view_quilt.xml b/app/src/main/res/drawable/ic_view_quilt.xml index db0a24829..ca86a6f7b 100644 --- a/app/src/main/res/drawable/ic_view_quilt.xml +++ b/app/src/main/res/drawable/ic_view_quilt.xml @@ -1,4 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_audio_play.xml b/app/src/main/res/layout/activity_audio_play.xml index 36d0a2622..53940a769 100644 --- a/app/src/main/res/layout/activity_audio_play.xml +++ b/app/src/main/res/layout/activity_audio_play.xml @@ -40,10 +40,11 @@ android:layout_width="0dp" android:layout_height="0dp" app:cardElevation="16dp" - android:layout_marginHorizontal="32dp" + android:layout_marginHorizontal="48dp" + android:layout_marginTop="0dp" android:layout_marginBottom="16dp" - app:layout_constraintDimensionRatio="1:1" - app:layout_constraintBottom_toTopOf="@+id/ll_title" + app:layout_constraintDimensionRatio="16:9" + app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/title_bar" @@ -123,6 +124,16 @@ tools:ignore="SmallSp" /> + + - - - - + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_font_config.xml b/app/src/main/res/layout/dialog_font_config.xml index 4d12a13aa..1947126f9 100644 --- a/app/src/main/res/layout/dialog_font_config.xml +++ b/app/src/main/res/layout/dialog_font_config.xml @@ -88,6 +88,13 @@ android:layout_height="wrap_content" app:icon="@drawable/ic_fill_colors" /> + + @@ -29,6 +30,41 @@ + + + + + + + + + + + + - + android:paddingTop="8dp" + android:orientation="horizontal" + tools:ignore="RtlHardcoded,RtlSymmetry"> + android:text="@string/header_font" /> + + + + + + + + + + @@ -84,7 +84,7 @@ android:id="@+id/dsb_header_padding_left" android:layout_width="120dp" android:layout_height="wrap_content" - app:max="200" + app:max="300" app:title="@string/padding_left" /> @@ -121,7 +121,7 @@ android:id="@+id/dsb_padding_top" android:layout_width="120dp" android:layout_height="wrap_content" - app:max="200" + app:max="300" app:title="@string/padding_top" /> @@ -147,7 +147,7 @@ android:id="@+id/dsb_padding_left" android:layout_width="120dp" android:layout_height="wrap_content" - app:max="200" + app:max="300" app:title="@string/padding_left" /> @@ -196,7 +196,7 @@ android:id="@+id/dsb_footer_padding_top" android:layout_width="120dp" android:layout_height="wrap_content" - app:max="200" + app:max="300" app:title="@string/padding_top" /> @@ -222,7 +222,7 @@ android:id="@+id/dsb_footer_padding_left" android:layout_width="120dp" android:layout_height="wrap_content" - app:max="200" + app:max="300" app:title="@string/padding_left" /> diff --git a/app/src/main/res/layout/dialog_regex_color_config.xml b/app/src/main/res/layout/dialog_regex_color_config.xml new file mode 100644 index 000000000..7d2e0339c --- /dev/null +++ b/app/src/main/res/layout/dialog_regex_color_config.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_tip_config.xml b/app/src/main/res/layout/dialog_tip_config.xml index 3e7ca2379..ba0f6cd66 100644 --- a/app/src/main/res/layout/dialog_tip_config.xml +++ b/app/src/main/res/layout/dialog_tip_config.xml @@ -93,6 +93,38 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="end"> + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_preference.xml b/app/src/main/res/layout/view_preference.xml index 22df1e371..3f67209cd 100644 --- a/app/src/main/res/layout/view_preference.xml +++ b/app/src/main/res/layout/view_preference.xml @@ -1,61 +1,61 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_preference_category.xml b/app/src/main/res/layout/view_preference_category.xml index 3ff05f842..d970b7dca 100644 --- a/app/src/main/res/layout/view_preference_category.xml +++ b/app/src/main/res/layout/view_preference_category.xml @@ -1,19 +1,19 @@ - - - - - + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_simple_counter.xml b/app/src/main/res/layout/view_simple_counter.xml index dc0255801..f52d5eadf 100644 --- a/app/src/main/res/layout/view_simple_counter.xml +++ b/app/src/main/res/layout/view_simple_counter.xml @@ -15,7 +15,6 @@ android:layout_height="wrap_content" android:layout_gravity="center" android:layout_marginEnd="8dp" - android:maxWidth="48sp" android:maxLines="2" android:text="@string/text" android:textColor="?android:attr/textColorSecondary" diff --git a/app/src/main/res/menu/source_edit.xml b/app/src/main/res/menu/source_edit.xml index 7953eba23..d6c8a0fd0 100644 --- a/app/src/main/res/menu/source_edit.xml +++ b/app/src/main/res/menu/source_edit.xml @@ -67,6 +67,11 @@ android:title="@string/str_share" app:showAsAction="never" /> + + - 阅读 + 阅读DIY 阅读·A 阅读 · 搜索 阅读需要访问存储卡权限,请前往“设置”—“应用权限”,打开所需权限 @@ -210,6 +210,7 @@ 软件分享 跟随系统 添加 + 减少 导入书源 本地导入 网络导入 @@ -470,6 +471,7 @@ 正文下一页 URL 规则(nextContentUrl) WebView JS(webJs) 图片样式(imageStyle) + 副文规则(subContent) 替换规则(replaceRegex) 资源正则(sourceRegex) 购买操作(payAction) @@ -902,6 +904,7 @@ 总是使用默认封面 总是显示默认封面(不显示网络封面) 字号 + 标题颜色 上边距 下边距 显示 @@ -1120,7 +1123,7 @@ 替换(启用/禁用) 显示上次更新时间 刷新列表 - 分隔线颜色 + 分隔线 移除重复标题 更新失败 阅读需要发送通知来显示朗读控制和下载进度。 @@ -1345,7 +1348,7 @@ 减少进入阅读界面时的卡顿,但加载书籍时间会延长。仅在开启共享元素动画时生效 延缓书籍加载 紧凑列表 - %s • %d章未读 + %1$s • %2$d章未读 当前 %d%% 菜单透明度 请选择操作 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 5004ff5f8..cbddc3116 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -187,6 +187,7 @@ 應用程式分享 跟隨系統 添加 + 減少 導入書源 本地導入 網絡導入 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index e8e695cec..aea103b3f 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -189,6 +189,7 @@ 軟體分享 跟隨系統 新增 + 減少 匯入書源 本機匯入 網路匯入 diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 060107844..16fecb080 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -12,6 +12,20 @@ File + + 列表 + 单列 + 双列 + 瀑布 + 三列 + + + + 网页 + 图片 + 视频 + + Tab Hide Tab diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 58d0529a8..3761c6ce4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,6 +38,7 @@ Enable system predictive back gestures Restart to apply Custom Theme + 外观设置 Click to select Compose Related Use Flexible App Top Bar @@ -91,6 +92,25 @@ Settings Theme Settings Some settings related to interface or color + 深度个性化设置 + 字体等深度个性化设置 + 主题管理 + 管理、导入导出完整主题包 + 字体设置 + 选择应用字体 + 颜色设置 + 重置颜色 + 应用栏颜色 + 导航栏颜色 + 字体颜色 + 背景颜色 + 边框设置 + 显示容器边框 + 边框粗细 + 边框样式 + 实线 + 虚线 + 虚线间隔 Other Settings Language • Other Settings About @@ -101,6 +121,7 @@ Version Local Search + Input search keyword Origin: %s Origin: %s Title @@ -213,6 +234,7 @@ Share to System Add + Minus Import book sources Import local Import online @@ -470,10 +492,11 @@ Chapter info(ChapterInfo) Js before update(preUpdateJs) Content(content) - Next content URL(nextContentUrl) + Next content url(nextContentUrl) Web view js(webJs) Source regex(sourceRegex) Replace regex(replaceRegex) + Sub content(subContent) Image style(imageStyle) Pay action(payAction) @@ -620,6 +643,12 @@ Background Images Background color Text Color + 页眉 + 页脚 + 设置字体 + 页脚字体 + 设置字号 + 页脚字号 Select a picture Group Management Group selection @@ -758,7 +787,6 @@ Primary Accent - Background color NavBar color Day Day,Primary @@ -855,7 +883,7 @@ None Title Show/Hide - Header Footer + Footer Header Rule Subscription Add the rule import address provided by the bosses\nClick to import rules after adding Pull the cloud progress @@ -911,6 +939,7 @@ Content source code List source code Font Size + Title Color Margin Top Margin Bottom Show @@ -1141,6 +1170,15 @@ Clear WebView Data Clear all data in WebView List + 启动 + 启动页html文本 + 启动页style文本 + 启动页Js(startJs) + 预注入Js(preloadJs) + 预加载 + 显示WebView日志 + 优先缓存 + 布局 Dictionary Rules Config dict rule Create @@ -1174,7 +1212,7 @@ respondTime: %1$d ms Load word count Exclude range, optional title or source URL - Format Js Rules + 格式化规则(formatJs) Adjustment position Please enter the correct range Custom Export @@ -1230,8 +1268,8 @@ Check Updates for Variants Look for other signature variants when checking for updates Current - Official Version - Beta Version + Official + Beta Coexist Version Stream Audio Playback Play while downloading; may stutter on poor networks. Works only with TTS source @@ -1240,7 +1278,7 @@ Reading requires phone state access to pause reading aloud during calls Start Read Aloud via Headset Buttons Use headset buttons to start reading aloud - Show Groups by Domain + 按域名分组显示 Theme Configuration Manga Viewer Disable Manga Zoom @@ -1348,37 +1386,37 @@ Reduces stutter when entering the reading interface, but increases book loading time. Delay Book Loading Compact List - %s • %dNot read + %1$s • %2$d Not read Current %d%% Menu Transparency - Select an action - Replace current book - Add as new book to shelf - Quick add to shelf - Added to shelf - Generate theme colors from image - To use a seed color, deselect the seed image + 请选择操作 + 替换当前书籍 + 新增书籍到书架 + 快速添加到书架 + 书籍已添加到书架 + 使用图片生成主题颜色 + 若需使用种子颜色,请取消种子图片选择 Click to delete or change - Since the Theme Engine depends on Monet, dynamic coloring and custom themes will not take effect on devices below Android 12. - Restart is required to apply changes - Restart later - Select group - Light/Dark mode switch - Long press to save image - Long press to set as global default mode - Slider Style - Bookshelf Update Limit + 由于主题引擎依赖Monet,对于低于Android12的设备,动态取色与自定义主题不会生效。 + 修改该选项需要重启 + 稍后重启 + 选择书源分组 + 深浅色模式开关 + 长按保存图片 + 长按设置为全局默认模式 + 滑动条显示样式 + 书架更新数量限制 + X轴距离 + Y轴距离 + 阴影半径 + 阴影 + 阅读显示 • 漫画相关 + 屏幕显示与亮度 Unlimited %1$d Books - X-axis offset - Y-axis offset - Shadow radius - Text Shadow - Reading Display • Manga Related - Display Setting 阅读行为 - Page Control + 翻页控制 网页重定向策略 允许所有重定向 总是询问 @@ -1391,20 +1429,20 @@ 从新到旧 名称升序 名称降序 - Dotted Line + 下划虚线 跳转到卷 取消选择 折叠所有卷 展开所有卷 - Custom - Global Setting - Font Style + 自定义 + 全局设置 + 字体样式 阴影样式 Long Tap to Customize 禁用翻页动画 禁用加载淡入动画 阴影设置 - Typography + 段落样式 已有相同书籍 Underline Color Dash Length @@ -1442,37 +1480,39 @@ 颜色规格 这里的选项仅可以控制使用Compose重写的界面。对于禁用的选项,会在日后实现时开放,现在仅作占位。 Enable Element Blur - Use Underline to Emphasize Text - Adapt to Special Styles + 总是使用下划线表示强调文本 + 适配特殊样式 Theme Style Container Opacity %d - Top Bar Opacity + 顶栏不透明度 %d - Bottom Bar Opacity + 底栏不透明度 %d - Start Web Service on App Startup + 启动应用时打开Web服务 听书预加载数量 %d 音频缓存保留时间(分钟) 当前%d, 输入 0 代表退出即刻清理。 - Show Swipe Animation - Image Clicking - Output Logs - Enable gradient blur - Vibrate While Selecting Text - Resync from WebDAV - Underline Padding - Layout Settings - Cover Info Orientation - Rules - Tool Bar Style - Split Title - Split Mode - Subtitle Scale - Subtitle Margin - Heading Spacing - Follow Background + 启用滑动时动画 + 点击图片行为 + 输出日志 + 启用渐变模糊 + 选择文本时震动 + 重新同步远程书籍 + 离字间距 + + + Cover orientation + Follow background + Heading spacing + Layout settings + Rule + Split title + Split mode + Subtitle margin + Subtitle scale + Tool bar style %1$d books %1$d selected No books here. Try global search. diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index f016ec7c2..64b9e0a2c 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -55,6 +55,8 @@ //**********System Style******************// +