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

Some files were not shown because too many files have changed in this diff Show More