Build a proper multi-screen desktop bunker UI, mirroring the Android app

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.
This commit is contained in:
Claude
2026-07-01 19:02:04 +00:00
parent 54463d16cb
commit 1e803a7a52
16 changed files with 1082 additions and 106 deletions
@@ -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<HistoryRow>) {
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),
)
}
}
@@ -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<StoredPermission>,
recentActivity: List<BunkerHistoryEntry>,
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) }
}
}
@@ -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>(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<BunkerRelayConnection?>(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<List<ConnectedApp>>(emptyList()) }
suspend fun refreshConnectedApps() {
connectedApps = services.historyLogger.connectedApps()
}
LaunchedEffect(Unit) { refreshConnectedApps() }
LaunchedEffect(pending.size) { refreshConnectedApps() }
var detailPermissions by remember { mutableStateOf<List<StoredPermission>>(emptyList()) }
var detailActivity by remember { mutableStateOf<List<BunkerHistoryEntry>>(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<List<String>>(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<com.greenart7c3.nostrsigner.desktop.data.HistoryRow>()) }
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") },
)
}
}
@@ -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") }
}
}
}
}
}
@@ -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<List<ConnectedApp>>(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)
}
}
}
@@ -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") }
}
}
@@ -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<ConnectedApp>, 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)
}
}
}
}
@@ -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)
}
}
}
@@ -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<String>,
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) }
}
@@ -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") }
}
}
@@ -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)
}
@@ -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"
}
}
@@ -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)
}
@@ -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()
}
@@ -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,
)
}
@@ -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))
}
}