From 3f0bbd80088c221bcbd4a6f38b70411a2667d62d Mon Sep 17 00:00:00 2001 From: eric Date: Sat, 8 Aug 2026 05:40:51 +0800 Subject: [PATCH] add delta sync for dm-v18 --- .../login_v3/data/api/Main_API_Service.kt | 8 +++ .../login_v3/data/api/api_class/DM_Basic.kt | 11 ++++ .../data/repository/dm/DM_Basic_Repository.kt | 40 ++++++++++++ .../Message_Component/MessageActionMenuRow.kt | 14 +++- .../Message_Component/MessageInputBar.kt | 64 +++++++++++++++++-- .../Message/UI/Detail/Message_Messaging.kt | 34 ++++++++++ .../Detail/Message_Messaging_ViewModel.kt | 24 +++++++ 7 files changed, 190 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/example/login_v3/data/api/Main_API_Service.kt b/app/src/main/java/com/example/login_v3/data/api/Main_API_Service.kt index c20c4a6..f359fdf 100644 --- a/app/src/main/java/com/example/login_v3/data/api/Main_API_Service.kt +++ b/app/src/main/java/com/example/login_v3/data/api/Main_API_Service.kt @@ -167,4 +167,12 @@ interface TecnologiaApi { @Path("room_id") roomId: String, @Path("msg_id") msgId: String ): Response + + //edit message + @PATCH("api/dm/{room_id}/messages/{msg_id}") + suspend fun editMessage( + @Path("room_id") roomId: String, + @Path("msg_id") msgId: String, + @Body request: EditMessageRequest + ): Response } \ No newline at end of file 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 b349b72..1c5aa38 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 @@ -218,6 +218,17 @@ data class MessageReactionUsersResponse( @Json(name = "users") val users: List ) +//edit message +@JsonClass(generateAdapter = true) +data class EditMessageRequest( + @Json(name = "content") val content: String +) + +@JsonClass(generateAdapter = true) +data class EditMessageResponse( + @Json(name = "ok") val ok: Boolean +) + 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 73e80db..a9937af 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 @@ -9,6 +9,7 @@ import android.provider.OpenableColumns import android.util.Log import com.example.login_v3.data.api.TecnologiaApi import com.example.login_v3.data.api.api_class.ChatRoom +import com.example.login_v3.data.api.api_class.EditMessageRequest import com.example.login_v3.data.api.api_class.Message import com.example.login_v3.data.api.api_class.MessageResponse import com.example.login_v3.data.api.api_class.Reaction @@ -569,6 +570,45 @@ class ChatRoomsRepository @Inject constructor( } } + // 編輯訊息(樂觀更新版本) + suspend fun editMessage(roomId: String, messageId: String, content: String): Result { + // 1. 先從本地撈出原本的訊息作為備份 + val backupEntity = try { + messageDao.getMessageById(messageId) + } catch (e: Exception) { + null + } + + // 2. 樂觀更新:立刻在本地修改內容 + if (backupEntity != null) { + try { + val updatedEntity = backupEntity.copy( + content = content, + isEdited = true + ) + messageDao.insertOrUpdate(updatedEntity) + } catch (e: Exception) { + return Result.failure(e) + } + } + + // 3. 呼叫 API + return try { + val response = api.editMessage(roomId, messageId, EditMessageRequest(content)) + if (response.isSuccessful && response.body()?.ok == true) { + Result.success(Unit) + } else { + // 失敗則回滾 + backupEntity?.let { messageDao.insertOrUpdate(it) } + Result.failure(Exception("編輯失敗")) + } + } catch (e: Exception) { + // 異常則回滾 + backupEntity?.let { messageDao.insertOrUpdate(it) } + Result.failure(e) + } + } + //download file diff --git a/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Component/MessageActionMenuRow.kt b/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Component/MessageActionMenuRow.kt index f6067e2..c50bfba 100644 --- a/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Component/MessageActionMenuRow.kt +++ b/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Component/MessageActionMenuRow.kt @@ -19,6 +19,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Reply +import androidx.compose.material.icons.filled.Edit // 🎯 新增 import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -41,9 +42,11 @@ fun MessageActionMenuRow( onCancel: () -> Unit, onReplyClick: (MessageUiModel) -> Unit, // 👈 修正:這裡只需要定義型態 onDeleteClick: (MessageUiModel) -> Unit, + onEditClick: (MessageUiModel) -> Unit, // 🎯 新增 isOwnMessage: Boolean, modifier: Modifier = Modifier ) { + // ... (rest of the function) // 💡 取得系統剪貼簿,用來實作「複製文字」功能 val clipboardManager = LocalClipboardManager.current @@ -116,7 +119,16 @@ fun MessageActionMenuRow( } ) - // 3. 刪除:只有在自己的訊息時才顯示 👈 + // 3. 編輯:只有在自己的文字訊息時顯示 🎯 + if (isOwnMessage && !isImage) { + ChatMenuButton( + imageVector = Icons.Default.Edit, + label = "編輯", + onClick = { onEditClick(message) } + ) + } + + // 4. 刪除:只有在自己的訊息時才顯示 👈 if (isOwnMessage) { ChatMenuButton( imageVector = Icons.Default.Delete, diff --git a/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Component/MessageInputBar.kt b/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Component/MessageInputBar.kt index 61cff64..2d31149 100644 --- a/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Component/MessageInputBar.kt +++ b/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Component/MessageInputBar.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AddCircle +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Photo import androidx.compose.material.icons.filled.Send @@ -28,6 +29,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect // 🎯 新增 import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -44,12 +46,25 @@ import com.example.login_v3.home.Message.UI.Detail.MessageUiModel fun MessageInputBar( isLoading: Boolean, replyingMessage: MessageUiModel?, + editingMessage: MessageUiModel? = null, // 🎯 新增 onCancelReply: () -> Unit, + onCancelEdit: () -> Unit = {}, // 🎯 新增 onSendClick: (String) -> Unit, + onEditSaveClick: (String) -> Unit = {}, // 🎯 新增 onAttachmentClick: () -> Unit ) { var textState by remember { mutableStateOf("") } + // 🎯 當進入編輯模式時,自動填入原本的內容 + LaunchedEffect(editingMessage) { + if (editingMessage != null) { + textState = editingMessage.content + } else { + // 如果從編輯模式退出且不是因為發送成功,可能需要清空? + // 但通常 clearEditingMessage 會觸發這裡 + } + } + // 💡 外層包裹一層 Box,並加上 padding 與 navigationBarsPadding,確保懸浮且不被系統列擋住 Box( modifier = Modifier @@ -103,6 +118,39 @@ fun MessageInputBar( } } + // 🎯 1.5 編輯預覽列 + AnimatedVisibility(visible = editingMessage != null) { + if (editingMessage != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFFFF3E0)) // 編輯模式用淡橘色區分 + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "編輯訊息", + style = MaterialTheme.typography.labelMedium, + color = Color(0xFFE65100), + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold + ) + } + IconButton(onClick = { + onCancelEdit() + textState = "" // 取消編輯時清空 + }, modifier = Modifier.size(24.dp)) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "取消編輯", + tint = Color.Gray + ) + } + } + } + } + // 2. 實際的輸入工具列 Row( modifier = Modifier @@ -129,7 +177,10 @@ fun MessageInputBar( textStyle = MaterialTheme.typography.bodyLarge, decorationBox = { innerTextField -> if (textState.isEmpty()) { - Text("請輸入訊息...", color = Color.LightGray) + Text( + text = if (editingMessage != null) "編輯訊息..." else "請輸入訊息...", + color = Color.LightGray + ) } innerTextField() } @@ -139,7 +190,11 @@ fun MessageInputBar( IconButton( onClick = { if (textState.isNotBlank() && !isLoading) { - onSendClick(textState) + if (editingMessage != null) { + onEditSaveClick(textState) + } else { + onSendClick(textState) + } textState = "" } }, @@ -150,9 +205,10 @@ fun MessageInputBar( } else { // 加上小背景讓送出按鈕更有重點 val iconColor = if (textState.isNotBlank()) Color(0xFFDA7029) else Color.LightGray + val icon = if (editingMessage != null) Icons.Default.Check else Icons.Default.Send Icon( - imageVector = Icons.Default.Send, - contentDescription = "送出", + imageVector = icon, + contentDescription = if (editingMessage != null) "儲存編輯" else "送出", tint = iconColor ) } diff --git a/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt b/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt index 7f50d47..8d480b0 100644 --- a/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt +++ b/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt @@ -113,6 +113,7 @@ sealed interface BottomBarState { data class ActionMenu(val message: MessageUiModel) : BottomBarState // 長按:動作選單 data class EmojiMenu(val message: MessageUiModel) : BottomBarState // 單擊:Emoji 工具列 object AttachmentMenu : BottomBarState // 🎯 新增:附件選單 + data class Edit(val message: MessageUiModel) : BottomBarState // 🎯 新增:編輯模式 } // 🎯 新增:Top Bar 狀態機 @@ -155,6 +156,8 @@ fun MessageMessaging( // 🎯 新增:Top Bar 的本地狀態 var topBarState by remember { mutableStateOf(TopBarState.Regular) } + val editingMessage by viewModel.editingMessage.collectAsStateWithLifecycle() + // 🌟 核心:使用 derivedStateOf 統一控管狀態權限(長按優先權通常大於單擊) // 🎯 新增:用來控管非訊息觸發的底部狀態(例如:AttachmentMenu) var localBottomBarState by remember { mutableStateOf(null) } @@ -163,6 +166,7 @@ fun MessageMessaging( when { actionMessage != null -> BottomBarState.ActionMenu(actionMessage!!) emojiTargetMessage != null -> BottomBarState.EmojiMenu(emojiTargetMessage!!) + editingMessage != null -> BottomBarState.Edit(editingMessage!!) // 🎯 編輯模式優先 localBottomBarState != null -> localBottomBarState!! // 🎯 如果有本地狀態,則採用它 else -> BottomBarState.Input } @@ -519,6 +523,21 @@ fun MessageMessaging( ) } + is BottomBarState.Edit -> { + MessageInputBar( + isLoading = sendStatus is SendMessageState.Loading, + replyingMessage = null, // 編輯時不顯示回覆 + editingMessage = currentState.message, + onCancelReply = {}, + onCancelEdit = { viewModel.clearEditingMessage() }, + onSendClick = {}, + onEditSaveClick = { content -> + viewModel.editMessage(roomId, currentState.message.id, content) + }, + onAttachmentClick = {} // 編輯模式不給上傳附件 + ) + } + is BottomBarState.ActionMenu -> { val currentActionMessage = currentState.message val currentUserId = (uiState as? MessagesUiState.Success)?.currentUserId @@ -532,6 +551,10 @@ fun MessageMessaging( viewModel.setReplyingMessage(msg) viewModel.clearActionMessage() }, + onEditClick = { msg -> + viewModel.setEditingMessage(msg) + viewModel.clearActionMessage() + }, onDeleteClick = { msg -> // 🌟 核心防呆:如果訊息還在發送中(SENDING),禁止點擊刪除,避免 tempId 送給後端造成 404 if (msg.status == MessageStatus.SENDING) { @@ -1331,6 +1354,17 @@ private fun TextMessageContent( text = message.content, style = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp) ) + + if (message.isEdited) { + Text( + text = "(已編輯)", + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 10.sp, + color = Color.Gray.copy(alpha = 0.7f) + ), + modifier = Modifier.align(Alignment.End).padding(top = 2.dp) + ) + } } } } 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 49093d6..7cc6ac4 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 @@ -178,6 +178,11 @@ class ChatViewModel @Inject constructor( fun setActionMessage(message: MessageUiModel?) { _actionMessage.value = message } fun clearActionMessage() { _actionMessage.value = null } + private val _editingMessage = MutableStateFlow(null) + val editingMessage: StateFlow = _editingMessage.asStateFlow() + fun setEditingMessage(message: MessageUiModel?) { _editingMessage.value = message } + fun clearEditingMessage() { _editingMessage.value = null } + private val _replyingMessage = MutableStateFlow(null) val replyingMessage: StateFlow = _replyingMessage.asStateFlow() fun setReplyingMessage(message: MessageUiModel?) { _replyingMessage.value = message } @@ -323,6 +328,25 @@ class ChatViewModel @Inject constructor( } } + fun editMessage(roomId: String, messageId: String, content: String) { + if (content.trim().isBlank()) return + + viewModelScope.launch { + _sendMessageState.value = SendMessageState.Loading + + val result = repository.editMessage(roomId, messageId, content) + + _editingMessage.value = null + + result.onSuccess { + _sendMessageState.value = SendMessageState.Success + }.onFailure { error -> + Log.e("ChatViewModel", "Edit message failed", error) + _sendMessageState.value = SendMessageState.Error(error.message ?: "編輯訊息失敗") + } + } + } + // --- 其餘 uploadAttachment、Emoji 功能保持原樣 --- fun uploadAttachment(roomId: String, fileUri: Uri) { viewModelScope.launch {