|
|
|
@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier |
|
|
|
import androidx.compose.ui.draw.clip |
|
|
|
import androidx.compose.ui.draw.clip |
|
|
|
import androidx.compose.ui.geometry.Offset |
|
|
|
import androidx.compose.ui.geometry.Offset |
|
|
|
import androidx.compose.ui.geometry.Size |
|
|
|
import androidx.compose.ui.geometry.Size |
|
|
|
|
|
|
|
import com.utun.chat.ui.theme.ChatColors |
|
|
|
import androidx.compose.ui.graphics.Color |
|
|
|
import androidx.compose.ui.graphics.Color |
|
|
|
import androidx.compose.ui.graphics.asImageBitmap |
|
|
|
import androidx.compose.ui.graphics.asImageBitmap |
|
|
|
import androidx.compose.ui.input.pointer.pointerInput |
|
|
|
import androidx.compose.ui.input.pointer.pointerInput |
|
|
|
@ -82,8 +83,8 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = |
|
|
|
onOpenFile: (Message) -> Unit = {}, |
|
|
|
onOpenFile: (Message) -> Unit = {}, |
|
|
|
onOpenImage: (Message) -> Unit = {}, |
|
|
|
onOpenImage: (Message) -> Unit = {}, |
|
|
|
onPlayVideo: (Message) -> Unit = {}) { |
|
|
|
onPlayVideo: (Message) -> Unit = {}) { |
|
|
|
val bgColor = if (message.isOutgoing) Color(0xFF4A90D9) else Color(0xFFE8E8E8) |
|
|
|
val bgColor = if (message.isOutgoing) ChatColors.Outgoing else ChatColors.Raised |
|
|
|
val textColor = if (message.isOutgoing) Color.White else Color.Black |
|
|
|
val textColor = ChatColors.Text |
|
|
|
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start |
|
|
|
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start |
|
|
|
val minBubbleWidth = (LocalConfiguration.current.screenWidthDp / 3).dp |
|
|
|
val minBubbleWidth = (LocalConfiguration.current.screenWidthDp / 3).dp |
|
|
|
val voiceMaxWidth = (LocalConfiguration.current.screenWidthDp * 0.75f).dp |
|
|
|
val voiceMaxWidth = (LocalConfiguration.current.screenWidthDp * 0.75f).dp |
|
|
|
@ -120,7 +121,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = |
|
|
|
.background(bgColor) |
|
|
|
.background(bgColor) |
|
|
|
.padding(12.dp) |
|
|
|
.padding(12.dp) |
|
|
|
) { |
|
|
|
) { |
|
|
|
if (!isVoice) Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f)) |
|
|
|
if (!isVoice) Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ChatColors.Secondary) |
|
|
|
Spacer(Modifier.height(2.dp)) |
|
|
|
Spacer(Modifier.height(2.dp)) |
|
|
|
|
|
|
|
|
|
|
|
when { |
|
|
|
when { |
|
|
|
@ -138,7 +139,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = |
|
|
|
Text( |
|
|
|
Text( |
|
|
|
formatTime(message.ts), |
|
|
|
formatTime(message.ts), |
|
|
|
fontSize = 11.sp, |
|
|
|
fontSize = 11.sp, |
|
|
|
color = textColor.copy(alpha = 0.5f), |
|
|
|
color = ChatColors.Secondary, |
|
|
|
modifier = Modifier.align(Alignment.End) |
|
|
|
modifier = Modifier.align(Alignment.End) |
|
|
|
) |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
@ -164,7 +165,7 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean, |
|
|
|
Column { |
|
|
|
Column { |
|
|
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { |
|
|
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { |
|
|
|
Box( |
|
|
|
Box( |
|
|
|
modifier = Modifier.size(32.dp).background(Color.White, CircleShape) |
|
|
|
modifier = Modifier.size(32.dp).background(ChatColors.Background, CircleShape) |
|
|
|
.clickable { |
|
|
|
.clickable { |
|
|
|
when { |
|
|
|
when { |
|
|
|
downloading -> {} |
|
|
|
downloading -> {} |
|
|
|
@ -181,7 +182,7 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean, |
|
|
|
playing && !paused -> "\u23F8" |
|
|
|
playing && !paused -> "\u23F8" |
|
|
|
else -> "\u25B6" |
|
|
|
else -> "\u25B6" |
|
|
|
}, |
|
|
|
}, |
|
|
|
fontSize = 14.sp, color = Color.Black |
|
|
|
fontSize = 14.sp, color = ChatColors.Text |
|
|
|
) |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
Spacer(Modifier.width(8.dp)) |
|
|
|
Spacer(Modifier.width(8.dp)) |
|
|
|
@ -212,7 +213,7 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean, |
|
|
|
val y = ph - barH |
|
|
|
val y = ph - barH |
|
|
|
val played = showCursor && i < playedIdx |
|
|
|
val played = showCursor && i < playedIdx |
|
|
|
drawRect( |
|
|
|
drawRect( |
|
|
|
color = when { played -> Color.White; else -> Color(0xFFCCCCD4).copy(alpha = 0.8f) }, |
|
|
|
color = if (played) ChatColors.Accent else ChatColors.Secondary, |
|
|
|
topLeft = Offset(x, y), |
|
|
|
topLeft = Offset(x, y), |
|
|
|
size = Size(barW, barH) |
|
|
|
size = Size(barW, barH) |
|
|
|
) |
|
|
|
) |
|
|
|
@ -241,12 +242,12 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean, |
|
|
|
else -> formatDurationMs(durMs) |
|
|
|
else -> formatDurationMs(durMs) |
|
|
|
}, |
|
|
|
}, |
|
|
|
fontSize = 12.sp, |
|
|
|
fontSize = 12.sp, |
|
|
|
color = textColor.copy(alpha = 0.55f) |
|
|
|
color = ChatColors.Secondary |
|
|
|
) |
|
|
|
) |
|
|
|
Text( |
|
|
|
Text( |
|
|
|
formatTime(message.ts), |
|
|
|
formatTime(message.ts), |
|
|
|
fontSize = 11.sp, |
|
|
|
fontSize = 11.sp, |
|
|
|
color = textColor.copy(alpha = 0.4f), |
|
|
|
color = ChatColors.Secondary, |
|
|
|
modifier = Modifier.align(Alignment.CenterVertically).padding(start = 8.dp) |
|
|
|
modifier = Modifier.align(Alignment.CenterVertically).padding(start = 8.dp) |
|
|
|
) |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
@ -287,7 +288,7 @@ private fun FileBubble(message: Message, textColor: Color, onDownload: (Message) |
|
|
|
Text( |
|
|
|
Text( |
|
|
|
"${formatFileSize(message.fileSize)} · $stateText", |
|
|
|
"${formatFileSize(message.fileSize)} · $stateText", |
|
|
|
fontSize = 12.sp, |
|
|
|
fontSize = 12.sp, |
|
|
|
color = textColor.copy(alpha = 0.5f) |
|
|
|
color = ChatColors.Secondary |
|
|
|
) |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
@ -357,7 +358,7 @@ private fun VideoBubble(message: Message, textColor: Color, |
|
|
|
) { |
|
|
|
) { |
|
|
|
thumb?.let { Image(it.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) } |
|
|
|
thumb?.let { Image(it.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) } |
|
|
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { |
|
|
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { |
|
|
|
Box(modifier = Modifier.size(44.dp).background(Color(0x88000000), CircleShape), contentAlignment = Alignment.Center) { |
|
|
|
Box(modifier = Modifier.size(44.dp).background(Color(0xCC000000), CircleShape), contentAlignment = Alignment.Center) { |
|
|
|
Text( |
|
|
|
Text( |
|
|
|
when { downloading -> "\u23F3"; isError -> "\u267B"; !downloaded -> "\u2B07"; else -> "\u25B6" }, |
|
|
|
when { downloading -> "\u23F3"; isError -> "\u267B"; !downloaded -> "\u2B07"; else -> "\u25B6" }, |
|
|
|
fontSize = 18.sp, color = Color.White |
|
|
|
fontSize = 18.sp, color = Color.White |
|
|
|
@ -368,7 +369,7 @@ private fun VideoBubble(message: Message, textColor: Color, |
|
|
|
Text( |
|
|
|
Text( |
|
|
|
formatDurationMs(durMs), fontSize = 11.sp, color = Color.White, |
|
|
|
formatDurationMs(durMs), fontSize = 11.sp, color = Color.White, |
|
|
|
modifier = Modifier.align(Alignment.BottomEnd).padding(6.dp) |
|
|
|
modifier = Modifier.align(Alignment.BottomEnd).padding(6.dp) |
|
|
|
.background(Color(0x88000000), RoundedCornerShape(4.dp)) |
|
|
|
.background(Color(0xCC000000), RoundedCornerShape(4.dp)) |
|
|
|
.padding(horizontal = 4.dp, vertical = 1.dp) |
|
|
|
.padding(horizontal = 4.dp, vertical = 1.dp) |
|
|
|
) |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
@ -392,7 +393,7 @@ private fun VideoBubble(message: Message, textColor: Color, |
|
|
|
else -> "Tap to play" |
|
|
|
else -> "Tap to play" |
|
|
|
}) |
|
|
|
}) |
|
|
|
}.trimEnd(), |
|
|
|
}.trimEnd(), |
|
|
|
fontSize = 12.sp, color = textColor.copy(alpha = 0.5f) |
|
|
|
fontSize = 12.sp, color = ChatColors.Secondary |
|
|
|
) |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
@ -434,7 +435,7 @@ private fun ImageBubble(message: Message, textColor: Color, |
|
|
|
) { |
|
|
|
) { |
|
|
|
bmp?.let { Image(it.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) } |
|
|
|
bmp?.let { Image(it.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) } |
|
|
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { |
|
|
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { |
|
|
|
Box(modifier = Modifier.size(44.dp).background(Color(0x88000000), CircleShape), contentAlignment = Alignment.Center) { |
|
|
|
Box(modifier = Modifier.size(44.dp).background(Color(0xCC000000), CircleShape), contentAlignment = Alignment.Center) { |
|
|
|
Text( |
|
|
|
Text( |
|
|
|
when { downloading -> "\u23F3"; isError -> "\u267B"; !downloaded -> "\u2B07"; else -> "" }, |
|
|
|
when { downloading -> "\u23F3"; isError -> "\u267B"; !downloaded -> "\u2B07"; else -> "" }, |
|
|
|
fontSize = 18.sp, color = Color.White |
|
|
|
fontSize = 18.sp, color = Color.White |
|
|
|
@ -453,7 +454,7 @@ private fun ImageBubble(message: Message, textColor: Color, |
|
|
|
!downloaded -> "Tap to download" |
|
|
|
!downloaded -> "Tap to download" |
|
|
|
else -> "Tap to open" |
|
|
|
else -> "Tap to open" |
|
|
|
}, |
|
|
|
}, |
|
|
|
fontSize = 12.sp, color = textColor.copy(alpha = 0.5f) |
|
|
|
fontSize = 12.sp, color = ChatColors.Secondary |
|
|
|
) |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
@ -548,7 +549,7 @@ fun InputBar( |
|
|
|
Text( |
|
|
|
Text( |
|
|
|
formatFileSize(pendingAttachmentSize), |
|
|
|
formatFileSize(pendingAttachmentSize), |
|
|
|
fontSize = 12.sp, |
|
|
|
fontSize = 12.sp, |
|
|
|
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) |
|
|
|
color = ChatColors.Secondary |
|
|
|
) |
|
|
|
) |
|
|
|
Spacer(Modifier.width(8.dp)) |
|
|
|
Spacer(Modifier.width(8.dp)) |
|
|
|
} |
|
|
|
} |
|
|
|
@ -577,7 +578,7 @@ fun InputBar( |
|
|
|
Surface( |
|
|
|
Surface( |
|
|
|
modifier = Modifier.weight(1f).height(48.dp), |
|
|
|
modifier = Modifier.weight(1f).height(48.dp), |
|
|
|
shape = RoundedCornerShape(8.dp), |
|
|
|
shape = RoundedCornerShape(8.dp), |
|
|
|
color = Color(0xFFE53935) |
|
|
|
color = Color(0xFF54252A) |
|
|
|
) { |
|
|
|
) { |
|
|
|
Row( |
|
|
|
Row( |
|
|
|
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), |
|
|
|
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), |
|
|
|
@ -615,10 +616,10 @@ fun InputBar( |
|
|
|
if (isRecording && isRecordingLocked) { |
|
|
|
if (isRecording && isRecordingLocked) { |
|
|
|
/* Locked: cancel + send voice */ |
|
|
|
/* Locked: cancel + send voice */ |
|
|
|
IconButton(onClick = onVoiceCancel) { |
|
|
|
IconButton(onClick = onVoiceCancel) { |
|
|
|
Text("\u2715", fontSize = 20.sp, color = Color(0xFFE53935)) |
|
|
|
Text("\u2715", fontSize = 20.sp, color = Color(0xFFFFB4AB)) |
|
|
|
} |
|
|
|
} |
|
|
|
IconButton(onClick = onVoiceSend) { |
|
|
|
IconButton(onClick = onVoiceSend) { |
|
|
|
Text("\u25B6", fontSize = 20.sp, color = Color(0xFF4CAF50)) |
|
|
|
Text("\u25B6", fontSize = 20.sp, color = Color(0xFF81C784)) |
|
|
|
} |
|
|
|
} |
|
|
|
} else if (!isRecording && (text.isNotBlank() || hasAttachment)) { |
|
|
|
} else if (!isRecording && (text.isNotBlank() || hasAttachment)) { |
|
|
|
/* Send button — round, primary color */ |
|
|
|
/* Send button — round, primary color */ |
|
|
|
@ -630,11 +631,11 @@ fun InputBar( |
|
|
|
.clickable { onSend() }, |
|
|
|
.clickable { onSend() }, |
|
|
|
contentAlignment = Alignment.Center |
|
|
|
contentAlignment = Alignment.Center |
|
|
|
) { |
|
|
|
) { |
|
|
|
Text("\u27A4", fontSize = 22.sp, color = Color.White) |
|
|
|
Text("\u27A4", fontSize = 22.sp, color = MaterialTheme.colorScheme.onPrimary) |
|
|
|
} |
|
|
|
} |
|
|
|
} else { |
|
|
|
} else { |
|
|
|
/* Unified mode button — short tap toggles voice/video, hold activates */ |
|
|
|
/* Unified mode button — short tap toggles voice/video, hold activates */ |
|
|
|
val pttBg = if (isRecording) Color(0xFFE53935) else Color.Transparent |
|
|
|
val pttBg = if (isRecording) Color(0xFFB32632) else Color.Transparent |
|
|
|
Box( |
|
|
|
Box( |
|
|
|
modifier = Modifier |
|
|
|
modifier = Modifier |
|
|
|
.size(48.dp) |
|
|
|
.size(48.dp) |
|
|
|
|