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:
parent
65ea3a5656
commit
31278f2440
6 changed files with 288 additions and 2 deletions
|
|
@ -791,6 +791,7 @@ fun ChatInput(
|
||||||
isRecording: Boolean = false,
|
isRecording: Boolean = false,
|
||||||
recordingAmplitude: Float = 0f,
|
recordingAmplitude: Float = 0f,
|
||||||
onMicClick: () -> Unit = {},
|
onMicClick: () -> Unit = {},
|
||||||
|
onCallClick: () -> Unit = {},
|
||||||
pendingFiles: List<PendingFile> = emptyList(),
|
pendingFiles: List<PendingFile> = emptyList(),
|
||||||
onAddClick: () -> Unit = {},
|
onAddClick: () -> Unit = {},
|
||||||
onRemoveFile: (String) -> Unit = {},
|
onRemoveFile: (String) -> Unit = {},
|
||||||
|
|
@ -922,7 +923,7 @@ fun ChatInput(
|
||||||
} else if (hasContent) {
|
} else if (hasContent) {
|
||||||
SendButton(enabled = canSend, onClick = onSend)
|
SendButton(enabled = canSend, onClick = onSend)
|
||||||
} else {
|
} else {
|
||||||
CallButton(onClick = { /* TODO: call feature */ })
|
CallButton(onClick = onCallClick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.spring
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.core.tween
|
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.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
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)
|
// Screen enumeration for modular state-driven navigation (Zero Technical Debt)
|
||||||
enum class AppScreen { Chat, Settings }
|
enum class AppScreen { Chat, Settings }
|
||||||
|
|
@ -154,6 +160,16 @@ fun ChatScreen(
|
||||||
val audioFileRef = remember { mutableStateOf<File?>(null) }
|
val audioFileRef = remember { mutableStateOf<File?>(null) }
|
||||||
var recordingAmplitude by remember { mutableStateOf(0f) }
|
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) {
|
LaunchedEffect(isRecording) {
|
||||||
if (!isRecording) { recordingAmplitude = 0f; return@LaunchedEffect }
|
if (!isRecording) { recordingAmplitude = 0f; return@LaunchedEffect }
|
||||||
while (isRecording) {
|
while (isRecording) {
|
||||||
|
|
@ -201,6 +217,59 @@ fun ChatScreen(
|
||||||
val pendingFiles = remember { mutableStateListOf<PendingFile>() }
|
val pendingFiles = remember { mutableStateListOf<PendingFile>() }
|
||||||
val context = LocalContext.current
|
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) }
|
var showAttachMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
fun uploadPickedFile(name: String, mimeType: String, bytes: ByteArray) {
|
fun uploadPickedFile(name: String, mimeType: String, bytes: ByteArray) {
|
||||||
|
|
@ -1205,6 +1274,7 @@ fun ChatScreen(
|
||||||
isRecording = isRecording,
|
isRecording = isRecording,
|
||||||
recordingAmplitude = recordingAmplitude,
|
recordingAmplitude = recordingAmplitude,
|
||||||
onMicClick = { toggleRecording() },
|
onMicClick = { toggleRecording() },
|
||||||
|
onCallClick = { startLiveCall() },
|
||||||
pendingFiles = pendingFiles,
|
pendingFiles = pendingFiles,
|
||||||
onAddClick = { showAttachMenu = !showAttachMenu },
|
onAddClick = { showAttachMenu = !showAttachMenu },
|
||||||
onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } },
|
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() },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
181
app/src/main/java/dev/kaizen/app/live/LiveCallOverlay.kt
Normal file
181
app/src/main/java/dev/kaizen/app/live/LiveCallOverlay.kt
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -97,8 +97,9 @@ object KaizenIcons {
|
||||||
val ArrowDown: ImageVector get() = Lucide.ArrowDown
|
val ArrowDown: ImageVector get() = Lucide.ArrowDown
|
||||||
val ArrowUp: ImageVector get() = Lucide.ArrowUp
|
val ArrowUp: ImageVector get() = Lucide.ArrowUp
|
||||||
|
|
||||||
// ── Audio ──────────────────────────────────────────────────────────────
|
// ── Audio / Call ────────────────────────────────────────────────────────
|
||||||
val Volume2: ImageVector get() = Lucide.Volume2
|
val Volume2: ImageVector get() = Lucide.Volume2
|
||||||
|
val PhoneOff: ImageVector get() = Lucide.PhoneOff
|
||||||
|
|
||||||
// ── Custom (brand overrides) ────────────────────────────────────────────
|
// ── Custom (brand overrides) ────────────────────────────────────────────
|
||||||
// Add hand-drawn SVGs here as ImageVector literals when ready.
|
// Add hand-drawn SVGs here as ImageVector literals when ready.
|
||||||
|
|
|
||||||
|
|
@ -180,4 +180,12 @@
|
||||||
<string name="settings_language_system">System</string>
|
<string name="settings_language_system">System</string>
|
||||||
<string name="settings_language_de">Deutsch</string>
|
<string name="settings_language_de">Deutsch</string>
|
||||||
<string name="settings_language_en">English</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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -182,4 +182,12 @@
|
||||||
<string name="settings_language_system">System</string>
|
<string name="settings_language_system">System</string>
|
||||||
<string name="settings_language_de">Deutsch</string>
|
<string name="settings_language_de">Deutsch</string>
|
||||||
<string name="settings_language_en">English</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>
|
</resources>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue