add delta sync for dm-v18

This commit is contained in:
eric 2026-08-08 05:40:51 +08:00
parent d84ba852c6
commit 3f0bbd8008
7 changed files with 190 additions and 5 deletions

View file

@ -167,4 +167,12 @@ interface TecnologiaApi {
@Path("room_id") roomId: String, @Path("room_id") roomId: String,
@Path("msg_id") msgId: String @Path("msg_id") msgId: String
): Response<Unit> ): Response<Unit>
//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<EditMessageResponse>
} }

View file

@ -218,6 +218,17 @@ data class MessageReactionUsersResponse(
@Json(name = "users") val users: List<String> @Json(name = "users") val users: List<String>
) )
//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
)

View file

@ -9,6 +9,7 @@ import android.provider.OpenableColumns
import android.util.Log import android.util.Log
import com.example.login_v3.data.api.TecnologiaApi 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.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.Message
import com.example.login_v3.data.api.api_class.MessageResponse import com.example.login_v3.data.api.api_class.MessageResponse
import com.example.login_v3.data.api.api_class.Reaction 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<Unit> {
// 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 //download file

View file

@ -19,6 +19,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Reply import androidx.compose.material.icons.filled.Reply
import androidx.compose.material.icons.filled.Edit // 🎯 新增
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -41,9 +42,11 @@ fun MessageActionMenuRow(
onCancel: () -> Unit, onCancel: () -> Unit,
onReplyClick: (MessageUiModel) -> Unit, // 👈 修正:這裡只需要定義型態 onReplyClick: (MessageUiModel) -> Unit, // 👈 修正:這裡只需要定義型態
onDeleteClick: (MessageUiModel) -> Unit, onDeleteClick: (MessageUiModel) -> Unit,
onEditClick: (MessageUiModel) -> Unit, // 🎯 新增
isOwnMessage: Boolean, isOwnMessage: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// ... (rest of the function)
// 💡 取得系統剪貼簿,用來實作「複製文字」功能 // 💡 取得系統剪貼簿,用來實作「複製文字」功能
val clipboardManager = LocalClipboardManager.current 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) { if (isOwnMessage) {
ChatMenuButton( ChatMenuButton(
imageVector = Icons.Default.Delete, imageVector = Icons.Default.Delete,

View file

@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddCircle 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.Close
import androidx.compose.material.icons.filled.Photo import androidx.compose.material.icons.filled.Photo
import androidx.compose.material.icons.filled.Send 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect // 🎯 新增
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -44,12 +46,25 @@ import com.example.login_v3.home.Message.UI.Detail.MessageUiModel
fun MessageInputBar( fun MessageInputBar(
isLoading: Boolean, isLoading: Boolean,
replyingMessage: MessageUiModel?, replyingMessage: MessageUiModel?,
editingMessage: MessageUiModel? = null, // 🎯 新增
onCancelReply: () -> Unit, onCancelReply: () -> Unit,
onCancelEdit: () -> Unit = {}, // 🎯 新增
onSendClick: (String) -> Unit, onSendClick: (String) -> Unit,
onEditSaveClick: (String) -> Unit = {}, // 🎯 新增
onAttachmentClick: () -> Unit onAttachmentClick: () -> Unit
) { ) {
var textState by remember { mutableStateOf("") } var textState by remember { mutableStateOf("") }
// 🎯 當進入編輯模式時,自動填入原本的內容
LaunchedEffect(editingMessage) {
if (editingMessage != null) {
textState = editingMessage.content
} else {
// 如果從編輯模式退出且不是因為發送成功,可能需要清空?
// 但通常 clearEditingMessage 會觸發這裡
}
}
// 💡 外層包裹一層 Box,並加上 padding 與 navigationBarsPadding,確保懸浮且不被系統列擋住 // 💡 外層包裹一層 Box,並加上 padding 與 navigationBarsPadding,確保懸浮且不被系統列擋住
Box( Box(
modifier = Modifier 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. 實際的輸入工具列 // 2. 實際的輸入工具列
Row( Row(
modifier = Modifier modifier = Modifier
@ -129,7 +177,10 @@ fun MessageInputBar(
textStyle = MaterialTheme.typography.bodyLarge, textStyle = MaterialTheme.typography.bodyLarge,
decorationBox = { innerTextField -> decorationBox = { innerTextField ->
if (textState.isEmpty()) { if (textState.isEmpty()) {
Text("請輸入訊息...", color = Color.LightGray) Text(
text = if (editingMessage != null) "編輯訊息..." else "請輸入訊息...",
color = Color.LightGray
)
} }
innerTextField() innerTextField()
} }
@ -139,7 +190,11 @@ fun MessageInputBar(
IconButton( IconButton(
onClick = { onClick = {
if (textState.isNotBlank() && !isLoading) { if (textState.isNotBlank() && !isLoading) {
onSendClick(textState) if (editingMessage != null) {
onEditSaveClick(textState)
} else {
onSendClick(textState)
}
textState = "" textState = ""
} }
}, },
@ -150,9 +205,10 @@ fun MessageInputBar(
} else { } else {
// 加上小背景讓送出按鈕更有重點 // 加上小背景讓送出按鈕更有重點
val iconColor = if (textState.isNotBlank()) Color(0xFFDA7029) else Color.LightGray val iconColor = if (textState.isNotBlank()) Color(0xFFDA7029) else Color.LightGray
val icon = if (editingMessage != null) Icons.Default.Check else Icons.Default.Send
Icon( Icon(
imageVector = Icons.Default.Send, imageVector = icon,
contentDescription = "送出", contentDescription = if (editingMessage != null) "儲存編輯" else "送出",
tint = iconColor tint = iconColor
) )
} }

View file

@ -113,6 +113,7 @@ sealed interface BottomBarState {
data class ActionMenu(val message: MessageUiModel) : BottomBarState // 長按:動作選單 data class ActionMenu(val message: MessageUiModel) : BottomBarState // 長按:動作選單
data class EmojiMenu(val message: MessageUiModel) : BottomBarState // 單擊:Emoji 工具列 data class EmojiMenu(val message: MessageUiModel) : BottomBarState // 單擊:Emoji 工具列
object AttachmentMenu : BottomBarState // 🎯 新增:附件選單 object AttachmentMenu : BottomBarState // 🎯 新增:附件選單
data class Edit(val message: MessageUiModel) : BottomBarState // 🎯 新增:編輯模式
} }
// 🎯 新增:Top Bar 狀態機 // 🎯 新增:Top Bar 狀態機
@ -155,6 +156,8 @@ fun MessageMessaging(
// 🎯 新增:Top Bar 的本地狀態 // 🎯 新增:Top Bar 的本地狀態
var topBarState by remember { mutableStateOf<TopBarState>(TopBarState.Regular) } var topBarState by remember { mutableStateOf<TopBarState>(TopBarState.Regular) }
val editingMessage by viewModel.editingMessage.collectAsStateWithLifecycle()
// 🌟 核心:使用 derivedStateOf 統一控管狀態權限(長按優先權通常大於單擊) // 🌟 核心:使用 derivedStateOf 統一控管狀態權限(長按優先權通常大於單擊)
// 🎯 新增:用來控管非訊息觸發的底部狀態(例如:AttachmentMenu) // 🎯 新增:用來控管非訊息觸發的底部狀態(例如:AttachmentMenu)
var localBottomBarState by remember { mutableStateOf<BottomBarState?>(null) } var localBottomBarState by remember { mutableStateOf<BottomBarState?>(null) }
@ -163,6 +166,7 @@ fun MessageMessaging(
when { when {
actionMessage != null -> BottomBarState.ActionMenu(actionMessage!!) actionMessage != null -> BottomBarState.ActionMenu(actionMessage!!)
emojiTargetMessage != null -> BottomBarState.EmojiMenu(emojiTargetMessage!!) emojiTargetMessage != null -> BottomBarState.EmojiMenu(emojiTargetMessage!!)
editingMessage != null -> BottomBarState.Edit(editingMessage!!) // 🎯 編輯模式優先
localBottomBarState != null -> localBottomBarState!! // 🎯 如果有本地狀態,則採用它 localBottomBarState != null -> localBottomBarState!! // 🎯 如果有本地狀態,則採用它
else -> BottomBarState.Input 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 -> { is BottomBarState.ActionMenu -> {
val currentActionMessage = currentState.message val currentActionMessage = currentState.message
val currentUserId = (uiState as? MessagesUiState.Success)?.currentUserId val currentUserId = (uiState as? MessagesUiState.Success)?.currentUserId
@ -532,6 +551,10 @@ fun MessageMessaging(
viewModel.setReplyingMessage(msg) viewModel.setReplyingMessage(msg)
viewModel.clearActionMessage() viewModel.clearActionMessage()
}, },
onEditClick = { msg ->
viewModel.setEditingMessage(msg)
viewModel.clearActionMessage()
},
onDeleteClick = { msg -> onDeleteClick = { msg ->
// 🌟 核心防呆:如果訊息還在發送中(SENDING),禁止點擊刪除,避免 tempId 送給後端造成 404 // 🌟 核心防呆:如果訊息還在發送中(SENDING),禁止點擊刪除,避免 tempId 送給後端造成 404
if (msg.status == MessageStatus.SENDING) { if (msg.status == MessageStatus.SENDING) {
@ -1331,6 +1354,17 @@ private fun TextMessageContent(
text = message.content, text = message.content,
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp) 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)
)
}
} }
} }
} }

View file

@ -178,6 +178,11 @@ class ChatViewModel @Inject constructor(
fun setActionMessage(message: MessageUiModel?) { _actionMessage.value = message } fun setActionMessage(message: MessageUiModel?) { _actionMessage.value = message }
fun clearActionMessage() { _actionMessage.value = null } fun clearActionMessage() { _actionMessage.value = null }
private val _editingMessage = MutableStateFlow<MessageUiModel?>(null)
val editingMessage: StateFlow<MessageUiModel?> = _editingMessage.asStateFlow()
fun setEditingMessage(message: MessageUiModel?) { _editingMessage.value = message }
fun clearEditingMessage() { _editingMessage.value = null }
private val _replyingMessage = MutableStateFlow<MessageUiModel?>(null) private val _replyingMessage = MutableStateFlow<MessageUiModel?>(null)
val replyingMessage: StateFlow<MessageUiModel?> = _replyingMessage.asStateFlow() val replyingMessage: StateFlow<MessageUiModel?> = _replyingMessage.asStateFlow()
fun setReplyingMessage(message: MessageUiModel?) { _replyingMessage.value = message } 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 功能保持原樣 --- // --- 其餘 uploadAttachment、Emoji 功能保持原樣 ---
fun uploadAttachment(roomId: String, fileUri: Uri) { fun uploadAttachment(roomId: String, fileUri: Uri) {
viewModelScope.launch { viewModelScope.launch {