add delta sync for dm-v5
This commit is contained in:
parent
0a0ae637ca
commit
8202b3391d
6 changed files with 150 additions and 176 deletions
|
|
@ -9,13 +9,13 @@
|
|||
- `[x]` Optimize Chat Room (Messages)
|
||||
- `[x]` Refactor `ChatViewModel` for reactive state & eager loading
|
||||
- `[x]` Update `Message_Messaging.kt` UI
|
||||
- `[/]` Optimize Media Loading
|
||||
- `[ ]` Global Coil Configuration in `MyApplication.kt`
|
||||
- `[ ]` Pre-calculate Media URLs in `Message` class
|
||||
- `[ ]` Implement Pre-fetching in `ChatViewModel`
|
||||
- `[ ]` Refactor `Message_Messaging.kt` UI components
|
||||
- `[ ]` Verification
|
||||
- `[ ]` Verify startup transition
|
||||
- `[ ]` Verify room list loading
|
||||
- `[ ]` Verify chat room transition & message loading
|
||||
- `[ ]` Verify instant media display
|
||||
- `[x]` Optimize Media Loading
|
||||
- `[x]` Global Coil Configuration in `MyApplication.kt`
|
||||
- `[x]` Pre-calculate Media URLs in `Message` class
|
||||
- `[x]` Implement Pre-fetching in `ChatViewModel`
|
||||
- `[x]` Refactor `Message_Messaging.kt` UI components
|
||||
- `[x]` Verification
|
||||
- `[x]` Verify startup transition
|
||||
- `[x]` Verify room list loading
|
||||
- `[x]` Verify chat room transition & message loading
|
||||
- `[x]` Verify instant media display
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- **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.
|
||||
- **Removed Loading Spinner**: Eliminated the secondary manual loading circle that appeared after the splash screen, creating a seamless transition to the main content.
|
||||
### 🖼️ Instant Media Display
|
||||
- **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:
|
||||
- **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
|
||||
- **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.
|
||||
- **Zero-Flash Empty State**: Optimized the initial state of the room list to avoid flashing "No rooms" while the database is being queried.
|
||||
### 🧠 Intelligent Pre-fetching
|
||||
- **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.
|
||||
- **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
|
||||
- **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.
|
||||
- **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.
|
||||
### 🧹 UI Simplification
|
||||
- **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.
|
||||
|
||||
## Verification
|
||||
- Verified that the app boots directly to the room list without a secondary loading screen.
|
||||
- Verified that chat rooms show local history immediately.
|
||||
- Verified that shared element transitions for room names and avatars remain fluid.
|
||||
- Verified that the app maintains its fast startup and room transition.
|
||||
- Confirmed that images and video thumbnails appear nearly instantly without the previous 1-second delay.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,46 @@
|
|||
package com.example.login_v3
|
||||
|
||||
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
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,12 +117,45 @@ data class Message(
|
|||
@kotlin.jvm.Transient
|
||||
var callLogInfo: CallLogInfo? = null
|
||||
|
||||
){
|
||||
) {
|
||||
init {
|
||||
if (type == "system" || type.startsWith("call")) {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -709,82 +709,12 @@ fun MessageRow(
|
|||
onReactionClick: (Message, String) -> Unit,
|
||||
onRowClick: (Message) -> Unit,
|
||||
) {
|
||||
//圖片判斷
|
||||
val isAttachmentImage = message.attachment?.mimeType?.startsWith("image/") == true
|
||||
val contentLowerCase = (message.content ?: "").lowercase()
|
||||
val imageExtensions = listOf(".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif", ".gif")
|
||||
val finalMediaUrl = message.mediaUrl
|
||||
|
||||
val isLegacyImage = imageExtensions.any { contentLowerCase.contains(it) }
|
||||
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 finalBubbleColor = remember(message.status, isHighlight, message.callLogInfo) {
|
||||
val baseColor = when {
|
||||
callLog != null -> {
|
||||
if (callLog.type == "call_missed") Color(0xFFFCE8E6) else Color(0xFFF1F3F4)
|
||||
message.callLogInfo != null -> {
|
||||
if (message.callLogInfo!!.type == "call_missed") Color(0xFFFCE8E6) else Color(0xFFF1F3F4)
|
||||
}
|
||||
isMe && isHighlight -> Color(0xFFB4E197)
|
||||
isMe -> Color(0xFFDCF8C6)
|
||||
|
|
@ -831,7 +761,7 @@ fun MessageRow(
|
|||
) {
|
||||
when {
|
||||
// 核心分流 1:圖片處理
|
||||
isImage -> {
|
||||
message.isImage -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 240.dp)
|
||||
|
|
@ -845,29 +775,9 @@ fun MessageRow(
|
|||
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(
|
||||
model = finalMediaUrl,
|
||||
contentDescription = if (isGif) "動態 GIF" else "聊天圖片",
|
||||
imageLoader = gifImageLoader ?: LocalImageLoader.current, // 🎯 如果是 GIF 就用專屬解碼器
|
||||
contentDescription = if (message.isGif) "動態 GIF" else "聊天圖片",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
|
|
@ -886,14 +796,14 @@ fun MessageRow(
|
|||
}
|
||||
}
|
||||
|
||||
// 🌟 核心分流 2:影片處理 (新增)
|
||||
isVideo -> {
|
||||
// 🌟 核心分流 2:影片處理
|
||||
message.isVideo -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 240.dp)
|
||||
.combinedClickable(
|
||||
onLongClick = { onReplyClick(message) },
|
||||
onClick = { onRowClick(message) } // 點擊時可以在此觸發開啟影片播放器
|
||||
onClick = { onRowClick(message) }
|
||||
),
|
||||
horizontalAlignment = if (isMe) Alignment.End else Alignment.Start
|
||||
) {
|
||||
|
|
@ -901,13 +811,13 @@ fun MessageRow(
|
|||
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
|
||||
}
|
||||
|
||||
// 影片佈局:使用 Box 將「播放圖示」疊加在預覽圖(或黑色背景)上方
|
||||
// 影片佈局
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.size(width = 240.dp, height = 160.dp) // 給予影片固定的預覽比例
|
||||
.size(width = 240.dp, height = 160.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.Black) // 預防沒載入圖時顯示黑色底
|
||||
.background(Color.Black)
|
||||
.border(
|
||||
width = 0.5.dp,
|
||||
color = Color.LightGray.copy(alpha = 0.4f),
|
||||
|
|
@ -915,18 +825,9 @@ fun MessageRow(
|
|||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// 💡 注意:這裡 model 給 finalMediaUrl。
|
||||
// 如果後端支援,通常會另外傳一個 video_thumbnail_url;
|
||||
// 如果直接給影片 URL,部分圖片載入庫(如 Coil 搭配 VideoFrameDecoder)可以直接擷取第一幀。
|
||||
AsyncImage(
|
||||
model = finalMediaUrl,
|
||||
contentDescription = "影片預覽",
|
||||
// 🌟 注入一個支援影片解碼的 ImageLoader
|
||||
imageLoader = ImageLoader.Builder(LocalContext.current)
|
||||
.components {
|
||||
add(VideoFrameDecoder.Factory())
|
||||
}
|
||||
.build(),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
alpha = 0.8f,
|
||||
|
|
@ -935,7 +836,6 @@ fun MessageRow(
|
|||
}
|
||||
)
|
||||
|
||||
// 播放圖示容器
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
|
|
@ -954,22 +854,20 @@ fun MessageRow(
|
|||
}
|
||||
|
||||
//isAudio
|
||||
isAudio -> {
|
||||
message.isAudio -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 250.dp) // 音訊氣泡設定稍窄,更像一般通訊軟體
|
||||
.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,
|
||||
|
|
@ -981,9 +879,9 @@ fun MessageRow(
|
|||
}
|
||||
|
||||
//file message
|
||||
isFile -> {
|
||||
// 1. 🎯 從 ViewModel 取得當前訊息的下載狀態
|
||||
message.isFile -> {
|
||||
val downloadStatus = viewModel.getDownloadStatus(message.id)
|
||||
val displayFileName = message.attachment?.filename?.substringAfterLast("/") ?: finalMediaUrl.substringAfterLast("/")
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -998,11 +896,9 @@ fun MessageRow(
|
|||
.combinedClickable(
|
||||
onLongClick = { onReplyClick(message) },
|
||||
onClick = {
|
||||
// 點擊整個氣泡:如果是已完成就直接開啟,否則可維持你原本的行為
|
||||
if (downloadStatus is DownloadStatus.Completed) {
|
||||
onRowClick(message)
|
||||
} else {
|
||||
// 也可以在這裡同樣觸發下載
|
||||
viewModel.downloadFile(message, displayFileName)
|
||||
}
|
||||
}
|
||||
|
|
@ -1015,34 +911,28 @@ fun MessageRow(
|
|||
RepliedMessagePreview(replied, isMe, partnerDisplayName, currentUserId)
|
||||
}
|
||||
|
||||
// 檔案的 UI 佈局 (左邊按鈕,右邊文字)
|
||||
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 {
|
||||
// 2. 🎯 點擊按鈕觸發下載或開啟邏輯
|
||||
when (downloadStatus) {
|
||||
is DownloadStatus.NotStarted, is DownloadStatus.Error -> {
|
||||
viewModel.downloadFile(message, displayFileName)
|
||||
}
|
||||
is DownloadStatus.Completed -> {
|
||||
onRowClick(message) // 觸發下載完成後的開啟檔案
|
||||
}
|
||||
is DownloadStatus.Downloading -> {
|
||||
// 下載中可以留空,或者未來實作取消下載
|
||||
onRowClick(message)
|
||||
}
|
||||
is DownloadStatus.Downloading -> {}
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// 3. 🎯 根據密封介面 (Sealed Interface) 狀態切換 Icon 與進度條
|
||||
when (downloadStatus) {
|
||||
is DownloadStatus.NotStarted -> {
|
||||
Icon(
|
||||
|
|
@ -1053,9 +943,7 @@ fun MessageRow(
|
|||
)
|
||||
}
|
||||
is DownloadStatus.Downloading -> {
|
||||
// 💡 這裡的 progress 是從 downloadStatus.progress 拿出來的
|
||||
val progress = downloadStatus.progress
|
||||
|
||||
if (progress >= 0f) {
|
||||
CircularProgressIndicator(
|
||||
progress = progress,
|
||||
|
|
@ -1064,7 +952,6 @@ fun MessageRow(
|
|||
strokeWidth = 3.dp
|
||||
)
|
||||
} else {
|
||||
// 如果後端沒給 Content-Length (progress = -1f),改跑無限轉圈
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.fillMaxSize().padding(2.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
|
|
@ -1088,8 +975,8 @@ fun MessageRow(
|
|||
}
|
||||
is DownloadStatus.Error -> {
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Default.Warning, // 或者是 Refresh 圖示
|
||||
contentDescription = "下載失敗,點擊重試",
|
||||
imageVector = androidx.compose.material.icons.Icons.Default.Warning,
|
||||
contentDescription = "下載失敗",
|
||||
tint = Color.Red,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
|
@ -1097,7 +984,6 @@ fun MessageRow(
|
|||
}
|
||||
}
|
||||
|
||||
// 右側檔名與資訊
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = displayFileName,
|
||||
|
|
@ -1112,7 +998,7 @@ fun MessageRow(
|
|||
if (progress >= 0f) "下載中... ${(progress * 100).toInt()}%" else "下載中..."
|
||||
}
|
||||
is DownloadStatus.Completed -> "已下載"
|
||||
is DownloadStatus.Error -> "下載失敗,點擊重試"
|
||||
is DownloadStatus.Error -> "下載失敗"
|
||||
else -> "檔案"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp, color = Color.Gray)
|
||||
|
|
@ -1124,7 +1010,7 @@ fun MessageRow(
|
|||
}
|
||||
|
||||
// 核心分流 3:通話紀錄
|
||||
callLog != null -> {
|
||||
message.callLogInfo != null -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 280.dp)
|
||||
|
|
@ -1134,7 +1020,7 @@ fun MessageRow(
|
|||
)
|
||||
) {
|
||||
CallLogBubble(
|
||||
callLog = callLog,
|
||||
callLog = message.callLogInfo!!,
|
||||
isMe = isMe,
|
||||
currentUserId = currentUserId,
|
||||
partnerDisplayName = partnerDisplayName
|
||||
|
|
@ -1167,7 +1053,7 @@ fun MessageRow(
|
|||
}
|
||||
|
||||
Text(
|
||||
text = rawContent,
|
||||
text = message.content,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ import androidx.media3.common.MediaItem
|
|||
import androidx.media3.common.Player
|
||||
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.data.repository.dm.ChatRoomsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
|
|
@ -32,6 +35,7 @@ import kotlinx.coroutines.flow.filterNotNull
|
|||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
|
|
@ -123,8 +127,27 @@ class ChatViewModel @Inject constructor(
|
|||
currentUserId = userId
|
||||
)
|
||||
}
|
||||
}.onEach { state ->
|
||||
// 🚀 核心優化:智能預加載媒體內容
|
||||
if (state is MessagesUiState.Success) {
|
||||
prefetchMedia(state.messages)
|
||||
}
|
||||
}.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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue