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