Add Live Call UI overlay + wiring in ChatScreen

LiveCallOverlay — fullscreen composable: KaizenOrb (amplitude-reactive,
streaming/recording variants), duration timer, status text (connecting/
listening/speaking), animated subtitle (transcript/response), end call
button (red gradient circle with PhoneOff icon), interrupt on orb tap.

ChatScreen wiring: CallButton → startLiveCall() → binds LiveCallService
→ Foreground Service with AudioPipeline + LiveClient. Overlay renders
above chat content with fade animation. endLiveCall() unbinds + stops.

ChatInput: onCallClick callback propagated from CallButton to ChatScreen.

i18n: live_connecting/listening/ready/speaking/end/reconnecting in de+en.
KaizenIcons: added PhoneOff (Lucide).
This commit is contained in:
Bruno Deanoz 2026-06-24 03:14:08 +02:00
parent 65ea3a5656
commit 31278f2440
6 changed files with 288 additions and 2 deletions

View file

@ -791,6 +791,7 @@ fun ChatInput(
isRecording: Boolean = false,
recordingAmplitude: Float = 0f,
onMicClick: () -> Unit = {},
onCallClick: () -> Unit = {},
pendingFiles: List<PendingFile> = emptyList(),
onAddClick: () -> Unit = {},
onRemoveFile: (String) -> Unit = {},
@ -922,7 +923,7 @@ fun ChatInput(
} else if (hasContent) {
SendButton(enabled = canSend, onClick = onSend)
} else {
CallButton(onClick = { /* TODO: call feature */ })
CallButton(onClick = onCallClick)
}
}
}

View file

@ -92,11 +92,17 @@ import androidx.core.content.ContextCompat
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import android.content.ComponentName
import android.content.ServiceConnection
import android.os.IBinder
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
import java.io.File
import dev.kaizen.app.live.CallStatus
import dev.kaizen.app.live.LiveCallOverlay
import dev.kaizen.app.live.LiveCallService
// Screen enumeration for modular state-driven navigation (Zero Technical Debt)
enum class AppScreen { Chat, Settings }
@ -154,6 +160,16 @@ fun ChatScreen(
val audioFileRef = remember { mutableStateOf<File?>(null) }
var recordingAmplitude by remember { mutableStateOf(0f) }
// ─── Live Call state (uses context/ttsVoice/currentConversationId declared below) ───
var liveService by remember { mutableStateOf<LiveCallService?>(null) }
var liveCallActive by remember { mutableStateOf(false) }
val liveStatus by liveService?.status?.collectAsState() ?: remember { mutableStateOf(CallStatus.IDLE) }
val liveSubtitle by liveService?.subtitle?.collectAsState() ?: remember { mutableStateOf("") }
val liveAmplitude by liveService?.amplitude?.collectAsState() ?: remember { mutableStateOf(0f) }
val liveUserSpeaking by liveService?.userSpeaking?.collectAsState() ?: remember { mutableStateOf(false) }
var liveStartedAt by remember { mutableStateOf(0L) }
var liveElapsedMs by remember { mutableStateOf(0L) }
LaunchedEffect(isRecording) {
if (!isRecording) { recordingAmplitude = 0f; return@LaunchedEffect }
while (isRecording) {
@ -201,6 +217,59 @@ fun ChatScreen(
val pendingFiles = remember { mutableStateListOf<PendingFile>() }
val context = LocalContext.current
// ─── Live Call service binding + control ──────────────────────────────
val liveServiceConnection = remember {
object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
liveService = (binder as? LiveCallService.LocalBinder)?.service
}
override fun onServiceDisconnected(name: ComponentName?) {
liveService = null
}
}
}
LaunchedEffect(liveCallActive) {
if (liveCallActive) {
liveStartedAt = System.currentTimeMillis()
while (liveCallActive) {
liveElapsedMs = System.currentTimeMillis() - liveStartedAt
kotlinx.coroutines.delay(500)
}
}
}
fun startLiveCall() {
val cfg = session.config ?: return
liveCallActive = true
val intent = LiveCallService.intent(context)
context.startForegroundService(intent)
context.bindService(intent, liveServiceConnection, 0)
scope.launch {
var attempts = 0
while (liveService == null && attempts < 20) {
kotlinx.coroutines.delay(50)
attempts++
}
liveService?.startCall(
baseUrl = cfg.baseUrl,
token = cfg.token,
model = cfg.model,
voice = ttsVoice,
conversationId = conversationId,
)
}
}
fun endLiveCall() {
liveService?.endCall()
liveCallActive = false
try { context.unbindService(liveServiceConnection) } catch (_: Exception) {}
liveService = null
}
var showAttachMenu by remember { mutableStateOf(false) }
fun uploadPickedFile(name: String, mimeType: String, bytes: ByteArray) {
@ -1205,6 +1274,7 @@ fun ChatScreen(
isRecording = isRecording,
recordingAmplitude = recordingAmplitude,
onMicClick = { toggleRecording() },
onCallClick = { startLiveCall() },
pendingFiles = pendingFiles,
onAddClick = { showAttachMenu = !showAttachMenu },
onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } },
@ -1302,6 +1372,23 @@ fun ChatScreen(
}
}
}
// ─── Live Call Overlay ────────────────────────────────
androidx.compose.animation.AnimatedVisibility(
visible = liveCallActive,
enter = androidx.compose.animation.fadeIn(tween(250)),
exit = androidx.compose.animation.fadeOut(tween(200)),
) {
LiveCallOverlay(
status = liveStatus,
subtitle = liveSubtitle,
amplitude = liveAmplitude,
userSpeaking = liveUserSpeaking,
elapsedMs = liveElapsedMs,
onEndCall = { endLiveCall() },
onInterrupt = { liveService?.interrupt() },
)
}
}
}
}

View file

@ -0,0 +1,181 @@
package dev.kaizen.app.live
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.kaizen.app.R
import dev.kaizen.app.chat.KaizenOrb
import dev.kaizen.app.ui.effect.GlassSurface
import dev.kaizen.app.ui.effect.KaizenShadows
import dev.kaizen.app.ui.icon.KaizenIcons
import dev.kaizen.app.ui.shape.KaizenShapes
import dev.kaizen.app.ui.theme.LocalKaizenAccent
@Composable
fun LiveCallOverlay(
status: CallStatus,
subtitle: String,
amplitude: Float,
userSpeaking: Boolean,
elapsedMs: Long,
onEndCall: () -> Unit,
onInterrupt: () -> Unit,
modifier: Modifier = Modifier,
) {
val isDark = isSystemInDarkTheme()
val accent = LocalKaizenAccent.current
val cs = MaterialTheme.colorScheme
Box(
modifier
.fillMaxSize()
.background(
if (isDark) Color.Black.copy(alpha = 0.85f)
else Color.White.copy(alpha = 0.88f)
)
.statusBarsPadding()
.navigationBarsPadding(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp),
) {
// ─── Duration ─────────────────────────────────────────
val elapsed = elapsedMs / 1000
val min = elapsed / 60
val sec = elapsed % 60
Text(
String.format("%d:%02d", min, sec),
color = cs.onSurface.copy(alpha = 0.5f),
fontSize = 14.sp,
fontWeight = FontWeight.W400,
letterSpacing = 1.sp,
)
// ─── Orb ──────────────────────────────────────────────
val isActive = status == CallStatus.LISTENING || status == CallStatus.SPEAKING
Box(
Modifier
.size(180.dp)
.then(
if (status == CallStatus.SPEAKING) {
Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onInterrupt,
)
} else Modifier
),
contentAlignment = Alignment.Center,
) {
KaizenOrb(
size = 160.dp,
streaming = status == CallStatus.SPEAKING,
recording = status == CallStatus.LISTENING && userSpeaking,
amplitude = if (isActive) amplitude else null,
)
}
// ─── Status text ──────────────────────────────────────
val statusText = when (status) {
CallStatus.IDLE -> ""
CallStatus.CONNECTING -> stringResource(R.string.live_connecting)
CallStatus.LISTENING -> if (userSpeaking) stringResource(R.string.live_listening)
else stringResource(R.string.live_ready)
CallStatus.SPEAKING -> stringResource(R.string.live_speaking)
}
Text(
statusText,
color = when (status) {
CallStatus.SPEAKING -> accent.primary
CallStatus.LISTENING -> if (userSpeaking) accent.primary.copy(alpha = 0.8f)
else cs.onSurface.copy(alpha = 0.4f)
else -> cs.onSurface.copy(alpha = 0.4f)
},
fontSize = 13.sp,
fontWeight = FontWeight.W400,
letterSpacing = 0.5.sp,
)
// ─── Subtitle (transcript / KI response) ─────────────
AnimatedVisibility(
visible = subtitle.isNotEmpty(),
enter = fadeIn(tween(200)) + expandVertically(),
exit = fadeOut(tween(150)) + shrinkVertically(),
) {
Text(
subtitle,
color = cs.onSurface.copy(alpha = 0.75f),
fontSize = 15.sp,
fontWeight = FontWeight.W300,
textAlign = TextAlign.Center,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
lineHeight = 22.sp,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(32.dp))
// ─── End Call button ──────────────────────────────────
EndCallButton(onClick = onEndCall)
}
}
}
@Composable
private fun EndCallButton(onClick: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val pressScale by animateFloatAsState(if (pressed) 0.9f else 1f, label = "end")
Box(
Modifier
.size(64.dp)
.scale(pressScale)
.clip(KaizenShapes.circle)
.background(
Brush.verticalGradient(
listOf(
Color(0xFFEF4444),
Color(0xFFDC2626),
)
)
)
.clickable(interactionSource = interaction, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(
KaizenIcons.PhoneOff,
contentDescription = stringResource(R.string.live_end),
tint = Color.White,
modifier = Modifier.size(28.dp),
)
}
}

View file

@ -97,8 +97,9 @@ object KaizenIcons {
val ArrowDown: ImageVector get() = Lucide.ArrowDown
val ArrowUp: ImageVector get() = Lucide.ArrowUp
// ── Audio ──────────────────────────────────────────────────────────────
// ── Audio / Call ────────────────────────────────────────────────────────
val Volume2: ImageVector get() = Lucide.Volume2
val PhoneOff: ImageVector get() = Lucide.PhoneOff
// ── Custom (brand overrides) ────────────────────────────────────────────
// Add hand-drawn SVGs here as ImageVector literals when ready.

View file

@ -180,4 +180,12 @@
<string name="settings_language_system">System</string>
<string name="settings_language_de">Deutsch</string>
<string name="settings_language_en">English</string>
<!-- Live Call -->
<string name="live_connecting">Connecting…</string>
<string name="live_listening">Listening…</string>
<string name="live_ready">Just start talking</string>
<string name="live_speaking">Responding…</string>
<string name="live_end">End</string>
<string name="live_reconnecting">Reconnecting…</string>
</resources>

View file

@ -182,4 +182,12 @@
<string name="settings_language_system">System</string>
<string name="settings_language_de">Deutsch</string>
<string name="settings_language_en">English</string>
<!-- Live Call -->
<string name="live_connecting">Verbinde…</string>
<string name="live_listening">Hört zu…</string>
<string name="live_ready">Sprich einfach los</string>
<string name="live_speaking">Antwortet…</string>
<string name="live_end">Beenden</string>
<string name="live_reconnecting">Verbindung wird wiederhergestellt…</string>
</resources>