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
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue