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