feat(amethyst): redesign the Nostr Signer screen around a scannable hero

Rework the signer settings screen from a plain toggle list into a purpose-
built pairing surface:

- A disabled-state hero (key medallion, headline, one big "Turn on signer"
  call to action) that reads as a feature intro rather than a setting.
- A live status card with an animated pulsing dot summarising "signing for N
  apps · listening on M relays".
- A QR hero: the bunker:// address rendered as a large scannable QR
  (QrCodeDrawer) on a white surface — pairing is scan-first, with copy and
  new-secret as tonal actions and the raw string kept as a caption.
- Scan-to-connect: a primary "Scan a code" button opening the QR scanner for
  nostrconnect:// offers, with paste-a-link as a revealable fallback.
- A connected-apps row showing the live count and linking into Connected Apps.

Reuses the existing QrCodeDrawer / SimpleQrCodeScanner composables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
This commit is contained in:
Claude
2026-07-17 15:18:22 +00:00
parent c3a1762637
commit 9f32f72a41
2 changed files with 347 additions and 114 deletions
@@ -22,54 +22,80 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46
import android.content.Context
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46PermissionAuthorizer
import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
import kotlinx.coroutines.launch
private val LiveGreen = Color(0xFF3DDC84)
@Composable
fun Nip46SignerScreen(
accountViewModel: AccountViewModel,
@@ -86,11 +112,36 @@ fun Nip46SignerScreen(
val relays by signer.listeningRelays.collectAsStateWithLifecycle()
val writeable = remember { account.signer.isWriteable() }
var connectedCount by remember { mutableIntStateOf(0) }
var refreshKey by remember { mutableIntStateOf(0) }
var scanning by remember { mutableStateOf(false) }
var bunkerUri by remember { mutableStateOf<String?>(null) }
// Recompute the advertised bunker URI whenever it is being shown or its inputs change.
LaunchedEffect(enabled, secret, relays) {
bunkerUri = if (enabled && writeable && relays.isNotEmpty()) signer.bunkerUri() else null
}
LaunchedEffect(enabled, refreshKey) {
connectedCount =
account.signerPermissionLedger.store
.allPolicies()
.keys
.count { it.startsWith("${Nip46PermissionAuthorizer.COORDINATE_PREFIX}:") }
}
fun onConnect(uri: String) {
scope.launch {
val result = signer.connectViaNostrConnect(uri.trim())
Toast.makeText(context, describe(context, result), Toast.LENGTH_LONG).show()
refreshKey++
}
}
if (scanning) {
SimpleQrCodeScanner { contents ->
scanning = false
contents?.let { onConnect(it) }
}
}
Scaffold(
topBar = { TopBarWithBackButton(stringResource(R.string.nip46_signer_title), nav) },
@@ -101,157 +152,219 @@ fun Nip46SignerScreen(
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
HeaderCard()
if (!writeable) {
Text(
stringResource(R.string.nip46_signer_readonly),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
ReadOnlyNotice()
return@Column
}
EnableRow(enabled = enabled, onToggle = { signer.setEnabled(it) })
if (!enabled) {
DisabledHero(onEnable = { signer.setEnabled(true) })
return@Column
}
if (enabled) {
if (relays.isEmpty()) {
Text(
stringResource(R.string.nip46_signer_status_no_relays),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
} else {
Text(
pluralStringResource(R.plurals.nip46_signer_status_listening, relays.size, relays.size),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
LiveStatusCard(
relayCount = relays.size,
connectedCount = connectedCount,
onToggleOff = { signer.setEnabled(false) },
)
bunkerUri?.let { uri ->
BunkerAddressCard(
uri = uri,
onCopy = {
clipboard.setText(AnnotatedString(uri))
Toast.makeText(context, R.string.nip46_signer_copied, Toast.LENGTH_SHORT).show()
},
onRegenerate = {
signer.regenerateSecret()
Toast.makeText(context, R.string.nip46_signer_regenerated, Toast.LENGTH_SHORT).show()
},
)
}
if (relays.isEmpty()) {
WarningCard(stringResource(R.string.nip46_signer_status_no_relays))
}
HorizontalDivider()
ConnectAppSection(
onConnect = { uri ->
scope.launch {
val result = signer.connectViaNostrConnect(uri.trim())
Toast.makeText(context, describe(context, result), Toast.LENGTH_SHORT).show()
}
bunkerUri?.let { uri ->
QrHeroCard(
uri = uri,
onCopy = {
clipboard.setText(AnnotatedString(uri))
Toast.makeText(context, R.string.nip46_signer_copied, Toast.LENGTH_SHORT).show()
},
onRegenerate = {
signer.regenerateSecret()
Toast.makeText(context, R.string.nip46_signer_regenerated, Toast.LENGTH_SHORT).show()
},
)
OutlinedButton(
onClick = { nav.nav(Route.ConnectedApps) },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.nip46_signer_manage_apps))
}
}
ConnectSection(
onScan = { scanning = true },
onPaste = { onConnect(it) },
)
ConnectedAppsRow(
count = connectedCount,
onClick = { nav.nav(Route.ConnectedApps) },
)
}
}
}
// ----------------------------------------------------------------------------
@Composable
private fun HeaderCard() {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
private fun DisabledHero(onEnable: () -> Unit) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(top = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
Box(
modifier =
Modifier
.size(104.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
MaterialSymbols.Key,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp),
)
Text(
stringResource(R.string.nip46_signer_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(52.dp),
)
}
Text(
stringResource(R.string.nip46_signer_hero_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
Text(
stringResource(R.string.nip46_signer_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Button(
onClick = onEnable,
modifier =
Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(16.dp),
) {
Icon(MaterialSymbols.Key, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.nip46_signer_turn_on), fontWeight = FontWeight.SemiBold)
}
}
}
@Composable
private fun EnableRow(
enabled: Boolean,
onToggle: (Boolean) -> Unit,
private fun LiveStatusCard(
relayCount: Int,
connectedCount: Int,
onToggleOff: () -> Unit,
) {
Row(
Card(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Text(
stringResource(R.string.nip46_signer_enable),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Switch(checked = enabled, onCheckedChange = onToggle)
Row(
modifier = Modifier.padding(18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
LiveDot()
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
stringResource(R.string.nip46_signer_live),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Text(
buildString {
append(pluralStringResource(R.plurals.nip46_signer_connected_count, connectedCount, connectedCount))
append(" · ")
append(pluralStringResource(R.plurals.nip46_signer_status_listening, relayCount, relayCount))
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
)
}
Switch(checked = true, onCheckedChange = { onToggleOff() })
}
}
}
@Composable
private fun BunkerAddressCard(
private fun LiveDot() {
val transition = rememberInfiniteTransition(label = "live")
val alpha by transition.animateFloat(
initialValue = 0.25f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
label = "pulse",
)
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(18.dp)) {
Canvas(Modifier.size(18.dp)) { drawCircle(color = LiveGreen, alpha = alpha * 0.35f) }
Canvas(Modifier.size(9.dp)) { drawCircle(color = LiveGreen) }
}
}
@Composable
private fun QrHeroCard(
uri: String,
onCopy: () -> Unit,
onRegenerate: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Surface(
shape = RoundedCornerShape(20.dp),
color = Color.White,
) {
QrCodeDrawer(
contents = uri,
modifier =
Modifier
.padding(16.dp)
.size(232.dp),
)
}
Text(
stringResource(R.string.nip46_signer_bunker_uri_label),
style = MaterialTheme.typography.titleSmall,
stringResource(R.string.nip46_signer_scan_caption),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
)
Text(
uri,
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
stringResource(R.string.nip46_signer_bunker_uri_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Button(onClick = onCopy, modifier = Modifier.weight(1f)) {
FilledTonalButton(onClick = onCopy, modifier = Modifier.weight(1f)) {
Icon(MaterialSymbols.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.nip46_signer_copy))
}
FilledTonalButton(onClick = onRegenerate, modifier = Modifier.weight(1f)) {
Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.nip46_signer_regenerate))
}
}
@@ -260,31 +373,141 @@ private fun BunkerAddressCard(
}
@Composable
private fun ConnectAppSection(onConnect: (String) -> Unit) {
private fun ConnectSection(
onScan: () -> Unit,
onPaste: (String) -> Unit,
) {
var showPaste by remember { mutableStateOf(false) }
var input by remember { mutableStateOf("") }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
stringResource(R.string.nip46_signer_connect_label),
style = MaterialTheme.typography.titleSmall,
)
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text(stringResource(R.string.nip46_signer_connect_hint)) },
fontWeight = FontWeight.SemiBold,
)
Button(
onClick = {
if (input.isNotBlank()) {
onConnect(input)
input = ""
}
},
modifier = Modifier.fillMaxWidth(),
enabled = input.isNotBlank(),
onClick = onScan,
modifier =
Modifier
.fillMaxWidth()
.height(54.dp),
shape = RoundedCornerShape(16.dp),
) {
Text(stringResource(R.string.nip46_signer_connect_button))
Icon(MaterialSymbols.CameraAlt, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.nip46_signer_scan_connect), fontWeight = FontWeight.SemiBold)
}
TextButton(onClick = { showPaste = !showPaste }, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.nip46_signer_paste_link))
}
AnimatedVisibility(visible = showPaste) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(14.dp),
placeholder = { Text(stringResource(R.string.nip46_signer_connect_hint)) },
)
Button(
onClick = {
if (input.isNotBlank()) {
onPaste(input)
input = ""
}
},
modifier = Modifier.fillMaxWidth(),
enabled = input.isNotBlank(),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.filledTonalButtonColors(),
) {
Text(stringResource(R.string.nip46_signer_connect_button))
}
}
}
}
}
@Composable
private fun ConnectedAppsRow(
count: Int,
onClick: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Icon(
MaterialSymbols.Apps,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.nip46_signer_manage_apps),
style = MaterialTheme.typography.titleSmall,
)
Text(
pluralStringResource(R.plurals.nip46_signer_connected_count, count, count),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
MaterialSymbols.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
}
}
@Composable
private fun WarningCard(message: String) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
) {
Text(
message,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
@Composable
private fun ReadOnlyNotice() {
Card(
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.padding(18.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(MaterialSymbols.Key, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(28.dp))
Text(
stringResource(R.string.nip46_signer_readonly),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
+10
View File
@@ -926,6 +926,16 @@
<string name="nip46_signer_connect_failed">Could not connect: %1$s</string>
<string name="nip46_signer_readonly">This is a read-only account and cannot sign.</string>
<string name="nip46_signer_remote_app">Remote signer app</string>
<string name="nip46_signer_hero_title">Sign for other apps</string>
<string name="nip46_signer_turn_on">Turn on signer</string>
<string name="nip46_signer_live">Live</string>
<string name="nip46_signer_scan_caption">Scan to connect an app to your key</string>
<string name="nip46_signer_scan_connect">Scan a code</string>
<string name="nip46_signer_paste_link">Paste a link instead</string>
<plurals name="nip46_signer_connected_count">
<item quantity="one">%1$d connected app</item>
<item quantity="other">%1$d connected apps</item>
</plurals>
<string name="napplet_connected_app_trust_level">Signing trust level</string>
<string name="napplet_connected_app_capabilities">Capabilities</string>
<string name="napplet_connected_app_forget">Forget this app</string>