feat(android): connect-screen redesign — Apple-style cards, FAB + bottom sheet, fixed status bar

Polish pass on the connect screen.

- Host cards: ElevatedCard with a colored letter-avatar (Apple-contact style), name + address, a
  colored status pill (Paired / PIN pairing / Trust on first use), and an overflow menu with Forget
  on saved hosts. Tapping a card connects. Unifies the old saved/discovered rows into one HostCard.
- Manual connect moved behind an "Add host" ExtendedFloatingActionButton that opens a
  ModalBottomSheet with the Host/Port form (the current M3 pattern) — declutters the list.
- Empty state when there are no saved/discovered hosts; single scrollable column; removed the
  "core ABI v2" footer.
- Status bar: enableEdgeToEdge driven explicitly dark (transparent bars + light icons) so the
  status/nav bars blend with our always-dark surface instead of showing a black band (the no-arg
  edge-to-edge had picked the system light/dark theme).

Verified live (emulator screenshots): cards render with avatars + status pills + Forget menu; the
FAB opens the bottom-sheet form; the status bar blends with light icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 18:26:14 +02:00
parent f4b4a6c1e4
commit 49cdafc042
@@ -11,10 +11,11 @@ import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.clickable
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
@@ -25,17 +26,28 @@ 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.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.OutlinedTextField
@@ -44,6 +56,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -54,11 +67,14 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
@@ -91,7 +107,13 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so
// the status/nav bars blend with our surface and get light icons. (The no-arg edge-to-edge
// picks the *system* light/dark, which left a black status bar over our dark background.)
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
)
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
Surface(modifier = Modifier.fillMaxSize()) { App() }
@@ -208,6 +230,7 @@ private fun App() {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
val scope = rememberCoroutineScope()
@@ -216,7 +239,6 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
var port by remember { mutableStateOf("9777") }
var connecting by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<String?>(null) }
val abi = remember { runCatching { NativeBridge.abiVersion() }.getOrDefault(-1) }
// The host streams at exactly this mode; "Native" settings resolve from the device display.
val (w, h, hz) = settings.effectiveMode(context)
@@ -308,32 +330,49 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
}
}
val sheetState = rememberModalBottomSheetState()
var showManualSheet by remember { mutableStateOf(false) }
Box(Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp),
.padding(horizontal = 20.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(8.dp))
Text("punktfunk", style = MaterialTheme.typography.headlineLarge)
Text(
"stream a remote desktop",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(28.dp))
Spacer(Modifier.height(24.dp))
status?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
}
if (savedHosts.isEmpty() && discovered.isEmpty()) {
EmptyHostsState()
}
if (savedHosts.isNotEmpty()) {
SectionLabel("Saved hosts")
savedHosts.forEach { kh ->
SavedHostRow(
kh,
HostCard(
name = kh.name,
address = "${kh.address}:${kh.port}",
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
enabled = !connecting,
onConnect = {
host = kh.address
port = kh.port.toString()
connect(kh.address, kh.port)
},
onConnect = { connect(kh.address, kh.port) },
onForget = {
knownHostStore.remove(kh.address, kh.port)
savedHosts = knownHostStore.all()
@@ -346,16 +385,51 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
if (discovered.isNotEmpty()) {
SectionLabel("Discovered on the network")
discovered.forEach { dh ->
DiscoveredHostRow(dh, enabled = !connecting) {
host = dh.host
port = dh.port.toString()
connect(dh.host, dh.port, dh)
}
HostCard(
name = dh.name,
address = "${dh.host}:${dh.port}",
status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU,
enabled = !connecting,
onConnect = { connect(dh.host, dh.port, dh) },
onForget = null,
)
}
Spacer(Modifier.height(20.dp))
}
SectionLabel("Connect manually")
Spacer(Modifier.height(96.dp)) // clearance so the last card scrolls clear of the FAB
}
ExtendedFloatingActionButton(
onClick = { showManualSheet = true },
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
text = { Text("Add host") },
expanded = !connecting,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp),
)
}
if (showManualSheet) {
ModalBottomSheet(
onDismissRequest = { showManualSheet = false },
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
) {
Text("Add a host", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(4.dp))
Text(
"Enter its address. You'll pair with the host's PIN on first connect.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(20.dp))
OutlinedTextField(
value = host,
onValueChange = { host = it },
@@ -372,26 +446,21 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(20.dp))
Button(
enabled = !connecting && host.isNotBlank() && port.isNotBlank(),
onClick = { connect(host.trim(), port.toInt()) },
modifier = Modifier.fillMaxWidth(),
) { Text(if (connecting) "Connecting…" else "Connect ($w×$h@$hz)") }
status?.let {
Spacer(Modifier.height(12.dp))
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
onClick = {
val h = host.trim()
val p = port.toIntOrNull() ?: 9777
scope.launch { sheetState.hide() }.invokeOnCompletion {
showManualSheet = false
connect(h, p)
}
},
modifier = Modifier.fillMaxWidth(),
) { Text("Connect ($w×$h@$hz)") }
}
}
Spacer(Modifier.height(28.dp))
Text(
"core ABI v$abi",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
pendingTrust?.let { pt ->
@@ -518,50 +587,127 @@ private fun SectionLabel(text: String) {
)
}
/** Trust state of a host, shown as a colored pill on its card. */
private enum class HostStatus(val label: String) {
PAIRED("Paired"),
PAIRING("PIN pairing"),
TOFU("Trust on first use"),
}
/**
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
* saved hosts) an overflow menu with Forget. Tapping the card connects.
*/
@Composable
private fun DiscoveredHostRow(dh: DiscoveredHost, enabled: Boolean, onTap: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(enabled = enabled, onClick = onTap),
private fun HostCard(
name: String,
address: String,
status: HostStatus,
enabled: Boolean,
onConnect: () -> Unit,
onForget: (() -> Unit)?,
) {
ElevatedCard(
onClick = onConnect,
enabled = enabled,
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
) {
Column(Modifier.padding(12.dp)) {
Text(dh.name, style = MaterialTheme.typography.bodyLarge)
val trust = if (dh.pairingRequired) "PIN pairing" else "PIN or trust-on-first-use"
Text("${dh.host}:${dh.port} · $trust", style = MaterialTheme.typography.bodySmall)
dh.fingerprint?.let { fp ->
Text("fp ${fp.take(16)}", style = MaterialTheme.typography.labelSmall)
Row(
modifier = Modifier.fillMaxWidth().padding(start = 14.dp, top = 12.dp, bottom = 12.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
HostAvatar(name)
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(
name,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Text(
address,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(6.dp))
StatusPill(status)
}
if (onForget != null) {
var menu by remember { mutableStateOf(false) }
Box {
IconButton(enabled = enabled, onClick = { menu = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = "More")
}
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(
text = { Text("Forget") },
onClick = {
menu = false
onForget()
},
)
}
}
} else {
Spacer(Modifier.width(8.dp))
}
}
}
}
/** A circular avatar with the host's first letter (Apple-contact style). */
@Composable
private fun SavedHostRow(
host: KnownHost,
enabled: Boolean,
onConnect: () -> Unit,
onForget: () -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
private fun HostAvatar(name: String) {
val letter = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
Box(
modifier = Modifier
.weight(1f)
.clickable(enabled = enabled, onClick = onConnect)
.padding(vertical = 12.dp),
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(host.name, style = MaterialTheme.typography.bodyLarge)
val trust = if (host.paired) "paired" else "trusted (TOFU)"
Text("${host.address}:${host.port} · $trust", style = MaterialTheme.typography.bodySmall)
Text("fp ${host.fpHex.take(16)}", style = MaterialTheme.typography.labelSmall)
Text(
letter,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
TextButton(enabled = enabled, onClick = onForget) { Text("Forget") }
}
/** A small colored dot + label for the host's trust state. */
@Composable
private fun StatusPill(status: HostStatus) {
val color = when (status) {
HostStatus.PAIRED -> MaterialTheme.colorScheme.primary
HostStatus.PAIRING -> MaterialTheme.colorScheme.tertiary
HostStatus.TOFU -> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape).background(color))
Spacer(Modifier.width(6.dp))
Text(status.label, style = MaterialTheme.typography.labelMedium, color = color)
}
}
/** Shown when there are no saved or discovered hosts. */
@Composable
private fun EmptyHostsState() {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 56.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("No hosts yet", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
"Hosts on your network show up here automatically.\nTap “Add host” to enter one by address.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}