更多自定义设置 @325506 (#994)

* 第一次上传

* 新增深度个性化设置-可以给主题选择自定义字体了

* 新增深度个性化设置-存在背景图片时,应用栏和导航栏会对背景图片进行模糊处理

* 新增深度个性化设置-主题颜色高度自定义

* 新增深度个性化设置-新增边框设置

* 合并后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>
This commit is contained in:
HlllllHandHapeLee authored and GitHub committed 2026-05-10 04:38:51 +08:00
1 parent 46c9496bdc
commit 64f7de8120
131 files changed
+8956 -1126

No files matched your search

+31 -1
View File
@@ -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/\")", "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", "WebSearch",
"Bash(./gradlew assembleAppDebug)", "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)"
] ]
} }
} }
+3
View File
@@ -40,6 +40,9 @@ Transform tasks into verifiable goals:
## Build / Test / Run ## Build / Test / Run
```bash ```bash
# Quick compile check (Kotlin only, no dex/package — fastest for verifying code compiles)
.\gradlew.bat :app:compileAppDebugKotlin
# Assemble all variants # Assemble all variants
./gradlew assembleAppRelease ./gradlew assembleAppRelease
+1
View File
@@ -278,5 +278,6 @@ dependencies {
implementation(libs.miuix.shapes.android) implementation(libs.miuix.shapes.android)
implementation(libs.capsule) implementation(libs.capsule)
implementation(libs.backdrop) implementation(libs.backdrop)
implementation(libs.lyricViewx)
implementation(libs.timber) implementation(libs.timber)
} }
File diff suppressed because it is too large. Load diff
@@ -4,6 +4,7 @@ package io.legado.app.constant
object PreferKey { object PreferKey {
const val language = "language" const val language = "language"
const val fontScale = "fontScale" const val fontScale = "fontScale"
const val appFontPath = "appFontPath"
const val appTheme = "app_theme" const val appTheme = "app_theme"
const val themeMode = "themeMode" const val themeMode = "themeMode"
const val userAgent = "userAgent" const val userAgent = "userAgent"
@@ -101,6 +102,7 @@ object PreferKey {
const val replaceEnableDefault = "replaceEnableDefault" const val replaceEnableDefault = "replaceEnableDefault"
const val showBrightnessView = "showBrightnessView" const val showBrightnessView = "showBrightnessView"
const val useUnderline = "useUnderline" const val useUnderline = "useUnderline"
const val regexColorRules = "regexColorRules"
const val adaptSpecialStyle = "adaptSpecialStyle" const val adaptSpecialStyle = "adaptSpecialStyle"
const val autoClearExpired = "autoClearExpired" const val autoClearExpired = "autoClearExpired"
const val autoChangeSource = "autoChangeSource" const val autoChangeSource = "autoChangeSource"
@@ -193,9 +195,32 @@ object PreferKey {
const val bottomBarOpacity = "bottomBarOpacity" const val bottomBarOpacity = "bottomBarOpacity"
const val enableBlur = "enableBlur" const val enableBlur = "enableBlur"
const val enableProgressiveBlur = "enableProgressiveBlur" 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 useFlexibleTopAppBar = "useFlexibleTopAppBar"
const val cBackground = "colorBackground" const val cBackground = "colorBackground"
const val cBBackground = "colorBottomBackground" 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 bgImage = "backgroundImage"
const val bgImageBlurring = "backgroundImageBlurring" const val bgImageBlurring = "backgroundImageBlurring"
const val cNPrimary = "colorPrimaryNight" const val cNPrimary = "colorPrimaryNight"
@@ -274,8 +299,16 @@ object PreferKey {
const val showTip = "showTip" const val showTip = "showTip"
const val showBookCount = "showBookCount" const val showBookCount = "showBookCount"
const val showBookIntro = "showBookIntro"
const val bookshelfIntroMaxLines = "bookshelfIntroMaxLines"
const val sliderVibrator = "sliderVibrator" const val sliderVibrator = "sliderVibrator"
const val selectVibrator = "selectVibrator" const val selectVibrator = "selectVibrator"
const val audioCacheCleanTime = "audioCacheCleanTime" const val audioCacheCleanTime = "audioCacheCleanTime"
const val audioPreDownloadNum = "audioPreDownloadNum" 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"
} }
@@ -69,7 +69,7 @@ val appDb by lazy {
} }
@Database( @Database(
version = 85, version = 86,
exportSchema = true, exportSchema = true,
entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class, entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class, ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -121,7 +121,8 @@ val appDb by lazy {
AutoMigration(from = 81, to = 82), AutoMigration(from = 81, to = 82),
AutoMigration(from = 82, to = 83), AutoMigration(from = 82, to = 83),
AutoMigration(from = 83, to = 84), AutoMigration(from = 83, to = 84),
AutoMigration(from = 84, to = 85) AutoMigration(from = 84, to = 85),
AutoMigration(from = 85, to = 86)
] ]
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -17,6 +17,11 @@ import io.legado.app.ui.main.bookshelf.BookShelfItem
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
data class GroupBookCount(
val groupId: Long,
val count: Int
)
@Dao @Dao
interface BookDao { interface BookDao {
@@ -91,7 +96,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
where type & ${BookType.text} > 0 where type & ${BookType.text} > 0
and type & ${BookType.local} = 0 and type & ${BookType.local} = 0
@@ -125,7 +132,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
ORDER BY durChapterTime DESC ORDER BY durChapterTime DESC
""" """
@@ -156,7 +165,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
WHERE type & ${BookType.audio} > 0 WHERE type & ${BookType.audio} > 0
""" """
@@ -187,7 +198,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
WHERE type & ${BookType.local} > 0 WHERE type & ${BookType.local} > 0
""" """
@@ -223,7 +236,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
where type & ${BookType.audio} = 0 and type & ${BookType.local} = 0 where type & ${BookType.audio} = 0 and type & ${BookType.local} = 0
and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0 and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0
@@ -260,7 +275,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
where type & ${BookType.local} > 0 where type & ${BookType.local} > 0
and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0 and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0
@@ -292,7 +309,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
WHERE (`group` & :group) > 0 WHERE (`group` & :group) > 0
""" """
@@ -325,7 +344,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
WHERE name like '%'||:key||'%' or author like '%'||:key||'%' or originName like '%'||:key||'%' WHERE name like '%'||:key||'%' or author like '%'||:key||'%' or originName like '%'||:key||'%'
""" """
@@ -356,7 +377,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
where type & ${BookType.updateError} > 0 where type & ${BookType.updateError} > 0
order by durChapterTime desc order by durChapterTime desc
@@ -388,7 +411,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
WHERE durChapterIndex = 0 AND durChapterPos = 0 WHERE durChapterIndex = 0 AND durChapterPos = 0
""" """
@@ -419,7 +444,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1 WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1
""" """
@@ -450,7 +477,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
WHERE totalChapterNum > 0 AND durChapterIndex > 0 AND durChapterIndex < totalChapterNum - 1 WHERE totalChapterNum > 0 AND durChapterIndex > 0 AND durChapterIndex < totalChapterNum - 1
""" """
@@ -481,7 +510,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
WHERE type & ${BookType.image} > 0 WHERE type & ${BookType.image} > 0
""" """
@@ -512,7 +543,9 @@ interface BookDao {
type, type,
`group`, `group`,
`order`, `order`,
canUpdate canUpdate,
ifnull(customIntro, intro) as intro,
kind
FROM books FROM books
WHERE type & ${BookType.text} > 0 WHERE type & ${BookType.text} > 0
""" """
@@ -627,4 +660,268 @@ interface BookDao {
@Query("delete from books where type & ${BookType.notShelf} > 0") @Query("delete from books where type & ${BookType.notShelf} > 0")
fun deleteNotShelfBook() fun deleteNotShelfBook()
// ── Group preview / count queries (DB-level, replaces in-memory buildGroupPreviewState) ──
@Query("SELECT COUNT(*) FROM books")
fun flowAllBookShelfCount(): Flow<Int>
@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<List<GroupBookCount>>
@Query("SELECT COUNT(*) FROM books WHERE (`group` & :groupId) > 0")
fun flowUserGroupBookCount(groupId: Long): Flow<Int>
fun flowGroupPreview(groupId: Long): Flow<List<BookShelfItem>> {
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<List<BookShelfItem>>
@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<List<BookShelfItem>>
@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<List<BookShelfItem>>
@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<List<BookShelfItem>>
@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<List<BookShelfItem>>
@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<List<BookShelfItem>>
@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<List<BookShelfItem>>
@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<List<BookShelfItem>>
@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<List<BookShelfItem>>
@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<List<BookShelfItem>>
@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<List<BookShelfItem>>
@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<List<BookShelfItem>>
@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<List<BookShelfItem>>
} }
@@ -86,12 +86,32 @@ data class RssSource(
var loadWithBaseUrl: Boolean = true, var loadWithBaseUrl: Boolean = true,
/**注入js**/ /**注入js**/
var injectJs: String? = null, 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") @ColumnInfo(defaultValue = "0")
var lastUpdateTime: Long = 0, var lastUpdateTime: Long = 0,
@ColumnInfo(defaultValue = "0") @ColumnInfo(defaultValue = "0")
var customOrder: Int = 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") @ColumnInfo(defaultValue = "ASK_CROSS_ORIGIN")
var redirectPolicy: String = "ASK_CROSS_ORIGIN" var redirectPolicy: String = "ASK_CROSS_ORIGIN"
@@ -143,6 +163,15 @@ data class RssSource(
&& equal(variableComment, source.variableComment) && equal(variableComment, source.variableComment)
&& equal(style, source.style) && equal(style, source.style)
&& equal(injectJs, source.injectJs) && 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 { private fun equal(a: String?, b: String?): Boolean {
@@ -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<FileDoc> {
val fontRegex = Regex("(?i).*\\.[ot]tf")
if (folderUri != null) {
val documentFile = DocumentFile.fromTreeUri(context, folderUri)
return documentFile?.let { docFile ->
val fontFiles = mutableListOf<FileDoc>()
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) }
}
}
@@ -1105,7 +1105,7 @@ interface JsExtensions : JsEncodeUtils {
* 弹窗提示 * 弹窗提示
*/ */
fun toast(msg: Any?) { fun toast(msg: Any?) {
rhinoContext.ensureActive() rhinoContextOrNull?.ensureActive()
appCtx.toastForJs("${getSource()?.getTag()}: ${msg.toString()}") appCtx.toastForJs("${getSource()?.getTag()}: ${msg.toString()}")
} }
@@ -1113,7 +1113,7 @@ interface JsExtensions : JsEncodeUtils {
* 弹窗提示 停留时间较长 * 弹窗提示 停留时间较长
*/ */
fun longToast(msg: Any?) { fun longToast(msg: Any?) {
rhinoContext.ensureActive() rhinoContextOrNull?.ensureActive()
appCtx.longToastForJs("${getSource()?.getTag()}: ${msg.toString()}") appCtx.longToastForJs("${getSource()?.getTag()}: ${msg.toString()}")
} }
@@ -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<Config> by lazy {
ArrayList(getConfigs() ?: emptyList())
}
val configList: List<Config> 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<Config>::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<Config>? {
val configFile = File(configFilePath)
if (configFile.exists()) {
kotlin.runCatching {
val json = configFile.readText()
return THEME_GSON.fromJson(json, Array<Config>::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<PersonalizationThemeConfig.Config>,
JsonDeserializer<PersonalizationThemeConfig.Config> {
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)
)
}
}
@@ -26,10 +26,12 @@ import io.legado.app.utils.getFile
import io.legado.app.utils.getMeanColor import io.legado.app.utils.getMeanColor
import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.getPrefInt import io.legado.app.utils.getPrefInt
import io.legado.app.utils.getPrefString
import io.legado.app.utils.hexString import io.legado.app.utils.hexString
import io.legado.app.utils.printOnDebug import io.legado.app.utils.printOnDebug
import io.legado.app.utils.putPrefBoolean import io.legado.app.utils.putPrefBoolean
import io.legado.app.utils.putPrefInt import io.legado.app.utils.putPrefInt
import io.legado.app.utils.putPrefString
import io.legado.app.utils.resizeAndRecycle import io.legado.app.utils.resizeAndRecycle
import splitties.init.appCtx import splitties.init.appCtx
import java.io.File import java.io.File
@@ -227,6 +229,12 @@ object ReadBookConfig {
} }
} }
val regexColorRules: ArrayList<RegexColorRule> get() = durConfig.regexColorRules
fun saveRegexColorRules() {
save()
}
/** /**
* 两端对齐 * 两端对齐
*/ */
@@ -260,6 +268,36 @@ object ReadBookConfig {
config.textFont = value 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 var textBold: Int
get() = config.textBold get() = config.textBold
set(value) { set(value) {
@@ -393,6 +431,12 @@ object ReadBookConfig {
config.titleBottomSpacing = value config.titleBottomSpacing = value
} }
var titleColor: Int
get() = config.titleColor
set(value) {
config.titleColor = value
}
var paragraphIndent: String var paragraphIndent: String
get() = config.paragraphIndent get() = config.paragraphIndent
set(value) { set(value) {
@@ -417,6 +461,12 @@ object ReadBookConfig {
config.underlinePadding = value config.underlinePadding = value
} }
var underlineExtend: Boolean
get() = config.underlineExtend
set(value) {
config.underlineExtend = value
}
var dottedLine: Boolean var dottedLine: Boolean
get() = config.dottedLine get() = config.dottedLine
set(value) { set(value) {
@@ -544,9 +594,14 @@ object ReadBookConfig {
} }
fun getExportConfig(): Config { fun getExportConfig(): Config {
val exportConfig = durConfig.copy() val exportConfig = durConfig.copy(regexColorRules = ArrayList(durConfig.regexColorRules.map { it.copy() }))
if (shareLayout) { if (shareLayout) {
exportConfig.textFont = shareConfig.textFont 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.textBold = shareConfig.textBold
exportConfig.textSize = shareConfig.textSize exportConfig.textSize = shareConfig.textSize
exportConfig.letterSpacing = shareConfig.letterSpacing exportConfig.letterSpacing = shareConfig.letterSpacing
@@ -556,6 +611,7 @@ object ReadBookConfig {
exportConfig.titleSize = shareConfig.titleSize exportConfig.titleSize = shareConfig.titleSize
exportConfig.titleTopSpacing = shareConfig.titleTopSpacing exportConfig.titleTopSpacing = shareConfig.titleTopSpacing
exportConfig.titleBottomSpacing = shareConfig.titleBottomSpacing exportConfig.titleBottomSpacing = shareConfig.titleBottomSpacing
exportConfig.titleColor = shareConfig.titleColor
exportConfig.paddingBottom = shareConfig.paddingBottom exportConfig.paddingBottom = shareConfig.paddingBottom
exportConfig.paddingLeft = shareConfig.paddingLeft exportConfig.paddingLeft = shareConfig.paddingLeft
exportConfig.paddingRight = shareConfig.paddingRight exportConfig.paddingRight = shareConfig.paddingRight
@@ -576,7 +632,8 @@ object ReadBookConfig {
exportConfig.tipFooterLeft = shareConfig.tipFooterLeft exportConfig.tipFooterLeft = shareConfig.tipFooterLeft
exportConfig.tipFooterMiddle = shareConfig.tipFooterMiddle exportConfig.tipFooterMiddle = shareConfig.tipFooterMiddle
exportConfig.tipFooterRight = shareConfig.tipFooterRight exportConfig.tipFooterRight = shareConfig.tipFooterRight
exportConfig.tipColor = shareConfig.tipColor exportConfig.tipHeaderColor = shareConfig.tipHeaderColor
exportConfig.tipFooterColor = shareConfig.tipFooterColor
exportConfig.headerMode = shareConfig.headerMode exportConfig.headerMode = shareConfig.headerMode
// MD3专有属性 // MD3专有属性
exportConfig.footerMode = shareConfig.footerMode exportConfig.footerMode = shareConfig.footerMode
@@ -628,6 +685,20 @@ object ReadBookConfig {
config.textFont = "" 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) { if (config.bgType == 2) {
val bgName = FileUtils.getName(config.bgStr) val bgName = FileUtils.getName(config.bgStr)
config.bgStr = bgName config.bgStr = bgName
@@ -688,6 +759,11 @@ object ReadBookConfig {
private var pageAnim: Int = 0,//翻页动画 private var pageAnim: Int = 0,//翻页动画
private var pageAnimEInk: Int = 4, private var pageAnimEInk: Int = 4,
var textFont: String = "",//字体 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 textBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体
var textSize: Int = 20,//文字大小 var textSize: Int = 20,//文字大小
var textItalic: Boolean = false,// 是否启用斜体 var textItalic: Boolean = false,// 是否启用斜体
@@ -704,6 +780,7 @@ object ReadBookConfig {
var titleSize: Int = 0, var titleSize: Int = 0,
var titleTopSpacing: Int = 0, var titleTopSpacing: Int = 0,
var titleBottomSpacing: Int = 0, var titleBottomSpacing: Int = 0,
var titleColor: Int = 0,
var titleBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体 var titleBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体
var titleLineSpacingExtra: Int = 12, var titleLineSpacingExtra: Int = 12,
var titleLineSpacingSub: Int = 12, var titleLineSpacingSub: Int = 12,
@@ -715,6 +792,7 @@ object ReadBookConfig {
var underline: Boolean = false, //下划线 var underline: Boolean = false, //下划线
var underlinePadding: Int = 10, var underlinePadding: Int = 10,
var underlineHeight: Int = 1, var underlineHeight: Int = 1,
var underlineExtend: Boolean = false, //下划线延伸
var underlineColor: String = "#3E3D3B", var underlineColor: String = "#3E3D3B",
var underlineColorNight: String = "#ADADAD", var underlineColorNight: String = "#ADADAD",
var dottedLine: Boolean = false, //虚线 var dottedLine: Boolean = false, //虚线
@@ -740,10 +818,12 @@ object ReadBookConfig {
var tipFooterLeft: Int = ReadTipConfig.chapterTitle, var tipFooterLeft: Int = ReadTipConfig.chapterTitle,
var tipFooterMiddle: Int = ReadTipConfig.none, var tipFooterMiddle: Int = ReadTipConfig.none,
var tipFooterRight: Int = ReadTipConfig.pageAndTotal, var tipFooterRight: Int = ReadTipConfig.pageAndTotal,
var tipColor: Int = 0, var tipHeaderColor: Int = 0,
var tipFooterColor: Int = 0,
var tipDividerColor: Int = -1, var tipDividerColor: Int = -1,
var headerMode: Int = 0, var headerMode: Int = 0,
var footerMode: Int = 0 var footerMode: Int = 0,
var regexColorRules: ArrayList<RegexColorRule> = arrayListOf()
) { ) {
@Transient @Transient
@@ -821,6 +901,11 @@ object ReadBookConfig {
"pageAnim" to pageAnim, "pageAnim" to pageAnim,
"pageAnimEInk" to pageAnimEInk, "pageAnimEInk" to pageAnimEInk,
"textFont" to textFont, "textFont" to textFont,
"titleFont" to titleFont,
"headerFont" to headerFont,
"footerFont" to footerFont,
"headerFontSize" to headerFontSize,
"footerFontSize" to footerFontSize,
"textBold" to textBold, "textBold" to textBold,
"textSize" to textSize, "textSize" to textSize,
"letterSpacing" to letterSpacing, "letterSpacing" to letterSpacing,
@@ -830,6 +915,7 @@ object ReadBookConfig {
"titleSize" to titleSize, "titleSize" to titleSize,
"titleTopSpacing" to titleTopSpacing, "titleTopSpacing" to titleTopSpacing,
"titleBottomSpacing" to titleBottomSpacing, "titleBottomSpacing" to titleBottomSpacing,
"titleColor" to titleColor,
"paragraphIndent" to paragraphIndent, "paragraphIndent" to paragraphIndent,
"paddingBottom" to paddingBottom, "paddingBottom" to paddingBottom,
"paddingLeft" to paddingLeft, "paddingLeft" to paddingLeft,
@@ -851,10 +937,12 @@ object ReadBookConfig {
"tipFooterLeft" to tipFooterLeft, "tipFooterLeft" to tipFooterLeft,
"tipFooterMiddle" to tipFooterMiddle, "tipFooterMiddle" to tipFooterMiddle,
"tipFooterRight" to tipFooterRight, "tipFooterRight" to tipFooterRight,
"tipColor" to tipColor, "tipHeaderColor" to tipHeaderColor,
"tipFooterColor" to tipFooterColor,
"tipDividerColor" to tipDividerColor, "tipDividerColor" to tipDividerColor,
"headerMode" to headerMode, "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? { fun getBgPath(bgIndex: Int): String? {
@@ -85,10 +85,16 @@ object ReadTipConfig {
ReadBookConfig.config.footerMode = value ReadBookConfig.config.footerMode = value
} }
var tipColor: Int var tipHeaderColor: Int
get() = ReadBookConfig.config.tipColor get() = ReadBookConfig.config.tipHeaderColor
set(value) { 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 var tipDividerColor: Int
@@ -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 = ""
)
@@ -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<TagColorPair> {
val baseHsl = FloatArray(3)
ColorUtils.colorToHSL(baseColor.toArgb(), baseHsl)
val baseHue = baseHsl[0]
val result = mutableListOf<TagColorPair>()
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
}
}
@@ -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
)
}
}
}
@@ -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<TagColorPair>::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)
}
}
}
@@ -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<ThemePack>()
val packList: List<ThemePack> 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<String>()
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
}
}
@@ -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<ThemePackConfig.ThemePack>,
JsonDeserializer<ThemePackConfig.ThemePack> {
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", "") ?: ""
)
}
}
@@ -14,6 +14,7 @@ import io.legado.app.help.DirectLinkUpload
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.LocalConfig import io.legado.app.help.config.LocalConfig
import io.legado.app.help.config.OldThemeConfig 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.config.ReadBookConfig
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
import io.legado.app.model.BookCover import io.legado.app.model.BookCover
@@ -83,6 +84,7 @@ object Backup {
ReadBookConfig.configFileName, ReadBookConfig.configFileName,
ReadBookConfig.shareConfigFileName, ReadBookConfig.shareConfigFileName,
OldThemeConfig.configFileName, OldThemeConfig.configFileName,
PersonalizationThemeConfig.configFileName,
BookCover.configFileName, BookCover.configFileName,
"config.xml" "config.xml"
) )
@@ -181,6 +183,8 @@ object Backup {
FileUtils.createFileIfNotExist(backupPath + File.separator + BookCover.configFileName) FileUtils.createFileIfNotExist(backupPath + File.separator + BookCover.configFileName)
.writeText(GSON.toJson(it)) .writeText(GSON.toJson(it))
} }
FileUtils.createFileIfNotExist(backupPath + File.separator + PersonalizationThemeConfig.configFileName)
.writeText(PersonalizationThemeConfig.toJson())
currentCoroutineContext().ensureActive() currentCoroutineContext().ensureActive()
appCtx.getSharedPreferences(backupPath, "config")?.let { sp -> appCtx.getSharedPreferences(backupPath, "config")?.let { sp ->
sp.edit(commit = true) { sp.edit(commit = true) {
@@ -23,6 +23,7 @@ object BackupConfig {
private const val readConfigKey = "readConfig" private const val readConfigKey = "readConfig"
private const val themeConfigKey = "themeConfig" private const val themeConfigKey = "themeConfig"
private const val coverConfigKey = "coverConfig" private const val coverConfigKey = "coverConfig"
private const val personalizationThemeConfigKey = "personalizationThemeConfig"
private const val localBookKey = "localBook" private const val localBookKey = "localBook"
//配置忽略key //配置忽略key
@@ -30,6 +31,7 @@ object BackupConfig {
readConfigKey, readConfigKey,
PreferKey.themeMode, PreferKey.themeMode,
themeConfigKey, themeConfigKey,
personalizationThemeConfigKey,
coverConfigKey, coverConfigKey,
PreferKey.bookshelfLayout, PreferKey.bookshelfLayout,
PreferKey.showRss, PreferKey.showRss,
@@ -42,6 +44,7 @@ object BackupConfig {
appCtx.getString(R.string.read_config), appCtx.getString(R.string.read_config),
appCtx.getString(R.string.theme_mode), appCtx.getString(R.string.theme_mode),
appCtx.getString(R.string.theme_config), appCtx.getString(R.string.theme_config),
"深度个性化主题",
appCtx.getString(R.string.cover_config), appCtx.getString(R.string.cover_config),
appCtx.getString(R.string.bookshelf_layout), appCtx.getString(R.string.bookshelf_layout),
appCtx.getString(R.string.show_rss), appCtx.getString(R.string.show_rss),
@@ -84,15 +87,9 @@ object BackupConfig {
private val themePrefKeys = arrayOf( private val themePrefKeys = arrayOf(
PreferKey.cPrimary, PreferKey.cPrimary,
PreferKey.cAccent, PreferKey.cNPrimary,
PreferKey.cBackground,
PreferKey.cBBackground,
PreferKey.bgImage, PreferKey.bgImage,
PreferKey.bgImageBlurring, PreferKey.bgImageBlurring,
PreferKey.cNPrimary,
PreferKey.cNAccent,
PreferKey.cNBackground,
PreferKey.cNBBackground,
PreferKey.bgImageN, PreferKey.bgImageN,
PreferKey.bgImageNBlurring PreferKey.bgImageNBlurring
) )
@@ -128,6 +125,8 @@ object BackupConfig {
get() = ignoreConfig[themeConfigKey] == true get() = ignoreConfig[themeConfigKey] == true
private val ignoreCoverConfig: Boolean private val ignoreCoverConfig: Boolean
get() = ignoreConfig[coverConfigKey] == true get() = ignoreConfig[coverConfigKey] == true
val ignorePersonalizationThemeConfig: Boolean
get() = ignoreConfig[personalizationThemeConfigKey] == true
private val ignoreBookshelfLayout: Boolean private val ignoreBookshelfLayout: Boolean
get() = ignoreConfig[PreferKey.bookshelfLayout] == true get() = ignoreConfig[PreferKey.bookshelfLayout] == true
private val ignoreShowRss: Boolean private val ignoreShowRss: Boolean
@@ -34,6 +34,7 @@ import io.legado.app.help.book.isLocal
import io.legado.app.help.book.upType import io.legado.app.help.book.upType
import io.legado.app.help.config.LocalConfig import io.legado.app.help.config.LocalConfig
import io.legado.app.help.config.OldThemeConfig 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.config.ReadBookConfig
import io.legado.app.model.BookCover import io.legado.app.model.BookCover
import io.legado.app.model.localBook.LocalBook import io.legado.app.model.localBook.LocalBook
@@ -233,6 +234,16 @@ object Restore {
}?.onFailure { }?.onFailure {
AppLog.put("恢复封面规则出错\n${it.localizedMessage}", it) 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) { if (!BackupConfig.ignoreReadConfig) {
//恢复阅读界面配置 //恢复阅读界面配置
File(path, ReadBookConfig.configFileName).takeIf { File(path, ReadBookConfig.configFileName).takeIf {
@@ -24,9 +24,9 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
private suspend fun getLatestRelease(): List<AppReleaseInfo> { private suspend fun getLatestRelease(): List<AppReleaseInfo> {
val url = if (checkVariant == AppVariant.OFFICIAL) 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 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) } val res = okHttpClient.newCallResponse { url(url) }
if (!res.isSuccessful) throw NoStackTraceException("获取新版本出错(${res.code})") if (!res.isSuccessful) throw NoStackTraceException("获取新版本出错(${res.code})")
@@ -65,7 +65,7 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
} }
suspend fun getReleaseByTag(tag: String): AppUpdate.UpdateInfo? { 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) } val res = okHttpClient.newCallResponse { url(url) }
if (!res.isSuccessful) return null if (!res.isSuccessful) return null
@@ -86,7 +86,7 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
val currentVersion = AppConst.appInfo.versionName val currentVersion = AppConst.appInfo.versionName
val releases = getLatestRelease() val releases = getLatestRelease()
val filtered = if (checkVariant == AppVariant.ALL) { val filtered = if (checkVariant == AppVariant.ALL || checkVariant == AppVariant.UNKNOWN) {
releases releases
} else { } else {
releases.filter { it.appVariant == checkVariant } releases.filter { it.appVariant == checkVariant }
@@ -153,8 +153,8 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
companion object { companion object {
fun parse(version: String): SemVer { fun parse(version: String): SemVer {
val regex = Regex("""(\d+)\.(\d+)\.(\d+)(?:-([\w.]+))?""") val regex = Regex("""(\d+)\.(\d+)\.(\d+)(?:[-_]([\w.]+))?""")
val match = regex.matchEntire(version) val match = regex.find(version)
?: throw IllegalArgumentException("Invalid version: $version") ?: throw IllegalArgumentException("Invalid version: $version")
val (maj, min, pat, pre) = match.destructured val (maj, min, pat, pre) = match.destructured
return SemVer(maj.toInt(), min.toInt(), pat.toInt(), pre.ifBlank { null }) return SemVer(maj.toInt(), min.toInt(), pat.toInt(), pre.ifBlank { null })
@@ -9,6 +9,8 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.BaseSource import io.legado.app.data.entities.BaseSource
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter 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.help.coroutine.Coroutine
import io.legado.app.model.AudioPlay import io.legado.app.model.AudioPlay
import io.legado.app.model.ReadBook 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.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
import io.legado.app.ui.rss.read.RssJsExtensions import io.legado.app.ui.rss.read.RssJsExtensions
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import io.legado.app.utils.escapeForJs
import io.legado.app.utils.fromJsonObject import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.isJsonObject
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.UUID import java.util.UUID
@Suppress("unused") @Suppress("unused")
class WebJsExtensions( class WebJsExtensions(
source: BaseSource, activity: AppCompatActivity, source: BaseSource,
private val webView: WebView, activity: AppCompatActivity?,
private val bookType: Int = 0, webView: WebView,
bookType: Int = 0,
callback: Callback? = null callback: Callback? = null
) : RssJsExtensions(activity, source) { ) : RssJsExtensions(activity, source) {
private val callbackRef: WeakReference<Callback> = WeakReference(callback) private val callbackRef: WeakReference<Callback> = WeakReference(callback)
private val webViewRef: WeakReference<WebView?> = WeakReference(webView)
interface Callback { interface Callback {
fun upConfig(config: String) fun upConfig(config: String)
fun onNavigateToArticles(sortUrl: String? = null)
} }
@JavascriptInterface @JavascriptInterface
@@ -40,6 +45,16 @@ class WebJsExtensions(
callbackRef.get()?.upConfig(config) 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 { private val bookAndChapter by lazy {
var book: Book? = null var book: Book? = null
var chapter: BookChapter? = null var chapter: BookChapter? = null
@@ -63,154 +78,201 @@ class WebJsExtensions(
private val book: Book? get() = bookAndChapter.first private val book: Book? get() = bookAndChapter.first
private val chapter: BookChapter? get() = bookAndChapter.second private val chapter: BookChapter? get() = bookAndChapter.second
/** val analyzeRule by lazy {
* 由软件主动注入的js函数调用 AnalyzeRule(book, source = getSource()).setChapter(chapter)
*/ }
@JavascriptInterface @JavascriptInterface
fun request(funName: String, jsParam: Array<String>, id: String) { fun request(funName: String, jsParam: Array<String?>, id: String) {
val activity = activityRef.get() ?: return val activity = activityRef.get() ?: return
Coroutine.async(activity.lifecycleScope) { 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) { when (funName) {
"run" -> AnalyzeRule(book, getSource()).run { "run" -> {
setCoroutineContext(coroutineContext) analyzeRule.setCoroutineContext(coroutineContext)
setChapter(chapter) .evalJS(
evalJS(jsParam[0]).toString() p0 ?: throw NoStackTraceException("error null")
).toString()
} }
"ajaxAwait" -> { "ajaxAwait" -> {
ajax(jsParam[0], jsParam[1].toIntOrNull()).toString() ajax(
p0 ?: throw NoStackTraceException("error url null"),
p1?.toLongOrNull()
).toString()
} }
"connectAwait" -> { "connectAwait" -> {
connect(jsParam[0], jsParam[1], jsParam[2].toIntOrNull()) connect(
p0 ?: throw NoStackTraceException("error url null"),
p1,
p2?.toIntOrNull()
)
} }
"getAwait" -> { "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" -> { "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" -> { "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" -> { "webViewAwait" -> {
webView(jsParam[0], jsParam[1], jsParam[2], jsParam[3].toBoolean()).toString() webView(
p0,
p1,
p2,
p3.toBoolean()
).toString()
} }
"webViewGetSourceAwait" -> { "webViewGetSourceAwait" -> {
webViewGetSource( webViewGetSource(
jsParam[0], p0,
jsParam[1], p1,
jsParam[2], p2,
jsParam[3], p3 ?: throw NoStackTraceException("error sourceRegex null"),
jsParam[4].toBoolean(), p4.toBoolean(),
jsParam[5].toLongOrNull() ?: 0 p5?.toLongOrNull() ?: 0
).toString() ).toString()
} }
"decryptStrAwait" -> { "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" -> { "encryptBase64Await" -> {
createSymmetricCrypto( createSymmetricCrypto(
jsParam[0], p0 ?: throw NoStackTraceException("error transformation null"),
jsParam[1], p1 ?: throw NoStackTraceException("error key null"),
jsParam[2] p2
).encryptBase64(jsParam[3]) ).encryptBase64(p3 ?: throw NoStackTraceException("error data null"))
} }
"encryptHexAwait" -> { "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" -> { "createSignHexAwait" -> {
createSign(jsParam[0]).setPublicKey(jsParam[1]).setPrivateKey(jsParam[2]) createSign(p0 ?: throw NoStackTraceException("error algorithm null"))
.signHex(jsParam[3]) .setPublicKey(p1 ?: throw NoStackTraceException("error publicKey null"))
.setPrivateKey(p2 ?: throw NoStackTraceException("error privateKey null"))
.signHex(p3 ?: throw NoStackTraceException("error data null"))
} }
"downloadFileAwait" -> { "downloadFileAwait" -> {
downloadFile(jsParam[0]) downloadFile(p0 ?: throw NoStackTraceException("error url null"))
} }
"readTxtFileAwait" -> { "readTxtFileAwait" -> {
readTxtFile(jsParam[0]) readTxtFile(p0 ?: throw NoStackTraceException("error path null"))
} }
"importScriptAwait" -> { "importScriptAwait" -> {
importScript(jsParam[0]) importScript(p0 ?: throw NoStackTraceException("error path null"))
} }
"getStringAwait" -> AnalyzeRule(book, getSource()).run { "getStringAwait" -> {
setCoroutineContext(coroutineContext) analyzeRule.setCoroutineContext(coroutineContext)
setChapter(chapter) .getString(p0, p1)
getString(jsParam[0], jsParam[1])
} }
else -> "error funName" else -> throw NoStackTraceException("error funName")
} }
}.onSuccess { data -> }.onSuccess { data ->
webView.evaluateJavascript( CacheManager.putMemory(id, data)
"window.$JSBridgeResult('$id', '${data.escapeForJs()}', null);", webViewRef.get()?.evaluateJavascript("window.$JSBridgeResult('$id', true);", null)
null
)
}.onError { }.onError {
webView.evaluateJavascript( CacheManager.putMemory(id, it.localizedMessage ?: "err")
"window.$JSBridgeResult('$id', null, '${it.localizedMessage?.escapeForJs()}');", webViewRef.get()?.evaluateJavascript("window.$JSBridgeResult('$id', false);", null)
null
)
} }
} }
@JavascriptInterface @JavascriptInterface
fun toast(msg: String?) { fun toast(msg: String?) {
super.toast(msg) super.toast(msg)
} }
@JavascriptInterface @JavascriptInterface
fun longToast(msg: String?) { fun longToast(msg: String?) {
super.longToast(msg) 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 @JavascriptInterface
fun log(msg: String?): String { fun log(msg: String?): String {
return super.log(msg).toString() return super.log(msg).toString()
} }
@JavascriptInterface @JavascriptInterface
fun ajax(url: String): String? { @JvmOverloads
return super.ajax(url, 9000) fun ajax(url: String, callTimeout: Int = 9000): String? {
return super.ajax(url, callTimeout.toLong())
} }
@JavascriptInterface @JavascriptInterface
fun ajax(url: String, callTimeout: Int?): String? { @JvmOverloads
return super.ajax(url, callTimeout?.toLong()) fun connect(urlStr: String, header: String? = null, callTimeout: Int? = 9000): String {
}
@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 {
return super.connect(urlStr, header, callTimeout?.toLong()).toString() return super.connect(urlStr, header, callTimeout?.toLong()).toString()
} }
@JavascriptInterface @JavascriptInterface
fun get(urlStr: String, headers: String): String { @JvmOverloads
val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap() fun get(urlStr: String, header: String, timeout: Int? = 9000): String {
return super.get(urlStr, headerMap, 9000).body() val headers = GSON.fromJsonObject<Map<String, String>>(header).getOrNull() ?: emptyMap()
return super.get(urlStr, headers, timeout).body()
} }
@JavascriptInterface @JavascriptInterface
fun get(urlStr: String, headers: String, timeout: Int?): String { @JvmOverloads
val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap() fun post(urlStr: String, body: String, header: String, timeout: Int? = 9000): String {
return super.get(urlStr, headerMap, timeout).body() val headers = GSON.fromJsonObject<Map<String, String>>(header).getOrNull() ?: emptyMap()
return super.post(urlStr, body, headers, timeout).body()
} }
@JavascriptInterface @JavascriptInterface
fun post(urlStr: String, body: String, headers: String): String { @JvmOverloads
val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap() fun head(urlStr: String, header: String, timeout: Int? = 9000): String {
return super.post(urlStr, body, headerMap, 9000).body() val headers = GSON.fromJsonObject<Map<String, String>>(header).getOrNull() ?: emptyMap()
return GSON.toJson(super.head(urlStr, headers, timeout).headers())
} }
@JavascriptInterface @JavascriptInterface
fun post(urlStr: String, body: String, headers: String, timeout: Int?): String { fun getStringList(rule: String?, mContent: String? = null, isUrl: Boolean = false): List<String>? {
val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap() return super.getStringList(rule, mContent, isUrl)
return super.post(urlStr, body, headerMap, timeout).body()
} }
@JavascriptInterface @JavascriptInterface
fun head(urlStr: String, headers: String): String { fun getString(ruleStr: String?, mContent: String? = null, isUrl: Boolean = false): String {
val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap() return super.getString(ruleStr, mContent, isUrl)
return GSON.toJson(super.head(urlStr, headerMap, 9000).headers())
}
@JavascriptInterface
fun head(urlStr: String, headers: String, timeout: Int?): String {
val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
return GSON.toJson(super.head(urlStr, headerMap, timeout).headers())
} }
companion object { companion object {
@@ -218,6 +280,7 @@ class WebJsExtensions(
val letters = "abcdefghijklmnopqrstuvwxyz" val letters = "abcdefghijklmnopqrstuvwxyz"
return letters.random() return letters.random()
} }
val uuid by lazy { val uuid by lazy {
UUID.randomUUID().toString().replace('-', getRandomLetter()).chunked(6) UUID.randomUUID().toString().replace('-', getRandomLetter()).chunked(6)
} }
@@ -234,9 +297,12 @@ class WebJsExtensions(
"<script src=\"$nameUrl\"></script>" "<script src=\"$nameUrl\"></script>"
} }
val getInjectionString = "try{var cache=$nameCache,source=$nameSource,java=$nameJava;}catch(e){}"
val JS_INJECTION by lazy { val JS_INJECTION by lazy {
""" """
const requestId = n => 'req_' + n + '_' + Date.now() + '_' + Math.random().toString(36).slice(-3); 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 JSBridgeCallbacks = {};
const java = window.$nameJava; const java = window.$nameJava;
delete window.$nameJava; delete window.$nameJava;
@@ -248,122 +314,124 @@ class WebJsExtensions(
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("run"); const id = requestId("run");
JSBridgeCallbacks[id] = { resolve, reject }; 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) => { return new Promise((resolve, reject) => {
const id = requestId("ajaxAwait"); const id = requestId("ajaxAwait");
JSBridgeCallbacks[id] = { resolve, reject }; 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) => { return new Promise((resolve, reject) => {
const id = requestId("connectAwait"); const id = requestId("connectAwait");
JSBridgeCallbacks[id] = { resolve, reject }; 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) => { return new Promise((resolve, reject) => {
const id = requestId("getAwait"); const id = requestId("getAwait");
JSBridgeCallbacks[id] = { resolve, reject }; 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) => { return new Promise((resolve, reject) => {
const id = requestId("headAwait"); const id = requestId("headAwait");
JSBridgeCallbacks[id] = { resolve, reject }; 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) => { return new Promise((resolve, reject) => {
const id = requestId("postAwait"); const id = requestId("postAwait");
JSBridgeCallbacks[id] = { resolve, reject }; 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) => { return new Promise((resolve, reject) => {
const id = requestId("webViewAwait"); const id = requestId("webViewAwait");
JSBridgeCallbacks[id] = { resolve, reject }; 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) => { return new Promise((resolve, reject) => {
const id = requestId("webViewGetSourceAwait"); const id = requestId("webViewGetSourceAwait");
JSBridgeCallbacks[id] = { resolve, reject }; 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) => { return new Promise((resolve, reject) => {
const id = requestId("decryptStrAwait"); const id = requestId("decryptStrAwait");
JSBridgeCallbacks[id] = { resolve, reject }; 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) => { return new Promise((resolve, reject) => {
const id = requestId("encryptBase64Await"); const id = requestId("encryptBase64Await");
JSBridgeCallbacks[id] = { resolve, reject }; 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) => { return new Promise((resolve, reject) => {
const id = requestId("encryptHexAwait"); const id = requestId("encryptHexAwait");
JSBridgeCallbacks[id] = { resolve, reject }; 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) => { return new Promise((resolve, reject) => {
const id = requestId("createSignHexAwait"); const id = requestId("createSignHexAwait");
JSBridgeCallbacks[id] = { resolve, reject }; 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) { function downloadFileAwait(url) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("downloadFileAwait"); const id = requestId("downloadFileAwait");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("downloadFileAwait", [String(url)], id); java.request("downloadFileAwait", [String(url)], id);
}); });
}; };
function readTxtFileAwait(path) { function readTxtFileAwait(path) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("readTxtFileAwait"); const id = requestId("readTxtFileAwait");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("readTxtFileAwait", [String(path)], id); java.request("readTxtFileAwait", [String(path)], id);
}); });
}; };
function importScriptAwait(url) { function importScriptAwait(url) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("importScriptAwait"); const id = requestId("importScriptAwait");
JSBridgeCallbacks[id] = { resolve, reject }; 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) => { return new Promise((resolve, reject) => {
const id = requestId("getStringAwait"); const id = requestId("getStringAwait");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("getStringAwait", [String(ruleStr), String(mContent)], id); java.request("getStringAwait", params(args), id);
}); });
}; };
window.$JSBridgeResult = function(requestId, result, error) { window.$JSBridgeResult = function(id, success) {
if (JSBridgeCallbacks[requestId]) { const callBack = JSBridgeCallbacks[id];
if (error) { if (callBack) {
JSBridgeCallbacks[requestId].reject(error); const result = cache.getFromMemory(id);
if (success) {
callBack.resolve(result);
} else { } else {
JSBridgeCallbacks[requestId].resolve(result); callBack.reject(result);
} }
delete JSBridgeCallbacks[requestId]; delete JSBridgeCallbacks[id];
} }
};""".trimIndent() };""".trimIndent()
} }
@@ -374,21 +442,25 @@ class WebJsExtensions(
const JSBridgeCallbacks = {}; const JSBridgeCallbacks = {};
const java = window.$nameJava; const java = window.$nameJava;
delete window.$nameJava; delete window.$nameJava;
const cache = window.$nameCache;
delete window.$nameCache;
function run(jsCode) { function run(jsCode) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = requestId("run"); const id = requestId("run");
JSBridgeCallbacks[id] = { resolve, reject }; JSBridgeCallbacks[id] = { resolve, reject };
java?.request("run", [String(jsCode)], id); java.request("run", [String(jsCode)], id);
}); });
}; };
window.$JSBridgeResult = function(requestId, result, error) { window.$JSBridgeResult = function(id, success) {
if (JSBridgeCallbacks[requestId]) { const callBack = JSBridgeCallbacks[id];
if (error) { if (callBack) {
JSBridgeCallbacks[requestId].reject(error); const result = cache.getFromMemory(id);
if (success) {
callBack.resolve(result);
} else { } else {
JSBridgeCallbacks[requestId].resolve(result); callBack.reject(result);
} }
delete JSBridgeCallbacks[requestId]; delete JSBridgeCallbacks[id];
} }
};""".trimIndent() };""".trimIndent()
} }
@@ -59,6 +59,7 @@ object AudioPlay : CoroutineScope by MainScope() {
var durChapterPos = 0 var durChapterPos = 0
var durChapter: BookChapter? = null var durChapter: BookChapter? = null
var durPlayUrl = "" var durPlayUrl = ""
var durLyric: String? = null
var durAudioSize = 0 var durAudioSize = 0
var inBookshelf = false var inBookshelf = false
var bookSource: BookSource? = null var bookSource: BookSource? = null
@@ -153,14 +154,23 @@ object AudioPlay : CoroutineScope by MainScope() {
.onSuccess { content -> .onSuccess { content ->
if (content.isEmpty()) { if (content.isEmpty()) {
appCtx.toastOnUi("未获取到资源链接") appCtx.toastOnUi("未获取到资源链接")
// 加载失败时清空歌词
durLyric = null
callback?.upLyric(null)
} else { } else {
contentLoadFinish(chapter, content) contentLoadFinish(chapter, content)
} }
}.onError { }.onError { error ->
AppLog.put("获取资源链接出错\n$it", it, true) AppLog.put("获取资源链接出错\n$error", error, true)
upLoading(false) upLoading(false)
// 加载失败时清空歌词
durLyric = null
callback?.upLyric(null)
}.onCancel { }.onCancel {
removeLoading(index) removeLoading(index)
// 取消加载时清空歌词
durLyric = null
callback?.upLyric(null)
}.onFinally { }.onFinally {
removeLoading(index) removeLoading(index)
} }
@@ -177,7 +187,10 @@ object AudioPlay : CoroutineScope by MainScope() {
private fun contentLoadFinish(chapter: BookChapter, content: String) { private fun contentLoadFinish(chapter: BookChapter, content: String) {
if (chapter.index == book?.durChapterIndex) { if (chapter.index == book?.durChapterIndex) {
durPlayUrl = content durPlayUrl = content
durLyric = chapter.getVariable("lyric").takeIf { it.isNotBlank() }
upPlayUrl() upPlayUrl()
// 重新加载歌词,确保循环播放时歌词进度重置
callback?.upLyric(durLyric)
} }
} }
@@ -397,6 +410,8 @@ object AudioPlay : CoroutineScope by MainScope() {
fun register(context: Context) { fun register(context: Context) {
activityContext = context activityContext = context
callback = context as CallBack callback = context as CallBack
// 主动更新歌词状态,确保重新进入时歌词控件能正确显示
callback?.upLyric(durLyric)
} }
fun unregister(context: Context) { fun unregister(context: Context) {
@@ -418,6 +433,8 @@ object AudioPlay : CoroutineScope by MainScope() {
interface CallBack { interface CallBack {
fun upLoading(loading: Boolean) fun upLoading(loading: Boolean)
fun upLyric(lyric: String?)
fun upLyricP(position: Int)
} }
@@ -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( private fun rssContentDebug(
scope: CoroutineScope, scope: CoroutineScope,
rssArticle: RssArticle, rssArticle: RssArticle,
@@ -24,10 +24,11 @@ object Rss {
sortUrl: String, sortUrl: String,
rssSource: RssSource, rssSource: RssSource,
page: Int, page: Int,
searchKey: String? = null,
context: CoroutineContext = Dispatchers.IO context: CoroutineContext = Dispatchers.IO
): Coroutine<Pair<MutableList<RssArticle>, String?>> { ): Coroutine<Pair<MutableList<RssArticle>, String?>> {
return Coroutine.async(scope, context) { return Coroutine.async(scope, context) {
getArticlesAwait(sortName, sortUrl, rssSource, page) getArticlesAwait(sortName, sortUrl, rssSource, page, searchKey)
} }
} }
@@ -36,8 +37,13 @@ object Rss {
sortUrl: String, sortUrl: String,
rssSource: RssSource, rssSource: RssSource,
page: Int, page: Int,
searchKey: String? = null
): Pair<MutableList<RssArticle>, String?> { ): Pair<MutableList<RssArticle>, String?> {
val ruleData = RuleData() val ruleData = RuleData()
if (!searchKey.isNullOrBlank()) {
ruleData.putVariable("searchKey", searchKey)
ruleData.putVariable("key", searchKey)
}
val analyzeUrl = AnalyzeUrl( val analyzeUrl = AnalyzeUrl(
sortUrl, sortUrl,
page = page, page = page,
@@ -211,9 +211,9 @@ object BookContent {
if (!book.isAudio && !book.isVideo) { //音频和视频获取的是链接,不需要html格式化 && !book.isVideo if (!book.isAudio && !book.isVideo) { //音频和视频获取的是链接,不需要html格式化 && !book.isVideo
val useHtmlMap = mutableMapOf<String, String>() val useHtmlMap = mutableMapOf<String, String>()
if (AppConfig.adaptSpecialStyle) { if (AppConfig.adaptSpecialStyle) {
content = AppPattern.useHtmlRegex.replace(content) { matchResult -> content = AppPattern.useHtmlRegex.replace(content) {
val placeholder = "{usehtml_${useHtmlMap.size}}" val placeholder = "{usehtml_${useHtmlMap.size}}"
useHtmlMap[placeholder] = matchResult.value useHtmlMap[placeholder] = it.value
placeholder placeholder
} }
} }
@@ -225,6 +225,13 @@ object BookContent {
content = content.replace(placeholder, originalContent) 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) { if (getNextPageUrl) {
val nextUrlRule = contentRule.nextContentUrl val nextUrlRule = contentRule.nextContentUrl
@@ -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.topbar.TopBarNavigationButton
import io.legado.app.ui.widget.components.card.TextCard 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.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.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar 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.GlassTopAppBarDefaults
@@ -132,57 +133,57 @@ fun AboutScreen(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
title = "" title = ""
){ ){
SettingItemWithDivider {
SettingItem( SettingItem(
title = stringResource(R.string.contributors), title = stringResource(R.string.contributors),
description = null,
option = null,
onClick = { onClick = {
onOpenUrl("https://github.com/gedoor/legado/graphs/contributors") onOpenUrl("https://github.com/gedoor/legado/graphs/contributors")
} }
) )
}
SettingItemWithDivider {
SettingItem( SettingItem(
title = stringResource(R.string.privacy_policy), title = stringResource(R.string.privacy_policy),
description = null,
option = null,
onClick = { onClick = {
onShowMdFile("隐私政策", "privacyPolicy.md") onShowMdFile("隐私政策", "privacyPolicy.md")
} }
) )
}
SettingItemWithDivider {
SettingItem( SettingItem(
title = stringResource(R.string.license), title = stringResource(R.string.license),
description = null,
option = null,
onClick = { onClick = {
onShowMdFile("许可证", "LICENSE.md") onShowMdFile("许可证", "LICENSE.md")
} }
) )
}
SettingItemWithDivider {
SettingItem( SettingItem(
title = stringResource(R.string.disclaimer), title = stringResource(R.string.disclaimer),
description = null,
option = null,
onClick = { onClick = {
onShowMdFile("免责声明", "disclaimer.md") onShowMdFile("免责声明", "disclaimer.md")
} }
) )
}
SettingItemWithDivider {
SettingItem( SettingItem(
title = stringResource(R.string.crash_log), title = stringResource(R.string.crash_log),
description = null,
option = null,
onClick = onShowCrashLogs onClick = onShowCrashLogs
) )
}
SettingItemWithDivider {
SettingItem( SettingItem(
title = stringResource(R.string.save_log), title = stringResource(R.string.save_log),
description = null,
option = null,
onClick = onSaveLog onClick = onSaveLog
) )
}
SettingItemWithDivider {
SettingItem( SettingItem(
title = stringResource(R.string.create_heap_dump), title = stringResource(R.string.create_heap_dump),
description = null,
option = null,
onClick = onCreateHeapDump onClick = onCreateHeapDump
) )
} }
} }
} }
}
} }
@@ -75,7 +75,7 @@ fun CrashReportScreen(
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
Text( AppText(
text = displayText, text = displayText,
style = LegadoTheme.typography.bodySmall, style = LegadoTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace
@@ -25,10 +25,13 @@ import androidx.lifecycle.lifecycleScope
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import com.google.android.material.color.DynamicColorsOptions import com.google.android.material.color.DynamicColorsOptions
import androidx.constraintlayout.widget.ConstraintLayout
import com.google.android.material.color.MaterialColors import com.google.android.material.color.MaterialColors
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import com.google.android.material.transition.platform.MaterialContainerTransform import com.google.android.material.transition.platform.MaterialContainerTransform
import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback 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.R
import io.legado.app.base.VMBaseActivity import io.legado.app.base.VMBaseActivity
import io.legado.app.constant.BookType 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.isAudio
import io.legado.app.help.book.removeType import io.legado.app.help.book.removeType
import io.legado.app.help.config.AppConfig 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.AudioPlay
import io.legado.app.model.BookCover import io.legado.app.model.BookCover
import io.legado.app.model.SourceCallBack 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.StartActivityContract
import io.legado.app.utils.ToolbarUtils.setAllIconsColor import io.legado.app.utils.ToolbarUtils.setAllIconsColor
import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.applyNavigationBarPadding
import io.legado.app.utils.dpToPx
import io.legado.app.utils.gone import io.legado.app.utils.gone
import io.legado.app.utils.observeEvent import io.legado.app.utils.observeEvent
import io.legado.app.utils.observeEventSticky import io.legado.app.utils.observeEventSticky
@@ -96,6 +101,9 @@ class AudioPlayActivity :
private var secondaryContainerFinalColor: Int = 0 private var secondaryContainerFinalColor: Int = 0
private var currentJob: Job? = null private var currentJob: Job? = null
private var wrappedContext: Context? = 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 { private val progressTimeFormat by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
@@ -227,6 +235,21 @@ class AudioPlayActivity :
} }
private fun initView() { 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.ivTimer.isEnabled = false
binding.ivFastForward.isEnabled = false binding.ivFastForward.isEnabled = false
@@ -279,7 +302,10 @@ class AudioPlayActivity :
override fun onStopTrackingTouch(slider: Slider) { override fun onStopTrackingTouch(slider: Slider) {
adjustProgress = false 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.playerProgress.trackActiveTintList = ColorStateList.valueOf(color)
binding.tvDurTime.setTextColor(color) binding.tvDurTime.setTextColor(color)
binding.tvAllTime.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) val safeValue = progress.toFloat().coerceIn(slider.valueFrom, slider.valueTo)
if (!adjustProgress) slider.value = safeValue if (!adjustProgress) slider.value = safeValue
binding.tvDurTime.text = progressTimeFormat.format(progress.toLong()) binding.tvDurTime.text = progressTimeFormat.format(progress.toLong())
// 更新歌词进度
upLyricP(progress)
} }
observeEventSticky<Int>(EventBus.AUDIO_BUFFER_PROGRESS) { observeEventSticky<Int>(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<BookChapter>) { override fun addToBookshelf(book: Book, toc: List<BookChapter>) {
viewModel.addToBookshelf(book, toc) { viewModel.addToBookshelf(book, toc) {
toastOnUi("已添加到书架") toastOnUi("已添加到书架")
@@ -72,6 +72,8 @@ import io.legado.app.constant.BookType
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.help.book.isLocal 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.about.AppLogSheet
import io.legado.app.ui.config.coverConfig.CoverConfig import io.legado.app.ui.config.coverConfig.CoverConfig
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
@@ -402,6 +404,9 @@ private fun BookInfoTransparentTopAppBar(
@Composable @Composable
private fun rememberBookInfoColorTheme(book: Book?): ThemeOverrideState? { private fun rememberBookInfoColorTheme(book: Book?): ThemeOverrideState? {
val useDefaultCover = AppConfig.useDefaultCover || book?.customCoverUrl == "use_default_cover"
if (useDefaultCover) return null
val imageLoader = koinInject<ImageLoader>() val imageLoader = koinInject<ImageLoader>()
var shouldExtractColor by remember(book?.bookUrl) { mutableStateOf(false) } 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 sourceOrigin = if (shouldExtractColor) book?.origin else null
val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi
val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) { val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) {
@@ -469,8 +479,15 @@ private fun BookInfoTopBarActions(
@Composable @Composable
private fun BookInfoBackdrop(book: Book) { private fun BookInfoBackdrop(book: Book) {
val cover = book.getDisplayCover() val useDefaultCover = AppConfig.useDefaultCover || book.customCoverUrl == "use_default_cover"
val sourceOrigin = book.origin 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 loadOnlyWifi = CoverConfig.loadCoverOnlyWifi
val context = LocalContext.current val context = LocalContext.current
val imageLoader = koinInject<ImageLoader>() val imageLoader = koinInject<ImageLoader>()
@@ -709,19 +709,30 @@ class BookInfoViewModel(
private fun refreshMeta(book: Book) { private fun refreshMeta(book: Book) {
execute { 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) { if (book.isLocal) {
val size = FileDoc.fromFile(book.bookUrl).size val size = FileDoc.fromFile(book.bookUrl).size
if (size > 0) { if (size > 0) {
kinds.add(ConvertUtils.formatFileSize(size)) sourceKinds.add(ConvertUtils.formatFileSize(size))
} }
} }
val mergedKinds = (customKinds + sourceKinds).distinct()
val userGroupIds = appDb.bookGroupDao.idsSum val userGroupIds = appDb.bookGroupDao.idsSum
val groupAnd = userGroupIds and book.group val groupAnd = userGroupIds and book.group
val hasCustomGroup = book.group > 0L && groupAnd != 0L val hasCustomGroup = book.group > 0L && groupAnd != 0L
val groupNames = appDb.bookGroupDao.getGroupNames(book.group).joinToString(",") val groupNames = appDb.bookGroupDao.getGroupNames(book.group).joinToString(",")
val normalizedGroupNames = groupNames.ifBlank { null } 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 { }.onSuccess {
currentKindLabels = it.first currentKindLabels = it.first
currentGroupNames = it.second currentGroupNames = it.second
@@ -3,6 +3,7 @@ package io.legado.app.ui.book.info.edit
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets 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.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.FolderOpen
import androidx.compose.material.icons.filled.ImageSearch import androidx.compose.material.icons.filled.ImageSearch
import androidx.compose.material.icons.filled.Replay 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.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -35,6 +40,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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.settingItem.SwitchSettingItem
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar 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.GlassTopAppBarDefaults
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.utils.SelectImageContract import io.legado.app.utils.SelectImageContract
import io.legado.app.utils.launch import io.legado.app.utils.launch
import io.legado.app.utils.showDialogFragment import io.legado.app.utils.showDialogFragment
@@ -174,10 +181,17 @@ fun BookInfoEditContent(
onCheckedChange = { viewModel.onFixedTypeChange(it) } onCheckedChange = { viewModel.onFixedTypeChange(it) }
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
val bookInfoInputColor = ThemeConfig.bookInfoInputColor
val inputBackgroundColor = if (bookInfoInputColor != 0) {
Color(bookInfoInputColor)
} else {
Color.Unspecified
}
AppTextField( AppTextField(
value = uiState.name, value = uiState.name,
onValueChange = { viewModel.onNameChange(it) }, onValueChange = { viewModel.onNameChange(it) },
label = "书名", label = "书名",
backgroundColor = inputBackgroundColor,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -185,6 +199,7 @@ fun BookInfoEditContent(
value = uiState.author, value = uiState.author,
onValueChange = { viewModel.onAuthorChange(it) }, onValueChange = { viewModel.onAuthorChange(it) },
label = "作者", label = "作者",
backgroundColor = inputBackgroundColor,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -192,13 +207,21 @@ fun BookInfoEditContent(
value = uiState.coverUrl ?: "", value = uiState.coverUrl ?: "",
onValueChange = { viewModel.onCoverUrlChange(it) }, onValueChange = { viewModel.onCoverUrlChange(it) },
label = "封面链接", label = "封面链接",
backgroundColor = inputBackgroundColor,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
KindEditor(
kindList = uiState.kindList,
onKindListChange = { viewModel.onKindListChange(it) },
backgroundColor = inputBackgroundColor
)
Spacer(modifier = Modifier.height(8.dp))
AppTextField( AppTextField(
value = uiState.intro ?: "", value = uiState.intro ?: "",
onValueChange = { viewModel.onIntroChange(it) }, onValueChange = { viewModel.onIntroChange(it) },
label = "简介", label = "简介",
backgroundColor = inputBackgroundColor,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -206,6 +229,7 @@ fun BookInfoEditContent(
value = uiState.remark ?: "", value = uiState.remark ?: "",
onValueChange = { viewModel.onRemarkChange(it) }, onValueChange = { viewModel.onRemarkChange(it) },
label = "备注", label = "备注",
backgroundColor = inputBackgroundColor,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
@@ -228,6 +252,13 @@ fun BookTypeDropdown(
textFieldState.setTextAndPlaceCursorAtEnd(selectedType) textFieldState.setTextAndPlaceCursorAtEnd(selectedType)
} }
val bookInfoInputColor = ThemeConfig.bookInfoInputColor
val inputBackgroundColor = if (bookInfoInputColor != 0) {
Color(bookInfoInputColor)
} else {
Color.Unspecified
}
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
modifier = Modifier.padding(horizontal = 8.dp), modifier = Modifier.padding(horizontal = 8.dp),
expanded = expanded, expanded = expanded,
@@ -238,6 +269,7 @@ fun BookTypeDropdown(
readOnly = true, readOnly = true,
lineLimits = TextFieldLineLimits.SingleLine, lineLimits = TextFieldLineLimits.SingleLine,
label = "书籍类型", label = "书籍类型",
backgroundColor = inputBackgroundColor,
trailingIcon = { trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
}, },
@@ -265,3 +297,90 @@ fun BookTypeDropdown(
} }
} }
} }
@Composable
fun KindEditor(
kindList: List<String>,
onKindListChange: (List<String>) -> 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<String> { !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)
)
}
}
}
}
}
}
}
@@ -19,6 +19,7 @@ import io.legado.app.model.ReadBook
import io.legado.app.utils.FileUtils import io.legado.app.utils.FileUtils
import io.legado.app.utils.MD5Utils import io.legado.app.utils.MD5Utils
import io.legado.app.utils.inputStream import io.legado.app.utils.inputStream
import io.legado.app.utils.splitNotBlank
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -31,6 +32,7 @@ data class BookInfoEditUiState(
val coverUrl: String? = null, val coverUrl: String? = null,
val intro: String? = null, val intro: String? = null,
val remark: String? = null, val remark: String? = null,
val kindList: List<String> = emptyList(),
val selectedType: String = "文本", val selectedType: String = "文本",
val bookTypes: List<String> = listOf("文本", "音频", "图片"), val bookTypes: List<String> = listOf("文本", "音频", "图片"),
val fixedType: Boolean = false, val fixedType: Boolean = false,
@@ -57,6 +59,7 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio
coverUrl = it.getDisplayCover(), coverUrl = it.getDisplayCover(),
intro = it.getDisplayIntro(), intro = it.getDisplayIntro(),
remark = it.remark, remark = it.remark,
kindList = it.kind?.split(",", "\n")?.filter { kind -> kind.isNotBlank() }.orEmpty(),
selectedType = _uiState.value.bookTypes[selectedTypeIndex], selectedType = _uiState.value.bookTypes[selectedTypeIndex],
fixedType = it.config.fixedType, fixedType = it.config.fixedType,
book = it book = it
@@ -85,6 +88,10 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio
_uiState.value = _uiState.value.copy(remark = remark) _uiState.value = _uiState.value.copy(remark = remark)
} }
fun onKindListChange(kindList: List<String>) {
_uiState.value = _uiState.value.copy(kindList = kindList)
}
fun onBookTypeChange(bookType: String) { fun onBookTypeChange(bookType: String) {
_uiState.value = _uiState.value.copy(selectedType = bookType) _uiState.value = _uiState.value.copy(selectedType = bookType)
} }
@@ -116,6 +123,7 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio
book.config.fixedType = currentState.fixedType book.config.fixedType = currentState.fixedType
book.customCoverUrl = if (currentState.coverUrl == book.coverUrl) null else currentState.coverUrl book.customCoverUrl = if (currentState.coverUrl == book.coverUrl) null else currentState.coverUrl
book.customIntro = if (currentState.intro == book.intro) null else currentState.intro book.customIntro = if (currentState.intro == book.intro) null else currentState.intro
book.kind = currentState.kindList.joinToString(",")
BookHelp.updateCacheFolder(oldBook, book) BookHelp.updateCacheFolder(oldBook, book)
if (ReadBook.book?.bookUrl == book.bookUrl) { if (ReadBook.book?.bookUrl == book.bookUrl) {
@@ -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.ReadStyleDialog
import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.A_COLOR import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.A_COLOR
import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.B_COLOR import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.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.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.ToolButtonConfigDialog
import io.legado.app.ui.book.read.config.UnderlineConfigDialog.Companion.U_COLOR import io.legado.app.ui.book.read.config.UnderlineConfigDialog.Companion.U_COLOR
import io.legado.app.ui.book.read.page.ContentTextView import io.legado.app.ui.book.read.page.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.PageDirection
import io.legado.app.ui.book.read.page.entities.TextPage 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.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.read.page.provider.LayoutProgressListener
import io.legado.app.ui.book.searchContent.SearchContentActivity import io.legado.app.ui.book.searchContent.SearchContentActivity
import io.legado.app.ui.book.searchContent.SearchResult import io.legado.app.ui.book.searchContent.SearchResult
@@ -1698,8 +1703,14 @@ class ReadBookActivity : BaseReadBookActivity(),
} }
} }
TIP_COLOR -> { TIP_HEADER_COLOR -> {
ReadTipConfig.tipColor = 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.TIP_COLOR, "")
postEvent(EventBus.UP_CONFIG, arrayListOf(2)) postEvent(EventBus.UP_CONFIG, arrayListOf(2))
} }
@@ -1710,6 +1721,24 @@ class ReadBookActivity : BaseReadBookActivity(),
postEvent(EventBus.UP_CONFIG, arrayListOf(2)) 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 -> { B_COLOR -> {
setMenuCurBg(color) setMenuCurBg(color)
postEvent(EventBus.UPDATE_READ_ACTION_BAR, true) postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
@@ -107,6 +107,10 @@ class FontConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_font_conf
callBack2?.showShadowSet() callBack2?.showShadowSet()
} }
binding.btnRegexColor.setOnClickListener {
RegexColorConfigDialog().show(childFragmentManager, "regexColorConfig")
}
binding.btnSelectFonts.setOnClickListener { binding.btnSelectFonts.setOnClickListener {
callBack2?.showFontSelect() callBack2?.showFontSelect()
} }
@@ -6,6 +6,7 @@ import android.os.Bundle
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.widget.addTextChangedListener
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
@@ -50,6 +51,7 @@ class FontSelectDialog : BaseBottomSheetDialogFragment(R.layout.dialog_font_sele
val curFontPath = callBack?.curFontPath ?: "" val curFontPath = callBack?.curFontPath ?: ""
FontAdapter(requireContext(), curFontPath) FontAdapter(requireContext(), curFontPath)
} }
private var allFontItems: List<FileDoc> = emptyList()
private val selectFontDir = registerForActivityResult(HandleFileContract()) { private val selectFontDir = registerForActivityResult(HandleFileContract()) {
it.uri?.let { uri -> it.uri?.let { uri ->
@@ -76,6 +78,22 @@ class FontSelectDialog : BaseBottomSheetDialogFragment(R.layout.dialog_font_sele
binding.recyclerView.layoutManager = GridLayoutManager(context, 2) binding.recyclerView.layoutManager = GridLayoutManager(context, 2)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
initView() 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() { private fun initView() {
@@ -135,6 +153,7 @@ class FontSelectDialog : BaseBottomSheetDialogFragment(R.layout.dialog_font_sele
} ?: ArrayList() } ?: ArrayList()
mergeFontItems(fontItems, getLocalFonts()) mergeFontItems(fontItems, getLocalFonts())
}.onSuccess { }.onSuccess {
allFontItems = it
adapter.setItems(it) adapter.setItems(it)
}.onError { }.onError {
AppLog.put("加载字体文件失败\n${it.localizedMessage}", it) AppLog.put("加载字体文件失败\n${it.localizedMessage}", it)
@@ -176,8 +195,10 @@ class FontSelectDialog : BaseBottomSheetDialogFragment(R.layout.dialog_font_sele
callBack?.selectFont("") callBack?.selectFont("")
} }
var explicitCallback: CallBack? = null
private val callBack: CallBack? 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) : inner class FontAdapter(context: Context, curFilePath: String) :
RecyclerAdapter<FileDoc, ItemFontBinding>(context) { RecyclerAdapter<FileDoc, ItemFontBinding>(context) {
@@ -11,23 +11,32 @@ import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.config.ReadTipConfig import io.legado.app.help.config.ReadTipConfig
import io.legado.app.lib.dialogs.selector import io.legado.app.lib.dialogs.selector
import io.legado.app.ui.book.read.ReadBookActivity 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.ui.book.read.config.TipConfigDialog.Companion.TIP_DIVIDER_COLOR
import io.legado.app.utils.getCompatColor import io.legado.app.utils.getCompatColor
import io.legado.app.utils.observeEvent import io.legado.app.utils.observeEvent
import io.legado.app.utils.postEvent import io.legado.app.utils.postEvent
import io.legado.app.utils.viewbindingdelegate.viewBinding 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 binding by viewBinding(DialogReadInfoBinding::bind)
private val callBack get() = activity as? ReadBookActivity 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?) { override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
initView() initView()
initEvent() initEvent()
observeEvent<String>(EventBus.TIP_COLOR) { observeEvent<String>(EventBus.TIP_COLOR) {
upTvTipColor() upTvHeaderColor()
upTvFooterColor()
upTvTipDividerColor() upTvTipDividerColor()
} }
observeEvent<ArrayList<Int>>(EventBus.UP_CONFIG) { list -> observeEvent<ArrayList<Int>>(EventBus.UP_CONFIG) { list ->
@@ -59,17 +68,28 @@ class InfoConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_info
callBack?.showPaddingConfig() callBack?.showPaddingConfig()
dismissAllowingStateLoss() dismissAllowingStateLoss()
} }
upTvTipColor() upTvHeaderColor()
upTvFooterColor()
upTvTipDividerColor() upTvTipDividerColor()
binding.scvHeaderFontSize.progress = ReadBookConfig.headerFontSize
} }
private fun upTvTipColor() { private fun upTvHeaderColor() {
val tipColor = if (ReadTipConfig.tipColor == 0) { val tipColor = if (ReadTipConfig.tipHeaderColor == 0) {
ReadBookConfig.textColor ReadBookConfig.textColor
} else { } 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() { private fun upTvTipDividerColor() {
@@ -171,19 +191,36 @@ class InfoConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_info
postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6)) postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6))
} }
} }
btnTipColor.setOnClickListener { btnHeaderColor.setOnClickListener {
context?.selector(items = ReadTipConfig.tipColorNames) { _, i -> context?.selector(items = ReadTipConfig.tipColorNames) { _, i ->
when (i) { when (i) {
0 -> { 0 -> {
ReadTipConfig.tipColor = 0 ReadTipConfig.tipHeaderColor = 0
upTvTipColor() upTvHeaderColor()
postEvent(EventBus.UP_CONFIG, arrayListOf(2)) postEvent(EventBus.UP_CONFIG, arrayListOf(2))
} }
1 -> ColorPickerDialog.newBuilder() 1 -> ColorPickerDialog.newBuilder()
.setShowAlphaSlider(false) .setShowAlphaSlider(false)
.setDialogType(ColorPickerDialog.TYPE_CUSTOM) .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()) .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 { private fun clearRepeat(repeat: Int) = ReadTipConfig.apply {
@@ -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<RegexColorRuleAdapter.ViewHolder>() {
private var items: List<RegexColorRule> = emptyList()
fun setItems(items: List<RegexColorRule>) {
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<android.widget.TextView>(R.id.tv_rule_name)
val tvRulePattern = itemView.findViewById<android.widget.TextView>(R.id.tv_rule_pattern)
val btnSelectFont = itemView.findViewById<com.google.android.material.button.MaterialButton>(R.id.btn_select_font)
val btnSelectColor = itemView.findViewById<AccentColorButton>(R.id.btn_select_color)
val btnDelete = itemView.findViewById<android.widget.ImageButton>(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)
}
}
}
}
@@ -19,17 +19,27 @@ import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding 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 { 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 TIP_DIVIDER_COLOR = 7898
const val TITLE_COLOR = 7896
const val B_COLOR = 114 const val B_COLOR = 114
const val A_COLOR = 514 const val A_COLOR = 514
} }
private val binding by viewBinding(DialogTipConfigBinding::bind) 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() { override fun onStart() {
super.onStart() super.onStart()
} }
@@ -41,6 +51,10 @@ class TipConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_tip_config
binding.abtnBackgroundColor.color = ReadBookConfig.durConfig.curMenuBg() binding.abtnBackgroundColor.color = ReadBookConfig.durConfig.curMenuBg()
binding.abtnAccentColor.color = ReadBookConfig.durConfig.curMenuAc() binding.abtnAccentColor.color = ReadBookConfig.durConfig.curMenuAc()
} }
observeEvent<ArrayList<Int>>(EventBus.UP_CONFIG) {
val preview = if (ReadBookConfig.titleColor != 0) ReadBookConfig.titleColor else ReadBookConfig.textColor
binding.abtnTitleColor.color = preview or 0xFF000000.toInt()
}
} }
private fun initView() { 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 weightOptions = context?.resources?.getStringArray(R.array.text_font_weight)
val weightValues = listOf(0, 1, 2) 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 { binding.textFontWeightConverter.setOnClickListener {
context?.alert(titleResource = R.string.text_font_weight_converter) { context?.alert(titleResource = R.string.text_font_weight_converter) {
weightOptions?.let { options -> weightOptions?.let { options ->
items(options.toList()) { _, i -> items(options.toList()) { _, i ->
ReadBookConfig.titleBold = weightValues[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)) 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.abtnBackgroundColor.color = ReadBookConfig.durConfig.curMenuBg()
binding.abtnAccentColor.color = ReadBookConfig.durConfig.curMenuAc() binding.abtnAccentColor.color = ReadBookConfig.durConfig.curMenuAc()
binding.abtnBackgroundColor.setOnClickListener { binding.abtnBackgroundColor.setOnClickListener {
@@ -10,6 +10,7 @@ import io.legado.app.databinding.DialogUnderlineConfigBinding
import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.config.ReadBookConfig.dottedLine import io.legado.app.help.config.ReadBookConfig.dottedLine
import io.legado.app.help.config.ReadBookConfig.underline 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.observeEvent
import io.legado.app.utils.postEvent import io.legado.app.utils.postEvent
import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.viewbindingdelegate.viewBinding
@@ -39,6 +40,7 @@ class UnderlineConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_unde
binding.swUnderline.isChecked = underline binding.swUnderline.isChecked = underline
binding.swDottedline.isChecked = dottedLine binding.swDottedline.isChecked = dottedLine
binding.swDottedline.isEnabled = underline binding.swDottedline.isEnabled = underline
binding.swUnderlineExtend.isChecked = underlineExtend
binding.swUnderline.addOnCheckedChangeListener { _, isChecked -> binding.swUnderline.addOnCheckedChangeListener { _, isChecked ->
underline = isChecked underline = isChecked
@@ -55,6 +57,11 @@ class UnderlineConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_unde
postEvent(EventBus.UP_CONFIG, arrayListOf(6, 9, 11)) 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 { binding.btnDottedLineBlack.apply {
progress = ReadBookConfig.durConfig.dottedBase.toInt() progress = ReadBookConfig.durConfig.dottedBase.toInt()
onChanged = { onChanged = {
@@ -8,6 +8,7 @@ import android.view.LayoutInflater
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.drawable.toDrawable
import androidx.core.net.toUri
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isGone 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.applyStatusBarPadding
import io.legado.app.utils.dpToPx import io.legado.app.utils.dpToPx
import io.legado.app.utils.gone import io.legado.app.utils.gone
import io.legado.app.utils.isContentScheme
import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat
import io.legado.app.utils.statusBarHeight import io.legado.app.utils.statusBarHeight
import splitties.views.backgroundColor import splitties.views.backgroundColor
import java.io.File
import java.util.Date import java.util.Date
/** /**
@@ -91,8 +94,11 @@ class PageView(context: Context) : FrameLayout(context) {
upTipStyle() upTipStyle()
ReadBookConfig.let { ReadBookConfig.let {
val textColor = it.textColor val textColor = it.textColor
val tipColor = with(ReadTipConfig) { val headerColor = with(ReadTipConfig) {
if (tipColor == 0) textColor else tipColor if (tipHeaderColor == 0) textColor else tipHeaderColor
}
val footerColor = with(ReadTipConfig) {
if (tipFooterColor == 0) textColor else tipFooterColor
} }
val tipDividerColor = with(ReadTipConfig) { val tipDividerColor = with(ReadTipConfig) {
when (tipDividerColor) { when (tipDividerColor) {
@@ -101,12 +107,12 @@ class PageView(context: Context) : FrameLayout(context) {
else -> tipDividerColor else -> tipDividerColor
} }
} }
tvHeaderLeft.setColor(tipColor) tvHeaderLeft.setColor(headerColor)
tvHeaderMiddle.setColor(tipColor) tvHeaderMiddle.setColor(headerColor)
tvHeaderRight.setColor(tipColor) tvHeaderRight.setColor(headerColor)
tvFooterLeft.setColor(tipColor) tvFooterLeft.setColor(footerColor)
tvFooterMiddle.setColor(tipColor) tvFooterMiddle.setColor(footerColor)
tvFooterRight.setColor(tipColor) tvFooterRight.setColor(footerColor)
vwTopDivider.backgroundColor = tipDividerColor vwTopDivider.backgroundColor = tipDividerColor
vwBottomDivider.backgroundColor = tipDividerColor vwBottomDivider.backgroundColor = tipDividerColor
upStatusBar() upStatusBar()
@@ -131,6 +137,24 @@ class PageView(context: Context) : FrameLayout(context) {
upBattery(battery) 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 * 显示状态栏时隐藏header
*/ */
@@ -208,106 +232,108 @@ class PageView(context: Context) : FrameLayout(context) {
tvFooterRight.batteryMode = BatteryView.BatteryMode.NO_BATTERY tvFooterRight.batteryMode = BatteryView.BatteryMode.NO_BATTERY
} }
} }
val tipTypeface = loadTypeface(ReadBookConfig.headerFont) ?: ChapterProvider.typeface
val tipTextSize = ReadBookConfig.headerFontSize.toFloat()
tvTitle = getTipView(ReadTipConfig.chapterTitle)?.apply { tvTitle = getTipView(ReadTipConfig.chapterTitle)?.apply {
tag = ReadTipConfig.chapterTitle tag = ReadTipConfig.chapterTitle
typeface = ChapterProvider.typeface typeface = tipTypeface
textSize = 12f textSize = tipTextSize
batteryMode = BatteryView.BatteryMode.NO_BATTERY batteryMode = BatteryView.BatteryMode.NO_BATTERY
} }
tvTitleArrow = getTipView(ReadTipConfig.chapterTitleArrow)?.apply { tvTitleArrow = getTipView(ReadTipConfig.chapterTitleArrow)?.apply {
tag = ReadTipConfig.chapterTitleArrow tag = ReadTipConfig.chapterTitleArrow
typeface = Typeface.DEFAULT typeface = Typeface.DEFAULT
textSize = 12f textSize = tipTextSize
batteryMode = BatteryView.BatteryMode.ARROW batteryMode = BatteryView.BatteryMode.ARROW
} }
tvTitleArrowClassic = getTipView(ReadTipConfig.chapterTitleArrowClassic)?.apply { tvTitleArrowClassic = getTipView(ReadTipConfig.chapterTitleArrowClassic)?.apply {
tag = ReadTipConfig.chapterTitleArrowClassic tag = ReadTipConfig.chapterTitleArrowClassic
typeface = ChapterProvider.typeface typeface = tipTypeface
textSize = 12f textSize = tipTextSize
batteryMode = BatteryView.BatteryMode.ARROW batteryMode = BatteryView.BatteryMode.ARROW
} }
tvTime = getTipView(ReadTipConfig.time)?.apply { tvTime = getTipView(ReadTipConfig.time)?.apply {
tag = ReadTipConfig.time tag = ReadTipConfig.time
typeface = ChapterProvider.typeface typeface = tipTypeface
textSize = 12f textSize = tipTextSize
batteryMode = BatteryView.BatteryMode.NO_BATTERY batteryMode = BatteryView.BatteryMode.NO_BATTERY
} }
tvBattery = getTipView(ReadTipConfig.battery)?.apply { tvBattery = getTipView(ReadTipConfig.battery)?.apply {
tag = ReadTipConfig.battery tag = ReadTipConfig.battery
typeface = Typeface.DEFAULT typeface = Typeface.DEFAULT
textSize = 11f textSize = tipTextSize
batteryMode = BatteryView.BatteryMode.OUTER batteryMode = BatteryView.BatteryMode.OUTER
} }
tvBatteryClassic = getTipView(ReadTipConfig.batteryClassic)?.apply { tvBatteryClassic = getTipView(ReadTipConfig.batteryClassic)?.apply {
tag = ReadTipConfig.batteryClassic tag = ReadTipConfig.batteryClassic
textSize = 11f textSize = tipTextSize
batteryMode = BatteryView.BatteryMode.CLASSIC batteryMode = BatteryView.BatteryMode.CLASSIC
} }
tvBatteryInside = getTipView(ReadTipConfig.batteryInside)?.apply { tvBatteryInside = getTipView(ReadTipConfig.batteryInside)?.apply {
tag = ReadTipConfig.batteryInside tag = ReadTipConfig.batteryInside
typeface = Typeface.DEFAULT typeface = Typeface.DEFAULT
textSize = 11f textSize = tipTextSize
batteryMode = BatteryView.BatteryMode.INNER batteryMode = BatteryView.BatteryMode.INNER
} }
tvBatteryIcon = getTipView(ReadTipConfig.batteryIcon)?.apply { tvBatteryIcon = getTipView(ReadTipConfig.batteryIcon)?.apply {
tag = ReadTipConfig.batteryIcon tag = ReadTipConfig.batteryIcon
typeface = Typeface.DEFAULT typeface = Typeface.DEFAULT
textSize = 11f textSize = tipTextSize
batteryMode = BatteryView.BatteryMode.ICON batteryMode = BatteryView.BatteryMode.ICON
} }
tvPage = getTipView(ReadTipConfig.page)?.apply { tvPage = getTipView(ReadTipConfig.page)?.apply {
tag = ReadTipConfig.page tag = ReadTipConfig.page
typeface = ChapterProvider.typeface typeface = tipTypeface
textSize = 12f textSize = tipTextSize
batteryMode = BatteryView.BatteryMode.NO_BATTERY batteryMode = BatteryView.BatteryMode.NO_BATTERY
} }
tvTotalProgress = getTipView(ReadTipConfig.totalProgress)?.apply { tvTotalProgress = getTipView(ReadTipConfig.totalProgress)?.apply {
tag = ReadTipConfig.totalProgress tag = ReadTipConfig.totalProgress
batteryMode = BatteryView.BatteryMode.NO_BATTERY batteryMode = BatteryView.BatteryMode.NO_BATTERY
typeface = ChapterProvider.typeface typeface = tipTypeface
textSize = 12f textSize = tipTextSize
} }
tvTotalProgress1 = getTipView(ReadTipConfig.totalProgress1)?.apply { tvTotalProgress1 = getTipView(ReadTipConfig.totalProgress1)?.apply {
tag = ReadTipConfig.totalProgress1 tag = ReadTipConfig.totalProgress1
batteryMode = BatteryView.BatteryMode.NO_BATTERY batteryMode = BatteryView.BatteryMode.NO_BATTERY
typeface = ChapterProvider.typeface typeface = tipTypeface
textSize = 12f textSize = tipTextSize
} }
tvPageAndTotal = getTipView(ReadTipConfig.pageAndTotal)?.apply { tvPageAndTotal = getTipView(ReadTipConfig.pageAndTotal)?.apply {
tag = ReadTipConfig.pageAndTotal tag = ReadTipConfig.pageAndTotal
batteryMode = BatteryView.BatteryMode.NO_BATTERY batteryMode = BatteryView.BatteryMode.NO_BATTERY
typeface = ChapterProvider.typeface typeface = tipTypeface
textSize = 12f textSize = tipTextSize
} }
tvBookName = getTipView(ReadTipConfig.bookName)?.apply { tvBookName = getTipView(ReadTipConfig.bookName)?.apply {
tag = ReadTipConfig.bookName tag = ReadTipConfig.bookName
batteryMode = BatteryView.BatteryMode.NO_BATTERY batteryMode = BatteryView.BatteryMode.NO_BATTERY
typeface = ChapterProvider.typeface typeface = tipTypeface
textSize = 12f textSize = tipTextSize
} }
tvTimeBattery = getTipView(ReadTipConfig.timeBattery)?.apply { tvTimeBattery = getTipView(ReadTipConfig.timeBattery)?.apply {
tag = ReadTipConfig.timeBattery tag = ReadTipConfig.timeBattery
typeface = Typeface.DEFAULT typeface = Typeface.DEFAULT
textSize = 11f textSize = tipTextSize
batteryMode = BatteryView.BatteryMode.TIME batteryMode = BatteryView.BatteryMode.TIME
} }
tvTimeBatteryClassic = getTipView(ReadTipConfig.timeBatteryClassic)?.apply { tvTimeBatteryClassic = getTipView(ReadTipConfig.timeBatteryClassic)?.apply {
tag = ReadTipConfig.timeBatteryClassic tag = ReadTipConfig.timeBatteryClassic
typeface = ChapterProvider.typeface typeface = tipTypeface
textSize = 11f textSize = tipTextSize
batteryMode = BatteryView.BatteryMode.CLASSIC batteryMode = BatteryView.BatteryMode.CLASSIC
} }
tvBatteryP = getTipView(ReadTipConfig.batteryPercentage)?.apply { tvBatteryP = getTipView(ReadTipConfig.batteryPercentage)?.apply {
tag = ReadTipConfig.batteryPercentage tag = ReadTipConfig.batteryPercentage
batteryMode = BatteryView.BatteryMode.NO_BATTERY batteryMode = BatteryView.BatteryMode.NO_BATTERY
typeface = ChapterProvider.typeface typeface = tipTypeface
textSize = 12f textSize = tipTextSize
} }
tvTimeBatteryP = getTipView(ReadTipConfig.timeBatteryPercentage)?.apply { tvTimeBatteryP = getTipView(ReadTipConfig.timeBatteryPercentage)?.apply {
tag = ReadTipConfig.timeBatteryPercentage tag = ReadTipConfig.timeBatteryPercentage
batteryMode = BatteryView.BatteryMode.NO_BATTERY batteryMode = BatteryView.BatteryMode.NO_BATTERY
typeface = ChapterProvider.typeface typeface = tipTypeface
textSize = 12f textSize = tipTextSize
} }
} }
@@ -73,6 +73,8 @@ data class TextLine(
fun addColumn(column: BaseColumn) { fun addColumn(column: BaseColumn) {
if (column !is TextColumn) { if (column !is TextColumn) {
onlyTextColumn = false onlyTextColumn = false
} else if (column.color != null) {
onlyTextColumn = false
} }
column.textLine = this column.textLine = this
textColumns.add(column) textColumns.add(column)
@@ -189,6 +191,8 @@ data class TextLine(
} }
val textColor = if (isReadAloud) { val textColor = if (isReadAloud) {
ReadBookConfig.textAccentColor ReadBookConfig.textAccentColor
} else if (isTitle && ReadBookConfig.titleColor != 0) {
ReadBookConfig.titleColor
} else { } else {
ReadBookConfig.textColor ReadBookConfig.textColor
} }
@@ -229,7 +233,17 @@ data class TextLine(
null null
val lineY = height + (ReadBookConfig.durConfig.underlinePadding - 10).dpToPx() 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)
} }
@@ -368,9 +368,14 @@ data class TextPage(
} }
fun upRenderHeight() { 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) { 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) renderHeight = max(renderHeight, leftHeight)
} }
} }
@@ -1,22 +1,26 @@
package io.legado.app.ui.book.read.page.entities.column package io.legado.app.ui.book.read.page.entities.column
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Typeface
import android.os.Build import android.os.Build
import androidx.annotation.Keep import androidx.annotation.Keep
import androidx.core.net.toUri
import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ReadBookConfig
import io.legado.app.ui.book.read.page.ContentTextView 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
import io.legado.app.ui.book.read.page.entities.TextLine.Companion.emptyTextLine 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.ui.book.read.page.provider.ChapterProvider
import io.legado.app.utils.isContentScheme
import splitties.init.appCtx
import java.io.File
/**
* 文字列
*/
@Keep @Keep
data class TextColumn( data class TextColumn(
override var start: Float, override var start: Float,
override var end: Float, override var end: Float,
override val charData: String, override val charData: String,
var color: Int? = null,
var fontPath: String? = null,
) : TextBaseColumn { ) : TextBaseColumn {
override var textLine: TextLine = emptyTextLine override var textLine: TextLine = emptyTextLine
@@ -47,19 +51,44 @@ data class TextColumn(
} else { } else {
ChapterProvider.contentPaint ChapterProvider.contentPaint
} }
val textColor = if (!textLine.useUnderline && (textLine.isReadAloud || isSearchResult)) { val textColor = color ?: if (!textLine.useUnderline && (textLine.isReadAloud || isSearchResult)) {
ReadBookConfig.textAccentColor ReadBookConfig.textAccentColor
} else if (textLine.isTitle && ReadBookConfig.titleColor != 0) {
ReadBookConfig.titleColor
} else { } else {
ReadBookConfig.textColor ReadBookConfig.textColor
} }
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 val originalSize = textPaint.textSize
textLine.titleTextSize?.let { textPaint.textSize = textLine.titleTextSize!!
textPaint.textSize = it if (needRestoreColor) textPaint.color = textColor
} if (needRestoreTypeface) textPaint.typeface = customTypeface
if (textPaint.color != textColor) {
textPaint.color = textColor
}
val y = textLine.lineBase - textLine.lineTop 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 (selected) {
canvas.drawRect(start, 0f, end, textLine.height, view.selectedPaint)
}
}
private fun drawText(canvas: Canvas, y: Float, textPaint: android.text.TextPaint) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
val letterSpacing = textPaint.letterSpacing * textPaint.textSize val letterSpacing = textPaint.letterSpacing * textPaint.textSize
val letterSpacingHalf = letterSpacing * 0.5f val letterSpacingHalf = letterSpacing * 0.5f
@@ -67,10 +96,32 @@ data class TextColumn(
} else { } else {
canvas.drawText(charData, start, y, textPaint) canvas.drawText(charData, start, y, textPaint)
} }
if (selected) {
canvas.drawRect(start, 0f, end, textLine.height, view.selectedPaint)
} }
textPaint.textSize = originalSize
companion object {
private val typefaceCache = mutableMapOf<String, Typeface?>()
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()
}
}
} }
} }
@@ -934,30 +934,49 @@ object ChapterProvider {
@SuppressLint("UseKtx") @SuppressLint("UseKtx")
private fun getPaints(typeface: Typeface?): Pair<TextPaint, TextPaint> { private fun getPaints(typeface: Typeface?): Pair<TextPaint, TextPaint> {
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 bold = Typeface.create(typeface, Typeface.BOLD)
val normal = Typeface.create(typeface, Typeface.NORMAL) 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) 1 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
Typeface.create(typeface, 900, false) Typeface.create(titleTypeface, 900, false)
else else
bold titleBoldTypeface
2 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) 2 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
Typeface.create(typeface, 300, false) Typeface.create(titleTypeface, 300, false)
else else
normal titleNormalTypeface
0 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) 0 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
Typeface.create(typeface, 400, false) Typeface.create(titleTypeface, 400, false)
else else
normal titleNormalTypeface
in 100..900 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) 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 else
normal titleNormalTypeface
else -> normal else -> titleNormalTypeface
} }
val textFont = when (ReadBookConfig.textBold) { val textFont = when (ReadBookConfig.textBold) {
@@ -989,7 +1008,7 @@ object ChapterProvider {
val tPaint = TextPaint() val tPaint = TextPaint()
tPaint.color = ReadBookConfig.textColor tPaint.color = ReadBookConfig.textColor
tPaint.letterSpacing = ReadBookConfig.letterSpacing tPaint.letterSpacing = ReadBookConfig.letterSpacing
tPaint.typeface = titleFont tPaint.typeface = titleFontTypeface
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && ReadBookConfig.titleBold in 100..900) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && ReadBookConfig.titleBold in 100..900)
tPaint.setFontVariationSettings("'wght' ${ReadBookConfig.titleBold}") tPaint.setFontVariationSettings("'wght' ${ReadBookConfig.titleBold}")
tPaint.textSize = with(ReadBookConfig) { textSize + titleSize }.toFloat().spToPx() tPaint.textSize = with(ReadBookConfig) { textSize + titleSize }.toFloat().spToPx()
@@ -69,6 +69,14 @@ class TextChapterLayout(
private val bookContent: BookContent, private val bookContent: BookContent,
) { ) {
companion object {
private val regexCache = mutableMapOf<String, Regex>()
fun invalidateRegexCache() {
regexCache.clear()
}
}
@Volatile @Volatile
private var listener: LayoutProgressListener? = textChapter private var listener: LayoutProgressListener? = textChapter
@@ -864,6 +872,7 @@ class TextChapterLayout(
) { ) {
val widthsArray = allocateFloatArray(text.length) val widthsArray = allocateFloatArray(text.length)
textPaint.getTextWidthsCompat(text, widthsArray) textPaint.getTextWidthsCompat(text, widthsArray)
val colorMap = applyRegexColorRules(text)
val layout = if (useZhLayout) { val layout = if (useZhLayout) {
val (words, widths) = measureTextSplit(text, widthsArray) val (words, widths) = measureTextSplit(text, widthsArray)
val indentSize = if (isFirstLine) paragraphIndent.length else 0 val indentSize = if (isFirstLine) paragraphIndent.length else 0
@@ -915,12 +924,15 @@ class TextChapterLayout(
val (words, widths) = measureTextSplit(lineText, widthsArray, lineStart) val (words, widths) = measureTextSplit(lineText, widthsArray, lineStart)
val desiredWidth = widths.fastSum() val desiredWidth = widths.fastSum()
textLine.text = lineText textLine.text = lineText
val lineWordStyles = if (colorMap != null) {
buildWordStyles(words, lineText, colorMap.colorArray, colorMap.fontPathArray, lineStart)
} else null
when (lineIndex) { when (lineIndex) {
0 if layout.lineCount > 1 && !isTitle && isFirstLine -> { 0 if layout.lineCount > 1 && !isTitle && isFirstLine -> {
//多行的第一行 非标题 //多行的第一行 非标题
addCharsToLineFirst( addCharsToLineFirst(
book, absStartX, textLine, words, textPaint, book, absStartX, textLine, words, textPaint,
desiredWidth, widths, srcList, clickList desiredWidth, widths, srcList, clickList, lineWordStyles
) )
} }
@@ -938,7 +950,7 @@ class TextChapterLayout(
} }
addCharsToLineNatural( addCharsToLineNatural(
book, absStartX, textLine, words, book, absStartX, textLine, words,
startX, !isTitle && lineIndex == 0, widths, srcList, clickList startX, !isTitle && lineIndex == 0, widths, srcList, clickList, lineWordStyles
) )
} }
else -> { else -> {
@@ -951,13 +963,13 @@ class TextChapterLayout(
val startX = (visibleWidth - desiredWidth) / 2 val startX = (visibleWidth - desiredWidth) / 2
addCharsToLineNatural( addCharsToLineNatural(
book, absStartX, textLine, words, book, absStartX, textLine, words,
startX, false, widths, srcList, clickList startX, false, widths, srcList, clickList, lineWordStyles
) )
} else { } else {
//中间行 //中间行
addCharsToLineMiddle( addCharsToLineMiddle(
book, absStartX, textLine, words, textPaint, book, absStartX, textLine, words, textPaint,
desiredWidth, 0f, widths, srcList, clickList desiredWidth, 0f, widths, srcList, clickList, lineWordStyles
) )
} }
} }
@@ -1007,17 +1019,17 @@ class TextChapterLayout(
textLine: TextLine, textLine: TextLine,
words: List<String>, words: List<String>,
textPaint: TextPaint, textPaint: TextPaint,
/**自然排版长度**/
desiredWidth: Float, desiredWidth: Float,
textWidths: List<Float>, textWidths: List<Float>,
srcList: LinkedList<String>?, srcList: LinkedList<String>?,
clickList: LinkedList<String?>? clickList: LinkedList<String?>?,
wordStyles: List<WordStyle>? = null
) { ) {
var x = 0f var x = 0f
if (!textFullJustify) { if (!textFullJustify) {
addCharsToLineNatural( addCharsToLineNatural(
book, absStartX, textLine, words, book, absStartX, textLine, words,
x, true, textWidths, srcList, clickList x, true, textWidths, srcList, clickList, wordStyles
) )
return return
} }
@@ -1038,9 +1050,10 @@ class TextChapterLayout(
if (words.size > bodyIndent.length) { if (words.size > bodyIndent.length) {
val text1 = words.subList(bodyIndent.length, words.size) val text1 = words.subList(bodyIndent.length, words.size)
val textWidths1 = textWidths.subList(bodyIndent.length, textWidths.size) val textWidths1 = textWidths.subList(bodyIndent.length, textWidths.size)
val wordStyles1 = wordStyles?.subList(bodyIndent.length, wordStyles.size)
addCharsToLineMiddle( addCharsToLineMiddle(
book, absStartX, textLine, text1, textPaint, book, absStartX, textLine, text1, textPaint,
desiredWidth, x, textWidths1, srcList, clickList desiredWidth, x, textWidths1, srcList, clickList, wordStyles1
) )
} }
} }
@@ -1054,18 +1067,17 @@ class TextChapterLayout(
textLine: TextLine, textLine: TextLine,
words: List<String>, words: List<String>,
textPaint: TextPaint, textPaint: TextPaint,
/**自然排版长度**/
desiredWidth: Float, desiredWidth: Float,
/**起始x坐标**/
startX: Float, startX: Float,
textWidths: List<Float>, textWidths: List<Float>,
srcList: LinkedList<String>?, srcList: LinkedList<String>?,
clickList: LinkedList<String?>? clickList: LinkedList<String?>?,
wordStyles: List<WordStyle>? = null
) { ) {
if (!textFullJustify) { if (!textFullJustify) {
addCharsToLineNatural( addCharsToLineNatural(
book, absStartX, textLine, words, book, absStartX, textLine, words,
startX, false, textWidths, srcList, clickList startX, false, textWidths, srcList, clickList, wordStyles
) )
return return
} }
@@ -1086,7 +1098,9 @@ class TextChapterLayout(
} }
addCharToLine( addCharToLine(
book, absStartX, textLine, char, 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 x = x1
} }
@@ -1102,7 +1116,9 @@ class TextChapterLayout(
val x1 = if (index != words.lastIndex) (x + cw + d) else (x + cw) val x1 = if (index != words.lastIndex) (x + cw + d) else (x + cw)
addCharToLine( addCharToLine(
book, absStartX, textLine, char, 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 x = x1
} }
@@ -1122,7 +1138,8 @@ class TextChapterLayout(
hasIndent: Boolean, hasIndent: Boolean,
textWidths: List<Float>, textWidths: List<Float>,
srcList: LinkedList<String>?, srcList: LinkedList<String>?,
clickList: LinkedList<String?>? clickList: LinkedList<String?>?,
wordStyles: List<WordStyle>? = null
) { ) {
val indentLength = paragraphIndent.length val indentLength = paragraphIndent.length
var x = startX var x = startX
@@ -1140,7 +1157,9 @@ class TextChapterLayout(
x1, x1,
index + 1 == words.size, index + 1 == words.size,
srcList, srcList,
clickList clickList,
wordStyles?.getOrNull(index)?.color,
wordStyles?.getOrNull(index)?.fontPath
) )
x = x1 x = x1
if (hasIndent && index == indentLength - 1) { if (hasIndent && index == indentLength - 1) {
@@ -1162,7 +1181,9 @@ class TextChapterLayout(
xEnd: Float, xEnd: Float,
isLineEnd: Boolean, isLineEnd: Boolean,
srcList: LinkedList<String>?, srcList: LinkedList<String>?,
clickList: LinkedList<String?>? clickList: LinkedList<String?>?,
color: Int? = null,
fontPath: String? = null
) { ) {
val column = when { val column = when {
!srcList.isNullOrEmpty() && (char == srcReplaceChar || char == reviewChar) -> { !srcList.isNullOrEmpty() && (char == srcReplaceChar || char == reviewChar) -> {
@@ -1176,19 +1197,14 @@ class TextChapterLayout(
click = click click = click
) )
} }
// isLineEnd && char == ChapterProvider.reviewChar -> {
// ReviewColumn(
// start = absStartX + xStart,
// end = absStartX + xEnd,
// count = 10
// )
// }
else -> { else -> {
TextColumn( TextColumn(
start = absStartX + xStart, start = absStartX + xStart,
end = absStartX + xEnd, 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 return code == 8203 || code == 8204 || code == 8205 || code == 8288
} }
private data class RegexMatchResult(
val colorArray: IntArray,
val fontPathArray: Array<String?>
)
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<String>(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<String>,
lineText: String,
colorArray: IntArray,
fontPathArray: Array<String?>,
lineStart: Int
): List<WordStyle> {
val wordStyles = mutableListOf<WordStyle>()
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
}
} }
@@ -342,6 +342,7 @@ class BookSourceEditActivity :
contentEntities.clear() contentEntities.clear()
contentEntities.apply { contentEntities.apply {
add(EditEntity("content", cr.content, R.string.rule_book_content)) 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("title", cr.title, R.string.rule_chapter_name))
add(EditEntity("nextContentUrl", cr.nextContentUrl, R.string.rule_next_content)) add(EditEntity("nextContentUrl", cr.nextContentUrl, R.string.rule_next_content))
add(EditEntity("webJs", cr.webJs, R.string.rule_web_js)) add(EditEntity("webJs", cr.webJs, R.string.rule_web_js))
@@ -530,6 +531,7 @@ class BookSourceEditActivity :
it.value = it.value?.takeIf { s -> s.isNotBlank() } it.value = it.value?.takeIf { s -> s.isNotBlank() }
when (it.key) { when (it.key) {
"content" -> contentRule.content = viewModel.ruleComplete(it.value) "content" -> contentRule.content = viewModel.ruleComplete(it.value)
"subContent" -> contentRule.subContent = viewModel.ruleComplete(it.value)
"title" -> contentRule.title = viewModel.ruleComplete(it.value) "title" -> contentRule.title = viewModel.ruleComplete(it.value)
"nextContentUrl" -> contentRule.nextContentUrl = "nextContentUrl" -> contentRule.nextContentUrl =
viewModel.ruleComplete(it.value, type = 2) viewModel.ruleComplete(it.value, type = 2)
@@ -7,10 +7,12 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import io.legado.app.utils.defaultSharedPreferences import io.legado.app.utils.defaultSharedPreferences
import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.getPrefFloat
import io.legado.app.utils.getPrefInt import io.legado.app.utils.getPrefInt
import io.legado.app.utils.getPrefLong import io.legado.app.utils.getPrefLong
import io.legado.app.utils.getPrefString import io.legado.app.utils.getPrefString
import io.legado.app.utils.putPrefBoolean import io.legado.app.utils.putPrefBoolean
import io.legado.app.utils.putPrefFloat
import io.legado.app.utils.putPrefInt import io.legado.app.utils.putPrefInt
import io.legado.app.utils.putPrefLong import io.legado.app.utils.putPrefLong
import io.legado.app.utils.putPrefString import io.legado.app.utils.putPrefString
@@ -63,6 +65,7 @@ fun <T> prefDelegate(
defaultValue is Int -> appCtx.getPrefInt(key, defaultValue) as T defaultValue is Int -> appCtx.getPrefInt(key, defaultValue) as T
defaultValue is Boolean -> appCtx.getPrefBoolean(key, defaultValue) as T defaultValue is Boolean -> appCtx.getPrefBoolean(key, defaultValue) as T
defaultValue is Long -> appCtx.getPrefLong(key, defaultValue) as T defaultValue is Long -> appCtx.getPrefLong(key, defaultValue) as T
defaultValue is Float -> appCtx.getPrefFloat(key, defaultValue) as T
else -> defaultValue else -> defaultValue
} }
} }
@@ -78,6 +81,7 @@ fun <T> prefDelegate(
is Int -> appCtx.putPrefInt(key, value) is Int -> appCtx.putPrefInt(key, value)
is Boolean -> appCtx.putPrefBoolean(key, value) is Boolean -> appCtx.putPrefBoolean(key, value)
is Long -> appCtx.putPrefLong(key, value) is Long -> appCtx.putPrefLong(key, value)
is Float -> appCtx.putPrefFloat(key, value)
} }
_value.value = value _value.value = value
onValueChange?.invoke(value) onValueChange?.invoke(value)
@@ -53,6 +53,16 @@ object BookshelfConfig {
*/ */
var showLastUpdateTime by prefDelegate(PreferKey.showLastUpdateTime, false) var showLastUpdateTime by prefDelegate(PreferKey.showLastUpdateTime, false)
/**
* 是否在列表中显示书籍简介
*/
var showBookIntro by prefDelegate(PreferKey.showBookIntro, false)
/**
* 列表模式下简介显示行数 (0为显示全部)
*/
var bookshelfIntroMaxLines by prefDelegate(PreferKey.bookshelfIntroMaxLines, 0)
/** /**
* 是否显示等待更新的书籍数量 * 是否显示等待更新的书籍数量
*/ */
@@ -30,6 +30,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -86,7 +87,8 @@ fun CoverManageSheet(
items(coverList) { path -> items(coverList) { path ->
Box { Box {
NormalCard( NormalCard(
cornerRadius = 12.dp cornerRadius = 12.dp,
containerColor = Color.Transparent
) { ) {
AsyncImage( AsyncImage(
model = path, model = path,
@@ -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
)
)
}
@@ -21,4 +21,8 @@ object MainConfig {
var autoRefreshBook by prefDelegate(PreferKey.autoRefresh, false) var autoRefreshBook by prefDelegate(PreferKey.autoRefresh, false)
var autoCheckNewBackup by prefDelegate(PreferKey.autoCheckNewBackup, true) var autoCheckNewBackup by prefDelegate(PreferKey.autoCheckNewBackup, true)
var showStatusBar by prefDelegate(PreferKey.showStatusBar, 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, "")
} }
@@ -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<List<FileDoc>>(emptyList()) }
var selectedFolderUri by remember { mutableStateOf<Uri?>(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()
)
}
}
@@ -5,6 +5,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -90,7 +91,10 @@ fun BackgroundImageManageSheet(
.aspectRatio(16f / 9f) .aspectRatio(16f / 9f)
.padding(16.dp) .padding(16.dp)
) { ) {
Box(contentAlignment = Alignment.Center) { Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
AppIcon( AppIcon(
imageVector = Icons.Default.Add, imageVector = Icons.Default.Add,
contentDescription = "Add", contentDescription = "Add",
@@ -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
}
)
}
}
@@ -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<String?>(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),
)
}
}
}
}
}
}
@@ -3,7 +3,16 @@ package io.legado.app.ui.config.themeConfig
import io.legado.app.constant.EventBus import io.legado.app.constant.EventBus
import io.legado.app.constant.PreferKey import io.legado.app.constant.PreferKey
import io.legado.app.ui.config.prefDelegate 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.postEvent
import io.legado.app.utils.putPrefString
import splitties.init.appCtx
data class TagColorPair(
val textColor: Int = 0,
val bgColor: Int = 0
)
object ThemeConfig { object ThemeConfig {
@@ -17,6 +26,16 @@ object ThemeConfig {
var enableProgressiveBlur by prefDelegate(PreferKey.enableProgressiveBlur, false) 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 useFlexibleTopAppBar by prefDelegate(PreferKey.useFlexibleTopAppBar, true)
var paletteStyle by prefDelegate(PreferKey.paletteStyle, "tonalSpot") var paletteStyle by prefDelegate(PreferKey.paletteStyle, "tonalSpot")
@@ -56,10 +75,48 @@ object ThemeConfig {
postEvent(EventBus.RECREATE, "") postEvent(EventBus.RECREATE, "")
} }
var cPrimary by prefDelegate(PreferKey.cPrimary, 0) { var appFontPath: String?
get() = appCtx.getPrefString(PreferKey.appFontPath)
set(value) {
appCtx.putPrefString(PreferKey.appFontPath, value)
postEvent(EventBus.RECREATE, "") 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) { var cNPrimary by prefDelegate(PreferKey.cNPrimary, 0) {
postEvent(EventBus.RECREATE, "") postEvent(EventBus.RECREATE, "")
} }
@@ -70,6 +127,28 @@ object ThemeConfig {
var launcherIcon by prefDelegate(PreferKey.launcherIcon, "ic_launcher") 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<TagColorPair> {
return try {
val json = customTagColorsJson
if (json.isNullOrBlank()) emptyList()
else GSON.fromJson(json, Array<TagColorPair>::class.java).toList()
} catch (e: Exception) {
emptyList()
}
}
fun saveCustomTagColors(colors: List<TagColorPair>) {
customTagColorsJson = GSON.toJson(colors)
}
var showDiscovery by prefDelegate(PreferKey.showDiscovery, true) var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
var showRss by prefDelegate(PreferKey.showRss, true) var showRss by prefDelegate(PreferKey.showRss, true)
@@ -2,8 +2,13 @@ package io.legado.app.ui.config.themeConfig
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent
import android.graphics.Typeface
import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.BorderStroke 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.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow 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.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.BrightnessMedium
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material3.ButtonGroupDefaults import androidx.compose.material3.ButtonGroupDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -68,8 +78,11 @@ import com.google.android.material.color.DynamicColors
import com.google.android.material.color.DynamicColorsOptions import com.google.android.material.color.DynamicColorsOptions
import io.legado.app.R import io.legado.app.R
import io.legado.app.base.AppContextWrapper 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.constant.EventBus
import io.legado.app.help.LauncherIconHelp 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.AppConfig
import io.legado.app.help.config.OldThemeConfig import io.legado.app.help.config.OldThemeConfig
import io.legado.app.lib.theme.ThemeStore 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.AppScaffold
import io.legado.app.ui.widget.components.AppTextField import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.SplicedColumnGroup 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.alert.AppAlertDialog
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton 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.dialog.ColorPickerSheet
import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem import io.legado.app.ui.widget.components.settingItem.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.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar 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.GlassTopAppBarDefaults
import io.legado.app.utils.FileDoc
import io.legado.app.utils.getPrefString
import io.legado.app.utils.postEvent import io.legado.app.utils.postEvent
import io.legado.app.utils.putPrefString
import io.legado.app.utils.restart import io.legado.app.utils.restart
import io.legado.app.utils.takePersistablePermissionSafely
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -104,6 +123,7 @@ import top.yukonga.miuix.kmp.basic.Text as MiuixText
@Composable @Composable
fun ThemeConfigScreen( fun ThemeConfigScreen(
onBackClick: () -> Unit, onBackClick: () -> Unit,
onNavigateToCustomTheme: () -> Unit,
viewModel: ThemeConfigViewModel = koinViewModel() viewModel: ThemeConfigViewModel = koinViewModel()
) { ) {
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
@@ -114,16 +134,35 @@ fun ThemeConfigScreen(
var selectedTheme by remember { mutableStateOf(ThemeConfig.appTheme) } var selectedTheme by remember { mutableStateOf(ThemeConfig.appTheme) }
var useMiuixMonet by remember { mutableStateOf(ThemeConfig.useMiuixMonet) } var useMiuixMonet by remember { mutableStateOf(ThemeConfig.useMiuixMonet) }
var showRestartDialog by remember { mutableStateOf(false) } 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 showLauncherIconPicker by remember { mutableStateOf(false) }
var showThemeListDialog by remember { mutableStateOf(false) } var showBorderColorPicker by remember { mutableStateOf(false) }
var saveThemeKey by remember { mutableStateOf<String?>(null) } var currentBorderColorKey by remember { mutableStateOf("containerBorderColor") }
var themeName by remember { mutableStateOf("") } var showNavIconSheet by remember { mutableStateOf(false) }
var showFontSheet by remember { mutableStateOf(false) }
var fontItems by remember { mutableStateOf<List<FileDoc>>(emptyList()) }
var fontFolderUri by remember { mutableStateOf<Uri?>(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 fontScaleValue = remember { mutableFloatStateOf(ThemeConfig.fontScale.toFloat()) }
val primaryColorValue = remember { mutableIntStateOf(ThemeConfig.cPrimary) }
val nightPrimaryColorValue = remember { mutableIntStateOf(ThemeConfig.cNPrimary) }
AppScaffold( AppScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -166,8 +205,8 @@ fun ThemeConfigScreen(
isDark = isDarkTheme, isDark = isDarkTheme,
isAmoled = ThemeConfig.isPureBlack, isAmoled = ThemeConfig.isPureBlack,
paletteStyle = ThemeConfig.paletteStyle, paletteStyle = ThemeConfig.paletteStyle,
customLightSeedColor = primaryColorValue.intValue, customLightSeedColor = ThemeConfig.cPrimary,
customNightSeedColor = nightPrimaryColorValue.intValue customNightSeedColor = ThemeConfig.cNPrimary
) )
} }
} }
@@ -259,8 +298,8 @@ fun ThemeConfigScreen(
isDark = isDarkTheme, isDark = isDarkTheme,
isAmoled = ThemeConfig.isPureBlack, isAmoled = ThemeConfig.isPureBlack,
paletteStyle = ThemeConfig.paletteStyle, paletteStyle = ThemeConfig.paletteStyle,
customLightSeedColor = primaryColorValue.intValue, customLightSeedColor = ThemeConfig.cPrimary,
customNightSeedColor = nightPrimaryColorValue.intValue, customNightSeedColor = ThemeConfig.cNPrimary,
onThemeSelected = { theme -> onThemeSelected = { theme ->
if (theme == "13") { if (theme == "13") {
val hasLightBg = !ThemeConfig.bgImageLight.isNullOrEmpty() val hasLightBg = !ThemeConfig.bgImageLight.isNullOrEmpty()
@@ -287,6 +326,16 @@ fun ThemeConfigScreen(
} }
SplicedColumnGroup { 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( DropdownListSettingItem(
title = stringResource(R.string.compose_engine), title = stringResource(R.string.compose_engine),
selectedValue = ThemeConfig.composeEngine, selectedValue = ThemeConfig.composeEngine,
@@ -327,55 +376,6 @@ fun ThemeConfigScreen(
) )
} }
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)) { SplicedColumnGroup(title = stringResource(R.string.main_activity)) {
SwitchSettingItem( SwitchSettingItem(
title = stringResource(R.string.show_discovery), title = stringResource(R.string.show_discovery),
@@ -417,6 +417,14 @@ fun ThemeConfigScreen(
checked = ThemeConfig.useFloatingBottomBarLiquidGlass, checked = ThemeConfig.useFloatingBottomBarLiquidGlass,
onCheckedChange = { ThemeConfig.useFloatingBottomBarLiquidGlass = it } onCheckedChange = { ThemeConfig.useFloatingBottomBarLiquidGlass = it }
) )
SliderSettingItem(
title = "底栏模糊强度",
description = "控制液态玻璃的扭曲程度",
value = ThemeConfig.bottomBarLensRadius,
defaultValue = 24f,
valueRange = 0f..50f,
onValueChange = { ThemeConfig.bottomBarLensRadius = it }
)
} }
DropdownListSettingItem( DropdownListSettingItem(
title = stringResource(R.string.tabletInterface), title = stringResource(R.string.tabletInterface),
@@ -441,22 +449,6 @@ fun ThemeConfigScreen(
) )
} }
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)) { SplicedColumnGroup(title = stringResource(R.string.compose_related)) {
if (!isMiuixEngine) { if (!isMiuixEngine) {
SwitchSettingItem( SwitchSettingItem(
@@ -485,6 +477,38 @@ fun ThemeConfigScreen(
onCheckedChange = { ThemeConfig.enableProgressiveBlur = it } 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) { AnimatedVisibility(visible = !isMiuixEngine && !ThemeConfig.enableBlur) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
@@ -579,6 +603,126 @@ fun ThemeConfigScreen(
} }
} }
} }
// 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( LauncherIconPickerSheet(
show = showLauncherIconPicker, show = showLauncherIconPicker,
@@ -649,36 +772,106 @@ fun ThemeConfigScreen(
} }
) )
ThemeListDialog( ColorPickerSheet(
show = showThemeListDialog, show = showBorderColorPicker,
onDismissRequest = { showThemeListDialog = false } 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( AppModalBottomSheet(
data = saveThemeKey, show = showFontSheet,
onDismissRequest = { saveThemeKey = null }, onDismissRequest = { showFontSheet = false },
title = stringResource(R.string.theme_name), title = stringResource(R.string.font_setting),
content = { startAction = {
AppTextField( SmallIconButton(
value = themeName, imageVector = Icons.Default.Delete,
onValueChange = { themeName = it }, contentDescription = "清除",
label = "name", onClick = {
singleLine = true, fontFolderUri = null
modifier = Modifier.fillMaxWidth(), context.putPrefString(PreferKey.fontFolder, "")
backgroundColor = LegadoTheme.colorScheme.surface 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
},
dismissText = stringResource(android.R.string.cancel),
onDismiss = { saveThemeKey = null }
) )
},
endAction = {
SmallIconButton(
imageVector = Icons.Default.Add,
contentDescription = "选择文件夹",
onClick = { fontFolderLauncher.launch(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) @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( data class ThemeColorPalette(
val primary: Color, val primary: Color,
val secondary: Color, val secondary: Color,
@@ -1065,3 +1237,4 @@ private fun getThemeColors(
onSurfaceVariant = colorScheme.onSurfaceVariant onSurfaceVariant = colorScheme.onSurfaceVariant
) )
} }
@@ -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<Int?>(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 }
)
}
@@ -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<ThemePackConfig.ThemePack?>(null) }
var applyTarget by remember { mutableStateOf<ThemePackConfig.ThemePack?>(null) }
var exportingPack by remember { mutableStateOf<ThemePackConfig.ThemePack?>(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<String>()
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
)
}
}
}
}
}
@@ -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.coverConfig.CoverConfigScreen
import io.legado.app.ui.config.mainConfig.MainConfig import io.legado.app.ui.config.mainConfig.MainConfig
import io.legado.app.ui.config.otherConfig.OtherConfigScreen 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.readConfig.ReadConfigScreen
import io.legado.app.ui.config.themeConfig.ThemeConfigScreen 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.MainRouteRssSort
import io.legado.app.ui.rss.article.RssSortRouteScreen import io.legado.app.ui.rss.article.RssSortRouteScreen
import io.legado.app.ui.rss.read.MainRouteRssRead 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_COVER = "settings/cover"
private const val ROUTE_SETTINGS_THEME = "settings/theme" private const val ROUTE_SETTINGS_THEME = "settings/theme"
private const val ROUTE_SETTINGS_BACKUP = "settings/backup" 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_LOCAL = "import/local"
private const val ROUTE_IMPORT_REMOTE = "import/remote" private const val ROUTE_IMPORT_REMOTE = "import/remote"
private const val ROUTE_CACHE = "cache" private const val ROUTE_CACHE = "cache"
@@ -278,6 +282,12 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
@Serializable @Serializable
private data object MainRouteSettingsBackup : MainRoute private data object MainRouteSettingsBackup : MainRoute
@Serializable
private data object MainRouteSettingsCustomTheme : MainRoute
@Serializable
private data object MainRouteSettingsThemePack : MainRoute
@Serializable @Serializable
private data object MainRouteImportLocal : MainRoute private data object MainRouteImportLocal : MainRoute
@@ -543,13 +553,27 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
} }
entry<MainRouteSettingsTheme> { entry<MainRouteSettingsTheme> {
ThemeConfigScreen(onBackClick = { navigateBack(backStack) }) ThemeConfigScreen(
onBackClick = { navigateBack(backStack) },
onNavigateToCustomTheme = { backStack.add(MainRouteSettingsCustomTheme) }
)
} }
entry<MainRouteSettingsBackup> { entry<MainRouteSettingsBackup> {
BackupConfigScreen(onBackClick = { navigateBack(backStack) }) BackupConfigScreen(onBackClick = { navigateBack(backStack) })
} }
entry<MainRouteSettingsCustomTheme> {
CustomThemeScreen(
onBackClick = { navigateBack(backStack) },
onNavigateToThemePack = { backStack.add(MainRouteSettingsThemePack) }
)
}
entry<MainRouteSettingsThemePack> {
ThemePackScreen(onBackClick = { navigateBack(backStack) })
}
entry<MainRouteImportLocal> { entry<MainRouteImportLocal> {
ImportBookScreen( ImportBookScreen(
onBackClick = { navigateBack(backStack) } onBackClick = { navigateBack(backStack) }
@@ -599,7 +623,6 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
} }
DisposableEffect(lifecycleOwner, searchViewModel) { DisposableEffect(lifecycleOwner, searchViewModel) {
searchViewModel.onIntent(SearchIntent.ResumeEngine)
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
when (event) { when (event) {
Lifecycle.Event.ON_RESUME -> { Lifecycle.Event.ON_RESUME -> {
@@ -616,7 +639,6 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
lifecycleOwner.lifecycle.addObserver(observer) lifecycleOwner.lifecycle.addObserver(observer)
onDispose { onDispose {
lifecycleOwner.lifecycle.removeObserver(observer) lifecycleOwner.lifecycle.removeObserver(observer)
searchViewModel.onIntent(SearchIntent.PauseEngine)
} }
} }
@@ -645,6 +667,15 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
initialSortUrl = route.sortUrl, initialSortUrl = route.sortUrl,
onBackClick = { navigateBack(backStack) }, onBackClick = { navigateBack(backStack) },
onOpenRead = { title, origin, link, openUrl -> onOpenRead = { title, origin, link, openUrl ->
if (link?.contains("@js:") == true) {
navigateToRoute(
backStack,
MainRouteRssSort(
sourceUrl = origin,
sortUrl = link
)
)
} else {
navigateToRoute( navigateToRoute(
backStack, backStack,
MainRouteRssRead( MainRouteRssRead(
@@ -655,6 +686,7 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
) )
) )
} }
}
) )
} }
@@ -668,7 +700,18 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
) )
} }
entry<MainRouteBookInfo> { route -> entry<MainRouteBookInfo>(
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<BookInfoViewModel>() val bookInfoViewModel = koinViewModel<BookInfoViewModel>()
BookInfoRouteScreen( BookInfoRouteScreen(
bookUrl = route.bookUrl, bookUrl = route.bookUrl,
@@ -734,7 +777,9 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
MainRouteSettingsRead, MainRouteSettingsRead,
MainRouteSettingsCover, MainRouteSettingsCover,
MainRouteSettingsTheme, MainRouteSettingsTheme,
MainRouteSettingsBackup -> { MainRouteSettingsBackup,
MainRouteSettingsCustomTheme,
MainRouteSettingsThemePack -> {
backStack.clear() backStack.clear()
backStack.add(MainRouteHome) backStack.add(MainRouteHome)
backStack.add(MainRouteSettings) backStack.add(MainRouteSettings)
@@ -969,6 +1014,16 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
private fun resolveStartRoute(route: String?): MainRoute { private fun resolveStartRoute(route: String?): MainRoute {
return when (route) { 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_MAIN -> MainRouteHome
ROUTE_SETTINGS -> MainRouteSettings ROUTE_SETTINGS -> MainRouteSettings
ROUTE_SETTINGS_OTHER -> MainRouteSettingsOther ROUTE_SETTINGS_OTHER -> MainRouteSettingsOther
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState 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.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource 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.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.BookshelfScreen
import io.legado.app.ui.main.bookshelf.BookshelfViewModel import io.legado.app.ui.main.bookshelf.BookshelfViewModel
import io.legado.app.ui.main.explore.ExploreScreen import io.legado.app.ui.main.explore.ExploreScreen
@@ -149,7 +155,11 @@ fun MainScreen(
} }
val hazeState = remember { HazeState() } 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 { val floatingBarBackdrop = rememberLayerBackdrop {
drawRect(floatingBarSurfaceColor) drawRect(floatingBarSurfaceColor)
drawContent() drawContent()
@@ -270,7 +280,13 @@ fun MainScreen(
} }
}, },
label = if (labelVisibilityMode != "unlabeled") { 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 } else null
) )
} }
@@ -280,67 +296,16 @@ fun MainScreen(
AppScaffold( AppScaffold(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
bottomBar = { bottomBar = {
if (!useRail && mainUiState.showBottomView) { if (!useRail && mainUiState.showBottomView && !useFloatingBottomBar) {
if (useFloatingBottomBar) { AppNavigationBar() {
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)
}
},
backdrop = floatingBarBackdrop,
tabsCount = destinations.size,
isBlurEnabled = useLiquidGlass
) {
destinations.forEachIndexed { index, destination -> destinations.forEachIndexed { index, destination ->
val selected = pagerState.targetPage == index val selected = pagerState.targetPage == index
FloatingBottomBarItem( val customIconPath = when (destination) {
onClick = { MainDestination.Bookshelf -> MainConfig.navIconBookshelf
coroutineScope.launch { MainDestination.Explore -> MainConfig.navIconExplore
pagerState.animateScrollToPage(index) MainDestination.Rss -> MainConfig.navIconRss
MainDestination.My -> MainConfig.navIconMy
} }
},
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( AppNavigationBarItem(
selected = selected, selected = selected,
onClick = { onClick = {
@@ -358,22 +323,25 @@ fun MainScreen(
noBlurColor = MaterialTheme.colorScheme.secondaryContainer, noBlurColor = MaterialTheme.colorScheme.secondaryContainer,
blurAlpha = GlassDefaults.ThickBlurAlpha blurAlpha = GlassDefaults.ThickBlurAlpha
), ),
m3ShowLabel = showLabel, m3ShowLabel = showLabel && !customIconPath.isNotEmpty(),
m3AlwaysShowLabel = alwaysShowLabel m3AlwaysShowLabel = alwaysShowLabel && !customIconPath.isNotEmpty(),
useCustomIcon = customIconPath.isNotEmpty()
) )
} }
} }
} }
}
}, },
contentWindowInsets = WindowInsets(0) contentWindowInsets = WindowInsets(0)
) { _ -> ) { _ ->
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxSize()
) {
Box(
modifier = Modifier.then(
if (useLiquidGlass) {
Modifier
.hazeSource(hazeState) .hazeSource(hazeState)
.then( .layerBackdrop(floatingBarBackdrop)
if (useFloatingBottomBar) {
Modifier.layerBackdrop(floatingBarBackdrop)
} else { } else {
Modifier Modifier
} }
@@ -426,6 +394,79 @@ fun MainScreen(
} }
} }
} }
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)
}
},
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, selected: Boolean,
modifier: Modifier = Modifier modifier: 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) val icon = AppIcons.mainDestination(destination, selected)
AppIcon(icon, contentDescription = null, modifier = modifier) AppIcon(icon, contentDescription = null, modifier = modifier)
}
} else {
val icon = AppIcons.mainDestination(destination, selected)
AppIcon(icon, contentDescription = null, modifier = modifier)
}
} }
@@ -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
)
@@ -5,10 +5,12 @@ import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
@@ -24,19 +26,29 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.shadow 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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow 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 androidx.compose.ui.unit.dp
import io.legado.app.R import io.legado.app.R
import io.legado.app.constant.BookType 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.bookshelfConfig.BookshelfConfig
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.card.NormalCard import io.legado.app.ui.widget.components.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.CoilBookCover
import io.legado.app.ui.widget.components.cover.BookshelfCover import io.legado.app.ui.widget.components.cover.BookshelfCover
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.splitNotBlank
import io.legado.app.utils.toTimeAgo import io.legado.app.utils.toTimeAgo
/** /**
@@ -63,24 +76,62 @@ fun BookshelfItem(
titleEnd: @Composable (() -> Unit)? = null, titleEnd: @Composable (() -> Unit)? = null,
subTitle: String? = null, subTitle: String? = null,
desc: String? = null, desc: String? = null,
descMaxLines: Int = 1,
extra: @Composable (RowScope.() -> Unit)? = null, extra: @Composable (RowScope.() -> Unit)? = null,
bottomContent: @Composable (() -> Unit)? = null,
titleSmallFont: Boolean = false, titleSmallFont: Boolean = false,
titleCenter: Boolean = true, titleCenter: Boolean = true,
titleMaxLines: Int = 2, titleMaxLines: Int = 2,
coverShadow: Boolean = false, coverShadow: Boolean = false,
titleColor: Color? = null,
descAnnotated: AnnotatedString? = null,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: (() -> Unit)? onLongClick: (() -> Unit)?
) { ) {
val selectedColor = if (isSelected) { val containerColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) {
LegadoTheme.colorScheme.secondaryContainer 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 { } else {
LegadoTheme.colorScheme.surface.copy(alpha = 0f) LegadoTheme.colorScheme.surface.copy(alpha = 0f)
Modifier
} }
if (isGrid) { if (isGrid) {
Box( Box(
modifier = modifier modifier = modifier
.clip(MaterialTheme.shapes.small)
.background(selectedColor)
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick onLongClick = onLongClick
@@ -99,7 +150,7 @@ fun BookshelfItem(
MaterialTheme.shapes.extraSmall MaterialTheme.shapes.extraSmall
) else Modifier ) else Modifier
) )
.clip(MaterialTheme.shapes.extraSmall) // 先阴影后裁剪 .clip(MaterialTheme.shapes.extraSmall)
) { ) {
cover(Modifier.fillMaxSize()) cover(Modifier.fillMaxSize())
if (gridStyle == 1) { if (gridStyle == 1) {
@@ -146,14 +197,18 @@ fun BookshelfItem(
} }
} }
} else { } else {
// 列表布局
Column { Column {
NormalCard( NormalCard(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(all = 4.dp), .padding(all = 4.dp)
.then(borderModifier),
cornerRadius = 8.dp, cornerRadius = 8.dp,
containerColor = selectedColor, containerColor = if (isSelected) {
LegadoTheme.colorScheme.secondaryContainer
} else {
containerColor
},
onClick = onClick, onClick = onClick,
onLongClick = onLongClick onLongClick = onLongClick
) { ) {
@@ -177,7 +232,11 @@ fun BookshelfItem(
) { ) {
AppText( AppText(
text = title, text = title,
style = LegadoTheme.typography.titleMediumEmphasized, style = if (titleColor != null) {
LegadoTheme.typography.titleMediumEmphasized.copy(color = titleColor)
} else {
LegadoTheme.typography.titleMediumEmphasized
},
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@@ -193,15 +252,24 @@ fun BookshelfItem(
) )
} }
if (!isCompact) { if (!isCompact) {
if (descAnnotated != null) {
AppText(
text = descAnnotated,
style = LegadoTheme.typography.labelSmallEmphasized,
maxLines = descMaxLines,
overflow = TextOverflow.Ellipsis
)
} else {
desc?.let { desc?.let {
AppText( AppText(
text = it, text = it,
style = LegadoTheme.typography.labelSmallEmphasized, style = LegadoTheme.typography.labelSmallEmphasized,
maxLines = 1, maxLines = descMaxLines,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
} }
}
extra?.let { extra?.let {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -210,6 +278,7 @@ fun BookshelfItem(
} }
} }
} }
bottomContent?.invoke()
} }
if (BookshelfConfig.bookshelfShowDivider) if (BookshelfConfig.bookshelfShowDivider)
HorizontalDivider( HorizontalDivider(
@@ -232,7 +301,6 @@ fun BookGroupCover(
modifier = modifier modifier = modifier
.aspectRatio(5f / 7f) .aspectRatio(5f / 7f)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.background(LegadoTheme.colorScheme.surfaceContainer)
) { ) {
if (!coverPath.isNullOrBlank()) { if (!coverPath.isNullOrBlank()) {
CoilBookCover( CoilBookCover(
@@ -326,7 +394,7 @@ fun BookGroupCover(
@Composable @Composable
fun BookGroupItemGrid( fun BookGroupItemGrid(
group: BookGroup, group: BookGroupUi,
previewBooks: List<BookShelfItem>, previewBooks: List<BookShelfItem>,
countText: String? = null, countText: String? = null,
gridStyle: Int = 0, gridStyle: Int = 0,
@@ -363,7 +431,7 @@ fun BookGroupItemGrid(
@Composable @Composable
fun BookGroupItemList( fun BookGroupItemList(
group: BookGroup, group: BookGroupUi,
previewBooks: List<BookShelfItem>, previewBooks: List<BookShelfItem>,
countText: String? = null, countText: String? = null,
isCompact: Boolean = false, isCompact: Boolean = false,
@@ -375,13 +443,27 @@ fun BookGroupItemList(
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: (() -> 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( BookshelfItem(
isGrid = false, isGrid = false,
gridStyle = 0, gridStyle = 0,
isCompact = isCompact, isCompact = isCompact,
cover = { BookGroupCover(books = previewBooks, coverPath = group.cover, modifier = it) }, cover = { BookGroupCover(books = previewBooks, coverPath = group.cover, modifier = it) },
title = group.groupName, title = group.groupName,
titleColor = primaryColor,
subTitle = countText, subTitle = countText,
descAnnotated = descAnnotated,
titleSmallFont = titleSmallFont, titleSmallFont = titleSmallFont,
titleCenter = titleCenter, titleCenter = titleCenter,
titleMaxLines = titleMaxLines, titleMaxLines = titleMaxLines,
@@ -485,6 +567,52 @@ fun BookItem(
book.author book.author
}, },
desc = book.durChapterTitle ?: "", 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 = { extra = {
if (BookshelfConfig.showLastUpdateTime && !book.isLocal) { if (BookshelfConfig.showLastUpdateTime && !book.isLocal) {
AppText( AppText(
@@ -25,7 +25,9 @@ data class BookShelfItem(
val type: Int, val type: Int,
val group: Long, val group: Long,
val order: Int, 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 fun getDisplayCover() = if (customCoverUrl.isNullOrEmpty()) coverUrl else customCoverUrl
@@ -12,6 +12,10 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringArrayResource
@@ -19,8 +23,11 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.R import io.legado.app.R
import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig 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.card.GlassCard
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet 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.CompactDropdownSettingItem
import io.legado.app.ui.widget.components.settingItem.CompactSliderSettingItem import io.legado.app.ui.widget.components.settingItem.CompactSliderSettingItem
import io.legado.app.ui.widget.components.settingItem.CompactSwitchSettingItem import io.legado.app.ui.widget.components.settingItem.CompactSwitchSettingItem
@@ -33,6 +40,7 @@ fun BookshelfConfigSheet(
) { ) {
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
var showLabelColorManage by remember { mutableStateOf(false) }
AppModalBottomSheet( AppModalBottomSheet(
title = stringResource(R.string.bookshelf_layout), title = stringResource(R.string.bookshelf_layout),
@@ -234,6 +242,38 @@ fun BookshelfConfigSheet(
onCheckedChange = { BookshelfConfig.showLastUpdateTime = it } 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( CompactSwitchSettingItem(
title = stringResource(R.string.show_wait_up_count), title = stringResource(R.string.show_wait_up_count),
checked = BookshelfConfig.showWaitUpCount, checked = BookshelfConfig.showWaitUpCount,
@@ -265,5 +305,10 @@ fun BookshelfConfigSheet(
onValueChange = { BookshelfConfig.bookshelfRefreshingLimit = it.toInt() } onValueChange = { BookshelfConfig.bookshelfRefreshingLimit = it.toInt() }
) )
} }
LabelColorManageSheet(
show = showLabelColorManage,
onDismissRequest = { showLabelColorManage = false }
)
} }
} }
@@ -76,8 +76,8 @@ import androidx.compose.ui.window.Dialog
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R import io.legado.app.R
import io.legado.app.base.BaseRuleEvent import io.legado.app.base.BaseRuleEvent
import io.legado.app.data.entities.BookGroup
import io.legado.app.ui.about.AppLogSheet 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.book.info.GroupSelectSheet
import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
import io.legado.app.ui.main.bookCoverSharedElementKey import io.legado.app.ui.main.bookCoverSharedElementKey
@@ -212,17 +212,6 @@ fun BookshelfScreen(
val latestGroups by rememberUpdatedState(uiState.groups) val latestGroups by rememberUpdatedState(uiState.groups)
val latestSelectedGroupId by rememberUpdatedState(uiState.selectedGroupId) 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) { LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage } snapshotFlow { pagerState.settledPage }
.distinctUntilChanged() .distinctUntilChanged()
@@ -709,10 +698,6 @@ fun BookshelfScreen(
paddingValues = paddingValues, paddingValues = paddingValues,
books = uiState.items, books = uiState.items,
uiState = uiState, uiState = uiState,
bookshelfLayoutMode = bookshelfLayoutMode,
bookshelfLayoutGrid = bookshelfLayoutGrid,
bookshelfLayoutList = bookshelfLayoutList,
isEditMode = isEditMode,
selectedBookUrls = selectedBookUrls, selectedBookUrls = selectedBookUrls,
canReorderBooks = false, canReorderBooks = false,
onToggleBookSelection = { toggleBookSelection(it.bookUrl) }, onToggleBookSelection = { toggleBookSelection(it.bookUrl) },
@@ -725,6 +710,7 @@ fun BookshelfScreen(
onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) }, onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) },
onBookClick = onBookClick, onBookClick = onBookClick,
onBookLongClick = onBookLongClick, onBookLongClick = onBookLongClick,
isCurrentPage = true,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
) )
@@ -732,17 +718,15 @@ fun BookshelfScreen(
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = 1, beyondViewportPageCount = 2,
key = { if (it < uiState.groups.size) uiState.groups[it].groupId else it } key = { if (it < uiState.groups.size) uiState.groups[it].groupId else it }
) { pageIndex -> ) { pageIndex ->
val group = uiState.groups.getOrNull(pageIndex) val group = uiState.groups.getOrNull(pageIndex)
if (group != null) { if (group != null) {
val isSelectedGroup = group.groupId == uiState.selectedGroupId val isSelectedGroup = group.groupId == uiState.selectedGroupId
val books = if (isSelectedGroup) { val books = uiState.allGroupBooks[group.groupId]
uiState.items ?: if (isSelectedGroup) uiState.items
} else { else emptyList()
emptyList()
}
val canReorderBooks = isEditMode && val canReorderBooks = isEditMode &&
!uiState.isSearch && !uiState.isSearch &&
(group.bookSort.takeIf { it >= 0 } (group.bookSort.takeIf { it >= 0 }
@@ -752,10 +736,6 @@ fun BookshelfScreen(
paddingValues = paddingValues, paddingValues = paddingValues,
books = books, books = books,
uiState = uiState, uiState = uiState,
bookshelfLayoutMode = bookshelfLayoutMode,
bookshelfLayoutGrid = bookshelfLayoutGrid,
bookshelfLayoutList = bookshelfLayoutList,
isEditMode = isEditMode,
selectedBookUrls = selectedBookUrls, selectedBookUrls = selectedBookUrls,
canReorderBooks = canReorderBooks, canReorderBooks = canReorderBooks,
onToggleBookSelection = { toggleBookSelection(it.bookUrl) }, onToggleBookSelection = { toggleBookSelection(it.bookUrl) },
@@ -788,6 +768,7 @@ fun BookshelfScreen(
onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) }, onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) },
onBookClick = onBookClick, onBookClick = onBookClick,
onBookLongClick = onBookLongClick, onBookLongClick = onBookLongClick,
isCurrentPage = isSelectedGroup,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
) )
@@ -1022,10 +1003,6 @@ fun BookshelfPage(
paddingValues: PaddingValues, paddingValues: PaddingValues,
books: List<BookShelfItem>, books: List<BookShelfItem>,
uiState: BookshelfUiState, uiState: BookshelfUiState,
bookshelfLayoutMode: Int,
bookshelfLayoutGrid: Int,
bookshelfLayoutList: Int,
isEditMode: Boolean,
selectedBookUrls: Set<String>, selectedBookUrls: Set<String>,
canReorderBooks: Boolean, canReorderBooks: Boolean,
onToggleBookSelection: (BookShelfItem) -> Unit, onToggleBookSelection: (BookShelfItem) -> Unit,
@@ -1038,10 +1015,12 @@ fun BookshelfPage(
onGlobalSearch: () -> Unit, onGlobalSearch: () -> Unit,
onBookClick: (BookShelfItem) -> Unit, onBookClick: (BookShelfItem) -> Unit,
onBookLongClick: (BookShelfItem) -> Unit, onBookLongClick: (BookShelfItem) -> Unit,
isCurrentPage: Boolean = true,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
) { ) {
if (books.isEmpty()) { if (books.isEmpty()) {
if (!isCurrentPage) return
if (uiState.isSearch) { if (uiState.isSearch) {
EmptyMessage( EmptyMessage(
modifier = Modifier modifier = Modifier
@@ -1068,6 +1047,14 @@ fun BookshelfPage(
return 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 columns = if (bookshelfLayoutMode == 0) bookshelfLayoutList else bookshelfLayoutGrid
val isGridMode = bookshelfLayoutMode != 0 val isGridMode = bookshelfLayoutMode != 0
val totalHorizontalPadding = val totalHorizontalPadding =
@@ -1148,7 +1135,7 @@ fun BookshelfPage(
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey(book.bookUrl), sharedCoverKey = bookCoverSharedElementKey(book.bookUrl),
onClick = { onClick = {
if (isEditMode) { if (uiState.isEditMode) {
onToggleBookSelection(book) onToggleBookSelection(book)
} else { } else {
onBookClick(book) onBookClick(book)
@@ -1158,7 +1145,7 @@ fun BookshelfPage(
null null
} else { } else {
{ {
if (isEditMode) { if (uiState.isEditMode) {
onToggleBookSelection(book) onToggleBookSelection(book)
} else { } else {
onBookLongClick(book) onBookLongClick(book)
@@ -1,5 +1,6 @@
package io.legado.app.ui.main.bookshelf package io.legado.app.ui.main.bookshelf
import androidx.compose.runtime.Stable
import io.legado.app.data.entities.BookGroup import io.legado.app.data.entities.BookGroup
import io.legado.app.ui.widget.components.list.ListUiState import io.legado.app.ui.widget.components.list.ListUiState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -9,8 +10,9 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
@Stable
data class BookshelfGroupSelectorState( data class BookshelfGroupSelectorState(
val groups: ImmutableList<BookGroup> = persistentListOf(), val groups: ImmutableList<BookGroupUi> = persistentListOf(),
val selectedGroupIndex: Int = 0, val selectedGroupIndex: Int = 0,
val selectedGroupId: Long = BookGroup.IdAll val selectedGroupId: Long = BookGroup.IdAll
) )
@@ -27,14 +29,15 @@ sealed interface BookshelfOverlay {
data object BatchDownloadConfirmDialog : BookshelfOverlay data object BatchDownloadConfirmDialog : BookshelfOverlay
} }
@Stable
data class BookshelfUiState( data class BookshelfUiState(
override val items: ImmutableList<BookShelfItem> = persistentListOf(), override val items: ImmutableList<BookShelfItem> = persistentListOf(),
override val selectedIds: ImmutableSet<Any> = persistentSetOf(), override val selectedIds: ImmutableSet<Any> = persistentSetOf(),
override val searchKey: String = "", override val searchKey: String = "",
override val isSearch: Boolean = false, override val isSearch: Boolean = false,
override val isLoading: Boolean = false, override val isLoading: Boolean = false,
val groups: ImmutableList<BookGroup> = persistentListOf(), val groups: ImmutableList<BookGroupUi> = persistentListOf(),
val allGroups: ImmutableList<BookGroup> = persistentListOf(), val allGroups: ImmutableList<BookGroupUi> = persistentListOf(),
val groupPreviews: ImmutableMap<Long, ImmutableList<BookShelfItem>> = persistentMapOf(), val groupPreviews: ImmutableMap<Long, ImmutableList<BookShelfItem>> = persistentMapOf(),
val groupBookCounts: ImmutableMap<Long, Int> = persistentMapOf(), val groupBookCounts: ImmutableMap<Long, Int> = persistentMapOf(),
val currentGroupBookCount: Int = 0, val currentGroupBookCount: Int = 0,
@@ -56,5 +59,6 @@ data class BookshelfUiState(
val subtitle: String? = null, val subtitle: String? = null,
val currentGroupName: String? = null, val currentGroupName: String? = null,
val draggingBooks: ImmutableList<BookShelfItem>? = null, val draggingBooks: ImmutableList<BookShelfItem>? = null,
val pendingSavedBooks: ImmutableList<BookShelfItem>? = null val pendingSavedBooks: ImmutableList<BookShelfItem>? = null,
val allGroupBooks: ImmutableMap<Long, ImmutableList<BookShelfItem>> = persistentMapOf()
) : ListUiState<BookShelfItem> ) : ListUiState<BookShelfItem>
@@ -73,10 +73,13 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -158,10 +161,6 @@ class BookshelfViewModel(
val allGroupsFlow: StateFlow<List<BookGroup>> = bookGroupRepository.flowAll() val allGroupsFlow: StateFlow<List<BookGroup>> = bookGroupRepository.flowAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private val allBooksFlow = appDb.bookDao.flowBookShelf()
.distinctUntilChanged()
.flowOn(Dispatchers.Default)
private data class GroupPreviewState( private data class GroupPreviewState(
val previews: ImmutableMap<Long, ImmutableList<BookShelfItem>>, val previews: ImmutableMap<Long, ImmutableList<BookShelfItem>>,
val counts: ImmutableMap<Long, Int>, val counts: ImmutableMap<Long, Int>,
@@ -173,7 +172,7 @@ class BookshelfViewModel(
groupIdFlow groupIdFlow
) { groups, selectedGroupId -> ) { groups, selectedGroupId ->
BookshelfGroupSelectorState( BookshelfGroupSelectorState(
groups = groups.toImmutableList(), groups = groups.map { it.toBookGroupUi() }.toImmutableList(),
selectedGroupIndex = groups.indexOfFirst { it.groupId == selectedGroupId } selectedGroupIndex = groups.indexOfFirst { it.groupId == selectedGroupId }
.coerceAtLeast(0), .coerceAtLeast(0),
selectedGroupId = selectedGroupId selectedGroupId = selectedGroupId
@@ -197,6 +196,25 @@ class BookshelfViewModel(
} }
}.distinctUntilChanged().flowOn(Dispatchers.Default) }.distinctUntilChanged().flowOn(Dispatchers.Default)
@OptIn(ExperimentalCoroutinesApi::class)
private val allGroupBooksFlow: StateFlow<Map<Long, List<BookShelfItem>>> = 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( private val visibleBooksFlow = combine(
booksFlow, booksFlow,
searchKeyFlow, searchKeyFlow,
@@ -213,14 +231,44 @@ class BookshelfViewModel(
selectedBookUrls.intersect(visibleBookUrls) selectedBookUrls.intersect(visibleBookUrls)
}.distinctUntilChanged() }.distinctUntilChanged()
private val groupPreviewsFlow = @OptIn(ExperimentalCoroutinesApi::class)
combine( private val groupPreviewsFlow = combine(
groupsFlow, groupsFlow,
allBooksFlow,
bookGroupStyleFlow, bookGroupStyleFlow,
sortConfigFlow appDb.bookDao.flowSystemGroupCounts()
) { groups, allBooks, bookGroupStyle, sortConfig -> ) { groups, bookGroupStyle, systemCounts ->
buildGroupPreviewState(groups, allBooks, bookGroupStyle, sortConfig) 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<Int> = 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<Long, ImmutableList<BookShelfItem>>()
var counts = persistentMapOf<Long, Int>()
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) }.distinctUntilChanged().flowOn(Dispatchers.Default)
private val coreInternalStateFlow = combine( private val coreInternalStateFlow = combine(
@@ -309,16 +357,28 @@ class BookshelfViewModel(
} }
private val dataStateFlow = combine( private val dataStateFlow = combine(
combine(
booksFlow, booksFlow,
groupsFlow, groupsFlow,
allGroupsFlow, allGroupsFlow,
groupPreviewsFlow, groupPreviewsFlow,
internalStateFlow internalStateFlow
) { books, groups, allGroups, previews, internal -> ) { books, groups, allGroups, previews, internal ->
BookshelfDataState(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<BookShelfItem>, val books: List<BookShelfItem>,
val groups: List<BookGroup>, val groups: List<BookGroup>,
val allGroups: List<BookGroup>, val allGroups: List<BookGroup>,
@@ -326,6 +386,15 @@ class BookshelfViewModel(
val internal: InternalState val internal: InternalState
) )
private data class BookshelfDataState(
val books: List<BookShelfItem>,
val groups: List<BookGroupUi>,
val allGroups: List<BookGroupUi>,
val previews: GroupPreviewState,
val internal: InternalState,
val allGroupBooks: Map<Long, List<BookShelfItem>>
)
val uiState: StateFlow<BookshelfUiState> = combine( val uiState: StateFlow<BookshelfUiState> = combine(
dataStateFlow, dataStateFlow,
interactionStateFlow interactionStateFlow
@@ -383,7 +452,8 @@ class BookshelfViewModel(
}, },
currentGroupName = currentGroupName, currentGroupName = currentGroupName,
draggingBooks = interaction.draggingBooks?.toImmutableList(), 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()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BookshelfUiState())
@@ -495,93 +565,6 @@ class BookshelfViewModel(
} }
} }
private fun buildGroupPreviewState(
groups: List<BookGroup>,
allBooks: List<BookShelfItem>,
bookGroupStyle: Int,
sortConfig: BookshelfSortConfig
): GroupPreviewState {
if (bookGroupStyle !in 2..3) {
return GroupPreviewState(persistentMapOf(), persistentMapOf(), allBooks.size)
}
val buckets = HashMap<Long, MutableList<BookShelfItem>>(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<Long, ImmutableList<BookShelfItem>>(groups.size)
val counts = HashMap<Long, Int>(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<BookShelfItem>): ImmutableList<BookShelfItem> {
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) { fun changeGroup(groupId: Long) {
if (groupIdFlow.value != groupId) { if (groupIdFlow.value != groupId) {
groupIdFlow.value = groupId groupIdFlow.value = groupId
@@ -5,6 +5,7 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement 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.search.SearchScope
import io.legado.app.ui.book.source.edit.BookSourceEditActivity import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.login.SourceLoginActivity 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
import io.legado.app.ui.theme.LegadoTheme.composeEngine import io.legado.app.ui.theme.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.theme.ThemeResolver
@@ -286,12 +288,22 @@ fun ExploreScreen(
.align(Alignment.TopStart) .align(Alignment.TopStart)
.padding(top = paddingValues.calculateTopPadding() + 4.dp, start = 8.dp) .padding(top = paddingValues.calculateTopPadding() + 4.dp, start = 8.dp)
) { item -> ) { 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( TextCard(
text = item.bookSourceName, text = item.bookSourceName,
textStyle = LegadoTheme.typography.labelMediumEmphasized, textStyle = LegadoTheme.typography.labelMediumEmphasized,
cornerRadius = 12.dp, cornerRadius = 12.dp,
horizontalPadding = 12.dp, horizontalPadding = 12.dp,
verticalPadding = 8.dp, verticalPadding = 8.dp,
border = if (enableBorder) {
BorderStroke(borderWidth, borderColor)
} else null,
onClick = { onClick = {
scope.launch { scope.launch {
val index = val index =
@@ -362,12 +374,23 @@ fun ExploreSourceHeader(
label = "CardColor" 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( GlassCard(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
cornerRadius = 12.dp, cornerRadius = 12.dp,
containerColor = containerColor containerColor = containerColor,
border = if (enableBorder) {
BorderStroke(borderWidth, borderColor)
} else null
) { ) {
ListItem( ListItem(
modifier = Modifier modifier = Modifier
@@ -1,6 +1,7 @@
package io.legado.app.ui.rss.article package io.legado.app.ui.rss.article
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.card.GlassCard
import io.legado.app.ui.widget.components.cover.buildCoverImageRequest import io.legado.app.ui.widget.components.cover.buildCoverImageRequest
import io.legado.app.utils.toastOnUi 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.Dispatchers.IO
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
@@ -92,6 +96,7 @@ fun RssArticlesPage(
rssUrl: String?, rssUrl: String?,
rssSource: RssSource?, rssSource: RssSource?,
viewModel: RssArticlesViewModel, viewModel: RssArticlesViewModel,
searchKey: String? = null,
onRead: (RssArticle) -> Unit, onRead: (RssArticle) -> Unit,
paddingValues: PaddingValues = PaddingValues(0.dp), paddingValues: PaddingValues = PaddingValues(0.dp),
modifier: Modifier = Modifier modifier: Modifier = Modifier
@@ -99,9 +104,10 @@ fun RssArticlesPage(
val context = LocalContext.current val context = LocalContext.current
val layout = remember(articleStyle) { articleStyle.toRssArticleLayout() } val layout = remember(articleStyle) { articleStyle.toRssArticleLayout() }
val loadState by viewModel.loadState.collectAsStateWithLifecycle() val loadState by viewModel.loadState.collectAsStateWithLifecycle()
val isPreload = remember(rssSource) { rssSource?.preload == true }
LaunchedEffect(sortName, sortUrl) { LaunchedEffect(sortName, sortUrl, searchKey) {
viewModel.init(sortName, sortUrl) viewModel.init(sortName, sortUrl, searchKey)
} }
val articleFlow = remember(rssUrl, sortName) { val articleFlow = remember(rssUrl, sortName) {
@@ -117,7 +123,11 @@ fun RssArticlesPage(
val articles by articleFlow.collectAsStateWithLifecycle(initialValue = emptyList()) val articles by articleFlow.collectAsStateWithLifecycle(initialValue = emptyList())
LaunchedEffect(rssSource?.sourceUrl, sortName, sortUrl) { LaunchedEffect(rssSource?.sourceUrl, sortName, sortUrl) {
rssSource?.let(viewModel::loadArticles) rssSource?.let { source ->
if (isPreload) {
viewModel.loadArticles(source)
}
}
} }
LaunchedEffect(loadState.errorMessage) { LaunchedEffect(loadState.errorMessage) {
@@ -142,6 +152,7 @@ fun RssArticlesPage(
LoadMoreDetector( LoadMoreDetector(
state = listState, state = listState,
enabled = loadState.canLoadMore, enabled = loadState.canLoadMore,
preloadThreshold = if (isPreload) 5 else 3,
onLoadMore = { rssSource?.let(viewModel::loadMore) } onLoadMore = { rssSource?.let(viewModel::loadMore) }
) )
LazyColumn( LazyColumn(
@@ -174,6 +185,7 @@ fun RssArticlesPage(
GridLoadMoreDetector( GridLoadMoreDetector(
state = gridState, state = gridState,
enabled = loadState.canLoadMore, enabled = loadState.canLoadMore,
preloadThreshold = if (isPreload) 5 else 3,
onLoadMore = { rssSource?.let(viewModel::loadMore) } onLoadMore = { rssSource?.let(viewModel::loadMore) }
) )
LazyVerticalGrid( LazyVerticalGrid(
@@ -208,6 +220,7 @@ fun RssArticlesPage(
StaggeredLoadMoreDetector( StaggeredLoadMoreDetector(
state = staggeredState, state = staggeredState,
enabled = loadState.canLoadMore, enabled = loadState.canLoadMore,
preloadThreshold = if (isPreload) 5 else 3,
onLoadMore = { rssSource?.let(viewModel::loadMore) } onLoadMore = { rssSource?.let(viewModel::loadMore) }
) )
LazyVerticalStaggeredGrid( LazyVerticalStaggeredGrid(
@@ -244,13 +257,14 @@ fun RssArticlesPage(
private fun LoadMoreDetector( private fun LoadMoreDetector(
state: LazyListState, state: LazyListState,
enabled: Boolean, enabled: Boolean,
preloadThreshold: Int = 3,
onLoadMore: () -> Unit onLoadMore: () -> Unit
) { ) {
LaunchedEffect(state, enabled) { LaunchedEffect(state, enabled) {
snapshotFlow { snapshotFlow {
val info = state.layoutInfo val info = state.layoutInfo
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 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 -> }.collect { shouldLoad ->
if (shouldLoad) onLoadMore() if (shouldLoad) onLoadMore()
} }
@@ -261,13 +275,14 @@ private fun LoadMoreDetector(
private fun GridLoadMoreDetector( private fun GridLoadMoreDetector(
state: LazyGridState, state: LazyGridState,
enabled: Boolean, enabled: Boolean,
preloadThreshold: Int = 3,
onLoadMore: () -> Unit onLoadMore: () -> Unit
) { ) {
LaunchedEffect(state, enabled) { LaunchedEffect(state, enabled) {
snapshotFlow { snapshotFlow {
val info = state.layoutInfo val info = state.layoutInfo
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 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 -> }.collect { shouldLoad ->
if (shouldLoad) onLoadMore() if (shouldLoad) onLoadMore()
} }
@@ -278,13 +293,14 @@ private fun GridLoadMoreDetector(
private fun StaggeredLoadMoreDetector( private fun StaggeredLoadMoreDetector(
state: LazyStaggeredGridState, state: LazyStaggeredGridState,
enabled: Boolean, enabled: Boolean,
preloadThreshold: Int = 3,
onLoadMore: () -> Unit onLoadMore: () -> Unit
) { ) {
LaunchedEffect(state, enabled) { LaunchedEffect(state, enabled) {
snapshotFlow { snapshotFlow {
val info = state.layoutInfo val info = state.layoutInfo
val lastVisible = info.visibleItemsInfo.maxOfOrNull { it.index } ?: -1 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 -> }.collect { shouldLoad ->
if (shouldLoad) onLoadMore() if (shouldLoad) onLoadMore()
} }
@@ -340,11 +356,22 @@ private fun RssArticleItem(
RssArticleLayout.Waterfall -> 3 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( GlassCard(
onClick = { onClick(article) }, onClick = { onClick(article) },
cornerRadius = 12.dp, cornerRadius = 12.dp,
containerColor = containerColor, containerColor = containerColor,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth().then(borderModifier)
) { ) {
when (layout) { when (layout) {
RssArticleLayout.List -> { RssArticleLayout.List -> {
@@ -24,21 +24,24 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
private var nextPageUrl: String? = null private var nextPageUrl: String? = null
var sortName: String = "" var sortName: String = ""
var sortUrl: String = "" var sortUrl: String = ""
var searchKey: String? = null
var page = 1 var page = 1
fun init(bundle: Bundle?) { fun init(bundle: Bundle?) {
bundle?.let { bundle?.let {
init( init(
sortName = it.getString("sortName") ?: "", sortName = it.getString("sortName") ?: "",
sortUrl = it.getString("sortUrl") ?: "" sortUrl = it.getString("sortUrl") ?: "",
searchKey = it.getString("searchKey")
) )
} }
} }
fun init(sortName: String, sortUrl: String) { fun init(sortName: String, sortUrl: String, searchKey: String? = null) {
if (this.sortName == sortName && this.sortUrl == sortUrl) return if (this.sortName == sortName && this.sortUrl == sortUrl && this.searchKey == searchKey) return
this.sortName = sortName this.sortName = sortName
this.sortUrl = sortUrl this.sortUrl = sortUrl
this.searchKey = searchKey
page = 1 page = 1
nextPageUrl = null nextPageUrl = null
_loadState.value = RssArticlesLoadState() _loadState.value = RssArticlesLoadState()
@@ -53,7 +56,7 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
) )
page = 1 page = 1
order = System.currentTimeMillis() 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 nextPageUrl = it.second
val articles = it.first val articles = it.first
articles.forEach { rssArticle -> articles.forEach { rssArticle ->
@@ -97,7 +100,7 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
) )
page++ page++
Rss.getArticles(viewModelScope, sortName, pageUrl, rssSource, page).onSuccess(IO) { Rss.getArticles(viewModelScope, sortName, pageUrl, rssSource, page, searchKey).onSuccess(IO) {
nextPageUrl = it.second nextPageUrl = it.second
loadMoreSuccess(it.first) loadMoreSuccess(it.first)
}.onError { }.onError {
@@ -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<WebView?>(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 {
"<style>$it</style>"
} ?: "<style>img{max-width:100% !important; width:auto; height:auto;}body{word-wrap:break-word; height:auto;max-width: 100%; width:auto;}</style>"
val jsScript = startJs?.takeIf { it.isNotBlank() }?.let {
"<script>$it</script>"
} ?: ""
val preloadScript = if (hasPreloadJs) {
WebJsExtensions.JS_URL
} else {
""
}
val htmlBuilder = StringBuilder()
val headIndex = html.indexOf("<head>")
if (headIndex >= 0) {
htmlBuilder.append(html, 0, headIndex + 6)
htmlBuilder.append(preloadScript)
htmlBuilder.append(html, headIndex + 6, html.length)
} else {
htmlBuilder.append("<head>")
htmlBuilder.append(preloadScript)
htmlBuilder.append("</head>")
htmlBuilder.append(html)
}
val result = htmlBuilder.toString()
val styleEndIndex = result.indexOf("</style>")
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("</head>")
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)
}
}
}
}
@@ -9,6 +9,7 @@ import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.RssReadRecord import io.legado.app.data.entities.RssReadRecord
import io.legado.app.data.entities.RssSource import io.legado.app.data.entities.RssSource
import io.legado.app.help.JsExtensions 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.association.AddToBookshelfDialog
import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.login.SourceLoginActivity 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())) activityRef.get()?.showDialogFragment(PhotoDialog(src, getSource()?.getKey()))
} }
@JvmOverloads
fun getStringList(rule: String?, mContent: Any? = null, isUrl: Boolean = false): List<String>? {
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 @JavascriptInterface
@JvmOverloads @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 val activity = activityRef.get() ?: return
activity.lifecycleScope.launch(IO) { activity.lifecycleScope.launch(IO) {
val source = getSource() ?: return@launch val source = getSource() ?: return@launch
@@ -151,7 +161,7 @@ open class RssJsExtensions(activity: AppCompatActivity?, source: BaseSource?) :
origin = sourceUrl, origin = sourceUrl,
readTime = System.currentTimeMillis() readTime = System.currentTimeMillis()
) )
appDb.rssReadRecordDao.insertRecord(rssReadRecord) //留下历史记录 appDb.rssReadRecordDao.insertRecord(rssReadRecord)
withContext(Main) { withContext(Main) {
activity.startActivity( activity.startActivity(
MainActivity.createRssReadIntent( MainActivity.createRssReadIntent(
@@ -1,16 +1,17 @@
package io.legado.app.ui.rss.source.debug package io.legado.app.ui.rss.source.debug
import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.widget.SearchView import android.view.View
import androidx.appcompat.widget.SearchView
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import io.legado.app.R import io.legado.app.R
import io.legado.app.base.VMBaseActivity import io.legado.app.base.VMBaseActivity
import io.legado.app.databinding.ActivitySourceDebugBinding import io.legado.app.databinding.ActivitySourceDebugBinding
//import io.legado.app.lib.theme.accentColor import io.legado.app.help.source.sortUrls
//import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.widget.dialog.TextDialog import io.legado.app.ui.widget.dialog.TextDialog
import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.applyNavigationBarPadding
import io.legado.app.utils.gone 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.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible import io.legado.app.utils.visible
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import splitties.views.onClick
import splitties.views.onLongClick
class RssSourceDebugActivity : VMBaseActivity<ActivitySourceDebugBinding, RssSourceDebugModel>() { class RssSourceDebugActivity : VMBaseActivity<ActivitySourceDebugBinding, RssSourceDebugModel>() {
@@ -28,10 +31,12 @@ class RssSourceDebugActivity : VMBaseActivity<ActivitySourceDebugBinding, RssSou
private val adapter by lazy { RssSourceDebugAdapter(this) } private val adapter by lazy { RssSourceDebugAdapter(this) }
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
initRecyclerView() initRecyclerView()
initSearchView() initSearchView()
initHelpView()
viewModel.observe { state, msg -> viewModel.observe { state, msg ->
lifecycleScope.launch { lifecycleScope.launch {
adapter.addItem(msg) adapter.addItem(msg)
@@ -41,7 +46,7 @@ class RssSourceDebugActivity : VMBaseActivity<ActivitySourceDebugBinding, RssSou
} }
} }
viewModel.initData(intent.getStringExtra("key")) { viewModel.initData(intent.getStringExtra("key")) {
startDebug() // initData 完成后的回调
} }
} }
@@ -59,21 +64,72 @@ class RssSourceDebugActivity : VMBaseActivity<ActivitySourceDebugBinding, RssSou
} }
private fun initRecyclerView() { private fun initRecyclerView() {
// binding.recyclerView.setEdgeEffectColor(primaryColor)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
binding.recyclerView.applyNavigationBarPadding() binding.recyclerView.applyNavigationBarPadding()
// binding.rotateLoading.loadingColor = accentColor
} }
private fun initSearchView() { private fun initSearchView() {
binding.titleBar.findViewById<SearchView>(R.id.search_view).gone() val searchView = binding.titleBar.findViewById<SearchView>(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
} }
private fun startDebug() { override fun onQueryTextChange(newText: String?): Boolean {
return false
}
})
}
@SuppressLint("SetTextI18n")
private fun initHelpView() {
binding.help.visible()
binding.textMy.onClick {
binding.titleBar.findViewById<SearchView>(R.id.search_view).setQuery(binding.textMy.text, true)
}
binding.textXt.onClick {
binding.titleBar.findViewById<SearchView>(R.id.search_view).setQuery(binding.textXt.text, true)
}
binding.textFx.onClick {
if (!binding.textFx.text.startsWith("ERROR:")) {
binding.titleBar.findViewById<SearchView>(R.id.search_view).setQuery(binding.textFx.text, true)
}
}
binding.textContent.onClick {
val query = binding.titleBar.findViewById<SearchView>(R.id.search_view).query
if (!query.isNullOrBlank()) {
binding.titleBar.findViewById<SearchView>(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<SearchView>(R.id.search_view).clearFocus()
return@launch
}
}
}
}
private fun startDebug(key: String) {
adapter.clearItems() adapter.clearItems()
binding.help.gone() // 关闭调试选项面板
viewModel.rssSource?.let { viewModel.rssSource?.let {
binding.rotateLoading.visible() binding.rotateLoading.visible()
viewModel.startDebug(it) viewModel.startDebug(it, key)
} ?: toastOnUi(R.string.error_no_source) } ?: toastOnUi(R.string.error_no_source)
} }
} }
@@ -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) { override fun printLog(state: Int, msg: String) {
when (state) { when (state) {
10 -> listSrc = msg 10 -> listSrc = msg
@@ -57,6 +57,7 @@ class RssSourceEditActivity :
} }
private val adapter by lazy { RssSourceEditAdapter() } private val adapter by lazy { RssSourceEditAdapter() }
private val sourceEntities: ArrayList<EditEntity> = ArrayList() private val sourceEntities: ArrayList<EditEntity> = ArrayList()
private val startEntities: ArrayList<EditEntity> = ArrayList()
private val listEntities: ArrayList<EditEntity> = ArrayList() private val listEntities: ArrayList<EditEntity> = ArrayList()
private val webViewEntities: ArrayList<EditEntity> = ArrayList() private val webViewEntities: ArrayList<EditEntity> = ArrayList()
private val selectDoc = registerForActivityResult(HandleFileContract()) { private val selectDoc = registerForActivityResult(HandleFileContract()) {
@@ -156,6 +157,8 @@ class RssSourceEditActivity :
ErrorCorrectionLevel.L ErrorCorrectionLevel.L
) )
R.id.menu_log -> showDialogFragment<io.legado.app.ui.about.AppLogDialog>()
R.id.menu_help -> showHelp("ruleHelp") R.id.menu_help -> showHelp("ruleHelp")
} }
return super.onCompatOptionsItemSelected(item) return super.onCompatOptionsItemSelected(item)
@@ -165,6 +168,9 @@ class RssSourceEditActivity :
binding.tabLayout.addTab(binding.tabLayout.newTab().apply { binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
setText(R.string.source_tab_base) 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 { binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
setText(R.string.source_tab_list) setText(R.string.source_tab_list)
}) })
@@ -199,8 +205,9 @@ class RssSourceEditActivity :
private fun setEditEntities(tabPosition: Int?) { private fun setEditEntities(tabPosition: Int?) {
when (tabPosition) { when (tabPosition) {
1 -> adapter.editEntities = listEntities 1 -> adapter.editEntities = startEntities
2 -> adapter.editEntities = webViewEntities 2 -> adapter.editEntities = listEntities
3 -> adapter.editEntities = webViewEntities
else -> adapter.editEntities = sourceEntities else -> adapter.editEntities = sourceEntities
} }
binding.recyclerView.scrollToPosition(0) binding.recyclerView.scrollToPosition(0)
@@ -212,6 +219,21 @@ class RssSourceEditActivity :
binding.cbIsEnable.isChecked = rs.enabled binding.cbIsEnable.isChecked = rs.enabled
binding.cbSingleUrl.isChecked = rs.singleUrl binding.cbSingleUrl.isChecked = rs.singleUrl
binding.cbIsEnableCookie.isChecked = rs.enabledCookieJar == true 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..<typeCount) {
rs.type = 0
}
if (typeCount > 0) {
binding.spType.setSelection(rs.type)
}
val styleCount = binding.lyType.adapter?.count ?: 0
if (styleCount > 0 && rs.articleStyle !in 0..<styleCount) {
rs.articleStyle = 0
}
if (styleCount > 0) {
binding.lyType.setSelection(rs.articleStyle)
}
} }
sourceEntities.clear() sourceEntities.clear()
sourceEntities.apply { sourceEntities.apply {
@@ -220,6 +242,7 @@ class RssSourceEditActivity :
add(EditEntity("sourceIcon", rs.sourceIcon, R.string.source_icon)) add(EditEntity("sourceIcon", rs.sourceIcon, R.string.source_icon))
add(EditEntity("sourceGroup", rs.sourceGroup, R.string.source_group)) add(EditEntity("sourceGroup", rs.sourceGroup, R.string.source_group))
add(EditEntity("sourceComment", rs.sourceComment, R.string.comment)) 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("sortUrl", rs.sortUrl, R.string.sort_url))
add(EditEntity("loginUrl", rs.loginUrl, R.string.login_url)) add(EditEntity("loginUrl", rs.loginUrl, R.string.login_url))
add(EditEntity("loginUi", rs.loginUi, R.string.login_ui)) 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("concurrentRate", rs.concurrentRate, R.string.concurrent_rate))
add(EditEntity("jsLib", rs.jsLib, "jsLib")) 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.clear()
listEntities.apply { listEntities.apply {
add(EditEntity("ruleArticles", rs.ruleArticles, R.string.r_articles)) add(EditEntity("ruleArticles", rs.ruleArticles, R.string.r_articles))
@@ -258,6 +288,22 @@ class RssSourceEditActivity :
EditEntity.ViewType.checkBox 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("ruleContent", rs.ruleContent, R.string.r_content))
add(EditEntity("style", rs.style, R.string.r_style)) add(EditEntity("style", rs.style, R.string.r_style))
add(EditEntity("injectJs", rs.injectJs, R.string.r_inject_js)) add(EditEntity("injectJs", rs.injectJs, R.string.r_inject_js))
@@ -280,6 +326,9 @@ class RssSourceEditActivity :
source.enabled = binding.cbIsEnable.isChecked source.enabled = binding.cbIsEnable.isChecked
source.singleUrl = binding.cbSingleUrl.isChecked source.singleUrl = binding.cbSingleUrl.isChecked
source.enabledCookieJar = binding.cbIsEnableCookie.isChecked source.enabledCookieJar = binding.cbIsEnableCookie.isChecked
source.preload = binding.cbIsEnablePreload.isChecked
source.type = binding.spType.selectedItemPosition
source.articleStyle = binding.lyType.selectedItemPosition
sourceEntities.forEach { sourceEntities.forEach {
it.value = it.value?.takeIf { s -> s.isNotBlank() } it.value = it.value?.takeIf { s -> s.isNotBlank() }
when (it.key) { when (it.key) {
@@ -295,10 +344,20 @@ class RssSourceEditActivity :
"header" -> source.header = it.value "header" -> source.header = it.value
"variableComment" -> source.variableComment = it.value "variableComment" -> source.variableComment = it.value
"concurrentRate" -> source.concurrentRate = it.value "concurrentRate" -> source.concurrentRate = it.value
"searchUrl" -> source.searchUrl = it.value
"sortUrl" -> source.sortUrl = it.value "sortUrl" -> source.sortUrl = it.value
"jsLib" -> source.jsLib = 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 { listEntities.forEach {
it.value = it.value?.takeIf { s -> s.isNotBlank() } it.value = it.value?.takeIf { s -> s.isNotBlank() }
when (it.key) { when (it.key) {
@@ -327,6 +386,8 @@ class RssSourceEditActivity :
when (it.key) { when (it.key) {
"enableJs" -> source.enableJs = it.value.isTrue() "enableJs" -> source.enableJs = it.value.isTrue()
"loadWithBaseUrl" -> source.loadWithBaseUrl = it.value.isTrue() "loadWithBaseUrl" -> source.loadWithBaseUrl = it.value.isTrue()
"showWebLog" -> source.showWebLog = it.value.isTrue()
"cacheFirst" -> source.cacheFirst = it.value.isTrue()
"ruleContent" -> source.ruleContent = "ruleContent" -> source.ruleContent =
viewModel.ruleComplete(it.value, source.ruleArticles) viewModel.ruleComplete(it.value, source.ruleArticles)
@@ -9,6 +9,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.config.themeConfig.ThemeConfig
import org.koin.compose.koinInject
@Composable @Composable
fun AppBackground( fun AppBackground(
@@ -29,10 +30,10 @@ fun AppBackground(
AsyncImage( AsyncImage(
model = bgImagePath, model = bgImagePath,
contentDescription = null, contentDescription = null,
imageLoader = koinInject(),
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.blur(blur.dp), .blur(blur.dp),
//TODO:低版本安卓使用coil-transformations
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} }
@@ -7,17 +7,145 @@ import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme import androidx.compose.material3.MotionScheme
import androidx.compose.material3.Shapes import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource 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.config.themeConfig.ThemeConfig
import io.legado.app.ui.theme.LegadoTheme
import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.ThemeController 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) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -35,12 +163,47 @@ fun AppTheme(
val useMiuixMonet = ThemeConfig.useMiuixMonet val useMiuixMonet = ThemeConfig.useMiuixMonet
val customPrimary = ThemeConfig.cPrimary val customPrimary = ThemeConfig.cPrimary
val customNightPrimary = ThemeConfig.cNPrimary 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 colorSchemeMode = ThemeResolver.resolveColorSchemeMode(themeModeValue)
val miuixColorSchemeMode = remember(themeModeValue, useMiuixMonet) { val miuixColorSchemeMode = remember(themeModeValue, useMiuixMonet) {
ThemeResolver.resolveMiuixColorSchemeMode(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 = val colorScheme =
remember( remember(
@@ -50,9 +213,33 @@ fun AppTheme(
isPureBlack, isPureBlack,
customPrimary, customPrimary,
customNightPrimary, customNightPrimary,
enableDeepPersonalization,
themeColor,
secondaryThemeColor,
primaryTextColor,
secondaryTextColor,
themeBackgroundColor,
customLabelContainerColor,
paletteStyleValue, paletteStyleValue,
materialVersion materialVersion
) { ) {
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 val customSeedColor = if (darkTheme) customNightPrimary else customPrimary
ThemeEngine.getColorScheme( ThemeEngine.getColorScheme(
context = context, context = context,
@@ -64,18 +251,15 @@ fun AppTheme(
customSeedColor = customSeedColor customSeedColor = customSeedColor
) )
} }
}
val customSeedColor = remember( val themeSeedColor = remember(appThemeMode, colorScheme.primary) {
darkTheme, if (appThemeMode == AppThemeMode.Custom) {
customPrimary,
customNightPrimary,
colorScheme.primary
) {
val seed = if (darkTheme) customNightPrimary else customPrimary val seed = if (darkTheme) customNightPrimary else customPrimary
if (seed != 0) Color(seed) else colorScheme.primary if (seed != 0) Color(seed) else colorScheme.primary
} else {
colorScheme.primary
} }
val themeSeedColor = remember(appThemeMode, customSeedColor, colorScheme.primary) {
if (appThemeMode == AppThemeMode.Custom) customSeedColor else colorScheme.primary
} }
val miuixPaletteStyle = remember(paletteStyleValue) { val miuixPaletteStyle = remember(paletteStyleValue) {
ThemeResolver.resolveMiuixPaletteStyle(paletteStyleValue) ThemeResolver.resolveMiuixPaletteStyle(paletteStyleValue)
@@ -111,7 +295,9 @@ fun AppTheme(
themeColors.useDynamicColor && themeColors.useDynamicColor &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
) { ) {
colorResource(id = android.R.color.system_accent1_500) // colorResource 只能在 @Composable 上下文中调用
// 这里使用一个固定颜色作为替代
Color(0xFF6750A4)
} else { } else {
themeSeedColor themeSeedColor
} }
@@ -142,13 +328,57 @@ fun AppTheme(
MiuixTheme(controller = controller) { MiuixTheme(controller = controller) {
val miuixStyles = MiuixTheme.textStyles val miuixStyles = MiuixTheme.textStyles
val legadoTypography = remember(miuixStyles) { val legadoTypography = remember<LegadoTypography>(miuixStyles, customFontFamily) {
miuixStylesToM3Typography(miuixStyles).toLegadoTypography() 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 miuixColorScheme = MiuixTheme.colorScheme
val mappedColorScheme = remember(miuixColorScheme) { 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( LegadoColorScheme(
primary = miuixColorScheme.primary, primary = miuixColorScheme.primary,
onPrimary = miuixColorScheme.onPrimary, onPrimary = miuixColorScheme.onPrimary,
@@ -166,13 +396,13 @@ fun AppTheme(
tertiaryContainer = miuixColorScheme.primaryContainer, tertiaryContainer = miuixColorScheme.primaryContainer,
onTertiaryContainer = miuixColorScheme.primaryVariant, onTertiaryContainer = miuixColorScheme.primaryVariant,
background = miuixColorScheme.background, background = customBgColor,
onBackground = miuixColorScheme.onBackground, onBackground = customFontColor,
surface = miuixColorScheme.surface, surface = miuixColorScheme.surface,
onSurface = miuixColorScheme.onSurface, onSurface = customFontColor,
surfaceVariant = miuixColorScheme.surfaceVariant, surfaceVariant = miuixColorScheme.surfaceVariant,
onSurfaceVariant = miuixColorScheme.onSurfaceSecondary, onSurfaceVariant = customFontColor,
surfaceTint = miuixColorScheme.primary, surfaceTint = miuixColorScheme.primary,
inverseSurface = miuixColorScheme.onSurface, inverseSurface = miuixColorScheme.onSurface,
inverseOnSurface = miuixColorScheme.surface, inverseOnSurface = miuixColorScheme.surface,
@@ -191,8 +421,7 @@ fun AppTheme(
surfaceContainer = miuixColorScheme.surfaceContainer, surfaceContainer = miuixColorScheme.surfaceContainer,
surfaceContainerHigh = miuixColorScheme.surfaceContainerHigh, surfaceContainerHigh = miuixColorScheme.surfaceContainerHigh,
surfaceContainerHighest = miuixColorScheme.surfaceContainerHighest, surfaceContainerHighest = miuixColorScheme.surfaceContainerHighest,
surfaceContainerLow = miuixColorScheme.secondaryContainer.copy(alpha = 0.32f) surfaceContainerLow = miuixColorScheme.secondaryContainer.copy(alpha = 0.32f),
.compositeOver(miuixColorScheme.surface),
surfaceContainerLowest = miuixColorScheme.background, surfaceContainerLowest = miuixColorScheme.background,
primaryFixed = miuixColorScheme.primaryContainer, primaryFixed = miuixColorScheme.primaryContainer,
@@ -208,10 +437,9 @@ fun AppTheme(
onTertiaryFixed = miuixColorScheme.onTertiaryContainer, onTertiaryFixed = miuixColorScheme.onTertiaryContainer,
onTertiaryFixedVariant = miuixColorScheme.onTertiaryContainer, onTertiaryFixedVariant = miuixColorScheme.onTertiaryContainer,
cardContainer = miuixColorScheme.primaryContainer.copy(alpha = 0.32f) cardContainer = miuixColorScheme.disabledPrimary,
.compositeOver(miuixColorScheme.surfaceContainer),
onCardContainer = miuixColorScheme.primary, onCardContainer = miuixColorScheme.primary,
onSheetContent = miuixColorScheme.surface.copy(alpha = 0.5f), onSheetContent = miuixColorScheme.surface.copy(alpha = 0.5f)
) )
} }
@@ -223,17 +451,86 @@ fun AppTheme(
} }
} }
} else { } 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( MaterialExpressiveTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = materialTypography, typography = materialTypography,
motionScheme = MotionScheme.expressive(), motionScheme = MotionScheme.expressive(),
shapes = Shapes() shapes = Shapes()
) { ) {
val legadoTypography = remember(materialTypography) { val legadoTypography = remember(materialTypography, customFontFamily) {
materialTypography.toLegadoTypography() 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( CompositionLocalProvider(
LocalLegadoTypography provides legadoTypography, LocalLegadoTypography provides legadoTypography,
@@ -1,5 +1,6 @@
package io.legado.app.ui.theme package io.legado.app.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeProgressive
@@ -9,6 +10,9 @@ import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.theme.hazeStyle.HazeLegado 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 * 自动感知全局配置的 HazeSource
@@ -27,14 +31,19 @@ fun Modifier.responsiveHazeEffect(
): Modifier { ): Modifier {
val enableBlur = ThemeConfig.enableBlur val enableBlur = ThemeConfig.enableBlur
val enableProgressiveBlur = ThemeConfig.enableProgressiveBlur 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 if (!enableBlur) return this
val style = if (enableProgressiveBlur) { val style = HazeLegado.custom(
HazeLegado.ultraThin() containerColor = containerColor,
} else { blurRadius = ThemeConfig.topBarBlurRadius,
HazeLegado.regular() blurAlpha = ThemeConfig.topBarBlurAlpha
} )
return this.hazeEffect( return this.hazeEffect(
state = state, state = state,
@@ -57,10 +66,15 @@ fun Modifier.responsiveHazeEffectFixedStyle(
state: HazeState state: HazeState
): Modifier { ): Modifier {
val enableBlur = ThemeConfig.enableBlur 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 if (!enableBlur) return this
val style = HazeLegado.ultraThinPlus() val style = HazeLegado.ultraThinPlus(containerColor = containerColor)
return this.hazeEffect( return this.hazeEffect(
state = state, state = state,
@@ -80,10 +94,17 @@ fun Modifier.responsiveHazeEffectFixedStyle(
@OptIn(ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
fun Modifier.regularHazeEffect(state: HazeState): Modifier { 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( return this.hazeEffect(
state = state, state = state,
style = HazeLegado.ultraThin() style = HazeLegado.ultraThin(containerColor = containerColor)
) )
} }
@@ -17,7 +17,12 @@ import top.yukonga.miuix.kmp.theme.ColorSchemeMode
import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.ThemeController 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( return LegadoColorScheme(
primary = primary, primary = primary,
onPrimary = onPrimary, onPrimary = onPrimary,
@@ -32,10 +37,10 @@ fun ColorScheme.toLegadoColorScheme(): LegadoColorScheme {
onTertiary = onTertiary, onTertiary = onTertiary,
tertiaryContainer = tertiaryContainer, tertiaryContainer = tertiaryContainer,
onTertiaryContainer = onTertiaryContainer, onTertiaryContainer = onTertiaryContainer,
background = background, background = customBgColor,
onBackground = onBackground, onBackground = customFontColor,
surface = surface, surface = surface,
onSurface = onSurface, onSurface = customFontColor,
surfaceVariant = surfaceVariant, surfaceVariant = surfaceVariant,
onSurfaceVariant = onSurfaceVariant, onSurfaceVariant = onSurfaceVariant,
surfaceTint = surfaceTint, 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 @Composable
fun ProvideColorSchemeOverride( fun ProvideColorSchemeOverride(
colorScheme: ColorScheme, colorScheme: ColorScheme,
@@ -33,20 +33,6 @@ fun miuixStylesToM3Typography(miuixStyles: TextStyles): Typography {
labelLarge = miuixStyles.footnote1.copy(fontSize = 14.sp), // 14.sp labelLarge = miuixStyles.footnote1.copy(fontSize = 14.sp), // 14.sp
labelMedium = miuixStyles.footnote1, // 13.sp labelMedium = miuixStyles.footnote1, // 13.sp
labelSmall = miuixStyles.footnote2, // 11.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 { fun Typography.toLegadoTypography(): LegadoTypography {
return LegadoTypography( return LegadoTypography(
headlineLarge = headlineLarge, headlineLarge = headlineLarge,
headlineLargeEmphasized = headlineLargeEmphasized, headlineLargeEmphasized = headlineLarge.copy(fontWeight = FontWeight.Medium),
headlineMedium = headlineMedium, headlineMedium = headlineMedium,
headlineMediumEmphasized = headlineMediumEmphasized, headlineMediumEmphasized = headlineMedium.copy(fontWeight = FontWeight.Medium),
headlineSmall = headlineSmall, headlineSmall = headlineSmall,
headlineSmallEmphasized = headlineSmallEmphasized, headlineSmallEmphasized = headlineSmall.copy(fontWeight = FontWeight.Medium),
titleLarge = titleLarge, titleLarge = titleLarge,
titleLargeEmphasized = titleLargeEmphasized, titleLargeEmphasized = titleLarge.copy(fontWeight = FontWeight.Medium),
titleMedium = titleMedium, titleMedium = titleMedium,
titleMediumEmphasized = titleMediumEmphasized, titleMediumEmphasized = titleMedium.copy(fontWeight = FontWeight.Medium),
titleSmall = titleSmall, titleSmall = titleSmall,
titleSmallEmphasized = titleSmallEmphasized, titleSmallEmphasized = titleSmall.copy(fontWeight = FontWeight.Medium),
bodyLarge = bodyLarge, bodyLarge = bodyLarge,
bodyLargeEmphasized = bodyLargeEmphasized, bodyLargeEmphasized = bodyLarge.copy(fontWeight = FontWeight.Medium),
bodyMedium = bodyMedium, bodyMedium = bodyMedium,
bodyMediumEmphasized = bodyMediumEmphasized, bodyMediumEmphasized = bodyMedium.copy(fontWeight = FontWeight.Medium),
bodySmall = bodySmall, bodySmall = bodySmall,
bodySmallEmphasized = bodySmallEmphasized, bodySmallEmphasized = bodySmall.copy(fontWeight = FontWeight.Medium),
labelLarge = labelLarge, labelLarge = labelLarge,
labelLargeEmphasized = labelLargeEmphasized, labelLargeEmphasized = labelLarge.copy(fontWeight = FontWeight.Medium),
labelMedium = labelMedium, labelMedium = labelMedium,
labelMediumEmphasized = labelMediumEmphasized, labelMediumEmphasized = labelMedium.copy(fontWeight = FontWeight.Medium),
labelSmall = labelSmall, labelSmall = labelSmall,
labelSmallEmphasized = labelSmallEmphasized labelSmallEmphasized = labelSmall.copy(fontWeight = FontWeight.Medium)
) )
} }
@@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeStyle import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.HazeTint 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.LegadoTheme.composeEngine
import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.theme.ThemeResolver
import top.yukonga.miuix.kmp.theme.MiuixTheme 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, containerColor: Color = if (ThemeResolver.isMiuixEngine(composeEngine)) MiuixTheme.colorScheme.surface else MaterialTheme.colorScheme.surface,
): HazeStyle = hazeLegado( ): HazeStyle = hazeLegado(
containerColor = containerColor, containerColor = containerColor,
lightAlpha = 0.35f, lightAlpha = ThemeConfig.topBarBlurAlpha / 100f * 0.35f / 0.73f,
darkAlpha = 0.55f, darkAlpha = ThemeConfig.topBarBlurAlpha / 100f * 0.55f / 0.8f,
) )
@Composable @Composable
@@ -40,16 +41,30 @@ object HazeLegado {
containerColor: Color = if (ThemeResolver.isMiuixEngine(composeEngine)) MiuixTheme.colorScheme.surface else MaterialTheme.colorScheme.surface, containerColor: Color = if (ThemeResolver.isMiuixEngine(composeEngine)) MiuixTheme.colorScheme.surface else MaterialTheme.colorScheme.surface,
): HazeStyle = hazeLegado( ): HazeStyle = hazeLegado(
containerColor = containerColor, containerColor = containerColor,
lightAlpha = 0.73f, lightAlpha = ThemeConfig.topBarBlurAlpha / 100f,
darkAlpha = 0.8f, 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( private fun hazeLegado(
containerColor: Color, containerColor: Color,
blurRadius: Int = 24,
lightAlpha: Float, lightAlpha: Float,
darkAlpha: Float, darkAlpha: Float,
): HazeStyle = HazeStyle( ): HazeStyle = HazeStyle(
blurRadius = 24.dp, blurRadius = blurRadius.dp,
backgroundColor = containerColor, backgroundColor = containerColor,
tint = HazeTint( tint = HazeTint(
containerColor.copy(alpha = if (containerColor.luminance() >= 0.5) lightAlpha else darkAlpha), containerColor.copy(alpha = if (containerColor.luminance() >= 0.5) lightAlpha else darkAlpha),
@@ -51,7 +51,7 @@ class SimpleCounterView @JvmOverloads constructor(
var max: Int var max: Int
get() = _max get() = _max
set(value) { set(value) {
_max = value _max = value.coerceAtLeast(1)
_progress = _progress.coerceAtMost(_max) _progress = _progress.coerceAtMost(_max)
updateValue() updateValue()
} }
@@ -1,18 +1,34 @@
package io.legado.app.ui.widget.components 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.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.BottomAppBarDefaults
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector 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.config.themeConfig.ThemeConfig
import io.legado.app.ui.theme.LegadoTheme 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.ThemeResolver
import io.legado.app.ui.theme.regularHazeEffect
import top.yukonga.miuix.kmp.basic.NavigationBarDisplayMode import top.yukonga.miuix.kmp.basic.NavigationBarDisplayMode
import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.basic.NavigationBar as MiuixNavigationBar import top.yukonga.miuix.kmp.basic.NavigationBar as MiuixNavigationBar
@@ -26,29 +42,43 @@ fun AppNavigationBar(
) { ) {
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine) val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
val opacity = (ThemeConfig.bottomBarOpacity.coerceIn(0, 100)) / 100f val opacity = (ThemeConfig.bottomBarOpacity.coerceIn(0, 100)) / 100f
val hazeState = LocalHazeState.current
val hazeModifier = if (hazeState != null) {
Modifier.regularHazeEffect(hazeState)
} else {
Modifier
}
if (isMiuix) { if (isMiuix) {
val baseColor = GlassDefaults.glassColor( val baseColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) {
Color(ThemeConfig.secondaryThemeColor)
} else {
GlassDefaults.glassColor(
noBlurColor = MiuixTheme.colorScheme.surface, noBlurColor = MiuixTheme.colorScheme.surface,
blurAlpha = GlassDefaults.DefaultBlurAlpha blurAlpha = GlassDefaults.TransparentAlpha
) )
}
val finalColor = baseColor.copy(alpha = (baseColor.alpha * opacity).coerceIn(0f, 1f)) val finalColor = baseColor.copy(alpha = (baseColor.alpha * opacity).coerceIn(0f, 1f))
MiuixNavigationBar( MiuixNavigationBar(
modifier = modifier, modifier = modifier.then(hazeModifier),
color = finalColor, color = finalColor,
mode = miuixMode, mode = miuixMode,
content = content content = content
) )
} else { } else {
val baseColor = GlassDefaults.glassColor( val baseColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) {
Color(ThemeConfig.secondaryThemeColor)
} else {
GlassDefaults.glassColor(
noBlurColor = BottomAppBarDefaults.containerColor, noBlurColor = BottomAppBarDefaults.containerColor,
blurAlpha = GlassDefaults.DefaultBlurAlpha blurAlpha = GlassDefaults.TransparentAlpha
) )
}
val finalColor = baseColor.copy(alpha = (baseColor.alpha * opacity).coerceIn(0f, 1f)) val finalColor = baseColor.copy(alpha = (baseColor.alpha * opacity).coerceIn(0f, 1f))
NavigationBar( NavigationBar(
modifier = modifier, modifier = modifier.then(hazeModifier),
containerColor = finalColor, containerColor = finalColor,
content = content content = content
) )
@@ -66,10 +96,35 @@ fun RowScope.AppNavigationBarItem(
m3IndicatorColor: Color, m3IndicatorColor: Color,
m3ShowLabel: Boolean, m3ShowLabel: Boolean,
m3AlwaysShowLabel: Boolean, m3AlwaysShowLabel: Boolean,
useCustomIcon: Boolean = false,
) { ) {
val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine) 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( MiuixNavigationBarItem(
selected = selected, selected = selected,
onClick = onClick, onClick = onClick,
@@ -15,9 +15,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp 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.HazeState
import dev.chrisbanes.haze.hazeSource
import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.LocalHazeState import io.legado.app.ui.theme.LocalHazeState
@@ -70,8 +75,10 @@ fun AppScaffold(
FabPosition.Center -> MiuixFabPosition.Center FabPosition.Center -> MiuixFabPosition.Center
else -> MiuixFabPosition.End else -> MiuixFabPosition.End
} }
Box(modifier = modifier.fillMaxSize()) {
BackgroundImageContent(isDark = isDark, hazeState = hazeState)
MiuixScaffold( MiuixScaffold(
modifier = modifier, modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
topBar(hazeState) topBar(hazeState)
}, },
@@ -98,10 +105,13 @@ fun AppScaffold(
} }
} }
} }
}
else -> { else -> {
Box(modifier = modifier.fillMaxSize()) {
BackgroundImageContent(isDark = isDark, hazeState = hazeState)
Scaffold( Scaffold(
modifier = modifier, modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
topBar(hazeState) topBar(hazeState)
}, },
@@ -131,4 +141,44 @@ fun AppScaffold(
} }
} }
} }
}
}
@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
)
}
}
} }
@@ -65,6 +65,7 @@ import com.kyant.backdrop.shadow.Shadow
import com.kyant.capsule.ContinuousCapsule import com.kyant.capsule.ContinuousCapsule
import io.legado.app.ui.animation.DampedDragAnimation import io.legado.app.ui.animation.DampedDragAnimation
import io.legado.app.ui.animation.InteractiveHighlight import io.legado.app.ui.animation.InteractiveHighlight
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -110,12 +111,19 @@ fun FloatingBottomBar(
backdrop: Backdrop, backdrop: Backdrop,
tabsCount: Int, tabsCount: Int,
isBlurEnabled: Boolean = true, isBlurEnabled: Boolean = true,
hasCustomIcons: Boolean = false,
content: @Composable RowScope.() -> Unit content: @Composable RowScope.() -> Unit
) { ) {
val isInLightTheme = !LegadoTheme.isDark val isInLightTheme = !LegadoTheme.isDark
val accentColor = LegadoTheme.colorScheme.primary val accentColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.themeColor != 0) {
val containerColor = if (isBlurEnabled) { Color(ThemeConfig.themeColor)
LegadoTheme.colorScheme.surfaceContainer.copy(alpha = 0.4f) } 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 { } else {
LegadoTheme.colorScheme.surfaceContainer LegadoTheme.colorScheme.surfaceContainer
} }
@@ -250,8 +258,8 @@ fun FloatingBottomBar(
effects = { effects = {
if (isBlurEnabled) { if (isBlurEnabled) {
vibrancy() vibrancy()
blur(8f.dp.toPx()) blur(ThemeConfig.bottomBarBlurRadius.toFloat().dp.toPx())
lens(24f.dp.toPx(), 24f.dp.toPx()) lens(ThemeConfig.bottomBarLensRadius.dp.toPx(), ThemeConfig.bottomBarLensRadius.dp.toPx())
} }
}, },
highlight = { highlight = {
@@ -307,8 +315,8 @@ fun FloatingBottomBar(
if (isBlurEnabled) { if (isBlurEnabled) {
val progress = dampedDragAnimation.pressProgress val progress = dampedDragAnimation.pressProgress
vibrancy() vibrancy()
blur(8f.dp.toPx()) blur(ThemeConfig.bottomBarBlurRadius.toFloat().dp.toPx())
lens(24f.dp.toPx() * progress, 24f.dp.toPx() * progress) lens(ThemeConfig.bottomBarLensRadius.dp.toPx() * progress, ThemeConfig.bottomBarLensRadius.dp.toPx() * progress)
} }
}, },
highlight = { highlight = {
@@ -331,7 +339,10 @@ fun FloatingBottomBar(
) )
.height(56.dp) .height(56.dp)
.padding(horizontal = 4.dp) .padding(horizontal = 4.dp)
.graphicsLayer(colorFilter = ColorFilter.tint(accentColor)), .then(
if (hasCustomIcons) Modifier
else Modifier.graphicsLayer(colorFilter = ColorFilter.tint(accentColor))
),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
content = content content = content
) )
@@ -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 DefaultBlurAlpha = 0.36f
val ThickBlurAlpha = 0.72f val ThickBlurAlpha = 0.72f
val TransparentAlpha = 0f val TransparentAlpha = 0f
@@ -1,35 +1,67 @@
package io.legado.app.ui.widget.components package io.legado.app.ui.widget.components
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.draw.clip 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
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver 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 io.legado.app.ui.widget.components.title.AdaptiveTitle
import top.yukonga.miuix.kmp.basic.Card as MiuixCard import top.yukonga.miuix.kmp.basic.Card as MiuixCard
/** val LocalSplicedColumnGroupState = compositionLocalOf { SplicedColumnGroupState(
* Settings Group Container by https://github.com/wxxsfxyzm/InstallerX-Revived enableItemDivider = false,
* Edit by @Kudomaga currentIndex = { 0 },
* @param title the title of the group incrementIndex = { }
* @param content a list of composable that will be displayed in the group ) }
* @param modifier Modifier
*/ data class SplicedColumnGroupState(
val enableItemDivider: Boolean,
val currentIndex: () -> Int,
val incrementIndex: () -> Unit
)
@Composable @Composable
fun SplicedColumnGroup( fun SplicedColumnGroup(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
title: String = "", title: String = "",
content: @Composable ColumnScope.() -> Unit, items: @Composable ColumnScope.() -> Unit,
) { ) {
val composeEngine = LegadoTheme.composeEngine 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)) { Column(modifier = modifier.padding(top = 8.dp, bottom = 8.dp)) {
if (title.isNotEmpty()) { if (title.isNotEmpty()) {
AdaptiveTitle( AdaptiveTitle(
@@ -38,6 +70,7 @@ fun SplicedColumnGroup(
) )
} }
CompositionLocalProvider(LocalSplicedColumnGroupState provides groupState) {
if (ThemeResolver.isMiuixEngine(composeEngine)) { if (ThemeResolver.isMiuixEngine(composeEngine)) {
MiuixCard { MiuixCard {
Column( Column(
@@ -46,20 +79,61 @@ fun SplicedColumnGroup(
.animateContentSize() .animateContentSize()
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
) { ) {
content() currentIndex.intValue = 0
items()
} }
} }
} else { } else {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.animateContentSize() .animateContentSize()
.clip(RoundedCornerShape(16.dp)), .clip(RoundedCornerShape(16.dp))
verticalArrangement = Arrangement.spacedBy(2.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()
}
} }
} }
} }
} }
@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()
}
}
@@ -2,6 +2,7 @@ package io.legado.app.ui.widget.components.card
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -40,7 +41,26 @@ private fun BaseCard(
) { ) {
val resolvedContainerColor = (containerColor ?: LegadoTheme.colorScheme.secondaryContainer) val resolvedContainerColor = (containerColor ?: LegadoTheme.colorScheme.secondaryContainer)
.let { it.copy(alpha = it.alpha * alpha) } .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( val colors = MiuixCardDefaults.defaultColors(
color = resolvedContainerColor, color = resolvedContainerColor,
contentColor = contentColor ?: LegadoTheme.colorScheme.onSurface contentColor = contentColor ?: LegadoTheme.colorScheme.onSurface
@@ -1,5 +1,6 @@
package io.legado.app.ui.widget.components.card package io.legado.app.ui.widget.components.card
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -35,7 +36,8 @@ fun TextCard(
verticalPadding: Dp = 4.dp, verticalPadding: Dp = 4.dp,
iconSize: Dp = 14.dp, iconSize: Dp = 14.dp,
spacing: Dp = 4.dp, spacing: Dp = 4.dp,
textStyle: TextStyle = LegadoTheme.typography.labelSmallEmphasized textStyle: TextStyle = LegadoTheme.typography.labelSmallEmphasized,
border: BorderStroke? = null
) { ) {
val defaultBackground = LegadoTheme.colorScheme.surfaceContainer val defaultBackground = LegadoTheme.colorScheme.surfaceContainer
val defaultContent = LegadoTheme.colorScheme.onSurface val defaultContent = LegadoTheme.colorScheme.onSurface
@@ -48,7 +50,8 @@ fun TextCard(
cornerRadius = cornerRadius, cornerRadius = cornerRadius,
containerColor = finalBackgroundColor, containerColor = finalBackgroundColor,
contentColor = finalContentColor, contentColor = finalContentColor,
onClick = onClick onClick = onClick,
border = border
) { ) {
Row( Row(
modifier = Modifier.padding( modifier = Modifier.padding(
@@ -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)
)
}
}
@@ -1,5 +1,6 @@
package io.legado.app.ui.widget.components.explore package io.legado.app.ui.widget.components.explore
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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 androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.rule.ExploreKind 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.theme.LegadoTheme
import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
@@ -38,13 +40,24 @@ fun ExploreKindItem(
val cornerRadius = 12.dp 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) { if (isClickable) {
GlassCard( GlassCard(
onClick = onClick, onClick = onClick,
cornerRadius = cornerRadius, cornerRadius = cornerRadius,
containerColor = backgroundColor, containerColor = backgroundColor,
contentColor = LegadoTheme.colorScheme.onSurface, contentColor = LegadoTheme.colorScheme.onSurface,
modifier = modifier modifier = modifier,
border = if (enableBorder) {
BorderStroke(borderWidth, borderColor)
} else null
) { ) {
KindText( KindText(
text = displayText, text = displayText,
@@ -58,7 +71,9 @@ fun ExploreKindItem(
containerColor = backgroundColor, containerColor = backgroundColor,
contentColor = LegadoTheme.colorScheme.primary, contentColor = LegadoTheme.colorScheme.primary,
modifier = modifier, modifier = modifier,
border = CardDefaults.outlinedCardBorder() border = if (enableBorder) {
BorderStroke(borderWidth, borderColor)
} else BorderStroke(1.dp, LegadoTheme.colorScheme.outline)
) { ) {
KindText( KindText(
text = displayText, text = displayText,
@@ -39,6 +39,7 @@ import io.legado.app.App
import io.legado.app.data.entities.rule.ExploreKind import io.legado.app.data.entities.rule.ExploreKind
import io.legado.app.ui.widget.components.explore.ExploreKindUiUseCase import io.legado.app.ui.widget.components.explore.ExploreKindUiUseCase
import io.legado.app.help.source.getExploreInfoMap 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.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
@@ -321,7 +322,11 @@ private fun ExploreKindCompactTextField(
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState() val isFocused by interactionSource.collectIsFocusedAsState()
val shape = RoundedCornerShape(10.dp) 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 LegadoTheme.colorScheme.primary
} else { } else {
Color.Transparent Color.Transparent
@@ -338,7 +343,7 @@ private fun ExploreKindCompactTextField(
.height(34.dp) .height(34.dp)
.clip(shape) .clip(shape)
.background(backgroundColor) .background(backgroundColor)
.border(width = 1.dp, color = borderColor, shape = shape), .border(width = borderWidth, color = borderColor, shape = shape),
decorationBox = { innerTextField -> decorationBox = { innerTextField ->
Box( Box(
modifier = Modifier modifier = Modifier
@@ -96,7 +96,7 @@ object AppIcons {
val Replay: ImageVector val Replay: ImageVector
@Composable @Composable
get() = if (isMiuix) MiuixIcons.Refresh ?: Icons.Default.Replay else Icons.Default.Replay get() = if (isMiuix) MiuixIcons.Refresh else Icons.Default.Replay
@Composable @Composable
fun mainDestination(destination: MainDestination, selected: Boolean): ImageVector { fun mainDestination(destination: MainDestination, selected: Boolean): ImageVector {
@@ -9,6 +9,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver 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.basic.BasicComponentDefaults
import top.yukonga.miuix.kmp.preference.ArrowPreference import top.yukonga.miuix.kmp.preference.ArrowPreference
@@ -24,6 +25,8 @@ fun ClickableSettingItem(
onClick: () -> Unit onClick: () -> Unit
) { ) {
val composeEngine = LegadoTheme.composeEngine val composeEngine = LegadoTheme.composeEngine
SplicedColumnDivider()
if (ThemeResolver.isMiuixEngine(composeEngine)) { if (ThemeResolver.isMiuixEngine(composeEngine)) {
ArrowPreference( ArrowPreference(
title = title, title = title,
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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.card.TextCard
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import top.yukonga.miuix.kmp.basic.BasicComponent 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.SwitchPreference
import top.yukonga.miuix.kmp.preference.WindowDropdownPreference import top.yukonga.miuix.kmp.preference.WindowDropdownPreference
import top.yukonga.miuix.kmp.basic.Slider as MiuixSlider 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
)
}
}
@@ -21,6 +21,7 @@ import io.legado.app.R
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow 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 io.legado.app.ui.widget.components.text.AppText
import top.yukonga.miuix.kmp.basic.BasicComponent import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField
@@ -46,6 +47,7 @@ fun InputSettingItem(
} }
val composeEngine = LegadoTheme.composeEngine val composeEngine = LegadoTheme.composeEngine
SplicedColumnDivider()
if (ThemeResolver.isMiuixEngine(composeEngine)) { if (ThemeResolver.isMiuixEngine(composeEngine)) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more