From 1e803a7a52babe8ab238fdb59bd857851c90b5ee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 19:02:04 +0000 Subject: [PATCH] Build a proper multi-screen desktop bunker UI, mirroring the Android app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single-screen ConnectedMainScreen with a NavigationRail shell (AppShell) and six flat destinations, hand-navigated via a sealed Screen class (no KMP navigation-compose library exists yet): - Home: pubkey + live relay connection status + quick counts - Connect: the bunker:// QR/URI, pulled out on its own - Connected Apps: mirrors Android's ApplicationsScreen list layout - App Detail: mirrors EditPermission — per-app Allow/Deny/Ask rows, per-row delete (via the new permissionsFor/deletePermission), recent activity, revoke-all - Activity: global reverse-chronological history log - Settings: custom relay list (persisted via RelayStore), data directory path, theme toggle DesktopTheme ports Amber's exact warm color scheme/shapes from app/.../ui/theme/Theme.kt. The approval dialog is restyled to show the app's avatar/name/method/kind/payload preview instead of a bare AlertDialog. Small reusable pieces (LetterAvatar, relative-time and method-description formatting, AWT clipboard helper) live under ui/components/, covered by DisplayFormattingTest. :desktop:createDistributable and the full :desktop/:shared/:app test suites all still pass after the expansion. --- .../nostrsigner/desktop/ui/ActivityScreen.kt | 77 +++++ .../nostrsigner/desktop/ui/AppDetailScreen.kt | 140 +++++++++ .../nostrsigner/desktop/ui/AppShell.kt | 282 ++++++++++++++++++ .../nostrsigner/desktop/ui/ApprovalDialog.kt | 74 +++++ .../nostrsigner/desktop/ui/BunkerApp.kt | 109 +------ .../nostrsigner/desktop/ui/ConnectScreen.kt | 41 +++ .../desktop/ui/ConnectedAppsScreen.kt | 58 ++++ .../nostrsigner/desktop/ui/HomeScreen.kt | 78 +++++ .../nostrsigner/desktop/ui/SettingsScreen.kt | 107 +++++++ .../nostrsigner/desktop/ui/SetupScreen.kt | 32 ++ .../desktop/ui/components/ClipboardUtil.kt | 9 + .../ui/components/DisplayFormatting.kt | 37 +++ .../desktop/ui/components/LetterAvatar.kt | 36 +++ .../nostrsigner/desktop/ui/nav/Screen.kt | 16 + .../desktop/ui/theme/DesktopTheme.kt | 59 ++++ .../ui/components/DisplayFormattingTest.kt | 33 ++ 16 files changed, 1082 insertions(+), 106 deletions(-) create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ActivityScreen.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AppDetailScreen.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AppShell.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApprovalDialog.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ConnectScreen.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ConnectedAppsScreen.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/HomeScreen.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SetupScreen.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/ClipboardUtil.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/DisplayFormatting.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/LetterAvatar.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/nav/Screen.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/theme/DesktopTheme.kt create mode 100644 desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/DisplayFormattingTest.kt diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ActivityScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ActivityScreen.kt new file mode 100644 index 00000000..7624f601 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ActivityScreen.kt @@ -0,0 +1,77 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.greenart7c3.nostrsigner.desktop.data.HistoryRow +import com.greenart7c3.nostrsigner.desktop.ui.components.LetterAvatar +import com.greenart7c3.nostrsigner.desktop.ui.components.bunkerMethodDescription +import com.greenart7c3.nostrsigner.desktop.ui.components.relativeTimeFromNow +import com.greenart7c3.nostrsigner.desktop.ui.components.shortenHex + +@Composable +fun ActivityScreen(history: List) { + Column(modifier = Modifier.padding(24.dp).fillMaxSize()) { + Text("Activity", style = MaterialTheme.typography.titleLarge) + + if (history.isEmpty()) { + Text( + "No requests handled yet.", + modifier = Modifier.padding(top = 16.dp), + style = MaterialTheme.typography.bodyMedium, + ) + return@Column + } + + LazyColumn(modifier = Modifier.padding(top = 16.dp)) { + items(history, key = { it.id }) { row -> + val entry = row.entry + val displayName = entry.appName?.ifBlank { null } ?: entry.appPubKey.shortenHex() + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LetterAvatar(displayName) + Column(modifier = Modifier.padding(start = 12.dp).weight(1f)) { + Text(displayName, style = MaterialTheme.typography.bodyLarge) + Text(bunkerMethodDescription(entry.method, entry.kind), style = MaterialTheme.typography.bodySmall) + } + ApprovedBadge(entry.approved) + Text( + relativeTimeFromNow(entry.time), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 12.dp), + ) + } + HorizontalDivider() + } + } + } +} + +@Composable +private fun ApprovedBadge(approved: Boolean) { + val color = if (approved) Color(0xFF2E7D32) else Color(0xFFC62828) + Surface(color = color, shape = RoundedCornerShape(4.dp)) { + Text( + if (approved) "Allowed" else "Rejected", + color = Color.White, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + ) + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AppDetailScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AppDetailScreen.kt new file mode 100644 index 00000000..081cb989 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AppDetailScreen.kt @@ -0,0 +1,140 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.greenart7c3.nostrsigner.desktop.data.StoredPermission +import com.greenart7c3.nostrsigner.desktop.ui.components.LetterAvatar +import com.greenart7c3.nostrsigner.desktop.ui.components.bunkerMethodDescription +import com.greenart7c3.nostrsigner.desktop.ui.components.relativeTimeFromNow +import com.greenart7c3.nostrsigner.desktop.ui.components.shortenHex +import com.greenart7c3.nostrsigner.desktop.ui.theme.orange +import com.greenart7c3.nostrsigner.shared.BunkerHistoryEntry + +@Composable +fun AppDetailScreen( + appPubKey: String, + appName: String, + bunkerUri: String, + permissions: List, + recentActivity: List, + onAllow: (StoredPermission) -> Unit, + onDeny: (StoredPermission) -> Unit, + onAsk: (StoredPermission) -> Unit, + onRevokeAll: () -> Unit, + onCopyUri: (String) -> Unit, + onBack: () -> Unit, +) { + val displayName = appName.ifBlank { appPubKey.shortenHex() } + + Column(modifier = Modifier.padding(24.dp).fillMaxSize()) { + Row { + IconButton(onClick = onBack) { Text("←") } + Spacer(Modifier.width(8.dp)) + LetterAvatar(displayName) + Column(modifier = Modifier.padding(start = 12.dp)) { + Text(displayName, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(appPubKey.shortenHex(), style = MaterialTheme.typography.bodySmall) + } + } + + Spacer(Modifier.height(16.dp)) + Text(bunkerUri, style = MaterialTheme.typography.bodySmall, maxLines = 1) + TextButton(onClick = { onCopyUri(bunkerUri) }) { Text("Copy connection string") } + + if (recentActivity.isNotEmpty()) { + Spacer(Modifier.height(16.dp)) + Text("Recent activity", style = MaterialTheme.typography.titleMedium) + recentActivity.take(10).forEach { entry -> + Text( + "${bunkerMethodDescription(entry.method, entry.kind)} — ${if (entry.approved) "allowed" else "rejected"} ${relativeTimeFromNow(entry.time)}", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + + Spacer(Modifier.height(24.dp)) + Text("Permissions", style = MaterialTheme.typography.titleMedium) + Text( + "Allow or deny future requests for this app; choosing Ask again removes the stored rule.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 12.dp), + ) + + if (permissions.isEmpty()) { + Text("No stored permissions yet — you'll be prompted on the next request.", style = MaterialTheme.typography.bodyMedium) + } else { + permissions.forEach { permission -> + PermissionRow(permission, onAllow, onDeny, onAsk) + } + } + + if (permissions.isNotEmpty()) { + Button( + modifier = Modifier.padding(top = 32.dp), + colors = ButtonDefaults.buttonColors(containerColor = orange), + onClick = onRevokeAll, + ) { + Text("Revoke all permissions") + } + } + } +} + +@Composable +private fun PermissionRow( + permission: StoredPermission, + onAllow: (StoredPermission) -> Unit, + onDeny: (StoredPermission) -> Unit, + onAsk: (StoredPermission) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .border(BorderStroke(1.dp, MaterialTheme.colorScheme.primary), RoundedCornerShape(6.dp)) + .padding(12.dp), + ) { + Text(bunkerMethodDescription(permission.method, permission.kind), style = MaterialTheme.typography.bodyLarge) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterToggle("Allow", selected = permission.approved) { onAllow(permission) } + FilterToggle("Deny", selected = !permission.approved) { onDeny(permission) } + TextButton(onClick = { onAsk(permission) }) { Text("Ask again") } + } + } +} + +@Composable +private fun FilterToggle(label: String, selected: Boolean, onClick: () -> Unit) { + val colors = if (selected) { + ButtonDefaults.buttonColors() + } else { + ButtonDefaults.outlinedButtonColors() + } + if (selected) { + Button(onClick = onClick, colors = colors) { Text(label) } + } else { + Button(onClick = onClick, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)) { Text(label) } + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AppShell.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AppShell.kt new file mode 100644 index 00000000..91437fc9 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/AppShell.kt @@ -0,0 +1,282 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Apps +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.greenart7c3.nostrsigner.desktop.data.AppDataDir +import com.greenart7c3.nostrsigner.desktop.data.BunkerDatabase +import com.greenart7c3.nostrsigner.desktop.data.ConnectedApp +import com.greenart7c3.nostrsigner.desktop.data.RelayStore +import com.greenart7c3.nostrsigner.desktop.data.SettingsStore +import com.greenart7c3.nostrsigner.desktop.data.SqliteBunkerHistoryLogger +import com.greenart7c3.nostrsigner.desktop.data.SqliteBunkerPermissionStore +import com.greenart7c3.nostrsigner.desktop.data.StoredPermission +import com.greenart7c3.nostrsigner.desktop.relay.BunkerRelayConnection +import com.greenart7c3.nostrsigner.desktop.relay.DEFAULT_BUNKER_RELAYS +import com.greenart7c3.nostrsigner.desktop.ui.components.copyToClipboard +import com.greenart7c3.nostrsigner.desktop.ui.nav.Screen +import com.greenart7c3.nostrsigner.desktop.ui.theme.DesktopTheme +import com.greenart7c3.nostrsigner.desktop.ui.theme.ThemeMode +import com.greenart7c3.nostrsigner.desktop.ui.theme.resolveIsDark +import com.greenart7c3.nostrsigner.shared.BunkerHistoryEntry +import com.greenart7c3.nostrsigner.shared.BunkerSigner +import com.greenart7c3.nostrsigner.shared.BunkerSigningEngine +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import java.awt.Desktop +import java.sql.Connection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +private class BunkerServices( + val connection: Connection, + val permissionStore: SqliteBunkerPermissionStore, + val historyLogger: SqliteBunkerHistoryLogger, + val relayStore: RelayStore, + val settingsStore: SettingsStore, + val approvalPort: DesktopApprovalPort, + val engine: BunkerSigningEngine, +) + +/** The desktop bunker's main shell: nav rail + current screen + the approval dialog overlay. */ +@Composable +fun AppShell(account: KeyPair, scope: CoroutineScope) { + val pubKeyHex = remember(account) { account.pubKey.toHexKey() } + var currentScreen by remember { mutableStateOf(Screen.Home) } + var themeMode by remember { mutableStateOf(ThemeMode.SYSTEM) } + + val services = remember(pubKeyHex) { + val db = BunkerDatabase.open() + val permissionStore = SqliteBunkerPermissionStore(db) + val historyLogger = SqliteBunkerHistoryLogger(db) + val approvalPort = DesktopApprovalPort() + val engine = BunkerSigningEngine( + account = BunkerSigner(account), + permissionStore = permissionStore, + approvalPort = approvalPort, + historyLogger = historyLogger, + appNameLookup = { pk -> historyLogger.nameFor(pk) }, + ) + BunkerServices( + connection = db, + permissionStore = permissionStore, + historyLogger = historyLogger, + relayStore = RelayStore(db), + settingsStore = SettingsStore(db), + approvalPort = approvalPort, + engine = engine, + ) + } + + // Approval dialogs / callbacks for one connection lifecycle. + var relayConnection by remember(pubKeyHex) { mutableStateOf(null) } + var connectedRelayCount by remember { mutableStateOf(0) } + + LaunchedEffect(pubKeyHex) { + services.settingsStore.get("theme_mode")?.let { saved -> + themeMode = runCatching { ThemeMode.valueOf(saved) }.getOrDefault(ThemeMode.SYSTEM) + } + + val persistedRelays = services.relayStore.list() + val relayUrls = persistedRelays.ifEmpty { DEFAULT_BUNKER_RELAYS } + val connection = BunkerRelayConnection(pubKeyHex, services.engine, scope, relayUrls) + connection.start() + relayConnection = connection + } + + DisposableEffect(pubKeyHex) { + onDispose { relayConnection?.stop() } + } + + LaunchedEffect(relayConnection) { + relayConnection?.connectedRelays?.collect { connectedRelayCount = it.size } + } + + val pending by services.approvalPort.pending.collectAsState() + + var connectedApps by remember { mutableStateOf>(emptyList()) } + suspend fun refreshConnectedApps() { + connectedApps = services.historyLogger.connectedApps() + } + LaunchedEffect(Unit) { refreshConnectedApps() } + LaunchedEffect(pending.size) { refreshConnectedApps() } + + var detailPermissions by remember { mutableStateOf>(emptyList()) } + var detailActivity by remember { mutableStateOf>(emptyList()) } + suspend fun refreshDetail(appPubKey: String) { + detailPermissions = services.permissionStore.permissionsFor(appPubKey) + detailActivity = services.historyLogger.recentHistoryFor(appPubKey).map { it.entry } + } + LaunchedEffect(currentScreen) { + (currentScreen as? Screen.AppDetail)?.let { refreshDetail(it.appPubKey) } + } + + var relayList by remember { mutableStateOf>(emptyList()) } + LaunchedEffect(currentScreen) { + if (currentScreen == Screen.Settings) relayList = services.relayStore.list() + } + + pending.firstOrNull()?.let { entry -> + ApprovalDialog(entry, onAnswer = { approved, remember -> services.approvalPort.answer(entry, approved, remember) }) + } + + DesktopTheme(darkTheme = themeMode.resolveIsDark()) { + Row(modifier = Modifier.fillMaxSize()) { + NavRail(currentScreen, pendingCount = pending.size, onNavigate = { currentScreen = it }) + + Box(modifier = Modifier.weight(1f)) { + when (val screen = currentScreen) { + Screen.Home -> HomeScreen( + pubKeyHex = pubKeyHex, + totalRelays = relayConnection?.relays?.size ?: 0, + connectedRelayCount = connectedRelayCount, + connectedAppsCount = connectedApps.size, + pendingApprovalCount = pending.size, + onCopyPubKey = { copyToClipboard(pubKeyHex) }, + ) + Screen.Connect -> ConnectScreen( + bunkerUri = relayConnection?.let { conn -> "bunker://$pubKeyHex?" + conn.relays.joinToString("&") { "relay=${it.url}" } }, + onCopyUri = { copyToClipboard(it) }, + ) + Screen.ConnectedApps -> ConnectedAppsScreen( + connectedApps = connectedApps, + onAppClick = { currentScreen = Screen.AppDetail(it) }, + ) + is Screen.AppDetail -> { + val app = connectedApps.find { it.pubKey == screen.appPubKey } + AppDetailScreen( + appPubKey = screen.appPubKey, + appName = app?.name.orEmpty(), + bunkerUri = "bunker://$pubKeyHex?" + (relayConnection?.relays?.joinToString("&") { "relay=${it.url}" } ?: ""), + permissions = detailPermissions, + recentActivity = detailActivity, + onAllow = { permission -> + scope.launch { + services.permissionStore.remember(permission.appPubKey, permission.method, permission.kind, true) + refreshDetail(screen.appPubKey) + } + }, + onDeny = { permission -> + scope.launch { + services.permissionStore.remember(permission.appPubKey, permission.method, permission.kind, false) + refreshDetail(screen.appPubKey) + } + }, + onAsk = { permission -> + scope.launch { + services.permissionStore.deletePermission(permission.appPubKey, permission.method, permission.kind) + refreshDetail(screen.appPubKey) + } + }, + onRevokeAll = { + scope.launch { + services.permissionStore.revokeAll(screen.appPubKey) + refreshDetail(screen.appPubKey) + } + }, + onCopyUri = { copyToClipboard(it) }, + onBack = { currentScreen = Screen.ConnectedApps }, + ) + } + Screen.Activity -> { + var history by remember { mutableStateOf(emptyList()) } + LaunchedEffect(Unit) { history = services.historyLogger.recentHistory() } + ActivityScreen(history) + } + Screen.Settings -> SettingsScreen( + relays = relayList, + onAddRelay = { url -> + scope.launch { + services.relayStore.add(url) + relayList = services.relayStore.list() + } + }, + onRemoveRelay = { url -> + scope.launch { + services.relayStore.remove(url) + relayList = services.relayStore.list() + } + }, + relayValidator = { RelayUrlNormalizer.normalizeOrNull(it) != null }, + dataDirPath = AppDataDir.directory.absolutePath, + onRevealDataDir = { + scope.launch(Dispatchers.IO) { + runCatching { Desktop.getDesktop().open(AppDataDir.directory) } + } + }, + themeMode = themeMode, + onThemeModeChange = { mode -> + themeMode = mode + scope.launch { services.settingsStore.set("theme_mode", mode.name) } + }, + ) + } + } + } + } +} + +@Composable +private fun NavRail(currentScreen: Screen, pendingCount: Int, onNavigate: (Screen) -> Unit) { + NavigationRail { + NavigationRailItem( + selected = currentScreen == Screen.Home, + onClick = { onNavigate(Screen.Home) }, + icon = { + if (pendingCount > 0) { + BadgedBox(badge = { Badge { Text("$pendingCount") } }) { Icon(Icons.Default.Home, contentDescription = "Home") } + } else { + Icon(Icons.Default.Home, contentDescription = "Home") + } + }, + label = { Text("Home") }, + ) + NavigationRailItem( + selected = currentScreen == Screen.Connect, + onClick = { onNavigate(Screen.Connect) }, + icon = { Icon(Icons.Default.Link, contentDescription = "Connect") }, + label = { Text("Connect") }, + ) + NavigationRailItem( + selected = currentScreen == Screen.ConnectedApps || currentScreen is Screen.AppDetail, + onClick = { onNavigate(Screen.ConnectedApps) }, + icon = { Icon(Icons.Default.Apps, contentDescription = "Connected apps") }, + label = { Text("Apps") }, + ) + NavigationRailItem( + selected = currentScreen == Screen.Activity, + onClick = { onNavigate(Screen.Activity) }, + icon = { Icon(Icons.Default.History, contentDescription = "Activity") }, + label = { Text("Activity") }, + ) + NavigationRailItem( + selected = currentScreen == Screen.Settings, + onClick = { onNavigate(Screen.Settings) }, + icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, + label = { Text("Settings") }, + ) + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApprovalDialog.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApprovalDialog.kt new file mode 100644 index 00000000..22a872b6 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApprovalDialog.kt @@ -0,0 +1,74 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.greenart7c3.nostrsigner.desktop.ui.components.LetterAvatar +import com.greenart7c3.nostrsigner.desktop.ui.components.bunkerMethodDescription +import com.greenart7c3.nostrsigner.desktop.ui.components.shortenHex + +@Composable +fun ApprovalDialog(entry: PendingApproval, onAnswer: (approved: Boolean, remember: Boolean) -> Unit) { + val request = entry.request + val displayName = request.appName?.takeIf { it.isNotBlank() } ?: request.appPubKey.shortenHex() + + Dialog(onDismissRequest = {}) { + Card { + Column(modifier = Modifier.padding(24.dp).width(360.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + LetterAvatar(displayName) + Column(modifier = Modifier.padding(start = 12.dp)) { + Text(displayName, style = MaterialTheme.typography.titleMedium) + Text(request.appPubKey.shortenHex(), style = MaterialTheme.typography.bodySmall) + } + } + + Spacer(Modifier.height(16.dp)) + Text(bunkerMethodDescription(request.method, request.kind), style = MaterialTheme.typography.bodyLarge) + + if (request.payloadPreview.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .border(BorderStroke(1.dp, MaterialTheme.colorScheme.outline), RoundedCornerShape(4.dp)) + .padding(8.dp), + ) { + Text( + request.payloadPreview, + style = MaterialTheme.typography.bodySmall, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Spacer(Modifier.height(24.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { onAnswer(false, false) }) { Text("Reject") } + TextButton(onClick = { onAnswer(true, false) }) { Text("Allow once") } + Button(onClick = { onAnswer(true, true) }) { Text("Always allow") } + } + } + } + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/BunkerApp.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/BunkerApp.kt index 87ee2444..218d6898 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/BunkerApp.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/BunkerApp.kt @@ -1,25 +1,10 @@ package com.greenart7c3.nostrsigner.desktop.ui -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -27,14 +12,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.greenart7c3.nostrsigner.desktop.data.AccountStore -import com.greenart7c3.nostrsigner.desktop.data.BunkerDatabase -import com.greenart7c3.nostrsigner.desktop.data.ConnectedApp -import com.greenart7c3.nostrsigner.desktop.data.SqliteBunkerHistoryLogger -import com.greenart7c3.nostrsigner.desktop.data.SqliteBunkerPermissionStore -import com.greenart7c3.nostrsigner.desktop.relay.BunkerRelayConnection -import com.greenart7c3.nostrsigner.shared.BunkerSigner -import com.greenart7c3.nostrsigner.shared.BunkerSigningEngine -import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.greenart7c3.nostrsigner.desktop.ui.theme.DesktopTheme import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -49,7 +27,7 @@ fun BunkerApp(scope: CoroutineScope) { loading = false } - MaterialTheme { + DesktopTheme(darkTheme = false) { Surface { when { loading -> Text("Loading...", modifier = Modifier.padding(24.dp)) @@ -57,88 +35,7 @@ fun BunkerApp(scope: CoroutineScope) { onGenerate = { scope.launch { account = AccountStore.generate() } }, onImport = { hex -> scope.launch { account = AccountStore.import(hex) } }, ) - else -> ConnectedMainScreen(account!!, scope) - } - } - } -} - -@Composable -private fun SetupScreen(onGenerate: () -> Unit, onImport: (String) -> Unit) { - var importText by remember { mutableStateOf("") } - Column(modifier = Modifier.padding(24.dp)) { - Text("Set up your Amber Bunker account", style = MaterialTheme.typography.titleLarge) - Spacer(Modifier.height(16.dp)) - Button(onClick = onGenerate) { Text("Generate a new key") } - Spacer(Modifier.height(24.dp)) - Text("Or import an existing private key (hex)") - OutlinedTextField(value = importText, onValueChange = { importText = it }, modifier = Modifier.padding(top = 8.dp)) - Spacer(Modifier.height(8.dp)) - Button(onClick = { onImport(importText.trim()) }, enabled = importText.isNotBlank()) { Text("Import") } - } -} - -@Composable -private fun ConnectedMainScreen(account: KeyPair, scope: CoroutineScope) { - val pubKeyHex = account.pubKey.toHexKey() - - val connection = remember(pubKeyHex) { - val db = BunkerDatabase.open() - val permissionStore = SqliteBunkerPermissionStore(db) - val historyLogger = SqliteBunkerHistoryLogger(db) - val approvalPort = DesktopApprovalPort() - val engine = BunkerSigningEngine(BunkerSigner(account), permissionStore, approvalPort, historyLogger) - val relayConnection = BunkerRelayConnection(pubKeyHex, engine, scope) - relayConnection.start() - Triple(relayConnection, approvalPort, historyLogger) - } - val (relayConnection, approvalPort, historyLogger) = connection - - val bunkerUri = remember(pubKeyHex) { - "bunker://$pubKeyHex?" + relayConnection.relays.joinToString("&") { "relay=${it.url}" } - } - val qrImage = remember(bunkerUri) { qrCodeImageBitmap(bunkerUri) } - val pending by approvalPort.pending.collectAsState() - var connectedApps by remember { mutableStateOf>(emptyList()) } - - LaunchedEffect(Unit) { connectedApps = historyLogger.connectedApps() } - - pending.firstOrNull()?.let { entry -> - AlertDialog( - onDismissRequest = {}, - title = { Text("Signing request") }, - text = { - Column { - Text("App: ${entry.request.appPubKey.take(12)}...") - Text("Method: ${entry.request.method}") - entry.request.kind?.let { Text("Kind: $it") } - } - }, - confirmButton = { - TextButton(onClick = { approvalPort.answer(entry, approved = true, remember = true) }) { Text("Always allow") } - }, - dismissButton = { - Row { - TextButton(onClick = { approvalPort.answer(entry, approved = true, remember = false) }) { Text("Allow once") } - TextButton(onClick = { approvalPort.answer(entry, approved = false, remember = false) }) { Text("Reject") } - } - }, - ) - } - - Column(modifier = Modifier.padding(24.dp).fillMaxSize()) { - Text("Amber Bunker", style = MaterialTheme.typography.titleLarge) - Spacer(Modifier.height(16.dp)) - Text("Public key: $pubKeyHex") - Spacer(Modifier.height(16.dp)) - Image(qrImage, contentDescription = "Bunker connection QR code") - Spacer(Modifier.height(8.dp)) - Text(bunkerUri, style = MaterialTheme.typography.bodySmall) - Spacer(Modifier.height(24.dp)) - Text("Connected apps", style = MaterialTheme.typography.titleMedium) - LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) { - items(connectedApps) { app -> - Text(app.name.ifBlank { app.pubKey.take(16) + "..." }) + else -> AppShell(account!!, scope) } } } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ConnectScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ConnectScreen.kt new file mode 100644 index 00000000..faa81b15 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ConnectScreen.kt @@ -0,0 +1,41 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.greenart7c3.nostrsigner.desktop.ui.qrCodeImageBitmap + +@Composable +fun ConnectScreen(bunkerUri: String?, onCopyUri: (String) -> Unit) { + Column(modifier = Modifier.padding(24.dp).fillMaxSize()) { + Text("Connect a client", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(8.dp)) + Text("Scan this QR code, or paste the connection string, in a NIP-46-compatible Nostr client.", style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(24.dp)) + + if (bunkerUri == null) { + Text("Connecting to relays…") + return@Column + } + + val qrImage = remember(bunkerUri) { qrCodeImageBitmap(bunkerUri) } + Image(qrImage, contentDescription = "Bunker connection QR code") + Spacer(Modifier.height(16.dp)) + SelectionContainer { + Text(bunkerUri, style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(8.dp)) + Button(onClick = { onCopyUri(bunkerUri) }) { Text("Copy to clipboard") } + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ConnectedAppsScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ConnectedAppsScreen.kt new file mode 100644 index 00000000..1e3b0ac7 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ConnectedAppsScreen.kt @@ -0,0 +1,58 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.greenart7c3.nostrsigner.desktop.data.ConnectedApp +import com.greenart7c3.nostrsigner.desktop.ui.components.LetterAvatar +import com.greenart7c3.nostrsigner.desktop.ui.components.relativeTimeFromNow +import com.greenart7c3.nostrsigner.desktop.ui.components.shortenHex + +@Composable +fun ConnectedAppsScreen(connectedApps: List, onAppClick: (String) -> Unit) { + Column(modifier = Modifier.padding(24.dp).fillMaxSize()) { + Text("Connected apps", style = MaterialTheme.typography.titleLarge) + + if (connectedApps.isEmpty()) { + Text( + "No apps connected yet — pair one from the Connect screen.", + modifier = Modifier.padding(top = 16.dp), + style = MaterialTheme.typography.bodyMedium, + ) + return@Column + } + + LazyColumn(modifier = Modifier.padding(top = 16.dp)) { + items(connectedApps, key = { it.pubKey }) { app -> + val displayName = app.name.ifBlank { app.pubKey.shortenHex() } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onAppClick(app.pubKey) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LetterAvatar(displayName) + Column(modifier = Modifier.padding(start = 12.dp)) { + Text(displayName, style = MaterialTheme.typography.titleMedium) + Text(app.pubKey.shortenHex(), style = MaterialTheme.typography.bodySmall) + Text("Last used ${relativeTimeFromNow(app.connectedAt)}", style = MaterialTheme.typography.bodySmall) + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.primary) + } + } + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/HomeScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/HomeScreen.kt new file mode 100644 index 00000000..06b227ab --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/HomeScreen.kt @@ -0,0 +1,78 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.greenart7c3.nostrsigner.desktop.ui.components.LetterAvatar +import com.greenart7c3.nostrsigner.desktop.ui.components.shortenHex + +@Composable +fun HomeScreen( + pubKeyHex: String, + totalRelays: Int, + connectedRelayCount: Int, + connectedAppsCount: Int, + pendingApprovalCount: Int, + onCopyPubKey: () -> Unit, +) { + Column(modifier = Modifier.padding(24.dp).fillMaxSize()) { + Row { + LetterAvatar(pubKeyHex, size = 56.dp) + Spacer(Modifier.width(16.dp)) + Column { + Text("Amber Bunker", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text( + pubKeyHex.shortenHex(), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.clickable(onClick = onCopyPubKey), + ) + } + } + + Spacer(Modifier.height(8.dp)) + Text( + if (connectedRelayCount == 0 && totalRelays > 0) { + "Connecting to relays…" + } else { + "Connected to $connectedRelayCount of $totalRelays relays" + }, + style = MaterialTheme.typography.bodySmall, + color = if (connectedRelayCount > 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + ) + + Spacer(Modifier.height(24.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + StatCard("Connected apps", connectedAppsCount) + StatCard("Pending approvals", pendingApprovalCount) + } + } +} + +@Composable +private fun StatCard(label: String, count: Int) { + Card( + modifier = Modifier.width(180.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + shape = RoundedCornerShape(8.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text("$count", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text(label, style = MaterialTheme.typography.bodySmall) + } + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt new file mode 100644 index 00000000..f0759021 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt @@ -0,0 +1,107 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.greenart7c3.nostrsigner.desktop.ui.theme.ThemeMode + +@Composable +fun SettingsScreen( + relays: List, + onAddRelay: (String) -> Unit, + onRemoveRelay: (String) -> Unit, + relayValidator: (String) -> Boolean, + dataDirPath: String, + onRevealDataDir: () -> Unit, + themeMode: ThemeMode, + onThemeModeChange: (ThemeMode) -> Unit, +) { + Column(modifier = Modifier.padding(24.dp).fillMaxSize()) { + Text("Settings", style = MaterialTheme.typography.titleLarge) + + Spacer(Modifier.height(24.dp)) + Text("Theme", style = MaterialTheme.typography.titleMedium) + Row(modifier = Modifier.padding(top = 8.dp)) { + ThemeMode.entries.forEach { mode -> + ThemeModeButton(mode, selected = mode == themeMode, onClick = { onThemeModeChange(mode) }) + } + } + + Spacer(Modifier.height(24.dp)) + Text("Relays", style = MaterialTheme.typography.titleMedium) + Text( + "Changes apply the next time Amber Bunker starts.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp, bottom = 8.dp), + ) + relays.forEach { relay -> + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(relay, modifier = Modifier.weight(1f)) + IconButton(onClick = { onRemoveRelay(relay) }) { Text("✕") } + } + } + + var newRelay by remember { mutableStateOf("") } + var error by remember { mutableStateOf(false) } + Row(modifier = Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = newRelay, + onValueChange = { + newRelay = it + error = false + }, + placeholder = { Text("wss://relay.example.com") }, + isError = error, + modifier = Modifier.weight(1f), + ) + Button( + modifier = Modifier.padding(start = 8.dp), + onClick = { + if (relayValidator(newRelay)) { + onAddRelay(newRelay.trim()) + newRelay = "" + } else { + error = true + } + }, + ) { Text("Add") } + } + + Spacer(Modifier.height(24.dp)) + Text("Data directory", style = MaterialTheme.typography.titleMedium) + Text(dataDirPath, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp)) + Button(modifier = Modifier.padding(top = 8.dp), onClick = onRevealDataDir) { Text("Reveal in file manager") } + } +} + +@Composable +private fun ThemeModeButton(mode: ThemeMode, selected: Boolean, onClick: () -> Unit) { + val label = when (mode) { + ThemeMode.SYSTEM -> "System" + ThemeMode.LIGHT -> "Light" + ThemeMode.DARK -> "Dark" + } + Button( + modifier = Modifier.padding(end = 8.dp), + colors = if (selected) ButtonDefaults.buttonColors() else ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + onClick = onClick, + ) { Text(label) } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SetupScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SetupScreen.kt new file mode 100644 index 00000000..1a206ee0 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SetupScreen.kt @@ -0,0 +1,32 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun SetupScreen(onGenerate: () -> Unit, onImport: (String) -> Unit) { + var importText by remember { mutableStateOf("") } + Column(modifier = Modifier.padding(24.dp)) { + Text("Set up your Amber Bunker account", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(16.dp)) + Button(onClick = onGenerate) { Text("Generate a new key") } + Spacer(Modifier.height(24.dp)) + Text("Or import an existing private key (hex)") + OutlinedTextField(value = importText, onValueChange = { importText = it }, modifier = Modifier.padding(top = 8.dp)) + Spacer(Modifier.height(8.dp)) + Button(onClick = { onImport(importText.trim()) }, enabled = importText.isNotBlank()) { Text("Import") } + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/ClipboardUtil.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/ClipboardUtil.kt new file mode 100644 index 00000000..0c60b195 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/ClipboardUtil.kt @@ -0,0 +1,9 @@ +package com.greenart7c3.nostrsigner.desktop.ui.components + +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection + +/** Plain AWT clipboard access — works uniformly across Linux/Windows/macOS without a Compose clipboard API. */ +fun copyToClipboard(text: String) { + Toolkit.getDefaultToolkit().systemClipboard.setContents(StringSelection(text), null) +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/DisplayFormatting.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/DisplayFormatting.kt new file mode 100644 index 00000000..6953a553 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/DisplayFormatting.kt @@ -0,0 +1,37 @@ +package com.greenart7c3.nostrsigner.desktop.ui.components + +import com.greenart7c3.nostrsigner.shared.BunkerMethod + +/** Shortens a hex pubkey for display, e.g. `deadbeef01...cafebabe02`. */ +fun String.shortenHex(): String = if (length > 16) "${take(8)}...${takeLast(8)}" else this + +/** A human-readable description of a bunker request, for approval prompts and activity rows. */ +fun bunkerMethodDescription(method: BunkerMethod, kind: Int?): String = when (method) { + BunkerMethod.SIGN_EVENT -> "Sign event" + (kind?.let { " (kind $it)" } ?: "") + BunkerMethod.CONNECT -> "Connect" + BunkerMethod.GET_PUBLIC_KEY -> "Get public key" + BunkerMethod.NIP04_ENCRYPT -> "Encrypt (NIP-04)" + BunkerMethod.NIP04_DECRYPT -> "Decrypt (NIP-04)" + BunkerMethod.NIP44_ENCRYPT -> "Encrypt (NIP-44)" + BunkerMethod.NIP44_DECRYPT -> "Decrypt (NIP-44)" + BunkerMethod.NIP44_V3_ENCRYPT -> "Encrypt (NIP-44 v3)" + BunkerMethod.NIP44_V3_DECRYPT -> "Decrypt (NIP-44 v3)" + BunkerMethod.DECRYPT_ZAP_EVENT -> "Decrypt zap event" + BunkerMethod.PING -> "Ping" + BunkerMethod.SWITCH_RELAYS -> "Switch relays" + BunkerMethod.SIGN_PSBT -> "Sign PSBT" + BunkerMethod.LOGOUT -> "Logout" + BunkerMethod.INVALID -> "Unknown request" +} + +/** A coarse "N units ago" string; `nowSeconds` is injectable for testing. */ +fun relativeTimeFromNow(epochSeconds: Long, nowSeconds: Long = System.currentTimeMillis() / 1000): String { + val diff = (nowSeconds - epochSeconds).coerceAtLeast(0) + return when { + diff < 60 -> "just now" + diff < 3600 -> "${diff / 60}m ago" + diff < 86400 -> "${diff / 3600}h ago" + diff < 604800 -> "${diff / 86400}d ago" + else -> "${diff / 604800}w ago" + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/LetterAvatar.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/LetterAvatar.kt new file mode 100644 index 00000000..3ce606aa --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/LetterAvatar.kt @@ -0,0 +1,36 @@ +package com.greenart7c3.nostrsigner.desktop.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.absoluteValue + +/** A colored circular initial, standing in for a connected app's/account's icon. */ +@Composable +fun LetterAvatar(name: String, modifier: Modifier = Modifier, size: Dp = 40.dp) { + val letter = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?" + val color = remember(name) { avatarColorFor(name) } + Box( + modifier = modifier.size(size).clip(CircleShape).background(color), + contentAlignment = Alignment.Center, + ) { + Text(letter, color = Color.White, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) + } +} + +private fun avatarColorFor(seed: String): Color { + val hue = (seed.hashCode().absoluteValue % 360).toFloat() + return Color.hsv(hue, 0.55f, 0.75f) +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/nav/Screen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/nav/Screen.kt new file mode 100644 index 00000000..84c0f994 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/nav/Screen.kt @@ -0,0 +1,16 @@ +package com.greenart7c3.nostrsigner.desktop.ui.nav + +/** The desktop bunker app's flat navigation destinations. */ +sealed class Screen { + data object Home : Screen() + + data object Connect : Screen() + + data object ConnectedApps : Screen() + + data class AppDetail(val appPubKey: String) : Screen() + + data object Activity : Screen() + + data object Settings : Screen() +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/theme/DesktopTheme.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/theme/DesktopTheme.kt new file mode 100644 index 00000000..5187b390 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/theme/DesktopTheme.kt @@ -0,0 +1,59 @@ +package com.greenart7c3.nostrsigner.desktop.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** Amber's warm color scheme, ported from `app/src/main/java/.../ui/theme/Theme.kt`. */ +val primaryColor = Color(0xFFFFCA62) +val primaryVariant = Color(0xFFC8541A) +val secondaryColor = Color(0xFFFFCA62) +val orange = Color(0xFFFF6B00) + +private val DarkColorPalette = darkColorScheme( + primary = primaryColor, + onPrimary = Color.White, + secondary = primaryVariant, + tertiary = secondaryColor, + primaryContainer = secondaryColor, + secondaryContainer = secondaryColor, +) + +private val LightColorPalette = lightColorScheme( + primary = primaryColor, + secondary = primaryVariant, + tertiary = secondaryColor, + primaryContainer = secondaryColor, + secondaryContainer = secondaryColor, + surface = Color(0xFFFFDE9E), + surfaceContainer = Color(0xFFFFDE9E), +) + +val DesktopShapes = Shapes( + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(4.dp), + large = RoundedCornerShape(0.dp), +) + +/** Manual theme selection — Compose Desktop has no reliable `isSystemInDarkTheme()`, so SYSTEM just defaults to LIGHT. */ +enum class ThemeMode { + SYSTEM, + LIGHT, + DARK, +} + +fun ThemeMode.resolveIsDark(): Boolean = this == ThemeMode.DARK + +@Composable +fun DesktopTheme(darkTheme: Boolean, content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = if (darkTheme) DarkColorPalette else LightColorPalette, + shapes = DesktopShapes, + content = content, + ) +} diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/DisplayFormattingTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/DisplayFormattingTest.kt new file mode 100644 index 00000000..ae973d57 --- /dev/null +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/ui/components/DisplayFormattingTest.kt @@ -0,0 +1,33 @@ +package com.greenart7c3.nostrsigner.desktop.ui.components + +import com.greenart7c3.nostrsigner.shared.BunkerMethod +import kotlin.test.Test +import kotlin.test.assertEquals + +class DisplayFormattingTest { + @Test + fun shortenHexKeepsShortStringsAsIs() { + assertEquals("abc", "abc".shortenHex()) + } + + @Test + fun shortenHexTruncatesLongStrings() { + val hex = "deadbeef00112233445566778899aabbccddeeff" + assertEquals("deadbeef...ccddeeff", hex.shortenHex()) + } + + @Test + fun signEventDescriptionIncludesKind() { + assertEquals("Sign event (kind 1)", bunkerMethodDescription(BunkerMethod.SIGN_EVENT, 1)) + assertEquals("Sign event", bunkerMethodDescription(BunkerMethod.SIGN_EVENT, null)) + } + + @Test + fun relativeTimeBuckets() { + val now = 1_000_000L + assertEquals("just now", relativeTimeFromNow(now - 5, now)) + assertEquals("5m ago", relativeTimeFromNow(now - 300, now)) + assertEquals("2h ago", relativeTimeFromNow(now - 7200, now)) + assertEquals("3d ago", relativeTimeFromNow(now - 259200, now)) + } +}