add delta sync for dm-v5

This commit is contained in:
eric 2026-07-23 16:22:04 +08:00
parent 0a0ae637ca
commit 8202b3391d
6 changed files with 150 additions and 176 deletions

View file

@ -9,13 +9,13 @@
- `[x]` Optimize Chat Room (Messages) - `[x]` Optimize Chat Room (Messages)
- `[x]` Refactor `ChatViewModel` for reactive state & eager loading - `[x]` Refactor `ChatViewModel` for reactive state & eager loading
- `[x]` Update `Message_Messaging.kt` UI - `[x]` Update `Message_Messaging.kt` UI
- `[/]` Optimize Media Loading - `[x]` Optimize Media Loading
- `[ ]` Global Coil Configuration in `MyApplication.kt` - `[x]` Global Coil Configuration in `MyApplication.kt`
- `[ ]` Pre-calculate Media URLs in `Message` class - `[x]` Pre-calculate Media URLs in `Message` class
- `[ ]` Implement Pre-fetching in `ChatViewModel` - `[x]` Implement Pre-fetching in `ChatViewModel`
- `[ ]` Refactor `Message_Messaging.kt` UI components - `[x]` Refactor `Message_Messaging.kt` UI components
- `[ ]` Verification - `[x]` Verification
- `[ ]` Verify startup transition - `[x]` Verify startup transition
- `[ ]` Verify room list loading - `[x]` Verify room list loading
- `[ ]` Verify chat room transition & message loading - `[x]` Verify chat room transition & message loading
- `[ ]` Verify instant media display - `[x]` Verify instant media display

View file

@ -1,31 +1,26 @@
# Walkthrough: Instant & Smooth Chat # Walkthrough: Instant & Smooth Chat (Phase 2)
I have optimized the app's loading performance and transition smoothness across the main navigation and chat room flows. I have implemented a comprehensive set of optimizations to ensure that media (images, videos, GIFs) in the chat room load instantly and smoothly, while also maintaining the fast startup and room transition speeds.
## Key Changes ## Key Improvements
### 🚀 Instant Startup ### 🖼️ Instant Media Display
- **Native Splash Screen Integration**: The app now uses the Android Splash Screen API to handle the initial authentication check. The splash screen remains visible until the app knows whether to show the login screen or the main chat list. - **Global ImageLoader**: Centralized the `ImageLoader` configuration in [MyApplication.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/MyApplication.kt). It now globally supports:
- **Removed Loading Spinner**: Eliminated the secondary manual loading circle that appeared after the splash screen, creating a seamless transition to the main content. - **Video Thumbnails**: Using `VideoFrameDecoder`.
- **Animated GIFs**: Using `GifDecoder`.
- **SVG Support**: Using `SvgDecoder`.
- **Caching**: Optimized memory and disk caching (25% memory, 2% disk).
- **Pre-calculated Logic**: Moved complex media URL and type-checking logic from the UI into the [Message](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/data/api/api_class/DM_Basic.kt) data model. The UI now simply reads `message.mediaUrl` or `message.isImage`.
### ⚡ Faster Room List ### 🧠 Intelligent Pre-fetching
- **Eager ViewModel Loading**: `ChatRoomsViewModel` now starts fetching the room list from the local database as soon as it's created, rather than waiting for the UI to be ready. - **Background Pre-loading**: Refactored the `ChatViewModel` to monitor incoming messages. As soon as new messages arrive in the local database, the ViewModel automatically triggers Coil to pre-fetch and cache the media content in the background.
- **Zero-Flash Empty State**: Optimized the initial state of the room list to avoid flashing "No rooms" while the database is being queried. - **Eliminated Blank States**: Because the images are often already in the cache by the time they are rendered, the "blank" or "flickering" state is significantly reduced or eliminated.
### ✨ Smoother Chat Transitions ### 🧹 UI Simplification
- **Reactive Chat ViewModel**: Refactored `ChatViewModel` to be fully reactive. It now combines room info, user status, and local messages into a single state derived directly from the database flows. - **Clean MessageRow**: Simplified the [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) code by removing redundant URL calculations and `remember` blocks, leading to better scroll performance.
- **Instant Message Display**: Messages stored in the local database are now shown immediately upon entering a chat room. The "Loading..." spinner flash has been removed for rooms with existing history.
- **Background Sync**: Network synchronization for new messages now happens entirely in the background, ensuring the UI remains responsive and smooth.
## Components Modified
- [MainActivity.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/MainActivity.kt): Integrated Splash Screen condition.
- [nav_controller.kt](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/navigation/nav_controller.kt): Removed intermediate loading state.
- [ChatRoomsViewModel](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Message_ViewModel.kt): Implemented eager StateFlow.
- [ChatViewModel](file:///home/eric/StudioProjects/Kotlin-Chat-Software/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Messaging_ViewModel.kt): Fully reactive architecture using `combine`.
- [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): Optimized UI for reactive state.
## Verification ## Verification
- Verified that the app boots directly to the room list without a secondary loading screen. - Verified that the app maintains its fast startup and room transition.
- Verified that chat rooms show local history immediately. - Confirmed that images and video thumbnails appear nearly instantly without the previous 1-second delay.
- Verified that shared element transitions for room names and avatars remain fluid. - Verified that GIFs and videos are correctly decoded using the new global decoders.
- Confirmed that scrolling through a media-heavy chat is fluid and responsive.

View file

@ -1,9 +1,46 @@
package com.example.login_v3 package com.example.login_v3
import android.app.Application import android.app.Application
import android.os.Build
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import coil.decode.VideoFrameDecoder
import coil.disk.DiskCache
import coil.memory.MemoryCache
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp @HiltAndroidApp
class MyApplication : Application() { class MyApplication : Application(), ImageLoaderFactory {
// 可選:全域初始化
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.components {
// 1. 影片預覽圖解碼器
add(VideoFrameDecoder.Factory())
// 2. GIF 解碼器 (根據版本選擇)
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
// 3. SVG 解碼器
add(SvgDecoder.Factory())
}
.memoryCache {
MemoryCache.Builder(this)
.maxSizePercent(0.25) // 使用 25% 的記憶體作為快取
.build()
}
.diskCache {
DiskCache.Builder()
.directory(this.cacheDir.resolve("image_cache"))
.maxSizePercent(0.02) // 使用 2% 的磁碟空間
.build()
}
.crossfade(true) // 開啟淡入動畫,視覺更平滑
.build()
}
} }

View file

@ -123,6 +123,39 @@ data class Message(
callLogInfo = CallLogInfo.parseContent(content) callLogInfo = CallLogInfo.parseContent(content)
} }
} }
// --- 🚀 新增:全域媒體屬性,讓 UI 秒讀不塞車 ---
val isImage: Boolean get() = type == "image" || attachment?.mimeType?.startsWith("image/") == true ||
content.lowercase().let { it.endsWith(".jpg") || it.endsWith(".jpeg") || it.endsWith(".png") || it.endsWith(".webp") || it.endsWith(".heic") || it.endsWith(".heif") || it.endsWith(".gif") }
val isGif: Boolean get() = attachment?.mimeType == "image/gif" || content.lowercase().endsWith(".gif")
val isVideo: Boolean get() = type == "video" || attachment?.mimeType?.startsWith("video/") == true ||
content.lowercase().let { it.endsWith(".mp4") || it.endsWith(".mov") || it.endsWith(".mkv") || it.endsWith(".avi") || it.endsWith(".3gp") || it.endsWith(".webm") }
val isAudio: Boolean get() = type == "audio" || attachment?.mimeType?.startsWith("audio/") == true ||
content.lowercase().let { it.endsWith(".mp3") || it.endsWith(".wav") || it.endsWith(".m4a") || it.endsWith(".aac") || it.endsWith(".ogg") || it.endsWith(".opus") || it.endsWith(".amr") }
val isFile: Boolean get() = attachment?.mimeType?.startsWith("application/") == true || attachment?.mimeType == "text/plain" ||
content.lowercase().let { it.endsWith(".zip") || it.endsWith(".rar") || it.endsWith(".7z") || it.endsWith(".pdf") || it.endsWith(".doc") || it.endsWith(".docx") || it.endsWith(".xls") || it.endsWith(".xlsx") || it.endsWith(".ppt") || it.endsWith(".pptx") || it.endsWith(".txt") }
val mediaUrl: String get() {
val rawPath = attachment?.filename ?: content
val baseUrl = "https://tg.technologia-tw.com"
return 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"
}
}
}
}
} }
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)

View file

@ -709,82 +709,12 @@ fun MessageRow(
onReactionClick: (Message, String) -> Unit, onReactionClick: (Message, String) -> Unit,
onRowClick: (Message) -> Unit, onRowClick: (Message) -> Unit,
) { ) {
//圖片判斷 val finalMediaUrl = message.mediaUrl
val isAttachmentImage = message.attachment?.mimeType?.startsWith("image/") == true
val contentLowerCase = (message.content ?: "").lowercase()
val imageExtensions = listOf(".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif", ".gif")
val isLegacyImage = imageExtensions.any { contentLowerCase.contains(it) } val finalBubbleColor = remember(message.status, isHighlight, message.callLogInfo) {
val isImage = isAttachmentImage || isLegacyImage
val rawContent = message.content ?: ""
// 🌟 新增一個專門用來判斷是否為 GIF 的布林值,UI 載入時會用到
val isGif = message.attachment?.mimeType == "image/gif" || contentLowerCase.endsWith(".gif")
// 🌟 2. 影片判斷
val isAttachmentVideo = message.attachment?.mimeType?.startsWith("video/") == true
val videoExtensions = listOf(".mp4", ".mov", ".mkv", ".avi", ".3gp", ".webm")
val isLegacyVideo = videoExtensions.any { contentLowerCase.contains(it) }
val isVideo = isAttachmentVideo || isLegacyVideo
//音訊判斷
val isTypeAudio = message.type == "audio"
val isAttachmentAudio = message.attachment?.mimeType?.startsWith("audio/") == true
val audioExtensions = listOf(".mp3", ".wav", ".m4a", ".aac", ".ogg", ".opus", ".amr")
val isLegacyAudio = audioExtensions.any { contentLowerCase.contains(it) }
val isAudio = isTypeAudio || isAttachmentAudio || isLegacyAudio
// 🌟 新增:檔案判斷 (包含 zip、rar、pdf、doc 等常規檔案)
val isAttachmentFile = message.attachment?.mimeType?.startsWith("application/") == true ||
message.attachment?.mimeType == "text/plain"
val fileExtensions = listOf(".zip", ".rar", ".7z", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt")
val isLegacyFile = fileExtensions.any { contentLowerCase.contains(it) }
val isFile = isAttachmentFile || isLegacyFile
val callLog = message.callLogInfo
// 計算媒體 URL
val finalMediaUrl = remember(message, isImage, isVideo, isAudio, isFile) {
val rawPath = when {
isAttachmentImage && !message.attachment?.filename.isNullOrBlank() -> message.attachment!!.filename
isAttachmentVideo && !message.attachment?.filename.isNullOrBlank() -> message.attachment!!.filename
isAttachmentAudio && !message.attachment?.filename.isNullOrBlank() -> message.attachment!!.filename
// 🌟 新增:檔案附件路徑抓取
isFile && message.attachment?.filename?.isNotBlank() == true -> message.attachment!!.filename
else -> message.content ?: ""
}
val baseUrl = "https://tg.technologia-tw.com"
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"
}
}
}
}
// 取得檔案的顯示名稱(如果是 zip 這種長檔名,可以從 URL 或 attachment 抓取)
val displayFileName = remember(message, finalMediaUrl) {
if (!message.attachment?.filename.isNullOrBlank()) {
// 如果後端有給原始檔名(例如:資料.zip),優先顯示
message.attachment.filename.substringAfterLast("/")
} else {
// 如果只有 URL,抓出最後一段當作檔名
finalMediaUrl.substringAfterLast("/")
}
}
val finalBubbleColor = remember(message.status, isHighlight, callLog) {
val baseColor = when { val baseColor = when {
callLog != null -> { message.callLogInfo != null -> {
if (callLog.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)
@ -831,7 +761,7 @@ fun MessageRow(
) { ) {
when { when {
// 核心分流 1:圖片處理 // 核心分流 1:圖片處理
isImage -> { message.isImage -> {
Column( Column(
modifier = Modifier modifier = Modifier
.widthIn(max = 240.dp) .widthIn(max = 240.dp)
@ -845,29 +775,9 @@ fun MessageRow(
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId) RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
} }
// 🌟 根據是否為 GIF,動態配置 ImageLoader
val context = LocalContext.current
val gifImageLoader = remember(isGif) {
if (isGif) {
ImageLoader.Builder(context)
.components {
// 依據 Android 版本自動選擇最佳的 GIF 解碼器
if (android.os.Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.build()
} else {
null // 普通圖片用系統預設的即可,省記憶體
}
}
AsyncImage( AsyncImage(
model = finalMediaUrl, model = finalMediaUrl,
contentDescription = if (isGif) "動態 GIF" else "聊天圖片", contentDescription = if (message.isGif) "動態 GIF" else "聊天圖片",
imageLoader = gifImageLoader ?: LocalImageLoader.current, // 🎯 如果是 GIF 就用專屬解碼器
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = Modifier modifier = Modifier
.padding(top = 4.dp) .padding(top = 4.dp)
@ -886,14 +796,14 @@ fun MessageRow(
} }
} }
// 🌟 核心分流 2:影片處理 (新增) // 🌟 核心分流 2:影片處理
isVideo -> { message.isVideo -> {
Column( Column(
modifier = Modifier modifier = Modifier
.widthIn(max = 240.dp) .widthIn(max = 240.dp)
.combinedClickable( .combinedClickable(
onLongClick = { onReplyClick(message) }, onLongClick = { onReplyClick(message) },
onClick = { onRowClick(message) } // 點擊時可以在此觸發開啟影片播放器 onClick = { onRowClick(message) }
), ),
horizontalAlignment = if (isMe) Alignment.End else Alignment.Start horizontalAlignment = if (isMe) Alignment.End else Alignment.Start
) { ) {
@ -901,13 +811,13 @@ fun MessageRow(
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId) RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
} }
// 影片佈局:使用 Box 將「播放圖示」疊加在預覽圖(或黑色背景)上方 // 影片佈局
Box( Box(
modifier = Modifier modifier = Modifier
.padding(top = 4.dp) .padding(top = 4.dp)
.size(width = 240.dp, height = 160.dp) // 給予影片固定的預覽比例 .size(width = 240.dp, height = 160.dp)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(Color.Black) // 預防沒載入圖時顯示黑色底 .background(Color.Black)
.border( .border(
width = 0.5.dp, width = 0.5.dp,
color = Color.LightGray.copy(alpha = 0.4f), color = Color.LightGray.copy(alpha = 0.4f),
@ -915,18 +825,9 @@ fun MessageRow(
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// 💡 注意:這裡 model 給 finalMediaUrl。
// 如果後端支援,通常會另外傳一個 video_thumbnail_url;
// 如果直接給影片 URL,部分圖片載入庫(如 Coil 搭配 VideoFrameDecoder)可以直接擷取第一幀。
AsyncImage( AsyncImage(
model = finalMediaUrl, model = finalMediaUrl,
contentDescription = "影片預覽", contentDescription = "影片預覽",
// 🌟 注入一個支援影片解碼的 ImageLoader
imageLoader = ImageLoader.Builder(LocalContext.current)
.components {
add(VideoFrameDecoder.Factory())
}
.build(),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
alpha = 0.8f, alpha = 0.8f,
@ -935,7 +836,6 @@ fun MessageRow(
} }
) )
// 播放圖示容器
Box( Box(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
@ -954,22 +854,20 @@ fun MessageRow(
} }
//isAudio //isAudio
isAudio -> { message.isAudio -> {
Column( Column(
modifier = Modifier modifier = Modifier
.widthIn(max = 250.dp) // 音訊氣泡設定稍窄,更像一般通訊軟體 .widthIn(max = 250.dp)
.combinedClickable( .combinedClickable(
onLongClick = { onReplyClick(message) }, onLongClick = { onReplyClick(message) },
onClick = { /* 整塊氣泡點擊邏輯,可留空 */ } onClick = { /* 整塊氣泡點擊邏輯,可留空 */ }
), ),
horizontalAlignment = if (isMe) Alignment.End else Alignment.Start horizontalAlignment = if (isMe) Alignment.End else Alignment.Start
) { ) {
// 如果有回覆別人的訊息,一樣顯示回覆預覽
message.repliedMessage?.let { replied -> message.repliedMessage?.let { replied ->
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId) RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
} }
// 呼叫你的音訊播放器元件
AudioMessageBubble( AudioMessageBubble(
message = message, message = message,
audioUrl = finalMediaUrl, audioUrl = finalMediaUrl,
@ -981,9 +879,9 @@ fun MessageRow(
} }
//file message //file message
isFile -> { message.isFile -> {
// 1. 🎯 從 ViewModel 取得當前訊息的下載狀態
val downloadStatus = viewModel.getDownloadStatus(message.id) val downloadStatus = viewModel.getDownloadStatus(message.id)
val displayFileName = message.attachment?.filename?.substringAfterLast("/") ?: finalMediaUrl.substringAfterLast("/")
Box( Box(
modifier = Modifier modifier = Modifier
@ -998,11 +896,9 @@ fun MessageRow(
.combinedClickable( .combinedClickable(
onLongClick = { onReplyClick(message) }, onLongClick = { onReplyClick(message) },
onClick = { onClick = {
// 點擊整個氣泡:如果是已完成就直接開啟,否則可維持你原本的行為
if (downloadStatus is DownloadStatus.Completed) { if (downloadStatus is DownloadStatus.Completed) {
onRowClick(message) onRowClick(message)
} else { } else {
// 也可以在這裡同樣觸發下載
viewModel.downloadFile(message, displayFileName) viewModel.downloadFile(message, displayFileName)
} }
} }
@ -1015,34 +911,28 @@ fun MessageRow(
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId) RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
} }
// 檔案的 UI 佈局 (左邊按鈕,右邊文字)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
// 檔案下載按鈕 / 進度顯示
Box( Box(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(40.dp)
.background(Color.White.copy(alpha = 0.6f), CircleShape) .background(Color.White.copy(alpha = 0.6f), CircleShape)
.clip(CircleShape) .clip(CircleShape)
.clickable { .clickable {
// 2. 🎯 點擊按鈕觸發下載或開啟邏輯
when (downloadStatus) { when (downloadStatus) {
is DownloadStatus.NotStarted, is DownloadStatus.Error -> { is DownloadStatus.NotStarted, is DownloadStatus.Error -> {
viewModel.downloadFile(message, displayFileName) viewModel.downloadFile(message, displayFileName)
} }
is DownloadStatus.Completed -> { is DownloadStatus.Completed -> {
onRowClick(message) // 觸發下載完成後的開啟檔案 onRowClick(message)
}
is DownloadStatus.Downloading -> {
// 下載中可以留空,或者未來實作取消下載
} }
is DownloadStatus.Downloading -> {}
} }
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// 3. 🎯 根據密封介面 (Sealed Interface) 狀態切換 Icon 與進度條
when (downloadStatus) { when (downloadStatus) {
is DownloadStatus.NotStarted -> { is DownloadStatus.NotStarted -> {
Icon( Icon(
@ -1053,9 +943,7 @@ fun MessageRow(
) )
} }
is DownloadStatus.Downloading -> { is DownloadStatus.Downloading -> {
// 💡 這裡的 progress 是從 downloadStatus.progress 拿出來的
val progress = downloadStatus.progress val progress = downloadStatus.progress
if (progress >= 0f) { if (progress >= 0f) {
CircularProgressIndicator( CircularProgressIndicator(
progress = progress, progress = progress,
@ -1064,7 +952,6 @@ fun MessageRow(
strokeWidth = 3.dp strokeWidth = 3.dp
) )
} else { } else {
// 如果後端沒給 Content-Length (progress = -1f),改跑無限轉圈
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.fillMaxSize().padding(2.dp), modifier = Modifier.fillMaxSize().padding(2.dp),
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
@ -1088,8 +975,8 @@ fun MessageRow(
} }
is DownloadStatus.Error -> { is DownloadStatus.Error -> {
Icon( Icon(
imageVector = androidx.compose.material.icons.Icons.Default.Warning, // 或者是 Refresh 圖示 imageVector = androidx.compose.material.icons.Icons.Default.Warning,
contentDescription = "下載失敗,點擊重試", contentDescription = "下載失敗",
tint = Color.Red, tint = Color.Red,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
@ -1097,7 +984,6 @@ fun MessageRow(
} }
} }
// 右側檔名與資訊
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = displayFileName, text = displayFileName,
@ -1112,7 +998,7 @@ fun MessageRow(
if (progress >= 0f) "下載中... ${(progress * 100).toInt()}%" else "下載中..." if (progress >= 0f) "下載中... ${(progress * 100).toInt()}%" else "下載中..."
} }
is DownloadStatus.Completed -> "已下載" is DownloadStatus.Completed -> "已下載"
is DownloadStatus.Error -> "下載失敗,點擊重試" is DownloadStatus.Error -> "下載失敗"
else -> "檔案" else -> "檔案"
}, },
style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp, color = Color.Gray) style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp, color = Color.Gray)
@ -1124,7 +1010,7 @@ fun MessageRow(
} }
// 核心分流 3:通話紀錄 // 核心分流 3:通話紀錄
callLog != null -> { message.callLogInfo != null -> {
Box( Box(
modifier = Modifier modifier = Modifier
.widthIn(max = 280.dp) .widthIn(max = 280.dp)
@ -1134,7 +1020,7 @@ fun MessageRow(
) )
) { ) {
CallLogBubble( CallLogBubble(
callLog = callLog, callLog = message.callLogInfo!!,
isMe = isMe, isMe = isMe,
currentUserId = currentUserId, currentUserId = currentUserId,
partnerDisplayName = partnerDisplayName partnerDisplayName = partnerDisplayName
@ -1167,7 +1053,7 @@ fun MessageRow(
} }
Text( Text(
text = rawContent, text = message.content,
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp) style = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp)
) )
} }

View file

@ -12,6 +12,9 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.ExoPlayer.* import androidx.media3.exoplayer.ExoPlayer.*
import coil.imageLoader
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.example.login_v3.R import com.example.login_v3.R
import com.example.login_v3.data.repository.dm.ChatRoomsRepository import com.example.login_v3.data.repository.dm.ChatRoomsRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -32,6 +35,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@ -123,8 +127,27 @@ class ChatViewModel @Inject constructor(
currentUserId = userId currentUserId = userId
) )
} }
}.onEach { state ->
// 🚀 核心優化:智能預加載媒體內容
if (state is MessagesUiState.Success) {
prefetchMedia(state.messages)
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, MessagesUiState.Loading) }.stateIn(viewModelScope, SharingStarted.Eagerly, MessagesUiState.Loading)
private fun prefetchMedia(messages: List<Message>) {
messages.forEach { msg ->
if (msg.isImage || msg.isVideo || msg.isGif) {
val request = ImageRequest.Builder(application)
.data(msg.mediaUrl)
.memoryCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(CachePolicy.ENABLED)
// 如果是影片,預載入時只抓取第一幀
.build()
application.imageLoader.enqueue(request)
}
}
}
// --- 其餘狀態 (保持原樣) --- // --- 其餘狀態 (保持原樣) ---
private val _deleteMessageState = MutableStateFlow<DeleteMessageState>(DeleteMessageState.Idle) private val _deleteMessageState = MutableStateFlow<DeleteMessageState>(DeleteMessageState.Idle)