From 49cdafc042592ef8b23a2964b9bbafaf2d7522eb Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 15 Jun 2026 18:26:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(android):=20connect-screen=20redesign=20?= =?UTF-8?q?=E2=80=94=20Apple-style=20cards,=20FAB=20+=20bottom=20sheet,=20?= =?UTF-8?q?fixed=20status=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../kotlin/io/unom/punktfunk/MainActivity.kt | 390 ++++++++++++------ 1 file changed, 268 insertions(+), 122 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt index 5f18530..d1013d0 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt @@ -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(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,92 +330,139 @@ private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { } } - Column( - modifier = Modifier - .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)) + val sheetState = rememberModalBottomSheetState() + var showManualSheet by remember { mutableStateOf(false) } - if (savedHosts.isNotEmpty()) { - SectionLabel("Saved hosts") - savedHosts.forEach { kh -> - SavedHostRow( - kh, - enabled = !connecting, - onConnect = { - host = kh.address - port = kh.port.toString() - 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 -> - 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)) + Box(Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(8.dp)) + Text("punktfunk", style = MaterialTheme.typography.headlineLarge) Text( - it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, + "stream a remote desktop", + style = MaterialTheme.typography.bodyMedium, + 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( - "core ABI v$abi", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + + 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 }, + 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 -> when (pt.kind) { 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 -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( - modifier = Modifier - .weight(1f) - .clickable(enabled = enabled, onClick = onConnect) - .padding(vertical = 12.dp), - ) { - 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) - } - TextButton(enabled = enabled, onClick = onForget) { Text("Forget") } - } +private fun HostAvatar(name: String) { + val letter = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?" + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + letter, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +} + +/** 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, + ) } }