add delta sync for dm-v10
This commit is contained in:
parent
e0b1133568
commit
afff839122
7 changed files with 216 additions and 21 deletions
|
|
@ -121,8 +121,10 @@ data class Message(
|
|||
val status: MessageStatus = MessageStatus.SUCCESS,
|
||||
|
||||
@kotlin.jvm.Transient
|
||||
var callLogInfo: CallLogInfo? = null
|
||||
var aspectRatio: Float? = null,
|
||||
|
||||
@kotlin.jvm.Transient
|
||||
var callLogInfo: CallLogInfo? = null
|
||||
) {
|
||||
init {
|
||||
if (type == "system" || type.startsWith("call")) {
|
||||
|
|
@ -182,7 +184,8 @@ data class WebSocketMessagePayload(
|
|||
@Json(name = "is_edited") val isEdited: Boolean,
|
||||
@Json(name = "is_deleted") val isDeleted: Boolean,
|
||||
@Json(name = "reply_to_id") val replyToId: String? = null,
|
||||
@Json(name = "reactions") val reactions: List<Reaction>? = emptyList()
|
||||
@Json(name = "reactions") val reactions: List<Reaction>? = emptyList(),
|
||||
@Json(name = "attachment") val attachment: Attachment? = null
|
||||
)
|
||||
|
||||
//send message
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory // 👈 確保
|
|||
import com.example.login_v3.data.api.api_class.LastMessage
|
||||
import com.example.login_v3.data.api.api_class.Partner
|
||||
import com.example.login_v3.data.api.api_class.Reaction
|
||||
import com.example.login_v3.data.api.api_class.Attachment
|
||||
|
||||
class RoomConverters {
|
||||
private val moshi = Moshi.Builder()
|
||||
|
|
@ -17,6 +18,7 @@ class RoomConverters {
|
|||
private val reactionAdapter = moshi.adapter<List<Reaction>>(reactionListType)
|
||||
private val partnerAdapter = moshi.adapter(Partner::class.java)
|
||||
private val lastMessageAdapter = moshi.adapter(LastMessage::class.java)
|
||||
private val attachmentAdapter = moshi.adapter(Attachment::class.java)
|
||||
|
||||
@TypeConverter
|
||||
fun fromReactionList(reactions: List<Reaction>?): String? {
|
||||
|
|
@ -62,4 +64,19 @@ class RoomConverters {
|
|||
null
|
||||
}
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromAttachment(attachment: Attachment?): String? {
|
||||
return attachmentAdapter.toJson(attachment)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toAttachment(json: String?): Attachment? {
|
||||
if (json.isNullOrBlank()) return null
|
||||
return try {
|
||||
attachmentAdapter.fromJson(json)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.example.login_v3.data.local.entities
|
|||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.example.login_v3.data.api.api_class.Reaction
|
||||
import com.example.login_v3.data.api.api_class.Attachment
|
||||
|
||||
enum class MessageStatus { SENDING, SUCCESS, FAILED }
|
||||
|
||||
|
|
@ -18,5 +19,7 @@ data class MessageEntity(
|
|||
val isDeleted: Boolean,
|
||||
val replyToId: String?,
|
||||
val status: MessageStatus,
|
||||
val reactions: List<Reaction>?
|
||||
val reactions: List<Reaction>?,
|
||||
val attachment: Attachment? = null,
|
||||
val aspectRatio: Float? = null
|
||||
)
|
||||
|
|
|
|||
|
|
@ -115,7 +115,9 @@ class ChatRoomsRepository @Inject constructor(
|
|||
isDeleted = payload.isDeleted,
|
||||
replyToId = payload.replyToId,
|
||||
status = MessageStatus.SUCCESS,
|
||||
reactions = payload.reactions
|
||||
reactions = payload.reactions,
|
||||
attachment = payload.attachment,
|
||||
aspectRatio = payload.attachment?.let { if (it.width > 0 && it.height > 0) it.width.toFloat() / it.height.toFloat() else null }
|
||||
)
|
||||
|
||||
// 3. 寫入本地資料庫
|
||||
|
|
@ -185,8 +187,11 @@ class ChatRoomsRepository @Inject constructor(
|
|||
isDeleted = entity.isDeleted,
|
||||
replyToId = entity.replyToId,
|
||||
status = entity.status, // 👈 把 Room 儲存的狀態倒出來給 UI
|
||||
reactions = entity.reactions
|
||||
)
|
||||
reactions = entity.reactions,
|
||||
attachment = entity.attachment
|
||||
).apply {
|
||||
aspectRatio = entity.aspectRatio
|
||||
}
|
||||
}
|
||||
|
||||
// 你原本的處理回覆邏輯
|
||||
|
|
@ -232,7 +237,9 @@ class ChatRoomsRepository @Inject constructor(
|
|||
isDeleted = networkMessage.isDeleted,
|
||||
replyToId = networkMessage.replyToId,
|
||||
status = MessageStatus.SUCCESS,
|
||||
reactions = networkMessage.reactions
|
||||
reactions = networkMessage.reactions,
|
||||
attachment = networkMessage.attachment,
|
||||
aspectRatio = networkMessage.attachment?.let { if (it.width > 0 && it.height > 0) it.width.toFloat() / it.height.toFloat() else null }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -300,7 +307,9 @@ class ChatRoomsRepository @Inject constructor(
|
|||
isDeleted = body.isDeleted,
|
||||
replyToId = body.replyToId,
|
||||
status = MessageStatus.SUCCESS, // 👈 狀態轉為成功
|
||||
reactions = body.reactions
|
||||
reactions = body.reactions,
|
||||
attachment = body.attachment,
|
||||
aspectRatio = body.attachment?.let { if (it.width > 0 && it.height > 0) it.width.toFloat() / it.height.toFloat() else null }
|
||||
)
|
||||
|
||||
// 🎯 2. 【關鍵大優化】利用剛寫好的 Transaction 一口氣替換!
|
||||
|
|
|
|||
|
|
@ -28,10 +28,21 @@ data class MessageUiModel(
|
|||
val isAudio: Boolean,
|
||||
val isFile: Boolean,
|
||||
val mediaUrl: String,
|
||||
val thumbnailUrl: String? = null,
|
||||
val aspectRatio: Float? = null
|
||||
)
|
||||
|
||||
fun Message.toUiModel(): MessageUiModel {
|
||||
val finalAspectRatio = aspectRatio ?: attachment?.let {
|
||||
if (it.width > 0 && it.height > 0) it.width.toFloat() / it.height.toFloat() else null
|
||||
}
|
||||
|
||||
val baseUrl = "https://tg.technologia-tw.com"
|
||||
val finalThumbnailUrl = attachment?.thumbnailUrl?.let { raw ->
|
||||
if (raw.startsWith("http")) raw
|
||||
else "$baseUrl${if (raw.startsWith("/")) "" else "/"}$raw"
|
||||
}
|
||||
|
||||
return MessageUiModel(
|
||||
id = id,
|
||||
senderId = senderId,
|
||||
|
|
@ -52,6 +63,7 @@ fun Message.toUiModel(): MessageUiModel {
|
|||
isAudio = isAudio,
|
||||
isFile = isFile,
|
||||
mediaUrl = mediaUrl,
|
||||
aspectRatio = null // Future: parse from metadata if available
|
||||
thumbnailUrl = finalThumbnailUrl,
|
||||
aspectRatio = finalAspectRatio
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,9 @@ import com.example.login_v3.home.Message.UI.Detail.Message_Component.MessageAtta
|
|||
import com.example.login_v3.home.Message.UI.Detail.Message_Component.UserStatusDot
|
||||
import com.example.login_v3.home.Message.UI.Detail.Message_Component.VideoLightbox
|
||||
import com.example.login_v3.home.Message.ViewModel.Detail.DownloadStatus
|
||||
import com.example.login_v3.utils.BlurHashDecoder
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
|
||||
|
||||
//bottom bar state
|
||||
|
|
@ -810,14 +813,25 @@ private fun RepliedMessagePreview(
|
|||
.height(22.dp)
|
||||
.background(if (isMe) Color(0xFF4CAF50) else Color(0xFF78909C))
|
||||
)
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).weight(1f)) {
|
||||
Text(
|
||||
text = if (replied.attachment?.mimeType?.startsWith("image/") == true) "[圖片]" else replied.content ?: "",
|
||||
text = if (replied.isImage) "[圖片]" else if (replied.isVideo) "[影片]" else replied.content ?: "",
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
if (replied.isImage || replied.isVideo) {
|
||||
AsyncImage(
|
||||
model = replied.thumbnailUrl ?: replied.mediaUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -845,27 +859,45 @@ private fun ImageMessageContent(
|
|||
|
||||
var isImageLoading by remember { mutableStateOf(true) }
|
||||
|
||||
// BlurHash Placeholder
|
||||
val blurhashPainter = remember(message.attachment?.blurhash) {
|
||||
message.attachment?.blurhash?.let { hash ->
|
||||
BlurHashDecoder.decode(hash, 32, 32)?.let { bitmap ->
|
||||
BitmapPainter(bitmap.asImageBitmap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.widthIn(max = 240.dp)
|
||||
.sizeIn(maxWidth = 240.dp, maxHeight = 320.dp) // 🎯 限制最大尺寸
|
||||
.then(
|
||||
if (message.aspectRatio != null) {
|
||||
Modifier.aspectRatio(message.aspectRatio)
|
||||
} else {
|
||||
Modifier.height(180.dp)
|
||||
Modifier.height(180.dp).width(240.dp)
|
||||
}
|
||||
)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color(0xFFF5F5F5))
|
||||
.shimmer(visible = isImageLoading)
|
||||
.shimmer(visible = isImageLoading && blurhashPainter == null)
|
||||
) {
|
||||
// 如果有 BlurHash,先顯示它
|
||||
blurhashPainter?.let {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = it,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = message.mediaUrl,
|
||||
model = message.thumbnailUrl ?: message.mediaUrl,
|
||||
contentDescription = if (message.isGif) "動態 GIF" else "聊天圖片",
|
||||
contentScale = ContentScale.Fit,
|
||||
contentScale = ContentScale.Crop, // 🎯 改為 Crop 確保填滿
|
||||
onLoading = { isImageLoading = true },
|
||||
onSuccess = { isImageLoading = false },
|
||||
onError = { errorState ->
|
||||
isImageLoading = false
|
||||
Log.e("ChatImageError", "原因: ${errorState.result.throwable}")
|
||||
|
|
@ -900,16 +932,25 @@ private fun VideoMessageContent(
|
|||
|
||||
var isVideoThumbLoading by remember { mutableStateOf(true) }
|
||||
|
||||
// BlurHash Placeholder
|
||||
val blurhashPainter = remember(message.attachment?.blurhash) {
|
||||
message.attachment?.blurhash?.let { hash ->
|
||||
BlurHashDecoder.decode(hash, 32, 32)?.let { bitmap ->
|
||||
BitmapPainter(bitmap.asImageBitmap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 影片佈局
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.width(240.dp)
|
||||
.sizeIn(maxWidth = 240.dp, maxHeight = 320.dp) // 🎯 限制最大尺寸
|
||||
.then(
|
||||
if (message.aspectRatio != null) {
|
||||
Modifier.aspectRatio(message.aspectRatio)
|
||||
} else {
|
||||
Modifier.height(160.dp)
|
||||
Modifier.height(160.dp).width(240.dp)
|
||||
}
|
||||
)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
|
|
@ -919,11 +960,21 @@ private fun VideoMessageContent(
|
|||
color = Color.LightGray.copy(alpha = 0.4f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.shimmer(visible = isVideoThumbLoading),
|
||||
.shimmer(visible = isVideoThumbLoading && blurhashPainter == null),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// 如果有 BlurHash,先顯示它
|
||||
blurhashPainter?.let {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = it,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = message.mediaUrl,
|
||||
model = message.thumbnailUrl ?: message.mediaUrl,
|
||||
contentDescription = "影片預覽",
|
||||
contentScale = ContentScale.Crop,
|
||||
onLoading = { isVideoThumbLoading = true },
|
||||
|
|
|
|||
100
app/src/main/java/com/example/login_v3/utils/BlurHashDecoder.kt
Normal file
100
app/src/main/java/com/example/login_v3/utils/BlurHashDecoder.kt
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package com.example.login_v3.utils
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.withSign
|
||||
|
||||
object BlurHashDecoder {
|
||||
|
||||
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f): Bitmap? {
|
||||
if (blurHash == null || blurHash.length < 6) return null
|
||||
|
||||
val numComponents = decode83(blurHash, 0, 1)
|
||||
val nx = (numComponents % 9) + 1
|
||||
val ny = (numComponents / 9) + 1
|
||||
|
||||
if (blurHash.length != 4 + 2 * nx * ny) return null
|
||||
|
||||
val maxAc = (decode83(blurHash, 1, 2) + 1) / 166f
|
||||
val colors = Array(nx * ny) { i ->
|
||||
if (i == 0) {
|
||||
val value = decode83(blurHash, 2, 6)
|
||||
decodeDc(value)
|
||||
} else {
|
||||
val value = decode83(blurHash, 4 + i * 2, 4 + i * 2 + 2)
|
||||
decodeAc(value, maxAc * punch)
|
||||
}
|
||||
}
|
||||
|
||||
val pixels = IntArray(width * height)
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until width) {
|
||||
var r = 0f
|
||||
var g = 0f
|
||||
var b = 0f
|
||||
for (j in 0 until ny) {
|
||||
for (i in 0 until nx) {
|
||||
val basis = cos(Math.PI * x * i / width) * cos(Math.PI * y * j / height)
|
||||
val color = colors[i + j * nx]
|
||||
r += color[0] * basis.toFloat()
|
||||
g += color[1] * basis.toFloat()
|
||||
b += color[2] * basis.toFloat()
|
||||
}
|
||||
}
|
||||
pixels[x + y * width] = Color.rgb(clamp(r), clamp(g), clamp(b))
|
||||
}
|
||||
}
|
||||
return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
|
||||
private fun decode83(str: String, start: Int, end: Int): Int {
|
||||
var res = 0
|
||||
for (i in start until end) {
|
||||
val c = str[i]
|
||||
val digit = charMap[c] ?: 0
|
||||
res = res * 83 + digit
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
private fun decodeDc(value: Int): FloatArray {
|
||||
val r = (value shr 16) / 255f
|
||||
val g = ((value shr 8) and 255) / 255f
|
||||
val b = (value and 255) / 255f
|
||||
return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b))
|
||||
}
|
||||
|
||||
private fun decodeAc(value: Int, maxAc: Float): FloatArray {
|
||||
val r = value / (19 * 19)
|
||||
val g = (value / 19) % 19
|
||||
val b = value % 19
|
||||
return floatArrayOf(
|
||||
signedPow2((r - 9) / 9f) * maxAc,
|
||||
signedPow2((g - 9) / 9f) * maxAc,
|
||||
signedPow2((b - 9) / 9f) * maxAc
|
||||
)
|
||||
}
|
||||
|
||||
private fun srgbToLinear(v: Float): Float {
|
||||
return if (v <= 0.04045f) v / 12.92f else ((v + 0.055f) / 1.055f).pow(2.4f)
|
||||
}
|
||||
|
||||
private fun linearToSrgb(v: Float): Float {
|
||||
val f = v.coerceIn(0f, 1f)
|
||||
return if (f <= 0.0031308f) f * 12.92f else (1.055f * f.pow(1 / 2.4f) - 0.055f)
|
||||
}
|
||||
|
||||
private fun clamp(v: Float): Int {
|
||||
return (linearToSrgb(v) * 255).toInt()
|
||||
}
|
||||
|
||||
private fun signedPow2(v: Float): Float {
|
||||
return v.pow(2f).withSign(v)
|
||||
}
|
||||
|
||||
private val charMap = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
|
||||
.mapIndexed { i, c -> c to i }
|
||||
.toMap()
|
||||
}
|
||||
Loading…
Reference in a new issue