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.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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user