Improved animation for HandsFreeBar and renamed layout

This commit is contained in:
souravanand001 2026-06-18 01:48:13 +05:30
parent 906659ca4e
commit 04216343f1
4 changed files with 239 additions and 199 deletions

View file

@ -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 = {},

View file

@ -348,7 +348,7 @@ fun ChatScreenContent(
}
},
) { innerPadding ->
MyLayout(
ChatLayout(
modifier = Modifier.padding(innerPadding),
onShowCopyIconChange = { newValue ->
showCopyIcon = newValue

View file

@ -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
)
}
}
}

View file

@ -296,9 +296,6 @@ class MainViewModel(
if (newMuteState) {
_isListening.value = false
_isMicReady.value = false
triggerToast("Microphone muted")
} else {
triggerToast("Microphone unmuted")
}
}