From 31278f244029eebd2e2a1531ba64173c1520ec0c Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Wed, 24 Jun 2026 03:14:08 +0200 Subject: [PATCH] Add Live Call UI overlay + wiring in ChatScreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../dev/kaizen/app/chat/ChatComponents.kt | 3 +- .../java/dev/kaizen/app/chat/ChatScreen.kt | 87 +++++++++ .../dev/kaizen/app/live/LiveCallOverlay.kt | 181 ++++++++++++++++++ .../dev/kaizen/app/ui/icon/KaizenIcons.kt | 3 +- app/src/main/res/values-en/strings.xml | 8 + app/src/main/res/values/strings.xml | 8 + 6 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/dev/kaizen/app/live/LiveCallOverlay.kt diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt index 969ffe2..c4d87f3 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt @@ -791,6 +791,7 @@ fun ChatInput( isRecording: Boolean = false, recordingAmplitude: Float = 0f, onMicClick: () -> Unit = {}, + onCallClick: () -> Unit = {}, pendingFiles: List = 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) } } } diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index 4140867..95d8d38 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -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(null) } var recordingAmplitude by remember { mutableStateOf(0f) } + // ─── Live Call state (uses context/ttsVoice/currentConversationId declared below) ─── + var liveService by remember { mutableStateOf(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() } 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() }, + ) + } } } } diff --git a/app/src/main/java/dev/kaizen/app/live/LiveCallOverlay.kt b/app/src/main/java/dev/kaizen/app/live/LiveCallOverlay.kt new file mode 100644 index 0000000..d1370cb --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/live/LiveCallOverlay.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/dev/kaizen/app/ui/icon/KaizenIcons.kt b/app/src/main/java/dev/kaizen/app/ui/icon/KaizenIcons.kt index 945facf..1a03c1b 100644 --- a/app/src/main/java/dev/kaizen/app/ui/icon/KaizenIcons.kt +++ b/app/src/main/java/dev/kaizen/app/ui/icon/KaizenIcons.kt @@ -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. diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 0591bc1..4ead4bd 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -180,4 +180,12 @@ System Deutsch English + + + Connecting… + Listening… + Just start talking + Responding… + End + Reconnecting… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ca1989..43fecf6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -182,4 +182,12 @@ System Deutsch English + + + Verbinde… + Hört zu… + Sprich einfach los + Antwortet… + Beenden + Verbindung wird wiederhergestellt…