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.