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,36 +3,45 @@ package com.huaga.life_echo.ui.components.memoir
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
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.ChapterContentDto
|
||||||
|
import com.huaga.life_echo.network.models.ChapterImageDto
|
||||||
import com.huaga.life_echo.ui.components.common.MarkdownText
|
import com.huaga.life_echo.ui.components.common.MarkdownText
|
||||||
import com.huaga.life_echo.ui.theme.AppTypography
|
import com.huaga.life_echo.ui.theme.AppTypography
|
||||||
import com.huaga.life_echo.ui.theme.LightPurple
|
import com.huaga.life_echo.ui.theme.LightPurple
|
||||||
import com.huaga.life_echo.utils.TextUtils
|
import com.huaga.life_echo.utils.TextUtils
|
||||||
|
|
||||||
/**
|
|
||||||
* 章节阅读视图组件
|
|
||||||
* 支持 Markdown 格式渲染
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChapterReadingView(
|
fun ChapterReadingView(
|
||||||
chapter: ChapterContentDto,
|
chapter: ChapterContentDto,
|
||||||
modifier: Modifier = Modifier
|
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(
|
LazyColumn(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp)
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp)
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
// 章节编号
|
|
||||||
Text(
|
Text(
|
||||||
text = "第${chapterOrderToDisplayIndex(chapter.orderIndex)}章",
|
text = "第${chapterOrderToDisplayIndex(chapter.orderIndex)}章",
|
||||||
fontSize = AppTypography.bodyMedium,
|
fontSize = AppTypography.bodyMedium,
|
||||||
@@ -40,7 +49,6 @@ fun ChapterReadingView(
|
|||||||
modifier = Modifier.padding(bottom = 4.dp)
|
modifier = Modifier.padding(bottom = 4.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 章节标题(支持多行换行,用 lineHeightXLoose 保证两行时行距更舒适)
|
|
||||||
Text(
|
Text(
|
||||||
text = chapter.title,
|
text = chapter.title,
|
||||||
fontSize = AppTypography.headingMedium,
|
fontSize = AppTypography.headingMedium,
|
||||||
@@ -49,24 +57,37 @@ fun ChapterReadingView(
|
|||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.padding(bottom = 24.dp)
|
modifier = Modifier.padding(bottom = 24.dp)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val processedContent = TextUtils.addParagraphFirstLineIndent(
|
items(blocks.size) { index ->
|
||||||
TextUtils.removeInlineChapterHeadings(
|
when (val block = blocks[index]) {
|
||||||
TextUtils.removeImagePlaceholders(
|
is MemoirContentBlock.Text -> {
|
||||||
chapter.content,
|
val processedContent = TextUtils.addParagraphFirstLineIndent(
|
||||||
hasImages = chapter.images.isNotEmpty()
|
TextUtils.removeInlineChapterHeadings(block.content)
|
||||||
)
|
)
|
||||||
)
|
MarkdownText(
|
||||||
)
|
content = processedContent,
|
||||||
MarkdownText(
|
modifier = Modifier.padding(bottom = 16.dp),
|
||||||
content = processedContent,
|
textColor = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.padding(bottom = 16.dp),
|
fontSize = AppTypography.titleMedium,
|
||||||
textColor = MaterialTheme.colorScheme.onSurface,
|
lineHeight = AppTypography.lineHeightXLoose
|
||||||
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 ->
|
chapter.quotes.forEach { quote ->
|
||||||
QuoteBlock(text = quote)
|
QuoteBlock(text = quote)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -75,9 +96,6 @@ fun ChapterReadingView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 引用样式组件
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun QuoteBlock(
|
fun QuoteBlock(
|
||||||
text: String,
|
text: String,
|
||||||
|
|||||||
@@ -8,31 +8,36 @@ import androidx.compose.material3.FloatingActionButton
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
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.ChapterContentDto
|
||||||
|
import com.huaga.life_echo.network.models.ChapterImageDto
|
||||||
import com.huaga.life_echo.ui.components.common.MarkdownText
|
import com.huaga.life_echo.ui.components.common.MarkdownText
|
||||||
import com.huaga.life_echo.ui.icons.AppIcons
|
import com.huaga.life_echo.ui.icons.AppIcons
|
||||||
import com.huaga.life_echo.ui.theme.AppTypography
|
import com.huaga.life_echo.ui.theme.AppTypography
|
||||||
import com.huaga.life_echo.ui.theme.LightPurple
|
import com.huaga.life_echo.ui.theme.LightPurple
|
||||||
import com.huaga.life_echo.utils.TextUtils
|
import com.huaga.life_echo.utils.TextUtils
|
||||||
|
|
||||||
/**
|
|
||||||
* 全文阅读视图组件
|
|
||||||
* 支持 Markdown 格式渲染
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FullTextReadingView(
|
fun FullTextReadingView(
|
||||||
chapters: List<ChapterContentDto>,
|
chapters: List<ChapterContentDto>,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onBackClick: () -> Unit = {}
|
onBackClick: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
|
var viewerImage by remember { mutableStateOf<ChapterImageDto?>(null) }
|
||||||
|
|
||||||
|
if (viewerImage != null) {
|
||||||
|
MemoirImageViewerDialog(
|
||||||
|
image = viewerImage!!,
|
||||||
|
onDismiss = { viewerImage = null }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -42,13 +47,11 @@ fun FullTextReadingView(
|
|||||||
) {
|
) {
|
||||||
chapters.sortedBy { it.orderIndex }.forEachIndexed { index, chapter ->
|
chapters.sortedBy { it.orderIndex }.forEachIndexed { index, chapter ->
|
||||||
item(key = chapter.id) {
|
item(key = chapter.id) {
|
||||||
// 章节分隔样式
|
|
||||||
if (index > 0) {
|
if (index > 0) {
|
||||||
ChapterDivider()
|
ChapterDivider()
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 章节标题
|
|
||||||
Text(
|
Text(
|
||||||
text = "第${chapterOrderToDisplayIndex(chapter.orderIndex)}章",
|
text = "第${chapterOrderToDisplayIndex(chapter.orderIndex)}章",
|
||||||
fontSize = AppTypography.bodyMedium,
|
fontSize = AppTypography.bodyMedium,
|
||||||
@@ -57,7 +60,6 @@ fun FullTextReadingView(
|
|||||||
modifier = Modifier.padding(bottom = 8.dp)
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 与单章阅读一致:两行标题时行距更舒适
|
|
||||||
Text(
|
Text(
|
||||||
text = chapter.title,
|
text = chapter.title,
|
||||||
fontSize = AppTypography.headingMedium,
|
fontSize = AppTypography.headingMedium,
|
||||||
@@ -67,23 +69,38 @@ fun FullTextReadingView(
|
|||||||
modifier = Modifier.padding(bottom = 16.dp)
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
val processedContent = TextUtils.addParagraphFirstLineIndent(
|
val blocks = remember(chapter.content, chapter.images) {
|
||||||
TextUtils.removeInlineChapterHeadings(
|
splitMemoirContent(chapter.content, chapter.images)
|
||||||
TextUtils.removeImagePlaceholders(
|
}
|
||||||
chapter.content,
|
|
||||||
hasImages = chapter.images.isNotEmpty()
|
blocks.forEach { block ->
|
||||||
)
|
when (block) {
|
||||||
)
|
is MemoirContentBlock.Text -> {
|
||||||
)
|
val processedContent = TextUtils.addParagraphFirstLineIndent(
|
||||||
MarkdownText(
|
TextUtils.removeInlineChapterHeadings(block.content)
|
||||||
content = processedContent,
|
)
|
||||||
modifier = Modifier.padding(bottom = 16.dp),
|
MarkdownText(
|
||||||
textColor = MaterialTheme.colorScheme.onSurface,
|
content = processedContent,
|
||||||
fontSize = AppTypography.titleMedium,
|
modifier = Modifier.padding(bottom = 16.dp),
|
||||||
lineHeight = AppTypography.lineHeightXLoose
|
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 ->
|
chapter.quotes.forEach { quote ->
|
||||||
QuoteBlock(text = quote)
|
QuoteBlock(text = quote)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -94,7 +111,6 @@ fun FullTextReadingView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回按钮(浮动)
|
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = onBackClick,
|
onClick = onBackClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -112,9 +128,6 @@ fun FullTextReadingView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 章节分隔样式组件
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChapterDivider(
|
fun ChapterDivider(
|
||||||
modifier: Modifier = Modifier
|
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,32 +85,22 @@ object TextUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 移除图片占位符
|
* 移除图片占位符(兜底工具,用于卡片摘要等不需要渲染图片的场景)
|
||||||
* 如果没有图片,移除所有{{{{IMAGE:...}}}}和{{IMAGE:...}}格式的占位符
|
* 阅读页应使用 splitMemoirContent() 按块渲染
|
||||||
* 同时确保每个段落之间有空行
|
|
||||||
* @param content 原始内容
|
* @param content 原始内容
|
||||||
* @param hasImages 是否有图片
|
* @param hasImages 已弃用,始终移除占位符
|
||||||
* @return 处理后的内容
|
* @return 处理后的内容
|
||||||
*/
|
*/
|
||||||
|
@Suppress("UNUSED_PARAMETER")
|
||||||
fun removeImagePlaceholders(content: String?, hasImages: Boolean = false): String {
|
fun removeImagePlaceholders(content: String?, hasImages: Boolean = false): String {
|
||||||
if (content.isNullOrBlank()) return ""
|
if (content.isNullOrBlank()) return ""
|
||||||
|
|
||||||
var processed = if (!hasImages) {
|
var processed = content
|
||||||
// 移除所有{{{{IMAGE:...}}}}格式的占位符(四个大括号)
|
.replace(Regex("\\{\\{\\{\\{IMAGE:[^}]+\\}\\}\\}\\}"), "")
|
||||||
content.replace(Regex("\\{\\{\\{\\{IMAGE:[^}]+\\}\\}\\}\\}"), "")
|
.replace(Regex("\\{\\{IMAGE:[^}]+\\}\\}"), "")
|
||||||
// 移除所有{{IMAGE:...}}格式的占位符(两个大括号)
|
.trim()
|
||||||
.replace(Regex("\\{\\{IMAGE:[^}]+\\}\\}"), "")
|
|
||||||
.trim()
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保每个段落之间有空行
|
|
||||||
// 将单个换行符(非空行后跟非空行)替换为双换行符
|
|
||||||
// 但保留已有的双换行符或更多换行符
|
|
||||||
processed = processed.replace(Regex("([^\n\\r])\\r?\\n([^\n\\r])"), "$1\n\n$2")
|
processed = processed.replace(Regex("([^\n\\r])\\r?\\n([^\n\\r])"), "$1\n\n$2")
|
||||||
|
|
||||||
// 清理多余的空行(连续3个或以上的换行符替换为2个)
|
|
||||||
processed = processed.replace(Regex("\n{3,}"), "\n\n")
|
processed = processed.replace(Regex("\n{3,}"), "\n\n")
|
||||||
|
|
||||||
return processed.trim()
|
return processed.trim()
|
||||||
|
|||||||
Reference in New Issue
Block a user