This commit is contained in:
Eric85710 2026-06-10 15:58:09 +08:00
parent d22d38b8fd
commit 6d48ad74ef
3 changed files with 342 additions and 313 deletions

View file

@ -7,6 +7,8 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -105,6 +107,8 @@ fun MessageMessaging(
roomId: String, roomId: String,
navController: NavController, navController: NavController,
onBackClick: () -> Unit, onBackClick: () -> Unit,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
viewModel: ChatViewModel = hiltViewModel(), viewModel: ChatViewModel = hiltViewModel(),
bottomBarViewModel: BottomBarViewModel bottomBarViewModel: BottomBarViewModel
) { ) {
@ -175,172 +179,177 @@ fun MessageMessaging(
is MessagesUiState.Loading -> "載入中..." is MessagesUiState.Loading -> "載入中..."
} }
Scaffold(
modifier = Modifier with(sharedTransitionScope){
.fillMaxSize() Scaffold(
.background( modifier = Modifier
brush = Brush.verticalGradient( .fillMaxSize()
colors = listOf .background(
(Color(0xFFDA7029), brush = Brush.verticalGradient(
Color(0xFF777777), colors = listOf
Color(0xFFB34800)) (Color(0xFFDA7029),
Color(0xFF777777),
Color(0xFFB34800))
)
),
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
val currentState = uiState
if (currentState is MessagesUiState.Success) {
UserAvatar(
avatarUrl = currentState.partnerAvatarUrl,
modifier = Modifier.size(36.dp)
)
}
Text(text = topBarTitle)
if (currentState is MessagesUiState.Success) {
if (currentState.partnerStatus == UserStatus.ONLINE) {
Box(
modifier = Modifier
.size(10.dp)
.background(
color = UserStatus.ONLINE.color,
shape = CircleShape
)
)
}
}
}
},
// 🌟 關鍵修改 1:設定半透明背景色
colors = TopAppBarDefaults.topAppBarColors(
// 這裡可以用 Surface 色加上 0.6 的透明度,或者直接用自訂顏色如 Color.White.copy(0.5f)
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.4f)
)
) )
), },
containerColor = Color.Transparent, bottomBar = {
topBar = { // 👈 使用我們定義的 sealed interface 狀態機
TopAppBar( AnimatedContent(
title = { targetState = bottomBarState,
Row( label = "BottomBarSwitchAnimation"
verticalAlignment = Alignment.CenterVertically, ) { currentState ->
horizontalArrangement = Arrangement.spacedBy(8.dp) when (currentState) {
) { is BottomBarState.Input -> {
val currentState = uiState MessageInputBar(
if (currentState is MessagesUiState.Success) { isLoading = sendStatus is SendMessageState.Loading,
UserAvatar( replyingMessage = replyingMessage,
avatarUrl = currentState.partnerAvatarUrl, onCancelReply = { viewModel.setReplyingMessage(null) },
modifier = Modifier.size(36.dp) onSendClick = { content -> viewModel.sendMessage(roomId, content) },
onImageSelected = { uri -> viewModel.uploadAttachment(roomId, uri) },
) )
} }
Text(text = topBarTitle) is BottomBarState.ActionMenu -> {
val currentActionMessage = currentState.message
val currentUserId = (uiState as? MessagesUiState.Success)?.currentUserId
val isOwnMessage = currentActionMessage.senderId == currentUserId
if (currentState is MessagesUiState.Success) { MessageActionMenuRow(
if (currentState.partnerStatus == UserStatus.ONLINE) { message = currentActionMessage,
Box( isOwnMessage = isOwnMessage,
modifier = Modifier onCancel = { viewModel.clearActionMessage() },
.size(10.dp) onReplyClick = { msg ->
.background( viewModel.setReplyingMessage(msg)
color = UserStatus.ONLINE.color, viewModel.clearActionMessage()
shape = CircleShape },
) onDeleteClick = { msg ->
) // 🌟 核心防呆:如果訊息還在發送中(SENDING),禁止點擊刪除,避免 tempId 送給後端造成 404
} if (msg.status == MessageStatus.SENDING) {
// 可以選擇在這裡彈出一個短暫的 Toast 提示使用者
Toast.makeText(context, "訊息發送中,請稍後再操作", Toast.LENGTH_SHORT).show()
} else {
// 正常訊息,呼叫樂觀更新刪除
viewModel.deleteMessage(roomId, msg.id)
}
}
)
} }
}
},
// 🌟 關鍵修改 1:設定半透明背景色
colors = TopAppBarDefaults.topAppBarColors(
// 這裡可以用 Surface 色加上 0.6 的透明度,或者直接用自訂顏色如 Color.White.copy(0.5f)
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)
)
)
},
bottomBar = {
// 👈 使用我們定義的 sealed interface 狀態機
AnimatedContent(
targetState = bottomBarState,
label = "BottomBarSwitchAnimation"
) { currentState ->
when (currentState) {
is BottomBarState.Input -> {
MessageInputBar(
isLoading = sendStatus is SendMessageState.Loading,
replyingMessage = replyingMessage,
onCancelReply = { viewModel.setReplyingMessage(null) },
onSendClick = { content -> viewModel.sendMessage(roomId, content) },
onImageSelected = { uri -> viewModel.uploadAttachment(roomId, uri) },
)
}
is BottomBarState.ActionMenu -> { is BottomBarState.EmojiMenu -> {
val currentActionMessage = currentState.message // 👈 渲染全新設計的獨立 Emoji Bar
val currentUserId = (uiState as? MessagesUiState.Success)?.currentUserId val currentEmojiMessage = currentState.message
val isOwnMessage = currentActionMessage.senderId == currentUserId MessageEmojiBar(
// 🌟 移除了 commonEmojis 參數,變得非常清爽!
MessageActionMenuRow( onEmojiClick = { emoji ->
message = currentActionMessage, val targetReaction = currentEmojiMessage.reactions?.find { it.emoji == emoji }
isOwnMessage = isOwnMessage, if (targetReaction?.meReacted == true) {
onCancel = { viewModel.clearActionMessage() }, viewModel.removeMessageReaction(roomId, currentEmojiMessage.id, emoji)
onReplyClick = { msg -> } else {
viewModel.setReplyingMessage(msg) viewModel.addMessageReaction(roomId, currentEmojiMessage.id, emoji)
viewModel.clearActionMessage() }
}, emojiTargetMessage = null // 點完後自動關閉
onDeleteClick = { msg -> },
// 🌟 核心防呆:如果訊息還在發送中(SENDING),禁止點擊刪除,避免 tempId 送給後端造成 404 onCancel = {
if (msg.status == MessageStatus.SENDING) { emojiTargetMessage = null // 點擊關閉
// 可以選擇在這裡彈出一個短暫的 Toast 提示使用者
Toast.makeText(context, "訊息發送中,請稍後再操作", Toast.LENGTH_SHORT).show()
} else {
// 正常訊息,呼叫樂觀更新刪除
viewModel.deleteMessage(roomId, msg.id)
} }
} )
) }
}
is BottomBarState.EmojiMenu -> {
// 👈 渲染全新設計的獨立 Emoji Bar
val currentEmojiMessage = currentState.message
MessageEmojiBar(
// 🌟 移除了 commonEmojis 參數,變得非常清爽!
onEmojiClick = { emoji ->
val targetReaction = currentEmojiMessage.reactions?.find { it.emoji == emoji }
if (targetReaction?.meReacted == true) {
viewModel.removeMessageReaction(roomId, currentEmojiMessage.id, emoji)
} else {
viewModel.addMessageReaction(roomId, currentEmojiMessage.id, emoji)
}
emojiTargetMessage = null // 點完後自動關閉
},
onCancel = {
emojiTargetMessage = null // 點擊關閉
}
)
} }
} }
} }
} ) { innerPadding ->
) { innerPadding -> Box(
Box( modifier = Modifier
modifier = Modifier .fillMaxSize()
.fillMaxSize() .padding(innerPadding),
.padding(innerPadding) contentAlignment = Alignment.Center
, ) {
contentAlignment = Alignment.Center // 根據不同的狀態渲染主畫面內容
) { when (val state = uiState) {
// 根據不同的狀態渲染主畫面內容 is MessagesUiState.Loading -> {
when (val state = uiState) { CircularProgressIndicator()
is MessagesUiState.Loading -> { }
CircularProgressIndicator()
} is MessagesUiState.Success -> {
is MessagesUiState.Success -> { if (state.messages.isEmpty()) {
if (state.messages.isEmpty()) { Text("目前沒有新訊息")
Text("目前沒有新訊息") } else {
} else { MessageList(
MessageList( currentUserId = state.currentUserId,
currentUserId = state.currentUserId, messages = state.messages,
messages = state.messages, partnerAvatarUrl = state.partnerAvatarUrl,
partnerAvatarUrl = state.partnerAvatarUrl, partnerDisplayName = state.roomTitle,
partnerDisplayName = state.roomTitle, activeEmojiMessageId = emojiTargetMessage?.id, // ✨ 新增短按目標 ID
activeEmojiMessageId = emojiTargetMessage?.id, // ✨ 新增短按目標 ID activeActionMessageId = actionMessage?.id,
activeActionMessageId = actionMessage?.id, onReplyClick = { message ->
onReplyClick = { message -> emojiTargetMessage = null
emojiTargetMessage = null viewModel.setActionMessage(message)
viewModel.setActionMessage(message) },
}, // 💡 單擊事件:開啟 Emoji 選單,同時清空長按選單
// 💡 單擊事件:開啟 Emoji 選單,同時清空長按選單 onRowClick = { message ->
onRowClick = { message -> viewModel.clearActionMessage()
viewModel.clearActionMessage() emojiTargetMessage = message
emojiTargetMessage = message },
}, onReactionClick = { message, emoji ->
onReactionClick = { message, emoji -> // 這是訊息氣泡下方既有 Reaction 小標籤的點擊事件,保持原樣即可
// 這是訊息氣泡下方既有 Reaction 小標籤的點擊事件,保持原樣即可 val targetReaction =
val targetReaction = message.reactions?.find { it.emoji == emoji } message.reactions?.find { it.emoji == emoji }
if (targetReaction?.meReacted == true) { if (targetReaction?.meReacted == true) {
viewModel.removeMessageReaction(roomId, message.id, emoji) viewModel.removeMessageReaction(roomId, message.id, emoji)
} else { } else {
viewModel.addMessageReaction(roomId, message.id, emoji) viewModel.addMessageReaction(roomId, message.id, emoji)
}
} }
} )
}
}
is MessagesUiState.Error -> {
Text(
text = "載入失敗:${state.message}",
color = MaterialTheme.colorScheme.error
) )
} }
} }
is MessagesUiState.Error -> {
Text(
text = "載入失敗:${state.message}",
color = MaterialTheme.colorScheme.error
)
}
} }
} }
} }

View file

@ -1,5 +1,7 @@
package com.example.login_v3.home.Message.UI.Detail package com.example.login_v3.home.Message.UI.Detail
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -76,6 +78,8 @@ val ChatRoom?.displayTitle: String
fun Loaded_Tg_Message( fun Loaded_Tg_Message(
navController: NavController, navController: NavController,
viewModel: ChatRoomsViewModel = hiltViewModel(), viewModel: ChatRoomsViewModel = hiltViewModel(),
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
onRoomClick: (String, String) -> Unit = { _, _ -> } onRoomClick: (String, String) -> Unit = { _, _ -> }
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
@ -191,11 +195,13 @@ fun Loaded_Tg_Message(
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
items( items(
items = rooms, items = rooms,
key = { it.roomId } // 增加效能,防止列表閃爍 key = { it.roomId }
) { room -> ) { room ->
RoomItem( RoomItem(
navController = navController, navController = navController,
room = room, room = room,
sharedTransitionScope = sharedTransitionScope, // 傳下去
animatedVisibilityScope = animatedVisibilityScope, // 傳下去
onClick = { roomId, roomName -> onClick = { roomId, roomName ->
onRoomClick(roomId, roomName) onRoomClick(roomId, roomName)
} }
@ -215,142 +221,149 @@ fun Loaded_Tg_Message(
fun RoomItem( fun RoomItem(
navController: NavController, navController: NavController,
room: ChatRoom, room: ChatRoom,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
// 🚀 修正 2:onClick 改成攜帶兩個參數 (roomId, roomName) // 🚀 修正 2:onClick 改成攜帶兩個參數 (roomId, roomName)
onClick: (String, String) -> Unit onClick: (String, String) -> Unit
) { ) {
val title = room.displayTitle val title = room.displayTitle
Card( with(sharedTransitionScope){
modifier = Modifier Card(
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp) // 增加外邊距,讓 Card 之間有呼吸感
.clickable {
// 🚀 修正:直接呼叫傳進來的 lambda 函式即可,不要包含 createRoute 唷!
onClick(room.roomId, room.displayTitle)
}
,
shape = RoundedCornerShape(16.dp), // 較大的圓角看起來更現代
colors = CardDefaults.cardColors(
containerColor = Color.Transparent // ✅ 透明背景
),
elevation = CardDefaults.cardElevation(
defaultElevation = 0.dp // 增加輕微陰影
)
) {
Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxWidth()
){ .padding(horizontal = 12.dp, vertical = 6.dp)
//glass effect .clickable { onClick(room.roomId, room.displayTitle) }
// 🌟 關鍵:把共享元素鎖定在整個外層 Container 上!
.sharedElement(
rememberSharedContentState(key = "container_${room.roomId}"),
animatedVisibilityScope = animatedVisibilityScope
)
,
shape = RoundedCornerShape(16.dp), // 較大的圓角看起來更現代
colors = CardDefaults.cardColors(
containerColor = Color.Transparent // ✅ 透明背景
),
elevation = CardDefaults.cardElevation(
defaultElevation = 0.dp // 增加輕微陰影
)
) {
Box( Box(
modifier = Modifier modifier = Modifier
.matchParentSize() .fillMaxSize()
.blur(10.dp) ){
) //glass effect
Box( Box(
modifier = Modifier
.matchParentSize()
.background(
color = MaterialTheme.colorScheme.primary,
RoundedCornerShape(16.dp)
)
.border(
1.dp,
Color.White.copy(alpha = 0.3f),
RoundedCornerShape(16.dp)
)
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(12.dp),
) {
// 1. 頭像
val imageUrl = room.roomIconUrl ?: room.partner?.fullContactAvatarUrl
AsyncImage(
model = imageUrl,
contentDescription = "Avatar",
contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.size(56.dp) // 稍微加大一點 .matchParentSize()
.clip(CircleShape) .blur(10.dp)
.border(1.dp, Color.White, CircleShape) // 頭像加個細白邊 )
.background(Color.LightGray) Box(
modifier = Modifier
.matchParentSize()
.background(
color = MaterialTheme.colorScheme.primary,
RoundedCornerShape(16.dp)
)
.border(
1.dp,
Color.White.copy(alpha = 0.3f),
RoundedCornerShape(16.dp)
)
) )
Spacer(modifier = Modifier.width(16.dp))
// 2. 中間內容 Row(
Column( verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f) modifier = Modifier.padding(12.dp),
) { ) {
Text( // 1. 頭像
text = room.roomName.orEmpty().ifEmpty { val imageUrl = room.roomIconUrl ?: room.partner?.fullContactAvatarUrl
room.partner?.displayName ?: "Unknown" AsyncImage(
}, model = imageUrl,
style = MaterialTheme.typography.titleMedium, contentDescription = "Avatar",
fontWeight = FontWeight.Bold, contentScale = ContentScale.Crop,
color = Color(0xFF333333), modifier = Modifier
maxLines = 1, .size(56.dp) // 稍微加大一點
overflow = TextOverflow.Ellipsis .clip(CircleShape)
.border(1.dp, Color.White, CircleShape) // 頭像加個細白邊
.background(Color.LightGray)
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.width(16.dp))
val lastMsgText = when { // 2. 中間內容
room.lastMessage?.isDeleted == true -> "訊息已刪除" Column(
room.lastMessage?.content != null -> room.lastMessage.content modifier = Modifier.weight(1f)
room.lastMessage?.attachment != null -> "[檔案] ${room.lastMessage.attachment.filename}" ) {
else -> "尚未有訊息"
}
Row() {
Text( Text(
text = lastMsgText, text = room.roomName.orEmpty().ifEmpty {
style = MaterialTheme.typography.bodyMedium, room.partner?.displayName ?: "Unknown"
color = Color.Gray, },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color(0xFF333333),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.height(4.dp))
if (room.unreadCount > 0) { val lastMsgText = when {
Badge( room.lastMessage?.isDeleted == true -> "訊息已刪除"
containerColor = Color(0xFFDA7029), // 使用你的主橘色 room.lastMessage?.content != null -> room.lastMessage.content
contentColor = Color.White room.lastMessage?.attachment != null -> "[檔案] ${room.lastMessage.attachment.filename}"
) { else -> "尚未有訊息"
Text( }
text = if (room.unreadCount > 99) "99+" else room.unreadCount.toString(),
modifier = Modifier.padding(horizontal = 4.dp) Row() {
) Text(
text = lastMsgText,
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.width(6.dp))
if (room.unreadCount > 0) {
Badge(
containerColor = Color(0xFFDA7029), // 使用你的主橘色
contentColor = Color.White
) {
Text(
text = if (room.unreadCount > 99) "99+" else room.unreadCount.toString(),
modifier = Modifier.padding(horizontal = 4.dp)
)
}
} }
} }
Spacer(modifier = Modifier.width(2.dp))
}
//online status
val partner = room.partner // 先從 room 取得 partner
if (partner != null) {
val status = UserStatus.fromString(partner.status)
if (status != UserStatus.UNKNOWN) {
Box(
modifier = Modifier
.size(14.dp)
.background(status.color, CircleShape) // 直接設定圓形背景
.border(2.dp, Color.White, CircleShape) // 白色外圈邊線
)
}
} }
Spacer(modifier = Modifier.width(2.dp)) Spacer(modifier = Modifier.width(10.dp))
} }
//online status
val partner = room.partner // 先從 room 取得 partner
if (partner != null) {
val status = UserStatus.fromString(partner.status)
if (status != UserStatus.UNKNOWN) {
Box(
modifier = Modifier
.size(14.dp)
.background(status.color, CircleShape) // 直接設定圓形背景
.border(2.dp, Color.White, CircleShape) // 白色外圈邊線
)
}
}
Spacer(modifier = Modifier.width(10.dp))
} }
} }
} }
} }

View file

@ -1,5 +1,6 @@
package com.example.login_v3.home.Message.UI package com.example.login_v3.home.Message.UI
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavType import androidx.navigation.NavType
@ -33,66 +34,72 @@ fun Tg_Message(
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
NavHost( SharedTransitionLayout{
navController = navController, NavHost(
startDestination = Screen.Messages.route, navController = navController,
) { startDestination = Screen.Messages.route,
// 你的訊息主頁面 ) {
composable(Screen.Messages.route) { // 你的訊息主頁面
Loaded_Tg_Message( // 訊息主頁面
navController = navController, composable(Screen.Messages.route) {
// ✨ 2. 這裡的點擊事件如果原本有傳 roomName,現在直接忽略,只傳 roomId 給路由 Loaded_Tg_Message(
onRoomClick = { roomId, _ -> navController = navController,
val route = Screen.MessageMessaging.createRoute(roomId) // 2. 傳入 sharedTransitionScope (this@SharedTransitionLayout)
navController.navigate(route) sharedTransitionScope = this@SharedTransitionLayout,
} // 3. 傳入 animatedVisibilityScope (this@composable)
) animatedVisibilityScope = this@composable,
} onRoomClick = { roomId, _ ->
val route = Screen.MessageMessaging.createRoute(roomId)
navController.navigate(route)
}
)
}
// 新建聯絡頁面 // 新建聯絡頁面
composable(Screen.CreateContact.route) { composable(Screen.CreateContact.route) {
Message_add_contact(navController) Message_add_contact(navController)
} }
// 好友列表頁面 // 好友列表頁面
composable(Screen.FriendsList.route) { composable(Screen.FriendsList.route) {
Message_contact_list( Message_contact_list(
navController, navController,
bottomBarViewModel = bottomBarViewModel bottomBarViewModel = bottomBarViewModel
) )
} }
//QR code //QR code
composable(Screen.ScanQRcode.route) { composable(Screen.ScanQRcode.route) {
Scan_QRcode( Scan_QRcode(
navController, navController,
bottomBarViewModel = bottomBarViewModel bottomBarViewModel = bottomBarViewModel
) )
} }
//Message detail //Message detail
composable( composable(
route = Screen.MessageMessaging.route, route = Screen.MessageMessaging.route,
arguments = listOf( arguments = listOf(
// ✨ 3. 只保留 roomId 的參數宣告,完全刪除 roomName 的 navArgument navArgument("roomId") {
navArgument("roomId") { type = NavType.StringType
type = NavType.StringType nullable = true
nullable = true defaultValue = ""
defaultValue = "" }
} )
) ) { backStackEntry ->
) { backStackEntry -> val roomId = backStackEntry.arguments?.getString("roomId").orEmpty()
val roomId = backStackEntry.arguments?.getString("roomId").orEmpty()
MessageMessaging(
roomId = roomId,
navController = navController,
// 4. 同樣把這兩個 Scope 傳入詳情頁
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@composable,
onBackClick = { navController.popBackStack() },
bottomBarViewModel = bottomBarViewModel
)
}
// ✨ 4. 呼叫新版的 MessageMessaging,不再傳入 roomName 參數
MessageMessaging(
roomId = roomId,
navController = navController,
onBackClick = {
navController.popBackStack()
},
bottomBarViewModel = bottomBarViewModel
)
} }
} }