fix: store image dimensions in DB to prevent layout shift on async load

Save width/height alongside file paths so AsyncImage can reserve the
correct aspect ratio before loading, eliminating flickering in LazyColumn.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-18 21:13:21 +09:00
parent 862e47823d
commit 1952c3eabb
6 changed files with 42 additions and 12 deletions

View file

@ -1,7 +1,9 @@
package io.picoclaw.android.core.data.local package io.picoclaw.android.core.data.local
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory
import android.util.Base64 import android.util.Base64
import io.picoclaw.android.core.domain.model.ImageData
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
@ -9,10 +11,18 @@ class ImageFileStorage(context: Context) {
private val imageDir = File(context.filesDir, "chat_images").also { it.mkdirs() } private val imageDir = File(context.filesDir, "chat_images").also { it.mkdirs() }
fun saveBase64ToFile(base64: String): String { fun saveBase64ToFile(base64: String): ImageData {
val bytes = Base64.decode(base64, Base64.DEFAULT) val bytes = Base64.decode(base64, Base64.DEFAULT)
val file = File(imageDir, "${UUID.randomUUID()}.jpg") val file = File(imageDir, "${UUID.randomUUID()}.jpg")
file.writeBytes(bytes) file.writeBytes(bytes)
return file.absolutePath
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, opts)
return ImageData(
path = file.absolutePath,
width = opts.outWidth,
height = opts.outHeight
)
} }
} }

View file

@ -5,18 +5,25 @@ import io.picoclaw.android.core.data.remote.dto.WsIncoming
import io.picoclaw.android.core.data.remote.dto.WsOutgoing import io.picoclaw.android.core.data.remote.dto.WsOutgoing
import io.picoclaw.android.core.domain.model.ChatMessage import io.picoclaw.android.core.domain.model.ChatMessage
import io.picoclaw.android.core.domain.model.ImageAttachment import io.picoclaw.android.core.domain.model.ImageAttachment
import io.picoclaw.android.core.domain.model.ImageData
import io.picoclaw.android.core.domain.model.MessageSender import io.picoclaw.android.core.domain.model.MessageSender
import io.picoclaw.android.core.domain.model.MessageStatus import io.picoclaw.android.core.domain.model.MessageStatus
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.util.UUID import java.util.UUID
@Serializable
private data class ImageEntry(val path: String, val width: Int, val height: Int)
object MessageMapper { object MessageMapper {
fun toDomain(entity: MessageEntity): ChatMessage { fun toDomain(entity: MessageEntity): ChatMessage {
val images = entity.imagePathList?.let { val images = entity.imagePathList?.let {
try { try {
Json.decodeFromString<List<String>>(it) Json.decodeFromString<List<ImageEntry>>(it).map { e ->
ImageData(e.path, e.width, e.height)
}
} catch (_: Exception) { } catch (_: Exception) {
emptyList() emptyList()
} }
@ -43,9 +50,9 @@ object MessageMapper {
) )
} }
fun toEntity(text: String, imagePaths: List<String>, status: MessageStatus): MessageEntity { fun toEntity(text: String, images: List<ImageData>, status: MessageStatus): MessageEntity {
val pathJson = if (imagePaths.isNotEmpty()) { val pathJson = if (images.isNotEmpty()) {
Json.encodeToString(imagePaths) Json.encodeToString(images.map { ImageEntry(it.path, it.width, it.height) })
} else null } else null
return MessageEntity( return MessageEntity(

View file

@ -48,8 +48,8 @@ class ChatRepositoryImpl(
} }
override suspend fun sendMessage(text: String, images: List<ImageAttachment>) { override suspend fun sendMessage(text: String, images: List<ImageAttachment>) {
val imagePaths = images.map { imageFileStorage.saveBase64ToFile(it.base64) } val imageDataList = images.map { imageFileStorage.saveBase64ToFile(it.base64) }
val entity = MessageMapper.toEntity(text, imagePaths, MessageStatus.SENDING) val entity = MessageMapper.toEntity(text, imageDataList, MessageStatus.SENDING)
messageDao.insert(entity) messageDao.insert(entity)
val wsDto = MessageMapper.toWsIncoming(text, images) val wsDto = MessageMapper.toWsIncoming(text, images)
val success = webSocketClient.send(wsDto) val success = webSocketClient.send(wsDto)

View file

@ -4,7 +4,7 @@ data class ChatMessage(
val id: String, val id: String,
val content: String, val content: String,
val sender: MessageSender, val sender: MessageSender,
val images: List<String> = emptyList(), val images: List<ImageData> = emptyList(),
val timestamp: Long, val timestamp: Long,
val status: MessageStatus val status: MessageStatus
) )

View file

@ -0,0 +1,7 @@
package io.picoclaw.android.core.domain.model
data class ImageData(
val path: String,
val width: Int,
val height: Int
)

View file

@ -2,6 +2,7 @@ package io.picoclaw.android.feature.chat.component
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
@ -49,13 +50,18 @@ fun MessageBubble(
modifier = Modifier.widthIn(max = 300.dp) modifier = Modifier.widthIn(max = 300.dp)
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
message.images.forEach { filePath -> message.images.forEach { imageData ->
val ratio = if (imageData.width > 0 && imageData.height > 0) {
imageData.width.toFloat() / imageData.height.toFloat()
} else 1f
AsyncImage( AsyncImage(
model = File(filePath), model = File(imageData.path),
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(bottom = 8.dp), .padding(bottom = 8.dp)
.aspectRatio(ratio),
contentScale = ContentScale.FillWidth contentScale = ContentScale.FillWidth
) )
} }