feat(wallet): dashboard polish — top-5 transactions, switch-wallet flow, drop relay backup status list (port wisp #571, #572)

- Dashboard footer lists the 5 most recent transactions instead of 1
- Delete wallet replaced by a switch-wallet flow: disconnect to use the
  default wallet or restore another; copy clarifies funds stay safe
- NWC dashboard parity + wallet settings cleanup
- Spark settings no longer render the per-relay backup status list
This commit is contained in:
The Daniel
2026-06-12 12:52:34 -04:00
parent c030accc29
commit d8dd5f390f
3 changed files with 196 additions and 199 deletions
@@ -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
@@ -126,6 +127,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
@@ -829,6 +831,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.darkwisp.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(
@@ -1263,7 +1309,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 {
@@ -1290,7 +1342,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),
@@ -1316,28 +1370,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 ─────────────────────────────────────────
@@ -1396,7 +1428,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)
}
}
@@ -3391,150 +3436,28 @@ 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
// wallets — the nsec already serves as their backup.
if (isLoggedIn && !isDefaultWallet) {
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Relay Backup Status",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface
)
IconButton(
onClick = onCheckRelayBackups,
enabled = !relayBackupCheckLoading
) {
if (relayBackupCheckLoading) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp
)
} else {
Icon(
Icons.Default.Refresh,
contentDescription = "Check relay backups",
modifier = Modifier.size(20.dp)
)
}
}
}
if (relayBackupStatuses.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(12.dp)) {
relayBackupStatuses.forEach { info ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(8.dp)
.background(
if (info.hasBackup) Color(0xFF4CAF50) else Color(0xFF9E9E9E),
CircleShape
)
)
Spacer(Modifier.width(8.dp))
Text(
info.relayUrl.removePrefix("wss://").removePrefix("ws://"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
Spacer(Modifier.height(8.dp))
var showDeleteDialog by remember { mutableStateOf(false) }
TextButton(
onClick = { showDeleteDialog = true },
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFD32F2F)),
enabled = deleteBackupStatus !is DeleteBackupStatus.InProgress
) {
if (deleteBackupStatus is DeleteBackupStatus.InProgress) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = Color(0xFFD32F2F)
)
Spacer(Modifier.width(8.dp))
}
Text("Delete Relay Backup")
}
if (deleteBackupStatus is DeleteBackupStatus.Success) {
Text(
"Backup deleted",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
} else if (deleteBackupStatus is DeleteBackupStatus.Error) {
Text(
deleteBackupStatus.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Delete Relay Backup?") },
text = {
Text("This will delete your wallet backup from your relays. Make sure you have your recovery phrase saved elsewhere.")
},
confirmButton = {
TextButton(
onClick = {
showDeleteDialog = false
onDeleteRelayBackup()
},
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFD32F2F))
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Cancel")
}
}
)
}
}
}
// Relay backup status list intentionally omitted — iOS doesn't
// surface a per-relay backup display, and the majority of users
// will use the default Spark wallet (which doesn't need relay
// backup since the nsec is the canonical backup). Non-default
// wallets can still tap "Backup to Nostr Relays" above; the
// status / delete plumbing in WalletViewModel stays alive in
// case we want to surface it again later.
}
// Disclaimer
@@ -3556,22 +3479,68 @@ private fun WalletSettingsContent(
Spacer(Modifier.height(32.dp))
Button(
onClick = onDeleteWallet,
modifier = Modifier.fillMaxWidth(),
colors = if (isDefaultWallet) ButtonDefaults.buttonColors()
else ButtonDefaults.buttonColors(
// iOS treats both the default-Spark "Switch" and the NWC
// "Disconnect" cases as quiet, recoverable affordances — a
// card row with red text + swap icon plus a caption. Only the
// truly destructive Delete (non-default Spark whose seed can't
// be re-derived from nsec) keeps the filled-red CTA so the user
// notices the irreversibility.
val isRecoverable = walletMode == WalletMode.NWC || isDefaultWallet
if (isRecoverable) {
Text(
stringResource(R.string.wallet_disconnect_section),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(start = 4.dp, bottom = 6.dp)
)
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onDeleteWallet),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.SwapHoriz,
contentDescription = null,
tint = Color(0xFFFF3B30),
modifier = Modifier.size(22.dp)
)
Spacer(Modifier.width(12.dp))
Text(
stringResource(R.string.wallet_switch_wallet),
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFFFF3B30)
)
}
}
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.wallet_switch_wallet_caption),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp)
)
} else {
Button(
onClick = onDeleteWallet,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFD32F2F),
contentColor = Color.White
)
) {
Text(
when {
walletMode == WalletMode.NWC -> "Disconnect"
isDefaultWallet -> stringResource(R.string.wallet_switch_wallet)
else -> "Delete Wallet"
}
)
) {
Text("Delete Wallet")
}
}
// Footer
@@ -3918,18 +3887,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))
@@ -3941,7 +3912,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))
@@ -3963,7 +3934,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
)
@@ -3984,11 +3955,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 {
@@ -4723,11 +4693,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,
@@ -4735,7 +4715,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))
@@ -880,6 +880,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
}
}
}
}
@@ -937,16 +949,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
}
+3 -1
View File
@@ -868,7 +868,9 @@
<string name="wallet_generate_invoice">Generate invoice</string>
<string name="wallet_use_default">Use my default wallet</string>
<string name="wallet_default_subtitle">Derived from your Nostr key — no extra backup needed.</string>
<string name="wallet_switch_wallet">Switch Wallet</string>
<string name="wallet_switch_wallet">Switch to a different wallet</string>
<string name="wallet_switch_wallet_body">Disconnect this wallet so you can use your default wallet or restore a different one. Your funds stay safe — you can reconnect this wallet anytime by entering its recovery phrase.</string>
<string name="wallet_disconnect_section">Disconnect Wallet</string>
<string name="wallet_switch_wallet_caption">Your default wallet is linked to your key and can always be restored. Switching connects a different wallet instead.</string>
<string name="wallet_default_seed_info">This wallet is derived from your Nostr key, so the phrase below is just for export to other wallet apps. You don\'t need to back it up — your nsec is the backup.</string>
</resources>