Merge pull request #563 from dmnyc/feat/wallet-nwc-backup
feat(wallet): cross-platform NWC connection backup via NIP-78 + parity polish
This commit is contained in:
@@ -128,4 +128,48 @@ object Nip78 {
|
||||
/** Extract the d-tag value from an event, or null. */
|
||||
fun extractDTag(event: NostrEvent): String? =
|
||||
event.tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
|
||||
|
||||
// ─── NWC connection backup ────────────────────────────────────────
|
||||
//
|
||||
// Cross-platform NWC URI backup per `NWC_BACKUP_PARITY.md`. iOS
|
||||
// and Android both publish to / read from the same flat `d` tag
|
||||
// (`nwc-wallet-backup` — no `wisp-` prefix, intentional, for
|
||||
// cross-platform interop). Content is the raw NWC URI string,
|
||||
// NIP-44 v2 encrypted to self.
|
||||
|
||||
const val NWC_BACKUP_D_TAG = "nwc-wallet-backup"
|
||||
|
||||
/** Build + sign a kind-30078 event carrying the encrypted NWC URI. */
|
||||
suspend fun createNwcBackupEvent(signer: NostrSigner, uri: String): NostrEvent {
|
||||
val encrypted = signer.nip44Encrypt(uri.trim(), signer.pubkeyHex)
|
||||
val tags = listOf(
|
||||
listOf("d", NWC_BACKUP_D_TAG),
|
||||
listOf("client", "Wisp"),
|
||||
listOf("encryption", "nip44")
|
||||
)
|
||||
return signer.signEvent(kind = KIND, content = encrypted, tags = tags)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a kind-30078 NWC-backup event and return the raw URI.
|
||||
* Returns null when the content is empty, decrypt fails, or the
|
||||
* plaintext doesn't look like a `nostr+walletconnect://` URI.
|
||||
*/
|
||||
suspend fun decryptNwcBackup(signer: NostrSigner, event: NostrEvent): String? {
|
||||
if (event.content.isBlank()) return null
|
||||
return try {
|
||||
val decrypted = signer.nip44Decrypt(event.content, event.pubkey).trim()
|
||||
if (decrypted.startsWith("nostr+walletconnect://", ignoreCase = true)) decrypted else null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Filter to fetch the user's NWC backup (single addressable event). */
|
||||
fun nwcBackupFilter(pubkeyHex: String): Filter = Filter(
|
||||
kinds = listOf(KIND),
|
||||
authors = listOf(pubkeyHex),
|
||||
dTags = listOf(NWC_BACKUP_D_TAG),
|
||||
limit = 1
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,10 +47,12 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -119,11 +121,15 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -156,6 +162,7 @@ import com.wisp.app.ui.component.NsecPasteGuard
|
||||
import com.wisp.app.ui.component.SatsNumpad
|
||||
import com.wisp.app.ui.util.AmountFormatter
|
||||
import com.wisp.app.viewmodel.AutoCheckState
|
||||
import com.wisp.app.viewmodel.NwcRestoreState
|
||||
import com.wisp.app.viewmodel.FeeState
|
||||
import com.wisp.app.viewmodel.BackupStatus
|
||||
import com.wisp.app.viewmodel.DeleteBackupStatus
|
||||
@@ -183,9 +190,10 @@ fun WalletScreen(
|
||||
// Hide the wallet app bar on the Home dashboard — the bottom-nav wallet
|
||||
// tab is the entry point, and the dashboard's own top row (brand logo +
|
||||
// refresh + settings) plays the role of the toolbar. The mode picker
|
||||
// and the wallet-setup sub-screens (NWC, Spark, Spark restore-seed)
|
||||
// render their own top-right Close pill instead of an app bar to match
|
||||
// iOS — leaves more headroom for the centered logo + title layout.
|
||||
// and the wallet-setup sub-screens (NWC, Spark, Spark restore-seed,
|
||||
// Spark backup) render their own top-right Close pill instead of an
|
||||
// app bar to match iOS — leaves more headroom for the centered logo
|
||||
// + title layout.
|
||||
val hideAppBar = currentPage is WalletPage.Home ||
|
||||
currentPage is WalletPage.ModeSelection ||
|
||||
currentPage is WalletPage.NwcSetup ||
|
||||
@@ -269,9 +277,12 @@ fun WalletScreen(
|
||||
walletState = walletState,
|
||||
connectionString = viewModel.connectionString.collectAsState().value,
|
||||
statusLines = viewModel.statusLines.collectAsState().value,
|
||||
nwcRestoreState = viewModel.nwcRestoreState.collectAsState().value,
|
||||
onConnectionStringChange = { viewModel.updateConnectionString(it) },
|
||||
onConnect = { viewModel.connectNwcWallet() },
|
||||
onDisconnect = { viewModel.disconnectWallet() },
|
||||
onRestoreFromBackup = { viewModel.restoreFromNwcBackup() },
|
||||
onDismissRestore = { viewModel.dismissNwcRestore() },
|
||||
onClose = { viewModel.navigateHome() }
|
||||
)
|
||||
is WalletPage.SparkSetup -> SparkSetupContent(
|
||||
@@ -604,9 +615,12 @@ private fun WalletConnectionContent(
|
||||
walletState: WalletState,
|
||||
connectionString: String,
|
||||
statusLines: List<String>,
|
||||
nwcRestoreState: NwcRestoreState = NwcRestoreState.Idle,
|
||||
onConnectionStringChange: (String) -> Unit,
|
||||
onConnect: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
onRestoreFromBackup: () -> Unit = {},
|
||||
onDismissRestore: () -> Unit = {},
|
||||
onClose: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -672,6 +686,80 @@ private fun WalletConnectionContent(
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
if (nwcRestoreState is NwcRestoreState.Searching) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.wallet_nwc_restore_searching),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (nwcRestoreState is NwcRestoreState.Found) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
val dateStr = remember(nwcRestoreState.createdAt) {
|
||||
val fmt = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)
|
||||
fmt.format(java.util.Date(nwcRestoreState.createdAt * 1000L))
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = accent.copy(alpha = 0.16f),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Outlined.CloudDownload,
|
||||
contentDescription = null,
|
||||
tint = accent,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.wallet_nwc_restore_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.wallet_nwc_restore_body, dateStr),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = onRestoreFromBackup,
|
||||
enabled = !isConnecting,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accent,
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.wallet_nwc_restore_action),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// Paste / Scan card — top half displays the pasted string (or
|
||||
@@ -770,14 +858,14 @@ private fun WalletConnectionContent(
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Info,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(14.dp)
|
||||
modifier = Modifier.size(14.dp).padding(top = 2.dp)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
@@ -787,6 +875,25 @@ private fun WalletConnectionContent(
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.CloudDownload,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(14.dp).padding(top = 2.dp)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.wallet_nwc_backup_footer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
if (walletState is WalletState.Error) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
@@ -819,18 +926,12 @@ private fun WalletConnectionContent(
|
||||
}
|
||||
}
|
||||
|
||||
if (statusLines.isNotEmpty()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
statusLines.forEach { line ->
|
||||
Text(
|
||||
line,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Status-line stream (per-relay connect/relay-status output) is
|
||||
// intentionally suppressed on the NWC setup screen — the Connect
|
||||
// button's own spinner + "Connecting…" label covers the happy path,
|
||||
// and any failure surfaces via WalletState.Error above. Showing
|
||||
// verbose connect logs here just looked like noise (especially
|
||||
// after a one-tap backup restore).
|
||||
|
||||
if (isConnecting) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
@@ -1142,12 +1243,21 @@ private fun WalletHomeContent(
|
||||
// fiat currency but does NOT flip the app-wide
|
||||
// [FiatPreferences.isFiatMode] flag (still respected by feed
|
||||
// counts / timestamps elsewhere).
|
||||
// Fixed-height container so the balance occupies the same vertical
|
||||
// space regardless of which mode is showing — keeps the lightning-
|
||||
// address pill + Send/Receive row anchored at a stable Y across the
|
||||
// sats/BTC/⚡/fiat/hidden cycle. One-line variants center vertically
|
||||
// within the reserved height; two-line variants (sats + subtitle,
|
||||
// hidden + "tap to reveal") fill it naturally.
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.clickable {
|
||||
balanceDisplay = balanceDisplay.next()
|
||||
WalletBalanceDisplayMode.write(prefs, pubkey, balanceDisplay)
|
||||
}
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.heightIn(min = 96.dp)
|
||||
.clickable {
|
||||
balanceDisplay = balanceDisplay.next()
|
||||
WalletBalanceDisplayMode.write(prefs, pubkey, balanceDisplay)
|
||||
}
|
||||
) {
|
||||
when (balanceDisplay) {
|
||||
WalletBalanceDisplayMode.HIDDEN -> {
|
||||
@@ -1203,25 +1313,76 @@ private fun WalletHomeContent(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"%,d".format(balanceSats),
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.wallet_sats),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
// BalanceUnit selects between three sat denominations
|
||||
// — SATS ("1,234" + "sats" subtitle), BITCOIN ("₿ 0.00001234"
|
||||
// BTC-denominated), LIGHTNING (⚡ + sats inline).
|
||||
// The setting lives in Wallet Settings → Display Unit.
|
||||
when (balanceUnit) {
|
||||
BalanceUnit.SATS -> {
|
||||
Text(
|
||||
"%,d".format(balanceSats),
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.wallet_sats),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
BalanceUnit.BITCOIN -> {
|
||||
// Symbol rendered in onSurfaceVariant so the
|
||||
// number stays the prominent reading — matches
|
||||
// the muted "sats" subtitle in the SATS branch.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"₿",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"%,d".format(balanceSats),
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
BalanceUnit.LIGHTNING -> {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_bolt),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.height(40.dp)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
"%,d".format(balanceSats),
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lightning address pill ─────────────────────────────────
|
||||
if (walletMode == WalletMode.SPARK && lightningAddress != null) {
|
||||
// Shown for any wallet mode that carries a lud16 — Spark wallets
|
||||
// expose one via the Breez SDK; NWC URIs may include `lud16=...`
|
||||
// which connectNwcWallet copies into `lightningAddress`. The old
|
||||
// NWC-only logo + "Nostr Wallet Connect" footer below the balance
|
||||
// was redundant (the dashboard header already brands the mode),
|
||||
// so it's removed.
|
||||
if (!lightningAddress.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Surface(
|
||||
modifier = Modifier.clickable {
|
||||
@@ -1248,7 +1409,9 @@ private fun WalletHomeContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (walletMode == WalletMode.SPARK && lightningAddress == null) {
|
||||
} else if (walletMode == WalletMode.SPARK) {
|
||||
// Spark-only setup CTA. NWC users can't register an address
|
||||
// from inside Wisp — it comes from the NWC URI or not at all.
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Surface(
|
||||
modifier = Modifier.clickable(onClick = onSetupAddress),
|
||||
@@ -1274,28 +1437,6 @@ private fun WalletHomeContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (walletMode == WalletMode.NWC) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_nwc_logo),
|
||||
contentDescription = "NWC",
|
||||
modifier = Modifier.height(16.dp),
|
||||
colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.wallet_nwc_title),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Send / Receive ─────────────────────────────────────────
|
||||
@@ -1354,7 +1495,20 @@ private fun WalletHomeContent(
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
|
||||
)
|
||||
recentTransactions.take(1).forEach { tx ->
|
||||
// Match iOS — show recent transactions inline, "View all"
|
||||
// expands to the full screen. Row count scales with the
|
||||
// device's available height so a compact phone (e.g.
|
||||
// ~640dp tall) doesn't crowd out the balance + Send/
|
||||
// Receive controls, while bigger displays still get up
|
||||
// to 5 rows like iOS.
|
||||
val screenHeightDp = LocalConfiguration.current.screenHeightDp
|
||||
val txCount = when {
|
||||
screenHeightDp >= 800 -> 5
|
||||
screenHeightDp >= 720 -> 4
|
||||
screenHeightDp >= 640 -> 3
|
||||
else -> 2
|
||||
}
|
||||
recentTransactions.take(txCount).forEach { tx ->
|
||||
TransactionRow(tx, profileLookup, balanceDisplay)
|
||||
}
|
||||
}
|
||||
@@ -2570,11 +2724,12 @@ private fun WalletModeSelectionContent(
|
||||
painter = painterResource(R.drawable.ic_spark_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(28.dp),
|
||||
colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(accent)
|
||||
colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(Color.White)
|
||||
)
|
||||
},
|
||||
title = stringResource(R.string.wallet_spark_title),
|
||||
subtitle = stringResource(R.string.wallet_spark_subtitle),
|
||||
subtitle = stringResource(R.string.wallet_spark_subtitle_recommended),
|
||||
highlighted = true,
|
||||
onClick = onSelectSpark
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
@@ -2599,14 +2754,31 @@ private fun WalletModeRow(
|
||||
leadingIcon: @Composable () -> Unit,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit
|
||||
onClick: () -> Unit,
|
||||
highlighted: Boolean = false
|
||||
) {
|
||||
val accent = WispThemeColors.zapColor
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
val containerColor = if (highlighted) accent else MaterialTheme.colorScheme.surfaceVariant
|
||||
val titleColor = if (highlighted) Color.White else MaterialTheme.colorScheme.onSurface
|
||||
val subtitleColor = if (highlighted) Color.White.copy(alpha = 0.85f) else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val arrowColor = if (highlighted) Color.White.copy(alpha = 0.85f) else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
// Layered shadows produce the iOS soft-glow halo: wide outer bleed +
|
||||
// tighter inner glow, both tinted with the accent. Colored shadows
|
||||
// only honor `ambientColor`/`spotColor` on API 28+; below that the
|
||||
// device falls back to the system grey shadow.
|
||||
val highlightModifier = if (highlighted) {
|
||||
Modifier
|
||||
.shadow(elevation = 24.dp, shape = shape, ambientColor = accent, spotColor = accent)
|
||||
.shadow(elevation = 10.dp, shape = shape, ambientColor = accent, spotColor = accent)
|
||||
} else Modifier
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(highlightModifier)
|
||||
.clickable(onClick = onClick),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
color = containerColor,
|
||||
shape = shape
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -2622,20 +2794,20 @@ private fun WalletModeRow(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = titleColor
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = subtitleColor
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
tint = arrowColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
@@ -2772,36 +2944,84 @@ private fun SparkSetupContent(
|
||||
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
// Option rows
|
||||
// Primary "Use my default wallet" gets the orange+glow treatment
|
||||
// so the obvious next step (re-derive the user's existing wallet)
|
||||
// reads as the default. Other paths collapse under "More options"
|
||||
// — but if there's no default option visible, expand by default
|
||||
// so users still see every path immediately.
|
||||
if (canUseDefaultWallet) {
|
||||
SparkOptionRow(
|
||||
icon = Icons.Outlined.VpnKey,
|
||||
WalletModeRow(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Outlined.VpnKey,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
},
|
||||
title = stringResource(R.string.wallet_use_default),
|
||||
subtitle = stringResource(R.string.wallet_default_subtitle),
|
||||
highlighted = true,
|
||||
onClick = onUseDefaultWallet
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
SparkOptionRow(
|
||||
icon = Icons.Outlined.Add,
|
||||
title = stringResource(R.string.wallet_create_title),
|
||||
subtitle = stringResource(R.string.wallet_create_subtitle),
|
||||
onClick = onCreateWallet
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SparkOptionRow(
|
||||
icon = Icons.Outlined.History,
|
||||
title = stringResource(R.string.wallet_restore_seed_title),
|
||||
subtitle = stringResource(R.string.wallet_restore_seed_subtitle),
|
||||
onClick = onRestoreFromSeed
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SparkOptionRow(
|
||||
icon = Icons.Outlined.CloudDownload,
|
||||
title = stringResource(R.string.wallet_restore_relays_title),
|
||||
subtitle = stringResource(R.string.wallet_restore_relays_subtitle),
|
||||
onClick = onRestoreFromRelay
|
||||
|
||||
var moreOptionsExpanded by remember { mutableStateOf(!canUseDefaultWallet) }
|
||||
val chevronRotation by animateFloatAsState(
|
||||
targetValue = if (moreOptionsExpanded) 180f else 0f,
|
||||
label = "more-options-chevron"
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { moreOptionsExpanded = !moreOptionsExpanded }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.wallet_more_options),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Icon(
|
||||
Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.graphicsLayer { rotationZ = chevronRotation }
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = moreOptionsExpanded,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
) {
|
||||
Column {
|
||||
SparkOptionRow(
|
||||
icon = Icons.Outlined.Add,
|
||||
title = stringResource(R.string.wallet_create_title),
|
||||
subtitle = stringResource(R.string.wallet_create_subtitle),
|
||||
onClick = onCreateWallet
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SparkOptionRow(
|
||||
icon = Icons.Outlined.History,
|
||||
title = stringResource(R.string.wallet_restore_seed_title),
|
||||
subtitle = stringResource(R.string.wallet_restore_seed_subtitle),
|
||||
onClick = onRestoreFromSeed
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SparkOptionRow(
|
||||
icon = Icons.Outlined.CloudDownload,
|
||||
title = stringResource(R.string.wallet_restore_relays_title),
|
||||
subtitle = stringResource(R.string.wallet_restore_relays_subtitle),
|
||||
onClick = onRestoreFromRelay
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (walletState is WalletState.Error) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
@@ -3328,6 +3548,9 @@ private fun WalletSettingsContent(
|
||||
Text(if (isDefaultWallet) "View Recovery Phrase" else "Backup Recovery Phrase")
|
||||
}
|
||||
|
||||
// Relay backup is only offered for non-default Spark wallets.
|
||||
// For default wallets the nsec is already the canonical backup
|
||||
// and relay-backup adds no value while cluttering the screen.
|
||||
if (!isDefaultWallet) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
@@ -3493,22 +3716,68 @@ private fun WalletSettingsContent(
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = onDeleteWallet,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = if (isDefaultWallet) ButtonDefaults.buttonColors()
|
||||
else ButtonDefaults.buttonColors(
|
||||
// iOS treats both the default-Spark "Switch" and the NWC
|
||||
// "Disconnect" cases as quiet, recoverable affordances — a
|
||||
// card row with red text + swap icon plus a caption. Only the
|
||||
// truly destructive Delete (non-default Spark whose seed can't
|
||||
// be re-derived from nsec) keeps the filled-red CTA so the user
|
||||
// notices the irreversibility.
|
||||
val isRecoverable = walletMode == WalletMode.NWC || isDefaultWallet
|
||||
if (isRecoverable) {
|
||||
Text(
|
||||
stringResource(R.string.wallet_disconnect_section),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 4.dp, bottom = 6.dp)
|
||||
)
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onDeleteWallet),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.SwapHoriz,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFFF3B30),
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
stringResource(R.string.wallet_switch_wallet),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = Color(0xFFFF3B30)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.wallet_switch_wallet_caption),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
} else {
|
||||
Button(
|
||||
onClick = onDeleteWallet,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFFD32F2F),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
walletMode == WalletMode.NWC -> "Disconnect"
|
||||
isDefaultWallet -> stringResource(R.string.wallet_switch_wallet)
|
||||
else -> "Delete Wallet"
|
||||
}
|
||||
)
|
||||
) {
|
||||
Text("Delete Wallet")
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
@@ -3855,18 +4124,20 @@ private fun DeleteWalletConfirmContent(
|
||||
) {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
// Confirmation page uses the same iOS-red `#FF3B30` for all
|
||||
// three flows (default-switch, NWC-disconnect, non-default
|
||||
// delete) so the user doesn't see one orange and one red CTA
|
||||
// for what's the same conceptual action — "stop using this
|
||||
// wallet." Matches the iOS-red used on the entry-point card.
|
||||
val confirmAccent = Color(0xFFFF3B30)
|
||||
Icon(
|
||||
if (isDefault) Icons.Default.SwapHoriz else Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.background(
|
||||
(if (isDefault) MaterialTheme.colorScheme.primary else Color(0xFFD32F2F))
|
||||
.copy(alpha = 0.1f),
|
||||
CircleShape
|
||||
)
|
||||
.background(confirmAccent.copy(alpha = 0.1f), CircleShape)
|
||||
.padding(16.dp),
|
||||
tint = if (isDefault) MaterialTheme.colorScheme.primary else Color(0xFFD32F2F)
|
||||
tint = confirmAccent
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
@@ -3878,7 +4149,7 @@ private fun DeleteWalletConfirmContent(
|
||||
else -> "Delete Wallet"
|
||||
},
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = if (isDefault) MaterialTheme.colorScheme.onSurface else Color(0xFFD32F2F)
|
||||
color = if (isDefault) MaterialTheme.colorScheme.onSurface else confirmAccent
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
@@ -3900,7 +4171,7 @@ private fun DeleteWalletConfirmContent(
|
||||
Text(
|
||||
"Make sure you have backed up your recovery phrase before proceeding.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFFD32F2F),
|
||||
color = confirmAccent,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
@@ -3921,11 +4192,10 @@ private fun DeleteWalletConfirmContent(
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = isNwc || isDefault || confirmText == "DELETE",
|
||||
colors = if (isDefault) ButtonDefaults.buttonColors()
|
||||
else ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFFD32F2F),
|
||||
contentColor = Color.White
|
||||
)
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = confirmAccent,
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
@@ -4459,11 +4729,21 @@ private fun WalletInfoRow(
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Fixed-width label column so values across rows align to the
|
||||
// same x-coordinate regardless of label length, and the value
|
||||
// text always has a stable container to ellipsize within.
|
||||
// Without this, "Lightning address" pushes its value column 30dp
|
||||
// to the right of "Relay" — the iOS settings panel uses the
|
||||
// same column-aligned layout.
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.widthIn(min = 110.dp)
|
||||
.padding(end = 12.dp)
|
||||
)
|
||||
Text(
|
||||
value,
|
||||
@@ -4471,7 +4751,8 @@ private fun WalletInfoRow(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(2f)
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (onCopy != null) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
@@ -81,6 +81,13 @@ sealed class AutoCheckState {
|
||||
object NotFound : AutoCheckState()
|
||||
}
|
||||
|
||||
sealed class NwcRestoreState {
|
||||
object Idle : NwcRestoreState()
|
||||
object Searching : NwcRestoreState()
|
||||
data class Found(val uri: String, val createdAt: Long) : NwcRestoreState()
|
||||
object NotFound : NwcRestoreState()
|
||||
}
|
||||
|
||||
sealed class FeeState {
|
||||
object Idle : FeeState()
|
||||
object Loading : FeeState()
|
||||
@@ -254,6 +261,11 @@ class WalletViewModel(
|
||||
private val _autoCheckState = MutableStateFlow<AutoCheckState>(AutoCheckState.Idle)
|
||||
val autoCheckState: StateFlow<AutoCheckState> = _autoCheckState
|
||||
|
||||
// NWC connection-string restore from NIP-78 backup (per NWC_BACKUP_PARITY.md)
|
||||
private val _nwcRestoreState = MutableStateFlow<NwcRestoreState>(NwcRestoreState.Idle)
|
||||
val nwcRestoreState: StateFlow<NwcRestoreState> = _nwcRestoreState
|
||||
private var nwcRestoreJob: Job? = null
|
||||
|
||||
// Per-relay backup status
|
||||
private val _relayBackupStatuses = MutableStateFlow<List<RelayBackupInfo>>(emptyList())
|
||||
val relayBackupStatuses: StateFlow<List<RelayBackupInfo>> = _relayBackupStatuses
|
||||
@@ -332,6 +344,7 @@ class WalletViewModel(
|
||||
relayPool.registerDedupBypass("auto-check-")
|
||||
relayPool.registerDedupBypass("wallet-backup-")
|
||||
relayPool.registerDedupBypass("delete-backup-")
|
||||
relayPool.registerDedupBypass("nwc-restore-")
|
||||
|
||||
val mode = walletModeRepo.getMode()
|
||||
when (mode) {
|
||||
@@ -464,6 +477,7 @@ class WalletViewModel(
|
||||
|
||||
fun selectNwcMode() {
|
||||
navigateTo(WalletPage.NwcSetup)
|
||||
searchNwcBackup()
|
||||
}
|
||||
|
||||
fun selectSparkMode() {
|
||||
@@ -607,6 +621,114 @@ class WalletViewModel(
|
||||
_autoCheckState.value = AutoCheckState.Idle
|
||||
}
|
||||
|
||||
// --- NWC backup (NIP-78 kind 30078, d=nwc-wallet-backup) ---
|
||||
//
|
||||
// Cross-platform with iOS per `NWC_BACKUP_PARITY.md`. Publish on every
|
||||
// successful connect (best-effort); search on NWC setup screen open and
|
||||
// surface a "Restore previous wallet" affordance when found.
|
||||
|
||||
private fun searchNwcBackup() {
|
||||
val signer = buildSigner() ?: run {
|
||||
_nwcRestoreState.value = NwcRestoreState.NotFound
|
||||
return
|
||||
}
|
||||
// Don't re-search while one is in flight or already has a result the
|
||||
// user hasn't dismissed yet.
|
||||
if (_nwcRestoreState.value is NwcRestoreState.Searching) return
|
||||
nwcRestoreJob?.cancel()
|
||||
_nwcRestoreState.value = NwcRestoreState.Searching
|
||||
nwcRestoreJob = viewModelScope.launch {
|
||||
try {
|
||||
relayPool.ensureWriteRelaysConnected()
|
||||
val pubkey = signer.pubkeyHex
|
||||
val subId = "nwc-restore-${System.currentTimeMillis()}"
|
||||
val filter = Nip78.nwcBackupFilter(pubkey)
|
||||
val seenIds = mutableSetOf<String>()
|
||||
val events = mutableListOf<NostrEvent>()
|
||||
var eoseCount = 0
|
||||
val collectJob = launch {
|
||||
relayPool.relayEvents.collect { relayEvent: RelayEvent ->
|
||||
if (relayEvent.subscriptionId == subId && seenIds.add(relayEvent.event.id)) {
|
||||
events.add(relayEvent.event)
|
||||
}
|
||||
}
|
||||
}
|
||||
val eoseJob = launch {
|
||||
relayPool.eoseSignals.collect { id ->
|
||||
if (id == subId) eoseCount++
|
||||
}
|
||||
}
|
||||
yield()
|
||||
val allCount = relayPool.getRelayUrls().size
|
||||
val minEose = (allCount * 2 + 2) / 3
|
||||
relayPool.sendToAll(ClientMessage.req(subId, filter))
|
||||
withTimeoutOrNull(10_000) {
|
||||
while (eoseCount < allCount) {
|
||||
delay(200)
|
||||
if (eoseCount >= minEose && events.isNotEmpty()) break
|
||||
}
|
||||
}
|
||||
collectJob.cancel()
|
||||
eoseJob.cancel()
|
||||
relayPool.closeOnAllRelays(subId)
|
||||
|
||||
val newest = events
|
||||
.filter { !it.content.isBlank() }
|
||||
.maxByOrNull { it.created_at }
|
||||
if (newest == null) {
|
||||
_nwcRestoreState.value = NwcRestoreState.NotFound
|
||||
return@launch
|
||||
}
|
||||
val uri = withContext(Dispatchers.Default) {
|
||||
Nip78.decryptNwcBackup(signer, newest)
|
||||
}
|
||||
if (uri.isNullOrBlank()) {
|
||||
_nwcRestoreState.value = NwcRestoreState.NotFound
|
||||
} else {
|
||||
// Don't offer to restore if it's already the active connection.
|
||||
val active = nwcRepo.getConnectionString()
|
||||
if (active != null && active.trim() == uri.trim()) {
|
||||
_nwcRestoreState.value = NwcRestoreState.NotFound
|
||||
} else {
|
||||
_nwcRestoreState.value = NwcRestoreState.Found(uri, newest.created_at)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
_nwcRestoreState.value = NwcRestoreState.NotFound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreFromNwcBackup() {
|
||||
val state = _nwcRestoreState.value
|
||||
if (state is NwcRestoreState.Found) {
|
||||
_nwcRestoreState.value = NwcRestoreState.Idle
|
||||
_connectionString.value = state.uri
|
||||
connectNwcWallet(state.uri)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissNwcRestore() {
|
||||
nwcRestoreJob?.cancel()
|
||||
_nwcRestoreState.value = NwcRestoreState.Idle
|
||||
}
|
||||
|
||||
private suspend fun publishNwcBackup(uri: String) {
|
||||
val signer = buildSigner() ?: return
|
||||
val trimmed = uri.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
try {
|
||||
relayPool.ensureWriteRelaysConnected()
|
||||
val event = withContext(Dispatchers.Default) {
|
||||
Nip78.createNwcBackupEvent(signer, trimmed)
|
||||
}
|
||||
val sent = relayPool.sendToWriteRelays(ClientMessage.event(event))
|
||||
Log.d("NwcBackup", "publish: sent to $sent relays")
|
||||
} catch (e: Exception) {
|
||||
Log.d("NwcBackup", "publish failed (non-fatal): ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// --- NWC Connection ---
|
||||
|
||||
fun updateConnectionString(value: String) {
|
||||
@@ -760,6 +882,25 @@ class WalletViewModel(
|
||||
// needed there.
|
||||
if (provider === nwcRepo) {
|
||||
launch { nwcRepo.fetchNodeInfo() }
|
||||
// Best-effort cross-device backup of the URI per
|
||||
// NWC_BACKUP_PARITY.md. Publish each connect so a
|
||||
// reconnect / wallet swap replaces the prior backup.
|
||||
val uri = nwcRepo.getConnectionString()
|
||||
if (!uri.isNullOrBlank()) {
|
||||
launch { publishNwcBackup(uri) }
|
||||
}
|
||||
}
|
||||
// Once the wallet finishes connecting, leave the setup
|
||||
// screen and land on Home — otherwise currentPage stays
|
||||
// at NwcSetup/SparkSetup and the Scaffold's "Wallet"
|
||||
// TopAppBar keeps showing over the dashboard. Guarded
|
||||
// to the setup pages so a connect-during-navigation
|
||||
// doesn't pop the user out of an unrelated sub-page.
|
||||
val page = _currentPage.value
|
||||
if (page is WalletPage.NwcSetup || page is WalletPage.SparkSetup) {
|
||||
pageStack.clear()
|
||||
pageStack.add(WalletPage.Home)
|
||||
_currentPage.value = WalletPage.Home
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -818,16 +959,18 @@ class WalletViewModel(
|
||||
|
||||
// Suppress the auto-create on the next navigateHome — the user
|
||||
// explicitly disconnected to choose a different wallet. They'll land
|
||||
// on the SparkSetup screen with the three options. Persist so the
|
||||
// choice survives app restarts.
|
||||
// on the wallet-mode picker (Spark vs NWC), matching iOS. Persist
|
||||
// so the choice survives app restarts.
|
||||
skipAutoCreate = true
|
||||
walletModeRepo.setAutoCreateSkipped(true)
|
||||
|
||||
pageStack.clear()
|
||||
pageStack.add(WalletPage.Home)
|
||||
if (wasSpark && keyRepo.hasKeypair()) {
|
||||
pageStack.add(WalletPage.SparkSetup)
|
||||
_currentPage.value = WalletPage.SparkSetup
|
||||
// Drop the user at the top-level wallet picker so they can
|
||||
// re-enter via Spark or NWC, not just Spark. iOS does the
|
||||
// same — Switch is a true "start over" affordance.
|
||||
_currentPage.value = WalletPage.Home
|
||||
} else {
|
||||
_currentPage.value = WalletPage.Home
|
||||
}
|
||||
|
||||
@@ -732,10 +732,17 @@
|
||||
<string name="wallet_connect_subtitle">Send and receive Lightning payments, and zap anyone on Nostr.</string>
|
||||
<string name="wallet_spark_title">Spark wallet</string>
|
||||
<string name="wallet_spark_subtitle">Self-custody, embedded. Use your default wallet or restore from seed/relays.</string>
|
||||
<string name="wallet_spark_subtitle_recommended">Self-custody, embedded. Recommended.</string>
|
||||
<string name="wallet_nwc_title">Nostr Wallet Connect</string>
|
||||
<string name="wallet_nwc_subtitle">Paste a connection string from Alby, Zeus, Rizful, Minibits, etc.</string>
|
||||
<string name="wallet_nwc_paste_prompt">Paste the connection string from your NWC-compatible wallet.</string>
|
||||
<string name="wallet_nwc_connection_hint">Connection string starts with nostr+walletconnect://</string>
|
||||
<string name="wallet_nwc_restore_title">Backup found</string>
|
||||
<string name="wallet_nwc_restore_searching">Searching for backup…</string>
|
||||
<string name="wallet_nwc_restore_subtitle">Encrypted NWC backup from another device</string>
|
||||
<string name="wallet_nwc_restore_body">A connection backed up on %1$s is available. Restore it to reconnect without pasting a string.</string>
|
||||
<string name="wallet_nwc_restore_action">Restore connection</string>
|
||||
<string name="wallet_nwc_backup_footer">Connecting saves an encrypted backup to your Nostr relays so you can restore this connection on other devices.</string>
|
||||
<string name="spark_setup_subtitle">Self-custodial Lightning, powered by Spark and Breez.</string>
|
||||
<string name="wallet_create_title">Create new wallet</string>
|
||||
<string name="wallet_create_subtitle">Generate a fresh 12-word seed phrase</string>
|
||||
@@ -889,7 +896,10 @@
|
||||
<string name="wallet_generate_invoice">Generate invoice</string>
|
||||
<string name="wallet_use_default">Use my default wallet</string>
|
||||
<string name="wallet_default_subtitle">Derived from your Nostr key — no extra backup needed.</string>
|
||||
<string name="wallet_switch_wallet">Switch Wallet</string>
|
||||
<string name="wallet_more_options">More options</string>
|
||||
<string name="wallet_switch_wallet">Switch to a different wallet</string>
|
||||
<string name="wallet_switch_wallet_body">Disconnect this wallet so you can use your default wallet or restore a different one. Your funds stay safe — you can reconnect this wallet anytime by entering its recovery phrase.</string>
|
||||
<string name="wallet_disconnect_section">Disconnect Wallet</string>
|
||||
<string name="wallet_switch_wallet_caption">Your default wallet is linked to your key and can always be restored. Switching connects a different wallet instead.</string>
|
||||
<string name="wallet_default_seed_info">This wallet is derived from your Nostr key, so the phrase below is just for export to other wallet apps. You don\'t need to back it up — your nsec is the backup.</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user