diff --git a/app/src/main/java/com/example/login_v3/data/api/api_class/DM_Basic.kt b/app/src/main/java/com/example/login_v3/data/api/api_class/DM_Basic.kt index 1c5aa38..6824864 100644 --- a/app/src/main/java/com/example/login_v3/data/api/api_class/DM_Basic.kt +++ b/app/src/main/java/com/example/login_v3/data/api/api_class/DM_Basic.kt @@ -124,7 +124,13 @@ data class Message( var aspectRatio: Float? = null, @kotlin.jvm.Transient - var callLogInfo: CallLogInfo? = null + var callLogInfo: CallLogInfo? = null, + + @kotlin.jvm.Transient + var isDownloaded: Boolean = false, + + @kotlin.jvm.Transient + var localPath: String? = null ) { init { if (type == "system" || type.startsWith("call")) { diff --git a/app/src/main/java/com/example/login_v3/data/di/AppDatabase.kt b/app/src/main/java/com/example/login_v3/data/di/AppDatabase.kt index 22c6b9e..bf2adcb 100644 --- a/app/src/main/java/com/example/login_v3/data/di/AppDatabase.kt +++ b/app/src/main/java/com/example/login_v3/data/di/AppDatabase.kt @@ -19,7 +19,7 @@ import com.example.login_v3.data.local.entities.RoomLocalEntity RoomLocalEntity::class, BlockedUserLocalEntity::class ], - version = 2, + version = 3, exportSchema = false ) @TypeConverters(RoomConverters::class) diff --git a/app/src/main/java/com/example/login_v3/data/local/dao/MessageDao.kt b/app/src/main/java/com/example/login_v3/data/local/dao/MessageDao.kt index 382a8dc..ebaef09 100644 --- a/app/src/main/java/com/example/login_v3/data/local/dao/MessageDao.kt +++ b/app/src/main/java/com/example/login_v3/data/local/dao/MessageDao.kt @@ -25,9 +25,32 @@ interface MessageDao { @Query("DELETE FROM messages WHERE id = :id") suspend fun deleteById(id: String) + @Query("SELECT id FROM messages WHERE chatRoomId = :roomId ORDER BY createdAt DESC LIMIT 1") + suspend fun getLatestMessageId(roomId: String): String? + @Transaction suspend fun replaceTempMessageWithSuccess(tempId: String, successEntity: MessageEntity) { deleteById(tempId) insertOrUpdate(successEntity) } + + @Transaction + suspend fun insertOrUpdateListPreservingLocalData(messages: List) { + messages.forEach { msg -> + val existing = getMessageById(msg.id) + if (existing != null) { + // 🎯 核心:保留本地已下載的標記與路徑,其餘由網路資料覆蓋 + val updated = msg.copy( + isDownloaded = existing.isDownloaded, + localPath = existing.localPath + ) + insertOrUpdate(updated) + } else { + insertOrUpdate(msg) + } + } + } + + @Query("UPDATE messages SET isDownloaded = :isDownloaded, localPath = :localPath WHERE id = :messageId") + suspend fun updateDownloadStatus(messageId: String, isDownloaded: Boolean, localPath: String?) } diff --git a/app/src/main/java/com/example/login_v3/data/local/entities/MessageEntity.kt b/app/src/main/java/com/example/login_v3/data/local/entities/MessageEntity.kt index a776225..247ba47 100644 --- a/app/src/main/java/com/example/login_v3/data/local/entities/MessageEntity.kt +++ b/app/src/main/java/com/example/login_v3/data/local/entities/MessageEntity.kt @@ -21,5 +21,7 @@ data class MessageEntity( val status: MessageStatus, val reactions: List?, val attachment: Attachment? = null, - val aspectRatio: Float? = null + val aspectRatio: Float? = null, + val isDownloaded: Boolean = false, // 👈 新增:標記媒體/訊息是否已下載到本地 + val localPath: String? = null // 👈 新增:儲存本地檔案路徑 ) diff --git a/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt b/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt index a9937af..f1ea9d3 100644 --- a/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt +++ b/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt @@ -121,8 +121,8 @@ class ChatRoomsRepository @Inject constructor( aspectRatio = payload.attachment?.let { if (it.width > 0 && it.height > 0) it.width.toFloat() / it.height.toFloat() else null } ) - // 3. 寫入本地資料庫 - messageDao.insertOrUpdate(entity) + // 3. 寫入本地資料庫 (同樣使用保留本地標記的版本) + messageDao.insertOrUpdateListPreservingLocalData(listOf(entity)) Log.d("ChatRoomsRepository", "WebSocket 訊息成功寫入資料庫: ${entity.content}") } else if (event?.type == "dm:typing") { @@ -192,6 +192,8 @@ class ChatRoomsRepository @Inject constructor( attachment = entity.attachment ).apply { aspectRatio = entity.aspectRatio + isDownloaded = entity.isDownloaded // 👈 新增 + localPath = entity.localPath // 👈 新增 } } @@ -244,9 +246,8 @@ class ChatRoomsRepository @Inject constructor( ) } - // 3. 寫入資料庫 - // 因為你寫了 OnConflictStrategy.REPLACE,歷史訊息會被乖乖追加進去,不會影響現有訊息! - messageDao.insertOrUpdateList(entities) + // 3. 寫入資料庫 (使用優化過的版本,保留本地下載狀態) + messageDao.insertOrUpdateListPreservingLocalData(entities) // 🎯 4. 關鍵:把整個 body 回傳回去(包含 nextCursor 和 hasMore) // 這樣 ViewModel 才能知道下一次往上滑時,要帶哪一個 cursor! @@ -366,6 +367,18 @@ class ChatRoomsRepository @Inject constructor( } } + suspend fun getLatestMessageId(roomId: String): String? { + Log.d("ChatDebug", "💾 [Repository] 正在查詢本地最新訊息 ID... roomId: $roomId") + return try { + val id = messageDao.getLatestMessageId(roomId) + Log.d("ChatDebug", "💾 [Repository] 本地最新 ID: $id") + id + } catch (e: Exception) { + Log.e("ChatDebug", "❌ [Repository] 查詢最新 ID 失敗", e) + null + } + } + //upload file suspend fun uploadAttachment(roomId: String, fileUri: Uri): Result { @@ -613,7 +626,7 @@ class ChatRoomsRepository @Inject constructor( //download file private val httpClient = OkHttpClient() - fun downloadFileFromUrlFlow(fileUrl: String, fileName: String): Flow> = flow { + fun downloadFileFromUrlFlow(messageId: String, fileUrl: String, fileName: String): Flow> = flow { // 🎯 1. 檢查並自動拼接完整的 Base URL val finalUrl = if (fileUrl.startsWith("http://") || fileUrl.startsWith("https://")) { fileUrl @@ -651,8 +664,9 @@ class ChatRoomsRepository @Inject constructor( } val totalBytes = body.contentLength() - // 🎯 修正後的寫法:直接存入手機系統的公共 Download 資料夾 - val targetDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + // 🎯 修正後的寫法:存入 app 專屬資料夾,避免被使用者手動刪除或污染 Download 資料夾 + // 如果希望使用者也能看到,可以維持 Environment.DIRECTORY_DOWNLOADS + val targetDirectory = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: context.filesDir val targetFile = File(targetDirectory, fileName) var bytesCopied = 0L @@ -675,6 +689,10 @@ class ChatRoomsRepository @Inject constructor( } } + // 🎯 關鍵:下載成功後更新資料庫狀態 + messageDao.updateDownloadStatus(messageId, true, targetFile.absolutePath) + Log.d("ChatDebug", "✅ 檔案下載完成且已更新資料庫: ${targetFile.absolutePath}") + emit(Result.success(1.0f)) } catch (e: Exception) { diff --git a/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/MessageUiModel.kt b/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/MessageUiModel.kt index fd31303..e5489c1 100644 --- a/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/MessageUiModel.kt +++ b/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/MessageUiModel.kt @@ -29,7 +29,9 @@ data class MessageUiModel( val isFile: Boolean, val mediaUrl: String, val thumbnailUrl: String? = null, - val aspectRatio: Float? = null + val aspectRatio: Float? = null, + val isDownloaded: Boolean = false, // 👈 新增 + val localPath: String? = null // 👈 新增 ) fun Message.toUiModel(): MessageUiModel { @@ -43,6 +45,13 @@ fun Message.toUiModel(): MessageUiModel { else "$baseUrl${if (raw.startsWith("/")) "" else "/"}$raw" } + // 🎯 核心邏輯:如果已經下載完成,且本地路徑存在,則 mediaUrl 優先指向本地檔案 + val finalMediaUrl = if (isDownloaded && !localPath.isNullOrBlank()) { + "file://$localPath" + } else { + mediaUrl + } + return MessageUiModel( id = id, senderId = senderId, @@ -62,8 +71,10 @@ fun Message.toUiModel(): MessageUiModel { isVideo = isVideo, isAudio = isAudio, isFile = isFile, - mediaUrl = mediaUrl, + mediaUrl = finalMediaUrl, // 👈 使用判斷後的 URL thumbnailUrl = finalThumbnailUrl, - aspectRatio = finalAspectRatio + aspectRatio = finalAspectRatio, + isDownloaded = isDownloaded, + localPath = localPath ) } diff --git a/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Messaging_ViewModel.kt b/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Messaging_ViewModel.kt index 7cc6ac4..0f30836 100644 --- a/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Messaging_ViewModel.kt +++ b/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Messaging_ViewModel.kt @@ -202,17 +202,45 @@ class ChatViewModel @Inject constructor( } private fun initChat(roomId: String) { + Log.d("ChatDebug", "🛠️ [ViewModel] initChat 開始 | roomId: $roomId") viewModelScope.launch { // 啟動 Session - try { repository.startChatSession(roomId) } catch (e: Exception) { Log.e("ChatViewModel", "WS Error", e) } + try { + Log.d("ChatDebug", "🌐 [ViewModel] 嘗試啟動 WebSocket Session...") + repository.startChatSession(roomId) + } catch (e: Exception) { + Log.e("ChatDebug", "❌ [ViewModel] WS Error", e) + } - // 背景同步 - repository.refreshChatMessages(roomId, cursor = null, limit = 20) - .onSuccess { response -> - nextCursor = response.nextCursor - hasMore = response.hasMore + // 🎯 核心優化:Delta-Sync 邏輯 + try { + Log.d("ChatDebug", "🔍 [ViewModel] 正在檢查本地資料一致性...") + val currentRoom = repository.getChatRoomFlow(roomId).first() + val localLatestId = repository.getLatestMessageId(roomId) + val serverLatestId = currentRoom?.lastMessage?.id + + Log.d("ChatDebug", "📊 [ViewModel] 比較結果 - LocalLatest: $localLatestId | ServerLatest: $serverLatestId") + + if (localLatestId == null || localLatestId != serverLatestId) { + Log.d("ChatDebug", "🔄 [ViewModel] 資料不一致,發起增量同步...") + // 背景同步 + repository.refreshChatMessages(roomId, cursor = null, limit = 20) + .onSuccess { response -> + nextCursor = response.nextCursor + hasMore = response.hasMore + Log.d("ChatDebug", "✅ [ViewModel] 同步成功 | nextCursor: $nextCursor") + repository.markAsRead(roomId) + } + .onFailure { e -> + Log.e("ChatDebug", "❌ [ViewModel] 同步失敗: ${e.message}") + } + } else { + Log.d("ChatDebug", "✅ [ViewModel] 資料已是最新,跳過 refresh") repository.markAsRead(roomId) } + } catch (e: Exception) { + Log.e("ChatDebug", "❌ [ViewModel] initChat 執行過程發生異常", e) + } } } @@ -536,8 +564,8 @@ class ChatViewModel @Inject constructor( viewModelScope.launch { fileDownloadStates[messageId] = DownloadStatus.Downloading(0f) - // 呼叫剛剛改好的 URL 版 Flow - repository.downloadFileFromUrlFlow(fileUrl, displayFileName) + // 呼叫剛剛改好的 URL 版 Flow,傳入 messageId + repository.downloadFileFromUrlFlow(messageId, fileUrl, displayFileName) .collect { progressResult -> progressResult.onSuccess { progress -> if (progress >= 1.0f) { diff --git a/app/src/main/java/com/example/login_v3/home/setting/Setting_list_Screen.kt b/app/src/main/java/com/example/login_v3/home/setting/Setting_list_Screen.kt index 9b6da2f..7e27e53 100644 --- a/app/src/main/java/com/example/login_v3/home/setting/Setting_list_Screen.kt +++ b/app/src/main/java/com/example/login_v3/home/setting/Setting_list_Screen.kt @@ -159,6 +159,7 @@ fun SharedTransitionScope.setting_list_Screen( style = MaterialTheme.typography.titleMedium, fontSize = 24.sp, fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, modifier = Modifier .sharedElement( sharedContentState = titleState, @@ -173,7 +174,7 @@ fun SharedTransitionScope.setting_list_Screen( item.description?.let { Text(text = it, style = MaterialTheme.typography.bodySmall, - color = Color.White + color = MaterialTheme.colorScheme.onSecondary ) } } diff --git a/app/src/main/java/com/example/login_v3/ui/theme/Color.kt b/app/src/main/java/com/example/login_v3/ui/theme/Color.kt index f509916..47a484b 100644 --- a/app/src/main/java/com/example/login_v3/ui/theme/Color.kt +++ b/app/src/main/java/com/example/login_v3/ui/theme/Color.kt @@ -19,6 +19,9 @@ val DarkGlass = Color(0x14000000) val WhiteText = Color(0xFFFFFFFF) val DarkText = Color(0xFF000000) +val sub_WhiteText = Color(0xFFD0D0D0) +val sub_DarkText = Color(0xFFD0D0D0) + //default background val White_default = Color(0xFF2C2C2C) diff --git a/app/src/main/java/com/example/login_v3/ui/theme/Theme.kt b/app/src/main/java/com/example/login_v3/ui/theme/Theme.kt index 949fe13..7e0fae7 100644 --- a/app/src/main/java/com/example/login_v3/ui/theme/Theme.kt +++ b/app/src/main/java/com/example/login_v3/ui/theme/Theme.kt @@ -17,7 +17,8 @@ private val DarkColorScheme = darkColorScheme( secondary = light_orange, tertiary = Pink80, background = Color(0xFF121212), - onBackground = DarkText + onBackground = WhiteText, + onSecondary = sub_DarkText, ) private val LightColorScheme = lightColorScheme( @@ -25,7 +26,8 @@ private val LightColorScheme = lightColorScheme( secondary = light_orange, tertiary = Pink40, background = Color(0xFF121212), - onBackground = WhiteText + onBackground = DarkText, + onSecondary = sub_WhiteText, /* Other default colors to override