git.iko.soy/opencode-android
on master help sign in
Fix chat crash by gating AnimatedContent on non-null send status
3a67ee1iko
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+29−23
@@ -624,28 +624,34 @@ private fun UserBubble(
624 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
624 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
625 // Crossfade the send-status slot so SENDING → FAILED → null transitions
625 // Crossfade the send-status slot so SENDING → FAILED → null transitions
626 // don't snap; a subtle fade reads as a polish detail rather than a pop.
626 // don't snap; a subtle fade reads as a polish detail rather than a pop.
627+ // Only mounted when there's an actual status to show: a null status (the
628+ // common case for any historical/confirmed message) renders nothing at all.
629+ // Passing null into AnimatedContent crashes its measure policy (an NPE in
630+ // the SubcomposeLayout's ChildData lookup when the null-state content is
631+ // empty), so gate the whole composable on sendStatus != null.
632+ sendStatus?.let { status ->
627− androidx.compose.animation.AnimatedContent(
633+ androidx.compose.animation.AnimatedContent(
628− targetState = sendStatus,
634+ targetState = status,
629− transitionSpec = {
635+ transitionSpec = {
630− androidx.compose.animation.fadeIn() togetherWith
636+ androidx.compose.animation.fadeIn() togetherWith
631− androidx.compose.animation.fadeOut()
637+ androidx.compose.animation.fadeOut()
632− },
638+ },
633− label = "send_status",
639+ label = "send_status",
634− ) { status ->
640+ ) { current ->
635− when (status) {
641+ when (current) {
636− MessageSendStatus.SENDING -> {
642+ MessageSendStatus.SENDING -> {
637− CircularProgressIndicator(
643+ CircularProgressIndicator(
638− Modifier.size(12.dp),
644+ Modifier.size(12.dp),
639− strokeWidth = 1.5.dp,
645+ strokeWidth = 1.5.dp,
640− color = MaterialTheme.colorScheme.onSurfaceVariant,
646+ color = MaterialTheme.colorScheme.onSurfaceVariant,
641− )
647+ )
642− Text(
648+ Text(
643− stringResource(R.string.message_sending),
649+ stringResource(R.string.message_sending),
644− style = MaterialTheme.typography.labelSmall,
650+ style = MaterialTheme.typography.labelSmall,
645− color = MaterialTheme.colorScheme.onSurfaceVariant,
651+ color = MaterialTheme.colorScheme.onSurfaceVariant,
646− )
652+ )
647− }
653+ }
648− MessageSendStatus.FAILED -> {
654+ MessageSendStatus.FAILED -> {
649 // Tap-to-retry: the whole failed label is tappable to re-send (far more
655 // Tap-to-retry: the whole failed label is tappable to re-send (far more
650 // discoverable than relying on the transient snackbar). A dismiss (×) button
656 // discoverable than relying on the transient snackbar). A dismiss (×) button
651 // removes the abandoned message entirely, since its text will never match a
657 // removes the abandoned message entirely, since its text will never match a
@@ -704,7 +710,7 @@ private fun UserBubble(
704 }
710 }
705 }
711 }
706 }
712 }
707− null -> {}
713+ }
708 }
714 }
709 }
715 }
710 if (isEdited && sendStatus == null) {
716 if (isEdited && sendStatus == null) {