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:
co-authored by
Claude Opus 5
parent
86550f3f61
commit
b39c992115
@@ -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) {
|
||||
|
||||
@@ -104,6 +104,37 @@ class ScreenshotTest {
|
||||
androidx.compose.foundation.layout.Column { NotificationsScreen(model, phase) }
|
||||
}
|
||||
|
||||
/** A call in progress — renderable because the look takes plain values. */
|
||||
@Test fun callActive() = shoot("screen-call-active") {
|
||||
CallScreenBody(
|
||||
peerName = "Hoang Nguyen",
|
||||
status = "On a call",
|
||||
actions = listOf(
|
||||
CallAction("mute", "🎤", "Mute") {},
|
||||
CallAction("end", "✕", "End", isPrimary = true,
|
||||
tint = androidx.compose.material3.MaterialTheme.colorScheme.error,
|
||||
iconColor = androidx.compose.ui.graphics.Color.White) {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun callRinging() = shoot("screen-call-ringing") {
|
||||
CallScreenBody(
|
||||
peerName = "Hoang Nguyen",
|
||||
status = "Incoming call",
|
||||
actions = listOf(
|
||||
CallAction("decline", "✕", "Decline",
|
||||
tint = androidx.compose.material3.MaterialTheme.colorScheme.error,
|
||||
iconColor = androidx.compose.ui.graphics.Color.White) {},
|
||||
CallAction("accept", "☎", "Answer", isPrimary = true,
|
||||
tint = Palette.Teal,
|
||||
iconColor = androidx.compose.ui.graphics.Color.White) {},
|
||||
),
|
||||
warning = "This server has no call relay, so the call only connects "
|
||||
+ "if a direct path exists between the two networks.",
|
||||
)
|
||||
}
|
||||
|
||||
/** The kit's buttons, so a change to one is visible without hunting. */
|
||||
@Test fun buttons() = shoot("buttons") {
|
||||
Column {
|
||||
|
||||
Reference in New Issue
Block a user