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,
|
// set the prompt state, nothing was composed to show it,
|
||||||
// and the prompt gates placing the call — so calling could
|
// and the prompt gates placing the call — so calling could
|
||||||
// not be started at all. The button looked simply dead.
|
// 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)
|
CallBanner(model, current)
|
||||||
|
if (LiveCallScreen(model, current)) return@Column
|
||||||
|
|
||||||
val chat = current.openChat
|
val chat = current.openChat
|
||||||
if (chat != null) {
|
if (chat != null) {
|
||||||
|
|||||||
@@ -104,6 +104,37 @@ class ScreenshotTest {
|
|||||||
androidx.compose.foundation.layout.Column { NotificationsScreen(model, phase) }
|
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. */
|
/** The kit's buttons, so a change to one is visible without hunting. */
|
||||||
@Test fun buttons() = shoot("buttons") {
|
@Test fun buttons() = shoot("buttons") {
|
||||||
Column {
|
Column {
|
||||||
|
|||||||
Reference in New Issue
Block a user