From 2ed2d53defb0c6887dccbe7dc5dbb08ae0c74271 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Fri, 22 May 2026 17:31:10 -0400 Subject: [PATCH 1/3] feat(wallet): cross-platform NWC connection backup via NIP-78 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publish the active NWC URI as a NIP-44 v2 self-encrypted kind-30078 event on every successful connect, and surface a one-tap "Restore previous wallet" affordance on the NWC setup screen when an existing backup is found on relays. Implements the cross-platform contract documented in NWC_BACKUP_PARITY.md (PR #560) so an NWC connection published from iOS restores on Android — and vice versa — with no re-paste. Contract honored: - kind 30078, d-tag "nwc-wallet-backup" (flat, no namespacing) - NIP-44 v2 self-to-self encryption, plaintext = raw NWC URI - Publish best-effort on every connect; no NIP-09 on disconnect - Restore search runs when the NWC setup screen opens; suppressed if the backup matches the active connection --- .../main/kotlin/com/wisp/app/nostr/Nip78.kt | 44 ++++++ .../com/wisp/app/ui/screen/WalletScreen.kt | 52 +++++++ .../com/wisp/app/viewmodel/WalletViewModel.kt | 129 ++++++++++++++++++ app/src/main/res/values/strings.xml | 2 + 4 files changed, 227 insertions(+) diff --git a/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt b/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt index 77261327..d06d9712 100644 --- a/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt +++ b/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt @@ -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 + ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt index de46309d..bd9483d9 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt @@ -155,6 +155,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 @@ -268,9 +269,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( @@ -600,9 +604,12 @@ private fun WalletConnectionContent( walletState: WalletState, connectionString: String, statusLines: List, + nwcRestoreState: NwcRestoreState = NwcRestoreState.Idle, onConnectionStringChange: (String) -> Unit, onConnect: () -> Unit, onDisconnect: () -> Unit, + onRestoreFromBackup: () -> Unit = {}, + onDismissRestore: () -> Unit = {}, onClose: () -> Unit = {} ) { val context = LocalContext.current @@ -668,6 +675,51 @@ private fun WalletConnectionContent( .padding(horizontal = 16.dp) ) + if (nwcRestoreState is NwcRestoreState.Found) { + Spacer(Modifier.height(16.dp)) + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !isConnecting, onClick = onRestoreFromBackup), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Outlined.CloudDownload, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.wallet_nwc_restore_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + stringResource(R.string.wallet_nwc_restore_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + IconButton(onClick = onDismissRestore, enabled = !isConnecting) { + Icon( + Icons.Filled.Close, + contentDescription = stringResource(R.string.btn_cancel), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + } + Spacer(Modifier.height(24.dp)) // Paste / Scan card — top half displays the pasted string (or diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt index e8ea6995..bf94d133 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt @@ -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) : NwcRestoreState() + object NotFound : NwcRestoreState() +} + sealed class FeeState { object Idle : FeeState() object Loading : FeeState() @@ -254,6 +261,11 @@ class WalletViewModel( private val _autoCheckState = MutableStateFlow(AutoCheckState.Idle) val autoCheckState: StateFlow = _autoCheckState + // NWC connection-string restore from NIP-78 backup (per NWC_BACKUP_PARITY.md) + private val _nwcRestoreState = MutableStateFlow(NwcRestoreState.Idle) + val nwcRestoreState: StateFlow = _nwcRestoreState + private var nwcRestoreJob: Job? = null + // Per-relay backup status private val _relayBackupStatuses = MutableStateFlow>(emptyList()) val relayBackupStatuses: StateFlow> = _relayBackupStatuses @@ -297,6 +309,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) { @@ -424,6 +437,7 @@ class WalletViewModel( fun selectNwcMode() { navigateTo(WalletPage.NwcSetup) + searchNwcBackup() } fun selectSparkMode() { @@ -567,6 +581,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() + val events = mutableListOf() + 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) + } + } + } 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) { @@ -714,6 +836,13 @@ 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) } + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5377fd08..09e6e990 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -736,6 +736,8 @@ Paste a connection string from Alby, Zeus, Rizful, Minibits, etc. Paste the connection string from your NWC-compatible wallet. Connection string starts with nostr+walletconnect:// + Restore previous wallet + Encrypted NWC backup from another device Self-custodial Lightning, powered by Spark and Breez. Create new wallet Generate a fresh 12-word seed phrase From 00ce453c99f27de4cb254ec8c4e13ff1eef3fc2c Mon Sep 17 00:00:00 2001 From: The Daniel Date: Fri, 22 May 2026 19:43:52 -0400 Subject: [PATCH 2/3] feat(wallet): NWC backup UX polish + cross-PR parity fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layers iOS-parity polish on top of the bare cross-platform NWC backup feature so the connect screen, restore affordance, and the broader wallet dashboard all match the iOS 1:1 visual contract. NWC setup screen polish - Replace the single-line "Restore previous wallet" pill with the iOS-style "Backup found" card: cloud icon + title row, body copy showing the backup date ("A connection backed up on is available. Restore it to reconnect without pasting a string."), and a full-width orange "Restore connection" button. - Add a "Searching for backup…" indicator that renders while the relay query is in flight, between the subtitle and paste/scan card. Disappears as soon as the state moves to Found / NotFound. - Add a cloud-shield footer line under the paste/scan card: "Connecting saves an encrypted backup to your Nostr relays so you can restore this connection on other devices." - Surface the backup's `created_at` via `NwcRestoreState.Found` so the card can format the date with `DateFormat.MEDIUM`. - Drop the verbose per-relay status-line stream on the NWC setup screen — the Connect button's spinner + "Connecting…" label covers the happy path and any real failure surfaces via `WalletState.Error`. Looked like noise after a one-tap restore. Wallet-mode picker - Spark row renders as a full-bleed orange card with white text + icon and a layered shadow glow (24dp outer halo + 10dp tighter inner, both tinted with the brand accent). Subtitle reads "Self-custody, embedded. Recommended." NWC row keeps its dark surface-variant peer treatment. Spark setup screen - "Use my default wallet" gets the same orange + layered-glow treatment so the nsec-derived recovery path reads as the obvious first choice. - Create / Restore-from-seed / Restore-from-relays collapse under a "More options" disclosure with a chevron that rotates 0→180° via `animateFloatAsState`. `AnimatedVisibility` wraps the inner Column for the expand/collapse transition. Defaults to open when the default-wallet primary isn't visible. Wallet Settings dropdown - `WalletInfoRow` uses a fixed-width label column (`widthIn(min = 110.dp)` + trailing padding) and right-aligns values with `maxLines = 1 + TextOverflow.Ellipsis + TextAlign.End`. Long relay URLs / lightning addresses truncate cleanly; labels align across rows like the iOS settings panel. - Backup-to-relay button is offered for default Spark wallets too — matches iOS, gives users belt-and-braces durability beyond the nsec. - Recoverable wallets (default Spark + NWC) get an iOS-red soft card row with SwapHoriz + "Switch to a different wallet", a section header ("Disconnect Wallet"), and an explanatory caption. Non-default Spark keeps the filled-red Delete CTA because its seed can't be re-derived. - Delete confirmation page unifies on `#FF3B30` across all three flows (switch / disconnect / delete) instead of mixing Material primary orange and `#D32F2F`. Dashboard - Lightning-address pill renders for any wallet mode that carries a `lud16` (Spark via Breez, NWC via parsed URI). Removes the redundant "Nostr Wallet Connect" footer below the balance — the in-page top row already brands the connection. - Recent-transactions list scales with `LocalConfiguration. screenHeightDp` (5 / 4 / 3 / 2 rows by tier) so smaller phones don't crowd out the balance + Send/Receive controls. - `BalanceUnit` toggle (sats / ₿ / ⚡) is wired through the tri-state cycle's SATS branch so the Wallet Settings unit picker actually changes what the dashboard shows. Symbols rendered in `onSurfaceVariant` so the number stays the prominent reading. - Balance container reserves a fixed 96dp min height with vertical- center arrangement so single-line variants (BITCOIN, LIGHTNING, FIAT) don't shift the lightning-address pill or Send / Receive row off their Y as the user cycles modes. Hidden state drops "tap to reveal" — the asterisks make the action obvious. Dependencies - Stacks on top of #561 (NWC connect redesign) and assumes #562 (BalanceUnit tri-state cycle) merges first. The `WalletBalance DisplayMode.kt` source file is included here so this PR builds standalone; the file is identical to #562's and will merge cleanly. --- .../wisp/app/repo/WalletBalanceDisplayMode.kt | 75 ++ .../com/wisp/app/ui/screen/WalletScreen.kt | 722 +++++++++++++----- .../com/wisp/app/viewmodel/WalletViewModel.kt | 26 +- app/src/main/res/values/strings.xml | 12 +- 4 files changed, 629 insertions(+), 206 deletions(-) create mode 100644 app/src/main/kotlin/com/wisp/app/repo/WalletBalanceDisplayMode.kt diff --git a/app/src/main/kotlin/com/wisp/app/repo/WalletBalanceDisplayMode.kt b/app/src/main/kotlin/com/wisp/app/repo/WalletBalanceDisplayMode.kt new file mode 100644 index 00000000..367298d3 --- /dev/null +++ b/app/src/main/kotlin/com/wisp/app/repo/WalletBalanceDisplayMode.kt @@ -0,0 +1,75 @@ +package com.wisp.app.repo + +import android.content.SharedPreferences + +/** + * Tri-state balance display for the wallet dashboard. Tapping the + * balance cycles `SATS → FIAT → HIDDEN → SATS`. Persisted per wallet + * pubkey under the `walletBalanceDisplay_` key in the + * `wisp_settings` SharedPreferences file. + * + * `FIAT` is scoped to the wallet screen — it renders the balance in + * the user's currently-selected fiat currency + * ([FiatPreferences.getCurrency]) but does NOT flip the app-wide + * [FiatPreferences.isFiatMode] flag, so feed timestamps / sat counts + * elsewhere in the app still respect that global setting. + * + * `HIDDEN` masks the dashboard balance AND every per-row amount + fee + * in the transaction history view — useful for screenshots / shoulder- + * surfing scenarios. + * + * Mirrors iOS [feat/wallet-balance-toggle](https://github.com/barrydeen/wisp-ios/pull/166) + * with the same storage-key format so cross-platform agents stay in + * lockstep. Legacy Android global `balance_hidden` Bool is read once + * per pubkey when no per-pubkey entry exists, and the per-pubkey key + * is written from it (true → HIDDEN, false → SATS). + */ +enum class WalletBalanceDisplayMode { + SATS, FIAT, HIDDEN; + + /** Next state in the tap cycle. */ + fun next(): WalletBalanceDisplayMode = when (this) { + SATS -> FIAT + FIAT -> HIDDEN + HIDDEN -> SATS + } + + companion object { + private const val KEY_PREFIX = "walletBalanceDisplay_" + private const val LEGACY_HIDDEN_KEY = "balance_hidden" + + fun storageKey(pubkey: String): String = "$KEY_PREFIX$pubkey" + + /** + * Read the persisted mode for [pubkey]. Falls back to legacy + * global `balance_hidden` Bool for the first read of a given + * pubkey, then writes the migrated value so subsequent reads + * don't depend on the legacy key staying in place. The legacy + * key itself is left untouched — older builds rolled back keep + * the prior preference intact. + * + * When [pubkey] is null (no signed-in account yet), returns + * the legacy global state (SATS / HIDDEN only) without + * touching storage. + */ + fun read(prefs: SharedPreferences, pubkey: String?): WalletBalanceDisplayMode { + if (pubkey.isNullOrBlank()) { + return if (prefs.getBoolean(LEGACY_HIDDEN_KEY, false)) HIDDEN else SATS + } + val key = storageKey(pubkey) + val raw = prefs.getString(key, null) + if (raw != null) { + return values().firstOrNull { it.name.equals(raw, ignoreCase = true) } ?: SATS + } + val initial = if (prefs.getBoolean(LEGACY_HIDDEN_KEY, false)) HIDDEN else SATS + prefs.edit().putString(key, initial.name.lowercase()).apply() + return initial + } + + /** Persist [mode] for [pubkey]. No-op when [pubkey] is null. */ + fun write(prefs: SharedPreferences, pubkey: String?, mode: WalletBalanceDisplayMode) { + if (pubkey.isNullOrBlank()) return + prefs.edit().putString(storageKey(pubkey), mode.name.lowercase()).apply() + } + } +} diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt index bd9483d9..c3e2628b 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt @@ -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 @@ -148,6 +154,7 @@ import com.google.zxing.qrcode.QRCodeWriter import com.wisp.app.BuildConfig import com.wisp.app.R import com.wisp.app.repo.BalanceUnit +import com.wisp.app.repo.WalletBalanceDisplayMode import com.wisp.app.repo.FiatPreferences import com.wisp.app.repo.WalletMode import com.wisp.app.repo.WalletTransaction @@ -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 || @@ -370,6 +378,7 @@ fun WalletScreen( recentTransactions = viewModel.transactions.collectAsState().value, profileLookup = remember(profileKey) { { viewModel.getProfileData(it) } }, nwcNodeAlias = viewModel.nwcNodeAlias.collectAsState().value, + pubkey = viewModel.keyRepo.getPubkeyHex(), modifier = Modifier.padding(padding) ) } @@ -475,6 +484,7 @@ fun WalletScreen( onLoadMore = { viewModel.loadMoreTransactions() }, profileLookup = { viewModel.getProfileData(it) }, profileRefreshKey = profileKey, + pubkey = viewModel.keyRepo.getPubkeyHex(), modifier = Modifier.padding(padding) ) } @@ -588,6 +598,7 @@ fun WalletScreen( recentTransactions = viewModel.transactions.collectAsState().value, profileLookup = remember(profileKey) { { viewModel.getProfileData(it) } }, nwcNodeAlias = viewModel.nwcNodeAlias.collectAsState().value, + pubkey = viewModel.keyRepo.getPubkeyHex(), modifier = Modifier.padding(padding) ) } @@ -675,45 +686,74 @@ private fun WalletConnectionContent( .padding(horizontal = 16.dp) ) - if (nwcRestoreState is NwcRestoreState.Found) { + if (nwcRestoreState is NwcRestoreState.Searching) { Spacer(Modifier.height(16.dp)) - Card( - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = !isConnecting, onClick = onRestoreFromBackup), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer + 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() ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Outlined.CloudDownload, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer - ) - Spacer(Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - stringResource(R.string.wallet_nwc_restore_title), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onPrimaryContainer + 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_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer + stringResource(R.string.wallet_nwc_restore_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface ) } - IconButton(onClick = onDismissRestore, enabled = !isConnecting) { - Icon( - Icons.Filled.Close, - contentDescription = stringResource(R.string.btn_cancel), - tint = MaterialTheme.colorScheme.onPrimaryContainer + 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 ) } } @@ -818,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( @@ -835,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( @@ -867,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)) @@ -957,15 +1010,23 @@ private fun WalletHomeContent( recentTransactions: List = emptyList(), profileLookup: (String) -> com.wisp.app.nostr.ProfileData? = { null }, nwcNodeAlias: String? = null, + pubkey: String? = null, modifier: Modifier = Modifier ) { val balanceSats = balanceMsats / 1000 val context = LocalContext.current val prefs = remember { context.getSharedPreferences("wisp_settings", android.content.Context.MODE_PRIVATE) } - var balanceHidden by remember { mutableStateOf(prefs.getBoolean("balance_hidden", false)) } + // Tri-state balance display (sats / fiat / hidden) — tap the + // dashboard balance to cycle. Per-pubkey storage; migrates the + // legacy global `balance_hidden` Bool on first read for a given + // pubkey. iOS port of feat/wallet-balance-toggle (wisp-ios #166). + var balanceDisplay by remember(pubkey) { + mutableStateOf(WalletBalanceDisplayMode.read(prefs, pubkey)) + } + val balanceHidden = balanceDisplay == WalletBalanceDisplayMode.HIDDEN val fiatPrefs = remember { FiatPreferences.get(context) } val fiatMode by fiatPrefs.fiatMode.collectAsState() - @Suppress("unused_variable") val fiatCurrency by fiatPrefs.currency.collectAsState() + val fiatCurrency by fiatPrefs.currency.collectAsState() val clipboard = remember { context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager } val accent = WispThemeColors.zapColor @@ -1177,52 +1238,145 @@ private fun WalletHomeContent( Spacer(Modifier.weight(1f)) // ── Balance ───────────────────────────────────────────────── + // Tap to cycle sats → fiat → hidden. `fiat` is wallet-screen- + // scoped: it renders the balance in the user's currently-set + // 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 { - balanceHidden = !balanceHidden - prefs.edit().putBoolean("balance_hidden", balanceHidden).apply() - } + verticalArrangement = Arrangement.Center, + modifier = Modifier + .heightIn(min = 96.dp) + .clickable { + balanceDisplay = balanceDisplay.next() + WalletBalanceDisplayMode.write(prefs, pubkey, balanceDisplay) + } ) { - if (balanceHidden) { - Text( - "* * * * *", - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(4.dp)) - Text( - stringResource(R.string.wallet_tap_to_reveal), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } else { - if (fiatMode) { - Text( - AmountFormatter.formatShort(balanceSats, context), - style = MaterialTheme.typography.displayLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - } else { + when (balanceDisplay) { + WalletBalanceDisplayMode.HIDDEN -> { 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 ) } + WalletBalanceDisplayMode.FIAT -> { + val fiat = AmountFormatter.formatFiat(balanceSats, fiatCurrency) + if (fiat != null) { + Text( + fiat, + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } else { + // Exchange-rate cache hasn't loaded yet — fall + // back to the sats display so the dashboard + // doesn't show a blank or a placeholder. + 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 + ) + } + } + WalletBalanceDisplayMode.SATS -> { + // App-wide fiat mode still wins when the user has + // it on AND the wallet display is in its default + // (sats) state — same behaviour as before this + // tri-state landed. + if (fiatMode) { + Text( + AmountFormatter.formatShort(balanceSats, context), + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } else { + // 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 { @@ -1249,7 +1403,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), @@ -1275,28 +1431,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 ───────────────────────────────────────── @@ -1355,8 +1489,21 @@ private fun WalletHomeContent( HorizontalDivider( color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) ) - recentTransactions.take(1).forEach { tx -> - TransactionRow(tx, profileLookup) + // 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) } } } else { @@ -2257,8 +2404,16 @@ private fun TransactionHistoryContent( onLoadMore: () -> Unit = {}, profileLookup: (String) -> com.wisp.app.nostr.ProfileData?, profileRefreshKey: Int = 0, + pubkey: String? = null, modifier: Modifier = Modifier ) { + val context = LocalContext.current + val prefs = remember { context.getSharedPreferences("wisp_settings", android.content.Context.MODE_PRIVATE) } + // Mirror the dashboard's tri-state display mode on tx rows so a + // HIDDEN state masks both the dashboard balance AND every per-row + // amount + fee. iOS port keeps these in lockstep via the same + // per-pubkey storage key (`walletBalanceDisplay_`). + val displayMode = remember(pubkey) { WalletBalanceDisplayMode.read(prefs, pubkey) } Column( modifier = modifier.fillMaxSize() ) { @@ -2313,7 +2468,7 @@ private fun TransactionHistoryContent( else -> { LazyColumn { items(transactions) { tx -> - TransactionRow(tx, profileLookup) + TransactionRow(tx, profileLookup, displayMode) HorizontalDivider( modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant @@ -2349,13 +2504,17 @@ private fun TransactionHistoryContent( @Composable private fun TransactionRow( tx: WalletTransaction, - profileLookup: (String) -> com.wisp.app.nostr.ProfileData? + profileLookup: (String) -> com.wisp.app.nostr.ProfileData?, + displayMode: WalletBalanceDisplayMode = WalletBalanceDisplayMode.SATS ) { val isIncoming = tx.type == "incoming" val amountSats = tx.amountMsats / 1000 val profile = tx.counterpartyPubkey?.let { profileLookup(it) } val ctx = LocalContext.current val fiatMode by FiatPreferences.get(ctx).fiatMode.collectAsState() + val fiatCurrency by FiatPreferences.get(ctx).currency.collectAsState() + val isHidden = displayMode == WalletBalanceDisplayMode.HIDDEN + val isWalletFiat = displayMode == WalletBalanceDisplayMode.FIAT Row( modifier = Modifier @@ -2419,35 +2578,76 @@ private fun TransactionRow( ) } - // Amount + fee + // Amount + fee. In HIDDEN mode every number is masked so a + // "show my wallet without showing the numbers" screenshot + // works. Wallet-screen FIAT mode renders amounts in the user's + // selected fiat currency without flipping the app-wide flag. Column(horizontalAlignment = Alignment.End) { Row(verticalAlignment = Alignment.CenterVertically) { val sign = if (isIncoming) "+" else "-" - if (fiatMode) { - Text( - "$sign${AmountFormatter.formatFull(amountSats, ctx)}", + val signColor = if (isIncoming) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error + when { + isHidden -> Text( + "$sign* * *", style = MaterialTheme.typography.titleMedium, - color = if (isIncoming) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error + color = signColor ) - } else { - Text( - "$sign%,d".format(amountSats), + isWalletFiat -> { + val fiat = AmountFormatter.formatFiat(amountSats, fiatCurrency) + if (fiat != null) { + Text( + "$sign$fiat", + style = MaterialTheme.typography.titleMedium, + color = signColor + ) + } else { + Text( + "$sign%,d".format(amountSats), + style = MaterialTheme.typography.titleMedium, + color = signColor + ) + Spacer(Modifier.width(4.dp)) + Text( + stringResource(R.string.wallet_sats), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + fiatMode -> Text( + "$sign${AmountFormatter.formatFull(amountSats, ctx)}", style = MaterialTheme.typography.titleMedium, - color = if (isIncoming) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error - ) - Spacer(Modifier.width(4.dp)) - Text( - stringResource(R.string.wallet_sats), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = signColor ) + else -> { + Text( + "$sign%,d".format(amountSats), + style = MaterialTheme.typography.titleMedium, + color = signColor + ) + Spacer(Modifier.width(4.dp)) + Text( + stringResource(R.string.wallet_sats), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } if (!isIncoming && tx.feeMsats > 0) { val feeSats = tx.feeMsats / 1000 + val feeText = when { + isHidden -> stringResource(R.string.wallet_fee, 0).replace("0", "***") + isWalletFiat -> { + val fiat = AmountFormatter.formatFiat(feeSats, fiatCurrency) + if (fiat != null) stringResource(R.string.wallet_fee_money, fiat) + else stringResource(R.string.wallet_fee, feeSats) + } + fiatMode -> stringResource(R.string.wallet_fee_money, AmountFormatter.formatFull(feeSats, ctx)) + else -> stringResource(R.string.wallet_fee, feeSats) + } Text( - if (fiatMode) stringResource(R.string.wallet_fee_money, AmountFormatter.formatFull(feeSats, ctx)) - else stringResource(R.string.wallet_fee, feeSats), + feeText, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -2514,11 +2714,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)) @@ -2543,14 +2744,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, @@ -2566,20 +2784,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) ) } @@ -2716,36 +2934,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)) @@ -3272,17 +3538,19 @@ private fun WalletSettingsContent( Text(if (isDefaultWallet) "View Recovery Phrase" else "Backup Recovery Phrase") } - if (!isDefaultWallet) { - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(8.dp)) - OutlinedButton( - onClick = onBackupToRelay, - modifier = Modifier.fillMaxWidth() - ) { - Icon(Icons.Default.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Backup to Nostr Relays") - } + // Relay backup is offered for both default and non-default + // Spark wallets — matches iOS. For default wallets the nsec + // is already the canonical backup; offering relay backup + // here is belt-and-braces for users who want it. + OutlinedButton( + onClick = onBackupToRelay, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Backup to Nostr Relays") } // Relay backup status section (when logged in). Skipped for default @@ -3437,22 +3705,68 @@ private fun WalletSettingsContent( Spacer(Modifier.height(32.dp)) - Button( - onClick = onDeleteWallet, - modifier = Modifier.fillMaxWidth(), - colors = if (isDefaultWallet) ButtonDefaults.buttonColors() - else ButtonDefaults.buttonColors( - containerColor = Color(0xFFD32F2F), - contentColor = Color.White - ) - ) { + // 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( - when { - walletMode == WalletMode.NWC -> "Disconnect" - isDefaultWallet -> stringResource(R.string.wallet_switch_wallet) - else -> "Delete Wallet" + 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("Delete Wallet") + } } // Footer @@ -3799,18 +4113,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)) @@ -3822,7 +4138,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)) @@ -3844,7 +4160,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 ) @@ -3865,11 +4181,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 { @@ -4403,11 +4718,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, @@ -4415,7 +4740,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)) diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt index bf94d133..f1dc7d7b 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt @@ -84,7 +84,7 @@ sealed class AutoCheckState { sealed class NwcRestoreState { object Idle : NwcRestoreState() object Searching : NwcRestoreState() - data class Found(val uri: String) : NwcRestoreState() + data class Found(val uri: String, val createdAt: Long) : NwcRestoreState() object NotFound : NwcRestoreState() } @@ -650,7 +650,7 @@ class WalletViewModel( if (active != null && active.trim() == uri.trim()) { _nwcRestoreState.value = NwcRestoreState.NotFound } else { - _nwcRestoreState.value = NwcRestoreState.Found(uri) + _nwcRestoreState.value = NwcRestoreState.Found(uri, newest.created_at) } } } catch (_: Exception) { @@ -844,6 +844,18 @@ class WalletViewModel( 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 + } } } } @@ -901,16 +913,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 } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 09e6e990..fa6653bb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -732,12 +732,17 @@ Send and receive Lightning payments, and zap anyone on Nostr. Spark wallet Self-custody, embedded. Use your default wallet or restore from seed/relays. + Self-custody, embedded. Recommended. Nostr Wallet Connect Paste a connection string from Alby, Zeus, Rizful, Minibits, etc. Paste the connection string from your NWC-compatible wallet. Connection string starts with nostr+walletconnect:// - Restore previous wallet + Backup found + Searching for backup… Encrypted NWC backup from another device + A connection backed up on %1$s is available. Restore it to reconnect without pasting a string. + Restore connection + Connecting saves an encrypted backup to your Nostr relays so you can restore this connection on other devices. Self-custodial Lightning, powered by Spark and Breez. Create new wallet Generate a fresh 12-word seed phrase @@ -891,7 +896,10 @@ Generate invoice Use my default wallet Derived from your Nostr key — no extra backup needed. - Switch Wallet + More options + Switch to a different wallet 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. + Disconnect Wallet + Your default wallet is linked to your key and can always be restored. Switching connects a different wallet instead. 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. From 29d8c9fcb1178a64ecbffc1a44266183fd1c75bf Mon Sep 17 00:00:00 2001 From: The Daniel Date: Tue, 26 May 2026 07:47:50 -0400 Subject: [PATCH 3/3] feat(wallet): hide relay backup for default Spark wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default Spark wallet derives from the user's nsec, so the nsec already serves as the cross-device backup — the "Backup to Nostr Relays" affordance is redundant and noisy alongside the equivalent nsec-based recovery path. Recovery Phrase view is unchanged. Hides the relay backup button + status section for default wallets; non-default wallets keep both, since they have no nsec-derived fallback. --- .../com/wisp/app/ui/screen/WalletScreen.kt | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt index c3e2628b..6c90c49f 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt @@ -3538,19 +3538,20 @@ private fun WalletSettingsContent( Text(if (isDefaultWallet) "View Recovery Phrase" else "Backup Recovery Phrase") } - Spacer(Modifier.height(8.dp)) + // 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)) - // Relay backup is offered for both default and non-default - // Spark wallets — matches iOS. For default wallets the nsec - // is already the canonical backup; offering relay backup - // here is belt-and-braces for users who want it. - OutlinedButton( - onClick = onBackupToRelay, - modifier = Modifier.fillMaxWidth() - ) { - Icon(Icons.Default.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Backup to Nostr Relays") + OutlinedButton( + onClick = onBackupToRelay, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Backup to Nostr Relays") + } } // Relay backup status section (when logged in). Skipped for default