diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/screen/WalletScreen.kt b/app/src/main/kotlin/com/darkwisp/app/ui/screen/WalletScreen.kt
index 35a2ec9..20b79f0 100644
--- a/app/src/main/kotlin/com/darkwisp/app/ui/screen/WalletScreen.kt
+++ b/app/src/main/kotlin/com/darkwisp/app/ui/screen/WalletScreen.kt
@@ -71,6 +71,7 @@ import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VpnKey
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material3.Surface
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
@@ -516,6 +517,7 @@ fun WalletScreen(
onAmountChange = { viewModel.setReceiveAmount(it) },
onGenerate = { sats, note, expirySecs -> viewModel.generateInvoice(sats, note, expirySecs) },
onLoadDepositAddress = { viewModel.loadDepositAddress() },
+ onShowAddressQR = { viewModel.navigateTo(WalletPage.LightningAddressQR) },
modifier = Modifier.padding(padding)
)
is WalletPage.ReceiveInvoice -> {
@@ -1778,46 +1780,60 @@ private fun SendInputContent(
}
}
+ val accent = WispThemeColors.zapColor
+ val fieldShape = RoundedCornerShape(14.dp)
+ val fieldBg = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
+ val hint = if (walletMode == WalletMode.SPARK) {
+ stringResource(R.string.wallet_send_input_hint_onchain)
+ } else {
+ stringResource(R.string.wallet_lightning_address_invoice)
+ }
+
Column(
modifier = modifier
.fillMaxSize()
- .padding(horizontal = 16.dp)
- .verticalScroll(rememberScrollState()),
- horizontalAlignment = Alignment.CenterHorizontally
+ .padding(horizontal = 20.dp)
+ .verticalScroll(rememberScrollState())
) {
- Spacer(Modifier.height(32.dp))
+ Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.wallet_send),
- style = MaterialTheme.typography.headlineMedium,
- color = MaterialTheme.colorScheme.onSurface
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
)
- Spacer(Modifier.height(24.dp))
+ Spacer(Modifier.height(20.dp))
- OutlinedTextField(
- value = input,
- onValueChange = onInputChange,
- label = { Text(stringResource(R.string.wallet_lightning_address_invoice)) },
- placeholder = {
- Text(
- if (walletMode == WalletMode.SPARK)
- stringResource(R.string.placeholder_send_spark)
- else
- stringResource(R.string.placeholder_user_domain)
- )
- },
- modifier = Modifier.fillMaxWidth(),
- singleLine = false,
- maxLines = 4,
- trailingIcon = {
- IconButton(onClick = {
- clipboardManager.getText()?.text?.let { onInputChange(it) }
- }) {
- Icon(Icons.Default.ContentPaste, contentDescription = stringResource(R.string.wallet_paste))
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 120.dp)
+ .background(fieldBg, fieldShape)
+ .padding(16.dp)
+ ) {
+ val entryStyle = MaterialTheme.typography.bodyLarge.copy(
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ BasicTextField(
+ value = input,
+ onValueChange = { new ->
+ if (!com.darkwisp.app.ui.component.NsecPasteGuard.blockIfNsec(input, new)) onInputChange(new)
+ },
+ textStyle = entryStyle,
+ cursorBrush = androidx.compose.ui.graphics.SolidColor(accent),
+ modifier = Modifier.fillMaxWidth(),
+ decorationBox = { inner ->
+ if (input.isEmpty()) {
+ Text(hint, style = entryStyle.copy(color = MaterialTheme.colorScheme.onSurfaceVariant))
+ }
+ inner()
}
- }
- )
+ )
+ }
if (error != null) {
Spacer(Modifier.height(8.dp))
@@ -1828,51 +1844,79 @@ private fun SendInputContent(
)
}
- Spacer(Modifier.height(24.dp))
+ Spacer(Modifier.height(16.dp))
- // Scan QR / Import from Gallery buttons
Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(12.dp)
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(fieldBg, fieldShape)
) {
- OutlinedButton(
+ SendInputAction(
+ icon = Icons.Default.QrCode,
+ label = stringResource(R.string.wallet_scan_qr),
onClick = onScanQR,
modifier = Modifier.weight(1f)
- ) {
- Icon(
- Icons.Default.QrCode,
- contentDescription = null,
- modifier = Modifier.size(18.dp)
- )
- Spacer(Modifier.width(8.dp))
- Text(stringResource(R.string.wallet_scan_qr))
- }
- OutlinedButton(
+ )
+ VerticalDivider(modifier = Modifier.height(24.dp))
+ SendInputAction(
+ icon = Icons.Default.ContentPaste,
+ label = stringResource(R.string.wallet_paste),
+ onClick = {
+ clipboardManager.getText()?.text?.let { new ->
+ if (!com.darkwisp.app.ui.component.NsecPasteGuard.blockIfNsec(input, new)) onInputChange(new)
+ }
+ },
+ modifier = Modifier.weight(1f)
+ )
+ VerticalDivider(modifier = Modifier.height(24.dp))
+ SendInputAction(
+ icon = Icons.Default.Image,
+ label = stringResource(R.string.wallet_gallery),
onClick = { galleryLauncher.launch("image/*") },
modifier = Modifier.weight(1f)
- ) {
- Icon(
- Icons.Default.Image,
- contentDescription = null,
- modifier = Modifier.size(18.dp)
- )
- Spacer(Modifier.width(8.dp))
- Text(stringResource(R.string.wallet_gallery))
- }
+ )
}
- Spacer(Modifier.height(16.dp))
+ Spacer(Modifier.height(24.dp))
Button(
onClick = onNext,
- modifier = Modifier.fillMaxWidth(),
- enabled = input.isNotBlank()
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(52.dp),
+ shape = fieldShape,
+ enabled = input.isNotBlank(),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = accent,
+ contentColor = Color.White,
+ disabledContainerColor = fieldBg,
+ disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant
+ )
) {
- Text(stringResource(R.string.wallet_next))
+ Text(stringResource(R.string.wallet_next), fontWeight = FontWeight.SemiBold)
}
}
}
+@Composable
+private fun SendInputAction(
+ icon: ImageVector,
+ label: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ TextButton(
+ onClick = onClick,
+ modifier = modifier,
+ colors = ButtonDefaults.textButtonColors(contentColor = WispThemeColors.zapColor)
+ ) {
+ Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(6.dp))
+ Text(label, fontWeight = FontWeight.Medium, maxLines = 1)
+ }
+}
+
// --- QR Scanner ---
@Composable
@@ -2262,6 +2306,7 @@ private fun ReceiveAmountContent(
onAmountChange: (String) -> Unit,
onGenerate: (Long, String, Int) -> Unit,
onLoadDepositAddress: () -> Unit,
+ onShowAddressQR: () -> Unit = {},
modifier: Modifier = Modifier
) {
val receiveCtx = LocalContext.current
@@ -2564,7 +2609,7 @@ private fun ReceiveAmountContent(
textAlign = TextAlign.Center
)
Spacer(Modifier.height(12.dp))
- LightningAddressReceiveRow(address = lightningAddress)
+ LightningAddressReceiveRow(address = lightningAddress, onShowQR = onShowAddressQR)
}
}
@@ -2574,54 +2619,64 @@ private fun ReceiveAmountContent(
// Lightning address displayed below the invoice form
@Composable
-private fun LightningAddressReceiveRow(address: String) {
+private fun LightningAddressReceiveRow(
+ address: String,
+ onShowQR: () -> Unit
+) {
val clipboardManager = LocalClipboardManager.current
- val context = LocalContext.current
- val actionShape = RoundedCornerShape(14.dp)
- val actionBg = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
-
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .background(actionBg, actionShape)
- .padding(horizontal = 16.dp, vertical = 12.dp)
- ) {
- Text(
- address,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurface,
- textAlign = TextAlign.Center,
- modifier = Modifier.fillMaxWidth()
- )
- Spacer(Modifier.height(10.dp))
+ Column {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ HorizontalDivider(modifier = Modifier.weight(1f))
+ Text(
+ stringResource(R.string.wallet_receive_via_address),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 12.dp)
+ )
+ HorizontalDivider(modifier = Modifier.weight(1f))
+ }
+ Spacer(Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
+ RoundedCornerShape(14.dp)
+ )
+ .padding(start = 16.dp, end = 6.dp, top = 6.dp, bottom = 6.dp)
) {
- TextButton(
- onClick = { clipboardManager.setText(AnnotatedString(address)) },
- modifier = Modifier.weight(1f),
- colors = ButtonDefaults.textButtonColors(contentColor = WispThemeColors.zapColor)
- ) {
- Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(16.dp))
- Spacer(Modifier.width(4.dp))
- Text(stringResource(R.string.wallet_copy_address), fontWeight = FontWeight.Medium, style = MaterialTheme.typography.labelMedium)
+ Icon(
+ Icons.Outlined.Bolt,
+ contentDescription = null,
+ tint = WispThemeColors.zapColor,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(Modifier.width(10.dp))
+ Text(
+ address,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f)
+ )
+ IconButton(onClick = onShowQR) {
+ Icon(
+ Icons.Default.QrCode,
+ contentDescription = stringResource(R.string.wallet_receive_address_tab),
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
}
- VerticalDivider(modifier = Modifier.height(20.dp))
- TextButton(
- onClick = {
- val intent = Intent(Intent.ACTION_SEND).apply {
- type = "text/plain"
- putExtra(Intent.EXTRA_TEXT, address)
- }
- context.startActivity(Intent.createChooser(intent, context.getString(R.string.wallet_share_address)))
- },
- modifier = Modifier.weight(1f),
- colors = ButtonDefaults.textButtonColors(contentColor = WispThemeColors.zapColor)
- ) {
- Icon(Icons.Default.Share, contentDescription = null, modifier = Modifier.size(16.dp))
- Spacer(Modifier.width(4.dp))
- Text(stringResource(R.string.wallet_share), fontWeight = FontWeight.Medium, style = MaterialTheme.typography.labelMedium)
+ IconButton(onClick = { clipboardManager.setText(AnnotatedString(address)) }) {
+ Icon(
+ Icons.Default.ContentCopy,
+ contentDescription = stringResource(R.string.cd_copy_invoice),
+ modifier = Modifier.size(18.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
}
}
}
@@ -2775,10 +2830,7 @@ private fun OnchainSendAmountContent(
) {
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
- IconButton(onClick = onBack) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.btn_back))
- }
- Icon(Icons.Default.ArrowUpward, contentDescription = null, tint = accent, modifier = Modifier.size(20.dp))
+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, tint = accent, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.wallet_onchain_send_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
}
@@ -2885,7 +2937,7 @@ private fun OnchainSendAmountContent(
shape = fieldShape,
colors = ButtonDefaults.buttonColors(containerColor = accent, contentColor = Color.White)
) {
- Icon(Icons.Default.ArrowUpward, contentDescription = null, modifier = Modifier.size(18.dp))
+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.wallet_onchain_continue), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
}
@@ -2975,10 +3027,7 @@ private fun OnchainSendConfirmContent(
) {
Spacer(Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
- IconButton(onClick = onBack) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.btn_back))
- }
- Icon(Icons.Default.ArrowUpward, contentDescription = null, tint = accent, modifier = Modifier.size(20.dp))
+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, tint = accent, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.wallet_onchain_send_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
}
@@ -3050,7 +3099,7 @@ private fun OnchainSendConfirmContent(
shape = fieldShape,
colors = ButtonDefaults.buttonColors(containerColor = accent, contentColor = Color.White)
) {
- Icon(Icons.Default.ArrowUpward, contentDescription = null, modifier = Modifier.size(18.dp))
+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.wallet_onchain_send_confirm), fontWeight = FontWeight.SemiBold)
}
@@ -3437,7 +3486,7 @@ private fun TransactionHistoryContent(
// both render. The list is deduped by (paymentHash, type)
// upstream, so these keys are unique.
items(transactions, key = { "${it.paymentHash}|${it.type}" }) { tx ->
- TransactionRow(tx, profileLookup, displayMode)
+ TransactionRow(tx, profileLookup, displayMode, expandable = true)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant
@@ -3474,20 +3523,25 @@ private fun TransactionHistoryContent(
private fun TransactionRow(
tx: WalletTransaction,
profileLookup: (String) -> com.darkwisp.app.nostr.ProfileData?,
- displayMode: WalletBalanceDisplayMode = WalletBalanceDisplayMode.SATS
+ displayMode: WalletBalanceDisplayMode = WalletBalanceDisplayMode.SATS,
+ expandable: Boolean = false
) {
val isIncoming = tx.type == "incoming"
val amountSats = tx.amountMsats / 1000
val profile = tx.counterpartyPubkey?.let { profileLookup(it) }
val ctx = LocalContext.current
+ val clipboardManager = LocalClipboardManager.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
+ var expanded by remember { mutableStateOf(false) }
+ Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
+ .then(if (expandable) Modifier.clickable { expanded = !expanded } else Modifier)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -3540,11 +3594,26 @@ private fun TransactionRow(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
- Text(
- formatRelativeTime(tx.settledAt ?: tx.createdAt),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (tx.pending) {
+ Text(
+ stringResource(R.string.wallet_tx_pending),
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Medium,
+ color = WispThemeColors.zapColor
+ )
+ Text(
+ " · ",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Text(
+ formatRelativeTime(tx.settledAt ?: tx.createdAt),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
}
// Amount + fee. In HIDDEN mode every number is masked so a
@@ -3622,6 +3691,122 @@ private fun TransactionRow(
)
}
}
+
+ if (expandable) {
+ Spacer(Modifier.width(4.dp))
+ Icon(
+ if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+
+ if (expandable) {
+ AnimatedVisibility(visible = expanded) {
+ TransactionDetailPanel(
+ tx = tx,
+ onCopy = { clipboardManager.setText(AnnotatedString(it)) }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TransactionDetailPanel(
+ tx: WalletTransaction,
+ onCopy: (String) -> Unit
+) {
+ val context = LocalContext.current
+ val sats = tx.amountMsats / 1000
+ val feeSats = tx.feeMsats / 1000
+ val fullDate = remember(tx.settledAt, tx.createdAt) {
+ val secs = tx.settledAt ?: tx.createdAt
+ java.text.SimpleDateFormat("MMM d, yyyy · h:mm a", java.util.Locale.getDefault())
+ .format(java.util.Date(secs * 1000))
+ }
+ val note = tx.description?.takeIf {
+ it.isNotBlank() && it != "null" && !it.trimStart().startsWith("{")
+ }
+ val idLabel = if (tx.isOnchain) "Transaction ID" else "Payment hash"
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, bottom = 12.dp)
+ .background(
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
+ RoundedCornerShape(12.dp)
+ )
+ .padding(14.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ TxDetailRow("Status", if (tx.pending) "Pending" else "Completed")
+ TxDetailRow("Type", if (tx.isOnchain) "On-chain" else "Lightning")
+ TxDetailRow("Amount", "%,d sats".format(sats))
+ if (tx.feeMsats > 0) TxDetailRow("Network fee", "%,d sats".format(feeSats))
+ TxDetailRow("Date", fullDate)
+ if (note != null) TxDetailRow("Note", note)
+ TxDetailRow(idLabel, tx.paymentHash, mono = true, onCopy = { onCopy(tx.paymentHash) })
+ if (tx.isOnchain) {
+ TextButton(
+ onClick = {
+ context.startActivity(
+ android.content.Intent(
+ android.content.Intent.ACTION_VIEW,
+ android.net.Uri.parse("https://mempool.space/tx/${tx.paymentHash}")
+ )
+ )
+ },
+ contentPadding = PaddingValues(0.dp),
+ colors = ButtonDefaults.textButtonColors(contentColor = WispThemeColors.zapColor)
+ ) {
+ Icon(Icons.Default.Share, contentDescription = null, modifier = Modifier.size(16.dp))
+ Spacer(Modifier.width(6.dp))
+ Text("View on mempool.space", style = MaterialTheme.typography.labelLarge)
+ }
+ }
+ }
+}
+
+@Composable
+private fun TxDetailRow(
+ label: String,
+ value: String,
+ mono: Boolean = false,
+ onCopy: (() -> Unit)? = null
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.Top
+ ) {
+ Text(
+ label,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.width(110.dp)
+ )
+ Spacer(Modifier.width(12.dp))
+ Text(
+ value,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = if (mono) FontFamily.Monospace else null,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f)
+ )
+ if (onCopy != null) {
+ Spacer(Modifier.width(6.dp))
+ Icon(
+ Icons.Default.ContentCopy,
+ contentDescription = stringResource(R.string.action_copy),
+ tint = WispThemeColors.zapColor,
+ modifier = Modifier
+ .size(18.dp)
+ .clickable(onClick = onCopy)
+ )
+ }
}
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7f08549..96d06cf 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -812,6 +812,7 @@
View on mempool.space
Lightning invoice or Bitcoin address
+ Lightning or Bitcoin address, invoice, or CLINK offer
Retry
Cancel
@@ -901,6 +902,8 @@
Invite codes
Generate invite code
Latest invite code
+ Copy
+ Pending
Copy code
Copy invite link
Assign role