Fix memoir image delivery and Android rendering
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -205,4 +205,3 @@ class MyMemoirViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user