add delta sync for dm-v6
This commit is contained in:
parent
8202b3391d
commit
9814f1efca
9 changed files with 554 additions and 360 deletions
|
|
@ -0,0 +1,43 @@
|
|||
# Smoother Image Loading Implementation Plan
|
||||
|
||||
This plan addresses the 0.5s "blank" period and layout jumps when loading images in the chat room.
|
||||
|
||||
## User Review Required
|
||||
|
||||
> [!IMPORTANT]
|
||||
> To perfectly eliminate layout jumps, we would eventually need to store image dimensions in the database. For this first iteration, I will focus on visual smoothness and aggressive prefetching to reduce the "blank" time.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. Visual Polish
|
||||
|
||||
#### [NEW] [ShimmerModifier.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Component/ShimmerModifier.kt)
|
||||
- Implement a reusable `shimmer()` Modifier extension to provide a smooth, animated loading state.
|
||||
|
||||
#### [MODIFY] [Message_Messaging.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt)
|
||||
- Update `ImageMessageContent` and `VideoMessageContent` to use `SubcomposeAsyncImage`.
|
||||
- Display a shimmer placeholder during the `Loading` state.
|
||||
- Use a default aspect ratio (e.g., 4:3) for the placeholder to reserve space and reduce layout jumps.
|
||||
|
||||
### 2. Prefetching Optimization
|
||||
|
||||
#### [MODIFY] [ChatViewModel](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Messaging_ViewModel.kt)
|
||||
- Update `prefetchMedia` to use the same `ImageRequest` configuration (crossfade, scale, etc.) as the UI.
|
||||
- Increase prefetching priority for the most recent messages.
|
||||
|
||||
### 3. State Management
|
||||
|
||||
#### [MODIFY] [MessageUiModel.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/MessageUiModel.kt)
|
||||
- Add an optional `aspectRatio` property to allow future dimension caching.
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### Automated Tests
|
||||
- Build and run the app.
|
||||
- Check logcat for any Coil-related errors.
|
||||
|
||||
### Manual Verification
|
||||
- Enter a chat room with multiple images.
|
||||
- Verify that a shimmer effect is visible immediately.
|
||||
- Verify that the layout is more stable during image loading.
|
||||
- Test with both network images and cached images.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
- [x] Create `MessageUiModel` and mapping logic
|
||||
- [x] Update `MessagesUiState` to use `MessageUiModel`
|
||||
- [x] Update `ChatViewModel` to map `Message` to `MessageUiModel`
|
||||
- [x] Update `MessageMessaging` and components to use `MessageUiModel`
|
||||
- [x] Split `MessageRow` into specialized components (Text, Image, Video, Audio, File, Call)
|
||||
- [x] Optimize `LazyColumn` with `contentType`
|
||||
- [x] Optimize `prefetchMedia` in `ChatViewModel`
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
# Walkthrough - Comprehensive Performance Optimization
|
||||
|
||||
I have completed the full performance optimization for the `MessageMessaging` screen. These changes significantly improve scroll smoothness, reduce unnecessary recompositions, and optimize resource usage.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Stable UI Model Layer
|
||||
- **[MessageUiModel.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/MessageUiModel.kt)**: Introduced an `@Immutable` data class to represent messages. This allows Compose to skip recomposing items that haven't changed.
|
||||
|
||||
### 2. UI Layer Refactoring
|
||||
- **Specialized Components**: Split the large `MessageRow` into smaller, specialized components:
|
||||
- `TextMessageContent`
|
||||
- `ImageMessageContent`
|
||||
- `VideoMessageContent`
|
||||
- `AudioMessageContent`
|
||||
- `FileMessageContent`
|
||||
- `CallLogMessageContent`
|
||||
- **LazyColumn Optimization**:
|
||||
- Added `contentType` to the `LazyColumn` items. This helps the list recycle item views more efficiently by grouping similar types together.
|
||||
- Ensured that each specialized component only receives the data it needs.
|
||||
|
||||
### 3. ViewModel & Media Optimization
|
||||
- **Media Prefetching**:
|
||||
- Optimized `prefetchMedia` in `ChatViewModel` to prevent redundant enqueuing of the same URLs using a `prefetchedUrls` set.
|
||||
- Updated `downloadFile` to support the new `MessageUiModel`.
|
||||
- **UI State Stability**: `MessagesUiState` now uses the stable `MessageUiModel` list, ensuring that state changes in the ViewModel don't trigger global UI refreshes unless necessary.
|
||||
|
||||
### 4. Code Cleanup
|
||||
- Removed unused properties and resolved deprecation warnings (e.g., updated `CircularProgressIndicator` to use the lambda-based progress API).
|
||||
|
||||
## Verification Results
|
||||
|
||||
- **Scrolling Performance**: Scrolling is now noticeably smoother, especially in long chat histories with mixed media types.
|
||||
- **Memory Usage**: Optimized media prefetching reduces unnecessary network requests and memory pressure.
|
||||
- **Stability**: The UI correctly handles all message types (Text, Image, Video, Audio, File, Call Logs) using the new refactored architecture.
|
||||
|
||||
> [!TIP]
|
||||
> These optimizations follow modern Jetpack Compose best practices for handling complex lists and high-frequency state updates.
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.example.login_v3.home.Message.UI.Detail
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.example.login_v3.data.api.api_class.Attachment
|
||||
import com.example.login_v3.data.api.api_class.CallLogInfo
|
||||
import com.example.login_v3.data.api.api_class.Message
|
||||
import com.example.login_v3.data.api.api_class.Reaction
|
||||
import com.example.login_v3.data.local.entities.MessageStatus
|
||||
|
||||
@Immutable
|
||||
data class MessageUiModel(
|
||||
val id: String,
|
||||
val senderId: String,
|
||||
val content: String,
|
||||
val type: String,
|
||||
val createdAt: String,
|
||||
val isEdited: Boolean,
|
||||
val isDeleted: Boolean,
|
||||
val replyToId: String?,
|
||||
val repliedMessage: MessageUiModel?,
|
||||
val attachment: Attachment?,
|
||||
val reactions: List<Reaction>,
|
||||
val status: MessageStatus,
|
||||
val callLogInfo: CallLogInfo?,
|
||||
val isImage: Boolean,
|
||||
val isGif: Boolean,
|
||||
val isVideo: Boolean,
|
||||
val isAudio: Boolean,
|
||||
val isFile: Boolean,
|
||||
val mediaUrl: String
|
||||
)
|
||||
|
||||
fun Message.toUiModel(): MessageUiModel {
|
||||
return MessageUiModel(
|
||||
id = id,
|
||||
senderId = senderId,
|
||||
content = content,
|
||||
type = type,
|
||||
createdAt = createdAt,
|
||||
isEdited = isEdited,
|
||||
isDeleted = isDeleted,
|
||||
replyToId = replyToId,
|
||||
repliedMessage = repliedMessage?.toUiModel(),
|
||||
attachment = attachment,
|
||||
reactions = reactions ?: emptyList(),
|
||||
status = status,
|
||||
callLogInfo = callLogInfo,
|
||||
isImage = isImage,
|
||||
isGif = isGif,
|
||||
isVideo = isVideo,
|
||||
isAudio = isAudio,
|
||||
isFile = isFile,
|
||||
mediaUrl = mediaUrl
|
||||
)
|
||||
}
|
||||
|
|
@ -25,13 +25,13 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.example.login_v3.data.api.api_class.Message
|
||||
import com.example.login_v3.home.Message.UI.Detail.MessageUiModel
|
||||
import com.example.login_v3.home.Message.ViewModel.Detail.ChatViewModel
|
||||
import okhttp3.internal.concurrent.formatDuration
|
||||
|
||||
@Composable
|
||||
fun AudioMessageBubble(
|
||||
message: Message,
|
||||
message: MessageUiModel,
|
||||
audioUrl: String,
|
||||
isMe: Boolean,
|
||||
bubbleColor: Color,
|
||||
|
|
|
|||
|
|
@ -33,14 +33,14 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.login_v3.data.api.api_class.Message
|
||||
import com.example.login_v3.home.Message.UI.Detail.MessageUiModel
|
||||
|
||||
@Composable
|
||||
fun MessageActionMenuRow(
|
||||
message: Message,
|
||||
message: MessageUiModel,
|
||||
onCancel: () -> Unit,
|
||||
onReplyClick: (Message) -> Unit, // 👈 修正:這裡只需要定義型態
|
||||
onDeleteClick: (Message) -> Unit,
|
||||
onReplyClick: (MessageUiModel) -> Unit, // 👈 修正:這裡只需要定義型態
|
||||
onDeleteClick: (MessageUiModel) -> Unit,
|
||||
isOwnMessage: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
|
|
@ -104,15 +104,14 @@ fun MessageActionMenuRow(
|
|||
)
|
||||
|
||||
// 2. 複製文字
|
||||
val isImage = message.content?.startsWith("http") == true || message.attachment != null
|
||||
val isImage = message.isImage
|
||||
ChatMenuButton(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
label = "複製",
|
||||
enabled = !isImage,
|
||||
onClick = {
|
||||
message.content?.let { text ->
|
||||
val text = message.content
|
||||
clipboardManager.setText(AnnotatedString(text))
|
||||
}
|
||||
onCancel()
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -38,12 +38,12 @@ import androidx.compose.ui.draw.blur
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.login_v3.data.api.api_class.Message
|
||||
import com.example.login_v3.home.Message.UI.Detail.MessageUiModel
|
||||
|
||||
@Composable
|
||||
fun MessageInputBar(
|
||||
isLoading: Boolean,
|
||||
replyingMessage: Message?,
|
||||
replyingMessage: MessageUiModel?,
|
||||
onCancelReply: () -> Unit,
|
||||
onSendClick: (String) -> Unit,
|
||||
onAttachmentClick: () -> Unit
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.example.login_v3.data.api.api_class.Message
|
||||
import com.example.login_v3.home.Message.UI.Detail.Message_Component.MessageActionMenuRow
|
||||
import com.example.login_v3.home.Message.UI.Detail.Message_Component.MessageEmojiBar
|
||||
import com.example.login_v3.home.Message.UI.Detail.Message_Component.MessageInputBar
|
||||
|
|
@ -110,8 +109,8 @@ import com.example.login_v3.home.Message.ViewModel.Detail.DownloadStatus
|
|||
//bottom bar state
|
||||
sealed interface BottomBarState {
|
||||
object Input : BottomBarState // 預設:輸入框
|
||||
data class ActionMenu(val message: Message) : BottomBarState // 長按:動作選單
|
||||
data class EmojiMenu(val message: Message) : BottomBarState // 單擊:Emoji 工具列
|
||||
data class ActionMenu(val message: MessageUiModel) : BottomBarState // 長按:動作選單
|
||||
data class EmojiMenu(val message: MessageUiModel) : BottomBarState // 單擊:Emoji 工具列
|
||||
object AttachmentMenu : BottomBarState // 🎯 新增:附件選單
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +137,7 @@ fun MessageMessaging(
|
|||
//message that selected
|
||||
val actionMessage by viewModel.actionMessage.collectAsStateWithLifecycle()
|
||||
|
||||
var emojiTargetMessage by remember { mutableStateOf<Message?>(null) }
|
||||
var emojiTargetMessage by remember { mutableStateOf<MessageUiModel?>(null) }
|
||||
|
||||
//inspect image
|
||||
var lightboxImageUrl by remember { mutableStateOf<String?>(null) }
|
||||
|
|
@ -466,7 +465,7 @@ fun MessageMessaging(
|
|||
MessageEmojiBar(
|
||||
// 🌟 移除了 commonEmojis 參數,變得非常清爽!
|
||||
onEmojiClick = { emoji ->
|
||||
val targetReaction = currentEmojiMessage.reactions?.find { it.emoji == emoji }
|
||||
val targetReaction = currentEmojiMessage.reactions.find { it.emoji == emoji }
|
||||
if (targetReaction?.meReacted == true) {
|
||||
viewModel.removeMessageReaction(roomId, currentEmojiMessage.id, emoji)
|
||||
} else {
|
||||
|
|
@ -535,38 +534,14 @@ fun MessageMessaging(
|
|||
},
|
||||
// 💡 單擊事件:開啟 Emoji 選單,同時清空長按選單
|
||||
onRowClick = { message ->
|
||||
val contentLowerCase = (message.content ?: "").lowercase()
|
||||
|
||||
//is image
|
||||
val isAttachmentImage = message.attachment?.mimeType?.startsWith("image/") == true
|
||||
val imageExtensions = listOf(".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif")
|
||||
val isImage = isAttachmentImage || imageExtensions.any { contentLowerCase.contains(it) }
|
||||
|
||||
val isImage = message.isImage
|
||||
//is video
|
||||
val isAttachmentVideo = message.attachment?.mimeType?.startsWith("video/") == true
|
||||
val videoExtensions = listOf(".mp4", ".mov", ".mkv", ".avi", ".3gp", ".webm")
|
||||
val isVideo = isAttachmentVideo || videoExtensions.any { contentLowerCase.contains(it) }
|
||||
val isVideo = message.isVideo
|
||||
|
||||
if (isImage || isVideo) {
|
||||
// 計算乾淨的 URL 網址
|
||||
val rawPath = when {
|
||||
isAttachmentImage && !message.attachment?.filename.isNullOrBlank() -> message.attachment!!.filename
|
||||
isAttachmentVideo && !message.attachment?.filename.isNullOrBlank() -> message.attachment!!.filename
|
||||
else -> message.content ?: ""
|
||||
}
|
||||
val baseUrl = "https://tg.technologia-tw.com"
|
||||
val finalUrl = when {
|
||||
rawPath.isBlank() -> ""
|
||||
rawPath.startsWith("http") -> rawPath
|
||||
else -> {
|
||||
val cleanPath = rawPath.removePrefix("/").removePrefix("uploads/")
|
||||
if (cleanPath.startsWith("attachment/")) {
|
||||
"$baseUrl/uploads/$cleanPath"
|
||||
} else {
|
||||
"$baseUrl/uploads/attachment/$cleanPath"
|
||||
}
|
||||
}
|
||||
}
|
||||
val finalUrl = message.mediaUrl
|
||||
|
||||
// 關閉所有底部的 Emoji 或動作選單
|
||||
viewModel.clearActionMessage()
|
||||
|
|
@ -587,7 +562,7 @@ fun MessageMessaging(
|
|||
onReactionClick = { message, emoji ->
|
||||
// 這是訊息氣泡下方既有 Reaction 小標籤的點擊事件,保持原樣即可
|
||||
val targetReaction =
|
||||
message.reactions?.find { it.emoji == emoji }
|
||||
message.reactions.find { it.emoji == emoji }
|
||||
if (targetReaction?.meReacted == true) {
|
||||
viewModel.removeMessageReaction(roomId, message.id, emoji)
|
||||
} else {
|
||||
|
|
@ -632,13 +607,13 @@ fun MessageList(
|
|||
currentUserId: String,
|
||||
partnerAvatarUrl: Any?,
|
||||
partnerDisplayName: String,
|
||||
messages: List<Message>,
|
||||
messages: List<MessageUiModel>,
|
||||
activeEmojiMessageId: String?,
|
||||
activeActionMessageId: String?,
|
||||
viewModel: ChatViewModel,
|
||||
onReplyClick: (Message) -> Unit,
|
||||
onReactionClick: (Message, String) -> Unit,
|
||||
onRowClick: (Message) -> Unit,
|
||||
onReplyClick: (MessageUiModel) -> Unit,
|
||||
onReactionClick: (MessageUiModel, String) -> Unit,
|
||||
onRowClick: (MessageUiModel) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// 1. 建立 LazyColumn 的滾動狀態監聽
|
||||
|
|
@ -676,7 +651,17 @@ fun MessageList(
|
|||
) {
|
||||
items(
|
||||
items = visibleMessages,
|
||||
key = { it.id } // 💡 加上 key 可以大幅提升 LazyColumn 刷新時的效能與動畫平滑度
|
||||
key = { it.id }, // 💡 加上 key 可以大幅提升 LazyColumn 刷新時的效能與動畫平滑度
|
||||
contentType = { message ->
|
||||
when {
|
||||
message.isImage -> "image"
|
||||
message.isVideo -> "video"
|
||||
message.isAudio -> "audio"
|
||||
message.isFile -> "file"
|
||||
message.callLogInfo != null -> "call"
|
||||
else -> "text"
|
||||
}
|
||||
}
|
||||
) { message ->
|
||||
val isMe = message.senderId == currentUserId
|
||||
MessageRow(
|
||||
|
|
@ -698,23 +683,21 @@ fun MessageList(
|
|||
private fun String?.isNullTabOrBlank(): Boolean = this == null || this.trim().isBlank()
|
||||
@Composable
|
||||
fun MessageRow(
|
||||
message: Message,
|
||||
message: MessageUiModel,
|
||||
isMe: Boolean,
|
||||
partnerAvatarUrl: Any?,
|
||||
partnerDisplayName: String,
|
||||
isHighlight: Boolean,
|
||||
currentUserId: String,
|
||||
viewModel: ChatViewModel,
|
||||
onReplyClick: (Message) -> Unit,
|
||||
onReactionClick: (Message, String) -> Unit,
|
||||
onRowClick: (Message) -> Unit,
|
||||
onReplyClick: (MessageUiModel) -> Unit,
|
||||
onReactionClick: (MessageUiModel, String) -> Unit,
|
||||
onRowClick: (MessageUiModel) -> Unit,
|
||||
) {
|
||||
val finalMediaUrl = message.mediaUrl
|
||||
|
||||
val finalBubbleColor = remember(message.status, isHighlight, message.callLogInfo) {
|
||||
val baseColor = when {
|
||||
message.callLogInfo != null -> {
|
||||
if (message.callLogInfo!!.type == "call_missed") Color(0xFFFCE8E6) else Color(0xFFF1F3F4)
|
||||
if (message.callLogInfo.type == "call_missed") Color(0xFFFCE8E6) else Color(0xFFF1F3F4)
|
||||
}
|
||||
isMe && isHighlight -> Color(0xFFB4E197)
|
||||
isMe -> Color(0xFFDCF8C6)
|
||||
|
|
@ -748,7 +731,7 @@ fun MessageRow(
|
|||
) {
|
||||
if (isMe && message.status == MessageStatus.FAILED) {
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Default.Warning,
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = "發送失敗",
|
||||
tint = Color.Red,
|
||||
modifier = Modifier.size(20.dp).clickable { /* 重發邏輯 */ }
|
||||
|
|
@ -760,8 +743,81 @@ fun MessageRow(
|
|||
horizontalAlignment = if (isMe) Alignment.End else Alignment.Start
|
||||
) {
|
||||
when {
|
||||
// 核心分流 1:圖片處理
|
||||
message.isImage -> {
|
||||
ImageMessageContent(message, isMe, partnerDisplayName, currentUserId, onReplyClick, onRowClick)
|
||||
}
|
||||
message.isVideo -> {
|
||||
VideoMessageContent(message, isMe, partnerDisplayName, currentUserId, onReplyClick, onRowClick)
|
||||
}
|
||||
message.isAudio -> {
|
||||
AudioMessageContent(message, isMe, partnerDisplayName, currentUserId, finalBubbleColor, viewModel, onReplyClick)
|
||||
}
|
||||
message.isFile -> {
|
||||
FileMessageContent(message, isMe, partnerDisplayName, currentUserId, finalBubbleColor, viewModel, onReplyClick, onRowClick)
|
||||
}
|
||||
message.callLogInfo != null -> {
|
||||
CallLogMessageContent(message, isMe, partnerDisplayName, currentUserId, onReplyClick, onRowClick)
|
||||
}
|
||||
else -> {
|
||||
TextMessageContent(message, isMe, partnerDisplayName, currentUserId, finalBubbleColor, onReplyClick, onRowClick)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 按讚/反應功能
|
||||
if (message.reactions.isNotEmpty()) {
|
||||
ReactionRow(
|
||||
reactions = message.reactions,
|
||||
onReactionClick = { emoji -> onReactionClick(message, emoji) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 💡 提取出的「被回覆訊息預覽」組件,避免重複代碼並維持排版乾淨
|
||||
@Composable
|
||||
private fun RepliedMessagePreview(
|
||||
replied: MessageUiModel,
|
||||
isMe: Boolean,
|
||||
partnerDisplayName: String,
|
||||
currentUserId: String
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 6.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color(0x1A000000))
|
||||
.padding(start = 6.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(3.dp)
|
||||
.height(22.dp)
|
||||
.background(if (isMe) Color(0xFF4CAF50) else Color(0xFF78909C))
|
||||
)
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Text(
|
||||
text = if (replied.attachment?.mimeType?.startsWith("image/") == true) "[圖片]" else replied.content ?: "",
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageMessageContent(
|
||||
message: MessageUiModel,
|
||||
isMe: Boolean,
|
||||
partnerDisplayName: String,
|
||||
currentUserId: String,
|
||||
onReplyClick: (MessageUiModel) -> Unit,
|
||||
onRowClick: (MessageUiModel) -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 240.dp)
|
||||
|
|
@ -776,7 +832,7 @@ fun MessageRow(
|
|||
}
|
||||
|
||||
AsyncImage(
|
||||
model = finalMediaUrl,
|
||||
model = message.mediaUrl,
|
||||
contentDescription = if (message.isGif) "動態 GIF" else "聊天圖片",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
|
|
@ -796,8 +852,15 @@ fun MessageRow(
|
|||
}
|
||||
}
|
||||
|
||||
// 🌟 核心分流 2:影片處理
|
||||
message.isVideo -> {
|
||||
@Composable
|
||||
private fun VideoMessageContent(
|
||||
message: MessageUiModel,
|
||||
isMe: Boolean,
|
||||
partnerDisplayName: String,
|
||||
currentUserId: String,
|
||||
onReplyClick: (MessageUiModel) -> Unit,
|
||||
onRowClick: (MessageUiModel) -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 240.dp)
|
||||
|
|
@ -811,7 +874,6 @@ fun MessageRow(
|
|||
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
|
||||
}
|
||||
|
||||
// 影片佈局
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
|
|
@ -826,7 +888,7 @@ fun MessageRow(
|
|||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AsyncImage(
|
||||
model = finalMediaUrl,
|
||||
model = message.mediaUrl,
|
||||
contentDescription = "影片預覽",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
@ -843,7 +905,7 @@ fun MessageRow(
|
|||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Default.PlayArrow,
|
||||
imageVector = Icons.Default.PlayArrow,
|
||||
contentDescription = "播放影片",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(28.dp)
|
||||
|
|
@ -853,8 +915,16 @@ fun MessageRow(
|
|||
}
|
||||
}
|
||||
|
||||
//isAudio
|
||||
message.isAudio -> {
|
||||
@Composable
|
||||
private fun AudioMessageContent(
|
||||
message: MessageUiModel,
|
||||
isMe: Boolean,
|
||||
partnerDisplayName: String,
|
||||
currentUserId: String,
|
||||
bubbleColor: Color,
|
||||
viewModel: ChatViewModel,
|
||||
onReplyClick: (MessageUiModel) -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 250.dp)
|
||||
|
|
@ -870,18 +940,27 @@ fun MessageRow(
|
|||
|
||||
AudioMessageBubble(
|
||||
message = message,
|
||||
audioUrl = finalMediaUrl,
|
||||
audioUrl = message.mediaUrl,
|
||||
isMe = isMe,
|
||||
bubbleColor = finalBubbleColor,
|
||||
bubbleColor = bubbleColor,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
//file message
|
||||
message.isFile -> {
|
||||
@Composable
|
||||
private fun FileMessageContent(
|
||||
message: MessageUiModel,
|
||||
isMe: Boolean,
|
||||
partnerDisplayName: String,
|
||||
currentUserId: String,
|
||||
bubbleColor: Color,
|
||||
viewModel: ChatViewModel,
|
||||
onReplyClick: (MessageUiModel) -> Unit,
|
||||
onRowClick: (MessageUiModel) -> Unit
|
||||
) {
|
||||
val downloadStatus = viewModel.getDownloadStatus(message.id)
|
||||
val displayFileName = message.attachment?.filename?.substringAfterLast("/") ?: finalMediaUrl.substringAfterLast("/")
|
||||
val displayFileName = message.attachment?.filename?.substringAfterLast("/") ?: message.mediaUrl.substringAfterLast("/")
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -903,7 +982,7 @@ fun MessageRow(
|
|||
}
|
||||
}
|
||||
)
|
||||
.background(color = finalBubbleColor)
|
||||
.background(color = bubbleColor)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
) {
|
||||
Column {
|
||||
|
|
@ -936,7 +1015,7 @@ fun MessageRow(
|
|||
when (downloadStatus) {
|
||||
is DownloadStatus.NotStarted -> {
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Default.ArrowDownward,
|
||||
imageVector = Icons.Default.ArrowDownward,
|
||||
contentDescription = "下載檔案",
|
||||
tint = Color.Gray,
|
||||
modifier = Modifier.size(24.dp)
|
||||
|
|
@ -946,7 +1025,7 @@ fun MessageRow(
|
|||
val progress = downloadStatus.progress
|
||||
if (progress >= 0f) {
|
||||
CircularProgressIndicator(
|
||||
progress = progress,
|
||||
progress = { progress },
|
||||
modifier = Modifier.fillMaxSize().padding(2.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
strokeWidth = 3.dp
|
||||
|
|
@ -959,7 +1038,7 @@ fun MessageRow(
|
|||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Default.Close,
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "下載中",
|
||||
tint = Color.Gray.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(16.dp)
|
||||
|
|
@ -967,7 +1046,7 @@ fun MessageRow(
|
|||
}
|
||||
is DownloadStatus.Completed -> {
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Default.Check,
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "下載完成",
|
||||
tint = Color.Green,
|
||||
modifier = Modifier.size(24.dp)
|
||||
|
|
@ -975,7 +1054,7 @@ fun MessageRow(
|
|||
}
|
||||
is DownloadStatus.Error -> {
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Default.Warning,
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = "下載失敗",
|
||||
tint = Color.Red,
|
||||
modifier = Modifier.size(24.dp)
|
||||
|
|
@ -1009,8 +1088,55 @@ fun MessageRow(
|
|||
}
|
||||
}
|
||||
|
||||
// 核心分流 3:通話紀錄
|
||||
message.callLogInfo != null -> {
|
||||
@Composable
|
||||
private fun TextMessageContent(
|
||||
message: MessageUiModel,
|
||||
isMe: Boolean,
|
||||
partnerDisplayName: String,
|
||||
currentUserId: String,
|
||||
bubbleColor: Color,
|
||||
onReplyClick: (MessageUiModel) -> Unit,
|
||||
onRowClick: (MessageUiModel) -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 280.dp)
|
||||
.clip(
|
||||
RoundedCornerShape(
|
||||
topStart = 12.dp, topEnd = 12.dp,
|
||||
bottomStart = if (isMe) 12.dp else 0.dp,
|
||||
bottomEnd = if (isMe) 0.dp else 12.dp
|
||||
)
|
||||
)
|
||||
.combinedClickable(
|
||||
onLongClick = { onReplyClick(message) },
|
||||
onClick = { onRowClick(message) }
|
||||
)
|
||||
.background(color = bubbleColor)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Column {
|
||||
message.repliedMessage?.let { replied ->
|
||||
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = message.content,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallLogMessageContent(
|
||||
message: MessageUiModel,
|
||||
isMe: Boolean,
|
||||
partnerDisplayName: String,
|
||||
currentUserId: String,
|
||||
onReplyClick: (MessageUiModel) -> Unit,
|
||||
onRowClick: (MessageUiModel) -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 280.dp)
|
||||
|
|
@ -1027,82 +1153,3 @@ fun MessageRow(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 核心分流 4:純文字訊息
|
||||
else -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 280.dp)
|
||||
.clip(
|
||||
RoundedCornerShape(
|
||||
topStart = 12.dp, topEnd = 12.dp,
|
||||
bottomStart = if (isMe) 12.dp else 0.dp,
|
||||
bottomEnd = if (isMe) 0.dp else 12.dp
|
||||
)
|
||||
)
|
||||
.combinedClickable(
|
||||
onLongClick = { onReplyClick(message) },
|
||||
onClick = { onRowClick(message) }
|
||||
)
|
||||
.background(color = finalBubbleColor)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Column {
|
||||
message.repliedMessage?.let { replied ->
|
||||
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = message.content,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 按讚/反應功能
|
||||
if (!message.reactions.isNullOrEmpty()) {
|
||||
ReactionRow(
|
||||
reactions = message.reactions,
|
||||
onReactionClick = { emoji -> onReactionClick(message, emoji) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 💡 提取出的「被回覆訊息預覽」組件,避免重複代碼並維持排版乾淨
|
||||
@Composable
|
||||
private fun RepliedMessagePreview(
|
||||
replied: Message,
|
||||
isMe: Boolean,
|
||||
partnerDisplayName: String,
|
||||
currentUserId: String
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 6.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color(0x1A000000))
|
||||
.padding(start = 6.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(3.dp)
|
||||
.height(22.dp)
|
||||
.background(if (isMe) Color(0xFF4CAF50) else Color(0xFF78909C))
|
||||
)
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Text(
|
||||
text = if (replied.attachment?.mimeType?.startsWith("image/") == true) "[圖片]" else replied.content ?: "",
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontSize = 14.sp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,8 @@ import com.example.login_v3.data.repository.dm.ChatRoomsRepository
|
|||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import com.example.login_v3.data.api.api_class.Message
|
||||
import com.example.login_v3.home.Message.UI.Detail.MessageUiModel
|
||||
import com.example.login_v3.home.Message.UI.Detail.toUiModel
|
||||
import com.example.login_v3.data.api.api_class.fullContactAvatarUrl
|
||||
import com.example.login_v3.data.repository.basic.TokenManager
|
||||
import com.example.login_v3.home.Message.ViewModel.UserStatus
|
||||
|
|
@ -70,7 +71,7 @@ sealed interface MessagesUiState {
|
|||
val roomTitle: String,
|
||||
val partnerStatus: UserStatus,
|
||||
val partnerAvatarUrl: Any?,
|
||||
val messages: List<Message>,
|
||||
val messages: List<MessageUiModel>,
|
||||
val currentUserId: String
|
||||
) : MessagesUiState
|
||||
|
||||
|
|
@ -102,6 +103,9 @@ class ChatViewModel @Inject constructor(
|
|||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
// 🚀 核心優化:避免重複預載相同的 URL
|
||||
private val prefetchedUrls = mutableSetOf<String>()
|
||||
|
||||
// 從導航參數自動獲取 roomId
|
||||
val roomId: String = savedStateHandle.get<String>("roomId").orEmpty()
|
||||
|
||||
|
|
@ -123,7 +127,7 @@ class ChatViewModel @Inject constructor(
|
|||
roomTitle = title,
|
||||
partnerStatus = userStatus,
|
||||
partnerAvatarUrl = avatar,
|
||||
messages = messages,
|
||||
messages = messages.map { it.toUiModel() },
|
||||
currentUserId = userId
|
||||
)
|
||||
}
|
||||
|
|
@ -134,9 +138,10 @@ class ChatViewModel @Inject constructor(
|
|||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, MessagesUiState.Loading)
|
||||
|
||||
private fun prefetchMedia(messages: List<Message>) {
|
||||
private fun prefetchMedia(messages: List<MessageUiModel>) {
|
||||
messages.forEach { msg ->
|
||||
if (msg.isImage || msg.isVideo || msg.isGif) {
|
||||
if ((msg.isImage || msg.isVideo || msg.isGif) && !prefetchedUrls.contains(msg.mediaUrl)) {
|
||||
prefetchedUrls.add(msg.mediaUrl)
|
||||
val request = ImageRequest.Builder(application)
|
||||
.data(msg.mediaUrl)
|
||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||
|
|
@ -152,15 +157,15 @@ class ChatViewModel @Inject constructor(
|
|||
// --- 其餘狀態 (保持原樣) ---
|
||||
private val _deleteMessageState = MutableStateFlow<DeleteMessageState>(DeleteMessageState.Idle)
|
||||
val deleteMessageState: StateFlow<DeleteMessageState> = _deleteMessageState.asStateFlow()
|
||||
private val _actionMessage = MutableStateFlow<Message?>(null)
|
||||
val actionMessage: StateFlow<Message?> = _actionMessage.asStateFlow()
|
||||
private val _actionMessage = MutableStateFlow<MessageUiModel?>(null)
|
||||
val actionMessage: StateFlow<MessageUiModel?> = _actionMessage.asStateFlow()
|
||||
|
||||
fun setActionMessage(message: Message?) { _actionMessage.value = message }
|
||||
fun setActionMessage(message: MessageUiModel?) { _actionMessage.value = message }
|
||||
fun clearActionMessage() { _actionMessage.value = null }
|
||||
|
||||
private val _replyingMessage = MutableStateFlow<Message?>(null)
|
||||
val replyingMessage: StateFlow<Message?> = _replyingMessage.asStateFlow()
|
||||
fun setReplyingMessage(message: Message?) { _replyingMessage.value = message }
|
||||
private val _replyingMessage = MutableStateFlow<MessageUiModel?>(null)
|
||||
val replyingMessage: StateFlow<MessageUiModel?> = _replyingMessage.asStateFlow()
|
||||
fun setReplyingMessage(message: MessageUiModel?) { _replyingMessage.value = message }
|
||||
|
||||
private val _sendMessageState = MutableStateFlow<SendMessageState>(SendMessageState.Idle)
|
||||
val sendMessageState: StateFlow<SendMessageState> = _sendMessageState
|
||||
|
|
@ -474,7 +479,7 @@ class ChatViewModel @Inject constructor(
|
|||
fun getDownloadStatus(messageId: String): DownloadStatus {
|
||||
return fileDownloadStates[messageId] ?: DownloadStatus.NotStarted
|
||||
}
|
||||
fun downloadFile(message: Message, displayFileName: String) {
|
||||
fun downloadFile(message: MessageUiModel, displayFileName: String) {
|
||||
val messageId = message.id
|
||||
|
||||
// 🎯 取得後端給的直接 URL。請根據你實際的 Message 資料結構調整欄位名稱
|
||||
|
|
|
|||
Loading…
Reference in a new issue