|
|
|
@ -5,7 +5,7 @@ import android.content.pm.PackageManager |
|
|
|
import androidx.activity.compose.rememberLauncherForActivityResult |
|
|
|
import androidx.activity.compose.rememberLauncherForActivityResult |
|
|
|
import androidx.activity.result.contract.ActivityResultContracts |
|
|
|
import androidx.activity.result.contract.ActivityResultContracts |
|
|
|
import androidx.compose.foundation.background |
|
|
|
import androidx.compose.foundation.background |
|
|
|
import androidx.compose.foundation.gestures.detectTapGestures |
|
|
|
import androidx.compose.foundation.interaction.MutableInteractionSource |
|
|
|
import androidx.compose.foundation.layout.* |
|
|
|
import androidx.compose.foundation.layout.* |
|
|
|
import androidx.compose.foundation.shape.RoundedCornerShape |
|
|
|
import androidx.compose.foundation.shape.RoundedCornerShape |
|
|
|
import androidx.compose.material3.* |
|
|
|
import androidx.compose.material3.* |
|
|
|
@ -17,11 +17,13 @@ import androidx.compose.ui.graphics.Color |
|
|
|
import androidx.compose.ui.input.pointer.pointerInput |
|
|
|
import androidx.compose.ui.input.pointer.pointerInput |
|
|
|
import androidx.compose.ui.platform.LocalConfiguration |
|
|
|
import androidx.compose.ui.platform.LocalConfiguration |
|
|
|
import androidx.compose.ui.platform.LocalContext |
|
|
|
import androidx.compose.ui.platform.LocalContext |
|
|
|
|
|
|
|
import androidx.compose.ui.platform.LocalDensity |
|
|
|
import androidx.compose.ui.text.font.FontWeight |
|
|
|
import androidx.compose.ui.text.font.FontWeight |
|
|
|
import androidx.compose.ui.unit.dp |
|
|
|
import androidx.compose.ui.unit.dp |
|
|
|
import androidx.compose.ui.unit.sp |
|
|
|
import androidx.compose.ui.unit.sp |
|
|
|
import androidx.core.content.ContextCompat |
|
|
|
import androidx.core.content.ContextCompat |
|
|
|
import com.utun.chat.data.Message |
|
|
|
import com.utun.chat.data.Message |
|
|
|
|
|
|
|
import com.utun.chat.data.NativeLib |
|
|
|
import kotlinx.coroutines.delay |
|
|
|
import kotlinx.coroutines.delay |
|
|
|
|
|
|
|
|
|
|
|
@Composable |
|
|
|
@Composable |
|
|
|
@ -75,6 +77,8 @@ fun MessageBubble(message: Message) { |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private val LOCK_SWIPE_THRESHOLD_DP = 80f |
|
|
|
|
|
|
|
|
|
|
|
@Composable |
|
|
|
@Composable |
|
|
|
fun InputBar( |
|
|
|
fun InputBar( |
|
|
|
text: String, |
|
|
|
text: String, |
|
|
|
@ -82,13 +86,20 @@ fun InputBar( |
|
|
|
onSend: () -> Unit, |
|
|
|
onSend: () -> Unit, |
|
|
|
enabled: Boolean = true, |
|
|
|
enabled: Boolean = true, |
|
|
|
isRecording: Boolean = false, |
|
|
|
isRecording: Boolean = false, |
|
|
|
|
|
|
|
isRecordingLocked: Boolean = false, |
|
|
|
recordingDurationMs: Int = 0, |
|
|
|
recordingDurationMs: Int = 0, |
|
|
|
|
|
|
|
recordingLevel: Float = 0f, |
|
|
|
onPttPressed: () -> Unit = {}, |
|
|
|
onPttPressed: () -> Unit = {}, |
|
|
|
onPttReleased: () -> Unit = {}, |
|
|
|
onPttReleased: () -> Unit = {}, |
|
|
|
|
|
|
|
onPttLock: () -> Unit = {}, |
|
|
|
|
|
|
|
onVoiceSend: () -> Unit = {}, |
|
|
|
|
|
|
|
onVoiceCancel: () -> Unit = {}, |
|
|
|
onAttachClicked: () -> Unit = {}, |
|
|
|
onAttachClicked: () -> Unit = {}, |
|
|
|
modifier: Modifier = Modifier |
|
|
|
modifier: Modifier = Modifier |
|
|
|
) { |
|
|
|
) { |
|
|
|
val ctx = LocalContext.current |
|
|
|
val ctx = LocalContext.current |
|
|
|
|
|
|
|
val density = LocalDensity.current |
|
|
|
|
|
|
|
val lockThresholdPx = with(density) { LOCK_SWIPE_THRESHOLD_DP.dp.toPx() } |
|
|
|
|
|
|
|
|
|
|
|
var hasMicPerm by remember { |
|
|
|
var hasMicPerm by remember { |
|
|
|
mutableStateOf( |
|
|
|
mutableStateOf( |
|
|
|
@ -100,21 +111,23 @@ fun InputBar( |
|
|
|
hasMicPerm = granted |
|
|
|
hasMicPerm = granted |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> |
|
|
|
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { _ -> } |
|
|
|
uri?.let { onAttachClicked() } |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Row( |
|
|
|
Row( |
|
|
|
modifier = modifier.fillMaxWidth().padding(8.dp), |
|
|
|
modifier = modifier.fillMaxWidth().padding(8.dp), |
|
|
|
verticalAlignment = Alignment.CenterVertically |
|
|
|
verticalAlignment = Alignment.CenterVertically |
|
|
|
) { |
|
|
|
) { |
|
|
|
/* Attach button */ |
|
|
|
/* Attach button */ |
|
|
|
|
|
|
|
if (!isRecording) { |
|
|
|
IconButton(onClick = { filePicker.launch("*/*") }, enabled = enabled) { |
|
|
|
IconButton(onClick = { filePicker.launch("*/*") }, enabled = enabled) { |
|
|
|
Text("\uD83D\uDCCE", fontSize = 20.sp) |
|
|
|
Text("\uD83D\uDCCE", fontSize = 20.sp) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
Spacer(Modifier.width(4.dp)) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* Recording overlay or text input */ |
|
|
|
if (isRecording) { |
|
|
|
if (isRecording) { |
|
|
|
/* Recording overlay */ |
|
|
|
|
|
|
|
Surface( |
|
|
|
Surface( |
|
|
|
modifier = Modifier.weight(1f).height(48.dp), |
|
|
|
modifier = Modifier.weight(1f).height(48.dp), |
|
|
|
shape = RoundedCornerShape(8.dp), |
|
|
|
shape = RoundedCornerShape(8.dp), |
|
|
|
@ -122,23 +135,25 @@ fun InputBar( |
|
|
|
) { |
|
|
|
) { |
|
|
|
Row( |
|
|
|
Row( |
|
|
|
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), |
|
|
|
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), |
|
|
|
verticalAlignment = Alignment.CenterVertically, |
|
|
|
verticalAlignment = Alignment.CenterVertically |
|
|
|
horizontalArrangement = Arrangement.SpaceBetween |
|
|
|
|
|
|
|
) { |
|
|
|
) { |
|
|
|
Text( |
|
|
|
Text( |
|
|
|
text = "\uD83D\uDD34 ${formatDuration(recordingDurationMs)}", |
|
|
|
text = "\uD83D\uDD34 ${formatDuration(recordingDurationMs)}", |
|
|
|
color = Color.White, |
|
|
|
color = Color.White, |
|
|
|
fontWeight = FontWeight.Bold, |
|
|
|
fontWeight = FontWeight.Bold, |
|
|
|
fontSize = 16.sp |
|
|
|
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), |
|
|
|
) |
|
|
|
) |
|
|
|
/* Cancel button — tap to cancel recording */ |
|
|
|
|
|
|
|
IconButton(onClick = onPttReleased) { |
|
|
|
|
|
|
|
Text("\u2715", color = Color.White, fontSize = 16.sp) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} else { |
|
|
|
} else { |
|
|
|
/* Text input */ |
|
|
|
|
|
|
|
OutlinedTextField( |
|
|
|
OutlinedTextField( |
|
|
|
value = text, |
|
|
|
value = text, |
|
|
|
onValueChange = onTextChange, |
|
|
|
onValueChange = onTextChange, |
|
|
|
@ -151,30 +166,71 @@ fun InputBar( |
|
|
|
|
|
|
|
|
|
|
|
Spacer(Modifier.width(4.dp)) |
|
|
|
Spacer(Modifier.width(4.dp)) |
|
|
|
|
|
|
|
|
|
|
|
/* PTT button — press to talk */ |
|
|
|
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 (isRecording) { |
|
|
|
|
|
|
|
/* Holding: only PTT active */ |
|
|
|
|
|
|
|
IconButton( |
|
|
|
|
|
|
|
onClick = {}, |
|
|
|
|
|
|
|
enabled = true, |
|
|
|
|
|
|
|
modifier = Modifier.background(Color(0xFFE53935), RoundedCornerShape(4.dp)) |
|
|
|
|
|
|
|
) { |
|
|
|
|
|
|
|
Text("\uD83C\uDFA4", fontSize = 20.sp) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
/* Idle: PTT + send */ |
|
|
|
IconButton( |
|
|
|
IconButton( |
|
|
|
onClick = { |
|
|
|
onClick = { |
|
|
|
if (!hasMicPerm) permLauncher.launch(Manifest.permission.RECORD_AUDIO) |
|
|
|
if (!hasMicPerm) { permLauncher.launch(Manifest.permission.RECORD_AUDIO); return@IconButton } |
|
|
|
}, |
|
|
|
}, |
|
|
|
enabled = enabled && hasMicPerm, |
|
|
|
enabled = true, |
|
|
|
modifier = Modifier.pointerInput(Unit) { |
|
|
|
modifier = Modifier.pointerInput(Unit) { |
|
|
|
detectTapGestures( |
|
|
|
awaitPointerEventScope { |
|
|
|
onPress = { |
|
|
|
/* wait for the first down */ |
|
|
|
|
|
|
|
var event = awaitPointerEvent() |
|
|
|
|
|
|
|
var change = event.changes.firstOrNull() ?: return@awaitPointerEventScope |
|
|
|
|
|
|
|
if (!change.pressed) return@awaitPointerEventScope |
|
|
|
|
|
|
|
change.consume() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (!hasMicPerm) return@awaitPointerEventScope |
|
|
|
onPttPressed() |
|
|
|
onPttPressed() |
|
|
|
tryAwaitRelease() |
|
|
|
var locked = false |
|
|
|
|
|
|
|
var totalDragY = 0f |
|
|
|
|
|
|
|
var lastY = change.position.y |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
while (true) { |
|
|
|
|
|
|
|
event = awaitPointerEvent() |
|
|
|
|
|
|
|
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() |
|
|
|
} |
|
|
|
} |
|
|
|
) |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
) { |
|
|
|
) { |
|
|
|
Text("\uD83C\uDFA4", fontSize = 20.sp) |
|
|
|
Text("\uD83C\uDFA4", fontSize = 20.sp) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/* Send button */ |
|
|
|
|
|
|
|
Button(onClick = onSend, enabled = enabled && text.isNotBlank()) { |
|
|
|
Button(onClick = onSend, enabled = enabled && text.isNotBlank()) { |
|
|
|
Text("Send") |
|
|
|
Text("Send") |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private fun formatDuration(ms: Int): String { |
|
|
|
private fun formatDuration(ms: Int): String { |
|
|
|
@ -184,7 +240,7 @@ private fun formatDuration(ms: Int): String { |
|
|
|
|
|
|
|
|
|
|
|
private fun formatTime(ts: Long): String { |
|
|
|
private fun formatTime(ts: Long): String { |
|
|
|
if (ts == 0L) return "" |
|
|
|
if (ts == 0L) return "" |
|
|
|
val s = ts / 1_000_000_000 // nanoseconds -> seconds |
|
|
|
val s = ts / 1_000_000_000 |
|
|
|
val h = (s / 3600) % 24 |
|
|
|
val h = (s / 3600) % 24 |
|
|
|
val m = (s / 60) % 60 |
|
|
|
val m = (s / 60) % 60 |
|
|
|
return "%02d:%02d".format(h, m) |
|
|
|
return "%02d:%02d".format(h, m) |
|
|
|
|