feat(android): render memoir images in reading views with loading and failure states

Made-with: Cursor
This commit is contained in:
Kevin
2026-03-10 16:09:32 +08:00
parent 2cecbd84ce
commit 830b6efc39
6 changed files with 350 additions and 85 deletions

View File

@@ -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()
}
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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),
)
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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()
}