feature/bookinfo-related-books (#1201)
* feature: add translation full content * feature: add translation full content * feature: add translation full content * feature: add translation full content * feature: add translation full content * feature:use chunk files replace chunk cache db * feature:use chunk files replace chunk cache db * feature:use chunk files replace chunk cache db * feature: add translation config navigation and refactor translation settings * feature: 体验优化 * feature: fixed error prompt * Fix Gson deserialization error for LLM translation classes under R8 obfuscation * Fix PMD error * feature: pre-translation * feature: pre-translation * 优化 * 优化 * 增加书籍详情页关联书籍推荐功能 * 修复关联书籍"更多"按钮URL模板变量未解析的问题 * 修复发现按钮部分不执行JS的问题 * 优化书籍详情封面长按逻辑 * 阅读界面自定义图标问题 * R8反混淆问题 * 主题保存时保存图片等资源 * 在首页、发现页等长按时,提供了一个查看简短书籍信息的界面与右上角书籍信息角标。现在书籍信息角标在封面设置中显示 * 现在可配置主页图标 * 主页模块配置被遮挡的问题 * 一些优化 * fix: address code review findings (null safety, error propagation, performance) - TranslateChapterUseCase: wrap execute() in try-catch to prevent task stuck in Translating state; propagate actual translation error instead of generic "Translation incomplete"; defensive null check on pairs - DictionaryRepositoryImpl: safe call on pairs for Gson null-bypass - LlmTranslateRepositoryImpl: safe calls on json.sentences and json.choices to prevent NPE from unexpected API responses - ContentChunker: normalize \r\n to \n before splitting paragraphs to handle Windows-formatted text files - BookInfoViewModel: parallelize related books loading with async/awaitAll - CoilBookCover: add finalPath as remember key to reset state on reuse - ThemeImportExport: fallback to filesDir when getExternalFilesDir is null --------- Co-authored-by: duanhl <duanhl7749@gmail.com>
This commit is contained in:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+12
-1
@@ -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,
|
||||
|
||||
@@ -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></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>` 表达式。在 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 本),过多的轮播内容会影响体验。
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -35,7 +35,8 @@ class DictionaryRepositoryImpl : DictionaryGateway {
|
||||
|
||||
override fun updateBookDic(book: Book, newPairs: List<DictPair>) {
|
||||
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 {
|
||||
|
||||
@@ -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<GoogleSentence>,
|
||||
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<OpenAIChoice>
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class OpenAIChoice(
|
||||
val message: OpenAIMessage
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class OpenAIMessage(
|
||||
val content: String
|
||||
)
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -7,7 +7,8 @@ object ContentChunker {
|
||||
fun chunk(text: String, maxCharsPerChunk: Int = 3000): List<TextChunk> {
|
||||
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<TextChunk>()
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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<DictPair> = 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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<String> = 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<DictPair>) -> 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<DictPair>) -> 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<Int, String>()
|
||||
val pendingChunks = mutableListOf<TextChunk>()
|
||||
|
||||
// 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<Int, String>()
|
||||
val pendingChunks = mutableListOf<TextChunk>()
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<String, String> {
|
||||
val assets = mutableMapOf<String, String>()
|
||||
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<String, String>) {
|
||||
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<String, String>? = null
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<SearchBook> {
|
||||
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<SearchBook> {
|
||||
return exploreBookAwait(bookSource, url, page, ruleData)
|
||||
}
|
||||
|
||||
suspend fun exploreBookWithResolvedUrl(
|
||||
bookSource: BookSource,
|
||||
url: String,
|
||||
page: Int? = 1,
|
||||
ruleData: RuleDataInterface = RuleData(),
|
||||
): Pair<String, ArrayList<SearchBook>> {
|
||||
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<SearchBook> {
|
||||
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<SearchBook> {
|
||||
return exploreBookAwait(bookSource, url, page)
|
||||
}
|
||||
|
||||
/**
|
||||
* 书籍信息
|
||||
*/
|
||||
|
||||
@@ -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<SearchBook?>(null) }
|
||||
var previewSharedCoverKey by remember { mutableStateOf<String?>(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,
|
||||
|
||||
@@ -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<List<SearchBook>>(emptyList())
|
||||
private val _filterState = MutableStateFlow(BookFilterState.fromId(AppConfig.exploreFilterState))
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
private val _isRefreshing = MutableStateFlow(false)
|
||||
private val _errorMsg = MutableStateFlow<String?>(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<BookShelfKey>
|
||||
): 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BookInfoActivity> {
|
||||
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)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ReadRecordTimelineDay> = emptyList(),
|
||||
val inBookshelf: Boolean = false,
|
||||
val bookSource: BookSource? = null,
|
||||
val relatedBooks: ImmutableList<RelatedBooksUi> = 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<SearchBook>,
|
||||
)
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ImageLoader>()
|
||||
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<ImageLoader>()
|
||||
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<SearchBook>,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BookChapter> = emptyList()
|
||||
private var currentWebFiles: List<BookInfoWebFile> = emptyList()
|
||||
private var currentRelatedBooks: List<RelatedBooksUi> = emptyList()
|
||||
private var currentKindLabels: List<String> = 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<RelatedBooksDef>(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<String, List<SearchBook>> {
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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() } }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -114,7 +114,20 @@ class ToolButtonConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_too
|
||||
}
|
||||
|
||||
private fun getAllButtonIds(): List<String> {
|
||||
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<Int, String> {
|
||||
@@ -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
|
||||
|
||||
@@ -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<SearchBook?>(null) }
|
||||
var previewSharedCoverKey by remember { mutableStateOf<String?>(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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -84,4 +84,9 @@ object CoverConfig {
|
||||
PreferKey.coverInfoOrientation,
|
||||
"0" // 0: vertical, 1: horizontal
|
||||
)
|
||||
|
||||
var exploreFilterState by prefDelegate(
|
||||
PreferKey.exploreFilterState,
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+11
-18
@@ -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)
|
||||
|
||||
@@ -54,6 +54,11 @@ fun NavIconManageSheet(
|
||||
var activeDest by remember { mutableStateOf<String?>(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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<HomepageModuleUi> = persistentListOf(),
|
||||
@@ -93,7 +100,7 @@ sealed interface ModuleLoadState {
|
||||
|
||||
@Stable
|
||||
data class Loaded(
|
||||
val books: ImmutableList<SearchBook>,
|
||||
val books: ImmutableList<HomepageBookItemUi>,
|
||||
val hasMore: Boolean = false,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val page: Int = 1
|
||||
|
||||
@@ -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<SearchBook?>(null) }
|
||||
var previewSharedCoverKey by remember { mutableStateOf<String?>(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,
|
||||
|
||||
@@ -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<Set<BookShelfKey>>(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<HomepageUiState> = 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)
|
||||
|
||||
@@ -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 <T> AddCustomModuleDialog(
|
||||
GlassCard(
|
||||
containerColor = LegadoTheme.colorScheme.onSheetContent
|
||||
) {
|
||||
DropdownListSettingItem(
|
||||
CompactDropdownSettingItem(
|
||||
title = stringResource(R.string.homepage_type_label),
|
||||
selectedValue = type,
|
||||
displayEntries = typeList.map { it.title }.toTypedArray(),
|
||||
|
||||
@@ -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<SearchBook>,
|
||||
books: ImmutableList<HomepageBookItemUi>,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SearchBook>,
|
||||
books: ImmutableList<HomepageBookItemUi>,
|
||||
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,
|
||||
|
||||
@@ -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<SearchBook>,
|
||||
onClick: (SearchBook, String?) -> Unit,
|
||||
books: ImmutableList<HomepageBookItemUi>,
|
||||
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,
|
||||
|
||||
@@ -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<SearchBook>,
|
||||
books: ImmutableList<HomepageBookItemUi>,
|
||||
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(
|
||||
|
||||
@@ -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<SearchBook>,
|
||||
books: ImmutableList<HomepageBookItemUi>,
|
||||
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),
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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<Color?>(initialValue = null, imageLoader, requestKey) {
|
||||
value = if (data == null) {
|
||||
null
|
||||
var seedColor by remember { mutableStateOf<Color?>(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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
+11
-11
@@ -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
|
||||
|
||||
@@ -976,6 +976,7 @@
|
||||
<string name="pref_anti_alias_summary">绘制图片时抗锯齿</string>
|
||||
<string name="upload_url">上传 URL</string>
|
||||
<string name="download_url_rule">下载 URL 规则</string>
|
||||
<string name="related_books">关联书籍(relatedBooks)</string>
|
||||
<string name="clipboard_empty">剪贴板没有内容</string>
|
||||
<string name="clipboard_empty_or_invalid">剪贴板为空或格式不正确</string>
|
||||
<string name="invalid_format">格式不正确</string>
|
||||
@@ -1620,6 +1621,11 @@
|
||||
<string name="underline_padding">离字间距</string>
|
||||
<string name="layout_setting">布局设置</string>
|
||||
<string name="cover_info_orientation">封面信息方向</string>
|
||||
<string name="network_book_badge_setting">网络书籍角标设置</string>
|
||||
<string name="filter_show_all">全部显示</string>
|
||||
<string name="filter_hide_in_shelf">隐藏已在书架的同源书籍</string>
|
||||
<string name="filter_hide_same_name_author">隐藏已在书架的非同源书籍</string>
|
||||
<string name="filter_show_not_in_shelf_only">只显示不在书架的书籍</string>
|
||||
<string name="rule_segment">规则</string>
|
||||
<string name="tool_bar_style">工具栏样式</string>
|
||||
<string name="split_title">标题分段</string>
|
||||
|
||||
@@ -1007,6 +1007,7 @@
|
||||
<string name="pref_anti_alias_summary">Anti-Aliasing when draw picture</string>
|
||||
<string name="upload_url">upload URL</string>
|
||||
<string name="download_url_rule">downloadUrlRule(downloadUrls)</string>
|
||||
<string name="related_books">Related Books(relatedBooks)</string>
|
||||
<string name="clipboard_empty">Clipboard is empty</string>
|
||||
<string name="clipboard_empty_or_invalid">Clipboard is empty or has an invalid format</string>
|
||||
<string name="invalid_format">Invalid format</string>
|
||||
@@ -1655,6 +1656,11 @@
|
||||
|
||||
<!-- Missing strings -->
|
||||
<string name="cover_info_orientation">Cover orientation</string>
|
||||
<string name="network_book_badge_setting">Network Book Badge</string>
|
||||
<string name="filter_show_all">Show All</string>
|
||||
<string name="filter_hide_in_shelf">Hide Same Source Books in Shelf</string>
|
||||
<string name="filter_hide_same_name_author">Hide Non-Same Source Books in Shelf</string>
|
||||
<string name="filter_show_not_in_shelf_only">Show Only Books Not in Shelf</string>
|
||||
<string name="follow_read_background">Follow background</string>
|
||||
<string name="heading_spacing">Heading spacing</string>
|
||||
<string name="layout_setting">Layout settings</string>
|
||||
|
||||
Reference in New Issue
Block a user