Android gets a real call screen; iOS gets its tab bar out of the way

🔴 **Android had no call screen — it had a banner.** A line of text and
two TextButtons stacked above whatever you happened to be on, for the
single most attention-demanding thing this app does. iOS has had a full
screen throughout and the kit draws one. Same split as iOS: the look
takes plain values so the screenshot harness can photograph it, because
a call screen nobody can look at is a call screen nobody has checked.

⚠️ The privacy prompt stays a banner deliberately. It is a question
asked BEFORE a call exists, and a full screen for it would look like a
call already happening.

🔴 **And a bug I introduced: the floating tab pill was sitting on the
message box.** Reported from the iOS app. The pill overlaps content by
design — that is what makes it float rather than reserve a strip — and
the composer is exactly where it lands, so the field could not be
tapped. `TabView` hid its own bar on a push and the replacement had
nothing to do that with. Both conversation screens now declare
`hidesPillTabBar()`, which is what the kit's own Messages-Details
artboard shows: a composer at the bottom and no tab bar at all.

⚠️ The iOS fix is written but NOT yet rendered — the machine is pinned
copying Xcode out of iCloud Drive and I am not running builds into
that. It compiles; it has not been looked at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-27 15:45:12 -04:00
co-authored by Claude Opus 5
parent 86550f3f61
commit b39c992115
3 changed files with 267 additions and 0 deletions
@@ -0,0 +1,228 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.clickable
/**
* A call, as a screen.
*
* 🔴 **Android had no call screen at all — it had a banner.** A line of text
* and two `TextButton`s stacked above whatever screen you happened to be on,
* so the most attention-demanding thing the app ever does looked like a
* notice. iOS has had a full screen throughout; the kit draws one; this is the
* parity gap that mattered most, and the one a person is most likely to be
* fumbling with under time pressure.
*
* 🔴 **Plain values, no `CallManager`.** Same split iOS uses: presentation that
* takes a name and a status can be put in front of the screenshot harness; a
* state machine cannot, and a call screen nobody can look at is a call screen
* nobody has checked.
*
* ⚠️ The kit animates a 400pt ring. A static ring pair stands in and says so —
* an animation that never resolves is worse than none.
*/
@Composable
fun CallScreenBody(
peerName: String,
status: String,
actions: List<CallAction>,
/** Shown under the status when this relay has no TURN server. */
warning: String? = null,
) {
val initials = peerName.trim().split(" ").filter { it.isNotBlank() }
.take(2).mapNotNull { it.firstOrNull() }.joinToString("").uppercase().ifEmpty { "?" }
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = Kit.margin)
.testTag("call-screen"),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(Space.xxl))
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(260.dp)
.border(24.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.10f), CircleShape)
)
Box(
modifier = Modifier
.size(190.dp)
.border(16.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.18f), CircleShape)
)
Box(
modifier = Modifier.size(128.dp).clip(CircleShape).background(Palette.AvatarFill),
contentAlignment = Alignment.Center,
) {
Text(
initials,
fontSize = 40.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
}
Spacer(Modifier.height(Space.xl))
Text(peerName, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Text(status, fontSize = 14.sp, color = Palette.SecondaryText)
// Said plainly and always: it is the reason to use this at all.
Text(
"End-to-end encrypted",
fontSize = 10.sp,
color = Palette.SecondaryText,
modifier = Modifier.padding(top = 2.dp),
)
warning?.let {
Spacer(Modifier.height(Space.md))
Text(
it,
fontSize = 12.sp,
color = Palette.SecondaryText,
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.weight(1f))
// The kit puts the secondaries on an arc around a 72dp primary. A row
// above it reproduces the reading order without pretending to
// trigonometry the kit did by hand.
val primary = actions.firstOrNull { it.isPrimary }
val others = actions.filterNot { it.isPrimary }
if (others.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(Space.xl)) {
others.forEach { CallButton(it, diameter = 48.dp) }
}
Spacer(Modifier.height(Space.lg))
}
primary?.let { CallButton(it, diameter = 72.dp) }
Spacer(Modifier.height(Space.xxl))
}
}
/** One control on the call screen. */
data class CallAction(
val id: String,
val symbol: String,
val label: String,
val isPrimary: Boolean = false,
val tint: Color? = null,
val iconColor: Color? = null,
val run: () -> Unit,
)
@Composable
private fun CallButton(action: CallAction, diameter: androidx.compose.ui.unit.Dp) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(diameter)
.clip(CircleShape)
.background(action.tint ?: MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = action.run)
.semantics { contentDescription = action.label }
.testTag("call-${action.id}"),
contentAlignment = Alignment.Center,
) {
Text(
action.symbol,
fontSize = (diameter.value * 0.36f).sp,
color = action.iconColor ?: MaterialTheme.colorScheme.onSurface,
)
}
Spacer(Modifier.height(6.dp))
Text(action.label, fontSize = 10.sp, color = Palette.SecondaryText)
}
}
/**
* Map the live call onto the screen above.
*
* ⚠️ Returns null when there is no call, so the caller can fall through to the
* ordinary tabs. The privacy prompt stays a banner deliberately — it is a
* question asked BEFORE a call exists, and a full screen for it would look
* like a call that is already happening.
*/
@Composable
fun LiveCallScreen(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedIn): Boolean {
val call = phase.call ?: return false
if (call.state == CallManager.State.IDLE) return false
val status = when (call.state) {
CallManager.State.RINGING ->
if (call.incoming) "Incoming call" else "Ringing…"
CallManager.State.DIALLING -> "Calling…"
CallManager.State.CONNECTING -> "Connecting…"
CallManager.State.ACTIVE -> "On a call"
else -> "Call ended"
}
val actions = when {
call.state == CallManager.State.RINGING && call.incoming -> listOf(
CallAction("decline", "✕", "Decline",
tint = MaterialTheme.colorScheme.error, iconColor = Color.White,
run = model::declineCall),
CallAction("accept", "☎", "Answer", isPrimary = true,
tint = Palette.Teal, iconColor = Color.White,
run = model::acceptCall),
)
call.state == CallManager.State.ACTIVE -> listOf(
CallAction("mute", if (call.muted) "🔇" else "🎤",
if (call.muted) "Unmute" else "Mute",
tint = if (call.muted) MaterialTheme.colorScheme.primary else null,
iconColor = if (call.muted) Color.White else null,
run = { model.setCallMuted(!call.muted) }),
CallAction("end", "✕", "End", isPrimary = true,
tint = MaterialTheme.colorScheme.error, iconColor = Color.White,
run = model::hangUp),
)
else -> listOf(
CallAction("end", "✕", "Cancel", isPrimary = true,
tint = MaterialTheme.colorScheme.error, iconColor = Color.White,
run = model::hangUp),
)
}
CallScreenBody(
peerName = call.peerName,
status = status,
actions = actions,
warning = if (call.relayless && call.state != CallManager.State.ACTIVE) {
"This server has no call relay, so the call only connects if a " +
"direct path exists between the two networks."
} else null,
)
return true
}
@@ -133,7 +133,15 @@ private fun Root(model: MessengerViewModel = viewModel()) {
// set the prompt state, nothing was composed to show it,
// and the prompt gates placing the call — so calling could
// not be started at all. The button looked simply dead.
// 🔴 **A live call takes the screen.** It used to be a
// banner above whatever you were on — a line of text and
// two text buttons for the single most attention-demanding
// thing this app does. The privacy prompt stays a banner
// deliberately: it is a question asked BEFORE a call
// exists, and a full screen for it would look like a call
// already happening.
CallBanner(model, current)
if (LiveCallScreen(model, current)) return@Column
val chat = current.openChat
if (chat != null) {