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.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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 com.example.login_v3.home.Message.ViewModel.Detail.ChatViewModel
|
||||||
import okhttp3.internal.concurrent.formatDuration
|
import okhttp3.internal.concurrent.formatDuration
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioMessageBubble(
|
fun AudioMessageBubble(
|
||||||
message: Message,
|
message: MessageUiModel,
|
||||||
audioUrl: String,
|
audioUrl: String,
|
||||||
isMe: Boolean,
|
isMe: Boolean,
|
||||||
bubbleColor: Color,
|
bubbleColor: Color,
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,14 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.unit.dp
|
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
|
@Composable
|
||||||
fun MessageActionMenuRow(
|
fun MessageActionMenuRow(
|
||||||
message: Message,
|
message: MessageUiModel,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onReplyClick: (Message) -> Unit, // 👈 修正:這裡只需要定義型態
|
onReplyClick: (MessageUiModel) -> Unit, // 👈 修正:這裡只需要定義型態
|
||||||
onDeleteClick: (Message) -> Unit,
|
onDeleteClick: (MessageUiModel) -> Unit,
|
||||||
isOwnMessage: Boolean,
|
isOwnMessage: Boolean,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
|
@ -104,15 +104,14 @@ fun MessageActionMenuRow(
|
||||||
)
|
)
|
||||||
|
|
||||||
// 2. 複製文字
|
// 2. 複製文字
|
||||||
val isImage = message.content?.startsWith("http") == true || message.attachment != null
|
val isImage = message.isImage
|
||||||
ChatMenuButton(
|
ChatMenuButton(
|
||||||
imageVector = Icons.Default.ContentCopy,
|
imageVector = Icons.Default.ContentCopy,
|
||||||
label = "複製",
|
label = "複製",
|
||||||
enabled = !isImage,
|
enabled = !isImage,
|
||||||
onClick = {
|
onClick = {
|
||||||
message.content?.let { text ->
|
val text = message.content
|
||||||
clipboardManager.setText(AnnotatedString(text))
|
clipboardManager.setText(AnnotatedString(text))
|
||||||
}
|
|
||||||
onCancel()
|
onCancel()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,12 @@ import androidx.compose.ui.draw.blur
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
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
|
@Composable
|
||||||
fun MessageInputBar(
|
fun MessageInputBar(
|
||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
replyingMessage: Message?,
|
replyingMessage: MessageUiModel?,
|
||||||
onCancelReply: () -> Unit,
|
onCancelReply: () -> Unit,
|
||||||
onSendClick: (String) -> Unit,
|
onSendClick: (String) -> Unit,
|
||||||
onAttachmentClick: () -> Unit
|
onAttachmentClick: () -> Unit
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import coil.compose.AsyncImage
|
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.MessageActionMenuRow
|
||||||
import com.example.login_v3.home.Message.UI.Detail.Message_Component.MessageEmojiBar
|
import com.example.login_v3.home.Message.UI.Detail.Message_Component.MessageEmojiBar
|
||||||
import com.example.login_v3.home.Message.UI.Detail.Message_Component.MessageInputBar
|
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
|
//bottom bar state
|
||||||
sealed interface BottomBarState {
|
sealed interface BottomBarState {
|
||||||
object Input : BottomBarState // 預設:輸入框
|
object Input : BottomBarState // 預設:輸入框
|
||||||
data class ActionMenu(val message: Message) : BottomBarState // 長按:動作選單
|
data class ActionMenu(val message: MessageUiModel) : BottomBarState // 長按:動作選單
|
||||||
data class EmojiMenu(val message: Message) : BottomBarState // 單擊:Emoji 工具列
|
data class EmojiMenu(val message: MessageUiModel) : BottomBarState // 單擊:Emoji 工具列
|
||||||
object AttachmentMenu : BottomBarState // 🎯 新增:附件選單
|
object AttachmentMenu : BottomBarState // 🎯 新增:附件選單
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,7 +137,7 @@ fun MessageMessaging(
|
||||||
//message that selected
|
//message that selected
|
||||||
val actionMessage by viewModel.actionMessage.collectAsStateWithLifecycle()
|
val actionMessage by viewModel.actionMessage.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
var emojiTargetMessage by remember { mutableStateOf<Message?>(null) }
|
var emojiTargetMessage by remember { mutableStateOf<MessageUiModel?>(null) }
|
||||||
|
|
||||||
//inspect image
|
//inspect image
|
||||||
var lightboxImageUrl by remember { mutableStateOf<String?>(null) }
|
var lightboxImageUrl by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
@ -466,7 +465,7 @@ fun MessageMessaging(
|
||||||
MessageEmojiBar(
|
MessageEmojiBar(
|
||||||
// 🌟 移除了 commonEmojis 參數,變得非常清爽!
|
// 🌟 移除了 commonEmojis 參數,變得非常清爽!
|
||||||
onEmojiClick = { emoji ->
|
onEmojiClick = { emoji ->
|
||||||
val targetReaction = currentEmojiMessage.reactions?.find { it.emoji == emoji }
|
val targetReaction = currentEmojiMessage.reactions.find { it.emoji == emoji }
|
||||||
if (targetReaction?.meReacted == true) {
|
if (targetReaction?.meReacted == true) {
|
||||||
viewModel.removeMessageReaction(roomId, currentEmojiMessage.id, emoji)
|
viewModel.removeMessageReaction(roomId, currentEmojiMessage.id, emoji)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -535,38 +534,14 @@ fun MessageMessaging(
|
||||||
},
|
},
|
||||||
// 💡 單擊事件:開啟 Emoji 選單,同時清空長按選單
|
// 💡 單擊事件:開啟 Emoji 選單,同時清空長按選單
|
||||||
onRowClick = { message ->
|
onRowClick = { message ->
|
||||||
val contentLowerCase = (message.content ?: "").lowercase()
|
|
||||||
|
|
||||||
//is image
|
//is image
|
||||||
val isAttachmentImage = message.attachment?.mimeType?.startsWith("image/") == true
|
val isImage = message.isImage
|
||||||
val imageExtensions = listOf(".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif")
|
|
||||||
val isImage = isAttachmentImage || imageExtensions.any { contentLowerCase.contains(it) }
|
|
||||||
|
|
||||||
//is video
|
//is video
|
||||||
val isAttachmentVideo = message.attachment?.mimeType?.startsWith("video/") == true
|
val isVideo = message.isVideo
|
||||||
val videoExtensions = listOf(".mp4", ".mov", ".mkv", ".avi", ".3gp", ".webm")
|
|
||||||
val isVideo = isAttachmentVideo || videoExtensions.any { contentLowerCase.contains(it) }
|
|
||||||
|
|
||||||
if (isImage || isVideo) {
|
if (isImage || isVideo) {
|
||||||
// 計算乾淨的 URL 網址
|
// 計算乾淨的 URL 網址
|
||||||
val rawPath = when {
|
val finalUrl = message.mediaUrl
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 關閉所有底部的 Emoji 或動作選單
|
// 關閉所有底部的 Emoji 或動作選單
|
||||||
viewModel.clearActionMessage()
|
viewModel.clearActionMessage()
|
||||||
|
|
@ -587,7 +562,7 @@ fun MessageMessaging(
|
||||||
onReactionClick = { message, emoji ->
|
onReactionClick = { message, emoji ->
|
||||||
// 這是訊息氣泡下方既有 Reaction 小標籤的點擊事件,保持原樣即可
|
// 這是訊息氣泡下方既有 Reaction 小標籤的點擊事件,保持原樣即可
|
||||||
val targetReaction =
|
val targetReaction =
|
||||||
message.reactions?.find { it.emoji == emoji }
|
message.reactions.find { it.emoji == emoji }
|
||||||
if (targetReaction?.meReacted == true) {
|
if (targetReaction?.meReacted == true) {
|
||||||
viewModel.removeMessageReaction(roomId, message.id, emoji)
|
viewModel.removeMessageReaction(roomId, message.id, emoji)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -632,13 +607,13 @@ fun MessageList(
|
||||||
currentUserId: String,
|
currentUserId: String,
|
||||||
partnerAvatarUrl: Any?,
|
partnerAvatarUrl: Any?,
|
||||||
partnerDisplayName: String,
|
partnerDisplayName: String,
|
||||||
messages: List<Message>,
|
messages: List<MessageUiModel>,
|
||||||
activeEmojiMessageId: String?,
|
activeEmojiMessageId: String?,
|
||||||
activeActionMessageId: String?,
|
activeActionMessageId: String?,
|
||||||
viewModel: ChatViewModel,
|
viewModel: ChatViewModel,
|
||||||
onReplyClick: (Message) -> Unit,
|
onReplyClick: (MessageUiModel) -> Unit,
|
||||||
onReactionClick: (Message, String) -> Unit,
|
onReactionClick: (MessageUiModel, String) -> Unit,
|
||||||
onRowClick: (Message) -> Unit,
|
onRowClick: (MessageUiModel) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
// 1. 建立 LazyColumn 的滾動狀態監聽
|
// 1. 建立 LazyColumn 的滾動狀態監聽
|
||||||
|
|
@ -676,7 +651,17 @@ fun MessageList(
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
items = visibleMessages,
|
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 ->
|
) { message ->
|
||||||
val isMe = message.senderId == currentUserId
|
val isMe = message.senderId == currentUserId
|
||||||
MessageRow(
|
MessageRow(
|
||||||
|
|
@ -698,23 +683,21 @@ fun MessageList(
|
||||||
private fun String?.isNullTabOrBlank(): Boolean = this == null || this.trim().isBlank()
|
private fun String?.isNullTabOrBlank(): Boolean = this == null || this.trim().isBlank()
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageRow(
|
fun MessageRow(
|
||||||
message: Message,
|
message: MessageUiModel,
|
||||||
isMe: Boolean,
|
isMe: Boolean,
|
||||||
partnerAvatarUrl: Any?,
|
partnerAvatarUrl: Any?,
|
||||||
partnerDisplayName: String,
|
partnerDisplayName: String,
|
||||||
isHighlight: Boolean,
|
isHighlight: Boolean,
|
||||||
currentUserId: String,
|
currentUserId: String,
|
||||||
viewModel: ChatViewModel,
|
viewModel: ChatViewModel,
|
||||||
onReplyClick: (Message) -> Unit,
|
onReplyClick: (MessageUiModel) -> Unit,
|
||||||
onReactionClick: (Message, String) -> Unit,
|
onReactionClick: (MessageUiModel, String) -> Unit,
|
||||||
onRowClick: (Message) -> Unit,
|
onRowClick: (MessageUiModel) -> Unit,
|
||||||
) {
|
) {
|
||||||
val finalMediaUrl = message.mediaUrl
|
|
||||||
|
|
||||||
val finalBubbleColor = remember(message.status, isHighlight, message.callLogInfo) {
|
val finalBubbleColor = remember(message.status, isHighlight, message.callLogInfo) {
|
||||||
val baseColor = when {
|
val baseColor = when {
|
||||||
message.callLogInfo != null -> {
|
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 && isHighlight -> Color(0xFFB4E197)
|
||||||
isMe -> Color(0xFFDCF8C6)
|
isMe -> Color(0xFFDCF8C6)
|
||||||
|
|
@ -748,7 +731,7 @@ fun MessageRow(
|
||||||
) {
|
) {
|
||||||
if (isMe && message.status == MessageStatus.FAILED) {
|
if (isMe && message.status == MessageStatus.FAILED) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = androidx.compose.material.icons.Icons.Default.Warning,
|
imageVector = Icons.Default.Warning,
|
||||||
contentDescription = "發送失敗",
|
contentDescription = "發送失敗",
|
||||||
tint = Color.Red,
|
tint = Color.Red,
|
||||||
modifier = Modifier.size(20.dp).clickable { /* 重發邏輯 */ }
|
modifier = Modifier.size(20.dp).clickable { /* 重發邏輯 */ }
|
||||||
|
|
@ -760,309 +743,28 @@ fun MessageRow(
|
||||||
horizontalAlignment = if (isMe) Alignment.End else Alignment.Start
|
horizontalAlignment = if (isMe) Alignment.End else Alignment.Start
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
// 核心分流 1:圖片處理
|
|
||||||
message.isImage -> {
|
message.isImage -> {
|
||||||
Column(
|
ImageMessageContent(message, isMe, partnerDisplayName, currentUserId, onReplyClick, onRowClick)
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(max = 240.dp)
|
|
||||||
.combinedClickable(
|
|
||||||
onLongClick = { onReplyClick(message) },
|
|
||||||
onClick = { onRowClick(message) }
|
|
||||||
),
|
|
||||||
horizontalAlignment = if (isMe) Alignment.End else Alignment.Start
|
|
||||||
) {
|
|
||||||
message.repliedMessage?.let { replied ->
|
|
||||||
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncImage(
|
|
||||||
model = finalMediaUrl,
|
|
||||||
contentDescription = if (message.isGif) "動態 GIF" else "聊天圖片",
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(top = 4.dp)
|
|
||||||
.sizeIn(maxWidth = 240.dp, maxHeight = 300.dp)
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.border(
|
|
||||||
width = 0.5.dp,
|
|
||||||
color = Color.LightGray.copy(alpha = 0.4f),
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
)
|
|
||||||
.background(Color(0xFFF5F5F5)),
|
|
||||||
onError = { errorState ->
|
|
||||||
android.util.Log.e("ChatImageError", "原因: ${errorState.result.throwable}")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🌟 核心分流 2:影片處理
|
|
||||||
message.isVideo -> {
|
message.isVideo -> {
|
||||||
Column(
|
VideoMessageContent(message, isMe, partnerDisplayName, currentUserId, onReplyClick, onRowClick)
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(max = 240.dp)
|
|
||||||
.combinedClickable(
|
|
||||||
onLongClick = { onReplyClick(message) },
|
|
||||||
onClick = { onRowClick(message) }
|
|
||||||
),
|
|
||||||
horizontalAlignment = if (isMe) Alignment.End else Alignment.Start
|
|
||||||
) {
|
|
||||||
message.repliedMessage?.let { replied ->
|
|
||||||
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 影片佈局
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(top = 4.dp)
|
|
||||||
.size(width = 240.dp, height = 160.dp)
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(Color.Black)
|
|
||||||
.border(
|
|
||||||
width = 0.5.dp,
|
|
||||||
color = Color.LightGray.copy(alpha = 0.4f),
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
AsyncImage(
|
|
||||||
model = finalMediaUrl,
|
|
||||||
contentDescription = "影片預覽",
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
alpha = 0.8f,
|
|
||||||
onError = { errorState ->
|
|
||||||
android.util.Log.e("ChatVideoThumbError", "原因: ${errorState.result.throwable}")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(48.dp)
|
|
||||||
.background(Color.Black.copy(alpha = 0.5f), CircleShape),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = androidx.compose.material.icons.Icons.Default.PlayArrow,
|
|
||||||
contentDescription = "播放影片",
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(28.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//isAudio
|
|
||||||
message.isAudio -> {
|
message.isAudio -> {
|
||||||
Column(
|
AudioMessageContent(message, isMe, partnerDisplayName, currentUserId, finalBubbleColor, viewModel, onReplyClick)
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(max = 250.dp)
|
|
||||||
.combinedClickable(
|
|
||||||
onLongClick = { onReplyClick(message) },
|
|
||||||
onClick = { /* 整塊氣泡點擊邏輯,可留空 */ }
|
|
||||||
),
|
|
||||||
horizontalAlignment = if (isMe) Alignment.End else Alignment.Start
|
|
||||||
) {
|
|
||||||
message.repliedMessage?.let { replied ->
|
|
||||||
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
|
|
||||||
}
|
|
||||||
|
|
||||||
AudioMessageBubble(
|
|
||||||
message = message,
|
|
||||||
audioUrl = finalMediaUrl,
|
|
||||||
isMe = isMe,
|
|
||||||
bubbleColor = finalBubbleColor,
|
|
||||||
viewModel = viewModel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//file message
|
|
||||||
message.isFile -> {
|
message.isFile -> {
|
||||||
val downloadStatus = viewModel.getDownloadStatus(message.id)
|
FileMessageContent(message, isMe, partnerDisplayName, currentUserId, finalBubbleColor, viewModel, onReplyClick, onRowClick)
|
||||||
val displayFileName = message.attachment?.filename?.substringAfterLast("/") ?: finalMediaUrl.substringAfterLast("/")
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(max = 260.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 = {
|
|
||||||
if (downloadStatus is DownloadStatus.Completed) {
|
|
||||||
onRowClick(message)
|
|
||||||
} else {
|
|
||||||
viewModel.downloadFile(message, displayFileName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.background(color = finalBubbleColor)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
message.repliedMessage?.let { replied ->
|
|
||||||
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.background(Color.White.copy(alpha = 0.6f), CircleShape)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.clickable {
|
|
||||||
when (downloadStatus) {
|
|
||||||
is DownloadStatus.NotStarted, is DownloadStatus.Error -> {
|
|
||||||
viewModel.downloadFile(message, displayFileName)
|
|
||||||
}
|
|
||||||
is DownloadStatus.Completed -> {
|
|
||||||
onRowClick(message)
|
|
||||||
}
|
|
||||||
is DownloadStatus.Downloading -> {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
when (downloadStatus) {
|
|
||||||
is DownloadStatus.NotStarted -> {
|
|
||||||
Icon(
|
|
||||||
imageVector = androidx.compose.material.icons.Icons.Default.ArrowDownward,
|
|
||||||
contentDescription = "下載檔案",
|
|
||||||
tint = Color.Gray,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is DownloadStatus.Downloading -> {
|
|
||||||
val progress = downloadStatus.progress
|
|
||||||
if (progress >= 0f) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
progress = progress,
|
|
||||||
modifier = Modifier.fillMaxSize().padding(2.dp),
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
strokeWidth = 3.dp
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.fillMaxSize().padding(2.dp),
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
strokeWidth = 3.dp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Icon(
|
|
||||||
imageVector = androidx.compose.material.icons.Icons.Default.Close,
|
|
||||||
contentDescription = "下載中",
|
|
||||||
tint = Color.Gray.copy(alpha = 0.7f),
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is DownloadStatus.Completed -> {
|
|
||||||
Icon(
|
|
||||||
imageVector = androidx.compose.material.icons.Icons.Default.Check,
|
|
||||||
contentDescription = "下載完成",
|
|
||||||
tint = Color.Green,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is DownloadStatus.Error -> {
|
|
||||||
Icon(
|
|
||||||
imageVector = androidx.compose.material.icons.Icons.Default.Warning,
|
|
||||||
contentDescription = "下載失敗",
|
|
||||||
tint = Color.Red,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = displayFileName,
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp),
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = when (downloadStatus) {
|
|
||||||
is DownloadStatus.Downloading -> {
|
|
||||||
val progress = downloadStatus.progress
|
|
||||||
if (progress >= 0f) "下載中... ${(progress * 100).toInt()}%" else "下載中..."
|
|
||||||
}
|
|
||||||
is DownloadStatus.Completed -> "已下載"
|
|
||||||
is DownloadStatus.Error -> "下載失敗"
|
|
||||||
else -> "檔案"
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp, color = Color.Gray)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 核心分流 3:通話紀錄
|
|
||||||
message.callLogInfo != null -> {
|
message.callLogInfo != null -> {
|
||||||
Box(
|
CallLogMessageContent(message, isMe, partnerDisplayName, currentUserId, onReplyClick, onRowClick)
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(max = 280.dp)
|
|
||||||
.combinedClickable(
|
|
||||||
onLongClick = { onReplyClick(message) },
|
|
||||||
onClick = { onRowClick(message) }
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
CallLogBubble(
|
|
||||||
callLog = message.callLogInfo!!,
|
|
||||||
isMe = isMe,
|
|
||||||
currentUserId = currentUserId,
|
|
||||||
partnerDisplayName = partnerDisplayName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 核心分流 4:純文字訊息
|
|
||||||
else -> {
|
else -> {
|
||||||
Box(
|
TextMessageContent(message, isMe, partnerDisplayName, currentUserId, finalBubbleColor, onReplyClick, onRowClick)
|
||||||
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. 按讚/反應功能
|
// 3. 按讚/反應功能
|
||||||
if (!message.reactions.isNullOrEmpty()) {
|
if (message.reactions.isNotEmpty()) {
|
||||||
ReactionRow(
|
ReactionRow(
|
||||||
reactions = message.reactions,
|
reactions = message.reactions,
|
||||||
onReactionClick = { emoji -> onReactionClick(message, emoji) }
|
onReactionClick = { emoji -> onReactionClick(message, emoji) }
|
||||||
|
|
@ -1077,7 +779,7 @@ fun MessageRow(
|
||||||
// 💡 提取出的「被回覆訊息預覽」組件,避免重複代碼並維持排版乾淨
|
// 💡 提取出的「被回覆訊息預覽」組件,避免重複代碼並維持排版乾淨
|
||||||
@Composable
|
@Composable
|
||||||
private fun RepliedMessagePreview(
|
private fun RepliedMessagePreview(
|
||||||
replied: Message,
|
replied: MessageUiModel,
|
||||||
isMe: Boolean,
|
isMe: Boolean,
|
||||||
partnerDisplayName: String,
|
partnerDisplayName: String,
|
||||||
currentUserId: String
|
currentUserId: String
|
||||||
|
|
@ -1106,3 +808,348 @@ private fun RepliedMessagePreview(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
.combinedClickable(
|
||||||
|
onLongClick = { onReplyClick(message) },
|
||||||
|
onClick = { onRowClick(message) }
|
||||||
|
),
|
||||||
|
horizontalAlignment = if (isMe) Alignment.End else Alignment.Start
|
||||||
|
) {
|
||||||
|
message.repliedMessage?.let { replied ->
|
||||||
|
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncImage(
|
||||||
|
model = message.mediaUrl,
|
||||||
|
contentDescription = if (message.isGif) "動態 GIF" else "聊天圖片",
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 4.dp)
|
||||||
|
.sizeIn(maxWidth = 240.dp, maxHeight = 300.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.border(
|
||||||
|
width = 0.5.dp,
|
||||||
|
color = Color.LightGray.copy(alpha = 0.4f),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
.background(Color(0xFFF5F5F5)),
|
||||||
|
onError = { errorState ->
|
||||||
|
android.util.Log.e("ChatImageError", "原因: ${errorState.result.throwable}")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
.combinedClickable(
|
||||||
|
onLongClick = { onReplyClick(message) },
|
||||||
|
onClick = { onRowClick(message) }
|
||||||
|
),
|
||||||
|
horizontalAlignment = if (isMe) Alignment.End else Alignment.Start
|
||||||
|
) {
|
||||||
|
message.repliedMessage?.let { replied ->
|
||||||
|
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 4.dp)
|
||||||
|
.size(width = 240.dp, height = 160.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color.Black)
|
||||||
|
.border(
|
||||||
|
width = 0.5.dp,
|
||||||
|
color = Color.LightGray.copy(alpha = 0.4f),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = message.mediaUrl,
|
||||||
|
contentDescription = "影片預覽",
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
alpha = 0.8f,
|
||||||
|
onError = { errorState ->
|
||||||
|
android.util.Log.e("ChatVideoThumbError", "原因: ${errorState.result.throwable}")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.background(Color.Black.copy(alpha = 0.5f), CircleShape),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PlayArrow,
|
||||||
|
contentDescription = "播放影片",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
.combinedClickable(
|
||||||
|
onLongClick = { onReplyClick(message) },
|
||||||
|
onClick = { /* 整塊氣泡點擊邏輯,可留空 */ }
|
||||||
|
),
|
||||||
|
horizontalAlignment = if (isMe) Alignment.End else Alignment.Start
|
||||||
|
) {
|
||||||
|
message.repliedMessage?.let { replied ->
|
||||||
|
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioMessageBubble(
|
||||||
|
message = message,
|
||||||
|
audioUrl = message.mediaUrl,
|
||||||
|
isMe = isMe,
|
||||||
|
bubbleColor = bubbleColor,
|
||||||
|
viewModel = viewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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("/") ?: message.mediaUrl.substringAfterLast("/")
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 260.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 = {
|
||||||
|
if (downloadStatus is DownloadStatus.Completed) {
|
||||||
|
onRowClick(message)
|
||||||
|
} else {
|
||||||
|
viewModel.downloadFile(message, displayFileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.background(color = bubbleColor)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
message.repliedMessage?.let { replied ->
|
||||||
|
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.background(Color.White.copy(alpha = 0.6f), CircleShape)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.clickable {
|
||||||
|
when (downloadStatus) {
|
||||||
|
is DownloadStatus.NotStarted, is DownloadStatus.Error -> {
|
||||||
|
viewModel.downloadFile(message, displayFileName)
|
||||||
|
}
|
||||||
|
is DownloadStatus.Completed -> {
|
||||||
|
onRowClick(message)
|
||||||
|
}
|
||||||
|
is DownloadStatus.Downloading -> {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
when (downloadStatus) {
|
||||||
|
is DownloadStatus.NotStarted -> {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowDownward,
|
||||||
|
contentDescription = "下載檔案",
|
||||||
|
tint = Color.Gray,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is DownloadStatus.Downloading -> {
|
||||||
|
val progress = downloadStatus.progress
|
||||||
|
if (progress >= 0f) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
progress = { progress },
|
||||||
|
modifier = Modifier.fillMaxSize().padding(2.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
strokeWidth = 3.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(2.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
strokeWidth = 3.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "下載中",
|
||||||
|
tint = Color.Gray.copy(alpha = 0.7f),
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is DownloadStatus.Completed -> {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = "下載完成",
|
||||||
|
tint = Color.Green,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is DownloadStatus.Error -> {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Warning,
|
||||||
|
contentDescription = "下載失敗",
|
||||||
|
tint = Color.Red,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = displayFileName,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 15.sp),
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = when (downloadStatus) {
|
||||||
|
is DownloadStatus.Downloading -> {
|
||||||
|
val progress = downloadStatus.progress
|
||||||
|
if (progress >= 0f) "下載中... ${(progress * 100).toInt()}%" else "下載中..."
|
||||||
|
}
|
||||||
|
is DownloadStatus.Completed -> "已下載"
|
||||||
|
is DownloadStatus.Error -> "下載失敗"
|
||||||
|
else -> "檔案"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp, color = Color.Gray)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
.combinedClickable(
|
||||||
|
onLongClick = { onReplyClick(message) },
|
||||||
|
onClick = { onRowClick(message) }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
CallLogBubble(
|
||||||
|
callLog = message.callLogInfo!!,
|
||||||
|
isMe = isMe,
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
partnerDisplayName = partnerDisplayName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ import com.example.login_v3.data.repository.dm.ChatRoomsRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
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.api.api_class.fullContactAvatarUrl
|
||||||
import com.example.login_v3.data.repository.basic.TokenManager
|
import com.example.login_v3.data.repository.basic.TokenManager
|
||||||
import com.example.login_v3.home.Message.ViewModel.UserStatus
|
import com.example.login_v3.home.Message.ViewModel.UserStatus
|
||||||
|
|
@ -70,7 +71,7 @@ sealed interface MessagesUiState {
|
||||||
val roomTitle: String,
|
val roomTitle: String,
|
||||||
val partnerStatus: UserStatus,
|
val partnerStatus: UserStatus,
|
||||||
val partnerAvatarUrl: Any?,
|
val partnerAvatarUrl: Any?,
|
||||||
val messages: List<Message>,
|
val messages: List<MessageUiModel>,
|
||||||
val currentUserId: String
|
val currentUserId: String
|
||||||
) : MessagesUiState
|
) : MessagesUiState
|
||||||
|
|
||||||
|
|
@ -102,6 +103,9 @@ class ChatViewModel @Inject constructor(
|
||||||
savedStateHandle: SavedStateHandle
|
savedStateHandle: SavedStateHandle
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
// 🚀 核心優化:避免重複預載相同的 URL
|
||||||
|
private val prefetchedUrls = mutableSetOf<String>()
|
||||||
|
|
||||||
// 從導航參數自動獲取 roomId
|
// 從導航參數自動獲取 roomId
|
||||||
val roomId: String = savedStateHandle.get<String>("roomId").orEmpty()
|
val roomId: String = savedStateHandle.get<String>("roomId").orEmpty()
|
||||||
|
|
||||||
|
|
@ -123,7 +127,7 @@ class ChatViewModel @Inject constructor(
|
||||||
roomTitle = title,
|
roomTitle = title,
|
||||||
partnerStatus = userStatus,
|
partnerStatus = userStatus,
|
||||||
partnerAvatarUrl = avatar,
|
partnerAvatarUrl = avatar,
|
||||||
messages = messages,
|
messages = messages.map { it.toUiModel() },
|
||||||
currentUserId = userId
|
currentUserId = userId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -134,9 +138,10 @@ class ChatViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, MessagesUiState.Loading)
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, MessagesUiState.Loading)
|
||||||
|
|
||||||
private fun prefetchMedia(messages: List<Message>) {
|
private fun prefetchMedia(messages: List<MessageUiModel>) {
|
||||||
messages.forEach { msg ->
|
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)
|
val request = ImageRequest.Builder(application)
|
||||||
.data(msg.mediaUrl)
|
.data(msg.mediaUrl)
|
||||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||||
|
|
@ -152,15 +157,15 @@ class ChatViewModel @Inject constructor(
|
||||||
// --- 其餘狀態 (保持原樣) ---
|
// --- 其餘狀態 (保持原樣) ---
|
||||||
private val _deleteMessageState = MutableStateFlow<DeleteMessageState>(DeleteMessageState.Idle)
|
private val _deleteMessageState = MutableStateFlow<DeleteMessageState>(DeleteMessageState.Idle)
|
||||||
val deleteMessageState: StateFlow<DeleteMessageState> = _deleteMessageState.asStateFlow()
|
val deleteMessageState: StateFlow<DeleteMessageState> = _deleteMessageState.asStateFlow()
|
||||||
private val _actionMessage = MutableStateFlow<Message?>(null)
|
private val _actionMessage = MutableStateFlow<MessageUiModel?>(null)
|
||||||
val actionMessage: StateFlow<Message?> = _actionMessage.asStateFlow()
|
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 }
|
fun clearActionMessage() { _actionMessage.value = null }
|
||||||
|
|
||||||
private val _replyingMessage = MutableStateFlow<Message?>(null)
|
private val _replyingMessage = MutableStateFlow<MessageUiModel?>(null)
|
||||||
val replyingMessage: StateFlow<Message?> = _replyingMessage.asStateFlow()
|
val replyingMessage: StateFlow<MessageUiModel?> = _replyingMessage.asStateFlow()
|
||||||
fun setReplyingMessage(message: Message?) { _replyingMessage.value = message }
|
fun setReplyingMessage(message: MessageUiModel?) { _replyingMessage.value = message }
|
||||||
|
|
||||||
private val _sendMessageState = MutableStateFlow<SendMessageState>(SendMessageState.Idle)
|
private val _sendMessageState = MutableStateFlow<SendMessageState>(SendMessageState.Idle)
|
||||||
val sendMessageState: StateFlow<SendMessageState> = _sendMessageState
|
val sendMessageState: StateFlow<SendMessageState> = _sendMessageState
|
||||||
|
|
@ -474,7 +479,7 @@ class ChatViewModel @Inject constructor(
|
||||||
fun getDownloadStatus(messageId: String): DownloadStatus {
|
fun getDownloadStatus(messageId: String): DownloadStatus {
|
||||||
return fileDownloadStates[messageId] ?: DownloadStatus.NotStarted
|
return fileDownloadStates[messageId] ?: DownloadStatus.NotStarted
|
||||||
}
|
}
|
||||||
fun downloadFile(message: Message, displayFileName: String) {
|
fun downloadFile(message: MessageUiModel, displayFileName: String) {
|
||||||
val messageId = message.id
|
val messageId = message.id
|
||||||
|
|
||||||
// 🎯 取得後端給的直接 URL。請根據你實際的 Message 資料結構調整欄位名稱
|
// 🎯 取得後端給的直接 URL。請根據你實際的 Message 資料結構調整欄位名稱
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue