mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-05 19:08:23 +00:00
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:
@@ -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") }
|
||||
}
|
||||
}
|
||||
+9
@@ -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)
|
||||
}
|
||||
+37
@@ -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"
|
||||
}
|
||||
}
|
||||
+36
@@ -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,
|
||||
)
|
||||
}
|
||||
+33
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user