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:
parent
862e47823d
commit
1952c3eabb
6 changed files with 42 additions and 12 deletions
|
|
@ -1,7 +1,9 @@
|
|||
package io.picoclaw.android.core.data.local
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import io.picoclaw.android.core.domain.model.ImageData
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -9,10 +11,18 @@ class ImageFileStorage(context: Context) {
|
|||
|
||||
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 file = File(imageDir, "${UUID.randomUUID()}.jpg")
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.domain.model.ChatMessage
|
||||
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.MessageStatus
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
private data class ImageEntry(val path: String, val width: Int, val height: Int)
|
||||
|
||||
object MessageMapper {
|
||||
|
||||
fun toDomain(entity: MessageEntity): ChatMessage {
|
||||
val images = entity.imagePathList?.let {
|
||||
try {
|
||||
Json.decodeFromString<List<String>>(it)
|
||||
Json.decodeFromString<List<ImageEntry>>(it).map { e ->
|
||||
ImageData(e.path, e.width, e.height)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
|
@ -43,9 +50,9 @@ object MessageMapper {
|
|||
)
|
||||
}
|
||||
|
||||
fun toEntity(text: String, imagePaths: List<String>, status: MessageStatus): MessageEntity {
|
||||
val pathJson = if (imagePaths.isNotEmpty()) {
|
||||
Json.encodeToString(imagePaths)
|
||||
fun toEntity(text: String, images: List<ImageData>, status: MessageStatus): MessageEntity {
|
||||
val pathJson = if (images.isNotEmpty()) {
|
||||
Json.encodeToString(images.map { ImageEntry(it.path, it.width, it.height) })
|
||||
} else null
|
||||
|
||||
return MessageEntity(
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ class ChatRepositoryImpl(
|
|||
}
|
||||
|
||||
override suspend fun sendMessage(text: String, images: List<ImageAttachment>) {
|
||||
val imagePaths = images.map { imageFileStorage.saveBase64ToFile(it.base64) }
|
||||
val entity = MessageMapper.toEntity(text, imagePaths, MessageStatus.SENDING)
|
||||
val imageDataList = images.map { imageFileStorage.saveBase64ToFile(it.base64) }
|
||||
val entity = MessageMapper.toEntity(text, imageDataList, MessageStatus.SENDING)
|
||||
messageDao.insert(entity)
|
||||
val wsDto = MessageMapper.toWsIncoming(text, images)
|
||||
val success = webSocketClient.send(wsDto)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ data class ChatMessage(
|
|||
val id: String,
|
||||
val content: String,
|
||||
val sender: MessageSender,
|
||||
val images: List<String> = emptyList(),
|
||||
val images: List<ImageData> = emptyList(),
|
||||
val timestamp: Long,
|
||||
val status: MessageStatus
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package io.picoclaw.android.core.domain.model
|
||||
|
||||
data class ImageData(
|
||||
val path: String,
|
||||
val width: Int,
|
||||
val height: Int
|
||||
)
|
||||
|
|
@ -2,6 +2,7 @@ package io.picoclaw.android.feature.chat.component
|
|||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
|
|
@ -49,13 +50,18 @@ fun MessageBubble(
|
|||
modifier = Modifier.widthIn(max = 300.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(
|
||||
model = File(filePath),
|
||||
model = File(imageData.path),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
.padding(bottom = 8.dp)
|
||||
.aspectRatio(ratio),
|
||||
contentScale = ContentScale.FillWidth
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue