From 830b6efc397bdf7278e20ac667b32bc53d10e9dd Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 10 Mar 2026 16:09:32 +0800 Subject: [PATCH] feat(android): render memoir images in reading views with loading and failure states Made-with: Cursor --- .../memoir/ChapterReadingImageBlocksTest.kt | 86 +++++++++++++++ .../components/memoir/ChapterReadingView.kt | 76 ++++++++----- .../components/memoir/FullTextReadingView.kt | 83 ++++++++------ .../memoir/MemoirImageViewerDialog.kt | 57 ++++++++++ .../ui/components/memoir/MemoirInlineImage.kt | 101 ++++++++++++++++++ .../com/huaga/life_echo/utils/TextUtils.kt | 32 ++---- 6 files changed, 350 insertions(+), 85 deletions(-) create mode 100644 app-android/app/src/androidTest/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingImageBlocksTest.kt create mode 100644 app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirImageViewerDialog.kt create mode 100644 app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt diff --git a/app-android/app/src/androidTest/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingImageBlocksTest.kt b/app-android/app/src/androidTest/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingImageBlocksTest.kt new file mode 100644 index 0000000..e0036d1 --- /dev/null +++ b/app-android/app/src/androidTest/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingImageBlocksTest.kt @@ -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() + + @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() + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingView.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingView.kt index 49baf5b..d47e77f 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingView.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingView.kt @@ -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(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, diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/FullTextReadingView.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/FullTextReadingView.kt index 6f6889f..9fe5675 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/FullTextReadingView.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/FullTextReadingView.kt @@ -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, modifier: Modifier = Modifier, onBackClick: () -> Unit = {} ) { + var viewerImage by remember { mutableStateOf(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 diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirImageViewerDialog.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirImageViewerDialog.kt new file mode 100644 index 0000000..2f368fa --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirImageViewerDialog.kt @@ -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), + ) + } + } + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt new file mode 100644 index 0000000..c8fc0ab --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt @@ -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 + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/utils/TextUtils.kt b/app-android/app/src/main/java/com/huaga/life_echo/utils/TextUtils.kt index f53a07f..f82bef8 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/utils/TextUtils.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/utils/TextUtils.kt @@ -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() }