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,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue