This commit is contained in:
Eric85710 2026-05-27 05:38:00 +08:00
parent f8799511de
commit 2ea1890a0d
2 changed files with 246 additions and 79 deletions

View file

@ -13,6 +13,8 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -35,12 +37,14 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AddCircle import androidx.compose.material.icons.filled.AddCircle
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
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.Send import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
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
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@ -48,6 +52,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -67,14 +72,17 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController import androidx.navigation.NavController
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.example.login_v3.R import com.example.login_v3.R
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.Reaction
import com.example.login_v3.home.Message.ViewModel.Detail.ChatViewModel import com.example.login_v3.home.Message.ViewModel.Detail.ChatViewModel
import com.example.login_v3.home.Message.ViewModel.Detail.MessagesUiState import com.example.login_v3.home.Message.ViewModel.Detail.MessagesUiState
import com.example.login_v3.home.Message.ViewModel.Detail.ReactionUsersState
import com.example.login_v3.home.Message.ViewModel.Detail.SendMessageState import com.example.login_v3.home.Message.ViewModel.Detail.SendMessageState
import com.example.login_v3.home.Message.ViewModel.UserStatus import com.example.login_v3.home.Message.ViewModel.UserStatus
import com.example.login_v3.navigation.BottomBarViewModel import com.example.login_v3.navigation.BottomBarViewModel
@ -99,6 +107,17 @@ fun MessageMessaging(
//reply function //reply function
val replyingMessage by viewModel.replyingMessage.collectAsStateWithLifecycle() val replyingMessage by viewModel.replyingMessage.collectAsStateWithLifecycle()
// 💡 1. 取得 ViewModel 裡的 Reaction 狀態
val reactionState by viewModel.reactionUsersState.collectAsStateWithLifecycle()
// 控制 BottomSheet 顯示的變數
var showReactionBottomSheet by remember { mutableStateOf(false) }
// 當狀態變成 Success 且不是空名單時,或者正在 Loading,就開啟 BottomSheet
LaunchedEffect(reactionState) {
if (reactionState is ReactionUsersState.Success || reactionState is ReactionUsersState.Loading) {
showReactionBottomSheet = true
}
}
//進入時重整 //進入時重整
LaunchedEffect(roomId) { LaunchedEffect(roomId) {
viewModel.loadMessages(roomId) viewModel.loadMessages(roomId)
@ -132,6 +151,69 @@ fun MessageMessaging(
is MessagesUiState.Loading -> "載入中..." is MessagesUiState.Loading -> "載入中..."
} }
// 💡 2. 渲染 BottomSheet 視窗
if (showReactionBottomSheet) {
ModalBottomSheet(
onDismissRequest = {
showReactionBottomSheet = false
viewModel.resetReactionUsersState() // 關閉時記得清空狀態,避免下次閃出舊資料
},
sheetState = rememberModalBottomSheetState()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 32.dp, start = 16.dp, end = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "按讚的名單",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(bottom = 16.dp)
)
when (val state = reactionState) {
is ReactionUsersState.Loading -> {
CircularProgressIndicator(modifier = Modifier.padding(16.dp))
}
is ReactionUsersState.Success -> {
if (state.users.isEmpty()) {
Text("目前沒有人點擊", color = Color.Gray)
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(state.users) { userName ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
) {
// 這裡可以用一個預設頭像,或是單純顯示文字
Icon(
imageVector = Icons.Default.AccountCircle,
contentDescription = null,
tint = Color.Gray,
modifier = Modifier.size(36.dp).padding(end = 8.dp)
)
Text(
text = userName,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}
}
is ReactionUsersState.Error -> {
Text(text = state.message, color = MaterialTheme.colorScheme.error)
}
else -> {}
}
}
}
}
Scaffold( Scaffold(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -216,7 +298,14 @@ fun MessageMessaging(
messages = state.messages, messages = state.messages,
partnerAvatarUrl = state.partnerAvatarUrl, partnerAvatarUrl = state.partnerAvatarUrl,
partnerDisplayName = state.roomTitle, partnerDisplayName = state.roomTitle,
onReplyClick = { message -> viewModel.setReplyingMessage(message) } onReplyClick = { message -> viewModel.setReplyingMessage(message) },
onReactionClick = { message, emoji ->
viewModel.loadMessageReactionUsers(
roomId = roomId,
messageId = message.id,
emoji = emoji
)
}
) )
} }
} }
@ -238,6 +327,7 @@ fun MessageList(
partnerDisplayName: String, partnerDisplayName: String,
messages: List<Message>, messages: List<Message>,
onReplyClick: (Message) -> Unit, onReplyClick: (Message) -> Unit,
onReactionClick: (Message, String) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
LazyColumn( LazyColumn(
@ -254,7 +344,8 @@ fun MessageList(
partnerDisplayName = partnerDisplayName, partnerDisplayName = partnerDisplayName,
isMe = isMe, isMe = isMe,
currentUserId = currentUserId, currentUserId = currentUserId,
onReplyClick = onReplyClick onReplyClick = onReplyClick,
onReactionClick = onReactionClick
) )
} }
} }
@ -267,7 +358,8 @@ fun MessageRow(
partnerAvatarUrl: Any?, partnerAvatarUrl: Any?,
partnerDisplayName: String, partnerDisplayName: String,
currentUserId: String, currentUserId: String,
onReplyClick: (Message) -> Unit // 💡 新增參數 onReplyClick: (Message) -> Unit,
onReactionClick: (Message, String) -> Unit
) { ) {
val isAttachmentImage = message.attachment?.mimeType?.startsWith("image/") == true val isAttachmentImage = message.attachment?.mimeType?.startsWith("image/") == true
val contentLowerCase = (message.content ?: "").lowercase() val contentLowerCase = (message.content ?: "").lowercase()
@ -295,88 +387,100 @@ fun MessageRow(
) )
} }
Box( Column(
modifier = Modifier horizontalAlignment = if (isMe) Alignment.End else Alignment.Start
.widthIn(max = 280.dp)
.background(
color = if (isMe) Color(0xFFDCF8C6) else Color(0xFFECEFF1),
shape = RoundedCornerShape(
topStart = 12.dp, topEnd = 12.dp,
bottomStart = if (isMe) 12.dp else 0.dp,
bottomEnd = if (isMe) 0.dp else 12.dp
)
)
.padding(horizontal = 12.dp, vertical = 8.dp)
) { ) {
Column { Box(
// 💡 【核心:渲染被回覆的訊息內容】 modifier = Modifier
message.repliedMessage?.let { replied -> .widthIn(max = 280.dp)
val repliedSenderName = if (replied.senderId == currentUserId) "你" else partnerDisplayName .background(
color = if (isMe) Color(0xFFDCF8C6) else Color(0xFFECEFF1),
shape = RoundedCornerShape(
topStart = 12.dp, topEnd = 12.dp,
bottomStart = if (isMe) 12.dp else 0.dp,
bottomEnd = if (isMe) 0.dp else 12.dp
)
)
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Column {
// 💡 【核心:渲染被回覆的訊息內容】
message.repliedMessage?.let { replied ->
val repliedSenderName = if (replied.senderId == currentUserId) "你" else partnerDisplayName
// 被回覆的小氣泡外框 // 被回覆的小氣泡外框
Row( Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 6.dp)
.clip(RoundedCornerShape(4.dp))
// 💡 修正這裡:直接根據 isMe 決定傳入哪一個 Color 物件即可
.background(if (isMe) Color(0xFFC7EBB2) else Color(0xFFE0E0E0))
.drawBehind {
// 在左側畫一條精緻的提示線 (類似 LINE / Telegram)
drawLine(
color = Color.Gray,
start = Offset(0f, 0f),
end = Offset(0f, size.height),
strokeWidth = 6f
)
}
.padding(start = 8.dp, top = 4.dp, bottom = 4.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = repliedSenderName,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
color = Color(0xFF1E88E5),
maxLines = 1
)
Text(
// 如果被回覆的是圖片,顯示 [圖片],否則顯示文字內容
text = if (replied.attachment?.mimeType?.startsWith("image/") == true) "[圖片]" else replied.content,
style = MaterialTheme.typography.bodySmall,
color = Color.DarkGray,
maxLines = 1, // 最多一行,避免太長
overflow = TextOverflow.Ellipsis
)
}
}
}
// 2. 原本的訊息主體內容(文字或圖片)
if (isImage) {
Box(
modifier = Modifier
.padding(top = 4.dp)
.widthIn(max = 240.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.LightGray)
) {
AsyncImage(
model = finalImageModel,
contentDescription = "聊天圖片",
contentScale = ContentScale.Fit,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = 300.dp) .padding(bottom = 6.dp)
.clip(RoundedCornerShape(4.dp))
// 💡 修正這裡:直接根據 isMe 決定傳入哪一個 Color 物件即可
.background(if (isMe) Color(0xFFC7EBB2) else Color(0xFFE0E0E0))
.drawBehind {
// 在左側畫一條精緻的提示線 (類似 LINE / Telegram)
drawLine(
color = Color.Gray,
start = Offset(0f, 0f),
end = Offset(0f, size.height),
strokeWidth = 6f
)
}
.padding(start = 8.dp, top = 4.dp, bottom = 4.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = repliedSenderName,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
color = Color(0xFF1E88E5),
maxLines = 1
)
Text(
// 如果被回覆的是圖片,顯示 [圖片],否則顯示文字內容
text = if (replied.attachment?.mimeType?.startsWith("image/") == true) "[圖片]" else replied.content,
style = MaterialTheme.typography.bodySmall,
color = Color.DarkGray,
maxLines = 1, // 最多一行,避免太長
overflow = TextOverflow.Ellipsis
)
}
}
}
// 2. 原本的訊息主體內容(文字或圖片)
if (isImage) {
Box(
modifier = Modifier
.padding(top = 4.dp)
.widthIn(max = 240.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.LightGray)
) {
AsyncImage(
model = finalImageModel,
contentDescription = "聊天圖片",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
)
}
} else {
Text(
text = rawContent,
style = MaterialTheme.typography.bodyMedium
) )
} }
} else {
Text(
text = rawContent,
style = MaterialTheme.typography.bodyMedium
)
} }
} }
// 💡 ✨ 新增:如果這條訊息有 Reaction,就顯示在氣泡的正下方
if (!message.reactions.isNullOrEmpty()) {
ReactionRow(
reactions = message.reactions,
onReactionClick = { emoji -> onReactionClick(message, emoji) }
)
}
} }
} }
} }
@ -392,6 +496,16 @@ fun MessageInputBar(
) { ) {
var textState by remember { mutableStateOf("") } var textState by remember { mutableStateOf("") }
val imagePickerLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let {
onImageSelected(it)
}
}
// 💡 使用打包器,確保軟鍵盤彈出時系統會正確計算高度 // 💡 使用打包器,確保軟鍵盤彈出時系統會正確計算高度
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -444,8 +558,17 @@ fun MessageInputBar(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// 圖片選擇按鈕 (範例,可換成你的實作) // 圖片選擇按鈕 (範例,可換成你的實作)
IconButton(onClick = { /* 觸發你的圖片選取器,回傳 URI */ }) { // ✅ 圖片按鈕
Icon(imageVector = Icons.Default.Add, contentDescription = "選擇圖片", tint = Color.Gray) IconButton(
onClick = {
imagePickerLauncher.launch("image/*")
}
) {
Icon(
imageVector = Icons.Default.Photo,
contentDescription = "選擇圖片",
tint = Color.Gray
)
} }
// 輸入文字框 // 輸入文字框
@ -496,3 +619,42 @@ fun UserAvatar(
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} }
@Composable
fun ReactionRow(
reactions: List<Reaction>,
onReactionClick: (String) -> Unit
) {
// 使用 FlowRow,如果貼圖太多會自動折行
OptIn(ExperimentalLayoutApi::class)
FlowRow(
modifier = Modifier.padding(top = 4.dp, start = 4.dp, end = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
reactions.forEach { reaction ->
if (reaction.count > 0) {
Row(
modifier = Modifier
.clip(CircleShape)
.background(
// 如果我自己有按讚,背景變稍微深色/藍色點綴,否則為淡灰色
if (reaction.meReacted) Color(0xFFBBDEFB) else Color(0xFFEEEEEE)
)
.clickable { onReactionClick(reaction.emoji) }
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(text = reaction.emoji, fontSize = 14.sp)
Text(
text = reaction.count.toString(),
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
color = if (reaction.meReacted) Color(0xFF1976D2) else Color.DarkGray
)
}
}
}
}
}

View file

@ -215,6 +215,11 @@ class ChatViewModel @Inject constructor(
} }
} }
// 💡 請確保這段程式碼有確實待在 ChatViewModel 裡面
fun resetReactionUsersState() {
_reactionUsersState.value = ReactionUsersState.Idle
}
//新增:重設發送狀態的 function //新增:重設發送狀態的 function
fun resetSendMessageState() { fun resetSendMessageState() {
_sendMessageState.value = SendMessageState.Idle _sendMessageState.value = SendMessageState.Idle