diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt new file mode 100644 index 0000000..fb3487a --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt @@ -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, + /** 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 +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index 1462b0e..8d06165 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -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) { diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt index bb46930..e4bf629 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ScreenshotTest.kt @@ -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 {