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:
@@ -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)?,
|
||||
) {
|
||||
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)
|
||||
ElevatedCard(
|
||||
onClick = onConnect,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user