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:
Kudomaga
2026-05-26 01:45:04 +08:00
committed by GitHub
co-authored by duanhl
parent 8205498f16
commit db5b18ca3c
53 changed files with 1970 additions and 579 deletions
+7 -1
View File
@@ -102,7 +102,13 @@
"Bash(Select-Object Name)", "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\\\\\", \"\"\\) })", "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 *)", "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)"
] ]
} }
} }
+12 -1
View File
@@ -63,7 +63,18 @@
-keep class **.data.entities.**{*;} -keep class **.data.entities.**{*;}
# Gson反序列化用的数据传输类 # Gson反序列化用的数据传输类
-keep class io.legado.app.model.translation.**{*;} -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 # hutool-core hutool-crypto
-keep class -keep class
!cn.hutool.core.util.RuntimeUtil, !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 package io.legado.app.data.entities
import androidx.annotation.Keep
/** /**
* Chunk-level translation cache record stored in .chunks.jsonl files. * Chunk-level translation cache record stored in .chunks.jsonl files.
* File path already contains bookUrl/chapterIndex/targetLanguage — chunkIndex is the key. * File path already contains bookUrl/chapterIndex/targetLanguage — chunkIndex is the key.
*/ */
@Keep
data class TranslationCache( data class TranslationCache(
val chunkIndex: Int, val chunkIndex: Int,
val originalChunkContent: String, val originalChunkContent: String,
@@ -21,7 +21,8 @@ data class BookInfoRule(
var tocUrl: String? = null, var tocUrl: String? = null,
var wordCount: String? = null, var wordCount: String? = null,
var canReName: String? = null, var canReName: String? = null,
var downloadUrls: String? = null var downloadUrls: String? = null,
var relatedBooks: String? = null
) : Parcelable { ) : Parcelable {
companion object { companion object {
@@ -35,7 +35,8 @@ class DictionaryRepositoryImpl : DictionaryGateway {
override fun updateBookDic(book: Book, newPairs: List<DictPair>) { override fun updateBookDic(book: Book, newPairs: List<DictPair>) {
val existingDict = getBookDictionaries(book) val existingDict = getBookDictionaries(book)
val updatedPairs = existingDict.pairs.toMutableList() @Suppress("SENSELESS_COMPARISON")
val updatedPairs = (existingDict.pairs ?: emptyList()).toMutableList()
for (newPair in newPairs) { for (newPair in newPairs) {
val existingIndex = updatedPairs.indexOfFirst { val existingIndex = updatedPairs.indexOfFirst {
@@ -1,5 +1,6 @@
package io.legado.app.data.repository package io.legado.app.data.repository
import androidx.annotation.Keep
import io.legado.app.domain.gateway.LlmGateway import io.legado.app.domain.gateway.LlmGateway
import io.legado.app.domain.model.DictPair import io.legado.app.domain.model.DictPair
import io.legado.app.domain.model.RetryReason import io.legado.app.domain.model.RetryReason
@@ -109,7 +110,7 @@ class LlmTranslateRepositoryImpl : LlmGateway {
return if (response.isSuccessful()) { return if (response.isSuccessful()) {
try { try {
val json = GSON.fromJson(response.body, GoogleTranslateResponse::class.java) 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()) { if (translatedText.isNotEmpty()) {
Result.success(translatedText) Result.success(translatedText)
} else { } else {
@@ -166,7 +167,7 @@ class LlmTranslateRepositoryImpl : LlmGateway {
return if (response.isSuccessful()) { return if (response.isSuccessful()) {
try { try {
val json = GSON.fromJson(response.body, OpenAIResponse::class.java) 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) { if (rawContent != null) {
// Parse the output with [dictionary] and [result] sections // Parse the output with [dictionary] and [result] sections
val parseResult = parseLlmOutput(rawContent, dictionaries) val parseResult = parseLlmOutput(rawContent, dictionaries)
@@ -349,30 +350,36 @@ $terms
} }
} }
@Keep
data class GoogleTranslateResponse( data class GoogleTranslateResponse(
val sentences: List<GoogleSentence>, val sentences: List<GoogleSentence>,
val src: String?, val src: String?,
val spell: GoogleSpell? val spell: GoogleSpell?
) )
@Keep
data class GoogleSentence( data class GoogleSentence(
val trans: String?, val trans: String?,
val orig: String?, val orig: String?,
val backend: Int? val backend: Int?
) )
@Keep
data class GoogleSpell( data class GoogleSpell(
val spell: String? val spell: String?
) )
@Keep
data class OpenAIResponse( data class OpenAIResponse(
val choices: List<OpenAIChoice> val choices: List<OpenAIChoice>
) )
@Keep
data class OpenAIChoice( data class OpenAIChoice(
val message: OpenAIMessage val message: OpenAIMessage
) )
@Keep
data class OpenAIMessage( data class OpenAIMessage(
val content: String 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.gateway.WebDavBackupGateway
import io.legado.app.domain.repository.BookDomainRepository import io.legado.app.domain.repository.BookDomainRepository
import io.legado.app.domain.usecase.AddBookUseCase 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.AppStartupMaintenanceUseCase
import io.legado.app.domain.usecase.BatchCacheDownloadUseCase import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
import io.legado.app.domain.usecase.CacheBookChaptersUseCase import io.legado.app.domain.usecase.CacheBookChaptersUseCase
@@ -163,6 +164,7 @@ val appModule = module {
singleOf(::ResolveBookShelfStateUseCase) singleOf(::ResolveBookShelfStateUseCase)
singleOf(::RefreshTocUseCase) singleOf(::RefreshTocUseCase)
singleOf(::AddBookUseCase) singleOf(::AddBookUseCase)
singleOf(::AddToBookshelfUseCase)
singleOf(::ImportBookshelfUseCase) singleOf(::ImportBookshelfUseCase)
singleOf(::ExportBookshelfUseCase) singleOf(::ExportBookshelfUseCase)
factory { GetReadRecordOverviewUseCase() } factory { GetReadRecordOverviewUseCase() }
@@ -7,7 +7,8 @@ object ContentChunker {
fun chunk(text: String, maxCharsPerChunk: Int = 3000): List<TextChunk> { fun chunk(text: String, maxCharsPerChunk: Int = 3000): List<TextChunk> {
if (text.isBlank()) return emptyList() 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() if (paragraphs.isEmpty()) return emptyList()
val chunks = mutableListOf<TextChunk>() val chunks = mutableListOf<TextChunk>()
@@ -1,9 +1,11 @@
package io.legado.app.domain.model package io.legado.app.domain.model
import androidx.annotation.Keep
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
/** 供 Gateway 和 ViewModel 使用的不可变模块模型 */ /** 供 Gateway 和 ViewModel 使用的不可变模块模型 */
@Immutable @Immutable
@Keep
data class ModuleItem( data class ModuleItem(
val id: String = "", val id: String = "",
val sourceUrl: String = "", val sourceUrl: String = "",
@@ -26,6 +28,7 @@ data class ModuleItem(
} }
@Immutable @Immutable
@Keep
data class CustomSetItem( data class CustomSetItem(
val id: String = "", val id: String = "",
val name: String = "", val name: String = "",
@@ -33,6 +36,7 @@ data class CustomSetItem(
) )
/** 模块定义(来自书源 JSON 解析或用户手动添加) */ /** 模块定义(来自书源 JSON 解析或用户手动添加) */
@Keep
data class ModuleDef( data class ModuleDef(
val key: String = "", val key: String = "",
val type: String = "", val type: String = "",
@@ -1,9 +1,12 @@
package io.legado.app.domain.model package io.legado.app.domain.model
import androidx.annotation.Keep
/** /**
* A pair of original text and its translation, used for maintaining * A pair of original text and its translation, used for maintaining
* consistent terminology across multiple translation chunks. * consistent terminology across multiple translation chunks.
*/ */
@Keep
data class DictPair( data class DictPair(
val original: String, val original: String,
val translation: String val translation: String
@@ -12,6 +15,7 @@ data class DictPair(
/** /**
* Collection of dictionary pairs with metadata. * Collection of dictionary pairs with metadata.
*/ */
@Keep
data class BookDictionary( data class BookDictionary(
val bookUrl: String, val bookUrl: String,
val pairs: List<DictPair> = emptyList(), val pairs: List<DictPair> = emptyList(),
@@ -21,6 +25,7 @@ data class BookDictionary(
/** /**
* A chunk of text for translation, with its index and paragraph mapping. * A chunk of text for translation, with its index and paragraph mapping.
*/ */
@Keep
data class TextChunk( data class TextChunk(
val index: Int, val index: Int,
val content: String, 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.domain.model.TextChunk
import io.legado.app.help.book.BookHelp import io.legado.app.help.book.BookHelp
import io.legado.app.ui.config.translation.TranslationConfig import io.legado.app.ui.config.translation.TranslationConfig
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@@ -45,6 +46,7 @@ class TranslateChapterUseCase(
onProgress: (TranslationProgress) -> Unit, onProgress: (TranslationProgress) -> Unit,
onTranslateStarted: () -> Unit onTranslateStarted: () -> Unit
): Result<String> = withContext(Dispatchers.IO) { ): Result<String> = withContext(Dispatchers.IO) {
try {
val originalContent = BookHelp.getContent(book, bookChapter) val originalContent = BookHelp.getContent(book, bookChapter)
?: return@withContext Result.failure(Exception("Failed to read original content")) ?: return@withContext Result.failure(Exception("Failed to read original content"))
@@ -59,7 +61,8 @@ class TranslateChapterUseCase(
// Load book dictionary for consistent terminology // Load book dictionary for consistent terminology
val bookDictionary = dictionaryGateway.getBookDictionaries(book) val bookDictionary = dictionaryGateway.getBookDictionaries(book)
val dictionaries = bookDictionary.pairs.toMutableList() @Suppress("SENSELESS_COMPARISON")
val dictionaries = (bookDictionary.pairs ?: emptyList()).toMutableList()
// Callback to update dictionary pairs immediately (persist as soon as discovered) // Callback to update dictionary pairs immediately (persist as soon as discovered)
val onDictionaryUpdate: (List<DictPair>) -> Unit = { newPairs -> val onDictionaryUpdate: (List<DictPair>) -> Unit = { newPairs ->
@@ -120,6 +123,7 @@ class TranslateChapterUseCase(
} }
onTranslateStarted() onTranslateStarted()
var translationError: Throwable? = null
coroutineScope { coroutineScope {
val concurrentChunks = TranslationConfig.llmConcurrentChunks.coerceIn(1, 4) val concurrentChunks = TranslationConfig.llmConcurrentChunks.coerceIn(1, 4)
val chunkGroups = pendingChunks.chunked(concurrentChunks) val chunkGroups = pendingChunks.chunked(concurrentChunks)
@@ -142,13 +146,17 @@ class TranslateChapterUseCase(
translatedChunks.keys translatedChunks.keys
)) ))
} else { } else {
val error = result.exceptionOrNull() ?: Exception("Translation failed") translationError = result.exceptionOrNull() ?: Exception("Translation failed")
return@coroutineScope return@coroutineScope
} }
} }
} }
} }
translationError?.let {
return@withContext Result.failure(it)
}
if (translatedChunks.size != chunks.size) { if (translatedChunks.size != chunks.size) {
return@withContext Result.failure(Exception("Translation incomplete")) return@withContext Result.failure(Exception("Translation incomplete"))
} }
@@ -161,6 +169,11 @@ class TranslateChapterUseCase(
onProgress(TranslationProgress(chunks.size, chunks.size, mergedContent, chunks.map { it.index }.toSet())) onProgress(TranslationProgress(chunks.size, chunks.size, mergedContent, chunks.map { it.index }.toSet()))
Result.success(mergedContent) Result.success(mergedContent)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
} }
/** /**
@@ -938,12 +938,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
appCtx.putPrefBoolean(PreferKey.shouldShowExpandButton, value) appCtx.putPrefBoolean(PreferKey.shouldShowExpandButton, value)
} }
var exploreFilterState: Int
get() = appCtx.getPrefInt(PreferKey.exploreFilterState, 0)
set(value) {
appCtx.putPrefInt(PreferKey.exploreFilterState, value)
}
var exploreLayoutState: Int var exploreLayoutState: Int
get() = appCtx.getPrefInt(PreferKey.exploreLayoutState, 0) get() = appCtx.getPrefInt(PreferKey.exploreLayoutState, 0)
set(value) { set(value) {
@@ -3,10 +3,13 @@ package io.legado.app.help.config
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import com.google.gson.GsonBuilder 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.ui.config.themeConfig.ThemeConfig
import io.legado.app.utils.EncoderUtils
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import splitties.init.appCtx import splitties.init.appCtx
import java.io.File import java.io.File
import java.io.FileOutputStream
/** /**
* 轻量级主题导入导出系统 * 轻量级主题导入导出系统
@@ -184,6 +187,7 @@ object ThemeImportExport {
defaultHomePage = ThemeConfig.defaultHomePage, defaultHomePage = ThemeConfig.defaultHomePage,
// 导航栏图标 // 导航栏图标
navIconHome = ThemeConfig.navIconHome,
navIconBookshelf = ThemeConfig.navIconBookshelf, navIconBookshelf = ThemeConfig.navIconBookshelf,
navIconExplore = ThemeConfig.navIconExplore, navIconExplore = ThemeConfig.navIconExplore,
navIconRss = ThemeConfig.navIconRss, navIconRss = ThemeConfig.navIconRss,
@@ -194,11 +198,70 @@ object ThemeImportExport {
// 其他 // 其他
useFlexibleTopAppBar = ThemeConfig.useFlexibleTopAppBar, useFlexibleTopAppBar = ThemeConfig.useFlexibleTopAppBar,
bgImageLight = ThemeConfig.bgImageLight,
bgImageDark = ThemeConfig.bgImageDark,
bgImageBlurring = ThemeConfig.bgImageBlurring, 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.defaultHomePage = data.defaultHomePage
// 导航栏图标 // 导航栏图标
ThemeConfig.navIconHome = data.navIconHome
ThemeConfig.navIconBookshelf = data.navIconBookshelf ThemeConfig.navIconBookshelf = data.navIconBookshelf
ThemeConfig.navIconExplore = data.navIconExplore ThemeConfig.navIconExplore = data.navIconExplore
ThemeConfig.navIconRss = data.navIconRss ThemeConfig.navIconRss = data.navIconRss
@@ -278,8 +342,90 @@ object ThemeImportExport {
// 其他 // 其他
ThemeConfig.useFlexibleTopAppBar = data.useFlexibleTopAppBar ThemeConfig.useFlexibleTopAppBar = data.useFlexibleTopAppBar
ThemeConfig.bgImageLight = data.bgImageLight
ThemeConfig.bgImageDark = data.bgImageDark
ThemeConfig.bgImageBlurring = data.bgImageBlurring ThemeConfig.bgImageBlurring = data.bgImageBlurring
ThemeConfig.bgImageNBlurring = data.bgImageNBlurring 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 defaultHomePage: String = "bookshelf",
// 导航栏图标 // 导航栏图标
val navIconHome: String = "",
val navIconBookshelf: String = "", val navIconBookshelf: String = "",
val navIconExplore: String = "", val navIconExplore: String = "",
val navIconRss: String = "", val navIconRss: String = "",
@@ -415,8 +562,32 @@ data class ThemeExportData(
// 其他 // 其他
val useFlexibleTopAppBar: Boolean = true, val useFlexibleTopAppBar: Boolean = true,
val bgImageLight: String? = null,
val bgImageDark: String? = null,
val bgImageBlurring: Int = 0, 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.AnalyzeRule.Companion.setCoroutineContext
import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.model.analyzeRule.RuleData import io.legado.app.model.analyzeRule.RuleData
import io.legado.app.model.analyzeRule.RuleDataInterface
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
@@ -106,6 +108,90 @@ object WebBook {
bookSource: BookSource, bookSource: BookSource,
url: String, url: String,
page: Int? = 1, 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> { ): ArrayList<SearchBook> {
val ruleData = RuleData() val ruleData = RuleData()
val analyzeUrl = AnalyzeUrl( val analyzeUrl = AnalyzeUrl(
@@ -114,10 +200,9 @@ object WebBook {
baseUrl = bookSource.bookSourceUrl, baseUrl = bookSource.bookSourceUrl,
source = bookSource, source = bookSource,
ruleData = ruleData, ruleData = ruleData,
coroutineContext = coroutineContext coroutineContext = currentCoroutineContext()
) )
var res = analyzeUrl.getStrResponseAwait() var res = analyzeUrl.getStrResponseAwait()
//检测书源是否已登录
bookSource.loginCheckJs?.let { checkJs -> bookSource.loginCheckJs?.let { checkJs ->
if (checkJs.isNotBlank()) { if (checkJs.isNotBlank()) {
res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse 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.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted 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.filled.GridView
import androidx.compose.material.icons.outlined.FilterAlt import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.runtime.Composable 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.data.entities.SearchBook
import io.legado.app.domain.model.BookShelfState import io.legado.app.domain.model.BookShelfState
import io.legado.app.domain.usecase.ExploreKindUiUseCase 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.main.bookCoverSharedElementKey
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.theme.ThemeResolver
@@ -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.LoadMoreFooter
import io.legado.app.ui.widget.components.book.SearchBookGridItem 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.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.card.TextCard
import io.legado.app.ui.widget.components.explore.ExploreKindSelectSheet 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.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
@@ -95,17 +92,28 @@ fun ExploreShowScreen(
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
) { ) {
var previewBook by remember { mutableStateOf<SearchBook?>(null) }
var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
LaunchedEffect(sourceUrl, exploreUrl, viewModel) { LaunchedEffect(sourceUrl, exploreUrl, viewModel) {
viewModel.initData(sourceUrl, exploreUrl) 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 isBookEnd by viewModel.isEnd.collectAsState()
val shouldTriggerAutoLoad by viewModel.shouldTriggerAutoLoad.collectAsState() val shouldTriggerAutoLoad by viewModel.shouldTriggerAutoLoad.collectAsState()
val isLoading by viewModel.isLoading.collectAsState() val isLoading by viewModel.isLoading.collectAsState()
val errorMsg by viewModel.errorMsg.collectAsState() val errorMsg by viewModel.errorMsg.collectAsState()
val filterState by viewModel.filterState.collectAsState()
val selectedTitle by viewModel.selectedKindTitle.collectAsState() val selectedTitle by viewModel.selectedKindTitle.collectAsState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
@@ -140,7 +148,6 @@ fun ExploreShowScreen(
total > 0 && last >= total - 1 total > 0 && last >= total - 1
} }
} }
var showMenu by remember { mutableStateOf(false) }
LaunchedEffect(shouldLoadMoreList.value, isGridMode) { LaunchedEffect(shouldLoadMoreList.value, isGridMode) {
if (!isGridMode && shouldLoadMoreList.value) viewModel.loadMore() if (!isGridMode && shouldLoadMoreList.value) viewModel.loadMore()
@@ -250,12 +257,6 @@ fun ExploreShowScreen(
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
modifier = Modifier.animateContentSize(tween(300)) modifier = Modifier.animateContentSize(tween(300))
) { ) {
TopBarActionButton(
onClick = { showMenu = true },
imageVector = Icons.Default.FilterList,
contentDescription = "Filter"
)
TopBarActionButton( TopBarActionButton(
onClick = { showKindSheet = true }, onClick = { showKindSheet = true },
imageVector = Icons.Outlined.FilterAlt, imageVector = Icons.Outlined.FilterAlt,
@@ -280,59 +281,6 @@ fun ExploreShowScreen(
imageVector = if (!isGridMode) Icons.AutoMirrored.Outlined.FormatListBulleted else Icons.Default.GridView, imageVector = if (!isGridMode) Icons.AutoMirrored.Outlined.FormatListBulleted else Icons.Default.GridView,
contentDescription = "切换布局" 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 scrollBehavior = scrollBehavior
) )
@@ -377,6 +325,10 @@ fun ExploreShowScreen(
book = item.book, book = item.book,
shelfState = item.shelfState, shelfState = item.shelfState,
onClick = { onBookClick(item.book, sharedCoverKey) }, onClick = { onBookClick(item.book, sharedCoverKey) },
onLongClick = { book, coverKey ->
previewBook = book
previewSharedCoverKey = coverKey
},
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
@@ -416,6 +368,10 @@ fun ExploreShowScreen(
book = item.book, book = item.book,
shelfState = item.shelfState, shelfState = item.shelfState,
onClick = { onBookClick(item.book, sharedCoverKey) }, onClick = { onBookClick(item.book, sharedCoverKey) },
onLongClick = { book, coverKey ->
previewBook = book
previewSharedCoverKey = coverKey
},
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, 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) @OptIn(ExperimentalSharedTransitionApi::class)
@@ -445,6 +419,7 @@ fun ExploreBookItem(
book: SearchBook, book: SearchBook,
shelfState: BookShelfState, shelfState: BookShelfState,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: ((SearchBook, String?) -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -454,6 +429,7 @@ fun ExploreBookItem(
book = book, book = book,
shelfState = shelfState, shelfState = shelfState,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick,
modifier = modifier, modifier = modifier,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
@@ -467,6 +443,7 @@ fun ExploreBookGridItem(
book: SearchBook, book: SearchBook,
onClick: () -> Unit, onClick: () -> Unit,
shelfState: BookShelfState, shelfState: BookShelfState,
onLongClick: ((SearchBook, String?) -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -476,6 +453,7 @@ fun ExploreBookGridItem(
book = book, book = book,
shelfState = shelfState, shelfState = shelfState,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick,
modifier = modifier, modifier = modifier,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, 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.entities.rule.ExploreKind
import io.legado.app.data.repository.ExploreRepository import io.legado.app.data.repository.ExploreRepository
import io.legado.app.domain.model.BookShelfState 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.BookShelfKey
import io.legado.app.domain.usecase.ExploreBooksUseCase import io.legado.app.domain.usecase.ExploreBooksUseCase
import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
@@ -56,10 +57,10 @@ class ExploreShowViewModel(
private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase, private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
private val exploreBooksUseCase: ExploreBooksUseCase, private val exploreBooksUseCase: ExploreBooksUseCase,
private val saveSearchBooksUseCase: SaveSearchBooksUseCase, private val saveSearchBooksUseCase: SaveSearchBooksUseCase,
private val addToBookshelfUseCase: AddToBookshelfUseCase,
) : ViewModel() { ) : ViewModel() {
private val _rawBooks = MutableStateFlow<List<SearchBook>>(emptyList()) private val _rawBooks = MutableStateFlow<List<SearchBook>>(emptyList())
private val _filterState = MutableStateFlow(BookFilterState.fromId(AppConfig.exploreFilterState))
private val _isLoading = MutableStateFlow(false) private val _isLoading = MutableStateFlow(false)
private val _isRefreshing = MutableStateFlow(false) private val _isRefreshing = MutableStateFlow(false)
private val _errorMsg = MutableStateFlow<String?>(null) private val _errorMsg = MutableStateFlow<String?>(null)
@@ -88,12 +89,9 @@ class ExploreShowViewModel(
val uiBooks = combine( val uiBooks = combine(
_rawBooks, _rawBooks,
_filterState,
_bookshelf _bookshelf
) { books, filter, bookshelf -> ) { books, bookshelf ->
books.filter { item -> books.map { item ->
isBookValid(item, filter, bookshelf)
}.map { item ->
ExploreBookItemUi( ExploreBookItemUi(
book = item, book = item,
shelfState = resolveBookShelfStateUseCase.execute( shelfState = resolveBookShelfStateUseCase.execute(
@@ -117,7 +115,6 @@ class ExploreShowViewModel(
val isLoading = _isLoading.asStateFlow() val isLoading = _isLoading.asStateFlow()
val errorMsg = _errorMsg.asStateFlow() val errorMsg = _errorMsg.asStateFlow()
val filterState = _filterState.asStateFlow()
init { init {
viewModelScope.launch { viewModelScope.launch {
@@ -173,11 +170,6 @@ class ExploreShowViewModel(
loadMore(isRefresh = true) loadMore(isRefresh = true)
} }
fun setFilterState(state: BookFilterState) {
_filterState.value = state
AppConfig.exploreFilterState = state.id
}
fun setLayout() { fun setLayout() {
val newState = if (_layoutState.value == 0) 1 else 0 val newState = if (_layoutState.value == 0) 1 else 0
_layoutState.value = newState _layoutState.value = newState
@@ -240,22 +232,9 @@ class ExploreShowViewModel(
) )
} }
private fun isBookValid( fun onAddToShelf(book: SearchBook) {
book: SearchBook, viewModelScope.launch {
filter: BookFilterState, addToBookshelfUseCase.execute(book)
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
} }
} }
} }
@@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
import io.legado.app.base.BaseComposeActivity import io.legado.app.base.BaseComposeActivity
import io.legado.app.ui.main.MainActivity import io.legado.app.ui.main.MainActivity
import io.legado.app.ui.widget.dialog.VariableDialog import io.legado.app.ui.widget.dialog.VariableDialog
import io.legado.app.utils.startActivity
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
class BookInfoActivity : BaseComposeActivity(), VariableDialog.Callback { class BookInfoActivity : BaseComposeActivity(), VariableDialog.Callback {
@@ -28,6 +29,20 @@ class BookInfoActivity : BaseComposeActivity(), VariableDialog.Callback {
onOpenSearch = { keyword -> onOpenSearch = { keyword ->
startActivity(MainActivity.createSearchIntent(this, key = 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.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource 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.entities.readRecord.ReadRecordTimelineDay
import io.legado.app.domain.usecase.ChangeSourceMigrationOptions import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
data class BookInfoUiState( data class BookInfoUiState(
val book: Book? = null, val book: Book? = null,
@@ -18,6 +21,7 @@ data class BookInfoUiState(
val readRecordTimelineDays: List<ReadRecordTimelineDay> = emptyList(), val readRecordTimelineDays: List<ReadRecordTimelineDay> = emptyList(),
val inBookshelf: Boolean = false, val inBookshelf: Boolean = false,
val bookSource: BookSource? = null, val bookSource: BookSource? = null,
val relatedBooks: ImmutableList<RelatedBooksUi> = persistentListOf(),
val isTocLoading: Boolean = true, val isTocLoading: Boolean = true,
val isBusy: Boolean = false, val isBusy: Boolean = false,
val deleteAlertEnabled: Boolean = true, val deleteAlertEnabled: Boolean = true,
@@ -58,6 +62,14 @@ data class BookInfoWebFile(
override fun toString(): String = name 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 { sealed interface BookInfoIntent {
data object DismissSheet : BookInfoIntent data object DismissSheet : BookInfoIntent
data object DismissDialog : BookInfoIntent data object DismissDialog : BookInfoIntent
@@ -102,6 +114,9 @@ sealed interface BookInfoIntent {
val entryName: String, val entryName: String,
val openAfterImport: Boolean, val openAfterImport: Boolean,
) : BookInfoIntent ) : BookInfoIntent
data class RelatedBookClick(val book: SearchBook) : BookInfoIntent
data class RelatedBooksMore(val title: String, val url: String) : BookInfoIntent
} }
sealed interface BookInfoEffect { sealed interface BookInfoEffect {
@@ -133,6 +148,19 @@ sealed interface BookInfoEffect {
val variable: String?, val variable: String?,
val comment: String, val comment: String,
) : BookInfoEffect ) : 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 { sealed interface BookInfoCallbackAction {
@@ -48,6 +48,8 @@ fun BookInfoRouteScreen(
onBack: () -> Unit, onBack: () -> Unit,
onFinish: (resultCode: Int?, afterTransition: Boolean) -> Unit, onFinish: (resultCode: Int?, afterTransition: Boolean) -> Unit,
onOpenSearch: (String) -> 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, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = 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 package io.legado.app.ui.book.info
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons 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.automirrored.outlined.FormatListBulleted
import androidx.compose.material.icons.filled.Book import androidx.compose.material.icons.filled.Book
import androidx.compose.material.icons.filled.BookmarkAdd 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.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.blur
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color 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.constant.BookType
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.SearchBook
import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
import io.legado.app.model.BookCover import io.legado.app.model.BookCover
import io.legado.app.ui.config.coverConfig.CoverConfig 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.LegadoTheme
import io.legado.app.ui.theme.LocalHazeState import io.legado.app.ui.theme.LocalHazeState
import io.legado.app.ui.theme.ProvideThemeOverride 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.AppScaffold
import io.legado.app.ui.widget.components.AppTextField import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.SmallTonalIconButton
import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.card.TextCard import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.icon.AppIcon 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.MiuixGlassScrollBehavior
import io.legado.app.ui.widget.components.topbar.TopBarActionButton import io.legado.app.ui.widget.components.topbar.TopBarActionButton
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.koin.compose.koinInject import org.koin.compose.koinInject
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
@@ -186,7 +191,12 @@ private fun BookInfoScreenContent(
} }
} else { } else {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
BookInfoBackdrop(book) BookInfoBackdrop(
bookUrl = book.bookUrl,
coverUrl = book.coverUrl,
customCoverUrl = book.customCoverUrl,
origin = book.origin,
)
AppPullToRefresh( AppPullToRefresh(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
isRefreshing = state.isTocLoading, isRefreshing = state.isTocLoading,
@@ -233,6 +243,18 @@ private fun BookInfoScreenContent(
onSourceClick = { onIntent(BookInfoIntent.ChangeSourceClick) }, onSourceClick = { onIntent(BookInfoIntent.ChangeSourceClick) },
onReadRecordClick = { onIntent(BookInfoIntent.ReadRecordClick) }, 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( BookInfoSummary(
book = book, book = book,
chapterList = state.chapterList, chapterList = state.chapterList,
@@ -401,23 +423,8 @@ private fun rememberBookInfoColorTheme(book: Book?): ThemeOverrideState? {
if (useDefaultCover) return null if (useDefaultCover) return null
val imageLoader = koinInject<ImageLoader>() val imageLoader = koinInject<ImageLoader>()
var shouldExtractColor by remember(book?.bookUrl) { mutableStateOf(false) } val coverPath = book?.getDisplayCover()
val sourceOrigin = book?.origin
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 loadOnlyWifi = CoverConfig.loadCoverOnlyWifi val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi
val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) { val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) {
listOf(coverPath, sourceOrigin, loadOnlyWifi) listOf(coverPath, sourceOrigin, loadOnlyWifi)
@@ -471,51 +478,49 @@ private fun BookInfoTopBarActions(
} }
@Composable @Composable
private fun BookInfoBackdrop(book: Book) { private fun BookInfoBackdrop(
val useDefaultCover = AppConfig.useDefaultCover || book.customCoverUrl == "use_default_cover" bookUrl: String,
coverUrl: String?,
customCoverUrl: String?,
origin: String,
) {
val useDefaultCover = AppConfig.useDefaultCover || customCoverUrl == "use_default_cover"
val isNight = AppConfig.isNightTheme val isNight = AppConfig.isNightTheme
val cover = if (useDefaultCover) { val cover = remember(bookUrl, customCoverUrl, coverUrl, isNight) {
BookCover.getRandomDefaultPath(book.bookUrl, isNight) if (useDefaultCover) {
BookCover.getRandomDefaultPath(bookUrl, isNight)
} else { } else {
book.getDisplayCover() 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 loadOnlyWifi = CoverConfig.loadCoverOnlyWifi
val context = LocalContext.current val context = LocalContext.current
val imageLoader = koinInject<ImageLoader>() 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( val seedOverlay = lerp(
LegadoTheme.colorScheme.secondaryContainer, LegadoTheme.colorScheme.secondaryContainer,
LegadoTheme.seedColor, LegadoTheme.seedColor,
0.42f 0.42f
) )
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
if (!cover.isNullOrBlank()) { 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( AsyncImage(
model = backdropRequest, model = backdropRequest,
imageLoader = imageLoader, imageLoader = imageLoader,
@@ -523,11 +528,11 @@ private fun BookInfoBackdrop(book: Book) {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(480.dp) .height(480.dp)
.blur(24.dp) .blur(24.dp),
.alpha(backdropAlpha),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
} }
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -698,7 +703,7 @@ private fun BookInfoHeader(
showLoadingPlaceholder = sharedCoverKey == null, showLoadingPlaceholder = sharedCoverKey == null,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey, sharedCoverKey = sharedCoverKey
) )
} }
Column( Column(
@@ -708,17 +713,40 @@ private fun BookInfoHeader(
.padding(top = 8.dp, bottom = 8.dp), .padding(top = 8.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
AppText( var showTitleMenu by remember { mutableStateOf(false) }
var isTitleExpanded by rememberSaveable { mutableStateOf(false) }
Box {
AnimatedTextLine(
text = book.name, text = book.name,
style = LegadoTheme.typography.headlineSmall, style = LegadoTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 3, maxLines = if (isTitleExpanded) Int.MAX_VALUE else 2,
modifier = Modifier.combinedClickable( modifier = Modifier.combinedClickable(
onClick = { onBookNameClick(false) }, onClick = { onBookNameClick(false) },
onLongClick = { onBookNameClick(true) } 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()), text = stringResource(R.string.author_show, book.getRealAuthor()),
style = LegadoTheme.typography.bodyLarge, style = LegadoTheme.typography.bodyLarge,
color = LegadoTheme.colorScheme.onSurfaceVariant, color = LegadoTheme.colorScheme.onSurfaceVariant,
@@ -727,7 +755,7 @@ private fun BookInfoHeader(
onLongClick = { onAuthorClick(true) } onLongClick = { onAuthorClick(true) }
) )
) )
AppText( AnimatedTextLine(
text = stringResource(R.string.origin_show, book.originName), text = stringResource(R.string.origin_show, book.originName),
style = LegadoTheme.typography.labelMedium, style = LegadoTheme.typography.labelMedium,
color = LegadoTheme.colorScheme.primary, color = LegadoTheme.colorScheme.primary,
@@ -896,12 +924,12 @@ private fun BookInfoSummary(
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 120.dp), .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
AppText( AnimatedTextLine(
text = stringResource(R.string.toc_s, book.durChapterTitle ?: stringResource(R.string.loading)), text = stringResource(R.string.toc_s, book.durChapterTitle ?: stringResource(R.string.loading)),
style = LegadoTheme.typography.titleMedium, style = LegadoTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
AppText( AnimatedTextLine(
text = stringResource(R.string.lasted_show, book.latestChapterTitle ?: ""), text = stringResource(R.string.lasted_show, book.latestChapterTitle ?: ""),
style = LegadoTheme.typography.bodyMedium, style = LegadoTheme.typography.bodyMedium,
color = LegadoTheme.colorScheme.onSurfaceVariant, color = LegadoTheme.colorScheme.onSurfaceVariant,
@@ -911,7 +939,7 @@ private fun BookInfoSummary(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
AppText( AnimatedTextLine(
text = stringResource(R.string.read_chapter_total, book.totalChapterNum), text = stringResource(R.string.read_chapter_total, book.totalChapterNum),
style = LegadoTheme.typography.labelMedium, style = LegadoTheme.typography.labelMedium,
color = LegadoTheme.colorScheme.primary, color = LegadoTheme.colorScheme.primary,
@@ -921,14 +949,14 @@ private fun BookInfoSummary(
text = "|", text = "|",
color = LegadoTheme.colorScheme.secondary 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), text = if (book.durChapterIndex + 1 == book.totalChapterNum && book.totalChapterNum > 0) "已读完" else stringResource(R.string.read_chapter_index, book.durChapterIndex + 1),
style = LegadoTheme.typography.labelMedium, style = LegadoTheme.typography.labelMedium,
color = LegadoTheme.colorScheme.secondary, color = LegadoTheme.colorScheme.secondary,
) )
} }
if (chapterList.isEmpty()) { if (chapterList.isEmpty()) {
AppText( AnimatedTextLine(
text = stringResource(R.string.error_load_toc), text = stringResource(R.string.error_load_toc),
style = LegadoTheme.typography.bodySmall, style = LegadoTheme.typography.bodySmall,
color = LegadoTheme.colorScheme.error color = LegadoTheme.colorScheme.error
@@ -942,12 +970,15 @@ private fun BookInfoSummary(
containerColor = LegadoTheme.colorScheme.surfaceContainerLow, containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
) { ) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { 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)) Spacer(modifier = Modifier.height(4.dp))
AppText( AnimatedTextLine(
text = book.getDisplayIntro().orEmpty().ifBlank { stringResource(R.string.intro_show_null) }, text = book.getDisplayIntro().orEmpty().ifBlank { stringResource(R.string.intro_show_null) },
style = LegadoTheme.typography.bodyMedium, style = LegadoTheme.typography.bodyMedium,
) )
@@ -1065,3 +1096,46 @@ private fun BookInfoDialogs(
AppLogSheet(show = state.showAppLogSheet, onDismissRequest = { onIntent(BookInfoIntent.DismissAppLogSheet) }) 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 coil.request.SuccessResult
import io.legado.app.R import io.legado.app.R
import io.legado.app.base.BaseViewModel import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppLog import io.legado.app.constant.AppLog
import io.legado.app.constant.AppPattern import io.legado.app.constant.AppPattern
import io.legado.app.constant.BookType 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.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource 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.entities.readRecord.ReadRecordTimelineDay
import io.legado.app.data.repository.BookGroupRepository import io.legado.app.data.repository.BookGroupRepository
import io.legado.app.data.repository.ReadRecordRepository 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.ui.widget.components.image.cover.buildCoverImageRequest
import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.ArchiveUtils
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.ImageSaveUtils import io.legado.app.utils.ImageSaveUtils
import io.legado.app.utils.UrlUtil import io.legado.app.utils.UrlUtil
import io.legado.app.utils.postEvent import io.legado.app.utils.postEvent
@@ -62,6 +65,9 @@ import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job 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.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
@@ -70,6 +76,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.collections.immutable.toImmutableList
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
class BookInfoViewModel( class BookInfoViewModel(
@@ -97,6 +104,7 @@ class BookInfoViewModel(
} }
private var currentChapterList: List<BookChapter> = emptyList() private var currentChapterList: List<BookChapter> = emptyList()
private var currentWebFiles: List<BookInfoWebFile> = emptyList() private var currentWebFiles: List<BookInfoWebFile> = emptyList()
private var currentRelatedBooks: List<RelatedBooksUi> = emptyList()
private var currentKindLabels: List<String> = emptyList() private var currentKindLabels: List<String> = emptyList()
private var currentGroupNames: String? = null private var currentGroupNames: String? = null
private var currentHasCustomGroup = false private var currentHasCustomGroup = false
@@ -147,6 +155,7 @@ class BookInfoViewModel(
} }
currentChapterList = emptyList() currentChapterList = emptyList()
currentWebFiles = emptyList() currentWebFiles = emptyList()
currentRelatedBooks = emptyList()
currentKindLabels = emptyList() currentKindLabels = emptyList()
currentGroupNames = null currentGroupNames = null
currentHasCustomGroup = false 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 { } else {
loadChapter(loadedBook, runPreUpdateJs) loadChapter(loadedBook, runPreUpdateJs)
} }
loadRelatedBooks(loadedBook, source)
}.onError { }.onError {
AppLog.put("获取书籍信息失败\n${it.localizedMessage}", it) AppLog.put("获取书籍信息失败\n${it.localizedMessage}", it)
context.toastOnUi(R.string.error_get_book_info) context.toastOnUi(R.string.error_get_book_info)
@@ -732,6 +745,7 @@ class BookInfoViewModel(
}.onSuccess { }.onSuccess {
currentBook = it currentBook = it
currentChapterList = toc currentChapterList = toc
currentRelatedBooks = emptyList()
currentGroupNames = null currentGroupNames = null
currentHasCustomGroup = false currentHasCustomGroup = false
currentKindLabels = emptyList() currentKindLabels = emptyList()
@@ -746,6 +760,7 @@ class BookInfoViewModel(
currentBook = book currentBook = book
currentChapterList = emptyList() currentChapterList = emptyList()
currentWebFiles = emptyList() currentWebFiles = emptyList()
currentRelatedBooks = emptyList()
currentKindLabels = emptyList() currentKindLabels = emptyList()
currentGroupNames = null currentGroupNames = null
currentHasCustomGroup = false currentHasCustomGroup = false
@@ -762,6 +777,7 @@ class BookInfoViewModel(
if (chapters.isNotEmpty()) { if (chapters.isNotEmpty()) {
currentChapterList = chapters currentChapterList = chapters
syncUiState(isTocLoading = false) syncUiState(isTocLoading = false)
source?.let { loadRelatedBooks(book, it) }
} else { } else {
loadChapter(book) loadChapter(book)
} }
@@ -1263,6 +1279,7 @@ class BookInfoViewModel(
book = currentBook?.uiCopy(), book = currentBook?.uiCopy(),
chapterList = currentChapterList, chapterList = currentChapterList,
webFiles = currentWebFiles, webFiles = currentWebFiles,
relatedBooks = currentRelatedBooks.toImmutableList(),
kindLabels = currentKindLabels, kindLabels = currentKindLabels,
groupNames = currentGroupNames, groupNames = currentGroupNames,
hasCustomGroup = currentHasCustomGroup, 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) { private fun emitEffect(effect: BookInfoEffect) {
_effects.tryEmit(effect) _effects.tryEmit(effect)
} }
@@ -1298,3 +1395,9 @@ private val BookInfoWebFile.isSupported: Boolean
private val BookInfoWebFile.isSupportDecompress: Boolean private val BookInfoWebFile.isSupportDecompress: Boolean
get() = AppPattern.archiveFileRegex.matches(name) 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", id = "search",
iconRes = R.drawable.ic_search, iconRes = R.drawable.ic_search,
description = context.getString(R.string.search_content), description = context.getString(R.string.search_content),
onClick = { runMenuOut{ callBack.openSearchActivity(null) } } 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() } }
), ),
ToolButton( ToolButton(
id = "catalog", id = "catalog",
@@ -706,7 +699,7 @@ class ReadMenu @JvmOverloads constructor(
ToolButton( ToolButton(
id = "replace_badge", id = "replace_badge",
iconRes = R.drawable.ic_find_replace, 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() } }, onLongClick = { runMenuOut { callBack.openReplaceRule() } },
onCheck = { runMenuOut { callBack.changeReplaceRuleState() } }, onCheck = { runMenuOut { callBack.changeReplaceRuleState() } },
onClick = { } onClick = { }
@@ -716,6 +709,13 @@ class ReadMenu @JvmOverloads constructor(
iconRes = R.drawable.ic_auto_page, iconRes = R.drawable.ic_auto_page,
description = context.getString(R.string.auto_next_page), description = context.getString(R.string.auto_next_page),
onClick = { runMenuOut { callBack.autoPage() } } 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> { 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> { 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) "theme" -> R.drawable.ic_daytime to getString(R.string.day_night_switch)
"prev_chapter" -> R.drawable.ic_previous to getString(R.string.previous_chapter) "prev_chapter" -> R.drawable.ic_previous to getString(R.string.previous_chapter)
"next_chapter" -> R.drawable.ic_next to getString(R.string.next_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" -> 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) "replace_badge" -> R.drawable.ic_find_replace to getString(R.string.replace_purify_badge)
else -> R.drawable.ic_help to id else -> R.drawable.ic_help to id
@@ -51,7 +51,9 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R import io.legado.app.R
import io.legado.app.data.entities.SearchBook
import io.legado.app.data.entities.SearchKeyword 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.bookCoverSharedElementKey
import io.legado.app.ui.main.bookshelf.BookShelfItem import io.legado.app.ui.main.bookshelf.BookShelfItem
import io.legado.app.ui.theme.LegadoTheme 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.SearchBar
import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.book.SearchBookListItem 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.SmallIconButton
import io.legado.app.ui.widget.components.button.SmallTextButton import io.legado.app.ui.widget.components.button.SmallTextButton
import io.legado.app.ui.widget.components.card.NormalCard import io.legado.app.ui.widget.components.card.NormalCard
@@ -97,6 +100,8 @@ fun SearchScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
var previewBook by remember { mutableStateOf<SearchBook?>(null) }
var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
var queryInput by rememberSaveable { mutableStateOf(state.query) } var queryInput by rememberSaveable { mutableStateOf(state.query) }
@@ -417,6 +422,10 @@ fun SearchScreen(
) )
) )
}, },
onLongClick = { book, coverKey ->
previewBook = book
previewSharedCoverKey = coverKey
},
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey sharedCoverKey = sharedCoverKey
@@ -561,6 +570,24 @@ fun SearchScreen(
Spacer(modifier = Modifier.height(20.dp)) 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( 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.entities.SearchBook
import io.legado.app.data.repository.SearchRepository import io.legado.app.data.repository.SearchRepository
import io.legado.app.domain.model.BookSearchScope 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.BookSearchControl
import io.legado.app.domain.usecase.BookSearchRequest import io.legado.app.domain.usecase.BookSearchRequest
import io.legado.app.domain.usecase.BookShelfKey import io.legado.app.domain.usecase.BookShelfKey
@@ -37,6 +38,7 @@ class SearchViewModel(
private val repository: SearchRepository, private val repository: SearchRepository,
private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase, private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
private val searchBooksUseCase: SearchBooksUseCase, private val searchBooksUseCase: SearchBooksUseCase,
private val addToBookshelfUseCase: AddToBookshelfUseCase,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(
@@ -72,6 +74,12 @@ class SearchViewModel(
observeQueryBookshelfHints() observeQueryBookshelfHints()
} }
fun onAddToShelf(book: SearchBook) {
viewModelScope.launch {
addToBookshelfUseCase.execute(book)
}
}
fun onIntent(intent: SearchIntent) { fun onIntent(intent: SearchIntent) {
when (intent) { when (intent) {
is SearchIntent.Initialize -> initialize(intent.key, intent.scopeRaw) 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("tocUrl", ir.tocUrl, R.string.rule_toc_url))
add(EditEntity("canReName", ir.canReName, R.string.rule_can_re_name)) add(EditEntity("canReName", ir.canReName, R.string.rule_can_re_name))
add(EditEntity("downloadUrls", ir.downloadUrls, R.string.download_url_rule)) add(EditEntity("downloadUrls", ir.downloadUrls, R.string.download_url_rule))
add(EditEntity("relatedBooks", ir.relatedBooks, R.string.related_books))
} }
// 目录页 // 目录页
val tr = bs.getTocRule() val tr = bs.getTocRule()
@@ -520,6 +521,7 @@ class BookSourceEditActivity :
"canReName" -> bookInfoRule.canReName = it.value "canReName" -> bookInfoRule.canReName = it.value
"downloadUrls" -> bookInfoRule.downloadUrls = "downloadUrls" -> bookInfoRule.downloadUrls =
viewModel.ruleComplete(it.value, bookInfoRule.init) viewModel.ruleComplete(it.value, bookInfoRule.init)
"relatedBooks" -> bookInfoRule.relatedBooks = it.value
} }
} }
tocEntities.forEach { tocEntities.forEach {
@@ -84,4 +84,9 @@ object CoverConfig {
PreferKey.coverInfoOrientation, PreferKey.coverInfoOrientation,
"0" // 0: vertical, 1: horizontal "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.theme.adaptiveContentPadding
import io.legado.app.ui.widget.components.AppScaffold import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.SplicedColumnGroup 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.dialog.ColorPickerSheet
import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class) @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)) { SplicedColumnGroup(title = stringResource(R.string.day)) {
val coverCount = CoverConfig.defaultCover.split(",").filter { it.isNotBlank() }.size val coverCount = CoverConfig.defaultCover.split(",").filter { it.isNotBlank() }.size
ClickableSettingItem( ClickableSettingItem(
@@ -9,15 +9,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.icon.AppIcon 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.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.text.AppText
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun BackgroundImageManageSheet( fun BackgroundImageManageSheet(
show: Boolean, isDarkTheme: Boolean?,
isDarkTheme: Boolean,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
viewModel: ThemeConfigViewModel = koinViewModel() viewModel: ThemeConfigViewModel = koinViewModel()
) { ) {
@@ -58,23 +51,23 @@ fun BackgroundImageManageSheet(
scope.launch { scope.launch {
viewModel.setBackgroundFromUri( viewModel.setBackgroundFromUri(
uri = it, uri = it,
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme == true
) )
} }
} }
} }
val currentPath = if (isDarkTheme) { AppModalBottomSheet(
data = isDarkTheme,
onDismissRequest = onDismissRequest,
title = stringResource(R.string.background_image),
) { isDark ->
val currentPath = if (isDark) {
ThemeConfig.bgImageDark ThemeConfig.bgImageDark
} else { } else {
ThemeConfig.bgImageLight ThemeConfig.bgImageLight
} }
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest,
title = stringResource(R.string.background_image),
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -122,7 +115,7 @@ fun BackgroundImageManageSheet(
) )
} }
SmallTonalIconButton( SmallTonalIconButton(
onClick = { viewModel.removeBackground(isDarkTheme) }, onClick = { viewModel.removeBackground(isDark) },
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(8.dp) .padding(8.dp)
@@ -54,6 +54,11 @@ fun NavIconManageSheet(
var activeDest by remember { mutableStateOf<String?>(null) } var activeDest by remember { mutableStateOf<String?>(null) }
val destinations = listOf( val destinations = listOf(
NavIconDestination(
"home",
R.string.home,
ThemeConfig.navIconHome
) { ThemeConfig.navIconHome = it },
NavIconDestination("bookshelf", R.string.bookshelf, ThemeConfig.navIconBookshelf) { ThemeConfig.navIconBookshelf = it }, NavIconDestination("bookshelf", R.string.bookshelf, ThemeConfig.navIconBookshelf) { ThemeConfig.navIconBookshelf = it },
NavIconDestination("explore", R.string.discovery, ThemeConfig.navIconExplore) { ThemeConfig.navIconExplore = it }, NavIconDestination("explore", R.string.discovery, ThemeConfig.navIconExplore) { ThemeConfig.navIconExplore = it },
NavIconDestination("rss", R.string.rss, ThemeConfig.navIconRss) { ThemeConfig.navIconRss = it }, NavIconDestination("rss", R.string.rss, ThemeConfig.navIconRss) { ThemeConfig.navIconRss = it },
@@ -84,7 +89,9 @@ fun NavIconManageSheet(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
title = stringResource(R.string.theme_config_nav_icons), 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Typeface import android.graphics.Typeface
import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -72,6 +71,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.ConstraintLayout
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R import io.legado.app.R
import io.legado.app.base.AppContextWrapper import io.legado.app.base.AppContextWrapper
@@ -133,7 +133,7 @@ fun ThemeConfigScreen(
var fontFolderUri by remember { var fontFolderUri by remember {
mutableStateOf( mutableStateOf(
context.getPrefString(PreferKey.fontFolder)?.let { Uri.parse(it) } context.getPrefString(PreferKey.fontFolder)?.toUri()
) )
} }
@@ -672,6 +672,7 @@ fun ThemeConfigScreen(
item { item {
SplicedColumnGroup(title = stringResource(R.string.theme_config_nav_icon_settings)) { SplicedColumnGroup(title = stringResource(R.string.theme_config_nav_icon_settings)) {
val customCount = listOf( val customCount = listOf(
ThemeConfig.navIconHome,
ThemeConfig.navIconBookshelf, ThemeConfig.navIconBookshelf,
ThemeConfig.navIconExplore, ThemeConfig.navIconExplore,
ThemeConfig.navIconRss, ThemeConfig.navIconRss,
@@ -722,20 +723,15 @@ fun ThemeConfigScreen(
) )
manageKey?.let { isDark ->
BackgroundImageManageSheet( BackgroundImageManageSheet(
show = true, isDarkTheme = manageKey,
isDarkTheme = isDark,
onDismissRequest = { manageKey = null } onDismissRequest = { manageKey = null }
) )
}
if (showNavIconSheet) {
NavIconManageSheet( NavIconManageSheet(
show = true, show = showNavIconSheet,
onDismissRequest = { showNavIconSheet = false } onDismissRequest = { showNavIconSheet = false }
) )
}
LauncherIconPickerSheet( LauncherIconPickerSheet(
@@ -21,6 +21,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.scene.SinglePaneSceneStrategy
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import io.legado.app.BuildConfig import io.legado.app.BuildConfig
import io.legado.app.R import io.legado.app.R
@@ -177,6 +178,7 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
SharedTransitionLayout { SharedTransitionLayout {
NavDisplay( NavDisplay(
backStack = backStack, backStack = backStack,
sceneStrategies = listOf(SinglePaneSceneStrategy()),
transitionSpec = { transitionSpec = {
(slideIntoContainer( (slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Start, towards = AnimatedContentTransitionScope.SlideDirection.Start,
@@ -420,6 +420,12 @@ fun MainActivity.mainEntryProvider(
onOpenSearch = { keyword -> onOpenSearch = { keyword ->
onNavigateToRoute(MainRouteSearch(key = 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, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = LocalNavAnimatedContentScope.current, animatedVisibilityScope = LocalNavAnimatedContentScope.current,
sharedCoverKey = route.sharedCoverKey ?: bookCoverSharedElementKey(route.bookUrl), sharedCoverKey = route.sharedCoverKey ?: bookCoverSharedElementKey(route.bookUrl),
@@ -3,11 +3,18 @@ package io.legado.app.ui.main.homepage
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.SearchBook
import io.legado.app.data.entities.rule.ExploreKind 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.HomepageModuleType
import io.legado.app.domain.model.ModuleDef import io.legado.app.domain.model.ModuleDef
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@Stable
data class HomepageBookItemUi(
val book: SearchBook,
val shelfState: BookShelfState = BookShelfState.NOT_IN_SHELF,
)
@Stable @Stable
data class HomepageUiState( data class HomepageUiState(
val modules: ImmutableList<HomepageModuleUi> = persistentListOf(), val modules: ImmutableList<HomepageModuleUi> = persistentListOf(),
@@ -93,7 +100,7 @@ sealed interface ModuleLoadState {
@Stable @Stable
data class Loaded( data class Loaded(
val books: ImmutableList<SearchBook>, val books: ImmutableList<HomepageBookItemUi>,
val hasMore: Boolean = false, val hasMore: Boolean = false,
val isLoadingMore: Boolean = false, val isLoadingMore: Boolean = false,
val page: Int = 1 val page: Int = 1
@@ -37,7 +37,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@@ -56,6 +55,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R import io.legado.app.R
import io.legado.app.data.entities.SearchBook
import io.legado.app.domain.model.HomepageModuleType import io.legado.app.domain.model.HomepageModuleType
import io.legado.app.ui.main.bookCoverSharedElementKey import io.legado.app.ui.main.bookCoverSharedElementKey
import io.legado.app.ui.main.homepage.modules.BannerModule 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.LoadMoreFooter
import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.book.SearchBookGridItem 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.button.SmallTonalIconButton
import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.icon.AppIcon import io.legado.app.ui.widget.components.icon.AppIcon
@@ -98,6 +99,8 @@ fun HomepageScreen(
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() 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 // Removed allSets and browseSources as they are now part of uiState.manageState
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
@@ -113,11 +116,7 @@ fun HomepageScreen(
}) })
val homeString = stringResource(R.string.home) val homeString = stringResource(R.string.home)
val currentTitle by remember( val currentTitle by remember(layoutMode, selectedSets) {
layoutMode,
pagerState.currentPage,
selectedSets,
) {
derivedStateOf { derivedStateOf {
if (layoutMode == 1) { if (layoutMode == 1) {
homeString homeString
@@ -177,7 +176,10 @@ fun HomepageScreen(
if (layoutMode == 1 && selectedSets.isNotEmpty()) { if (layoutMode == 1 && selectedSets.isNotEmpty()) {
AppTabRow( AppTabRow(
tabTitles = selectedSets.map { it.sourceName }, tabTitles = selectedSets.map { it.sourceName },
selectedTabIndex = pagerState.currentPage, selectedTabIndex = pagerState.currentPage.coerceIn(
0,
selectedSets.size - 1
),
onTabSelected = { index -> onTabSelected = { index ->
scope.launch { pagerState.animateScrollToPage(index) } scope.launch { pagerState.animateScrollToPage(index) }
} }
@@ -223,6 +225,10 @@ fun HomepageScreen(
onErrorClick = { errorMsg = it }, onErrorClick = { errorMsg = it },
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
onBookLongClick = { book, sharedCoverKey ->
previewBook = book
previewSharedCoverKey = sharedCoverKey
},
) )
} }
} }
@@ -241,7 +247,7 @@ fun HomepageScreen(
onDismiss = { errorMsg = null }, onDismiss = { errorMsg = null },
content = { msg -> content = { msg ->
SelectionContainer { SelectionContainer {
Text( AppText(
text = msg, text = msg,
style = LegadoTheme.typography.bodyMedium, style = LegadoTheme.typography.bodyMedium,
modifier = Modifier modifier = Modifier
@@ -303,6 +309,23 @@ fun HomepageScreen(
layoutMode = layoutMode, layoutMode = layoutMode,
onLayoutModeChange = { viewModel.setLayoutMode(it) }, 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(), gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(),
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
onBookLongClick: (SearchBook, String?) -> Unit = { _, _ -> },
onErrorClick: (String) -> Unit onErrorClick: (String) -> Unit
) { ) {
if (modules.isEmpty()) { if (modules.isEmpty()) {
@@ -490,14 +514,20 @@ private fun ModuleList(
HomepageModuleType.Waterfall -> { HomepageModuleType.Waterfall -> {
itemsIndexed( itemsIndexed(
state.books, 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( val sharedCoverKey = bookCoverSharedElementKey(
book.bookUrl, item.book.bookUrl,
"home:${moduleUi.globalId}:waterfall:$index" "home:${moduleUi.globalId}:waterfall:$index"
) )
WaterfallItem( WaterfallItem(
book = book, item = item,
onClick = { viewModel.onBookClick(book, sharedCoverKey) }, onClick = {
viewModel.onBookClick(
item.book,
sharedCoverKey
)
},
onLongClick = onBookLongClick,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey, sharedCoverKey = sharedCoverKey,
@@ -520,15 +550,21 @@ private fun ModuleList(
HomepageModuleType.InfiniteGrid -> { HomepageModuleType.InfiniteGrid -> {
itemsIndexed( itemsIndexed(
state.books, 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( val sharedCoverKey = bookCoverSharedElementKey(
book.bookUrl, item.book.bookUrl,
"home:${moduleUi.globalId}:infinite:$index" "home:${moduleUi.globalId}:infinite:$index"
) )
SearchBookGridItem( SearchBookGridItem(
book = book, book = item.book,
shelfState = io.legado.app.domain.model.BookShelfState.NOT_IN_SHELF, shelfState = item.shelfState,
onClick = { viewModel.onBookClick(book, sharedCoverKey) }, onClick = {
viewModel.onBookClick(
item.book,
sharedCoverKey
)
},
onLongClick = onBookLongClick,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey sharedCoverKey = sharedCoverKey
@@ -560,6 +596,7 @@ private fun ModuleList(
onClick = { book, sharedCoverKey -> onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey) viewModel.onBookClick(book, sharedCoverKey)
}, },
onLongClick = onBookLongClick,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
columns = columns, columns = columns,
maxRows = rows, maxRows = rows,
@@ -582,6 +619,7 @@ private fun ModuleList(
onClick = { book, sharedCoverKey -> onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey) viewModel.onBookClick(book, sharedCoverKey)
}, },
onLongClick = onBookLongClick,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
@@ -600,6 +638,7 @@ private fun ModuleList(
onClick = { book, sharedCoverKey -> onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey) viewModel.onBookClick(book, sharedCoverKey)
}, },
onLongClick = onBookLongClick,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
@@ -618,6 +657,7 @@ private fun ModuleList(
onClick = { book, sharedCoverKey -> onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey) viewModel.onBookClick(book, sharedCoverKey)
}, },
onLongClick = onBookLongClick,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
rows = config["layout_rows"]?.toIntOrNull() ?: 4, rows = config["layout_rows"]?.toIntOrNull() ?: 4,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
@@ -637,6 +677,7 @@ private fun ModuleList(
onClick = { book, sharedCoverKey -> onClick = { book, sharedCoverKey ->
viewModel.onBookClick(book, sharedCoverKey) viewModel.onBookClick(book, sharedCoverKey)
}, },
onLongClick = onBookLongClick,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, 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.BookSource
import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.SearchBook
import io.legado.app.data.repository.BookSourceRepository 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.gateway.HomepageModulesGateway
import io.legado.app.domain.model.BookShelfState
import io.legado.app.domain.model.CustomSetItem import io.legado.app.domain.model.CustomSetItem
import io.legado.app.domain.model.HomepageModuleType import io.legado.app.domain.model.HomepageModuleType
import io.legado.app.domain.model.ModuleDef import io.legado.app.domain.model.ModuleDef
import io.legado.app.domain.model.ModuleItem 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.ExploreBooksUseCase
import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
import io.legado.app.domain.usecase.SaveSearchBooksUseCase import io.legado.app.domain.usecase.SaveSearchBooksUseCase
import io.legado.app.help.source.exploreKinds import io.legado.app.help.source.exploreKinds
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
@@ -41,10 +46,15 @@ class HomepageViewModel(
application: Application, application: Application,
private val bookSourceRepository: BookSourceRepository, private val bookSourceRepository: BookSourceRepository,
private val gateway: HomepageModulesGateway, private val gateway: HomepageModulesGateway,
private val searchRepository: SearchRepository,
private val exploreBooksUseCase: ExploreBooksUseCase, private val exploreBooksUseCase: ExploreBooksUseCase,
private val saveSearchBooksUseCase: SaveSearchBooksUseCase, private val saveSearchBooksUseCase: SaveSearchBooksUseCase,
private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
private val addToBookshelfUseCase: AddToBookshelfUseCase,
) : BaseViewModel(application) { ) : BaseViewModel(application) {
private val _bookshelf = MutableStateFlow<Set<BookShelfKey>>(emptySet())
companion object { companion object {
private const val CUSTOM_SET_URL_PREFIX = "custom://" private const val CUSTOM_SET_URL_PREFIX = "custom://"
private const val HOMEPAGE_DEFAULT_GRID_ROWS = 2 private const val HOMEPAGE_DEFAULT_GRID_ROWS = 2
@@ -178,7 +188,7 @@ class HomepageViewModel(
) )
} }
private val displayModulesFlow = combine( private val rawModulesFlow = combine(
orderedModuleDefsFlow, orderedModuleDefsFlow,
_moduleContentStates, _moduleContentStates,
_bookSourcesCache, _bookSourcesCache,
@@ -214,6 +224,33 @@ class HomepageViewModel(
}.toImmutableList() }.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 状态 // 5. 最终 UI 状态
val uiState: StateFlow<HomepageUiState> = combine( val uiState: StateFlow<HomepageUiState> = combine(
displayModulesFlow, displayModulesFlow,
@@ -293,6 +330,12 @@ class HomepageViewModel(
} }
} }
} }
viewModelScope.launch {
searchRepository.bookshelfKeys.collect { keys ->
_bookshelf.value = keys
}
}
} }
private suspend fun syncModulesFromSource(source: BookSource) { private suspend fun syncModulesFromSource(source: BookSource) {
@@ -376,9 +419,20 @@ class HomepageViewModel(
val hasMore = isInfinite(module.type, module.layoutConfig) && books.isNotEmpty() val hasMore = isInfinite(module.type, module.layoutConfig) && books.isNotEmpty()
books to hasMore books to hasMore
}.onSuccess { (books, hasMore) -> }.onSuccess { (books, hasMore) ->
val shelf = _bookshelf.value
_moduleContentStates.update { _moduleContentStates.update {
it + (module.id to ModuleLoadState.Loaded( 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 hasMore = hasMore
)) ))
} }
@@ -406,8 +460,19 @@ class HomepageViewModel(
_moduleContentStates.update { states -> _moduleContentStates.update { states ->
val lastState = val lastState =
states[globalId] as? ModuleLoadState.Loaded ?: return@update states states[globalId] as? ModuleLoadState.Loaded ?: return@update states
val existingUrls = lastState.books.map { it.bookUrl }.toSet() val existingUrls = lastState.books.map { it.book.bookUrl }.toSet()
val deduped = result.books.filter { it.bookUrl !in existingUrls } 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( states + (globalId to ModuleLoadState.Loaded(
books = (lastState.books + deduped).toImmutableList(), books = (lastState.books + deduped).toImmutableList(),
hasMore = deduped.isNotEmpty(), isLoadingMore = false, page = nextPage 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?) { fun onBookClick(book: SearchBook, sharedCoverKey: String?) {
viewModelScope.launch { viewModelScope.launch {
saveSearchBooksUseCase.save(book) 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.card.GlassCard
import io.legado.app.ui.widget.components.divider.PillHeaderDivider 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.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 import io.legado.app.ui.widget.components.text.AppText
data class AddDialogPrefill( data class AddDialogPrefill(
@@ -109,7 +109,7 @@ fun <T> AddCustomModuleDialog(
GlassCard( GlassCard(
containerColor = LegadoTheme.colorScheme.onSheetContent containerColor = LegadoTheme.colorScheme.onSheetContent
) { ) {
DropdownListSettingItem( CompactDropdownSettingItem(
title = stringResource(R.string.homepage_type_label), title = stringResource(R.string.homepage_type_label),
selectedValue = type, selectedValue = type,
displayEntries = typeList.map { it.title }.toTypedArray(), 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.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState 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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.SearchBook 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.bookCoverSharedElementKey
import io.legado.app.ui.main.homepage.HomepageBookItemUi
import io.legado.app.ui.theme.fadingEdge 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.image.cover.CoilBookCover
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun BannerModule( fun BannerModule(
books: ImmutableList<SearchBook>, books: ImmutableList<HomepageBookItemUi>,
onClick: (SearchBook, String?) -> Unit, onClick: (SearchBook, String?) -> Unit,
onLongClick: ((SearchBook, String?) -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -39,11 +50,13 @@ fun BannerModule(
.fadingEdge(lazyListState, gradientWidth = 16.dp), .fadingEdge(lazyListState, gradientWidth = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.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( val sharedCoverKey = bookCoverSharedElementKey(
book.bookUrl, book.bookUrl,
sharedCoverKeySourceId?.let { "$it:$index" } sharedCoverKeySourceId?.let { "$it:$index" }
) )
Box {
CoilBookCover( CoilBookCover(
name = book.name, name = book.name,
author = book.author, author = book.author,
@@ -52,11 +65,43 @@ fun BannerModule(
sourceOrigin = book.origin, sourceOrigin = book.origin,
modifier = Modifier modifier = Modifier
.width(96.dp) .width(96.dp)
.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
}
),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey 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.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape 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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.SearchBook 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.bookCoverSharedElementKey
import io.legado.app.ui.main.homepage.HomepageBookItemUi
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.fadingEdge 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.image.cover.CoilBookCover
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -31,11 +40,12 @@ import kotlinx.collections.immutable.ImmutableList
/** /**
* 卡片模块:横向滚动的推荐卡片 * 卡片模块:横向滚动的推荐卡片
*/ */
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun CardModule( fun CardModule(
books: ImmutableList<SearchBook>, books: ImmutableList<HomepageBookItemUi>,
onClick: (SearchBook, String?) -> Unit, onClick: (SearchBook, String?) -> Unit,
onLongClick: ((SearchBook, String?) -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -50,7 +60,8 @@ fun CardModule(
.fadingEdge(lazyListState, gradientWidth = 8.dp), .fadingEdge(lazyListState, gradientWidth = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.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( val sharedCoverKey = bookCoverSharedElementKey(
book.bookUrl, book.bookUrl,
sharedCoverKeySourceId?.let { "$it:$index" } sharedCoverKeySourceId?.let { "$it:$index" }
@@ -60,8 +71,23 @@ fun CardModule(
.width(120.dp) .width(120.dp)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(LegadoTheme.colorScheme.surfaceContainerLow) .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
}
)
) { ) {
Box {
CoilBookCover( CoilBookCover(
name = book.name, name = book.name,
author = book.author, author = book.author,
@@ -72,9 +98,27 @@ fun CardModule(
.wrapContentWidth(), .wrapContentWidth(),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey 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( AppText(
text = book.name, text = book.name,
style = LegadoTheme.typography.labelLargeEmphasized, style = LegadoTheme.typography.labelLargeEmphasized,
@@ -11,17 +11,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp 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.bookCoverSharedElementKey
import io.legado.app.ui.main.homepage.HomepageBookItemUi
import io.legado.app.ui.widget.components.book.SearchBookGridItem import io.legado.app.ui.widget.components.book.SearchBookGridItem
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun GridModule( fun GridModule(
books: ImmutableList<SearchBook>, books: ImmutableList<HomepageBookItemUi>,
onClick: (SearchBook, String?) -> Unit, onClick: (io.legado.app.data.entities.SearchBook, String?) -> Unit,
onLongClick: ((io.legado.app.data.entities.SearchBook, String?) -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
columns: Int = 3, columns: Int = 3,
maxRows: Int? = null, maxRows: Int? = null,
@@ -43,16 +43,17 @@ fun GridModule(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
for ((columnIndex, book) in row.withIndex()) { for ((columnIndex, item) in row.withIndex()) {
val itemIndex = rowIndex * columns + columnIndex val itemIndex = rowIndex * columns + columnIndex
val sharedCoverKey = bookCoverSharedElementKey( val sharedCoverKey = bookCoverSharedElementKey(
book.bookUrl, item.book.bookUrl,
sharedCoverKeySourceId?.let { "$it:$itemIndex" } sharedCoverKeySourceId?.let { "$it:$itemIndex" }
) )
SearchBookGridItem( SearchBookGridItem(
book = book, book = item.book,
shelfState = BookShelfState.NOT_IN_SHELF, shelfState = item.shelfState,
onClick = { onClick(book, sharedCoverKey) }, onClick = { onClick(item.book, sharedCoverKey) },
onLongClick = onLongClick,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
@@ -3,7 +3,9 @@ package io.legado.app.ui.main.homepage.modules
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope 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.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row 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.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape 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.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.SearchBook 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.bookCoverSharedElementKey
import io.legado.app.ui.main.homepage.HomepageBookItemUi
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.fadingEdge import io.legado.app.ui.theme.fadingEdge
import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.image.cover.CoilBookCover import io.legado.app.ui.widget.components.image.cover.CoilBookCover
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -36,8 +44,9 @@ import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun GridRankingModule( fun GridRankingModule(
books: ImmutableList<SearchBook>, books: ImmutableList<HomepageBookItemUi>,
onClick: (SearchBook, String?) -> Unit, onClick: (SearchBook, String?) -> Unit,
onLongClick: ((SearchBook, String?) -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
rows: Int = 4, rows: Int = 4,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
@@ -71,16 +80,17 @@ fun GridRankingModule(
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 12.dp, horizontal = 12.dp) .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 itemIndex = pageIndex * rows + rowIndex
val sharedCoverKey = bookCoverSharedElementKey( val sharedCoverKey = bookCoverSharedElementKey(
book.bookUrl, item.book.bookUrl,
sharedCoverKeySourceId?.let { "$it:$itemIndex" } sharedCoverKeySourceId?.let { "$it:$itemIndex" }
) )
GridRankingItem( GridRankingItem(
rank = pages.flatten().indexOf(book) + 1, rank = pages.flatten().indexOf(item) + 1,
book = book, item = item,
onClick = { onClick(book, sharedCoverKey) }, onClick = { onClick(item.book, sharedCoverKey) },
onLongClick = onLongClick,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey, sharedCoverKey = sharedCoverKey,
@@ -95,25 +105,42 @@ fun GridRankingModule(
} }
} }
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class)
@Composable @Composable
private fun GridRankingItem( private fun GridRankingItem(
rank: Int, rank: Int,
book: SearchBook, item: HomepageBookItemUi,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: ((SearchBook, String?) -> Unit)? = null,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null, sharedCoverKey: String? = null,
) { ) {
val book = item.book
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick) .combinedClickable(
.padding(vertical = 4.dp, horizontal = 4.dp), 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, verticalAlignment = Alignment.CenterVertically,
) { ) {
// 1. 封面 // 1. 封面
Box {
CoilBookCover( CoilBookCover(
name = book.name, name = book.name,
author = book.author, author = book.author,
@@ -122,9 +149,27 @@ private fun GridRankingItem(
modifier = Modifier.width(48.dp), modifier = Modifier.width(48.dp),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey 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. 排名 // 2. 排名
AppText( AppText(
text = "$rank", text = "$rank",
@@ -4,7 +4,9 @@ import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -29,8 +31,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.R import io.legado.app.R
import io.legado.app.data.entities.SearchBook 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.bookCoverSharedElementKey
import io.legado.app.ui.main.homepage.HomepageBookItemUi
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.book.SearchBookListItem import io.legado.app.ui.widget.components.book.SearchBookListItem
import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.GlassCard
@@ -44,8 +46,9 @@ private const val MAX_COUNT = 20
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun RankingModule( fun RankingModule(
books: ImmutableList<SearchBook>, books: ImmutableList<HomepageBookItemUi>,
onClick: (SearchBook, String?) -> Unit, onClick: (SearchBook, String?) -> Unit,
onLongClick: ((SearchBook, String?) -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -66,15 +69,17 @@ fun RankingModule(
.animateContentSize() .animateContentSize()
) { ) {
// 显示书籍列表 // 显示书籍列表
displayBooks.forEachIndexed { index, book -> displayBooks.forEachIndexed { index, item ->
RankingItem( RankingItem(
rank = index + 1, rank = index + 1,
book = book, book = item.book,
shelfState = item.shelfState,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = bookCoverSharedElementKey( sharedCoverKey = bookCoverSharedElementKey(
book.bookUrl, item.book.bookUrl,
sharedCoverKeySourceId?.let { "$it:$index" } sharedCoverKeySourceId?.let { "$it:$index" }
) )
) )
@@ -114,12 +119,14 @@ fun RankingModule(
} }
} }
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class)
@Composable @Composable
private fun RankingItem( private fun RankingItem(
rank: Int, rank: Int,
book: SearchBook, book: SearchBook,
shelfState: io.legado.app.domain.model.BookShelfState,
onClick: (SearchBook, String?) -> Unit, onClick: (SearchBook, String?) -> Unit,
onLongClick: ((SearchBook, String?) -> Unit)? = null,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null, sharedCoverKey: String? = null,
@@ -127,8 +134,22 @@ private fun RankingItem(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick(book, sharedCoverKey) } .combinedClickable(
.padding(vertical = 4.dp, horizontal = 4.dp), 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, verticalAlignment = Alignment.CenterVertically,
) { ) {
AppText( AppText(
@@ -144,7 +165,7 @@ private fun RankingItem(
) )
SearchBookListItem( SearchBookListItem(
book = book, book = book,
shelfState = BookShelfState.NOT_IN_SHELF, shelfState = shelfState,
onClick = null, onClick = null,
showPadding = false, showPadding = false,
modifier = Modifier.weight(1f), 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.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.SearchBook 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.theme.LegadoTheme
import io.legado.app.ui.widget.components.book.SearchBookTagChip 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.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.image.cover.CoilBookCover
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
@@ -27,24 +36,38 @@ import io.legado.app.ui.widget.components.text.AppText
* 瀑布流单项组件 * 瀑布流单项组件
* 建议直接在 LazyVerticalStaggeredGrid 的 items 中使用,以获得最佳回收性能 * 建议直接在 LazyVerticalStaggeredGrid 的 items 中使用,以获得最佳回收性能
*/ */
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun WaterfallItem( fun WaterfallItem(
book: SearchBook, item: HomepageBookItemUi,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: ((SearchBook, String?) -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedCoverKey: String? = null, sharedCoverKey: String? = null,
) { ) {
val book = item.book
GlassCard( 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 containerColor = LegadoTheme.colorScheme.surfaceContainerLow
) { ) {
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick) .combinedClickable(
onClick = onClick,
onLongClick = onLongClick?.let { cb -> { cb(book, sharedCoverKey) } }
)
) { ) {
Box {
CoilBookCover( CoilBookCover(
name = book.name, name = book.name,
author = book.author, author = book.author,
@@ -55,9 +78,27 @@ fun WaterfallItem(
.fillMaxWidth(), .fillMaxWidth(),
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedCoverKey = sharedCoverKey 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)) Spacer(modifier = Modifier.height(8.dp))
Column( Column(
@@ -5,8 +5,11 @@ import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
@@ -55,15 +58,20 @@ fun rememberImageSeedColor(
configureRequest: ImageRequest.Builder.() -> Unit = {}, configureRequest: ImageRequest.Builder.() -> Unit = {},
): Color? { ): Color? {
val context = LocalContext.current val context = LocalContext.current
val seedColor by produceState<Color?>(initialValue = null, imageLoader, requestKey) { var seedColor by remember { mutableStateOf<Color?>(null) }
value = if (data == null) {
null LaunchedEffect(imageLoader, requestKey) {
if (data == null) {
seedColor = null
} else { } else {
imageLoader.extractSeedColor( val extracted = imageLoader.extractSeedColor(
context = context, context = context,
data = data, data = data,
configureRequest = configureRequest configureRequest = configureRequest
) )
if (extracted != null) {
seedColor = extracted
}
} }
} }
return seedColor return seedColor
@@ -3,7 +3,8 @@ package io.legado.app.ui.widget.components.book
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize 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.image.cover.CoilBookCover
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun SearchBookListItem( fun SearchBookListItem(
book: SearchBook, book: SearchBook,
shelfState: BookShelfState, shelfState: BookShelfState,
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
onLongClick: ((SearchBook, String?) -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showPadding: Boolean = true, showPadding: Boolean = true,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
@@ -55,8 +57,24 @@ fun SearchBookListItem(
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .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(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 Box(modifier = Modifier
.width(72.dp) .width(72.dp)
@@ -166,12 +184,13 @@ fun SearchBookListItem(
} }
} }
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun SearchBookGridItem( fun SearchBookGridItem(
book: SearchBook, book: SearchBook,
shelfState: BookShelfState, shelfState: BookShelfState,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: ((SearchBook, String?) -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -181,7 +200,21 @@ fun SearchBookGridItem(
modifier = modifier modifier = modifier
.width(IntrinsicSize.Min) .width(IntrinsicSize.Min)
.clip(RoundedCornerShape(4.dp)) .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( Box(
modifier = Modifier 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,
)
}
}
}
@@ -114,17 +114,34 @@ fun ExploreKindMultiTypeItem(
state, state,
valueOverride, valueOverride,
onValueChange, onValueChange,
onRunAction,
modifier, modifier,
backgroundColor backgroundColor
) )
} }
ExploreKind.Type.toggle -> { ExploreKind.Type.toggle -> {
ToggleTypeItem(kind, sourceUrl, state, valueOverride, onValueChange, BaseItem) ToggleTypeItem(
kind,
sourceUrl,
state,
valueOverride,
onValueChange,
onRunAction,
BaseItem
)
} }
ExploreKind.Type.select -> { ExploreKind.Type.select -> {
SelectTypeItem(kind, sourceUrl, state, valueOverride, onValueChange, BaseItem) SelectTypeItem(
kind,
sourceUrl,
state,
valueOverride,
onValueChange,
onRunAction,
BaseItem
)
} }
else -> { else -> {
@@ -194,6 +211,7 @@ private fun TextTypeItem(
state: ExploreKindItemState, state: ExploreKindItemState,
valueOverride: String?, valueOverride: String?,
onValueChange: ((String) -> Unit)?, onValueChange: ((String) -> Unit)?,
onRunAction: (() -> Unit)?,
modifier: Modifier, modifier: Modifier,
backgroundColor: Color backgroundColor: Color
) { ) {
@@ -214,7 +232,7 @@ private fun TextTypeItem(
actionJob?.cancel() actionJob?.cancel()
actionJob = scope.launch { actionJob = scope.launch {
delay(600) delay(600)
state.executeAction(kind.action) if (onRunAction != null) onRunAction() else state.executeAction(kind.action)
} }
} }
}, },
@@ -231,6 +249,7 @@ private fun ToggleTypeItem(
state: ExploreKindItemState, state: ExploreKindItemState,
valueOverride: String?, valueOverride: String?,
onValueChange: ((String) -> Unit)?, onValueChange: ((String) -> Unit)?,
onRunAction: (() -> Unit)?,
BaseItem: @Composable (String, () -> Unit) -> Unit BaseItem: @Composable (String, () -> Unit) -> Unit
) { ) {
val chars = remember(kind.chars) { val chars = remember(kind.chars) {
@@ -256,7 +275,7 @@ private fun ToggleTypeItem(
val nextIndex = if (currentIndex < 0) 0 else (currentIndex + 1) % chars.size val nextIndex = if (currentIndex < 0) 0 else (currentIndex + 1) % chars.size
char = chars.getOrElse(nextIndex) { "" } char = chars.getOrElse(nextIndex) { "" }
state.updateValue(char, onValueChange) state.updateValue(char, onValueChange)
state.executeAction(kind.action) if (onRunAction != null) onRunAction() else state.executeAction(kind.action)
} }
BaseItem(text, internalOnClick) BaseItem(text, internalOnClick)
@@ -269,6 +288,7 @@ private fun SelectTypeItem(
state: ExploreKindItemState, state: ExploreKindItemState,
valueOverride: String?, valueOverride: String?,
onValueChange: ((String) -> Unit)?, onValueChange: ((String) -> Unit)?,
onRunAction: (() -> Unit)?,
BaseItem: @Composable (String, () -> Unit) -> Unit BaseItem: @Composable (String, () -> Unit) -> Unit
) { ) {
val chars = remember(kind.chars) { val chars = remember(kind.chars) {
@@ -307,7 +327,7 @@ private fun SelectTypeItem(
if (selected != option) { if (selected != option) {
selected = option selected = option
state.updateValue(option, onValueChange) 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -83,10 +82,16 @@ fun CoilBookCover(
} }
val hasCustomDefault = !randomPath.isNullOrBlank() val hasCustomDefault = !randomPath.isNullOrBlank()
var isOnlineCoverLoaded by remember(path, sharedCoverKey, finalPath) { var isOnlineCoverLoaded by remember(finalPath) {
mutableStateOf(sharedCoverKey != null && finalPath != null) mutableStateOf(sharedCoverKey != null && finalPath != null)
} }
LaunchedEffect(finalPath) {
if (finalPath == null) {
isOnlineCoverLoaded = false
}
}
val transitionRadius = rememberSharedCoverTransitionRadius( val transitionRadius = rememberSharedCoverTransitionRadius(
sharedCoverKey = sharedCoverKey, sharedCoverKey = sharedCoverKey,
radius = radius, radius = radius,
@@ -94,7 +99,6 @@ fun CoilBookCover(
) )
val shape = remember(transitionRadius) { RoundedCornerShape(transitionRadius) } val shape = remember(transitionRadius) { RoundedCornerShape(transitionRadius) }
key(path, sharedCoverKey) {
Box( Box(
modifier = modifier modifier = modifier
.aspectRatio(5f / 7f) .aspectRatio(5f / 7f)
@@ -189,9 +193,9 @@ fun CoilBookCover(
) )
} }
} }
}
} }
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
private fun rememberSharedCoverTransitionRadius( private fun rememberSharedCoverTransitionRadius(
@@ -104,18 +104,18 @@ fun AppModalBottomSheet(
LocalWindowInfo.current.containerSize.height.toDp() * 0.8f LocalWindowInfo.current.containerSize.height.toDp() * 0.8f
} }
MaterialExpressiveTheme(
colorScheme = colorScheme,
typography = Typography(),
motionScheme = MotionScheme.expressive(),
shapes = Shapes()
) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
sheetState = sheetState, sheetState = sheetState,
containerColor = sheetContainerColor, containerColor = sheetContainerColor,
contentColor = sheetContentColor, contentColor = sheetContentColor,
dragHandle = { BottomSheetDefaults.DragHandle(color = sheetDragHandleColor) } dragHandle = { BottomSheetDefaults.DragHandle(color = sheetDragHandleColor) }
) {
MaterialExpressiveTheme(
colorScheme = colorScheme,
typography = Typography(),
motionScheme = MotionScheme.expressive(),
shapes = Shapes()
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -976,6 +976,7 @@
<string name="pref_anti_alias_summary">绘制图片时抗锯齿</string> <string name="pref_anti_alias_summary">绘制图片时抗锯齿</string>
<string name="upload_url">上传 URL</string> <string name="upload_url">上传 URL</string>
<string name="download_url_rule">下载 URL 规则</string> <string name="download_url_rule">下载 URL 规则</string>
<string name="related_books">关联书籍(relatedBooks</string>
<string name="clipboard_empty">剪贴板没有内容</string> <string name="clipboard_empty">剪贴板没有内容</string>
<string name="clipboard_empty_or_invalid">剪贴板为空或格式不正确</string> <string name="clipboard_empty_or_invalid">剪贴板为空或格式不正确</string>
<string name="invalid_format">格式不正确</string> <string name="invalid_format">格式不正确</string>
@@ -1620,6 +1621,11 @@
<string name="underline_padding">离字间距</string> <string name="underline_padding">离字间距</string>
<string name="layout_setting">布局设置</string> <string name="layout_setting">布局设置</string>
<string name="cover_info_orientation">封面信息方向</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="rule_segment">规则</string>
<string name="tool_bar_style">工具栏样式</string> <string name="tool_bar_style">工具栏样式</string>
<string name="split_title">标题分段</string> <string name="split_title">标题分段</string>
+6
View File
@@ -1007,6 +1007,7 @@
<string name="pref_anti_alias_summary">Anti-Aliasing when draw picture</string> <string name="pref_anti_alias_summary">Anti-Aliasing when draw picture</string>
<string name="upload_url">upload URL</string> <string name="upload_url">upload URL</string>
<string name="download_url_rule">downloadUrlRule(downloadUrls)</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">Clipboard is empty</string>
<string name="clipboard_empty_or_invalid">Clipboard is empty or has an invalid format</string> <string name="clipboard_empty_or_invalid">Clipboard is empty or has an invalid format</string>
<string name="invalid_format">Invalid format</string> <string name="invalid_format">Invalid format</string>
@@ -1655,6 +1656,11 @@
<!-- Missing strings --> <!-- Missing strings -->
<string name="cover_info_orientation">Cover orientation</string> <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="follow_read_background">Follow background</string>
<string name="heading_spacing">Heading spacing</string> <string name="heading_spacing">Heading spacing</string>
<string name="layout_setting">Layout settings</string> <string name="layout_setting">Layout settings</string>