feat: voice recording auto-send + recording indicator UI
Voice messages now upload and send automatically when recording stops — no manual send button needed. The input area transforms during recording: text field replaced by a centered pulsing red dot + "Aufnahme läuft…" label, with a single centered red stop button below. The old flow (record → attachment chip → manual send) treated voice like a file attachment. New flow: tap mic → record → tap stop → auto-upload → auto-send → stream response. Matches the expected voice message UX.
This commit is contained in:
parent
497f0a82f7
commit
20a71adb99
2 changed files with 159 additions and 49 deletions
|
|
@ -553,6 +553,34 @@ fun MessageRow(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecordingIndicator() {
|
||||||
|
val cs = MaterialTheme.colorScheme
|
||||||
|
val transition = rememberInfiniteTransition(label = "rec")
|
||||||
|
val dotAlpha by transition.animateFloat(
|
||||||
|
initialValue = 1f, targetValue = 0.3f,
|
||||||
|
animationSpec = infiniteRepeatable(tween(800, easing = FastOutSlowInEasing), RepeatMode.Reverse),
|
||||||
|
label = "dot",
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().heightIn(min = 48.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Canvas(Modifier.size(10.dp)) {
|
||||||
|
drawCircle(Color(0xFFEF4444).copy(alpha = dotAlpha))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.chat_recording),
|
||||||
|
color = cs.onSurfaceVariant.copy(alpha = 0.70f),
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessageActionButton(icon: ImageVector, tint: Color, onClick: () -> Unit) {
|
private fun MessageActionButton(icon: ImageVector, tint: Color, onClick: () -> Unit) {
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -629,6 +657,23 @@ fun ChatInput(
|
||||||
cornerRadius = 32.dp,
|
cornerRadius = 32.dp,
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(horizontal = 14.dp, vertical = 14.dp)) {
|
Column(Modifier.padding(horizontal = 14.dp, vertical = 14.dp)) {
|
||||||
|
if (isRecording) {
|
||||||
|
// Recording state — replaces the text field with a recording indicator
|
||||||
|
RecordingIndicator()
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Box(
|
||||||
|
Modifier.size(42.dp).clip(KaizenShapes.circle)
|
||||||
|
.background(Color(0xFFEF4444))
|
||||||
|
.clickable(onClick = onMicClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(KaizenIcons.Square, stringResource(R.string.chat_stop), tint = Color.White, modifier = Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if (pendingFiles.isNotEmpty()) {
|
if (pendingFiles.isNotEmpty()) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(bottom = 10.dp),
|
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(bottom = 10.dp),
|
||||||
|
|
@ -662,17 +707,11 @@ fun ChatInput(
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Box(
|
Box(
|
||||||
Modifier.size(38.dp).clip(KaizenShapes.circle)
|
Modifier.size(38.dp).clip(KaizenShapes.circle).background(iconBg).clickable(onClick = onMicClick),
|
||||||
.background(if (isRecording) Color(0xFFEF4444).copy(alpha = if (isDark) 0.85f else 0.80f) else iconBg)
|
|
||||||
.clickable(onClick = onMicClick),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (isRecording) {
|
|
||||||
Icon(KaizenIcons.Square, stringResource(R.string.chat_recording), tint = Color.White, modifier = Modifier.size(14.dp))
|
|
||||||
} else {
|
|
||||||
Icon(KaizenIcons.Mic, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(19.dp))
|
Icon(KaizenIcons.Mic, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(19.dp))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
|
@ -686,6 +725,7 @@ fun ChatInput(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -217,7 +217,77 @@ fun ChatScreen(
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
stopRecording(recorderRef, audioFileRef) { name, mime, bytes ->
|
stopRecording(recorderRef, audioFileRef) { name, mime, bytes ->
|
||||||
isRecording = false
|
isRecording = false
|
||||||
uploadPickedFile(name, mime, bytes)
|
val cfg = session.config ?: return@stopRecording
|
||||||
|
scope.launch {
|
||||||
|
when (val r = KaizenApi.uploadFile(cfg.baseUrl, cfg.token, name, mime, bytes)) {
|
||||||
|
is FetchResult.Ok -> {
|
||||||
|
val att = r.data
|
||||||
|
val userParentId = messages.lastOrNull()?.wireId
|
||||||
|
val userMsg = Message(nextId++, Role.User, "", attachments = listOf(att))
|
||||||
|
messages.add(userMsg)
|
||||||
|
val assistantId = nextId++
|
||||||
|
val assistantWireId = java.util.UUID.randomUUID().toString()
|
||||||
|
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId))
|
||||||
|
isStreaming = true
|
||||||
|
haptics.click()
|
||||||
|
|
||||||
|
val history = messages
|
||||||
|
.filter { it.id != assistantId }
|
||||||
|
.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }
|
||||||
|
|
||||||
|
val convIdDeferred = if (conversationId == null) {
|
||||||
|
scope.async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) }
|
||||||
|
} else null
|
||||||
|
|
||||||
|
streamJob = launch {
|
||||||
|
haptics.thinkingStart()
|
||||||
|
var sawContent = false
|
||||||
|
try {
|
||||||
|
KaizenApi.chat(
|
||||||
|
cfg.baseUrl, cfg.token, cfg.model, history,
|
||||||
|
attachments = listOf(att),
|
||||||
|
).collect { state ->
|
||||||
|
val idx = messages.indexOfLast { it.id == assistantId }
|
||||||
|
if (idx < 0) return@collect
|
||||||
|
if (!sawContent && state.content.isNotEmpty()) {
|
||||||
|
sawContent = true; haptics.responseStart()
|
||||||
|
}
|
||||||
|
messages[idx] = messages[idx].copy(
|
||||||
|
thinking = if (sawContent) false else messages[idx].thinking,
|
||||||
|
content = state.content,
|
||||||
|
reasoning = state.reasoning,
|
||||||
|
tools = state.tools,
|
||||||
|
sources = state.sources,
|
||||||
|
query = state.query,
|
||||||
|
streaming = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: kotlinx.coroutines.CancellationException) {
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
val assistantIdx = messages.indexOfLast { it.id == assistantId }
|
||||||
|
if (assistantIdx >= 0) messages[assistantIdx] = messages[assistantIdx].copy(streaming = false, thinking = false)
|
||||||
|
isStreaming = false
|
||||||
|
haptics.responseEnd()
|
||||||
|
// Persist
|
||||||
|
val cid = convIdDeferred?.await() ?: conversationId ?: return@launch
|
||||||
|
if (conversationId == null) conversationId = cid
|
||||||
|
val assistantMsg = messages.lastOrNull { it.id == assistantId } ?: return@launch
|
||||||
|
val userSave = SaveMessage(id = userMsg.wireId, parentId = userParentId, role = "user", content = "", attachments = listOf(att))
|
||||||
|
val assistantSave = SaveMessage(id = assistantMsg.wireId, parentId = userMsg.wireId, role = "assistant", content = assistantMsg.content, model = cfg.model, reasoning = assistantMsg.reasoning.takeIf { it.isNotBlank() }, sources = assistantMsg.sources.takeIf { it.isNotEmpty() }?.map { dev.kaizen.app.net.SearchSource(it.title, it.url, it.snippet) })
|
||||||
|
KaizenApi.saveMessages(cfg.baseUrl, cfg.token, cid, listOf(userSave, assistantSave))
|
||||||
|
if (convIdDeferred != null) {
|
||||||
|
chat.conversationRepo.insertOptimistic(cid, "(Voice)")
|
||||||
|
val title = KaizenApi.generateTitle(cfg.baseUrl, cfg.token, cid)
|
||||||
|
if (title != null) chat.conversationRepo.updateTitle(cid, title)
|
||||||
|
chat.conversationRepo.refresh(cfg.baseUrl, cfg.token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is FetchResult.Fail -> {
|
||||||
|
Toast.makeText(context, r.reason, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue