@ -25,6 +25,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.data.Message
@ -36,7 +37,8 @@ import kotlinx.coroutines.delay
fun MessageBubble ( message : Message , playbackState : ChatViewModel . PlaybackState = ChatViewModel . PlaybackState ( ) ,
onPlayVoice : ( String , Int ) -> Unit = { _ , _ -> } ,
onSeekVoice : ( String , Int , Int ) -> Unit = { _ , _ , _ -> } ,
onDownloadFile : ( Message ) -> Unit = { } ) {
onDownloadFile : ( Message ) -> Unit = { } ,
onOpenFile : ( Message ) -> Unit = { } ) {
val bgColor = if ( message . isOutgoing ) Color ( 0xFF4A90D9 ) else Color ( 0xFFE8E8E8 )
val textColor = if ( message . isOutgoing ) Color . White else Color . Black
val alignment = if ( message . isOutgoing ) Arrangement . End else Arrangement . Start
@ -63,10 +65,8 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
when {
isVoice -> VoiceBubble ( message , textColor , isActive , playbackState , onPlayVoice , onSeekVoice )
isMedia -> FileBubble ( message , textColor , onDownloadFile )
isMedia -> FileBubble ( message , textColor , onDownloadFile , onOpenFile )
else -> {
Text ( message . author , fontSize = 13. sp , fontWeight = FontWeight . Bold , color = textColor . copy ( alpha = 0.7f ) )
Spacer ( Modifier . height ( 2. dp ) )
Text ( message . text , fontSize = 17. sp , color = textColor )
}
}
@ -171,7 +171,8 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean,
}
@Composable
private fun FileBubble ( message : Message , textColor : Color , onDownload : ( Message ) -> Unit = { } ) {
private fun FileBubble ( message : Message , textColor : Color , onDownload : ( Message ) -> Unit = { } ,
onOpen : ( Message ) -> Unit = { } ) {
val state = message . downloadState
val stateIcon = when {
state == " downloaded " -> " \u2705 "
@ -179,13 +180,18 @@ private fun FileBubble(message: Message, textColor: Color, onDownload: (Message)
else -> " \u2B07 \uFE0F "
}
val stateText = when {
state == " downloaded " -> " Downloaded "
state == " downloaded " -> " Tap to save "
state == " downloading " -> " Downloading... "
else -> " Tap to download "
}
Column (
modifier = Modifier . clickable { if ( state . isEmpty ( ) ) onDownload ( message ) }
modifier = Modifier . clickable {
when {
state == " downloaded " -> onOpen ( message )
state . isEmpty ( ) -> onDownload ( message )
}
}
) {
Row ( verticalAlignment = Alignment . CenterVertically ) {
Text ( stateIcon , fontSize = 18. sp )
@ -235,6 +241,9 @@ fun InputBar(
onVoiceSend : ( ) -> Unit = { } ,
onVoiceCancel : ( ) -> Unit = { } ,
onAttachClicked : ( ) -> Unit = { } ,
pendingAttachmentName : String = " " ,
pendingAttachmentSize : Long = 0L ,
onClearAttachment : ( ) -> Unit = { } ,
modifier : Modifier = Modifier
) {
val ctx = LocalContext . current
@ -251,126 +260,163 @@ fun InputBar(
hasMicPerm = granted
}
val filePicker = rememberLauncherForActivityResult ( ActivityResultContracts . GetContent ( ) ) { _ -> }
val hasAttachment = pendingAttachmentName . isNotEmpty ( )
Row (
modifier = modifier . fillMaxWidth ( ) . padding ( 8. dp ) ,
verticalAlignment = Alignment . CenterVertically
) {
/* Attach button */
if ( !is Recording ) {
IconButton ( onClick = { filePicker . launch ( " */* " ) } , enabled = enabled ) {
Text ( " \uD83D \uDCCE " , fontSize = 20. sp )
}
} else {
Spacer ( Modifier . width ( 4. dp ) )
}
/* Recording overlay or text input */
if ( isRecording ) {
Column ( modifier = modifier . fillMaxWidth ( ) . padding ( 8. dp ) ) {
/* Attachment preview bar */
if ( hasAttachment ) {
Surface (
modifier = Modifier . weight ( 1f ) . height ( 48 .dp ) ,
modifier = Modifier . fillMaxWidth ( ) . height ( 36. dp ) . padding ( bottom = 4. dp ) ,
shape = RoundedCornerShape ( 8. dp ) ,
color = Color ( 0xFFE53935 )
color = MaterialTheme . colorScheme . primaryContainer
) {
Row (
modifier = Modifier . fillMaxSize ( ) . padding ( horizontal = 12 .dp ) ,
modifier = Modifier . fillMaxSize ( ) . padding ( horizontal = 8. dp ) ,
verticalAlignment = Alignment . CenterVertically
) {
Text ( " \uD83D \uDCCE " , fontSize = 14. sp )
Spacer ( Modifier . width ( 6. dp ) )
Text (
text = " \uD83D \uDD34 ${formatDuration(recordingDurationMs)} " ,
color = Color . White ,
fontWeight = FontWeight . Bold ,
fontSize = 16. sp ,
pendingAttachmentName ,
fontSize = 14. sp ,
maxLines = 1 ,
overflow = TextOverflow . Ellipsis ,
modifier = Modifier . weight ( 1f )
)
val lvlPct = ( recordingLevel * 40f ) . coerceIn ( 0f , 1f )
LinearProgressIndicator (
progress = { lvlPct } ,
modifier = Modifier . width ( 60. dp ) . height ( 4. dp ) . padding ( end = 8. dp ) ,
color = Color . White ,
trackColor = Color . White . copy ( alpha = 0.3f ) ,
)
if ( pendingAttachmentSize > 0 ) {
Text (
formatFileSize ( pendingAttachmentSize ) ,
fontSize = 12. sp ,
color = MaterialTheme . colorScheme . onPrimaryContainer . copy ( alpha = 0.6f )
)
Spacer ( Modifier . width ( 8. dp ) )
}
IconButton (
onClick = onClearAttachment ,
modifier = Modifier . size ( 24. dp )
) {
Text ( " \u2715 " , fontSize = 14. sp )
}
}
}
} else {
OutlinedTextField (
value = text ,
onValueChange = onTextChange ,
enabled = enabled ,
modifier = Modifier . weight ( 1f ) ,
placeholder = { Text ( " Message... " ) } ,
maxLines = 4
)
}
Spacer ( Modifier . width ( 4. dp ) )
if ( isRecording && isRecordingLocked ) {
/* Locked: cancel + send voice */
IconButton ( onClick = onVoiceCancel ) {
Text ( " \u2715 " , fontSize = 20. sp , color = Color ( 0xFFE53935 ) )
}
IconButton ( onClick = onVoiceSend ) {
Text ( " \u25B6 " , fontSize = 20. sp , color = Color ( 0xFF4CAF50 ) )
}
} else if ( !is Recording && text . isNotBlank ( ) ) {
/* Send button — round, primary color */
val sendColor = MaterialTheme . colorScheme . primary
Box (
modifier = Modifier
. size ( 44. dp )
. background ( sendColor , CircleShape )
. clickable { onSend ( ) } ,
contentAlignment = Alignment . Center
) {
Text ( " \u27A4 " , fontSize = 22. sp , color = Color . White )
Row ( modifier = Modifier . fillMaxWidth ( ) , verticalAlignment = Alignment . CenterVertically ) {
/* Attach button */
if ( !is Recording ) {
IconButton ( onClick = onAttachClicked , enabled = enabled ) {
Text ( " \uD83D \uDCCE " , fontSize = 20. sp )
}
} else {
Spacer ( Modifier . width ( 4. dp ) )
}
} else {
/* PTT Box — podcast/recording, same identity in idle and holding */
val pttBg = if ( isRecording ) Color ( 0xFFE53935 ) else Color . Transparent
Box (
modifier = Modifier
. size ( 48. dp )
. background ( pttBg , RoundedCornerShape ( 4. dp ) )
. pointerInput ( hasMicPerm ) {
awaitPointerEventScope {
while ( true ) {
val down = awaitPointerEvent ( )
val ch = down . changes . firstOrNull ( ) ?: continue
if ( ! ch . pressed ) continue
ch . consume ( )
if ( ! hasMicPerm ) {
permLauncher . launch ( Manifest . permission . RECORD _AUDIO )
continue
}
/* Recording overlay or text input */
if ( isRecording ) {
Surface (
modifier = Modifier . weight ( 1f ) . height ( 48. dp ) ,
shape = RoundedCornerShape ( 8. dp ) ,
color = Color ( 0xFFE53935 )
) {
Row (
modifier = Modifier . fillMaxSize ( ) . padding ( horizontal = 12. dp ) ,
verticalAlignment = Alignment . CenterVertically
) {
Text (
text = " \uD83D \uDD34 ${formatDuration(recordingDurationMs)} " ,
color = Color . White ,
fontWeight = FontWeight . Bold ,
fontSize = 16. sp ,
modifier = Modifier . weight ( 1f )
)
val lvlPct = ( recordingLevel * 40f ) . coerceIn ( 0f , 1f )
LinearProgressIndicator (
progress = { lvlPct } ,
modifier = Modifier . width ( 60. dp ) . height ( 4. dp ) . padding ( end = 8. dp ) ,
color = Color . White ,
trackColor = Color . White . copy ( alpha = 0.3f ) ,
)
}
}
} else {
OutlinedTextField (
value = text ,
onValueChange = onTextChange ,
enabled = enabled ,
modifier = Modifier . weight ( 1f ) ,
placeholder = { Text ( " Message... " ) } ,
maxLines = 4
)
}
onPttPressed ( )
var locked = false
var totalDragY = 0f
var lastY = ch . position . y
Spacer ( Modifier . width ( 4. dp ) )
if ( isRecording && isRecordingLocked ) {
/* Locked: cancel + send voice */
IconButton ( onClick = onVoiceCancel ) {
Text ( " \u2715 " , fontSize = 20. sp , color = Color ( 0xFFE53935 ) )
}
IconButton ( onClick = onVoiceSend ) {
Text ( " \u25B6 " , fontSize = 20. sp , color = Color ( 0xFF4CAF50 ) )
}
} else if ( !is Recording && ( text . isNotBlank ( ) || hasAttachment ) ) {
/* Send button — round, primary color */
val sendColor = MaterialTheme . colorScheme . primary
Box (
modifier = Modifier
. size ( 44. dp )
. background ( sendColor , CircleShape )
. clickable { onSend ( ) } ,
contentAlignment = Alignment . Center
) {
Text ( " \u27A4 " , fontSize = 22. sp , color = Color . White )
}
} else {
/* PTT Box — podcast/recording, same identity in idle and holding */
val pttBg = if ( isRecording ) Color ( 0xFFE53935 ) else Color . Transparent
Box (
modifier = Modifier
. size ( 48. dp )
. background ( pttBg , RoundedCornerShape ( 4. dp ) )
. pointerInput ( hasMicPerm ) {
awaitPointerEventScope {
while ( true ) {
val event = awaitPointerEvent ( )
val change = event . changes . firstOrNull ( ) ?: break
if ( change . pressed ) {
val dy = change . position . y - lastY
lastY = change . position . y
totalDragY += dy
if ( totalDragY < - lockThresholdPx && ! locked ) { locked = true ; onPttLock ( ) }
change . consume ( )
} else {
change . consume ( ) ; break
val down = awaitPointerEvent ( )
val ch = down . changes . firstOrNull ( ) ?: continue
if ( ! ch . pressed ) continue
ch . consume ( )
if ( ! hasMicPerm ) {
permLauncher . launch ( Manifest . permission . RECORD _AUDIO )
continue
}
onPttPressed ( )
var locked = false
var totalDragY = 0f
var lastY = ch . position . y
while ( true ) {
val event = awaitPointerEvent ( )
val change = event . changes . firstOrNull ( ) ?: break
if ( change . pressed ) {
val dy = change . position . y - lastY
lastY = change . position . y
totalDragY += dy
if ( totalDragY < - lockThresholdPx && ! locked ) { locked = true ; onPttLock ( ) }
change . consume ( )
} else {
change . consume ( ) ; break
}
}
onPttReleased ( )
}
onPttReleased ( )
}
}
} ,
contentAlignment = Alignment . Center
) {
Text ( " \uD83C \uDFA4 " , fontSize = 20. sp , color = if ( isRecording ) Color . White else Color . Unspecified )
} ,
contentAlignment = Alignment . Center
) {
Text ( " \uD83C \uDFA4 " , fontSize = 20. sp , color = if ( isRecording ) Color . White else Color . Unspecified )
}
}
}
}