diff --git a/WALLET_PARITY.md b/WALLET_PARITY.md
index 7810fd30..08d73af6 100644
--- a/WALLET_PARITY.md
+++ b/WALLET_PARITY.md
@@ -772,25 +772,25 @@ existing wallet have no obvious entry point.
**Restructure per §2.6**:
-- [ ] Replace the top-level layout with the two-row picker (Spark /
+- [x] Replace the top-level layout with the two-row picker (Spark /
NWC) described in §2.6 Screen 1.
-- [ ] Add a Spark sub-screen matching §2.6 Screen 2 with four option
+- [x] Add a Spark sub-screen matching §2.6 Screen 2 with four option
rows (or three when `hasKeypair() == false`).
-- [ ] Move the `maybeAutoCreateDefaultWallet()` entry point to the new
+- [x] Move the `maybeAutoCreateDefaultWallet()` entry point to the new
"Use my default wallet" row at the top of the Spark sub-screen.
Ignore `skipAutoCreate` on explicit tap.
-- [ ] Add the string resources from §2.6 to `strings.xml`.
-- [ ] Verify the existing flows still wire through:
+- [x] Add the string resources from §2.6 to `strings.xml`.
+- [x] Verify the existing flows still wire through:
- Create new wallet → existing BIP39-generate + confirm-backup flow
- Restore from seed phrase → existing 12-word entry flow
- Restore from relays → existing NIP-78 backup search flow
- Nostr Wallet Connect → existing NWC paste-string flow
-- [ ] Disconnect flow on a default wallet says **"Switch Wallet"** and
+- [x] Disconnect flow on a default wallet says **"Switch Wallet"** and
the body copy refers to the wallet as your *default wallet* —
never "Wisp wallet" or "wisp wallet".
-- [ ] Settings section header renamed from "Danger Zone" to
+- [x] Settings section header renamed from "Danger Zone" to
**"Disconnect Wallet"** (per §4.8).
-- [ ] Dashboard welcome banner for default wallets per §3.5 (blue/accent
+- [x] Dashboard welcome banner for default wallets per §3.5 (blue/accent
tint, key icon, "secured by your key" copy) — separate from the
existing amber warning banner for custom wallets.
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 a6f1f93d..3d481358 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
@@ -51,6 +51,7 @@ 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
@@ -124,6 +125,7 @@ import androidx.compose.ui.draw.scale
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
@@ -787,6 +789,50 @@ private fun WalletConnectionContent(
)
}
+ if (showScanner) {
+ // Dialog overlay matches the iOS "Scan QR" button on the NWC
+ // entry sheet — opens the camera in-place, on success closes
+ // the dialog and seeds the connection-string field. Stripping
+ // `nostr+walletconnect://` is intentionally left to the scan
+ // value: many wallet apps QR-encode the full URI, and that's
+ // what the connect step expects.
+ androidx.compose.ui.window.Dialog(
+ onDismissRequest = { showScanner = false }
+ ) {
+ androidx.compose.material3.Surface(
+ shape = RoundedCornerShape(16.dp),
+ color = MaterialTheme.colorScheme.surface,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ stringResource(R.string.wallet_scan_qr_code),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(Modifier.height(12.dp))
+ com.wisp.app.ui.component.QrScanner(
+ onResult = { value ->
+ showScanner = false
+ onConnectionStringChange(value.trim())
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(320.dp),
+ promptText = stringResource(R.string.wallet_point_camera)
+ )
+ Spacer(Modifier.height(12.dp))
+ TextButton(onClick = { showScanner = false }) {
+ Text(stringResource(R.string.btn_cancel))
+ }
+ }
+ }
+ }
+ }
+
if (walletState is WalletState.Error) {
Spacer(Modifier.height(8.dp))
Text(
@@ -1221,7 +1267,13 @@ private fun WalletHomeContent(
}
// ── 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 +1300,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 +1328,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 +1386,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)
}
}
@@ -3324,17 +3369,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
@@ -3489,22 +3536,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
@@ -3851,18 +3944,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 accent = 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(accent.copy(alpha = 0.1f), CircleShape)
.padding(16.dp),
- tint = if (isDefault) MaterialTheme.colorScheme.primary else Color(0xFFD32F2F)
+ tint = accent
)
Spacer(Modifier.height(24.dp))
@@ -3874,7 +3969,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 accent
)
Spacer(Modifier.height(16.dp))
@@ -3896,7 +3991,7 @@ private fun DeleteWalletConfirmContent(
Text(
"Make sure you have backed up your recovery phrase before proceeding.",
style = MaterialTheme.typography.bodyMedium,
- color = Color(0xFFD32F2F),
+ color = accent,
textAlign = TextAlign.Center
)
@@ -3917,11 +4012,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 = accent,
+ contentColor = Color.White
+ )
) {
Text(
when {
@@ -4455,11 +4549,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,
@@ -4467,7 +4571,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 1aa20571..79b4fec0 100644
--- a/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt
+++ b/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt
@@ -761,6 +761,18 @@ class WalletViewModel(
if (provider === nwcRepo) {
launch { nwcRepo.fetchNodeInfo() }
}
+ // 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 +830,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 565d7414..35b44841 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -889,7 +889,9 @@
Generate invoice
Use my default wallet
Derived from your Nostr key — no extra backup needed.
- Switch Wallet
+ 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.