From db5b18ca3cbf0d90c903e19e347578f4de4ced71 Mon Sep 17 00:00:00 2001 From: Kudomaga <63206378+HapeLee@users.noreply.github.com> Date: Tue, 26 May 2026 01:45:04 +0800 Subject: [PATCH] feature/bookinfo-related-books (#1201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feature: add translation full content * feature: add translation full content * feature: add translation full content * feature: add translation full content * feature: add translation full content * feature:use chunk files replace chunk cache db * feature:use chunk files replace chunk cache db * feature:use chunk files replace chunk cache db * feature: add translation config navigation and refactor translation settings * feature: 体验优化 * feature: fixed error prompt * Fix Gson deserialization error for LLM translation classes under R8 obfuscation * Fix PMD error * feature: pre-translation * feature: pre-translation * 优化 * 优化 * 增加书籍详情页关联书籍推荐功能 * 修复关联书籍"更多"按钮URL模板变量未解析的问题 * 修复发现按钮部分不执行JS的问题 * 优化书籍详情封面长按逻辑 * 阅读界面自定义图标问题 * R8反混淆问题 * 主题保存时保存图片等资源 * 在首页、发现页等长按时,提供了一个查看简短书籍信息的界面与右上角书籍信息角标。现在书籍信息角标在封面设置中显示 * 现在可配置主页图标 * 主页模块配置被遮挡的问题 * 一些优化 * fix: address code review findings (null safety, error propagation, performance) - TranslateChapterUseCase: wrap execute() in try-catch to prevent task stuck in Translating state; propagate actual translation error instead of generic "Translation incomplete"; defensive null check on pairs - DictionaryRepositoryImpl: safe call on pairs for Gson null-bypass - LlmTranslateRepositoryImpl: safe calls on json.sentences and json.choices to prevent NPE from unexpected API responses - ContentChunker: normalize \r\n to \n before splitting paragraphs to handle Windows-formatted text files - BookInfoViewModel: parallelize related books loading with async/awaitAll - CoilBookCover: add finalPath as remember key to reset state on reuse - ThemeImportExport: fallback to filesDir when getExternalFilesDir is null --------- Co-authored-by: duanhl --- .claude/settings.local.json | 8 +- app/proguard-rules.pro | 13 +- .../assets/web/help/md/relatedBooksHelp.md | 224 +++++++++++++++++ .../app/data/entities/TranslationCache.kt | 3 + .../app/data/entities/rule/BookInfoRule.kt | 3 +- .../repository/DictionaryRepositoryImpl.kt | 3 +- .../repository/LlmTranslateRepositoryImpl.kt | 11 +- .../main/java/io/legado/app/di/appModule.kt | 2 + .../legado/app/domain/model/ContentChunker.kt | 3 +- .../legado/app/domain/model/HomepageModels.kt | 4 + .../app/domain/model/TranslationModels.kt | 5 + .../domain/usecase/AddToBookshelfUseCase.kt | 20 ++ .../domain/usecase/TranslateChapterUseCase.kt | 225 +++++++++-------- .../io/legado/app/help/config/AppConfig.kt | 6 - .../app/help/config/ThemeImportExport.kt | 175 ++++++++++++- .../io/legado/app/model/webBook/WebBook.kt | 97 ++++++- .../app/ui/book/explore/ExploreShowScreen.kt | 112 ++++----- .../ui/book/explore/ExploreShowViewModel.kt | 35 +-- .../app/ui/book/info/BookInfoActivity.kt | 15 ++ .../app/ui/book/info/BookInfoContract.kt | 28 +++ .../app/ui/book/info/BookInfoRouteScreen.kt | 10 + .../legado/app/ui/book/info/BookInfoScreen.kt | 238 ++++++++++++------ .../app/ui/book/info/BookInfoViewModel.kt | 103 ++++++++ .../io/legado/app/ui/book/read/ReadMenu.kt | 18 +- .../read/config/ToolButtonConfigDialog.kt | 17 +- .../legado/app/ui/book/search/SearchScreen.kt | 27 ++ .../app/ui/book/search/SearchViewModel.kt | 8 + .../source/edit/BookSourceEditActivity.kt | 2 + .../app/ui/config/coverConfig/CoverConfig.kt | 5 + .../config/coverConfig/CoverConfigScreen.kt | 19 +- .../themeConfig/BackgroundImageManageSheet.kt | 29 +-- .../config/themeConfig/NavIconManageSheet.kt | 9 +- .../config/themeConfig/ThemeConfigScreen.kt | 26 +- .../io/legado/app/ui/main/MainActivity.kt | 2 + .../io/legado/app/ui/main/MainNavGraph.kt | 6 + .../app/ui/main/homepage/HomepageContract.kt | 9 +- .../app/ui/main/homepage/HomepageScreen.kt | 75 ++++-- .../app/ui/main/homepage/HomepageViewModel.kt | 88 ++++++- .../homepage/manage/AddCustomModuleDialog.kt | 4 +- .../ui/main/homepage/modules/BannerModule.kt | 79 ++++-- .../ui/main/homepage/modules/CardModule.kt | 78 ++++-- .../ui/main/homepage/modules/GridModule.kt | 19 +- .../homepage/modules/GridRankingModule.kt | 87 +++++-- .../ui/main/homepage/modules/RankingModule.kt | 39 ++- .../main/homepage/modules/WaterfallModule.kt | 73 ++++-- .../app/ui/theme/ImageSeedColorExtractor.kt | 18 +- .../widget/components/book/SearchBookItem.kt | 43 +++- .../components/book/SearchBookPreviewSheet.kt | 186 ++++++++++++++ .../explore/ExploreKindMultiTypeItem.kt | 30 ++- .../components/image/cover/CoilBookCover.kt | 176 ++++++------- .../modalBottomSheet/AppModalBottomSheet.kt | 22 +- app/src/main/res/values-zh-rCN/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + 53 files changed, 1970 insertions(+), 579 deletions(-) create mode 100644 app/src/main/assets/web/help/md/relatedBooksHelp.md create mode 100644 app/src/main/java/io/legado/app/domain/usecase/AddToBookshelfUseCase.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookPreviewSheet.kt diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 275c5e74d..525947053 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -102,7 +102,13 @@ "Bash(Select-Object Name)", "PowerShell(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\" -Directory -Depth 1 | ForEach-Object { $_.FullName.Replace\\(\"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\\\\\", \"\"\\) })", "Bash(gh pr *)", - "PowerShell(Get-Content *)" + "PowerShell(Get-Content *)", + "Bash(python *)", + "Bash(C:\\\\Users\\\\www13\\\\.claude\\\\projects\\\\D--AndroidPrj-legado-with-MD3\\\\c1441549-f22e-480f-8eaf-d830b19148d1\\\\tool-results\\\\guangyu_source.json)", + "Bash(node -e ' *)", + "Bash(node *)", + "Bash(Select-String -Pattern \"BUILD|error|Error\")", + "Bash(Select-Object -First 10)" ] } } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 5ba5133f7..27db8bef9 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -63,7 +63,18 @@ -keep class **.data.entities.**{*;} # Gson反序列化用的数据传输类 -keep class io.legado.app.model.translation.**{*;} --keep class io.legado.app.data.entities.TranslationCache{*;} +-keep class io.legado.app.domain.model.DictPair{*;} +-keep class io.legado.app.domain.model.BookDictionary{*;} +-keep class io.legado.app.domain.model.TextChunk{*;} +-keep class io.legado.app.domain.model.ModuleDef{*;} +-keep class io.legado.app.domain.model.ModuleItem{*;} +-keep class io.legado.app.domain.model.CustomSetItem{*;} +-keep class io.legado.app.data.repository.GoogleTranslateResponse{*;} +-keep class io.legado.app.data.repository.GoogleSentence{*;} +-keep class io.legado.app.data.repository.GoogleSpell{*;} +-keep class io.legado.app.data.repository.OpenAIResponse{*;} +-keep class io.legado.app.data.repository.OpenAIChoice{*;} +-keep class io.legado.app.data.repository.OpenAIMessage{*;} # hutool-core hutool-crypto -keep class !cn.hutool.core.util.RuntimeUtil, diff --git a/app/src/main/assets/web/help/md/relatedBooksHelp.md b/app/src/main/assets/web/help/md/relatedBooksHelp.md new file mode 100644 index 000000000..95eeed6dd --- /dev/null +++ b/app/src/main/assets/web/help/md/relatedBooksHelp.md @@ -0,0 +1,224 @@ +# 关联书籍 (Related Books) 配置规范 + +书源的 `ruleBookInfo.relatedBooks` 字段允许开发者声明一组关联书籍模块,用于在书籍详情页底部展示「关联书籍」横滑轮播。支持配置多个模块,每个模块有独立的标题和数据来源,例如「同作者作品」、「读这本书的人还在读」等。 + +--- + +## 1. 字段位置 + +在书源编辑器的 **详情页** 选项卡中,新增了 `relatedBooks` 字段。 + +``` +详情页 → relatedBooks +``` + +对应 JSON 路径:`ruleBookInfo.relatedBooks` + +--- + +## 2. 数据结构 + +`relatedBooks` 是一个包含多个模块定义对象的 JSON 数组。 + +### 模块字段 + +| 字段 | 类型 | 必须 | 说明 | +|:--------|:---------|:---|:---------------------------------------| +| **key** | `String` | 否 | 模块唯一标识。建议使用 `[a-z0-9_]` 字符。若未提供则使用 title。 | +| **title** | `String` | 是 | 模块标题,显示在轮播上方。如「同作者作品」。 | +| **url** | `String` | 是 | 数据接口 URL。支持模板变量替换。 | + +--- + +## 3. 工作原理 + +1. 当用户打开某本书的详情页时,如果该书源配置了 `relatedBooks`,系统会解析 JSON 数组。 +2. 对每个模块,系统将 URL 中的模板变量替换为当前书籍的实际值,然后发起请求。 +3. 返回的数据使用书源的 **发现规则**(`ruleExplore`)进行解析,获取书籍列表。 +4. 解析后的书籍以横滑轮播的形式展示在详情页的操作按钮和书籍简介之间。 +5. 当前查看的书籍会自动从结果中过滤掉,避免重复显示。 +6. 如果某个模块请求失败或返回空列表,该模块会被静默跳过,不影响其他模块和详情页功能。 + +> **注意**:关联书籍的解析复用的是「发现」规则(`ruleExplore`),而非「详情页」规则(`ruleBookInfo`)。请确保书源的发现规则已正确配置。 + +--- + +## 4. URL 语法 + +URL 支持与 `exploreUrl` 相同的 JS 语法,包括 `@js:` 前缀、`` 标签和 `{{...}}` 内嵌表达式。 + +### JS 上下文中的可用对象 + +| 对象 | 说明 | 示例属性 | +|:---------|:---------------------|:--------------------------------------------------------------------| +| `book` | 当前书籍对象 | `book.name`, `book.author`, `book.kind`, `book.bookUrl`, `book.tocUrl`, `book.origin` | +| `source` | 当前书源对象 | `source.bookSourceUrl`, `source.getVariable()` 等 | +| `cookie` | Cookie 存储 | `cookie.getKey(domain, key)` | +| `page` | 页码(固定为 1) | `page` | +| `java` | JS 扩展工具(`AnalyzeUrl`) | `java.ajax()`, `java.log()` 等 | + +### 简单模板语法 + +对于简单的 URL,可以直接使用 `{{book.属性名}}` 语法,值会自动进行 URL 编码: + +``` +https://example.com/search?keyword={{book.author}}&name={{book.name}} +``` + +### `@js:` 表达式 + +对于需要逻辑处理的 URL,使用 `@js:` 前缀: + +``` +@js:"https://example.com/api/related?author=" + java.net.URLEncoder.encode(book.author, "UTF-8") +``` + +--- + +## 5. 完整 JSON 示例 + +```json +[ + { + "key": "same_author", + "title": "同作者作品", + "url": "https://example.com/search?keyword={{book.author}}&type=author&page=1" + }, + { + "key": "readers_also_read", + "title": "读这本书的人还在读", + "url": "https://example.com/api/related?book={{book.bookUrl}}&limit=20" + }, + { + "key": "same_genre", + "title": "同类推荐", + "url": "https://example.com/category/{{book.kind}}?page=1" + } +] +``` + +以上配置会在详情页底部显示三行轮播: + +``` +┌─────────────────────────────────────────────┐ +│ [操作按钮区域] │ +├─────────────────────────────────────────────┤ +│ 同作者作品 │ +│ [封面1] [封面2] [封面3] [封面4] → │ +│ │ +│ 读这本书的人还在读 │ +│ [封面1] [封面2] [封面3] [封面4] → │ +│ │ +│ 同类推荐 │ +│ [封面1] [封面2] [封面3] [封面4] → │ +├─────────────────────────────────────────────┤ +│ [书籍简介区域] │ +└─────────────────────────────────────────────┘ +``` + +--- + +## 6. 简化示例 + +如果只需要一行关联书籍,JSON 数组只包含一个元素即可: + +```json +[ + { + "title": "同作者作品", + "url": "https://example.com/search?keyword={{book.author}}" + } +] +``` + +`key` 字段为可选,未提供时自动使用 `title` 作为标识。 + +--- + +## 7. URL 示例 + +### 按作者查找相关书籍 + +``` +https://example.com/search?keyword={{book.author}}&type=author +``` + +### 按书籍名称查找同系列 + +``` +https://example.com/search?keyword={{book.name}}&type=related +``` + +### 按分类查找同类书籍 + +``` +https://example.com/category/{{book.kind}}?page=1 +``` + +### 使用 JS 表达式 + +与 `exploreUrl` 一样,URL 完整支持 `@js:` 和 `` 表达式。在 JS 上下文中,`book` 对象即当前书籍,可访问 `book.name`、`book.author`、`book.kind`、`book.bookUrl`、`book.tocUrl` 等属性。 + +**简单拼接:** + +``` +@js:"https://example.com/api/related?author=" + java.net.URLEncoder.encode(book.author, "UTF-8") + "&book_id=" + book.bookUrl.split("/").pop() +``` + +**带条件逻辑:** + +``` +@js: +var base = "https://example.com/api/related"; +if (book.kind && book.kind.contains("玄幻")) { + base + "?genre=fantasy&author=" + java.net.URLEncoder.encode(book.author, "UTF-8") +} else { + base + "?author=" + java.net.URLEncoder.encode(book.author, "UTF-8") +} +``` + +**带 Cookie/鉴权的请求:** + +``` +@js: +var ck = "sessionid=" + (String(cookie.getKey("example.com", "sessionid")) || ""); +"https://example.com/api/related?book_id=" + book.bookUrl.split("/").pop() + "&cookie=" + java.net.URLEncoder.encode(ck, "UTF-8") +``` + +--- + +## 8. 显示逻辑 + +| 条件 | 行为 | +|:------------------------------------|:-------------------------| +| `relatedBooks` 为空或未配置 | 不显示关联书籍模块 | +| JSON 格式错误 | 静默跳过,不影响详情页其他内容 | +| 某个模块的 URL 请求失败 | 跳过该模块,其他模块正常显示 | +| 某个模块返回空列表 | 跳过该模块,其他模块正常显示 | +| 所有模块均无结果 | 不显示关联书籍区域 | +| 结果中包含当前书籍 | 自动过滤掉当前书籍 | +| 本地书籍(无书源) | 不显示关联书籍模块 | +| 切换书源时 | 清空关联书籍,重新加载新源的数据 | + +--- + +## 9. 书源编辑器配置 + +在书源编辑器的 **详情页** 选项卡中: + +| 字段 | 值 | +|:--------------------|:------------------------------------------------------------------| +| relatedBooks | `[{"title":"同作者作品","url":"https://example.com/search?keyword={{book.author}}"}]` | + +--- + +## 10. 最佳实践 + +1. **合理设置模块数量**:建议 1-3 个模块,过多的轮播行会影响页面体验。 +2. **使用有意义的标题**:标题应清晰描述推荐来源,如「同作者作品」比「推荐」更具引导性。 +3. **优先使用作者或分类**:按作者查找是最常见的关联方式,能有效推荐同作者的其他作品。 +4. **避免过于宽泛的查询**:如果 URL 返回的结果与当前书籍关联性不强,用户体验会下降。 +5. **确保发现规则兼容**:URL 返回的数据必须能被 `ruleExplore` 正确解析。 +6. **简单场景用模板,复杂场景用 JS**:简单的 `{{book.author}}` 替换直接用模板语法;需要条件判断、字符串拼接、Cookie 处理等复杂逻辑时用 `@js:` 表达式。 +7. **测试边界情况**:测试作者名包含特殊字符(如 `&`、`#`、中文)时 URL 是否正常工作。模板变量会自动进行 URL 编码,但 `@js:` 表达式中需要手动调用 `java.net.URLEncoder.encode()`。 +8. **控制返回数量**:建议服务端限制返回数量(如 10-20 本),过多的轮播内容会影响体验。 diff --git a/app/src/main/java/io/legado/app/data/entities/TranslationCache.kt b/app/src/main/java/io/legado/app/data/entities/TranslationCache.kt index 4ac2aa958..2d236fb4f 100644 --- a/app/src/main/java/io/legado/app/data/entities/TranslationCache.kt +++ b/app/src/main/java/io/legado/app/data/entities/TranslationCache.kt @@ -1,9 +1,12 @@ package io.legado.app.data.entities +import androidx.annotation.Keep + /** * Chunk-level translation cache record stored in .chunks.jsonl files. * File path already contains bookUrl/chapterIndex/targetLanguage — chunkIndex is the key. */ +@Keep data class TranslationCache( val chunkIndex: Int, val originalChunkContent: String, diff --git a/app/src/main/java/io/legado/app/data/entities/rule/BookInfoRule.kt b/app/src/main/java/io/legado/app/data/entities/rule/BookInfoRule.kt index 54fd63900..eaca171de 100644 --- a/app/src/main/java/io/legado/app/data/entities/rule/BookInfoRule.kt +++ b/app/src/main/java/io/legado/app/data/entities/rule/BookInfoRule.kt @@ -21,7 +21,8 @@ data class BookInfoRule( var tocUrl: String? = null, var wordCount: String? = null, var canReName: String? = null, - var downloadUrls: String? = null + var downloadUrls: String? = null, + var relatedBooks: String? = null ) : Parcelable { companion object { diff --git a/app/src/main/java/io/legado/app/data/repository/DictionaryRepositoryImpl.kt b/app/src/main/java/io/legado/app/data/repository/DictionaryRepositoryImpl.kt index 586008fb3..ce59f551e 100644 --- a/app/src/main/java/io/legado/app/data/repository/DictionaryRepositoryImpl.kt +++ b/app/src/main/java/io/legado/app/data/repository/DictionaryRepositoryImpl.kt @@ -35,7 +35,8 @@ class DictionaryRepositoryImpl : DictionaryGateway { override fun updateBookDic(book: Book, newPairs: List) { val existingDict = getBookDictionaries(book) - val updatedPairs = existingDict.pairs.toMutableList() + @Suppress("SENSELESS_COMPARISON") + val updatedPairs = (existingDict.pairs ?: emptyList()).toMutableList() for (newPair in newPairs) { val existingIndex = updatedPairs.indexOfFirst { diff --git a/app/src/main/java/io/legado/app/data/repository/LlmTranslateRepositoryImpl.kt b/app/src/main/java/io/legado/app/data/repository/LlmTranslateRepositoryImpl.kt index e34d8ad5c..0761172c3 100644 --- a/app/src/main/java/io/legado/app/data/repository/LlmTranslateRepositoryImpl.kt +++ b/app/src/main/java/io/legado/app/data/repository/LlmTranslateRepositoryImpl.kt @@ -1,5 +1,6 @@ package io.legado.app.data.repository +import androidx.annotation.Keep import io.legado.app.domain.gateway.LlmGateway import io.legado.app.domain.model.DictPair import io.legado.app.domain.model.RetryReason @@ -109,7 +110,7 @@ class LlmTranslateRepositoryImpl : LlmGateway { return if (response.isSuccessful()) { try { val json = GSON.fromJson(response.body, GoogleTranslateResponse::class.java) - val translatedText = json.sentences.mapNotNull { it.trans }.joinToString("") + val translatedText = json?.sentences?.mapNotNull { it.trans }?.joinToString("") ?: "" if (translatedText.isNotEmpty()) { Result.success(translatedText) } else { @@ -166,7 +167,7 @@ class LlmTranslateRepositoryImpl : LlmGateway { return if (response.isSuccessful()) { try { val json = GSON.fromJson(response.body, OpenAIResponse::class.java) - val rawContent = json.choices.firstOrNull()?.message?.content + val rawContent = json?.choices?.firstOrNull()?.message?.content if (rawContent != null) { // Parse the output with [dictionary] and [result] sections val parseResult = parseLlmOutput(rawContent, dictionaries) @@ -349,30 +350,36 @@ $terms } } +@Keep data class GoogleTranslateResponse( val sentences: List, val src: String?, val spell: GoogleSpell? ) +@Keep data class GoogleSentence( val trans: String?, val orig: String?, val backend: Int? ) +@Keep data class GoogleSpell( val spell: String? ) +@Keep data class OpenAIResponse( val choices: List ) +@Keep data class OpenAIChoice( val message: OpenAIMessage ) +@Keep data class OpenAIMessage( val content: String ) diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index 95dccf0e0..49486a793 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/app/src/main/java/io/legado/app/di/appModule.kt @@ -52,6 +52,7 @@ import io.legado.app.domain.gateway.TranslationCacheGateway import io.legado.app.domain.gateway.WebDavBackupGateway import io.legado.app.domain.repository.BookDomainRepository import io.legado.app.domain.usecase.AddBookUseCase +import io.legado.app.domain.usecase.AddToBookshelfUseCase import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase import io.legado.app.domain.usecase.BatchCacheDownloadUseCase import io.legado.app.domain.usecase.CacheBookChaptersUseCase @@ -163,6 +164,7 @@ val appModule = module { singleOf(::ResolveBookShelfStateUseCase) singleOf(::RefreshTocUseCase) singleOf(::AddBookUseCase) + singleOf(::AddToBookshelfUseCase) singleOf(::ImportBookshelfUseCase) singleOf(::ExportBookshelfUseCase) factory { GetReadRecordOverviewUseCase() } diff --git a/app/src/main/java/io/legado/app/domain/model/ContentChunker.kt b/app/src/main/java/io/legado/app/domain/model/ContentChunker.kt index 179cbb6a9..43abcc2ca 100644 --- a/app/src/main/java/io/legado/app/domain/model/ContentChunker.kt +++ b/app/src/main/java/io/legado/app/domain/model/ContentChunker.kt @@ -7,7 +7,8 @@ object ContentChunker { fun chunk(text: String, maxCharsPerChunk: Int = 3000): List { if (text.isBlank()) return emptyList() - val paragraphs = text.split("\n\n").filter { it.isNotBlank() } + val normalizedText = text.replace("\r\n", "\n") + val paragraphs = normalizedText.split("\n\n").filter { it.isNotBlank() } if (paragraphs.isEmpty()) return emptyList() val chunks = mutableListOf() diff --git a/app/src/main/java/io/legado/app/domain/model/HomepageModels.kt b/app/src/main/java/io/legado/app/domain/model/HomepageModels.kt index 263a7833a..891f74435 100644 --- a/app/src/main/java/io/legado/app/domain/model/HomepageModels.kt +++ b/app/src/main/java/io/legado/app/domain/model/HomepageModels.kt @@ -1,9 +1,11 @@ package io.legado.app.domain.model +import androidx.annotation.Keep import androidx.compose.runtime.Immutable /** 供 Gateway 和 ViewModel 使用的不可变模块模型 */ @Immutable +@Keep data class ModuleItem( val id: String = "", val sourceUrl: String = "", @@ -26,6 +28,7 @@ data class ModuleItem( } @Immutable +@Keep data class CustomSetItem( val id: String = "", val name: String = "", @@ -33,6 +36,7 @@ data class CustomSetItem( ) /** 模块定义(来自书源 JSON 解析或用户手动添加) */ +@Keep data class ModuleDef( val key: String = "", val type: String = "", diff --git a/app/src/main/java/io/legado/app/domain/model/TranslationModels.kt b/app/src/main/java/io/legado/app/domain/model/TranslationModels.kt index 741e6ae66..2df73b77b 100644 --- a/app/src/main/java/io/legado/app/domain/model/TranslationModels.kt +++ b/app/src/main/java/io/legado/app/domain/model/TranslationModels.kt @@ -1,9 +1,12 @@ package io.legado.app.domain.model +import androidx.annotation.Keep + /** * A pair of original text and its translation, used for maintaining * consistent terminology across multiple translation chunks. */ +@Keep data class DictPair( val original: String, val translation: String @@ -12,6 +15,7 @@ data class DictPair( /** * Collection of dictionary pairs with metadata. */ +@Keep data class BookDictionary( val bookUrl: String, val pairs: List = emptyList(), @@ -21,6 +25,7 @@ data class BookDictionary( /** * A chunk of text for translation, with its index and paragraph mapping. */ +@Keep data class TextChunk( val index: Int, val content: String, diff --git a/app/src/main/java/io/legado/app/domain/usecase/AddToBookshelfUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/AddToBookshelfUseCase.kt new file mode 100644 index 000000000..be1728f81 --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/usecase/AddToBookshelfUseCase.kt @@ -0,0 +1,20 @@ +package io.legado.app.domain.usecase + +import io.legado.app.constant.BookType +import io.legado.app.data.appDb +import io.legado.app.data.entities.SearchBook +import io.legado.app.help.book.removeType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class AddToBookshelfUseCase { + + suspend fun execute(book: SearchBook) = withContext(Dispatchers.IO) { + val b = book.toBook() + b.removeType(BookType.notShelf) + if (b.order == 0) { + b.order = appDb.bookDao.minOrder - 1 + } + b.save() + } +} diff --git a/app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt index 92c9f7225..91a7bb0ef 100644 --- a/app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt +++ b/app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt @@ -13,6 +13,7 @@ import io.legado.app.domain.model.RetryReason import io.legado.app.domain.model.TextChunk import io.legado.app.help.book.BookHelp import io.legado.app.ui.config.translation.TranslationConfig +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -45,122 +46,134 @@ class TranslateChapterUseCase( onProgress: (TranslationProgress) -> Unit, onTranslateStarted: () -> Unit ): Result = withContext(Dispatchers.IO) { - val originalContent = BookHelp.getContent(book, bookChapter) - ?: return@withContext Result.failure(Exception("Failed to read original content")) + try { + val originalContent = BookHelp.getContent(book, bookChapter) + ?: return@withContext Result.failure(Exception("Failed to read original content")) - val cachedTranslation = - translationCacheGateway.readTranslation(book, bookChapter, targetLanguage) - if (cachedTranslation != null) { - onProgress(TranslationProgress(1, 1, cachedTranslation, emptySet())) - return@withContext Result.success(cachedTranslation) - } - - val contentHash = translationCacheGateway.computeContentHash(originalContent) - - // Load book dictionary for consistent terminology - val bookDictionary = dictionaryGateway.getBookDictionaries(book) - val dictionaries = bookDictionary.pairs.toMutableList() - - // Callback to update dictionary pairs immediately (persist as soon as discovered) - val onDictionaryUpdate: (List) -> Unit = { newPairs -> - val merged = synchronized(dictionaryLock) { - mergeDictionaryPairs(dictionaries, newPairs) + val cachedTranslation = + translationCacheGateway.readTranslation(book, bookChapter, targetLanguage) + if (cachedTranslation != null) { + onProgress(TranslationProgress(1, 1, cachedTranslation, emptySet())) + return@withContext Result.success(cachedTranslation) } - if (merged) { - synchronized(dictionaryLock) { - dictionaryGateway.updateBookDic(book, dictionaries.toList()) + + val contentHash = translationCacheGateway.computeContentHash(originalContent) + + // Load book dictionary for consistent terminology + val bookDictionary = dictionaryGateway.getBookDictionaries(book) + @Suppress("SENSELESS_COMPARISON") + val dictionaries = (bookDictionary.pairs ?: emptyList()).toMutableList() + + // Callback to update dictionary pairs immediately (persist as soon as discovered) + val onDictionaryUpdate: (List) -> Unit = { newPairs -> + val merged = synchronized(dictionaryLock) { + mergeDictionaryPairs(dictionaries, newPairs) + } + if (merged) { + synchronized(dictionaryLock) { + dictionaryGateway.updateBookDic(book, dictionaries.toList()) + } } } - } - val chunks = ContentChunker.chunk(originalContent, TranslationConfig.llmMaxCharsPerChunk) - if (chunks.isEmpty()) { - return@withContext Result.failure(Exception("Failed to chunk content")) - } - - val cachedChunks = - translationCacheGateway.getCachedChunks(book, bookChapter, targetLanguage, contentHash) - val cachedChunkMap = cachedChunks.filter { it.isSuccess }.associateBy { it.chunkIndex } - - val translatedChunks = mutableMapOf() - val pendingChunks = mutableListOf() - - // Load already cached chunks - for (chunk in chunks) { - val cached = cachedChunkMap[chunk.index] - if (cached != null && cached.translatedChunkContent != null) { - translatedChunks[chunk.index] = cached.translatedChunkContent - } else { - pendingChunks.add(chunk) + val chunks = ContentChunker.chunk(originalContent, TranslationConfig.llmMaxCharsPerChunk) + if (chunks.isEmpty()) { + return@withContext Result.failure(Exception("Failed to chunk content")) } - } - // If we have partial cached chunks, report initial mixed content - if (translatedChunks.isNotEmpty()) { - val mixedContent = PartialTranslationAssembler.assemble(chunks, translatedChunks) - onProgress(TranslationProgress( - translatedChunks.size, - chunks.size, - mixedContent, - translatedChunks.keys - )) - } + val cachedChunks = + translationCacheGateway.getCachedChunks(book, bookChapter, targetLanguage, contentHash) + val cachedChunkMap = cachedChunks.filter { it.isSuccess }.associateBy { it.chunkIndex } + + val translatedChunks = mutableMapOf() + val pendingChunks = mutableListOf() + + // Load already cached chunks + for (chunk in chunks) { + val cached = cachedChunkMap[chunk.index] + if (cached != null && cached.translatedChunkContent != null) { + translatedChunks[chunk.index] = cached.translatedChunkContent + } else { + pendingChunks.add(chunk) + } + } + + // If we have partial cached chunks, report initial mixed content + if (translatedChunks.isNotEmpty()) { + val mixedContent = PartialTranslationAssembler.assemble(chunks, translatedChunks) + onProgress(TranslationProgress( + translatedChunks.size, + chunks.size, + mixedContent, + translatedChunks.keys + )) + } + + if (pendingChunks.isEmpty()) { + val sortedChunks = chunks.sortedBy { it.index }.mapNotNull { translatedChunks[it.index]?.let { content -> TextChunk(it.index, content, it.paragraphIndices) } } + val mergedContent = ContentChunker.merge(sortedChunks) + translationCacheGateway.writeTranslation( + book, + bookChapter, + targetLanguage, + mergedContent + ) + onProgress(TranslationProgress(chunks.size, chunks.size, mergedContent, chunks.map { it.index }.toSet())) + return@withContext Result.success(mergedContent) + } + + onTranslateStarted() + var translationError: Throwable? = null + coroutineScope { + val concurrentChunks = TranslationConfig.llmConcurrentChunks.coerceIn(1, 4) + val chunkGroups = pendingChunks.chunked(concurrentChunks) + + for ((groupIndex, group) in chunkGroups.withIndex()) { + val results = group.map { chunk -> + async { + translateAndCacheChunk(chunk, book, bookChapter, targetLanguage, contentHash, dictionaries, onDictionaryUpdate) + } + }.awaitAll() + + for ((chunk, result) in group.zip(results)) { + if (result.isSuccess) { + translatedChunks[chunk.index] = result.getOrThrow() + val mixedContent = PartialTranslationAssembler.assemble(chunks, translatedChunks) + onProgress(TranslationProgress( + translatedChunks.size + cachedChunkMap.size, + chunks.size, + mixedContent, + translatedChunks.keys + )) + } else { + translationError = result.exceptionOrNull() ?: Exception("Translation failed") + return@coroutineScope + } + } + } + } + + translationError?.let { + return@withContext Result.failure(it) + } + + if (translatedChunks.size != chunks.size) { + return@withContext Result.failure(Exception("Translation incomplete")) + } + + val allTranslatedChunks = chunks.sortedBy { it.index }.mapNotNull { chunk -> + translatedChunks[chunk.index]?.let { content -> TextChunk(chunk.index, content, chunk.paragraphIndices) } + } + val mergedContent = ContentChunker.merge(allTranslatedChunks) + translationCacheGateway.writeTranslation(book, bookChapter, targetLanguage, mergedContent) - if (pendingChunks.isEmpty()) { - val sortedChunks = chunks.sortedBy { it.index }.mapNotNull { translatedChunks[it.index]?.let { content -> TextChunk(it.index, content, it.paragraphIndices) } } - val mergedContent = ContentChunker.merge(sortedChunks) - translationCacheGateway.writeTranslation( - book, - bookChapter, - targetLanguage, - mergedContent - ) onProgress(TranslationProgress(chunks.size, chunks.size, mergedContent, chunks.map { it.index }.toSet())) - return@withContext Result.success(mergedContent) + Result.success(mergedContent) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) } - - onTranslateStarted() - coroutineScope { - val concurrentChunks = TranslationConfig.llmConcurrentChunks.coerceIn(1, 4) - val chunkGroups = pendingChunks.chunked(concurrentChunks) - - for ((groupIndex, group) in chunkGroups.withIndex()) { - val results = group.map { chunk -> - async { - translateAndCacheChunk(chunk, book, bookChapter, targetLanguage, contentHash, dictionaries, onDictionaryUpdate) - } - }.awaitAll() - - for ((chunk, result) in group.zip(results)) { - if (result.isSuccess) { - translatedChunks[chunk.index] = result.getOrThrow() - val mixedContent = PartialTranslationAssembler.assemble(chunks, translatedChunks) - onProgress(TranslationProgress( - translatedChunks.size + cachedChunkMap.size, - chunks.size, - mixedContent, - translatedChunks.keys - )) - } else { - val error = result.exceptionOrNull() ?: Exception("Translation failed") - return@coroutineScope - } - } - } - } - - if (translatedChunks.size != chunks.size) { - return@withContext Result.failure(Exception("Translation incomplete")) - } - - val allTranslatedChunks = chunks.sortedBy { it.index }.mapNotNull { chunk -> - translatedChunks[chunk.index]?.let { content -> TextChunk(chunk.index, content, chunk.paragraphIndices) } - } - val mergedContent = ContentChunker.merge(allTranslatedChunks) - translationCacheGateway.writeTranslation(book, bookChapter, targetLanguage, mergedContent) - - onProgress(TranslationProgress(chunks.size, chunks.size, mergedContent, chunks.map { it.index }.toSet())) - Result.success(mergedContent) } /** diff --git a/app/src/main/java/io/legado/app/help/config/AppConfig.kt b/app/src/main/java/io/legado/app/help/config/AppConfig.kt index 95f1d542c..944b5f8cd 100644 --- a/app/src/main/java/io/legado/app/help/config/AppConfig.kt +++ b/app/src/main/java/io/legado/app/help/config/AppConfig.kt @@ -938,12 +938,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { appCtx.putPrefBoolean(PreferKey.shouldShowExpandButton, value) } - var exploreFilterState: Int - get() = appCtx.getPrefInt(PreferKey.exploreFilterState, 0) - set(value) { - appCtx.putPrefInt(PreferKey.exploreFilterState, value) - } - var exploreLayoutState: Int get() = appCtx.getPrefInt(PreferKey.exploreLayoutState, 0) set(value) { diff --git a/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt b/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt index f0d19980f..d66ef0a41 100644 --- a/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt +++ b/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt @@ -3,10 +3,13 @@ package io.legado.app.help.config import android.content.Context import android.net.Uri import com.google.gson.GsonBuilder +import io.legado.app.ui.config.coverConfig.CoverConfig import io.legado.app.ui.config.themeConfig.ThemeConfig +import io.legado.app.utils.EncoderUtils import io.legado.app.utils.GSON import splitties.init.appCtx import java.io.File +import java.io.FileOutputStream /** * 轻量级主题导入导出系统 @@ -184,6 +187,7 @@ object ThemeImportExport { defaultHomePage = ThemeConfig.defaultHomePage, // 导航栏图标 + navIconHome = ThemeConfig.navIconHome, navIconBookshelf = ThemeConfig.navIconBookshelf, navIconExplore = ThemeConfig.navIconExplore, navIconRss = ThemeConfig.navIconRss, @@ -194,11 +198,70 @@ object ThemeImportExport { // 其他 useFlexibleTopAppBar = ThemeConfig.useFlexibleTopAppBar, + bgImageLight = ThemeConfig.bgImageLight, + bgImageDark = ThemeConfig.bgImageDark, bgImageBlurring = ThemeConfig.bgImageBlurring, - bgImageNBlurring = ThemeConfig.bgImageNBlurring + bgImageNBlurring = ThemeConfig.bgImageNBlurring, + appFontPath = ThemeConfig.appFontPath, + + // 封面配置 (CoverConfig) + coverLoadOnlyWifi = CoverConfig.loadCoverOnlyWifi, + coverUseDefault = CoverConfig.useDefaultCover, + coverShowShadow = CoverConfig.coverShowShadow, + coverShowStroke = CoverConfig.coverShowStroke, + coverDefaultColor = CoverConfig.coverDefaultColor, + coverDefaultImage = CoverConfig.defaultCover, + coverTextColor = CoverConfig.coverTextColor, + coverShadowColor = CoverConfig.coverShadowColor, + coverShowName = CoverConfig.coverShowName, + coverShowAuthor = CoverConfig.coverShowAuthor, + coverDefaultImageDark = CoverConfig.defaultCoverDark, + coverTextColorN = CoverConfig.coverTextColorN, + coverShadowColorN = CoverConfig.coverShadowColorN, + coverShowNameN = CoverConfig.coverShowNameN, + coverShowAuthorN = CoverConfig.coverShowAuthorN, + coverInfoOrientation = CoverConfig.coverInfoOrientation, + assets = exportAssets() ) } + /** + * 将相关的图片、字体资源转换为Base64 + */ + private fun exportAssets(): Map { + val assets = mutableMapOf() + val assetPaths = mapOf( + "bgImageLight" to ThemeConfig.bgImageLight, + "bgImageDark" to ThemeConfig.bgImageDark, + "navIconHome" to ThemeConfig.navIconHome, + "navIconBookshelf" to ThemeConfig.navIconBookshelf, + "navIconExplore" to ThemeConfig.navIconExplore, + "navIconRss" to ThemeConfig.navIconRss, + "navIconMy" to ThemeConfig.navIconMy, + "appFontPath" to ThemeConfig.appFontPath, + "coverDefaultImage" to CoverConfig.defaultCover, + "coverDefaultImageDark" to CoverConfig.defaultCoverDark + ) + + assetPaths.forEach { (key, path) -> + if (!path.isNullOrBlank()) { + try { + val file = if (path.startsWith("content://")) { + null // TODO: 处理 content uri + } else { + File(path) + } + if (file?.exists() == true) { + assets[key] = EncoderUtils.base64Encode(file.readBytes()) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + return assets + } + /** * 将导出数据应用到当前配置 */ @@ -268,6 +331,7 @@ object ThemeImportExport { ThemeConfig.defaultHomePage = data.defaultHomePage // 导航栏图标 + ThemeConfig.navIconHome = data.navIconHome ThemeConfig.navIconBookshelf = data.navIconBookshelf ThemeConfig.navIconExplore = data.navIconExplore ThemeConfig.navIconRss = data.navIconRss @@ -278,8 +342,90 @@ object ThemeImportExport { // 其他 ThemeConfig.useFlexibleTopAppBar = data.useFlexibleTopAppBar + ThemeConfig.bgImageLight = data.bgImageLight + ThemeConfig.bgImageDark = data.bgImageDark ThemeConfig.bgImageBlurring = data.bgImageBlurring ThemeConfig.bgImageNBlurring = data.bgImageNBlurring + ThemeConfig.appFontPath = data.appFontPath + + // 封面配置 (CoverConfig) + CoverConfig.loadCoverOnlyWifi = data.coverLoadOnlyWifi + CoverConfig.useDefaultCover = data.coverUseDefault + CoverConfig.coverShowShadow = data.coverShowShadow + CoverConfig.coverShowStroke = data.coverShowStroke + CoverConfig.coverDefaultColor = data.coverDefaultColor + CoverConfig.defaultCover = data.coverDefaultImage + CoverConfig.coverTextColor = data.coverTextColor + CoverConfig.coverShadowColor = data.coverShadowColor + CoverConfig.coverShowName = data.coverShowName + CoverConfig.coverShowAuthor = data.coverShowAuthor + CoverConfig.defaultCoverDark = data.coverDefaultImageDark + CoverConfig.coverTextColorN = data.coverTextColorN + CoverConfig.coverShadowColorN = data.coverShadowColorN + CoverConfig.coverShowNameN = data.coverShowNameN + CoverConfig.coverShowAuthorN = data.coverShowAuthorN + CoverConfig.coverInfoOrientation = data.coverInfoOrientation + + // 应用嵌入的资源 + data.assets?.let { assets -> + applyAssets(assets) + } + } + + private fun applyAssets(assets: Map) { + assets.forEach { (key, base64) -> + try { + val bytes = EncoderUtils.base64DecodeToByteArray(base64) + val destFile = when { + key.startsWith("bgImage") -> { + val baseDir = appCtx.getExternalFilesDir(null) ?: appCtx.filesDir + val folder = File(baseDir, key) + folder.mkdirs() + File(folder, "theme_asset_${System.currentTimeMillis()}.jpg") + } + + key.startsWith("navIcon") -> { + val folder = File(appCtx.filesDir, "nav_icons") + folder.mkdirs() + File(folder, "${key.removePrefix("navIcon").lowercase()}.png") + } + + key == "appFontPath" -> { + val folder = File(appCtx.filesDir, "fonts") + folder.mkdirs() + File(folder, "theme_font.ttf") + } + + key.startsWith("coverDefaultImage") -> { + val baseDir = appCtx.getExternalFilesDir(null) ?: appCtx.filesDir + val folder = File(baseDir, "covers") + folder.mkdirs() + File(folder, "${key}_${System.currentTimeMillis()}.jpg") + } + + else -> null + } + + destFile?.let { file -> + FileOutputStream(file).use { it.write(bytes) } + val path = file.absolutePath + when (key) { + "bgImageLight" -> ThemeConfig.bgImageLight = path + "bgImageDark" -> ThemeConfig.bgImageDark = path + "navIconHome" -> ThemeConfig.navIconHome = path + "navIconBookshelf" -> ThemeConfig.navIconBookshelf = path + "navIconExplore" -> ThemeConfig.navIconExplore = path + "navIconRss" -> ThemeConfig.navIconRss = path + "navIconMy" -> ThemeConfig.navIconMy = path + "appFontPath" -> ThemeConfig.appFontPath = path + "coverDefaultImage" -> CoverConfig.defaultCover = path + "coverDefaultImageDark" -> CoverConfig.defaultCoverDark = path + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } } /** @@ -405,6 +551,7 @@ data class ThemeExportData( val defaultHomePage: String = "bookshelf", // 导航栏图标 + val navIconHome: String = "", val navIconBookshelf: String = "", val navIconExplore: String = "", val navIconRss: String = "", @@ -415,8 +562,32 @@ data class ThemeExportData( // 其他 val useFlexibleTopAppBar: Boolean = true, + val bgImageLight: String? = null, + val bgImageDark: String? = null, val bgImageBlurring: Int = 0, - val bgImageNBlurring: Int = 0 + val bgImageNBlurring: Int = 0, + val appFontPath: String? = null, + + // 封面配置 (CoverConfig) + val coverLoadOnlyWifi: Boolean = false, + val coverUseDefault: Boolean = false, + val coverShowShadow: Boolean = false, + val coverShowStroke: Boolean = true, + val coverDefaultColor: Boolean = true, + val coverDefaultImage: String = "", + val coverTextColor: Int = -16777216, + val coverShadowColor: Int = -16777216, + val coverShowName: Boolean = true, + val coverShowAuthor: Boolean = true, + val coverDefaultImageDark: String = "", + val coverTextColorN: Int = -1, + val coverShadowColorN: Int = -1, + val coverShowNameN: Boolean = true, + val coverShowAuthorN: Boolean = true, + val coverInfoOrientation: String = "0", + + // 嵌入的二进制资源 (Base64) + val assets: Map? = null ) /** diff --git a/app/src/main/java/io/legado/app/model/webBook/WebBook.kt b/app/src/main/java/io/legado/app/model/webBook/WebBook.kt index f79820989..9240f3b41 100644 --- a/app/src/main/java/io/legado/app/model/webBook/WebBook.kt +++ b/app/src/main/java/io/legado/app/model/webBook/WebBook.kt @@ -17,9 +17,11 @@ import io.legado.app.model.analyzeRule.AnalyzeRule import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.analyzeRule.RuleData +import io.legado.app.model.analyzeRule.RuleDataInterface import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.sync.Semaphore import kotlin.coroutines.CoroutineContext @@ -106,6 +108,90 @@ object WebBook { bookSource: BookSource, url: String, page: Int? = 1, + ruleData: RuleDataInterface = RuleData(), + ): ArrayList { + val analyzeUrl = AnalyzeUrl( + mUrl = url, + page = page, + baseUrl = bookSource.bookSourceUrl, + source = bookSource, + ruleData = ruleData, + coroutineContext = currentCoroutineContext() + ) + var res = analyzeUrl.getStrResponseAwait() + //检测书源是否已登录 + bookSource.loginCheckJs?.let { checkJs -> + if (checkJs.isNotBlank()) { + res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse + } + } + checkRedirect(bookSource, res) + val listRuleData = when (ruleData) { + is RuleData -> ruleData + is Book -> RuleData() + else -> RuleData() + } + return BookList.analyzeBookList( + bookSource = bookSource, + ruleData = listRuleData, + analyzeUrl = analyzeUrl, + baseUrl = res.url, + body = res.body, + isSearch = false + ) + } + + suspend fun exploreBookSuspend( + bookSource: BookSource, + url: String, + page: Int? = 1, + ruleData: RuleDataInterface = RuleData(), + ): List { + return exploreBookAwait(bookSource, url, page, ruleData) + } + + suspend fun exploreBookWithResolvedUrl( + bookSource: BookSource, + url: String, + page: Int? = 1, + ruleData: RuleDataInterface = RuleData(), + ): Pair> { + val analyzeUrl = AnalyzeUrl( + mUrl = url, + page = page, + baseUrl = bookSource.bookSourceUrl, + source = bookSource, + ruleData = ruleData, + coroutineContext = currentCoroutineContext() + ) + var res = analyzeUrl.getStrResponseAwait() + bookSource.loginCheckJs?.let { checkJs -> + if (checkJs.isNotBlank()) { + res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse + } + } + checkRedirect(bookSource, res) + val resolvedUrl = res.url + val listRuleData = when (ruleData) { + is RuleData -> ruleData + is Book -> RuleData() + else -> RuleData() + } + val books = BookList.analyzeBookList( + bookSource = bookSource, + ruleData = listRuleData, + analyzeUrl = analyzeUrl, + baseUrl = res.url, + body = res.body, + isSearch = false + ) + return resolvedUrl to books + } + + suspend fun exploreBookWithRuleData( + bookSource: BookSource, + url: String, + page: Int? = 1, ): ArrayList { val ruleData = RuleData() val analyzeUrl = AnalyzeUrl( @@ -114,10 +200,9 @@ object WebBook { baseUrl = bookSource.bookSourceUrl, source = bookSource, ruleData = ruleData, - coroutineContext = coroutineContext + coroutineContext = currentCoroutineContext() ) var res = analyzeUrl.getStrResponseAwait() - //检测书源是否已登录 bookSource.loginCheckJs?.let { checkJs -> if (checkJs.isNotBlank()) { res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse @@ -134,14 +219,6 @@ object WebBook { ) } - suspend fun exploreBookSuspend( - bookSource: BookSource, - url: String, - page: Int? = 1, - ): List { - return exploreBookAwait(bookSource, url, page) - } - /** * 书籍信息 */ diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt index 79a7357de..4ebfb255f 100644 --- a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt @@ -29,13 +29,10 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.outlined.FilterAlt import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Slider import androidx.compose.runtime.Composable @@ -56,6 +53,7 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import io.legado.app.data.entities.SearchBook import io.legado.app.domain.model.BookShelfState import io.legado.app.domain.usecase.ExploreKindUiUseCase +import io.legado.app.ui.config.coverConfig.CoverConfig import io.legado.app.ui.main.bookCoverSharedElementKey import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.ThemeResolver @@ -66,10 +64,9 @@ import io.legado.app.ui.widget.components.AppScaffold import io.legado.app.ui.widget.components.LoadMoreFooter import io.legado.app.ui.widget.components.book.SearchBookGridItem import io.legado.app.ui.widget.components.book.SearchBookListItem +import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet import io.legado.app.ui.widget.components.card.TextCard import io.legado.app.ui.widget.components.explore.ExploreKindSelectSheet -import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu -import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar @@ -95,17 +92,28 @@ fun ExploreShowScreen( sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, ) { + var previewBook by remember { mutableStateOf(null) } + var previewSharedCoverKey by remember { mutableStateOf(null) } LaunchedEffect(sourceUrl, exploreUrl, viewModel) { viewModel.initData(sourceUrl, exploreUrl) } - val books by viewModel.uiBooks.collectAsState() + val rawBooks by viewModel.uiBooks.collectAsState() + val filterStateId = CoverConfig.exploreFilterState + val books = remember(rawBooks, filterStateId) { + val filter = BookFilterState.fromId(filterStateId) + when (filter) { + BookFilterState.SHOW_ALL -> rawBooks + BookFilterState.HIDE_IN_SHELF -> rawBooks.filter { it.shelfState != BookShelfState.IN_SHELF } + BookFilterState.HIDE_SAME_NAME_AUTHOR -> rawBooks.filter { it.shelfState != BookShelfState.SAME_NAME_AUTHOR } + BookFilterState.SHOW_NOT_IN_SHELF_ONLY -> rawBooks.filter { it.shelfState == BookShelfState.NOT_IN_SHELF } + } + } val isBookEnd by viewModel.isEnd.collectAsState() val shouldTriggerAutoLoad by viewModel.shouldTriggerAutoLoad.collectAsState() val isLoading by viewModel.isLoading.collectAsState() val errorMsg by viewModel.errorMsg.collectAsState() - val filterState by viewModel.filterState.collectAsState() val selectedTitle by viewModel.selectedKindTitle.collectAsState() val listState = rememberLazyListState() val gridState = rememberLazyGridState() @@ -140,7 +148,6 @@ fun ExploreShowScreen( total > 0 && last >= total - 1 } } - var showMenu by remember { mutableStateOf(false) } LaunchedEffect(shouldLoadMoreList.value, isGridMode) { if (!isGridMode && shouldLoadMoreList.value) viewModel.loadMore() @@ -250,12 +257,6 @@ fun ExploreShowScreen( horizontalArrangement = Arrangement.End, modifier = Modifier.animateContentSize(tween(300)) ) { - TopBarActionButton( - onClick = { showMenu = true }, - imageVector = Icons.Default.FilterList, - contentDescription = "Filter" - ) - TopBarActionButton( onClick = { showKindSheet = true }, imageVector = Icons.Outlined.FilterAlt, @@ -280,59 +281,6 @@ fun ExploreShowScreen( imageVector = if (!isGridMode) Icons.AutoMirrored.Outlined.FormatListBulleted else Icons.Default.GridView, contentDescription = "切换布局" ) - - RoundDropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false } - ) { - RoundDropdownMenuItem( - text = "全部显示", - onClick = { - viewModel.setFilterState(BookFilterState.SHOW_ALL) - showMenu = false - }, - trailingIcon = { - if (filterState == BookFilterState.SHOW_ALL) - Icon(Icons.Default.Check, null) - } - ) - - RoundDropdownMenuItem( - text = "隐藏已在书架的同源书籍", - onClick = { - viewModel.setFilterState(BookFilterState.HIDE_IN_SHELF) - showMenu = false - }, - trailingIcon = { - if (filterState == BookFilterState.HIDE_IN_SHELF) - Icon(Icons.Default.Check, null) - } - ) - - RoundDropdownMenuItem( - text = "隐藏已在书架的非同源书籍", - onClick = { - viewModel.setFilterState(BookFilterState.HIDE_SAME_NAME_AUTHOR) - showMenu = false - }, - trailingIcon = { - if (filterState == BookFilterState.HIDE_SAME_NAME_AUTHOR) - Icon(Icons.Default.Check, null) - } - ) - - RoundDropdownMenuItem( - text = "只显示不在书架的书籍", - onClick = { - viewModel.setFilterState(BookFilterState.SHOW_NOT_IN_SHELF_ONLY) - showMenu = false - }, - trailingIcon = { - if (filterState == BookFilterState.SHOW_NOT_IN_SHELF_ONLY) - Icon(Icons.Default.Check, null) - } - ) - } }, scrollBehavior = scrollBehavior ) @@ -377,6 +325,10 @@ fun ExploreShowScreen( book = item.book, shelfState = item.shelfState, onClick = { onBookClick(item.book, sharedCoverKey) }, + onLongClick = { book, coverKey -> + previewBook = book + previewSharedCoverKey = coverKey + }, modifier = Modifier.animateItem(), sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, @@ -416,6 +368,10 @@ fun ExploreShowScreen( book = item.book, shelfState = item.shelfState, onClick = { onBookClick(item.book, sharedCoverKey) }, + onLongClick = { book, coverKey -> + previewBook = book + previewSharedCoverKey = coverKey + }, modifier = Modifier.animateItem(), sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, @@ -437,6 +393,24 @@ fun ExploreShowScreen( } } } + + val previewShelfState = previewBook?.let { book -> + books.find { it.book.bookUrl == book.bookUrl }?.shelfState + ?: BookShelfState.NOT_IN_SHELF + } + SearchBookPreviewSheet( + data = previewBook, + shelfState = previewShelfState, + sharedCoverKey = previewSharedCoverKey, + onDismissRequest = { previewBook = null }, + onOpenDetail = { book, sharedCoverKey -> + previewBook = null + onBookClick(book, sharedCoverKey) + }, + onAddToShelf = { book -> + viewModel.onAddToShelf(book) + }, + ) } @OptIn(ExperimentalSharedTransitionApi::class) @@ -445,6 +419,7 @@ fun ExploreBookItem( book: SearchBook, shelfState: BookShelfState, onClick: () -> Unit, + onLongClick: ((SearchBook, String?) -> Unit)? = null, modifier: Modifier = Modifier, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, @@ -454,6 +429,7 @@ fun ExploreBookItem( book = book, shelfState = shelfState, onClick = onClick, + onLongClick = onLongClick, modifier = modifier, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, @@ -467,6 +443,7 @@ fun ExploreBookGridItem( book: SearchBook, onClick: () -> Unit, shelfState: BookShelfState, + onLongClick: ((SearchBook, String?) -> Unit)? = null, modifier: Modifier = Modifier, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, @@ -476,6 +453,7 @@ fun ExploreBookGridItem( book = book, shelfState = shelfState, onClick = onClick, + onLongClick = onLongClick, modifier = modifier, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt index 9b551c9a1..cc0da0334 100644 --- a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt @@ -7,6 +7,7 @@ import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.rule.ExploreKind import io.legado.app.data.repository.ExploreRepository import io.legado.app.domain.model.BookShelfState +import io.legado.app.domain.usecase.AddToBookshelfUseCase import io.legado.app.domain.usecase.BookShelfKey import io.legado.app.domain.usecase.ExploreBooksUseCase import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase @@ -56,10 +57,10 @@ class ExploreShowViewModel( private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase, private val exploreBooksUseCase: ExploreBooksUseCase, private val saveSearchBooksUseCase: SaveSearchBooksUseCase, + private val addToBookshelfUseCase: AddToBookshelfUseCase, ) : ViewModel() { private val _rawBooks = MutableStateFlow>(emptyList()) - private val _filterState = MutableStateFlow(BookFilterState.fromId(AppConfig.exploreFilterState)) private val _isLoading = MutableStateFlow(false) private val _isRefreshing = MutableStateFlow(false) private val _errorMsg = MutableStateFlow(null) @@ -88,12 +89,9 @@ class ExploreShowViewModel( val uiBooks = combine( _rawBooks, - _filterState, _bookshelf - ) { books, filter, bookshelf -> - books.filter { item -> - isBookValid(item, filter, bookshelf) - }.map { item -> + ) { books, bookshelf -> + books.map { item -> ExploreBookItemUi( book = item, shelfState = resolveBookShelfStateUseCase.execute( @@ -117,7 +115,6 @@ class ExploreShowViewModel( val isLoading = _isLoading.asStateFlow() val errorMsg = _errorMsg.asStateFlow() - val filterState = _filterState.asStateFlow() init { viewModelScope.launch { @@ -173,11 +170,6 @@ class ExploreShowViewModel( loadMore(isRefresh = true) } - fun setFilterState(state: BookFilterState) { - _filterState.value = state - AppConfig.exploreFilterState = state.id - } - fun setLayout() { val newState = if (_layoutState.value == 0) 1 else 0 _layoutState.value = newState @@ -240,22 +232,9 @@ class ExploreShowViewModel( ) } - private fun isBookValid( - book: SearchBook, - filter: BookFilterState, - shelf: Set - ): Boolean { - val state = resolveBookShelfStateUseCase.execute( - name = book.name, - author = book.author, - url = book.bookUrl, - shelf = shelf - ) - return when (filter) { - BookFilterState.SHOW_ALL -> true - BookFilterState.HIDE_IN_SHELF -> state != BookShelfState.IN_SHELF - BookFilterState.HIDE_SAME_NAME_AUTHOR -> state != BookShelfState.SAME_NAME_AUTHOR - BookFilterState.SHOW_NOT_IN_SHELF_ONLY -> state == BookShelfState.NOT_IN_SHELF + fun onAddToShelf(book: SearchBook) { + viewModelScope.launch { + addToBookshelfUseCase.execute(book) } } } diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt index 7ec901235..02bc3e58f 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable import io.legado.app.base.BaseComposeActivity import io.legado.app.ui.main.MainActivity import io.legado.app.ui.widget.dialog.VariableDialog +import io.legado.app.utils.startActivity import org.koin.androidx.viewmodel.ext.android.viewModel class BookInfoActivity : BaseComposeActivity(), VariableDialog.Callback { @@ -28,6 +29,20 @@ class BookInfoActivity : BaseComposeActivity(), VariableDialog.Callback { onOpenSearch = { keyword -> startActivity(MainActivity.createSearchIntent(this, key = keyword)) }, + onNavigateToBookInfo = { name, author, bookUrl, origin, coverPath -> + startActivity { + putExtra("bookUrl", bookUrl) + putExtra("name", name) + putExtra("author", author) + putExtra("origin", origin) + putExtra("coverPath", coverPath) + } + }, + onNavigateToExploreShow = { title, sourceUrl, exploreUrl -> + startActivity( + MainActivity.createExploreShowIntent(this, title, sourceUrl, exploreUrl) + ) + }, ) } diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt index 5d56452ae..3dc77d10e 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt @@ -4,8 +4,11 @@ import android.net.Uri import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay import io.legado.app.domain.usecase.ChangeSourceMigrationOptions +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf data class BookInfoUiState( val book: Book? = null, @@ -18,6 +21,7 @@ data class BookInfoUiState( val readRecordTimelineDays: List = emptyList(), val inBookshelf: Boolean = false, val bookSource: BookSource? = null, + val relatedBooks: ImmutableList = persistentListOf(), val isTocLoading: Boolean = true, val isBusy: Boolean = false, val deleteAlertEnabled: Boolean = true, @@ -58,6 +62,14 @@ data class BookInfoWebFile( override fun toString(): String = name } +data class RelatedBooksUi( + val key: String, + val title: String, + val url: String, + val resolvedUrl: String, + val books: ImmutableList, +) + sealed interface BookInfoIntent { data object DismissSheet : BookInfoIntent data object DismissDialog : BookInfoIntent @@ -102,6 +114,9 @@ sealed interface BookInfoIntent { val entryName: String, val openAfterImport: Boolean, ) : BookInfoIntent + + data class RelatedBookClick(val book: SearchBook) : BookInfoIntent + data class RelatedBooksMore(val title: String, val url: String) : BookInfoIntent } sealed interface BookInfoEffect { @@ -133,6 +148,19 @@ sealed interface BookInfoEffect { val variable: String?, val comment: String, ) : BookInfoEffect + + data class NavigateToBookInfo( + val name: String?, + val author: String?, + val bookUrl: String, + val origin: String?, + val coverPath: String?, + ) : BookInfoEffect + data class NavigateToExploreShow( + val title: String?, + val sourceUrl: String, + val exploreUrl: String?, + ) : BookInfoEffect } sealed interface BookInfoCallbackAction { diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt index 38cf6ec70..1e7235e0e 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt @@ -48,6 +48,8 @@ fun BookInfoRouteScreen( onBack: () -> Unit, onFinish: (resultCode: Int?, afterTransition: Boolean) -> Unit, onOpenSearch: (String) -> Unit, + onNavigateToBookInfo: (name: String?, author: String?, bookUrl: String, origin: String?, coverPath: String?) -> Unit = { _, _, _, _, _ -> }, + onNavigateToExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit = { _, _, _ -> }, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, sharedCoverKey: String? = null, @@ -165,6 +167,14 @@ fun BookInfoRouteScreen( ) ) } + + is BookInfoEffect.NavigateToBookInfo -> { + onNavigateToBookInfo(effect.name, effect.author, effect.bookUrl, effect.origin, effect.coverPath) + } + + is BookInfoEffect.NavigateToExploreShow -> { + onNavigateToExploreShow(effect.title, effect.sourceUrl, effect.exploreUrl) + } } } } diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt index 148de9cb9..660845acb 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt @@ -1,9 +1,9 @@ package io.legado.app.ui.book.info import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.Crossfade import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -26,6 +26,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted import androidx.compose.material.icons.filled.Book import androidx.compose.material.icons.filled.BookmarkAdd @@ -51,7 +52,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -70,10 +70,12 @@ import io.legado.app.R import io.legado.app.constant.BookType import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.SearchBook 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.config.coverConfig.CoverConfig +import io.legado.app.ui.main.homepage.modules.BannerModule import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LocalHazeState import io.legado.app.ui.theme.ProvideThemeOverride @@ -87,6 +89,7 @@ import io.legado.app.ui.widget.components.AppPullToRefresh import io.legado.app.ui.widget.components.AppScaffold import io.legado.app.ui.widget.components.AppTextField import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.button.SmallTonalIconButton import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.TextCard import io.legado.app.ui.widget.components.icon.AppIcon @@ -104,6 +107,8 @@ import io.legado.app.ui.widget.components.topbar.M3GlassScrollBehavior import io.legado.app.ui.widget.components.topbar.MiuixGlassScrollBehavior import io.legado.app.ui.widget.components.topbar.TopBarActionButton import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay import org.koin.compose.koinInject import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior @@ -186,7 +191,12 @@ private fun BookInfoScreenContent( } } else { Box(modifier = Modifier.fillMaxSize()) { - BookInfoBackdrop(book) + BookInfoBackdrop( + bookUrl = book.bookUrl, + coverUrl = book.coverUrl, + customCoverUrl = book.customCoverUrl, + origin = book.origin, + ) AppPullToRefresh( modifier = Modifier.fillMaxSize(), isRefreshing = state.isTocLoading, @@ -233,6 +243,18 @@ private fun BookInfoScreenContent( onSourceClick = { onIntent(BookInfoIntent.ChangeSourceClick) }, onReadRecordClick = { onIntent(BookInfoIntent.ReadRecordClick) }, ) + state.relatedBooks.forEach { module -> + RelatedBooksBanner( + title = module.title, + books = module.books, + onBookClick = { book, _ -> + onIntent(BookInfoIntent.RelatedBookClick(book)) + }, + onMoreClick = { + onIntent(BookInfoIntent.RelatedBooksMore(module.title, module.resolvedUrl)) + }, + ) + } BookInfoSummary( book = book, chapterList = state.chapterList, @@ -401,23 +423,8 @@ private fun rememberBookInfoColorTheme(book: Book?): ThemeOverrideState? { if (useDefaultCover) return null val imageLoader = koinInject() - var shouldExtractColor by remember(book?.bookUrl) { mutableStateOf(false) } - - LaunchedEffect(book?.bookUrl) { - shouldExtractColor = false - if (book != null) { - delay(520) - shouldExtractColor = true - } - } - - val isNight = AppConfig.isNightTheme - - val coverPath = if (shouldExtractColor) { - book?.getDisplayCover() - } else null - - val sourceOrigin = if (shouldExtractColor) book?.origin else null + val coverPath = book?.getDisplayCover() + val sourceOrigin = book?.origin val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) { listOf(coverPath, sourceOrigin, loadOnlyWifi) @@ -471,62 +478,60 @@ private fun BookInfoTopBarActions( } @Composable -private fun BookInfoBackdrop(book: Book) { - val useDefaultCover = AppConfig.useDefaultCover || book.customCoverUrl == "use_default_cover" +private fun BookInfoBackdrop( + bookUrl: String, + coverUrl: String?, + customCoverUrl: String?, + origin: String, +) { + val useDefaultCover = AppConfig.useDefaultCover || customCoverUrl == "use_default_cover" val isNight = AppConfig.isNightTheme - val cover = if (useDefaultCover) { - BookCover.getRandomDefaultPath(book.bookUrl, isNight) - } else { - book.getDisplayCover() + val cover = remember(bookUrl, customCoverUrl, coverUrl, isNight) { + if (useDefaultCover) { + BookCover.getRandomDefaultPath(bookUrl, isNight) + } else { + if (customCoverUrl.isNullOrEmpty()) coverUrl else customCoverUrl + } } - val sourceOrigin = if (!useDefaultCover) book.origin else null + val sourceOrigin = if (!useDefaultCover) origin else null val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi val context = LocalContext.current val imageLoader = koinInject() - var showBackdropImage by remember(cover) { mutableStateOf(false) } - LaunchedEffect(cover) { - showBackdropImage = false - if (!cover.isNullOrBlank()) { - delay(520) - showBackdropImage = true - } - } - - val backdropAlpha by animateFloatAsState( - targetValue = if (showBackdropImage) 1f else 0f, - animationSpec = tween(800), - label = "BackdropFade" - ) - - val backdropRequest = remember(cover, sourceOrigin, loadOnlyWifi, context) { - buildCoverImageRequest( - context = context, - data = cover, - sourceOrigin = sourceOrigin, - loadOnlyWifi = loadOnlyWifi, - crossfade = false, - ) - } val seedOverlay = lerp( LegadoTheme.colorScheme.secondaryContainer, LegadoTheme.seedColor, 0.42f ) Box(modifier = Modifier.fillMaxSize()) { - if (!cover.isNullOrBlank()) { - AsyncImage( - model = backdropRequest, - imageLoader = imageLoader, - contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .height(480.dp) - .blur(24.dp) - .alpha(backdropAlpha), - contentScale = ContentScale.Crop, - ) + Crossfade( + targetState = cover, + animationSpec = tween(800), + label = "BackdropCrossfade" + ) { currentCover -> + if (!currentCover.isNullOrBlank()) { + val backdropRequest = remember(currentCover, sourceOrigin, loadOnlyWifi, context) { + buildCoverImageRequest( + context = context, + data = currentCover, + sourceOrigin = sourceOrigin, + loadOnlyWifi = loadOnlyWifi, + crossfade = true, + memoryCacheKey = currentCover, + ) + } + AsyncImage( + model = backdropRequest, + imageLoader = imageLoader, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(480.dp) + .blur(24.dp), + contentScale = ContentScale.Crop, + ) + } } Box( modifier = Modifier @@ -698,7 +703,7 @@ private fun BookInfoHeader( showLoadingPlaceholder = sharedCoverKey == null, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = sharedCoverKey, + sharedCoverKey = sharedCoverKey ) } Column( @@ -708,17 +713,40 @@ private fun BookInfoHeader( .padding(top = 8.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { - AppText( - text = book.name, - style = LegadoTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - maxLines = 3, - modifier = Modifier.combinedClickable( - onClick = { onBookNameClick(false) }, - onLongClick = { onBookNameClick(true) } + var showTitleMenu by remember { mutableStateOf(false) } + var isTitleExpanded by rememberSaveable { mutableStateOf(false) } + Box { + AnimatedTextLine( + text = book.name, + style = LegadoTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + maxLines = if (isTitleExpanded) Int.MAX_VALUE else 2, + modifier = Modifier.combinedClickable( + onClick = { onBookNameClick(false) }, + onLongClick = { showTitleMenu = true } + ) ) - ) - AppText( + RoundDropdownMenu( + expanded = showTitleMenu, + onDismissRequest = { showTitleMenu = false } + ) { + RoundDropdownMenuItem( + text = stringResource(R.string.search), + onClick = { + showTitleMenu = false + onBookNameClick(true) + } + ) + RoundDropdownMenuItem( + text = stringResource(if (isTitleExpanded) R.string.collapse else R.string.expand), + onClick = { + showTitleMenu = false + isTitleExpanded = !isTitleExpanded + } + ) + } + } + AnimatedTextLine( text = stringResource(R.string.author_show, book.getRealAuthor()), style = LegadoTheme.typography.bodyLarge, color = LegadoTheme.colorScheme.onSurfaceVariant, @@ -727,7 +755,7 @@ private fun BookInfoHeader( onLongClick = { onAuthorClick(true) } ) ) - AppText( + AnimatedTextLine( text = stringResource(R.string.origin_show, book.originName), style = LegadoTheme.typography.labelMedium, color = LegadoTheme.colorScheme.primary, @@ -896,12 +924,12 @@ private fun BookInfoSummary( .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 120.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { - AppText( + AnimatedTextLine( text = stringResource(R.string.toc_s, book.durChapterTitle ?: stringResource(R.string.loading)), style = LegadoTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) - AppText( + AnimatedTextLine( text = stringResource(R.string.lasted_show, book.latestChapterTitle ?: ""), style = LegadoTheme.typography.bodyMedium, color = LegadoTheme.colorScheme.onSurfaceVariant, @@ -911,7 +939,7 @@ private fun BookInfoSummary( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - AppText( + AnimatedTextLine( text = stringResource(R.string.read_chapter_total, book.totalChapterNum), style = LegadoTheme.typography.labelMedium, color = LegadoTheme.colorScheme.primary, @@ -921,14 +949,14 @@ private fun BookInfoSummary( text = "|", color = LegadoTheme.colorScheme.secondary ) - AppText( + AnimatedTextLine( text = if (book.durChapterIndex + 1 == book.totalChapterNum && book.totalChapterNum > 0) "已读完" else stringResource(R.string.read_chapter_index, book.durChapterIndex + 1), style = LegadoTheme.typography.labelMedium, color = LegadoTheme.colorScheme.secondary, ) } if (chapterList.isEmpty()) { - AppText( + AnimatedTextLine( text = stringResource(R.string.error_load_toc), style = LegadoTheme.typography.bodySmall, color = LegadoTheme.colorScheme.error @@ -942,12 +970,15 @@ private fun BookInfoSummary( containerColor = LegadoTheme.colorScheme.surfaceContainerLow, ) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - AppText(text = remark, style = LegadoTheme.typography.labelMediumEmphasized) + AnimatedTextLine( + text = remark, + style = LegadoTheme.typography.labelMediumEmphasized + ) } } } Spacer(modifier = Modifier.height(4.dp)) - AppText( + AnimatedTextLine( text = book.getDisplayIntro().orEmpty().ifBlank { stringResource(R.string.intro_show_null) }, style = LegadoTheme.typography.bodyMedium, ) @@ -1065,3 +1096,46 @@ private fun BookInfoDialogs( AppLogSheet(show = state.showAppLogSheet, onDismissRequest = { onIntent(BookInfoIntent.DismissAppLogSheet) }) } + +@Composable +private fun RelatedBooksBanner( + title: String, + books: ImmutableList, + onBookClick: (SearchBook, String?) -> Unit, + onMoreClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + if (title.isNotBlank()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppText( + text = title, + style = LegadoTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + SmallTonalIconButton( + onClick = onMoreClick, + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "more", + ) + } + } + BannerModule( + books = books.map { io.legado.app.ui.main.homepage.HomepageBookItemUi(book = it) } + .toImmutableList(), + onClick = onBookClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt index cb05a74bd..f30da80b1 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt @@ -11,6 +11,7 @@ import coil.ImageLoader import coil.request.SuccessResult import io.legado.app.R import io.legado.app.base.BaseViewModel + import io.legado.app.constant.AppLog import io.legado.app.constant.AppPattern import io.legado.app.constant.BookType @@ -19,6 +20,7 @@ import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay import io.legado.app.data.repository.BookGroupRepository import io.legado.app.data.repository.ReadRecordRepository @@ -54,6 +56,7 @@ import io.legado.app.ui.main.MainIntent import io.legado.app.ui.widget.components.image.cover.buildCoverImageRequest import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonArray import io.legado.app.utils.ImageSaveUtils import io.legado.app.utils.UrlUtil import io.legado.app.utils.postEvent @@ -62,6 +65,9 @@ import io.legado.app.utils.toastOnUi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -70,6 +76,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.collections.immutable.toImmutableList import java.io.ByteArrayOutputStream class BookInfoViewModel( @@ -97,6 +104,7 @@ class BookInfoViewModel( } private var currentChapterList: List = emptyList() private var currentWebFiles: List = emptyList() + private var currentRelatedBooks: List = emptyList() private var currentKindLabels: List = emptyList() private var currentGroupNames: String? = null private var currentHasCustomGroup = false @@ -147,6 +155,7 @@ class BookInfoViewModel( } currentChapterList = emptyList() currentWebFiles = emptyList() + currentRelatedBooks = emptyList() currentKindLabels = emptyList() currentGroupNames = null currentHasCustomGroup = false @@ -263,6 +272,9 @@ class BookInfoViewModel( } } } + + is BookInfoIntent.RelatedBookClick -> onRelatedBookClick(intent.book) + is BookInfoIntent.RelatedBooksMore -> onRelatedBooksMore(intent.title, intent.url) } } @@ -706,6 +718,7 @@ class BookInfoViewModel( } else { loadChapter(loadedBook, runPreUpdateJs) } + loadRelatedBooks(loadedBook, source) }.onError { AppLog.put("获取书籍信息失败\n${it.localizedMessage}", it) context.toastOnUi(R.string.error_get_book_info) @@ -732,6 +745,7 @@ class BookInfoViewModel( }.onSuccess { currentBook = it currentChapterList = toc + currentRelatedBooks = emptyList() currentGroupNames = null currentHasCustomGroup = false currentKindLabels = emptyList() @@ -746,6 +760,7 @@ class BookInfoViewModel( currentBook = book currentChapterList = emptyList() currentWebFiles = emptyList() + currentRelatedBooks = emptyList() currentKindLabels = emptyList() currentGroupNames = null currentHasCustomGroup = false @@ -762,6 +777,7 @@ class BookInfoViewModel( if (chapters.isNotEmpty()) { currentChapterList = chapters syncUiState(isTocLoading = false) + source?.let { loadRelatedBooks(book, it) } } else { loadChapter(book) } @@ -1263,6 +1279,7 @@ class BookInfoViewModel( book = currentBook?.uiCopy(), chapterList = currentChapterList, webFiles = currentWebFiles, + relatedBooks = currentRelatedBooks.toImmutableList(), kindLabels = currentKindLabels, groupNames = currentGroupNames, hasCustomGroup = currentHasCustomGroup, @@ -1277,6 +1294,86 @@ class BookInfoViewModel( } } + private fun onRelatedBookClick(book: SearchBook) { + emitEffect( + BookInfoEffect.NavigateToBookInfo( + name = book.name, + author = book.author, + bookUrl = book.bookUrl, + origin = book.origin, + coverPath = book.coverUrl, + ) + ) + } + + private fun onRelatedBooksMore(title: String, resolvedUrl: String) { + val source = bookSource ?: return + emitEffect( + BookInfoEffect.NavigateToExploreShow( + title = title, + sourceUrl = source.bookSourceUrl, + exploreUrl = resolvedUrl, + ) + ) + } + + private fun loadRelatedBooks(book: Book, source: BookSource) { + val modulesJson = source.ruleBookInfo?.relatedBooks + if (modulesJson.isNullOrBlank()) { + currentRelatedBooks = emptyList() + syncUiState() + return + } + val modules = try { + GSON.fromJsonArray(modulesJson) + .getOrNull() + ?.filter { !it.url.isNullOrBlank() } + ?.map { it.copy(url = it.url!!.replace(Regex("\\s"), "")) } + ?: emptyList() + } catch (e: Exception) { + emptyList() + } + if (modules.isEmpty()) { + currentRelatedBooks = emptyList() + syncUiState() + return + } + execute { + coroutineScope { + modules.map { def -> + async { + val (resolvedUrl, books) = try { + resolveAndExplore(source, def.url!!, book) + } catch (e: Exception) { + def.url!! to emptyList() + } + RelatedBooksUi( + key = def.key ?: def.title.orEmpty(), + title = def.title.orEmpty(), + url = def.url!!, + resolvedUrl = resolvedUrl, + books = books.filter { it.bookUrl != book.bookUrl }.toImmutableList(), + ) + } + }.awaitAll().filter { it.books.isNotEmpty() } + } + }.onSuccess { result -> + currentRelatedBooks = result + syncUiState() + }.onError { + currentRelatedBooks = emptyList() + syncUiState() + } + } + + private suspend fun resolveAndExplore( + source: BookSource, + url: String, + book: Book, + ): Pair> { + return WebBook.exploreBookWithResolvedUrl(source, url, 1, book) + } + private fun emitEffect(effect: BookInfoEffect) { _effects.tryEmit(effect) } @@ -1298,3 +1395,9 @@ private val BookInfoWebFile.isSupported: Boolean private val BookInfoWebFile.isSupportDecompress: Boolean get() = AppPattern.archiveFileRegex.matches(name) + +private data class RelatedBooksDef( + val key: String? = null, + val title: String? = null, + val url: String? = null, +) diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt index 04435f25a..9a71d6e40 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt @@ -637,14 +637,7 @@ class ReadMenu @JvmOverloads constructor( id = "search", iconRes = R.drawable.ic_search, description = context.getString(R.string.search_content), - onClick = { runMenuOut{ callBack.openSearchActivity(null) } } - ), - ToolButton( - id = "translate", - iconRes = R.drawable.ic_translate, - description = context.getString(R.string.translate), - onClick = { runMenuOut { callBack.onTranslationClick() } }, - onLongClick = { runMenuOut { callBack.onTranslationLongClick() } } + onClick = { runMenuOut { callBack.openSearchActivity(null) } } ), ToolButton( id = "catalog", @@ -706,7 +699,7 @@ class ReadMenu @JvmOverloads constructor( ToolButton( id = "replace_badge", iconRes = R.drawable.ic_find_replace, - description = context.getString(R.string.replace_purify), + description = context.getString(R.string.replace_purify_badge), onLongClick = { runMenuOut { callBack.openReplaceRule() } }, onCheck = { runMenuOut { callBack.changeReplaceRuleState() } }, onClick = { } @@ -716,6 +709,13 @@ class ReadMenu @JvmOverloads constructor( iconRes = R.drawable.ic_auto_page, description = context.getString(R.string.auto_next_page), onClick = { runMenuOut { callBack.autoPage() } } + ), + ToolButton( + id = "translate", + iconRes = R.drawable.ic_translate, + description = context.getString(R.string.translate), + onClick = { runMenuOut { callBack.onTranslationClick() } }, + onLongClick = { runMenuOut { callBack.onTranslationLongClick() } } ) ) } diff --git a/app/src/main/java/io/legado/app/ui/book/read/config/ToolButtonConfigDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/config/ToolButtonConfigDialog.kt index 0944a5977..1edcc0481 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/config/ToolButtonConfigDialog.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/config/ToolButtonConfigDialog.kt @@ -114,7 +114,20 @@ class ToolButtonConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_too } private fun getAllButtonIds(): List { - return listOf("search", "auto_page", "catalog", "read_aloud", "setting", "addBookmark", "theme", "prev_chapter", "next_chapter", "translate_chapter", "replace", "replace_badge") + return listOf( + "search", + "auto_page", + "catalog", + "read_aloud", + "setting", + "addBookmark", + "theme", + "prev_chapter", + "next_chapter", + "replace", + "replace_badge", + "translate" + ) } private fun getButtonInfo(id: String): Pair { @@ -128,7 +141,7 @@ class ToolButtonConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_too "theme" -> R.drawable.ic_daytime to getString(R.string.day_night_switch) "prev_chapter" -> R.drawable.ic_previous to getString(R.string.previous_chapter) "next_chapter" -> R.drawable.ic_next to getString(R.string.next_chapter) - "translate_chapter" -> R.drawable.ic_translate to getString(R.string.translate_chapter) + "translate" -> R.drawable.ic_translate to getString(R.string.translate) "replace" -> R.drawable.ic_find_replace to getString(R.string.replace_purify) "replace_badge" -> R.drawable.ic_find_replace to getString(R.string.replace_purify_badge) else -> R.drawable.ic_help to id diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt index 8da472196..5e099fc68 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt @@ -51,7 +51,9 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.legado.app.R +import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.SearchKeyword +import io.legado.app.domain.model.BookShelfState import io.legado.app.ui.main.bookCoverSharedElementKey import io.legado.app.ui.main.bookshelf.BookShelfItem import io.legado.app.ui.theme.LegadoTheme @@ -64,6 +66,7 @@ import io.legado.app.ui.widget.components.AppScaffold import io.legado.app.ui.widget.components.SearchBar import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.book.SearchBookListItem +import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet import io.legado.app.ui.widget.components.button.SmallIconButton import io.legado.app.ui.widget.components.button.SmallTextButton import io.legado.app.ui.widget.components.card.NormalCard @@ -97,6 +100,8 @@ fun SearchScreen( ) { val context = LocalContext.current val state by viewModel.uiState.collectAsStateWithLifecycle() + var previewBook by remember { mutableStateOf(null) } + var previewSharedCoverKey by remember { mutableStateOf(null) } val listState = rememberLazyListState() val lifecycleOwner = LocalLifecycleOwner.current var queryInput by rememberSaveable { mutableStateOf(state.query) } @@ -417,6 +422,10 @@ fun SearchScreen( ) ) }, + onLongClick = { book, coverKey -> + previewBook = book + previewSharedCoverKey = coverKey + }, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, sharedCoverKey = sharedCoverKey @@ -561,6 +570,24 @@ fun SearchScreen( Spacer(modifier = Modifier.height(20.dp)) } } + + val previewShelfState = previewBook?.let { book -> + state.results.find { it.book.bookUrl == book.bookUrl }?.shelfState + ?: BookShelfState.NOT_IN_SHELF + } + SearchBookPreviewSheet( + data = previewBook, + shelfState = previewShelfState, + sharedCoverKey = previewSharedCoverKey, + onDismissRequest = { previewBook = null }, + onOpenDetail = { book, sharedCoverKey -> + previewBook = null + viewModel.onIntent(SearchIntent.OpenSearchBook(book, sharedCoverKey)) + }, + onAddToShelf = { book -> + viewModel.onAddToShelf(book) + }, + ) } private data class SearchFloatingSummary( diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt index d5e01a647..2889a34e7 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt @@ -7,6 +7,7 @@ import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.SearchBook import io.legado.app.data.repository.SearchRepository import io.legado.app.domain.model.BookSearchScope +import io.legado.app.domain.usecase.AddToBookshelfUseCase import io.legado.app.domain.usecase.BookSearchControl import io.legado.app.domain.usecase.BookSearchRequest import io.legado.app.domain.usecase.BookShelfKey @@ -37,6 +38,7 @@ class SearchViewModel( private val repository: SearchRepository, private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase, private val searchBooksUseCase: SearchBooksUseCase, + private val addToBookshelfUseCase: AddToBookshelfUseCase, ) : ViewModel() { private val _uiState = MutableStateFlow( @@ -72,6 +74,12 @@ class SearchViewModel( observeQueryBookshelfHints() } + fun onAddToShelf(book: SearchBook) { + viewModelScope.launch { + addToBookshelfUseCase.execute(book) + } + } + fun onIntent(intent: SearchIntent) { when (intent) { is SearchIntent.Initialize -> initialize(intent.key, intent.scopeRaw) diff --git a/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt index 71b24a03d..c6e417a3e 100644 --- a/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt @@ -330,6 +330,7 @@ class BookSourceEditActivity : add(EditEntity("tocUrl", ir.tocUrl, R.string.rule_toc_url)) add(EditEntity("canReName", ir.canReName, R.string.rule_can_re_name)) add(EditEntity("downloadUrls", ir.downloadUrls, R.string.download_url_rule)) + add(EditEntity("relatedBooks", ir.relatedBooks, R.string.related_books)) } // 目录页 val tr = bs.getTocRule() @@ -520,6 +521,7 @@ class BookSourceEditActivity : "canReName" -> bookInfoRule.canReName = it.value "downloadUrls" -> bookInfoRule.downloadUrls = viewModel.ruleComplete(it.value, bookInfoRule.init) + "relatedBooks" -> bookInfoRule.relatedBooks = it.value } } tocEntities.forEach { diff --git a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfig.kt b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfig.kt index 50097a997..53e80d936 100644 --- a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfig.kt +++ b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfig.kt @@ -84,4 +84,9 @@ object CoverConfig { PreferKey.coverInfoOrientation, "0" // 0: vertical, 1: horizontal ) + + var exploreFilterState by prefDelegate( + PreferKey.exploreFilterState, + 0 + ) } diff --git a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigScreen.kt index bf0f3512f..6ab78f78a 100644 --- a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigScreen.kt @@ -25,13 +25,13 @@ import io.legado.app.constant.PreferKey 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.dialog.ColorPickerSheet import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem 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 org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -130,6 +130,23 @@ fun CoverConfigScreen( ) } + SplicedColumnGroup(title = stringResource(R.string.network_book_badge_setting)) { + DropdownListSettingItem( + title = stringResource(R.string.network_book_badge_setting), + selectedValue = CoverConfig.exploreFilterState.toString(), + displayEntries = arrayOf( + stringResource(R.string.filter_show_all), + stringResource(R.string.filter_hide_in_shelf), + stringResource(R.string.filter_hide_same_name_author), + stringResource(R.string.filter_show_not_in_shelf_only) + ), + entryValues = arrayOf("0", "1", "2", "3"), + onValueChange = { + CoverConfig.exploreFilterState = it.toInt() + } + ) + } + SplicedColumnGroup(title = stringResource(R.string.day)) { val coverCount = CoverConfig.defaultCover.split(",").filter { it.isNotBlank() }.size ClickableSettingItem( diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt index a2e757dce..a3d6d2d79 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt @@ -9,15 +9,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape 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.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -37,15 +32,13 @@ import io.legado.app.ui.widget.components.card.NormalCard import io.legado.app.ui.widget.components.filePicker.FilePickerSheet 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 kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun BackgroundImageManageSheet( - show: Boolean, - isDarkTheme: Boolean, + isDarkTheme: Boolean?, onDismissRequest: () -> Unit, viewModel: ThemeConfigViewModel = koinViewModel() ) { @@ -58,23 +51,23 @@ fun BackgroundImageManageSheet( scope.launch { viewModel.setBackgroundFromUri( uri = it, - isDarkTheme = isDarkTheme + isDarkTheme = isDarkTheme == true ) } } } - val currentPath = if (isDarkTheme) { - ThemeConfig.bgImageDark - } else { - ThemeConfig.bgImageLight - } - AppModalBottomSheet( - show = show, + data = isDarkTheme, onDismissRequest = onDismissRequest, title = stringResource(R.string.background_image), - ) { + ) { isDark -> + val currentPath = if (isDark) { + ThemeConfig.bgImageDark + } else { + ThemeConfig.bgImageLight + } + Column( modifier = Modifier .fillMaxWidth() @@ -122,7 +115,7 @@ fun BackgroundImageManageSheet( ) } SmallTonalIconButton( - onClick = { viewModel.removeBackground(isDarkTheme) }, + onClick = { viewModel.removeBackground(isDark) }, modifier = Modifier .align(Alignment.TopEnd) .padding(8.dp) diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/NavIconManageSheet.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/NavIconManageSheet.kt index 741f3b309..2b47ecbe3 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/NavIconManageSheet.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/NavIconManageSheet.kt @@ -54,6 +54,11 @@ fun NavIconManageSheet( var activeDest by remember { mutableStateOf(null) } val destinations = listOf( + NavIconDestination( + "home", + R.string.home, + ThemeConfig.navIconHome + ) { ThemeConfig.navIconHome = it }, NavIconDestination("bookshelf", R.string.bookshelf, ThemeConfig.navIconBookshelf) { ThemeConfig.navIconBookshelf = it }, NavIconDestination("explore", R.string.discovery, ThemeConfig.navIconExplore) { ThemeConfig.navIconExplore = it }, NavIconDestination("rss", R.string.rss, ThemeConfig.navIconRss) { ThemeConfig.navIconRss = it }, @@ -84,7 +89,9 @@ fun NavIconManageSheet( onDismissRequest = onDismissRequest, title = stringResource(R.string.theme_config_nav_icons), ) { - Column(modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp)) { + Column(modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp)) { Row( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt index a7bfe181b..d04728771 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt @@ -4,7 +4,6 @@ import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.graphics.Typeface -import android.net.Uri import android.os.Handler import android.os.Looper import androidx.activity.compose.rememberLauncherForActivityResult @@ -72,6 +71,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.constraintlayout.compose.ConstraintLayout +import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.legado.app.R import io.legado.app.base.AppContextWrapper @@ -133,7 +133,7 @@ fun ThemeConfigScreen( var fontFolderUri by remember { mutableStateOf( - context.getPrefString(PreferKey.fontFolder)?.let { Uri.parse(it) } + context.getPrefString(PreferKey.fontFolder)?.toUri() ) } @@ -672,6 +672,7 @@ fun ThemeConfigScreen( item { SplicedColumnGroup(title = stringResource(R.string.theme_config_nav_icon_settings)) { val customCount = listOf( + ThemeConfig.navIconHome, ThemeConfig.navIconBookshelf, ThemeConfig.navIconExplore, ThemeConfig.navIconRss, @@ -722,20 +723,15 @@ fun ThemeConfigScreen( ) - manageKey?.let { isDark -> - BackgroundImageManageSheet( - show = true, - isDarkTheme = isDark, - onDismissRequest = { manageKey = null } - ) - } + BackgroundImageManageSheet( + isDarkTheme = manageKey, + onDismissRequest = { manageKey = null } + ) - if (showNavIconSheet) { - NavIconManageSheet( - show = true, - onDismissRequest = { showNavIconSheet = false } - ) - } + NavIconManageSheet( + show = showNavIconSheet, + onDismissRequest = { showNavIconSheet = false } + ) LauncherIconPickerSheet( diff --git a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt index b44cd923c..c8972f406 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt @@ -21,6 +21,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.lifecycleScope import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.scene.SinglePaneSceneStrategy import androidx.navigation3.ui.NavDisplay import io.legado.app.BuildConfig import io.legado.app.R @@ -177,6 +178,7 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { SharedTransitionLayout { NavDisplay( backStack = backStack, + sceneStrategies = listOf(SinglePaneSceneStrategy()), transitionSpec = { (slideIntoContainer( towards = AnimatedContentTransitionScope.SlideDirection.Start, diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt index 882f9e771..1156b1805 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt @@ -420,6 +420,12 @@ fun MainActivity.mainEntryProvider( onOpenSearch = { keyword -> onNavigateToRoute(MainRouteSearch(key = keyword)) }, + onNavigateToBookInfo = { name, author, bookUrl, origin, coverPath -> + onNavigateToRoute(MainRouteBookInfo(name, author, bookUrl, origin, coverPath)) + }, + onNavigateToExploreShow = { title, sourceUrl, exploreUrl -> + onNavigateToRoute(MainRouteExploreShow(title, sourceUrl, exploreUrl)) + }, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = LocalNavAnimatedContentScope.current, sharedCoverKey = route.sharedCoverKey ?: bookCoverSharedElementKey(route.bookUrl), diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt index 047b1bd65..39d3feeab 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt @@ -3,11 +3,18 @@ package io.legado.app.ui.main.homepage import androidx.compose.runtime.Stable import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.rule.ExploreKind +import io.legado.app.domain.model.BookShelfState import io.legado.app.domain.model.HomepageModuleType import io.legado.app.domain.model.ModuleDef import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +@Stable +data class HomepageBookItemUi( + val book: SearchBook, + val shelfState: BookShelfState = BookShelfState.NOT_IN_SHELF, +) + @Stable data class HomepageUiState( val modules: ImmutableList = persistentListOf(), @@ -93,7 +100,7 @@ sealed interface ModuleLoadState { @Stable data class Loaded( - val books: ImmutableList, + val books: ImmutableList, val hasMore: Boolean = false, val isLoadingMore: Boolean = false, val page: Int = 1 diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt index 96019d9a5..2e7a41d1d 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt @@ -37,7 +37,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -56,6 +55,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.legado.app.R +import io.legado.app.data.entities.SearchBook import io.legado.app.domain.model.HomepageModuleType import io.legado.app.ui.main.bookCoverSharedElementKey import io.legado.app.ui.main.homepage.modules.BannerModule @@ -71,6 +71,7 @@ import io.legado.app.ui.widget.components.AppScaffold import io.legado.app.ui.widget.components.LoadMoreFooter import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.book.SearchBookGridItem +import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet import io.legado.app.ui.widget.components.button.SmallTonalIconButton import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.icon.AppIcon @@ -98,6 +99,8 @@ fun HomepageScreen( animatedVisibilityScope: AnimatedVisibilityScope? = null, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var previewBook by remember { mutableStateOf(null) } + var previewSharedCoverKey by remember { mutableStateOf(null) } // Removed allSets and browseSources as they are now part of uiState.manageState val scope = rememberCoroutineScope() val context = LocalContext.current @@ -113,11 +116,7 @@ fun HomepageScreen( }) val homeString = stringResource(R.string.home) - val currentTitle by remember( - layoutMode, - pagerState.currentPage, - selectedSets, - ) { + val currentTitle by remember(layoutMode, selectedSets) { derivedStateOf { if (layoutMode == 1) { homeString @@ -177,7 +176,10 @@ fun HomepageScreen( if (layoutMode == 1 && selectedSets.isNotEmpty()) { AppTabRow( tabTitles = selectedSets.map { it.sourceName }, - selectedTabIndex = pagerState.currentPage, + selectedTabIndex = pagerState.currentPage.coerceIn( + 0, + selectedSets.size - 1 + ), onTabSelected = { index -> scope.launch { pagerState.animateScrollToPage(index) } } @@ -223,6 +225,10 @@ fun HomepageScreen( onErrorClick = { errorMsg = it }, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, + onBookLongClick = { book, sharedCoverKey -> + previewBook = book + previewSharedCoverKey = sharedCoverKey + }, ) } } @@ -241,7 +247,7 @@ fun HomepageScreen( onDismiss = { errorMsg = null }, content = { msg -> SelectionContainer { - Text( + AppText( text = msg, style = LegadoTheme.typography.bodyMedium, modifier = Modifier @@ -303,6 +309,23 @@ fun HomepageScreen( layoutMode = layoutMode, onLayoutModeChange = { viewModel.setLayoutMode(it) }, ) + + SearchBookPreviewSheet( + data = previewBook, + shelfState = previewBook?.let { viewModel.getCurrentBookShelfState(it) }, + sharedCoverKey = previewSharedCoverKey, + onDismissRequest = { previewBook = null }, + onOpenDetail = { book, sharedCoverKey -> + previewBook = null + onBookClick( + book.name, book.author, book.bookUrl, + book.origin, book.coverUrl, sharedCoverKey + ) + }, + onAddToShelf = { book -> + viewModel.onAddToShelf(book) + }, + ) } } @@ -315,6 +338,7 @@ private fun ModuleList( gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, + onBookLongClick: (SearchBook, String?) -> Unit = { _, _ -> }, onErrorClick: (String) -> Unit ) { if (modules.isEmpty()) { @@ -490,14 +514,20 @@ private fun ModuleList( HomepageModuleType.Waterfall -> { itemsIndexed( state.books, - key = { index, book -> "wf_${moduleUi.globalId}_${book.bookUrl}_$index" }) { index, book -> + key = { index, item -> "wf_${moduleUi.globalId}_${item.book.bookUrl}_$index" }) { index, item -> val sharedCoverKey = bookCoverSharedElementKey( - book.bookUrl, + item.book.bookUrl, "home:${moduleUi.globalId}:waterfall:$index" ) WaterfallItem( - book = book, - onClick = { viewModel.onBookClick(book, sharedCoverKey) }, + item = item, + onClick = { + viewModel.onBookClick( + item.book, + sharedCoverKey + ) + }, + onLongClick = onBookLongClick, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, sharedCoverKey = sharedCoverKey, @@ -520,15 +550,21 @@ private fun ModuleList( HomepageModuleType.InfiniteGrid -> { itemsIndexed( state.books, - key = { index, book -> "inf_grid_${moduleUi.globalId}_${book.bookUrl}_$index" }) { index, book -> + key = { index, item -> "inf_grid_${moduleUi.globalId}_${item.book.bookUrl}_$index" }) { index, item -> val sharedCoverKey = bookCoverSharedElementKey( - book.bookUrl, + item.book.bookUrl, "home:${moduleUi.globalId}:infinite:$index" ) SearchBookGridItem( - book = book, - shelfState = io.legado.app.domain.model.BookShelfState.NOT_IN_SHELF, - onClick = { viewModel.onBookClick(book, sharedCoverKey) }, + book = item.book, + shelfState = item.shelfState, + onClick = { + viewModel.onBookClick( + item.book, + sharedCoverKey + ) + }, + onLongClick = onBookLongClick, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, sharedCoverKey = sharedCoverKey @@ -560,6 +596,7 @@ private fun ModuleList( onClick = { book, sharedCoverKey -> viewModel.onBookClick(book, sharedCoverKey) }, + onLongClick = onBookLongClick, modifier = Modifier.fillMaxWidth(), columns = columns, maxRows = rows, @@ -582,6 +619,7 @@ private fun ModuleList( onClick = { book, sharedCoverKey -> viewModel.onBookClick(book, sharedCoverKey) }, + onLongClick = onBookLongClick, modifier = Modifier.fillMaxWidth(), sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, @@ -600,6 +638,7 @@ private fun ModuleList( onClick = { book, sharedCoverKey -> viewModel.onBookClick(book, sharedCoverKey) }, + onLongClick = onBookLongClick, modifier = Modifier.fillMaxWidth(), sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, @@ -618,6 +657,7 @@ private fun ModuleList( onClick = { book, sharedCoverKey -> viewModel.onBookClick(book, sharedCoverKey) }, + onLongClick = onBookLongClick, modifier = Modifier.fillMaxWidth(), rows = config["layout_rows"]?.toIntOrNull() ?: 4, sharedTransitionScope = sharedTransitionScope, @@ -637,6 +677,7 @@ private fun ModuleList( onClick = { book, sharedCoverKey -> viewModel.onBookClick(book, sharedCoverKey) }, + onLongClick = onBookLongClick, modifier = Modifier.fillMaxWidth(), sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt index d244f597a..f916f8b15 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt @@ -7,12 +7,17 @@ import io.legado.app.base.BaseViewModel import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.SearchBook import io.legado.app.data.repository.BookSourceRepository +import io.legado.app.data.repository.SearchRepository import io.legado.app.domain.gateway.HomepageModulesGateway +import io.legado.app.domain.model.BookShelfState import io.legado.app.domain.model.CustomSetItem import io.legado.app.domain.model.HomepageModuleType import io.legado.app.domain.model.ModuleDef import io.legado.app.domain.model.ModuleItem +import io.legado.app.domain.usecase.AddToBookshelfUseCase +import io.legado.app.domain.usecase.BookShelfKey import io.legado.app.domain.usecase.ExploreBooksUseCase +import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase import io.legado.app.domain.usecase.SaveSearchBooksUseCase import io.legado.app.help.source.exploreKinds import io.legado.app.utils.GSON @@ -41,10 +46,15 @@ class HomepageViewModel( application: Application, private val bookSourceRepository: BookSourceRepository, private val gateway: HomepageModulesGateway, + private val searchRepository: SearchRepository, private val exploreBooksUseCase: ExploreBooksUseCase, private val saveSearchBooksUseCase: SaveSearchBooksUseCase, + private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase, + private val addToBookshelfUseCase: AddToBookshelfUseCase, ) : BaseViewModel(application) { + private val _bookshelf = MutableStateFlow>(emptySet()) + companion object { private const val CUSTOM_SET_URL_PREFIX = "custom://" private const val HOMEPAGE_DEFAULT_GRID_ROWS = 2 @@ -178,7 +188,7 @@ class HomepageViewModel( ) } - private val displayModulesFlow = combine( + private val rawModulesFlow = combine( orderedModuleDefsFlow, _moduleContentStates, _bookSourcesCache, @@ -214,6 +224,33 @@ class HomepageViewModel( }.toImmutableList() } + private val displayModulesFlow = combine( + rawModulesFlow, + _bookshelf + ) { modules, bookshelf -> + modules.map { module -> + val state = module.state + if (state is ModuleLoadState.Loaded) { + module.copy( + state = state.copy( + books = state.books.map { item -> + item.copy( + shelfState = resolveBookShelfStateUseCase.execute( + name = item.book.name, + author = item.book.author, + url = item.book.bookUrl, + shelf = bookshelf + ) + ) + }.toImmutableList() + ) + ) + } else { + module + } + }.toImmutableList() + } + // 5. 最终 UI 状态 val uiState: StateFlow = combine( displayModulesFlow, @@ -293,6 +330,12 @@ class HomepageViewModel( } } } + + viewModelScope.launch { + searchRepository.bookshelfKeys.collect { keys -> + _bookshelf.value = keys + } + } } private suspend fun syncModulesFromSource(source: BookSource) { @@ -376,9 +419,20 @@ class HomepageViewModel( val hasMore = isInfinite(module.type, module.layoutConfig) && books.isNotEmpty() books to hasMore }.onSuccess { (books, hasMore) -> + val shelf = _bookshelf.value _moduleContentStates.update { it + (module.id to ModuleLoadState.Loaded( - books.toImmutableList(), + books.map { book -> + HomepageBookItemUi( + book = book, + shelfState = resolveBookShelfStateUseCase.execute( + name = book.name, + author = book.author, + url = book.bookUrl, + shelf = shelf + ) + ) + }.toImmutableList(), hasMore = hasMore )) } @@ -406,8 +460,19 @@ class HomepageViewModel( _moduleContentStates.update { states -> val lastState = states[globalId] as? ModuleLoadState.Loaded ?: return@update states - val existingUrls = lastState.books.map { it.bookUrl }.toSet() - val deduped = result.books.filter { it.bookUrl !in existingUrls } + val existingUrls = lastState.books.map { it.book.bookUrl }.toSet() + val shelf = _bookshelf.value + val deduped = result.books.filter { it.bookUrl !in existingUrls }.map { book -> + HomepageBookItemUi( + book = book, + shelfState = resolveBookShelfStateUseCase.execute( + name = book.name, + author = book.author, + url = book.bookUrl, + shelf = shelf + ) + ) + } states + (globalId to ModuleLoadState.Loaded( books = (lastState.books + deduped).toImmutableList(), hasMore = deduped.isNotEmpty(), isLoadingMore = false, page = nextPage @@ -772,6 +837,21 @@ class HomepageViewModel( } } + fun getCurrentBookShelfState(book: SearchBook): BookShelfState { + return resolveBookShelfStateUseCase.execute( + name = book.name, + author = book.author, + url = book.bookUrl, + shelf = _bookshelf.value + ) + } + + fun onAddToShelf(book: SearchBook) { + execute { + addToBookshelfUseCase.execute(book) + } + } + fun onBookClick(book: SearchBook, sharedCoverKey: String?) { viewModelScope.launch { saveSearchBooksUseCase.save(book) diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/manage/AddCustomModuleDialog.kt b/app/src/main/java/io/legado/app/ui/main/homepage/manage/AddCustomModuleDialog.kt index d6a582074..61ee9c630 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/manage/AddCustomModuleDialog.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/manage/AddCustomModuleDialog.kt @@ -29,7 +29,7 @@ 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.divider.PillHeaderDivider import io.legado.app.ui.widget.components.settingItem.CompactClickableSettingItem -import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem +import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem import io.legado.app.ui.widget.components.text.AppText data class AddDialogPrefill( @@ -109,7 +109,7 @@ fun AddCustomModuleDialog( GlassCard( containerColor = LegadoTheme.colorScheme.onSheetContent ) { - DropdownListSettingItem( + CompactDropdownSettingItem( title = stringResource(R.string.homepage_type_label), selectedValue = type, displayEntries = typeList.map { it.title }.toTypedArray(), diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/BannerModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/BannerModule.kt index aa6f1dde3..c04810258 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/modules/BannerModule.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/BannerModule.kt @@ -3,27 +3,38 @@ package io.legado.app.ui.main.homepage.modules import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope -import androidx.compose.foundation.clickable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Shuffle import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import io.legado.app.data.entities.SearchBook +import io.legado.app.domain.model.BookShelfState import io.legado.app.ui.main.bookCoverSharedElementKey +import io.legado.app.ui.main.homepage.HomepageBookItemUi import io.legado.app.ui.theme.fadingEdge +import io.legado.app.ui.widget.components.card.TextCard import io.legado.app.ui.widget.components.image.cover.CoilBookCover import kotlinx.collections.immutable.ImmutableList -@OptIn(ExperimentalSharedTransitionApi::class) +@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class) @Composable fun BannerModule( - books: ImmutableList, + books: ImmutableList, onClick: (SearchBook, String?) -> Unit, + onLongClick: ((SearchBook, String?) -> Unit)? = null, modifier: Modifier = Modifier, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, @@ -39,24 +50,58 @@ fun BannerModule( .fadingEdge(lazyListState, gradientWidth = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - itemsIndexed(books, key = { index, book -> "${book.bookUrl}:$index" }) { index, book -> + itemsIndexed(books, key = { index, item -> "${item.book.bookUrl}:$index" }) { index, item -> + val book = item.book val sharedCoverKey = bookCoverSharedElementKey( book.bookUrl, sharedCoverKeySourceId?.let { "$it:$index" } ) - CoilBookCover( - name = book.name, - author = book.author, - path = book.coverUrl, - radius = 12.dp, - sourceOrigin = book.origin, - modifier = Modifier - .width(96.dp) - .clickable { onClick(book, sharedCoverKey) }, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = sharedCoverKey - ) + Box { + CoilBookCover( + name = book.name, + author = book.author, + path = book.coverUrl, + radius = 12.dp, + sourceOrigin = book.origin, + modifier = Modifier + .width(96.dp) + .combinedClickable( + onClick = { onClick(book, sharedCoverKey) }, + onLongClick = onLongClick?.let { cb -> { cb(book, sharedCoverKey) } } + ) + .then( + with(sharedTransitionScope) { + if (this != null) { + Modifier.sharedBounds( + sharedContentState = rememberSharedContentState("preview:${book.bookUrl}"), + animatedVisibilityScope = animatedVisibilityScope + ?: return@with Modifier, + ) + } else Modifier + } + ), + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = sharedCoverKey, + ) + + val shelfIcon = when (item.shelfState) { + BookShelfState.IN_SHELF -> Icons.Default.Check + BookShelfState.SAME_NAME_AUTHOR -> Icons.Default.Shuffle + else -> null + } + if (shelfIcon != null) { + TextCard( + icon = shelfIcon, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp), + cornerRadius = 4.dp, + horizontalPadding = 2.dp, + verticalPadding = 2.dp + ) + } + } } } } diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/CardModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/CardModule.kt index a4c4365da..c121fee4c 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/modules/CardModule.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/CardModule.kt @@ -3,9 +3,11 @@ package io.legado.app.ui.main.homepage.modules import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +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.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -15,15 +17,22 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Shuffle 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.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.legado.app.data.entities.SearchBook +import io.legado.app.domain.model.BookShelfState import io.legado.app.ui.main.bookCoverSharedElementKey +import io.legado.app.ui.main.homepage.HomepageBookItemUi import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.fadingEdge +import io.legado.app.ui.widget.components.card.TextCard import io.legado.app.ui.widget.components.image.cover.CoilBookCover import io.legado.app.ui.widget.components.text.AppText import kotlinx.collections.immutable.ImmutableList @@ -31,11 +40,12 @@ import kotlinx.collections.immutable.ImmutableList /** * 卡片模块:横向滚动的推荐卡片 */ -@OptIn(ExperimentalSharedTransitionApi::class) +@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class) @Composable fun CardModule( - books: ImmutableList, + books: ImmutableList, onClick: (SearchBook, String?) -> Unit, + onLongClick: ((SearchBook, String?) -> Unit)? = null, modifier: Modifier = Modifier, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, @@ -50,7 +60,8 @@ fun CardModule( .fadingEdge(lazyListState, gradientWidth = 8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - itemsIndexed(books, key = { index, book -> "${book.bookUrl}:$index" }) { index, book -> + itemsIndexed(books, key = { index, item -> "${item.book.bookUrl}:$index" }) { index, item -> + val book = item.book val sharedCoverKey = bookCoverSharedElementKey( book.bookUrl, sharedCoverKeySourceId?.let { "$it:$index" } @@ -60,20 +71,53 @@ fun CardModule( .width(120.dp) .clip(RoundedCornerShape(16.dp)) .background(LegadoTheme.colorScheme.surfaceContainerLow) - .clickable { onClick(book, sharedCoverKey) } + .combinedClickable( + onClick = { onClick(book, sharedCoverKey) }, + onLongClick = onLongClick?.let { cb -> { cb(book, sharedCoverKey) } } + ) + .then( + with(sharedTransitionScope) { + if (this != null) { + Modifier.sharedBounds( + sharedContentState = rememberSharedContentState("preview:${book.bookUrl}"), + animatedVisibilityScope = animatedVisibilityScope + ?: return@with Modifier, + ) + } else Modifier + } + ) ) { - CoilBookCover( - name = book.name, - author = book.author, - path = book.coverUrl, - radius = 16.dp, - sourceOrigin = book.origin, - modifier = Modifier - .wrapContentWidth(), - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = sharedCoverKey - ) + Box { + CoilBookCover( + name = book.name, + author = book.author, + path = book.coverUrl, + radius = 16.dp, + sourceOrigin = book.origin, + modifier = Modifier + .wrapContentWidth(), + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = sharedCoverKey, + ) + + val shelfIcon = when (item.shelfState) { + BookShelfState.IN_SHELF -> Icons.Default.Check + BookShelfState.SAME_NAME_AUTHOR -> Icons.Default.Shuffle + else -> null + } + if (shelfIcon != null) { + TextCard( + icon = shelfIcon, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + cornerRadius = 4.dp, + horizontalPadding = 2.dp, + verticalPadding = 2.dp + ) + } + } AppText( text = book.name, diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridModule.kt index 7423b3f87..eebc4692f 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridModule.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridModule.kt @@ -11,17 +11,17 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import io.legado.app.data.entities.SearchBook -import io.legado.app.domain.model.BookShelfState import io.legado.app.ui.main.bookCoverSharedElementKey +import io.legado.app.ui.main.homepage.HomepageBookItemUi import io.legado.app.ui.widget.components.book.SearchBookGridItem import kotlinx.collections.immutable.ImmutableList @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun GridModule( - books: ImmutableList, - onClick: (SearchBook, String?) -> Unit, + books: ImmutableList, + onClick: (io.legado.app.data.entities.SearchBook, String?) -> Unit, + onLongClick: ((io.legado.app.data.entities.SearchBook, String?) -> Unit)? = null, modifier: Modifier = Modifier, columns: Int = 3, maxRows: Int? = null, @@ -43,16 +43,17 @@ fun GridModule( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - for ((columnIndex, book) in row.withIndex()) { + for ((columnIndex, item) in row.withIndex()) { val itemIndex = rowIndex * columns + columnIndex val sharedCoverKey = bookCoverSharedElementKey( - book.bookUrl, + item.book.bookUrl, sharedCoverKeySourceId?.let { "$it:$itemIndex" } ) SearchBookGridItem( - book = book, - shelfState = BookShelfState.NOT_IN_SHELF, - onClick = { onClick(book, sharedCoverKey) }, + book = item.book, + shelfState = item.shelfState, + onClick = { onClick(item.book, sharedCoverKey) }, + onLongClick = onLongClick, modifier = Modifier.weight(1f), sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridRankingModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridRankingModule.kt index 098423c1f..8b4f2d037 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridRankingModule.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridRankingModule.kt @@ -3,7 +3,9 @@ package io.legado.app.ui.main.homepage.modules import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope -import androidx.compose.foundation.clickable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -15,6 +17,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Shuffle import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -25,10 +30,13 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.legado.app.data.entities.SearchBook +import io.legado.app.domain.model.BookShelfState import io.legado.app.ui.main.bookCoverSharedElementKey +import io.legado.app.ui.main.homepage.HomepageBookItemUi import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.fadingEdge import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.card.TextCard import io.legado.app.ui.widget.components.image.cover.CoilBookCover import io.legado.app.ui.widget.components.text.AppText import kotlinx.collections.immutable.ImmutableList @@ -36,8 +44,9 @@ import kotlinx.collections.immutable.ImmutableList @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun GridRankingModule( - books: ImmutableList, + books: ImmutableList, onClick: (SearchBook, String?) -> Unit, + onLongClick: ((SearchBook, String?) -> Unit)? = null, modifier: Modifier = Modifier, rows: Int = 4, sharedTransitionScope: SharedTransitionScope? = null, @@ -71,16 +80,17 @@ fun GridRankingModule( .fillMaxWidth() .padding(vertical = 12.dp, horizontal = 12.dp) ) { - for ((rowIndex, book) in page.withIndex()) { + for ((rowIndex, item) in page.withIndex()) { val itemIndex = pageIndex * rows + rowIndex val sharedCoverKey = bookCoverSharedElementKey( - book.bookUrl, + item.book.bookUrl, sharedCoverKeySourceId?.let { "$it:$itemIndex" } ) GridRankingItem( - rank = pages.flatten().indexOf(book) + 1, - book = book, - onClick = { onClick(book, sharedCoverKey) }, + rank = pages.flatten().indexOf(item) + 1, + item = item, + onClick = { onClick(item.book, sharedCoverKey) }, + onLongClick = onLongClick, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, sharedCoverKey = sharedCoverKey, @@ -95,35 +105,70 @@ fun GridRankingModule( } } -@OptIn(ExperimentalSharedTransitionApi::class) +@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class) @Composable private fun GridRankingItem( rank: Int, - book: SearchBook, + item: HomepageBookItemUi, onClick: () -> Unit, + onLongClick: ((SearchBook, String?) -> Unit)? = null, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, sharedCoverKey: String? = null, ) { + val book = item.book Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = onClick) - .padding(vertical = 4.dp, horizontal = 4.dp), + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick?.let { cb -> { cb(book, sharedCoverKey) } } + ) + .padding(vertical = 4.dp, horizontal = 4.dp) + .then( + with(sharedTransitionScope) { + if (this != null) { + Modifier.sharedBounds( + sharedContentState = rememberSharedContentState("preview:${book.bookUrl}"), + animatedVisibilityScope = animatedVisibilityScope + ?: return@with Modifier, + ) + } else Modifier + } + ), verticalAlignment = Alignment.CenterVertically, ) { // 1. 封面 - CoilBookCover( - name = book.name, - author = book.author, - path = book.coverUrl, - sourceOrigin = book.origin, - modifier = Modifier.width(48.dp), - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = sharedCoverKey - ) + Box { + CoilBookCover( + name = book.name, + author = book.author, + path = book.coverUrl, + sourceOrigin = book.origin, + modifier = Modifier.width(48.dp), + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = sharedCoverKey, + ) + + val shelfIcon = when (item.shelfState) { + BookShelfState.IN_SHELF -> Icons.Default.Check + BookShelfState.SAME_NAME_AUTHOR -> Icons.Default.Shuffle + else -> null + } + if (shelfIcon != null) { + TextCard( + icon = shelfIcon, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(2.dp), + cornerRadius = 4.dp, + horizontalPadding = 2.dp, + verticalPadding = 2.dp + ) + } + } // 2. 排名 AppText( diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt index 9c99cb0e2..c64001f07 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt @@ -4,7 +4,9 @@ import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -29,8 +31,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import io.legado.app.R import io.legado.app.data.entities.SearchBook -import io.legado.app.domain.model.BookShelfState import io.legado.app.ui.main.bookCoverSharedElementKey +import io.legado.app.ui.main.homepage.HomepageBookItemUi import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.widget.components.book.SearchBookListItem import io.legado.app.ui.widget.components.card.GlassCard @@ -44,8 +46,9 @@ private const val MAX_COUNT = 20 @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun RankingModule( - books: ImmutableList, + books: ImmutableList, onClick: (SearchBook, String?) -> Unit, + onLongClick: ((SearchBook, String?) -> Unit)? = null, modifier: Modifier = Modifier, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, @@ -66,15 +69,17 @@ fun RankingModule( .animateContentSize() ) { // 显示书籍列表 - displayBooks.forEachIndexed { index, book -> + displayBooks.forEachIndexed { index, item -> RankingItem( rank = index + 1, - book = book, + book = item.book, + shelfState = item.shelfState, onClick = onClick, + onLongClick = onLongClick, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, sharedCoverKey = bookCoverSharedElementKey( - book.bookUrl, + item.book.bookUrl, sharedCoverKeySourceId?.let { "$it:$index" } ) ) @@ -114,12 +119,14 @@ fun RankingModule( } } -@OptIn(ExperimentalSharedTransitionApi::class) +@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class) @Composable private fun RankingItem( rank: Int, book: SearchBook, + shelfState: io.legado.app.domain.model.BookShelfState, onClick: (SearchBook, String?) -> Unit, + onLongClick: ((SearchBook, String?) -> Unit)? = null, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, sharedCoverKey: String? = null, @@ -127,8 +134,22 @@ private fun RankingItem( Row( modifier = Modifier .fillMaxWidth() - .clickable { onClick(book, sharedCoverKey) } - .padding(vertical = 4.dp, horizontal = 4.dp), + .combinedClickable( + onClick = { onClick(book, sharedCoverKey) }, + onLongClick = onLongClick?.let { cb -> { cb(book, sharedCoverKey) } } + ) + .padding(vertical = 4.dp, horizontal = 4.dp) + .then( + with(sharedTransitionScope) { + if (this != null) { + Modifier.sharedBounds( + sharedContentState = rememberSharedContentState("preview:${book.bookUrl}"), + animatedVisibilityScope = animatedVisibilityScope + ?: return@with Modifier, + ) + } else Modifier + } + ), verticalAlignment = Alignment.CenterVertically, ) { AppText( @@ -144,7 +165,7 @@ private fun RankingItem( ) SearchBookListItem( book = book, - shelfState = BookShelfState.NOT_IN_SHELF, + shelfState = shelfState, onClick = null, showPadding = false, modifier = Modifier.weight(1f), diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/WaterfallModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/WaterfallModule.kt index 5b27a1061..548597c58 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/modules/WaterfallModule.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/WaterfallModule.kt @@ -3,23 +3,32 @@ package io.legado.app.ui.main.homepage.modules import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope -import androidx.compose.foundation.clickable +import androidx.compose.foundation.ExperimentalFoundationApi +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.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Shuffle import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.legado.app.data.entities.SearchBook +import io.legado.app.domain.model.BookShelfState +import io.legado.app.ui.main.homepage.HomepageBookItemUi import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.widget.components.book.SearchBookTagChip import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.card.TextCard import io.legado.app.ui.widget.components.image.cover.CoilBookCover import io.legado.app.ui.widget.components.text.AppText @@ -27,36 +36,68 @@ import io.legado.app.ui.widget.components.text.AppText * 瀑布流单项组件 * 建议直接在 LazyVerticalStaggeredGrid 的 items 中使用,以获得最佳回收性能 */ -@OptIn(ExperimentalSharedTransitionApi::class) +@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class) @Composable fun WaterfallItem( - book: SearchBook, + item: HomepageBookItemUi, onClick: () -> Unit, + onLongClick: ((SearchBook, String?) -> Unit)? = null, modifier: Modifier = Modifier, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, sharedCoverKey: String? = null, ) { + val book = item.book GlassCard( + modifier = with(sharedTransitionScope) { + if (this != null) { + Modifier.sharedBounds( + sharedContentState = rememberSharedContentState("preview:${book.bookUrl}"), + animatedVisibilityScope = animatedVisibilityScope ?: return@with Modifier, + ) + } else Modifier + }, containerColor = LegadoTheme.colorScheme.surfaceContainerLow ) { Column( modifier = modifier .fillMaxWidth() - .clickable(onClick = onClick) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick?.let { cb -> { cb(book, sharedCoverKey) } } + ) ) { - CoilBookCover( - name = book.name, - author = book.author, - path = book.coverUrl, - radius = 16.dp, - sourceOrigin = book.origin, - modifier = Modifier - .fillMaxWidth(), - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = sharedCoverKey - ) + Box { + CoilBookCover( + name = book.name, + author = book.author, + path = book.coverUrl, + radius = 16.dp, + sourceOrigin = book.origin, + modifier = Modifier + .fillMaxWidth(), + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = sharedCoverKey, + ) + + val shelfIcon = when (item.shelfState) { + BookShelfState.IN_SHELF -> Icons.Default.Check + BookShelfState.SAME_NAME_AUTHOR -> Icons.Default.Shuffle + else -> null + } + if (shelfIcon != null) { + TextCard( + icon = shelfIcon, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp), + cornerRadius = 4.dp, + horizontalPadding = 2.dp, + verticalPadding = 2.dp + ) + } + } Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/io/legado/app/ui/theme/ImageSeedColorExtractor.kt b/app/src/main/java/io/legado/app/ui/theme/ImageSeedColorExtractor.kt index 7ccb55ce3..2210db336 100644 --- a/app/src/main/java/io/legado/app/ui/theme/ImageSeedColorExtractor.kt +++ b/app/src/main/java/io/legado/app/ui/theme/ImageSeedColorExtractor.kt @@ -5,8 +5,11 @@ import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.core.graphics.drawable.toBitmap @@ -55,15 +58,20 @@ fun rememberImageSeedColor( configureRequest: ImageRequest.Builder.() -> Unit = {}, ): Color? { val context = LocalContext.current - val seedColor by produceState(initialValue = null, imageLoader, requestKey) { - value = if (data == null) { - null + var seedColor by remember { mutableStateOf(null) } + + LaunchedEffect(imageLoader, requestKey) { + if (data == null) { + seedColor = null } else { - imageLoader.extractSeedColor( + val extracted = imageLoader.extractSeedColor( context = context, data = data, configureRequest = configureRequest ) + if (extracted != null) { + seedColor = extracted + } } } return seedColor diff --git a/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt index cde66ba1c..fa9204257 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt @@ -3,7 +3,8 @@ package io.legado.app.ui.widget.components.book import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope -import androidx.compose.foundation.clickable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize @@ -40,12 +41,13 @@ import io.legado.app.ui.widget.components.card.TextCard import io.legado.app.ui.widget.components.image.cover.CoilBookCover import io.legado.app.ui.widget.components.text.AppText -@OptIn(ExperimentalSharedTransitionApi::class) +@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class) @Composable fun SearchBookListItem( book: SearchBook, shelfState: BookShelfState, onClick: (() -> Unit)?, + onLongClick: ((SearchBook, String?) -> Unit)? = null, modifier: Modifier = Modifier, showPadding: Boolean = true, sharedTransitionScope: SharedTransitionScope? = null, @@ -55,8 +57,24 @@ fun SearchBookListItem( Row( modifier = modifier .fillMaxWidth() - .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .then( + if (onClick != null || onLongClick != null) Modifier.combinedClickable( + onClick = onClick ?: {}, + onLongClick = onLongClick?.let { cb -> { cb(book, sharedCoverKey) } } + ) else Modifier + ) .then(if (showPadding) Modifier.adaptiveHorizontalPadding(vertical = 8.dp) else Modifier) + .then( + with(sharedTransitionScope) { + if (this != null) { + Modifier.sharedBounds( + sharedContentState = rememberSharedContentState("preview:${book.bookUrl}"), + animatedVisibilityScope = animatedVisibilityScope + ?: return@with Modifier, + ) + } else Modifier + } + ) ) { Box(modifier = Modifier .width(72.dp) @@ -166,12 +184,13 @@ fun SearchBookListItem( } } -@OptIn(ExperimentalSharedTransitionApi::class) +@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class) @Composable fun SearchBookGridItem( book: SearchBook, shelfState: BookShelfState, onClick: () -> Unit, + onLongClick: ((SearchBook, String?) -> Unit)? = null, modifier: Modifier = Modifier, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, @@ -181,7 +200,21 @@ fun SearchBookGridItem( modifier = modifier .width(IntrinsicSize.Min) .clip(RoundedCornerShape(4.dp)) - .clickable(onClick = onClick) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick?.let { cb -> { cb(book, sharedCoverKey) } } + ) + .then( + with(sharedTransitionScope) { + if (this != null) { + Modifier.sharedBounds( + sharedContentState = rememberSharedContentState("preview:${book.bookUrl}"), + animatedVisibilityScope = animatedVisibilityScope + ?: return@with Modifier, + ) + } else Modifier + } + ) ) { Box( modifier = Modifier diff --git a/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookPreviewSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookPreviewSheet.kt new file mode 100644 index 000000000..b0c1d3c5e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookPreviewSheet.kt @@ -0,0 +1,186 @@ +package io.legado.app.ui.widget.components.book + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.data.entities.SearchBook +import io.legado.app.domain.model.BookShelfState +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.fadingEdge +import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow +import io.legado.app.ui.widget.components.image.cover.CoilBookCover +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.text.AppText +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun SearchBookPreviewSheet( + data: SearchBook?, + shelfState: BookShelfState? = null, + sharedCoverKey: String? = null, + onDismissRequest: () -> Unit, + onOpenDetail: (SearchBook, String?) -> Unit, + onAddToShelf: (SearchBook) -> Unit, +) { + AppModalBottomSheet( + data = data, + onDismissRequest = onDismissRequest, + ) { book -> + val isInShelf = shelfState == BookShelfState.IN_SHELF + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(bottom = 24.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = Modifier + .width(120.dp) + .aspectRatio(5f / 7f) + ) { + CoilBookCover( + name = book.name, + author = book.author, + path = book.coverUrl, + sourceOrigin = book.origin, + modifier = Modifier + .width(120.dp) + .aspectRatio(5f / 7f), + ) + } + + Column( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + AppText( + text = book.name, + style = LegadoTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + + if (book.author.isNotBlank()) { + AppText( + text = book.author, + style = LegadoTheme.typography.bodyLarge, + color = LegadoTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (book.originName.isNotBlank()) { + AppText( + text = book.originName, + style = LegadoTheme.typography.labelMedium, + color = LegadoTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + val latestChapter = book.latestChapterTitle + if (!latestChapter.isNullOrBlank()) { + AppText( + text = latestChapter, + style = LegadoTheme.typography.bodySmall, + color = LegadoTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (book.time > 0) { + val dateFormat = remember { + SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) + } + AppText( + text = dateFormat.format(Date(book.time)), + style = LegadoTheme.typography.bodySmall, + color = LegadoTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + ) + } + } + } + + val kinds = book.getKindList() + if (kinds.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + val lazyListState = rememberLazyListState() + LazyRow( + state = lazyListState, + modifier = Modifier + .fillMaxWidth() + .fadingEdge(lazyListState, gradientWidth = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(kinds) { kind -> + SearchBookTagChip(text = kind) + } + } + } + + val intro = book.intro?.replace("\\s+".toRegex(), " ")?.trim() + if (!intro.isNullOrBlank()) { + Spacer(modifier = Modifier.height(12.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 160.dp) + .verticalScroll(rememberScrollState()) + ) { + AppText( + text = intro, + style = LegadoTheme.typography.labelMediumEmphasized, + color = LegadoTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + ConfirmDismissButtonsRow( + onDismiss = { onOpenDetail(book, sharedCoverKey) }, + onConfirm = { onAddToShelf(book) }, + dismissText = stringResource(R.string.book_info), + confirmText = if (isInShelf) stringResource(R.string.already_in_bookshelf) + else stringResource(R.string.add_to_bookshelf), + confirmEnabled = !isInShelf, + ) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt index 3d363990a..12afa7051 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt @@ -114,17 +114,34 @@ fun ExploreKindMultiTypeItem( state, valueOverride, onValueChange, + onRunAction, modifier, backgroundColor ) } ExploreKind.Type.toggle -> { - ToggleTypeItem(kind, sourceUrl, state, valueOverride, onValueChange, BaseItem) + ToggleTypeItem( + kind, + sourceUrl, + state, + valueOverride, + onValueChange, + onRunAction, + BaseItem + ) } ExploreKind.Type.select -> { - SelectTypeItem(kind, sourceUrl, state, valueOverride, onValueChange, BaseItem) + SelectTypeItem( + kind, + sourceUrl, + state, + valueOverride, + onValueChange, + onRunAction, + BaseItem + ) } else -> { @@ -194,6 +211,7 @@ private fun TextTypeItem( state: ExploreKindItemState, valueOverride: String?, onValueChange: ((String) -> Unit)?, + onRunAction: (() -> Unit)?, modifier: Modifier, backgroundColor: Color ) { @@ -214,7 +232,7 @@ private fun TextTypeItem( actionJob?.cancel() actionJob = scope.launch { delay(600) - state.executeAction(kind.action) + if (onRunAction != null) onRunAction() else state.executeAction(kind.action) } } }, @@ -231,6 +249,7 @@ private fun ToggleTypeItem( state: ExploreKindItemState, valueOverride: String?, onValueChange: ((String) -> Unit)?, + onRunAction: (() -> Unit)?, BaseItem: @Composable (String, () -> Unit) -> Unit ) { val chars = remember(kind.chars) { @@ -256,7 +275,7 @@ private fun ToggleTypeItem( val nextIndex = if (currentIndex < 0) 0 else (currentIndex + 1) % chars.size char = chars.getOrElse(nextIndex) { "" } state.updateValue(char, onValueChange) - state.executeAction(kind.action) + if (onRunAction != null) onRunAction() else state.executeAction(kind.action) } BaseItem(text, internalOnClick) @@ -269,6 +288,7 @@ private fun SelectTypeItem( state: ExploreKindItemState, valueOverride: String?, onValueChange: ((String) -> Unit)?, + onRunAction: (() -> Unit)?, BaseItem: @Composable (String, () -> Unit) -> Unit ) { val chars = remember(kind.chars) { @@ -307,7 +327,7 @@ private fun SelectTypeItem( if (selected != option) { selected = option state.updateValue(option, onValueChange) - state.executeAction(kind.action) + if (onRunAction != null) onRunAction() else state.executeAction(kind.action) } } ) diff --git a/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt b/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt index bcf215660..a75d427c2 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt @@ -26,7 +26,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -83,10 +82,16 @@ fun CoilBookCover( } val hasCustomDefault = !randomPath.isNullOrBlank() - var isOnlineCoverLoaded by remember(path, sharedCoverKey, finalPath) { + var isOnlineCoverLoaded by remember(finalPath) { mutableStateOf(sharedCoverKey != null && finalPath != null) } + LaunchedEffect(finalPath) { + if (finalPath == null) { + isOnlineCoverLoaded = false + } + } + val transitionRadius = rememberSharedCoverTransitionRadius( sharedCoverKey = sharedCoverKey, radius = radius, @@ -94,104 +99,103 @@ fun CoilBookCover( ) val shape = remember(transitionRadius) { RoundedCornerShape(transitionRadius) } - key(path, sharedCoverKey) { - Box( - modifier = modifier - .aspectRatio(5f / 7f) - .then( - with(sharedTransitionScope) { - if (this != null && animatedVisibilityScope != null && sharedCoverKey != null) { - Modifier.sharedElement( - sharedContentState = rememberSharedContentState(sharedCoverKey), - animatedVisibilityScope = animatedVisibilityScope, - clipInOverlayDuringTransition = OverlayClip(shape) - ) - } else Modifier - } - ) - .then( - if (CoverConfig.coverShowShadow) { - Modifier.shadow(4.dp, shape) + Box( + modifier = modifier + .aspectRatio(5f / 7f) + .then( + with(sharedTransitionScope) { + if (this != null && animatedVisibilityScope != null && sharedCoverKey != null) { + Modifier.sharedElement( + sharedContentState = rememberSharedContentState(sharedCoverKey), + animatedVisibilityScope = animatedVisibilityScope, + clipInOverlayDuringTransition = OverlayClip(shape) + ) } else Modifier - ) - .background( - if (!hasCustomDefault && !isOnlineCoverLoaded) { - LegadoTheme.colorScheme.surfaceContainerLow - } else Color.Transparent, - shape - ) - .clip(shape) - ) { - Box(modifier = Modifier.fillMaxSize()) { - if (hasCustomDefault && !isOnlineCoverLoaded) { - AsyncImage( - model = buildCoverImageRequest( - context = context, - data = randomPath, - sourceOrigin = null, - loadOnlyWifi = false, - crossfade = showLoadingPlaceholder, - memoryCacheKey = randomPath, - ), - contentDescription = null, - imageLoader = koinInject(), - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - ) } + ) + .then( + if (CoverConfig.coverShowShadow) { + Modifier.shadow(4.dp, shape) + } else Modifier + ) + .background( + if (!hasCustomDefault && !isOnlineCoverLoaded) { + LegadoTheme.colorScheme.surfaceContainerLow + } else Color.Transparent, + shape + ) + .clip(shape) + ) { + Box(modifier = Modifier.fillMaxSize()) { + if (hasCustomDefault && !isOnlineCoverLoaded) { + AsyncImage( + model = buildCoverImageRequest( + context = context, + data = randomPath, + sourceOrigin = null, + loadOnlyWifi = false, + crossfade = showLoadingPlaceholder, + memoryCacheKey = randomPath, + ), + contentDescription = null, + imageLoader = koinInject(), + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + ) + } - if (finalPath != null) { - AsyncImage( - model = buildCoverImageRequest( - context = context, - data = finalPath, - sourceOrigin = sourceOrigin, - loadOnlyWifi = CoverConfig.loadCoverOnlyWifi, - crossfade = showLoadingPlaceholder, - memoryCacheKey = finalPath, - ), - contentDescription = null, - imageLoader = koinInject(), - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - onSuccess = { - isOnlineCoverLoaded = true - onLoadFinish?.invoke() - }, - onError = { - isOnlineCoverLoaded = false - onLoadFinish?.invoke() - } - ) - } else { - LaunchedEffect(Unit) { + if (finalPath != null) { + AsyncImage( + model = buildCoverImageRequest( + context = context, + data = finalPath, + sourceOrigin = sourceOrigin, + loadOnlyWifi = CoverConfig.loadCoverOnlyWifi, + crossfade = showLoadingPlaceholder, + memoryCacheKey = finalPath, + ), + contentDescription = null, + imageLoader = koinInject(), + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + onSuccess = { + isOnlineCoverLoaded = true + onLoadFinish?.invoke() + }, + onError = { + isOnlineCoverLoaded = false onLoadFinish?.invoke() } + ) + } else { + LaunchedEffect(Unit) { + onLoadFinish?.invoke() } } + } - if (showLoadingPlaceholder && !isOnlineCoverLoaded) { - if (!hasCustomDefault) { - Icon( - Icons.Default.Book, - contentDescription = null, - tint = LegadoTheme.colorScheme.secondary, - modifier = Modifier - .fillMaxSize(0.35f) - .align(Alignment.Center) - ) - } - CoverTextOverlay( - name = name, - author = author, - isNight = isNight + if (showLoadingPlaceholder && !isOnlineCoverLoaded) { + if (!hasCustomDefault) { + Icon( + Icons.Default.Book, + contentDescription = null, + tint = LegadoTheme.colorScheme.secondary, + modifier = Modifier + .fillMaxSize(0.35f) + .align(Alignment.Center) ) } + CoverTextOverlay( + name = name, + author = author, + isNight = isNight + ) } } } + @OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun rememberSharedCoverTransitionRadius( diff --git a/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt index 640c3f18b..7b5de25df 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt @@ -104,18 +104,18 @@ fun AppModalBottomSheet( LocalWindowInfo.current.containerSize.height.toDp() * 0.8f } - ModalBottomSheet( - onDismissRequest = onDismissRequest, - sheetState = sheetState, - containerColor = sheetContainerColor, - contentColor = sheetContentColor, - dragHandle = { BottomSheetDefaults.DragHandle(color = sheetDragHandleColor) } + MaterialExpressiveTheme( + colorScheme = colorScheme, + typography = Typography(), + motionScheme = MotionScheme.expressive(), + shapes = Shapes() ) { - MaterialExpressiveTheme( - colorScheme = colorScheme, - typography = Typography(), - motionScheme = MotionScheme.expressive(), - shapes = Shapes() + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + containerColor = sheetContainerColor, + contentColor = sheetContentColor, + dragHandle = { BottomSheetDefaults.DragHandle(color = sheetDragHandleColor) } ) { Column( modifier = Modifier diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e07f85bac..c491743b6 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -976,6 +976,7 @@ 绘制图片时抗锯齿 上传 URL 下载 URL 规则 + 关联书籍(relatedBooks) 剪贴板没有内容 剪贴板为空或格式不正确 格式不正确 @@ -1620,6 +1621,11 @@ 离字间距 布局设置 封面信息方向 + 网络书籍角标设置 + 全部显示 + 隐藏已在书架的同源书籍 + 隐藏已在书架的非同源书籍 + 只显示不在书架的书籍 规则 工具栏样式 标题分段 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a92084550..15842f874 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1007,6 +1007,7 @@ Anti-Aliasing when draw picture upload URL downloadUrlRule(downloadUrls) + Related Books(relatedBooks) Clipboard is empty Clipboard is empty or has an invalid format Invalid format @@ -1655,6 +1656,11 @@ Cover orientation + Network Book Badge + Show All + Hide Same Source Books in Shelf + Hide Non-Same Source Books in Shelf + Show Only Books Not in Shelf Follow background Heading spacing Layout settings