Fix memoir image delivery and Android rendering

This commit is contained in:
Kevin
2026-03-11 10:06:12 +08:00
parent 0970cb7408
commit a76cf8da18
23 changed files with 537 additions and 51 deletions

View File

@@ -18,7 +18,9 @@ fun splitMemoirContent(content: String, images: List<ChapterImageDto>): List<Mem
val blocks = mutableListOf<MemoirContentBlock>()
images.sortedBy { it.index }.forEach { image ->
val placeholder = image.placeholder
if (!remaining.contains(placeholder)) return@forEach
if (!remaining.contains(placeholder)) {
return@forEach
}
val parts = remaining.split(placeholder, limit = 2)
val before = stripImagePlaceholders(parts.first())
if (before.isNotBlank()) blocks += MemoirContentBlock.Text(before)

View File

@@ -8,8 +8,8 @@ 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.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -20,12 +20,53 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.compose.SubcomposeAsyncImage
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
internal fun memoirImageAspectRatio(size: String?): Float {
val match = Regex("""^\s*(\d+)\s*x\s*(\d+)\s*$""").matchEntire(size.orEmpty()) ?: return 1f
val width = match.groupValues[1].toFloatOrNull() ?: return 1f
val height = match.groupValues[2].toFloatOrNull() ?: return 1f
if (width <= 0f || height <= 0f) return 1f
return width / height
}
@Composable
private fun MemoirImageLoadingPlaceholder(
image: ChapterImageDto,
modifier: Modifier = Modifier,
text: String = "图片生成中…",
) {
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()
.aspectRatio(memoirImageAspectRatio(image.size))
.clip(RoundedCornerShape(16.dp))
.background(LightPurple.copy(alpha = alpha))
.testTag("memoir-image-loading-${image.index}"),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
fontSize = AppTypography.bodyMedium,
color = SlatePurple.copy(alpha = 0.6f),
)
}
}
@Composable
fun MemoirInlineImage(
image: ChapterImageDto,
@@ -33,43 +74,36 @@ fun MemoirInlineImage(
modifier: Modifier = Modifier,
) {
when (image.status) {
"completed" -> AsyncImage(
"completed" -> SubcomposeAsyncImage(
model = image.url,
contentDescription = image.description,
contentScale = ContentScale.FillWidth,
loading = {
MemoirImageLoadingPlaceholder(
image = image,
modifier = modifier,
text = "图片加载中…",
)
},
error = {
MemoirImageLoadingPlaceholder(
image = image,
modifier = modifier,
text = "图片暂不可用",
)
},
modifier = modifier
.fillMaxWidth()
.aspectRatio(memoirImageAspectRatio(image.size))
.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),
)
}
}
"pending", "processing" -> MemoirImageLoadingPlaceholder(
image = image,
modifier = modifier,
text = "图片生成中…",
)
else -> Unit
}
}

View File

@@ -0,0 +1,13 @@
package com.huaga.life_echo.ui.screens
import com.huaga.life_echo.network.models.ChapterDto
internal const val MEMOIR_IMAGE_POLL_INTERVAL_MS = 3_000L
internal fun hasPendingMemoirImages(chapters: List<ChapterDto>): Boolean {
return chapters.any { chapter ->
chapter.images.any { image ->
image.status == "pending" || image.status == "processing"
}
}
}

View File

@@ -35,6 +35,7 @@ import com.huaga.life_echo.ui.settings.AppSettings
import com.huaga.life_echo.ui.theme.*
import com.huaga.life_echo.ui.viewmodel.MyMemoirViewModel
import com.huaga.life_echo.ui.viewmodel.ViewModelFactory
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
@@ -164,6 +165,22 @@ fun MyMemoirScreen(
val contentChapters = remember(chapterDtos) {
chapterDtos.filter { it.content.isNotBlank() }
}
val shouldPollChapterImages = remember(chapterDtos) {
hasPendingMemoirImages(chapterDtos)
}
val latestIsLoading by rememberUpdatedState(isLoading)
val latestIsRefreshing by rememberUpdatedState(isRefreshing)
LaunchedEffect(shouldPollChapterImages) {
if (!shouldPollChapterImages) return@LaunchedEffect
while (true) {
delay(MEMOIR_IMAGE_POLL_INTERVAL_MS)
if (!latestIsLoading && !latestIsRefreshing) {
viewModel.refreshChapters()
}
}
}
AnimatedContent(
targetState = showFullTextReading || selectedChapter != null,

View File

@@ -205,4 +205,3 @@ class MyMemoirViewModel(
}
}
}

View File

@@ -38,7 +38,6 @@ class ChatInputPresentationTest {
)
assertEquals(ChatInputLeadingAction.SWITCH_TO_VOICE, presentation.leadingAction)
assertTrue(presentation.showEmojiAction)
assertEquals(ChatInputTrailingAction.ADD, presentation.trailingAction)
}
@@ -51,7 +50,6 @@ class ChatInputPresentationTest {
)
assertEquals(ChatInputTrailingAction.SEND, presentation.trailingAction)
assertTrue(presentation.showEmojiAction)
}
@Test
@@ -63,7 +61,6 @@ class ChatInputPresentationTest {
)
assertEquals(ChatInputLeadingAction.SWITCH_TO_TEXT, presentation.leadingAction)
assertTrue(presentation.showEmojiAction)
assertEquals(ChatInputTrailingAction.ADD, presentation.trailingAction)
}
@@ -76,7 +73,6 @@ class ChatInputPresentationTest {
)
assertEquals(ChatInputLeadingAction.SWITCH_TO_TEXT, presentation.leadingAction)
assertFalse(presentation.showEmojiAction)
assertEquals(ChatInputTrailingAction.CANCEL, presentation.trailingAction)
}

View File

@@ -0,0 +1,30 @@
package com.huaga.life_echo.ui.components.memoir
import org.junit.Assert.assertEquals
import org.junit.Test
class MemoirInlineImageSizingTest {
@Test
fun memoirImageAspectRatio_defaultsToSquare_whenSizeMissing() {
assertEquals(1f, memoirImageAspectRatio(null), 0.0001f)
assertEquals(1f, memoirImageAspectRatio(""), 0.0001f)
}
@Test
fun memoirImageAspectRatio_parsesSquareSize() {
assertEquals(1f, memoirImageAspectRatio("1024x1024"), 0.0001f)
}
@Test
fun memoirImageAspectRatio_parsesLandscapeAndPortraitSizes() {
assertEquals(1.5f, memoirImageAspectRatio("1536x1024"), 0.0001f)
assertEquals(0.6667f, memoirImageAspectRatio("1024x1536"), 0.0001f)
}
@Test
fun memoirImageAspectRatio_fallsBackToSquare_whenSizeMalformed() {
assertEquals(1f, memoirImageAspectRatio("oops"), 0.0001f)
assertEquals(1f, memoirImageAspectRatio("1024x0"), 0.0001f)
}
}

View File

@@ -0,0 +1,60 @@
package com.huaga.life_echo.ui.screens
import com.huaga.life_echo.network.models.ChapterDto
import com.huaga.life_echo.network.models.ChapterImageDto
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class MemoirImagePollingTest {
@Test
fun hasPendingMemoirImages_returnsTrue_whenAnyImageIsPendingOrProcessing() {
val chapters = listOf(
chapterWithImages("completed"),
chapterWithImages("processing"),
)
assertTrue(hasPendingMemoirImages(chapters))
}
@Test
fun hasPendingMemoirImages_returnsFalse_whenAllImagesAreTerminal() {
val chapters = listOf(
chapterWithImages("completed"),
chapterWithImages("failed"),
)
assertFalse(hasPendingMemoirImages(chapters))
}
private fun chapterWithImages(status: String): ChapterDto {
return ChapterDto(
id = "chapter-$status",
title = "title-$status",
content = "content-$status",
order_index = 0,
status = "partial",
category = "childhood",
images = listOf(
ChapterImageDto(
index = 0,
placeholder = "{{IMAGE:$status}}",
description = status,
prompt = null,
url = if (status == "completed") "https://cos.example.com/$status.png" else null,
status = status,
provider = "liblib",
style = "memoir",
size = "1024x1024",
error = null,
created_at = null,
updated_at = null,
)
),
updated_at = null,
is_new = false,
source_segments = emptyList(),
)
}
}