@@ -46,7 +46,6 @@ import androidx.compose.runtime.key
46import androidx.compose.runtime.mutableStateOf
46import androidx.compose.runtime.mutableStateOf
47import androidx.compose.runtime.remember
47import androidx.compose.runtime.remember
48import androidx.compose.runtime.setValue
48import androidx.compose.runtime.setValue
49−import androidx.compose.animation.togetherWith
50import androidx.compose.ui.Alignment
49import androidx.compose.ui.Alignment
51import androidx.compose.ui.Modifier
50import androidx.compose.ui.Modifier
52import androidx.compose.ui.draw.clip
51import androidx.compose.ui.draw.clip
@@ -640,20 +639,31 @@ private fun UserBubble(
640 verticalAlignment = Alignment.CenterVertically,
639 verticalAlignment = Alignment.CenterVertically,
642 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
641 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
643− // Crossfade the send-status slot so SENDING → FAILED → null transitions
642+ // Crossfade the send-status slot so SENDING → FAILED transitions don't
644− // don't snap; a subtle fade reads as a polish detail rather than a pop.
643+ // snap; a subtle fade reads as a polish detail rather than a pop. Only
645− // Only mounted when there's an actual status to show: a null status (the
644+ // mounted when there's an actual status to show: a null status (the
646 // common case for any historical/confirmed message) renders nothing at all.
645 // common case for any historical/confirmed message) renders nothing at all.
647− // Passing null into AnimatedContent crashes its measure policy (an NPE in
646+ //
648− // the SubcomposeLayout's ChildData lookup when the null-state content is
647+ // Crossfade is used instead of AnimatedContent deliberately. AnimatedContent
649− // empty), so gate the whole composable on sendStatus != null.
648+ // animates size transitions via SizeAnimationModifierNode, whose measure
649+ // policy reads AnimatedContentTransitionScopeImpl$ChildData.isTarget — a
650+ // MutableState<Boolean> that can be null at the moment the whole
651+ // AnimatedContent subtree is removed from composition mid-transition (which
652+ // is exactly what happens here when sendStatus flips SENDING/FAILED → null
653+ // on confirmation/dismiss). That null read throws an NPE inside
654+ // SizeAnimationModifierNode.measure during the next draw-phase display-list
655+ // rebuild, crashing the app when opening a chat or sending a message. The
656+ // prior fix (gating AnimatedContent on sendStatus != null) only avoided
657+ // passing null as targetState; it did not avoid the removal-mid-animation
658+ // tear. Crossfade has no SizeModifier/ChildData path, so removing it
659+ // mid-fade (when sendStatus → null) is safe — the fade simply ends early,
660+ // which is the intended visual (the indicator vanishes on confirmation).
650 sendStatus?.let { status ->
661 sendStatus?.let { status ->
651− androidx.compose.animation.AnimatedContent(
662+ androidx.compose.animation.Crossfade(
652 targetState = status,
663 targetState = status,
653− transitionSpec = {
664+ animationSpec = androidx.compose.animation.core.tween(
654− androidx.compose.animation.fadeIn() togetherWith
665+ NetworkConfig.motionFadeDurationMs,
655− androidx.compose.animation.fadeOut()
657 label = "send_status",
667 label = "send_status",
658 ) { current ->
668 ) { current ->
659 when (current) {
669 when (current) {