feat(android): render memoir images in reading views with loading and failure states
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
package com.huaga.life_echo.ui.components.memoir
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import com.huaga.life_echo.network.models.ChapterContentDto
|
||||
import com.huaga.life_echo.network.models.ChapterImageDto
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class ChapterReadingImageBlocksTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun chapterReadingView_showsLoadingCard_forProcessingImage() {
|
||||
val chapter = ChapterContentDto(
|
||||
id = "chapter-1",
|
||||
title = "童年的夏天",
|
||||
content = "那条路我一直记得。\n\n{{{{IMAGE:南方小镇的青石板路}}}}",
|
||||
orderIndex = 0,
|
||||
status = "completed",
|
||||
category = "childhood",
|
||||
pageCount = null,
|
||||
updatedAt = 0L,
|
||||
quotes = emptyList(),
|
||||
images = listOf(
|
||||
ChapterImageDto(
|
||||
index = 0,
|
||||
placeholder = "{{{{IMAGE:南方小镇的青石板路}}}}",
|
||||
description = "南方小镇的青石板路",
|
||||
prompt = null,
|
||||
url = null,
|
||||
status = "processing",
|
||||
provider = "liblib",
|
||||
style = "watercolor",
|
||||
size = "1024x1024",
|
||||
error = null,
|
||||
created_at = null,
|
||||
updated_at = null,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
composeRule.setContent { ChapterReadingView(chapter = chapter) }
|
||||
|
||||
composeRule.onNodeWithTag("memoir-image-loading-0").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chapterReadingView_showsFailureCard_forFailedImage_withoutRawPlaceholderText() {
|
||||
val chapter = ChapterContentDto(
|
||||
id = "chapter-1",
|
||||
title = "童年的夏天",
|
||||
content = "那条路我一直记得。\n\n{{{{IMAGE:南方小镇的青石板路}}}}",
|
||||
orderIndex = 0,
|
||||
status = "completed",
|
||||
category = "childhood",
|
||||
pageCount = null,
|
||||
updatedAt = 0L,
|
||||
quotes = emptyList(),
|
||||
images = listOf(
|
||||
ChapterImageDto(
|
||||
index = 0,
|
||||
placeholder = "{{{{IMAGE:南方小镇的青石板路}}}}",
|
||||
description = "南方小镇的青石板路",
|
||||
prompt = null,
|
||||
url = null,
|
||||
status = "failed",
|
||||
provider = "liblib",
|
||||
style = "watercolor",
|
||||
size = "1024x1024",
|
||||
error = "provider timeout",
|
||||
created_at = null,
|
||||
updated_at = null,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
composeRule.setContent { ChapterReadingView(chapter = chapter) }
|
||||
|
||||
composeRule.onNodeWithTag("memoir-image-error-0").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -3,44 +3,52 @@ package com.huaga.life_echo.ui.components.memoir
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.huaga.life_echo.network.models.ChapterContentDto
|
||||
import com.huaga.life_echo.network.models.ChapterImageDto
|
||||
import com.huaga.life_echo.ui.components.common.MarkdownText
|
||||
import com.huaga.life_echo.ui.theme.AppTypography
|
||||
import com.huaga.life_echo.ui.theme.LightPurple
|
||||
import com.huaga.life_echo.utils.TextUtils
|
||||
|
||||
/**
|
||||
* 章节阅读视图组件
|
||||
* 支持 Markdown 格式渲染
|
||||
*/
|
||||
@Composable
|
||||
fun ChapterReadingView(
|
||||
chapter: ChapterContentDto,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val blocks = remember(chapter.content, chapter.images) {
|
||||
splitMemoirContent(chapter.content, chapter.images)
|
||||
}
|
||||
|
||||
var viewerImage by remember { mutableStateOf<ChapterImageDto?>(null) }
|
||||
|
||||
if (viewerImage != null) {
|
||||
MemoirImageViewerDialog(
|
||||
image = viewerImage!!,
|
||||
onDismiss = { viewerImage = null }
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp)
|
||||
) {
|
||||
item {
|
||||
// 章节编号
|
||||
Text(
|
||||
text = "第${chapterOrderToDisplayIndex(chapter.orderIndex)}章",
|
||||
fontSize = AppTypography.bodyMedium,
|
||||
color = LightPurple,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
|
||||
// 章节标题(支持多行换行,用 lineHeightXLoose 保证两行时行距更舒适)
|
||||
|
||||
Text(
|
||||
text = chapter.title,
|
||||
fontSize = AppTypography.headingMedium,
|
||||
@@ -49,24 +57,37 @@ fun ChapterReadingView(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
val processedContent = TextUtils.addParagraphFirstLineIndent(
|
||||
TextUtils.removeInlineChapterHeadings(
|
||||
TextUtils.removeImagePlaceholders(
|
||||
chapter.content,
|
||||
hasImages = chapter.images.isNotEmpty()
|
||||
}
|
||||
|
||||
items(blocks.size) { index ->
|
||||
when (val block = blocks[index]) {
|
||||
is MemoirContentBlock.Text -> {
|
||||
val processedContent = TextUtils.addParagraphFirstLineIndent(
|
||||
TextUtils.removeInlineChapterHeadings(block.content)
|
||||
)
|
||||
)
|
||||
)
|
||||
MarkdownText(
|
||||
content = processedContent,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
textColor = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = AppTypography.titleMedium,
|
||||
lineHeight = AppTypography.lineHeightXLoose
|
||||
)
|
||||
|
||||
// 引用块
|
||||
MarkdownText(
|
||||
content = processedContent,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
textColor = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = AppTypography.titleMedium,
|
||||
lineHeight = AppTypography.lineHeightXLoose
|
||||
)
|
||||
}
|
||||
is MemoirContentBlock.Image -> {
|
||||
MemoirInlineImage(
|
||||
image = block.image,
|
||||
onClick = {
|
||||
if (block.image.status == "completed" && !block.image.url.isNullOrBlank()) {
|
||||
viewerImage = block.image
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
chapter.quotes.forEach { quote ->
|
||||
QuoteBlock(text = quote)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
@@ -75,9 +96,6 @@ fun ChapterReadingView(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 引用样式组件
|
||||
*/
|
||||
@Composable
|
||||
fun QuoteBlock(
|
||||
text: String,
|
||||
|
||||
@@ -8,31 +8,36 @@ import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.huaga.life_echo.network.models.ChapterContentDto
|
||||
import com.huaga.life_echo.network.models.ChapterImageDto
|
||||
import com.huaga.life_echo.ui.components.common.MarkdownText
|
||||
import com.huaga.life_echo.ui.icons.AppIcons
|
||||
import com.huaga.life_echo.ui.theme.AppTypography
|
||||
import com.huaga.life_echo.ui.theme.LightPurple
|
||||
import com.huaga.life_echo.utils.TextUtils
|
||||
|
||||
/**
|
||||
* 全文阅读视图组件
|
||||
* 支持 Markdown 格式渲染
|
||||
*/
|
||||
@Composable
|
||||
fun FullTextReadingView(
|
||||
chapters: List<ChapterContentDto>,
|
||||
modifier: Modifier = Modifier,
|
||||
onBackClick: () -> Unit = {}
|
||||
) {
|
||||
var viewerImage by remember { mutableStateOf<ChapterImageDto?>(null) }
|
||||
|
||||
if (viewerImage != null) {
|
||||
MemoirImageViewerDialog(
|
||||
image = viewerImage!!,
|
||||
onDismiss = { viewerImage = null }
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
@@ -42,13 +47,11 @@ fun FullTextReadingView(
|
||||
) {
|
||||
chapters.sortedBy { it.orderIndex }.forEachIndexed { index, chapter ->
|
||||
item(key = chapter.id) {
|
||||
// 章节分隔样式
|
||||
if (index > 0) {
|
||||
ChapterDivider()
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
|
||||
// 章节标题
|
||||
|
||||
Text(
|
||||
text = "第${chapterOrderToDisplayIndex(chapter.orderIndex)}章",
|
||||
fontSize = AppTypography.bodyMedium,
|
||||
@@ -56,8 +59,7 @@ fun FullTextReadingView(
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
// 与单章阅读一致:两行标题时行距更舒适
|
||||
|
||||
Text(
|
||||
text = chapter.title,
|
||||
fontSize = AppTypography.headingMedium,
|
||||
@@ -66,35 +68,49 @@ fun FullTextReadingView(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
val processedContent = TextUtils.addParagraphFirstLineIndent(
|
||||
TextUtils.removeInlineChapterHeadings(
|
||||
TextUtils.removeImagePlaceholders(
|
||||
chapter.content,
|
||||
hasImages = chapter.images.isNotEmpty()
|
||||
)
|
||||
)
|
||||
)
|
||||
MarkdownText(
|
||||
content = processedContent,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
textColor = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = AppTypography.titleMedium,
|
||||
lineHeight = AppTypography.lineHeightXLoose
|
||||
)
|
||||
|
||||
// 引用块
|
||||
|
||||
val blocks = remember(chapter.content, chapter.images) {
|
||||
splitMemoirContent(chapter.content, chapter.images)
|
||||
}
|
||||
|
||||
blocks.forEach { block ->
|
||||
when (block) {
|
||||
is MemoirContentBlock.Text -> {
|
||||
val processedContent = TextUtils.addParagraphFirstLineIndent(
|
||||
TextUtils.removeInlineChapterHeadings(block.content)
|
||||
)
|
||||
MarkdownText(
|
||||
content = processedContent,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
textColor = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = AppTypography.titleMedium,
|
||||
lineHeight = AppTypography.lineHeightXLoose
|
||||
)
|
||||
}
|
||||
is MemoirContentBlock.Image -> {
|
||||
MemoirInlineImage(
|
||||
image = block.image,
|
||||
onClick = {
|
||||
if (block.image.status == "completed" && !block.image.url.isNullOrBlank()) {
|
||||
viewerImage = block.image
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chapter.quotes.forEach { quote ->
|
||||
QuoteBlock(text = quote)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 返回按钮(浮动)
|
||||
|
||||
FloatingActionButton(
|
||||
onClick = onBackClick,
|
||||
modifier = Modifier
|
||||
@@ -112,9 +128,6 @@ fun FullTextReadingView(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 章节分隔样式组件
|
||||
*/
|
||||
@Composable
|
||||
fun ChapterDivider(
|
||||
modifier: Modifier = Modifier
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.huaga.life_echo.ui.components.memoir
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.compose.AsyncImage
|
||||
import com.huaga.life_echo.network.models.ChapterImageDto
|
||||
import com.huaga.life_echo.ui.theme.AppTypography
|
||||
|
||||
@Composable
|
||||
fun MemoirImageViewerDialog(
|
||||
image: ChapterImageDto,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.9f))
|
||||
.clickable(onClick = onDismiss),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = image.url,
|
||||
contentDescription = image.description,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
)
|
||||
if (image.description.isNotBlank()) {
|
||||
Text(
|
||||
text = image.description,
|
||||
fontSize = AppTypography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.huaga.life_echo.ui.components.memoir
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.huaga.life_echo.network.models.ChapterImageDto
|
||||
import com.huaga.life_echo.ui.theme.AppTypography
|
||||
import com.huaga.life_echo.ui.theme.LightPurple
|
||||
import com.huaga.life_echo.ui.theme.SlatePurple
|
||||
|
||||
@Composable
|
||||
fun MemoirInlineImage(
|
||||
image: ChapterImageDto,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (image.status) {
|
||||
"completed" -> AsyncImage(
|
||||
model = image.url,
|
||||
contentDescription = image.description,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.testTag("memoir-image-${image.index}")
|
||||
)
|
||||
"pending", "processing" -> {
|
||||
val transition = rememberInfiniteTransition(label = "shimmer")
|
||||
val alpha by transition.animateFloat(
|
||||
initialValue = 0.10f,
|
||||
targetValue = 0.25f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(800),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "shimmer-alpha"
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(220.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(LightPurple.copy(alpha = alpha))
|
||||
.testTag("memoir-image-loading-${image.index}"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "图片生成中…",
|
||||
fontSize = AppTypography.bodyMedium,
|
||||
color = SlatePurple.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
"failed" -> Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(LightPurple.copy(alpha = 0.10f))
|
||||
.padding(16.dp)
|
||||
.testTag("memoir-image-error-${image.index}")
|
||||
) {
|
||||
Text(
|
||||
text = "图片生成失败",
|
||||
fontSize = AppTypography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = SlatePurple,
|
||||
)
|
||||
if (image.description.isNotBlank()) {
|
||||
Text(
|
||||
text = image.description,
|
||||
fontSize = AppTypography.captionMedium,
|
||||
color = SlatePurple.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
@@ -85,34 +85,24 @@ object TextUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除图片占位符
|
||||
* 如果没有图片,移除所有{{{{IMAGE:...}}}}和{{IMAGE:...}}格式的占位符
|
||||
* 同时确保每个段落之间有空行
|
||||
* 移除图片占位符(兜底工具,用于卡片摘要等不需要渲染图片的场景)
|
||||
* 阅读页应使用 splitMemoirContent() 按块渲染
|
||||
* @param content 原始内容
|
||||
* @param hasImages 是否有图片
|
||||
* @param hasImages 已弃用,始终移除占位符
|
||||
* @return 处理后的内容
|
||||
*/
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun removeImagePlaceholders(content: String?, hasImages: Boolean = false): String {
|
||||
if (content.isNullOrBlank()) return ""
|
||||
|
||||
var processed = if (!hasImages) {
|
||||
// 移除所有{{{{IMAGE:...}}}}格式的占位符(四个大括号)
|
||||
content.replace(Regex("\\{\\{\\{\\{IMAGE:[^}]+\\}\\}\\}\\}"), "")
|
||||
// 移除所有{{IMAGE:...}}格式的占位符(两个大括号)
|
||||
.replace(Regex("\\{\\{IMAGE:[^}]+\\}\\}"), "")
|
||||
.trim()
|
||||
} else {
|
||||
content
|
||||
}
|
||||
|
||||
// 确保每个段落之间有空行
|
||||
// 将单个换行符(非空行后跟非空行)替换为双换行符
|
||||
// 但保留已有的双换行符或更多换行符
|
||||
|
||||
var processed = content
|
||||
.replace(Regex("\\{\\{\\{\\{IMAGE:[^}]+\\}\\}\\}\\}"), "")
|
||||
.replace(Regex("\\{\\{IMAGE:[^}]+\\}\\}"), "")
|
||||
.trim()
|
||||
|
||||
processed = processed.replace(Regex("([^\n\\r])\\r?\\n([^\n\\r])"), "$1\n\n$2")
|
||||
|
||||
// 清理多余的空行(连续3个或以上的换行符替换为2个)
|
||||
processed = processed.replace(Regex("\n{3,}"), "\n\n")
|
||||
|
||||
|
||||
return processed.trim()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user