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.SurfaceView
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll 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.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.DropdownMenu
import androidx.compose.material.icons.Icons import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material.icons.filled.Home import androidx.compose.material3.ElevatedCard
import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@@ -44,6 +56,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -54,11 +67,14 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType 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.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -91,7 +107,13 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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 { setContent {
MaterialTheme(colorScheme = darkColorScheme()) { MaterialTheme(colorScheme = darkColorScheme()) {
Surface(modifier = Modifier.fillMaxSize()) { App() } Surface(modifier = Modifier.fillMaxSize()) { App() }
@@ -208,6 +230,7 @@ private fun App() {
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -216,7 +239,6 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
var port by remember { mutableStateOf("9777") } var port by remember { mutableStateOf("9777") }
var connecting by remember { mutableStateOf(false) } var connecting by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<String?>(null) } 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. // The host streams at exactly this mode; "Native" settings resolve from the device display.
val (w, h, hz) = settings.effectiveMode(context) val (w, h, hz) = settings.effectiveMode(context)
@@ -308,92 +330,139 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
} }
} }
Column( val sheetState = rememberModalBottomSheetState()
modifier = Modifier var showManualSheet by remember { mutableStateOf(false) }
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("punktfunk", style = MaterialTheme.typography.headlineLarge)
Text(
"stream a remote desktop",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(28.dp))
if (savedHosts.isNotEmpty()) { Box(Modifier.fillMaxSize()) {
SectionLabel("Saved hosts") Column(
savedHosts.forEach { kh -> modifier = Modifier
SavedHostRow( .fillMaxSize()
kh, .verticalScroll(rememberScrollState())
enabled = !connecting, .padding(horizontal = 20.dp, vertical = 16.dp),
onConnect = { horizontalAlignment = Alignment.CenterHorizontally,
host = kh.address ) {
port = kh.port.toString() Spacer(Modifier.height(8.dp))
connect(kh.address, kh.port) Text("punktfunk", style = MaterialTheme.typography.headlineLarge)
},
onForget = {
knownHostStore.remove(kh.address, kh.port)
savedHosts = knownHostStore.all()
},
)
}
Spacer(Modifier.height(20.dp))
}
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)
}
}
Spacer(Modifier.height(20.dp))
}
SectionLabel("Connect manually")
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = port,
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
label = { Text("Port") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.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( Text(
it, "stream a remote desktop",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
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 ->
HostCard(
name = kh.name,
address = "${kh.address}:${kh.port}",
status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU,
enabled = !connecting,
onConnect = { connect(kh.address, kh.port) },
onForget = {
knownHostStore.remove(kh.address, kh.port)
savedHosts = knownHostStore.all()
},
)
}
Spacer(Modifier.height(20.dp))
}
if (discovered.isNotEmpty()) {
SectionLabel("Discovered on the network")
discovered.forEach { 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))
}
Spacer(Modifier.height(96.dp)) // clearance so the last card scrolls clear of the FAB
} }
Spacer(Modifier.height(28.dp))
Text( ExtendedFloatingActionButton(
"core ABI v$abi", onClick = { showManualSheet = true },
style = MaterialTheme.typography.labelSmall, icon = { Icon(Icons.Filled.Add, contentDescription = null) },
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 },
label = { Text("Host") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = port,
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
label = { Text("Port") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(20.dp))
Button(
enabled = !connecting && host.isNotBlank() && port.isNotBlank(),
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)") }
}
}
}
pendingTrust?.let { pt -> pendingTrust?.let { pt ->
when (pt.kind) { when (pt.kind) {
PendingTrust.Kind.TRUST_NEW -> AlertDialog( PendingTrust.Kind.TRUST_NEW -> AlertDialog(
@@ -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 @Composable
private fun DiscoveredHostRow(dh: DiscoveredHost, enabled: Boolean, onTap: () -> Unit) { private fun HostCard(
Card( name: String,
modifier = Modifier address: String,
.fillMaxWidth() status: HostStatus,
.padding(vertical = 4.dp) enabled: Boolean,
.clickable(enabled = enabled, onClick = onTap), onConnect: () -> Unit,
onForget: (() -> Unit)?,
) {
ElevatedCard(
onClick = onConnect,
enabled = enabled,
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
) { ) {
Column(Modifier.padding(12.dp)) { Row(
Text(dh.name, style = MaterialTheme.typography.bodyLarge) modifier = Modifier.fillMaxWidth().padding(start = 14.dp, top = 12.dp, bottom = 12.dp, end = 4.dp),
val trust = if (dh.pairingRequired) "PIN pairing" else "PIN or trust-on-first-use" verticalAlignment = Alignment.CenterVertically,
Text("${dh.host}:${dh.port} · $trust", style = MaterialTheme.typography.bodySmall) ) {
dh.fingerprint?.let { fp -> HostAvatar(name)
Text("fp ${fp.take(16)}", style = MaterialTheme.typography.labelSmall) 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 @Composable
private fun SavedHostRow( private fun HostAvatar(name: String) {
host: KnownHost, val letter = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
enabled: Boolean, Box(
onConnect: () -> Unit, modifier = Modifier
onForget: () -> Unit, .size(44.dp)
) { .clip(CircleShape)
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { .background(MaterialTheme.colorScheme.primaryContainer),
Row( contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().padding(start = 12.dp), ) {
verticalAlignment = Alignment.CenterVertically, Text(
) { letter,
Column( style = MaterialTheme.typography.titleMedium,
modifier = Modifier color = MaterialTheme.colorScheme.onPrimaryContainer,
.weight(1f) )
.clickable(enabled = enabled, onClick = onConnect) }
.padding(vertical = 12.dp), }
) {
Text(host.name, style = MaterialTheme.typography.bodyLarge) /** A small colored dot + label for the host's trust state. */
val trust = if (host.paired) "paired" else "trusted (TOFU)" @Composable
Text("${host.address}:${host.port} · $trust", style = MaterialTheme.typography.bodySmall) private fun StatusPill(status: HostStatus) {
Text("fp ${host.fpHex.take(16)}", style = MaterialTheme.typography.labelSmall) val color = when (status) {
} HostStatus.PAIRED -> MaterialTheme.colorScheme.primary
TextButton(enabled = enabled, onClick = onForget) { Text("Forget") } 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,
)
} }
} }