From 04216343f17d722a540dbbfdffbbab0f98d3a377 Mon Sep 17 00:00:00 2001 From: souravanand001 Date: Thu, 18 Jun 2026 01:48:13 +0530 Subject: [PATCH] Improved animation for HandsFreeBar and renamed layout --- .../ui/screen/{MyLayout.kt => ChatLayout.kt} | 6 +- .../com/app/assistant/ui/screen/ChatScreen.kt | 2 +- .../app/assistant/ui/screen/HandsFreeBar.kt | 427 ++++++++++-------- .../app/assistant/viewmodel/MainViewModel.kt | 3 - 4 files changed, 239 insertions(+), 199 deletions(-) rename app/src/main/java/com/app/assistant/ui/screen/{MyLayout.kt => ChatLayout.kt} (99%) diff --git a/app/src/main/java/com/app/assistant/ui/screen/MyLayout.kt b/app/src/main/java/com/app/assistant/ui/screen/ChatLayout.kt similarity index 99% rename from app/src/main/java/com/app/assistant/ui/screen/MyLayout.kt rename to app/src/main/java/com/app/assistant/ui/screen/ChatLayout.kt index 66b7dd6..f09f426 100644 --- a/app/src/main/java/com/app/assistant/ui/screen/MyLayout.kt +++ b/app/src/main/java/com/app/assistant/ui/screen/ChatLayout.kt @@ -53,7 +53,7 @@ import androidx.compose.animation.core.tween @Composable @Suppress("ktlint:standard:function-naming") -fun MyLayout( +fun ChatLayout( modifier: Modifier = Modifier, onShowCopyIconChange: (Boolean) -> Unit, selectedItemIndex: Int?, @@ -286,9 +286,9 @@ fun MyLayout( @Preview(showBackground = true) @Composable -fun MyLayoutPreview() { +fun ChatLayoutPreview() { AssistantTheme { - MyLayout( + ChatLayout( onShowCopyIconChange = {}, selectedItemIndex = null, onSelectedItemChange = {}, diff --git a/app/src/main/java/com/app/assistant/ui/screen/ChatScreen.kt b/app/src/main/java/com/app/assistant/ui/screen/ChatScreen.kt index 6b14b1c..0e5f8dd 100644 --- a/app/src/main/java/com/app/assistant/ui/screen/ChatScreen.kt +++ b/app/src/main/java/com/app/assistant/ui/screen/ChatScreen.kt @@ -348,7 +348,7 @@ fun ChatScreenContent( } }, ) { innerPadding -> - MyLayout( + ChatLayout( modifier = Modifier.padding(innerPadding), onShowCopyIconChange = { newValue -> showCopyIcon = newValue diff --git a/app/src/main/java/com/app/assistant/ui/screen/HandsFreeBar.kt b/app/src/main/java/com/app/assistant/ui/screen/HandsFreeBar.kt index f52cddb..b3f5e7f 100644 --- a/app/src/main/java/com/app/assistant/ui/screen/HandsFreeBar.kt +++ b/app/src/main/java/com/app/assistant/ui/screen/HandsFreeBar.kt @@ -3,6 +3,7 @@ package com.app.assistant.ui.screen import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.FastOutSlowInEasing @@ -33,10 +34,15 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp @@ -72,28 +78,41 @@ fun HandsFreeBar( } } - val orbBackgroundColor by animateColorAsState( - targetValue = when (currentState) { - HandsFreeState.STARTING -> MaterialTheme.colorScheme.surfaceVariant - HandsFreeState.LISTENING -> MaterialTheme.colorScheme.primaryContainer - HandsFreeState.THINKING -> MaterialTheme.colorScheme.tertiaryContainer - HandsFreeState.SPEAKING -> MaterialTheme.colorScheme.secondaryContainer - }, + // Dynamic state transition parameters + val startingAlpha by animateFloatAsState( + targetValue = if (currentState == HandsFreeState.STARTING) 1f else 0f, animationSpec = tween(durationMillis = 350), - label = "orbBackground" + label = "startingAlpha" ) - val orbContentColor by animateColorAsState( - targetValue = when (currentState) { - HandsFreeState.STARTING -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - HandsFreeState.LISTENING -> MaterialTheme.colorScheme.primary - HandsFreeState.THINKING -> MaterialTheme.colorScheme.tertiary - HandsFreeState.SPEAKING -> MaterialTheme.colorScheme.secondary - }, + val listeningProgress by animateFloatAsState( + targetValue = if (currentState == HandsFreeState.LISTENING) 1f else 0f, animationSpec = tween(durationMillis = 350), - label = "orbContent" + label = "listeningProgress" ) + val thinkingProgress by animateFloatAsState( + targetValue = if (currentState == HandsFreeState.THINKING) 1f else 0f, + animationSpec = tween(durationMillis = 350), + label = "thinkingProgress" + ) + + val speakingProgress by animateFloatAsState( + targetValue = if (currentState == HandsFreeState.SPEAKING) 1f else 0f, + animationSpec = tween(durationMillis = 350), + label = "speakingProgress" + ) + + val dotsSpreadProgress by animateFloatAsState( + targetValue = if (currentState == HandsFreeState.THINKING || currentState == HandsFreeState.SPEAKING) 1f else 0f, + animationSpec = tween(durationMillis = 350), + label = "dotsSpreadProgress" + ) + + // Keep the orb background color and content color consistent across all states + val orbBackgroundColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + val orbContentColor = MaterialTheme.colorScheme.primary + val orbWidth by animateDpAsState( targetValue = when (currentState) { HandsFreeState.STARTING -> 48.dp @@ -134,12 +153,81 @@ fun HandsFreeBar( initialValue = 0f, targetValue = (2 * Math.PI).toFloat(), animationSpec = infiniteRepeatable( - animation = tween(1200, easing = LinearEasing), + animation = tween(2200, easing = LinearEasing), repeatMode = RepeatMode.Restart ), label = "wavePhase" ) + // Starting state animations + val startingRotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(1500, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "startingRotation" + ) + + val coreScale by infiniteTransition.animateFloat( + initialValue = 0.7f, + targetValue = 1.0f, + animationSpec = infiniteRepeatable( + animation = tween(800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "startingCore" + ) + + // Speaking state voice equalizer animations + val speakTransition = rememberInfiniteTransition(label = "speakingEqualizer") + val s1 by speakTransition.animateFloat( + initialValue = 0.15f, + targetValue = 0.9f, + animationSpec = infiniteRepeatable( + animation = tween(350, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "speakBar1" + ) + val s2 by speakTransition.animateFloat( + initialValue = 0.25f, + targetValue = 0.75f, + animationSpec = infiniteRepeatable( + animation = tween(280, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "speakBar2" + ) + val s3 by speakTransition.animateFloat( + initialValue = 0.1f, + targetValue = 1.0f, + animationSpec = infiniteRepeatable( + animation = tween(420, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "speakBar3" + ) + val s4 by speakTransition.animateFloat( + initialValue = 0.2f, + targetValue = 0.8f, + animationSpec = infiniteRepeatable( + animation = tween(310, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "speakBar4" + ) + val s5 by speakTransition.animateFloat( + initialValue = 0.15f, + targetValue = 0.6f, + animationSpec = infiniteRepeatable( + animation = tween(460, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "speakBar5" + ) + Row( modifier = modifier .fillMaxWidth() @@ -182,15 +270,15 @@ fun HandsFreeBar( modifier = Modifier.weight(1f), contentAlignment = Alignment.Center ) { - if (currentState == HandsFreeState.THINKING) { - // Outer pulsing ring (drawn behind using graphicsLayer scale so it does not affect parent height) + // Outer pulsing ring (drawn behind using graphicsLayer scale so it does not affect parent height) + if (thinkingProgress > 0f) { Box( modifier = Modifier .size(orbWidth, orbHeight) .graphicsLayer { scaleX = thinkingPulseScale scaleY = thinkingPulseScale - alpha = thinkingPulseAlpha + alpha = thinkingPulseAlpha * thinkingProgress } .background( color = orbBackgroundColor, @@ -205,193 +293,148 @@ fun HandsFreeBar( .background(color = orbBackgroundColor, shape = RoundedCornerShape(24.dp)), contentAlignment = Alignment.Center ) { - // Creative animations depending on state - when (currentState) { - HandsFreeState.STARTING -> { - // Circular rotating arc segment with a pulsing inner core dot - val rotation by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween(1500, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "startingRotation" - ) - - val coreScale by infiniteTransition.animateFloat( - initialValue = 0.7f, - targetValue = 1.0f, - animationSpec = infiniteRepeatable( - animation = tween(800, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "startingCore" - ) + val colorPrimary = MaterialTheme.colorScheme.primary + val colorSecondary = MaterialTheme.colorScheme.secondary + val colorTertiary = MaterialTheme.colorScheme.tertiary - Box( - modifier = Modifier.size(32.dp), - contentAlignment = Alignment.Center - ) { - // Outer spinning arc segment - Canvas( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { rotationZ = rotation } - ) { - drawArc( - color = orbContentColor, - startAngle = 0f, - sweepAngle = 270f, - useCenter = false, - style = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round) - ) - } - // Inner breathing core - Box( - modifier = Modifier - .size(12.dp) - .graphicsLayer { - scaleX = coreScale - scaleY = coreScale - } - .background(orbContentColor, CircleShape) - ) - } + Canvas(modifier = Modifier.fillMaxSize()) { + val width = size.width + val height = size.height + val centerX = width / 2 + val centerY = height / 2 + + // Helper to lerp floats + fun lerp(start: Float, stop: Float, fraction: Float): Float { + return start + fraction * (stop - start) } - HandsFreeState.LISTENING -> { - // Out of the box: Flowing double sine wave - Canvas(modifier = Modifier.size(width = 52.dp, height = 24.dp)) { - val width = size.width - val height = size.height - val centerY = height / 2 - val amplitude = height / 2 - 2.dp.toPx() - // Draw primary wave - val wavePath = Path() - wavePath.moveTo(0f, centerY) - for (x in 0..width.toInt()) { - val frequency = (2 * Math.PI.toFloat() / width) * 1.5f - val y = kotlin.math.sin(x * frequency + wavePhase) * amplitude + centerY - wavePath.lineTo(x.toFloat(), y) - } - drawPath( - path = wavePath, + // A. STARTING outer ring rotating arc segment + if (startingAlpha > 0f) { + val arcSize = 32.dp.toPx() + rotate(degrees = startingRotation, pivot = Offset(centerX, centerY)) { + drawArc( color = orbContentColor, - style = Stroke(width = 2.5.dp.toPx(), cap = StrokeCap.Round) - ) - - // Draw secondary wave - val wavePath2 = Path() - wavePath2.moveTo(0f, centerY) - for (x in 0..width.toInt()) { - val frequency = (2 * Math.PI.toFloat() / width) * 1.2f - val y = kotlin.math.sin(x * frequency - wavePhase + 1.2f) * (amplitude * 0.6f) + centerY - wavePath2.lineTo(x.toFloat(), y) - } - drawPath( - path = wavePath2, - color = orbContentColor.copy(alpha = 0.5f), - style = Stroke(width = 1.5.dp.toPx(), cap = StrokeCap.Round) + startAngle = 0f, + sweepAngle = 270f, + useCenter = false, + topLeft = Offset(centerX - arcSize / 2, centerY - arcSize / 2), + size = Size(arcSize, arcSize), + style = Stroke(width = 3.dp.toPx(), cap = StrokeCap.Round), + alpha = startingAlpha ) } } - HandsFreeState.THINKING -> { - Canvas(modifier = Modifier.fillMaxSize()) { - val width = size.width - val height = size.height - val centerX = width / 2 - val centerY = height / 2 - val baseRadius = 2.5.dp.toPx() - val spacing = 7.5.dp.toPx() - val horizontalAmplitude = 5.dp.toPx() - val verticalAmplitude = 1.5.dp.toPx() + // B. STARTING core dot (solid color) + if (startingAlpha > 0f) { + val startDotRadius = 6.dp.toPx() * coreScale + drawCircle( + color = orbContentColor, + radius = startDotRadius, + center = Offset(centerX, centerY), + alpha = startingAlpha + ) + } - for (i in 0 until 5) { - // Staggered phase for each dot - val dotPhase = wavePhase - i * 0.6f + // C. LISTENING state morphing blob (gradient with white outline) + if (listeningProgress > 0f) { + // 1. Soft glowing background aura inside the capsule + val auraRadius = (height / 2) * 0.85f + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + colorTertiary.copy(alpha = 0.3f * listeningProgress), + colorPrimary.copy(alpha = 0.1f * listeningProgress), + Color.Transparent + ), + center = Offset(centerX, centerY), + radius = auraRadius + ), + radius = auraRadius, + center = Offset(centerX, centerY) + ) - // 3D-like orbital offsets - val dx = horizontalAmplitude * kotlin.math.cos(dotPhase) - val dy = verticalAmplitude * kotlin.math.sin(dotPhase) + // 2. Morphing Blob Path calculation + val baseRadius = lerp(6.dp.toPx(), height * 0.32f, listeningProgress) + val amp1 = lerp(0f, 2.dp.toPx(), listeningProgress) + val amp2 = lerp(0f, 1.5.dp.toPx(), listeningProgress) - val homeX = centerX + (i - 2) * spacing - val x = homeX + dx - val y = centerY + dy - - // Scale and alpha oscillate to create depth/3D effect - val scale = 0.8f + 0.4f * ((kotlin.math.sin(dotPhase) + 1f) / 2f) - val alpha = 0.4f + 0.6f * ((kotlin.math.sin(dotPhase) + 1f) / 2f) - - drawCircle( - color = orbContentColor, - radius = baseRadius * scale, - center = Offset(x, y), - alpha = alpha - ) + val blobPath = Path() + val steps = 72 + for (i in 0..steps) { + val angle = (i * 2 * Math.PI / steps).toFloat() + val r = baseRadius + + amp1 * kotlin.math.sin(2 * angle + wavePhase) + + amp2 * kotlin.math.cos(3 * angle - wavePhase) + val x = centerX + r * kotlin.math.cos(angle) + val y = centerY + r * kotlin.math.sin(angle) + if (i == 0) { + blobPath.moveTo(x, y) + } else { + blobPath.lineTo(x, y) } } - } - HandsFreeState.SPEAKING -> { - // Out of the box: Active voice equalizer with varied baseline heights - val speakTransition = rememberInfiniteTransition(label = "speakingEqualizer") - val s1 by speakTransition.animateFloat( - initialValue = 0.15f, - targetValue = 0.9f, - animationSpec = infiniteRepeatable( - animation = tween(350, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "speakBar1" - ) - val s2 by speakTransition.animateFloat( - initialValue = 0.25f, - targetValue = 0.75f, - animationSpec = infiniteRepeatable( - animation = tween(280, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "speakBar2" - ) - val s3 by speakTransition.animateFloat( - initialValue = 0.1f, - targetValue = 1.0f, - animationSpec = infiniteRepeatable( - animation = tween(420, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "speakBar3" - ) - val s4 by speakTransition.animateFloat( - initialValue = 0.2f, - targetValue = 0.8f, - animationSpec = infiniteRepeatable( - animation = tween(310, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "speakBar4" - ) - val s5 by speakTransition.animateFloat( - initialValue = 0.15f, - targetValue = 0.6f, - animationSpec = infiniteRepeatable( - animation = tween(460, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "speakBar5" + blobPath.close() + + val blobGradient = Brush.linearGradient( + colors = listOf(colorPrimary, colorTertiary, colorSecondary), + start = Offset(centerX - baseRadius, centerY - baseRadius), + end = Offset(centerX + baseRadius, centerY + baseRadius) ) - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 8.dp) - ) { - Box(Modifier.width(3.dp).height(8.dp + 16.dp * s1).background(orbContentColor, CircleShape)) - Box(Modifier.width(3.dp).height(12.dp + 14.dp * s2).background(orbContentColor, CircleShape)) - Box(Modifier.width(3.dp).height(6.dp + 22.dp * s3).background(orbContentColor, CircleShape)) - Box(Modifier.width(3.dp).height(10.dp + 18.dp * s4).background(orbContentColor, CircleShape)) - Box(Modifier.width(3.dp).height(8.dp + 12.dp * s5).background(orbContentColor, CircleShape)) + // Fill the blob + drawPath(path = blobPath, brush = blobGradient, alpha = listeningProgress) + + // Thin white outline + drawPath( + path = blobPath, + color = Color.White.copy(alpha = 0.4f * listeningProgress), + style = Stroke(width = 1.dp.toPx()) + ) + } + + // D. THINKING and SPEAKING states: 5 orbiting dots / equalizer bars + if (dotsSpreadProgress > 0f) { + val spacing = 8.dp.toPx() + val dotWidth = 5.dp.toPx() + val dotHeight = 5.dp.toPx() + + val horizontalAmplitude = 5.dp.toPx() + val verticalAmplitude = 1.5.dp.toPx() + + for (i in 0 until 5) { + // Equalizer height for this bar + val eqHeight = when (i) { + 0 -> 8.dp.toPx() + 16.dp.toPx() * s1 + 1 -> 12.dp.toPx() + 14.dp.toPx() * s2 + 2 -> 6.dp.toPx() + 22.dp.toPx() * s3 + 3 -> 10.dp.toPx() + 18.dp.toPx() * s4 + else -> 8.dp.toPx() + 12.dp.toPx() * s5 + } + + // Interpolate height between circle (dotHeight) and equalizer bar height + val currentHeight = lerp(dotHeight, eqHeight, speakingProgress) + + // 3D-like orbital offsets (only active in THINKING) + val dotPhase = wavePhase - i * 0.6f + val dx = horizontalAmplitude * kotlin.math.cos(dotPhase.toDouble()).toFloat() * thinkingProgress + val dy = verticalAmplitude * kotlin.math.sin(dotPhase.toDouble()).toFloat() * thinkingProgress + + // Interpolate home horizontal position based on spread progress + val homeX = centerX + (i - 2) * spacing * dotsSpreadProgress + val x = homeX + dx + val y = centerY + dy + + val topLeftX = x - dotWidth / 2 + val topLeftY = y - currentHeight / 2 + + drawRoundRect( + color = orbContentColor, + topLeft = Offset(topLeftX, topLeftY), + size = Size(dotWidth, currentHeight), + cornerRadius = CornerRadius(dotWidth / 2, dotWidth / 2), + alpha = dotsSpreadProgress + ) } } } diff --git a/app/src/main/java/com/app/assistant/viewmodel/MainViewModel.kt b/app/src/main/java/com/app/assistant/viewmodel/MainViewModel.kt index 3f5c914..6eb9865 100644 --- a/app/src/main/java/com/app/assistant/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/app/assistant/viewmodel/MainViewModel.kt @@ -296,9 +296,6 @@ class MainViewModel( if (newMuteState) { _isListening.value = false _isMicReady.value = false - triggerToast("Microphone muted") - } else { - triggerToast("Microphone unmuted") } }