diff --git a/app/src/main/kotlin/com/wisp/app/nostr/Nip47.kt b/app/src/main/kotlin/com/wisp/app/nostr/Nip47.kt index b5057cf5..227e11bb 100644 --- a/app/src/main/kotlin/com/wisp/app/nostr/Nip47.kt +++ b/app/src/main/kotlin/com/wisp/app/nostr/Nip47.kt @@ -49,7 +49,7 @@ object Nip47 { object GetBalance : NwcRequest() object GetInfo : NwcRequest() data class PayInvoice(val invoice: String) : NwcRequest() - data class MakeInvoice(val amountMsats: Long, val description: String) : NwcRequest() + data class MakeInvoice(val amountMsats: Long, val description: String, val expiry: Int? = null) : NwcRequest() data class ListTransactions(val limit: Int = 50, val offset: Int = 0) : NwcRequest() } @@ -172,6 +172,7 @@ object Nip47 { put("params", buildJsonObject { put("amount", request.amountMsats) put("description", request.description) + request.expiry?.let { put("expiry", it) } }) } is NwcRequest.ListTransactions -> buildJsonObject { diff --git a/app/src/main/kotlin/com/wisp/app/repo/NwcRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/NwcRepository.kt index 621e840e..e2bfc485 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/NwcRepository.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/NwcRepository.kt @@ -57,6 +57,9 @@ class NwcRepository(private val context: Context, private val relayPool: RelayPo private val _paymentReceived = MutableSharedFlow(extraBufferCapacity = 8) override val paymentReceived: SharedFlow = _paymentReceived + private val _transactionsChanged = MutableSharedFlow(extraBufferCapacity = 8) + override val transactionsChanged: SharedFlow = _transactionsChanged + private fun emitStatus(msg: String) { Log.d(TAG, msg) _statusLog.tryEmit(msg) @@ -346,8 +349,8 @@ class NwcRepository(private val context: Context, private val relayPool: RelayPo return result.map { (it as Nip47.NwcResponse.PayInvoiceResult).preimage } } - override suspend fun makeInvoice(amountMsats: Long, description: String): Result { - val result = sendRequest(Nip47.NwcRequest.MakeInvoice(amountMsats, description)) + override suspend fun makeInvoice(amountMsats: Long, description: String, expirySecs: Int): Result { + val result = sendRequest(Nip47.NwcRequest.MakeInvoice(amountMsats, description, expirySecs)) return result.map { (it as Nip47.NwcResponse.MakeInvoiceResult).invoice } } diff --git a/app/src/main/kotlin/com/wisp/app/repo/SparkRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/SparkRepository.kt index 84ae5c52..8793c891 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/SparkRepository.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/SparkRepository.kt @@ -5,10 +5,13 @@ import android.util.Log import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import breez_sdk_spark.CheckLightningAddressRequest +import breez_sdk_spark.ClaimDepositRequest import breez_sdk_spark.ConnectRequest +import breez_sdk_spark.DepositInfo import breez_sdk_spark.EventListener import breez_sdk_spark.GetInfoRequest import breez_sdk_spark.ListPaymentsRequest +import breez_sdk_spark.MaxFee import breez_sdk_spark.Network import breez_sdk_spark.PaymentDetails import breez_sdk_spark.PaymentType @@ -101,6 +104,9 @@ class SparkRepository( private val _paymentReceived = MutableSharedFlow(extraBufferCapacity = 8) override val paymentReceived: SharedFlow = _paymentReceived + private val _transactionsChanged = MutableSharedFlow(extraBufferCapacity = 8) + override val transactionsChanged: SharedFlow = _transactionsChanged + // Identity pubkey from the SDK's GetInfoResponse — exposed for the // Wallet Info expandable in settings. Populated on first balance fetch // after connect. @@ -309,15 +315,22 @@ class SparkRepository( is SdkEvent.PaymentSucceeded -> { emitStatus("Payment succeeded") refreshBalanceInternal() + _transactionsChanged.tryEmit(Unit) if (e.payment.paymentType == PaymentType.RECEIVE) { _paymentReceived.tryEmit(e.payment.amount.toLong() * 1000) } } is SdkEvent.PaymentFailed -> { emitStatus("Payment failed") + _transactionsChanged.tryEmit(Unit) } is SdkEvent.PaymentPending -> { emitStatus("Payment pending") + refreshBalanceInternal() + _transactionsChanged.tryEmit(Unit) + } + is SdkEvent.UnclaimedDeposits -> { + claimDeposits(e.unclaimedDeposits) } else -> {} } @@ -329,6 +342,7 @@ class SparkRepository( emitStatus("Connected to Spark") refreshBalanceInternal() + claimPendingDeposits() } catch (e: Exception) { emitStatus("Connection failed: ${e.message}") Log.e(TAG, "Spark connect failed", e) @@ -467,7 +481,7 @@ class SparkRepository( // --- Receive --- - override suspend fun makeInvoice(amountMsats: Long, description: String): Result = + override suspend fun makeInvoice(amountMsats: Long, description: String, expirySecs: Int): Result = withContext(Dispatchers.IO) { try { val instance = sdk ?: return@withContext Result.failure(Exception("Not connected")) @@ -477,7 +491,7 @@ class SparkRepository( val method = ReceivePaymentMethod.Bolt11Invoice( description = description.ifEmpty { "Wisp wallet" }, amountSats = amountSats, - expirySecs = 3600u, + expirySecs = expirySecs.toUInt(), paymentHash = null ) val response = instance.receivePayment(ReceivePaymentRequest(method)) @@ -502,6 +516,54 @@ class SparkRepository( } } + // --- On-chain deposit claiming --- + + /** + * On-chain deposits sit unclaimed (and show as "Pending" in history) until + * explicitly claimed once they have enough confirmations. The SDK emits + * [SdkEvent.UnclaimedDeposits] when deposits become claimable; claim them + * automatically so they settle without user action. + */ + private suspend fun claimDeposits(deposits: List) { + val instance = sdk ?: return + var claimedAny = false + for (deposit in deposits) { + try { + instance.claimDeposit( + ClaimDepositRequest( + txid = deposit.txid, + vout = deposit.vout, + maxFee = MaxFee.NetworkRecommended(leewaySatPerVbyte = 5UL) + ) + ) + claimedAny = true + emitStatus("Claimed on-chain deposit") + } catch (e: Exception) { + emitStatus("Failed to claim deposit: ${e.message}") + Log.e(TAG, "claimDeposit failed for ${deposit.txid}", e) + } + } + if (claimedAny) { + refreshBalanceInternal() + _transactionsChanged.tryEmit(Unit) + } + } + + /** Claim any deposits that became claimable while the app was closed. */ + suspend fun claimPendingDeposits() { + withContext(Dispatchers.IO) { + try { + val instance = sdk ?: return@withContext + val response = instance.listUnclaimedDeposits(breez_sdk_spark.ListUnclaimedDepositsRequest) + if (response.deposits.isNotEmpty()) { + claimDeposits(response.deposits) + } + } catch (e: Exception) { + Log.d(TAG, "listUnclaimedDeposits failed: ${e.message}") + } + } + } + // --- Transactions --- override suspend fun listTransactions(limit: Int, offset: Int): Result> = @@ -515,17 +577,25 @@ class SparkRepository( )) val transactions = response.payments.map { payment -> val lightningDetails = payment.details as? PaymentDetails.Lightning - - // Prefer bolt11-decoded hash (matches ZapSender records), fall back to HTLC hash, then payment ID - val decoded = lightningDetails?.invoice?.let { - com.wisp.app.nostr.Bolt11.decode(it) + val depositDetails = payment.details as? PaymentDetails.Deposit + val withdrawDetails = payment.details as? PaymentDetails.Withdraw + val onchain = depositDetails != null || withdrawDetails != null + + val paymentHash = when { + onchain -> depositDetails?.txId ?: withdrawDetails?.txId ?: payment.id + else -> { + // Prefer bolt11-decoded hash (matches ZapSender records), fall back to HTLC hash, then payment ID + val decoded = lightningDetails?.invoice?.let { + com.wisp.app.nostr.Bolt11.decode(it) + } + val htlcHash = lightningDetails?.htlcDetails?.paymentHash?.lowercase() + decoded?.paymentHash ?: htlcHash ?: payment.id + } } - val htlcHash = lightningDetails?.htlcDetails?.paymentHash?.lowercase() - val paymentHash = decoded?.paymentHash ?: htlcHash ?: payment.id // Prefer Spark's description, fall back to bolt11 description // (bolt11 tag 13 may contain the kind 9734 zap request JSON) val description = lightningDetails?.description - ?: decoded?.description + ?: lightningDetails?.invoice?.let { com.wisp.app.nostr.Bolt11.decode(it)?.description } WalletTransaction( type = when (payment.paymentType) { @@ -537,7 +607,15 @@ class SparkRepository( amountMsats = payment.amount.toLong() * 1000, feeMsats = payment.fees.toLong() * 1000, createdAt = payment.timestamp.toLong(), - settledAt = payment.timestamp.toLong() + settledAt = payment.timestamp.toLong(), + // On-chain payments made outside this app instance (another + // wallet on the same seed) aren't tracked by this SDK session, + // so PaymentStatus can stay stuck at PENDING long after the + // underlying transaction is confirmed. Since wisp doesn't + // initiate on-chain send/receive itself, don't trust that flag + // for on-chain rows — the mempool.space link lets users verify. + pending = !onchain && payment.status == breez_sdk_spark.PaymentStatus.PENDING, + isOnchain = onchain ) } Result.success(transactions) diff --git a/app/src/main/kotlin/com/wisp/app/repo/WalletProvider.kt b/app/src/main/kotlin/com/wisp/app/repo/WalletProvider.kt index 35997863..f4eaf9af 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/WalletProvider.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/WalletProvider.kt @@ -11,12 +11,15 @@ interface WalletProvider { /** Emits the amount in msats whenever an incoming payment is received. */ val paymentReceived: SharedFlow + /** Emits Unit whenever the transaction list should be refreshed. */ + val transactionsChanged: SharedFlow + fun hasConnection(): Boolean fun connect() fun disconnect() suspend fun fetchBalance(): Result suspend fun payInvoice(bolt11: String): Result - suspend fun makeInvoice(amountMsats: Long, description: String): Result + suspend fun makeInvoice(amountMsats: Long, description: String, expirySecs: Int = 3600): Result suspend fun listTransactions(limit: Int = 50, offset: Int = 0): Result> } @@ -29,5 +32,7 @@ data class WalletTransaction( val createdAt: Long, val settledAt: Long?, /** Pubkey of the counterparty (recipient for outgoing, sender for incoming zaps). */ - val counterpartyPubkey: String? = null + val counterpartyPubkey: String? = null, + val pending: Boolean = false, + val isOnchain: Boolean = false ) 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 4656eef9..093fbd5a 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 @@ -100,9 +100,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.TextFieldDefaults import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions @@ -186,6 +184,21 @@ import com.wisp.app.viewmodel.WalletPage import com.wisp.app.viewmodel.WalletState import com.wisp.app.viewmodel.WalletViewModel +// Full-screen wallet bottom sheets expand to the top of the window — push +// the grab handle below the status bar so it isn't clipped by device chrome. +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun WalletSheetDragHandle() { + Box( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding(), + contentAlignment = Alignment.Center + ) { + BottomSheetDefaults.DragHandle() + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun WalletScreen( @@ -212,7 +225,25 @@ fun WalletScreen( currentPage is WalletPage.ModeSelection || currentPage is WalletPage.NwcSetup || currentPage is WalletPage.SparkSetup || - currentPage is WalletPage.SparkRestoreSeed + currentPage is WalletPage.SparkRestoreSeed || + currentPage is WalletPage.SendInput || + currentPage is WalletPage.SendAmount || + currentPage is WalletPage.SendConfirm || + currentPage is WalletPage.Sending || + currentPage is WalletPage.SendResult || + currentPage is WalletPage.ReceiveAmount || + currentPage is WalletPage.ReceiveInvoice || + currentPage is WalletPage.ReceiveSuccess + + val isSendFlow = currentPage is WalletPage.SendInput || + currentPage is WalletPage.SendAmount || + currentPage is WalletPage.SendConfirm || + currentPage is WalletPage.Sending || + currentPage is WalletPage.SendResult + val isReceiveFlow = currentPage is WalletPage.ReceiveAmount || + currentPage is WalletPage.ReceiveInvoice || + currentPage is WalletPage.ReceiveSuccess + Scaffold( contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { @@ -396,14 +427,6 @@ fun WalletScreen( modifier = Modifier.padding(padding) ) } - is WalletPage.SendInput -> SendInputContent( - input = viewModel.sendInput.collectAsState().value, - error = viewModel.sendError.collectAsState().value, - onInputChange = { viewModel.updateSendInput(it) }, - onNext = { viewModel.processInput() }, - onScanQR = { viewModel.navigateTo(WalletPage.ScanQR) }, - modifier = Modifier.padding(padding) - ) is WalletPage.ScanQR -> ScanQRContent( onResult = { scanned -> viewModel.updateSendInput(scanned) @@ -411,78 +434,85 @@ fun WalletScreen( }, modifier = Modifier.padding(padding) ) - is WalletPage.SendAmount -> { - val page = currentPage as WalletPage.SendAmount - SendAmountContent( - address = page.address, - amount = viewModel.sendAmount.collectAsState().value, - error = viewModel.sendError.collectAsState().value, - isLoading = viewModel.isLoading.collectAsState().value, - onDigit = { viewModel.updateSendAmount(it) }, - onBackspace = { viewModel.sendAmountBackspace() }, - onConfirm = { - val sats = viewModel.sendAmount.value.toLongOrNull() ?: return@SendAmountContent - viewModel.resolveLightningAddress(page.address, sats) + is WalletPage.SendInput, + is WalletPage.SendAmount, + is WalletPage.SendConfirm, + is WalletPage.Sending, + is WalletPage.SendResult -> { + // Home stays visible behind the send bottom sheet. + val profileKey = viewModel.profileRefreshKey.collectAsState().value + WalletHomeContent( + balanceMsats = balanceMsats, + walletMode = viewModel.walletMode.collectAsState().value, + balanceUnit = viewModel.balanceUnit.collectAsState().value, + showSettingsAlert = viewModel.walletMode.collectAsState().value == WalletMode.SPARK + && !viewModel.seedBackupAcked.collectAsState().value + && !viewModel.isDefaultWallet.collectAsState().value, + seedBackupAcked = viewModel.seedBackupAcked.collectAsState().value, + backupMissing = viewModel.backupMissing.collectAsState().value, + isDefaultWallet = viewModel.isDefaultWallet.collectAsState().value, + onSend = {}, + onReceive = { viewModel.navigateTo(WalletPage.ReceiveAmount) }, + onTransactions = { + viewModel.loadTransactions() + viewModel.navigateTo(WalletPage.Transactions) }, - modifier = Modifier.padding(padding) - ) - } - is WalletPage.SendConfirm -> { - val page = currentPage as WalletPage.SendConfirm - val feeState by viewModel.feeState.collectAsState() - val walletMode by viewModel.walletMode.collectAsState() - LaunchedEffect(page.invoice) { - viewModel.prepareFee(page.invoice) - } - SendConfirmContent( - invoice = page.invoice, - amountSats = page.amountSats, - description = page.description, - feeState = feeState, - networkName = when (walletMode) { - WalletMode.SPARK -> "Spark" - WalletMode.NWC -> "NWC" - else -> "Unknown" + onRefresh = { viewModel.refreshBalance() }, + onSettings = { viewModel.navigateTo(WalletPage.Settings) }, + onBackupToRelay = { + viewModel.resetBackupStatus() + viewModel.navigateTo(WalletPage.BackupToRelay) }, - onPay = { viewModel.payInvoice(page.invoice) }, - onCancel = { viewModel.navigateBack() }, - modifier = Modifier.padding(padding) - ) - } - is WalletPage.Sending -> SendingContent( - modifier = Modifier.padding(padding) - ) - is WalletPage.SendResult -> { - val page = currentPage as WalletPage.SendResult - SendResultContent( - success = page.success, - message = page.message, - onDone = { viewModel.navigateHome() }, - modifier = Modifier.padding(padding) - ) - } - is WalletPage.ReceiveAmount -> ReceiveAmountContent( - amount = viewModel.receiveAmount.collectAsState().value, - isLoading = viewModel.isLoading.collectAsState().value, - lightningAddress = viewModel.lightningAddress.collectAsState().value, - onAmountChange = { viewModel.setReceiveAmount(it) }, - onGenerate = { sats, note -> viewModel.generateInvoice(sats, note) }, - modifier = Modifier.padding(padding) - ) - is WalletPage.ReceiveInvoice -> { - val page = currentPage as WalletPage.ReceiveInvoice - ReceiveInvoiceContent( - invoice = page.invoice, - amountSats = page.amountSats, - onDone = { viewModel.navigateHome() }, + onViewSeed = { viewModel.showMnemonicBackup() }, + lightningAddress = viewModel.lightningAddress.collectAsState().value, + onSetupAddress = { + viewModel.resetAddressSetupState() + viewModel.navigateTo(WalletPage.LightningAddressSetup) + }, + recentTransactions = viewModel.transactions.collectAsState().value, + profileLookup = remember(profileKey) { { viewModel.getProfileData(it) } }, + nwcNodeAlias = viewModel.nwcNodeAlias.collectAsState().value, + pubkey = viewModel.keyRepo.getPubkeyHex(), modifier = Modifier.padding(padding) ) } + is WalletPage.ReceiveAmount, + is WalletPage.ReceiveInvoice, is WalletPage.ReceiveSuccess -> { - val page = currentPage as WalletPage.ReceiveSuccess - ReceiveSuccessContent( - amountSats = page.amountSats, - onDone = { viewModel.navigateHome() }, + // Home stays visible behind the receive bottom sheet. + val profileKey = viewModel.profileRefreshKey.collectAsState().value + WalletHomeContent( + balanceMsats = balanceMsats, + walletMode = viewModel.walletMode.collectAsState().value, + balanceUnit = viewModel.balanceUnit.collectAsState().value, + showSettingsAlert = viewModel.walletMode.collectAsState().value == WalletMode.SPARK + && !viewModel.seedBackupAcked.collectAsState().value + && !viewModel.isDefaultWallet.collectAsState().value, + seedBackupAcked = viewModel.seedBackupAcked.collectAsState().value, + backupMissing = viewModel.backupMissing.collectAsState().value, + isDefaultWallet = viewModel.isDefaultWallet.collectAsState().value, + onSend = { viewModel.navigateTo(WalletPage.SendInput) }, + onReceive = {}, + onTransactions = { + viewModel.loadTransactions() + viewModel.navigateTo(WalletPage.Transactions) + }, + onRefresh = { viewModel.refreshBalance() }, + onSettings = { viewModel.navigateTo(WalletPage.Settings) }, + onBackupToRelay = { + viewModel.resetBackupStatus() + viewModel.navigateTo(WalletPage.BackupToRelay) + }, + onViewSeed = { viewModel.showMnemonicBackup() }, + lightningAddress = viewModel.lightningAddress.collectAsState().value, + onSetupAddress = { + viewModel.resetAddressSetupState() + viewModel.navigateTo(WalletPage.LightningAddressSetup) + }, + recentTransactions = viewModel.transactions.collectAsState().value, + profileLookup = remember(profileKey) { { viewModel.getProfileData(it) } }, + nwcNodeAlias = viewModel.nwcNodeAlias.collectAsState().value, + pubkey = viewModel.keyRepo.getPubkeyHex(), modifier = Modifier.padding(padding) ) } @@ -644,6 +674,7 @@ fun WalletScreen( ModalBottomSheet( onDismissRequest = { viewModel.navigateBack() }, sheetState = txSheetState, + dragHandle = { WalletSheetDragHandle() } ) { TransactionHistoryContent( transactions = viewModel.transactions.collectAsState().value, @@ -658,6 +689,98 @@ fun WalletScreen( ) } } + + // Send flow full-screen bottom sheet — swipe down to dismiss back to Home. + if (isSendFlow) { + val sendSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = { viewModel.navigateHome() }, + sheetState = sendSheetState, + dragHandle = { WalletSheetDragHandle() } + ) { + when (val page = currentPage) { + is WalletPage.SendInput -> SendInputContent( + input = viewModel.sendInput.collectAsState().value, + error = viewModel.sendError.collectAsState().value, + walletMode = viewModel.walletMode.collectAsState().value, + onInputChange = { viewModel.updateSendInput(it) }, + onNext = { viewModel.processInput() }, + onScanQR = { viewModel.navigateTo(WalletPage.ScanQR) } + ) + is WalletPage.SendAmount -> { + SendAmountContent( + address = page.address, + amount = viewModel.sendAmount.collectAsState().value, + error = viewModel.sendError.collectAsState().value, + isLoading = viewModel.isLoading.collectAsState().value, + onDigit = { viewModel.updateSendAmount(it) }, + onBackspace = { viewModel.sendAmountBackspace() }, + onConfirm = { + val sats = viewModel.sendAmount.value.toLongOrNull() ?: return@SendAmountContent + viewModel.resolveLightningAddress(page.address, sats) + } + ) + } + is WalletPage.SendConfirm -> { + val feeState by viewModel.feeState.collectAsState() + val walletMode by viewModel.walletMode.collectAsState() + LaunchedEffect(page.invoice) { + viewModel.prepareFee(page.invoice) + } + SendConfirmContent( + invoice = page.invoice, + amountSats = page.amountSats, + description = page.description, + feeState = feeState, + networkName = when (walletMode) { + WalletMode.SPARK -> "Spark" + WalletMode.NWC -> "NWC" + else -> "Unknown" + }, + onPay = { viewModel.payInvoice(page.invoice) }, + onCancel = { viewModel.navigateBack() } + ) + } + is WalletPage.Sending -> SendingContent() + is WalletPage.SendResult -> SendResultContent( + success = page.success, + message = page.message, + onDone = { viewModel.navigateHome() } + ) + else -> {} + } + } + } + + // Receive flow full-screen bottom sheet — swipe down to dismiss back to Home. + if (isReceiveFlow) { + val receiveSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = { viewModel.navigateHome() }, + sheetState = receiveSheetState, + dragHandle = { WalletSheetDragHandle() } + ) { + when (val page = currentPage) { + is WalletPage.ReceiveAmount -> ReceiveAmountContent( + amount = viewModel.receiveAmount.collectAsState().value, + isLoading = viewModel.isLoading.collectAsState().value, + lightningAddress = viewModel.lightningAddress.collectAsState().value, + onAmountChange = { viewModel.setReceiveAmount(it) }, + onGenerate = { sats, note, expirySecs -> viewModel.generateInvoice(sats, note, expirySecs) } + ) + is WalletPage.ReceiveInvoice -> ReceiveInvoiceContent( + invoice = page.invoice, + amountSats = page.amountSats, + onDone = { viewModel.navigateHome() } + ) + is WalletPage.ReceiveSuccess -> ReceiveSuccessContent( + amountSats = page.amountSats, + onDone = { viewModel.navigateHome() } + ) + else -> {} + } + } + } } } } @@ -1601,7 +1724,8 @@ private fun WalletActionButton( Box( modifier = Modifier .size(64.dp) - .background(tint, CircleShape) + .clip(CircleShape) + .background(tint) .clickable(onClick = onClick), contentAlignment = Alignment.Center ) { @@ -1627,6 +1751,7 @@ private fun WalletActionButton( private fun SendInputContent( input: String, error: String?, + walletMode: WalletMode = WalletMode.NONE, onInputChange: (String) -> Unit, onNext: () -> Unit, onScanQR: () -> Unit, @@ -1671,39 +1796,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 = { new -> if (!NsecPasteGuard.blockIfNsec(input, new)) onInputChange(new) }, - label = { Text(stringResource(R.string.wallet_lightning_address_invoice)) }, - placeholder = { Text(stringResource(R.string.placeholder_user_domain)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = false, - maxLines = 4, - trailingIcon = { - IconButton(onClick = { - clipboardManager.getText()?.text?.let { new -> if (!NsecPasteGuard.blockIfNsec(input, new)) onInputChange(new) } - }) { - 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 (!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)) @@ -1714,51 +1860,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 (!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 @@ -2123,7 +2297,9 @@ private fun SendResultContent( // --- Receive amount --- -private enum class ReceiveTab { INVOICE, ADDRESS } +private enum class InvoiceExpiry(val seconds: Int) { + ONE_HOUR(3600), ONE_DAY(86400), CUSTOM(0) +} @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -2132,7 +2308,8 @@ private fun ReceiveAmountContent( isLoading: Boolean, lightningAddress: String?, onAmountChange: (String) -> Unit, - onGenerate: (Long, String) -> Unit, + onGenerate: (Long, String, Int) -> Unit, + onShowAddressQR: () -> Unit = {}, modifier: Modifier = Modifier ) { val receiveCtx = LocalContext.current @@ -2147,20 +2324,40 @@ private fun ReceiveAmountContent( ((fiatValue / btcPrice) * 100_000_000.0).toLong() } else null val satAmount: Long? = if (fiatMode) fiatSats else amount.toLongOrNull() - val canCreate = (satAmount ?: 0L) > 0L && !isLoading var description by remember { mutableStateOf("") } - var selectedTab by remember { mutableStateOf(ReceiveTab.INVOICE) } - val hasLightningAddress = !lightningAddress.isNullOrBlank() + var showAddressQR by remember { mutableStateOf(false) } + var selectedExpiry by remember { mutableStateOf(InvoiceExpiry.ONE_HOUR) } + var customHours by remember { mutableStateOf("") } + val expirySecs = when (selectedExpiry) { + InvoiceExpiry.ONE_HOUR -> 3600 + InvoiceExpiry.ONE_DAY -> 86400 + InvoiceExpiry.CUSTOM -> (customHours.toIntOrNull() ?: 0) * 3600 + } + val canCreate = (satAmount ?: 0L) > 0L && !isLoading && + (selectedExpiry != InvoiceExpiry.CUSTOM || expirySecs > 0) val fieldShape = RoundedCornerShape(14.dp) val fieldBg = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + val scrollState = rememberScrollState() + var invoiceGenerating by remember { mutableStateOf(false) } + + // Reset local generating flag once the ViewModel finishes + LaunchedEffect(isLoading) { if (!isLoading) invoiceGenerating = false } + + // Scroll after AnimatedVisibility has expanded and laid out + LaunchedEffect(showAddressQR) { + if (showAddressQR) { + kotlinx.coroutines.delay(350) + scrollState.animateScrollTo(scrollState.maxValue) + } + } Column( modifier = modifier .fillMaxSize() .padding(horizontal = 20.dp) - .verticalScroll(rememberScrollState()) + .verticalScroll(scrollState) ) { Spacer(Modifier.height(8.dp)) @@ -2175,187 +2372,272 @@ private fun ReceiveAmountContent( Spacer(Modifier.height(20.dp)) - if (hasLightningAddress) { - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - SegmentedButton( - selected = selectedTab == ReceiveTab.INVOICE, - onClick = { selectedTab = ReceiveTab.INVOICE }, - shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2) - ) { Text(stringResource(R.string.wallet_receive_invoice_tab)) } - SegmentedButton( - selected = selectedTab == ReceiveTab.ADDRESS, - onClick = { selectedTab = ReceiveTab.ADDRESS }, - shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2) - ) { Text(stringResource(R.string.wallet_receive_address_tab)) } - } - Spacer(Modifier.height(20.dp)) - } + // AMOUNT field + Text( + stringResource(R.string.wallet_receive_amount_label), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(8.dp)) - if (selectedTab == ReceiveTab.ADDRESS && hasLightningAddress) { - ReceiveAddressBlock(address = lightningAddress!!) - } else { - // AMOUNT field - Text( - stringResource(R.string.wallet_receive_amount_label), - style = MaterialTheme.typography.labelMedium, + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .background(fieldBg, fieldShape) + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + val amountTextStyle = TextStyle( + fontSize = 32.sp, fontWeight = FontWeight.SemiBold, - letterSpacing = 0.5.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurface ) - Spacer(Modifier.height(8.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .background(fieldBg, fieldShape) - .padding(horizontal = 16.dp, vertical = 16.dp) - ) { - val amountTextStyle = TextStyle( - fontSize = 32.sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - BasicTextField( - value = amount, - onValueChange = { input -> - val filtered = if (fiatMode) { - // Allow digits + a single decimal point - val sb = StringBuilder() - var seenDot = false - for (c in input) { - if (c.isDigit()) sb.append(c) - else if (c == '.' && !seenDot) { sb.append(c); seenDot = true } - } - sb.toString() - } else { - input.filter { it.isDigit() } + BasicTextField( + value = amount, + onValueChange = { input -> + val filtered = if (fiatMode) { + val sb = StringBuilder() + var seenDot = false + for (c in input) { + if (c.isDigit()) sb.append(c) + else if (c == '.' && !seenDot) { sb.append(c); seenDot = true } } - onAmountChange(filtered) - }, - textStyle = amountTextStyle, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = if (fiatMode) KeyboardType.Decimal else KeyboardType.NumberPassword - ), - cursorBrush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.primary), - modifier = Modifier.weight(1f), - decorationBox = { inner -> - Box(contentAlignment = Alignment.CenterStart) { - if (amount.isEmpty()) { - Text( - "0", - style = amountTextStyle.copy(color = MaterialTheme.colorScheme.onSurfaceVariant) - ) - } - inner() + sb.toString() + } else { + input.filter { it.isDigit() } + } + onAmountChange(filtered) + }, + textStyle = amountTextStyle, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = if (fiatMode) KeyboardType.Decimal else KeyboardType.NumberPassword + ), + cursorBrush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier.weight(1f), + decorationBox = { inner -> + Box(contentAlignment = Alignment.CenterStart) { + if (amount.isEmpty()) { + Text( + "0", + style = amountTextStyle.copy(color = MaterialTheme.colorScheme.onSurfaceVariant) + ) } + inner() } - ) - Spacer(Modifier.width(8.dp)) - Text( - if (fiatMode) currency.code else stringResource(R.string.wallet_sats), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (fiatMode && fiatSats != null && fiatSats > 0L) { - Spacer(Modifier.height(6.dp)) - Text( - "≈ %,d sats".format(fiatSats), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 4.dp) - ) - } - - Spacer(Modifier.height(20.dp)) - - // NOTE field + } + ) + Spacer(Modifier.width(8.dp)) Text( - stringResource(R.string.wallet_receive_note_label), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - letterSpacing = 0.5.sp, + if (fiatMode) currency.code else stringResource(R.string.wallet_sats), + style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) + } + if (fiatMode && fiatSats != null && fiatSats > 0L) { + Spacer(Modifier.height(6.dp)) + Text( + "≈ %,d sats".format(fiatSats), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp) + ) + } + + Spacer(Modifier.height(20.dp)) + + // NOTE field + Text( + stringResource(R.string.wallet_receive_note_label), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(8.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .background(fieldBg, fieldShape) + .padding(horizontal = 16.dp, vertical = 14.dp) + ) { + val noteStyle = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface + ) + BasicTextField( + value = description, + onValueChange = { description = it }, + textStyle = noteStyle, + singleLine = true, + cursorBrush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier.fillMaxWidth(), + decorationBox = { inner -> + if (description.isEmpty()) { + Text( + stringResource(R.string.wallet_receive_note_placeholder), + style = noteStyle.copy(color = MaterialTheme.colorScheme.onSurfaceVariant) + ) + } + inner() + } + ) + } + + Spacer(Modifier.height(20.dp)) + + // EXPIRES selector + Text( + stringResource(R.string.wallet_receive_expires_label), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf( + InvoiceExpiry.ONE_HOUR to stringResource(R.string.wallet_receive_expires_1h), + InvoiceExpiry.ONE_DAY to stringResource(R.string.wallet_receive_expires_24h), + InvoiceExpiry.CUSTOM to stringResource(R.string.wallet_receive_expires_custom) + ).forEach { (expiry, label) -> + val isSelected = selectedExpiry == expiry + OutlinedButton( + onClick = { selectedExpiry = expiry }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) else Color.Transparent, + contentColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ), + border = BorderStroke( + 1.dp, + if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline + ), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp) + ) { Text(label, style = MaterialTheme.typography.labelMedium) } + } + } + if (selectedExpiry == InvoiceExpiry.CUSTOM) { Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = customHours, + onValueChange = { customHours = it.filter { c -> c.isDigit() } }, + label = { Text(stringResource(R.string.wallet_receive_expires_hours)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(Modifier.height(24.dp)) + if (invoiceGenerating) { Box( modifier = Modifier .fillMaxWidth() .background(fieldBg, fieldShape) - .padding(horizontal = 16.dp, vertical = 14.dp) + .padding(vertical = 14.dp), + contentAlignment = Alignment.Center ) { - val noteStyle = MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurface + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(10.dp)) + Text( + stringResource(R.string.wallet_creating_invoice), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + Button( + onClick = { + val sats = satAmount ?: return@Button + invoiceGenerating = true + onGenerate(sats, description, expirySecs) + }, + enabled = canCreate, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = fieldShape, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = Color.White, + disabledContainerColor = fieldBg, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant ) - BasicTextField( - value = description, - onValueChange = { description = it }, - textStyle = noteStyle, - singleLine = true, - cursorBrush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.primary), - modifier = Modifier.fillMaxWidth(), - decorationBox = { inner -> - if (description.isEmpty()) { - Text( - stringResource(R.string.wallet_receive_note_placeholder), - style = noteStyle.copy(color = MaterialTheme.colorScheme.onSurfaceVariant) - ) - } - inner() - } + ) { + Text( + stringResource(R.string.wallet_receive_create_invoice), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold ) } + } + // Lightning address — shown below invoice form as "or receive via" row + if (!lightningAddress.isNullOrBlank()) { Spacer(Modifier.height(24.dp)) - - if (isLoading) { - Box( + LightningAddressReceiveRow( + address = lightningAddress, + onShowQR = { showAddressQR = !showAddressQR } + ) + AnimatedVisibility(visible = showAddressQR) { + val clipboardManager = LocalClipboardManager.current + val qrBitmap = remember(lightningAddress) { + val writer = QRCodeWriter() + val matrix = writer.encode(lightningAddress, BarcodeFormat.QR_CODE, 512, 512) + val bmp = Bitmap.createBitmap(matrix.width, matrix.height, Bitmap.Config.RGB_565) + for (x in 0 until matrix.width) { + for (y in 0 until matrix.height) { + bmp.setPixel(x, y, if (matrix.get(x, y)) android.graphics.Color.BLACK else android.graphics.Color.WHITE) + } + } + bmp + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxWidth() - .background(fieldBg, fieldShape) - .padding(vertical = 14.dp), - contentAlignment = Alignment.Center + .padding(top = 16.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f), + RoundedCornerShape(16.dp) + ) + .padding(20.dp) ) { + Image( + bitmap = qrBitmap.asImageBitmap(), + contentDescription = stringResource(R.string.cd_invoice_qr_code), + modifier = Modifier + .size(240.dp) + .background(Color.White, RoundedCornerShape(12.dp)) + .padding(10.dp) + ) + Spacer(Modifier.height(12.dp)) Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator( - strokeWidth = 2.dp, - modifier = Modifier.size(18.dp) - ) - Spacer(Modifier.width(10.dp)) Text( - stringResource(R.string.wallet_creating_invoice), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + lightningAddress, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) ) + IconButton(onClick = { clipboardManager.setText(AnnotatedString(lightningAddress)) }) { + Icon( + Icons.Default.ContentCopy, + contentDescription = stringResource(R.string.wallet_copy_address), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } - } else { - Button( - onClick = { - val sats = satAmount ?: return@Button - onGenerate(sats, description) - }, - enabled = canCreate, - modifier = Modifier - .fillMaxWidth() - .height(52.dp), - shape = fieldShape, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = Color.White, - disabledContainerColor = fieldBg, - disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant - ) - ) { - Text( - stringResource(R.string.wallet_receive_create_invoice), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold - ) - } } } @@ -2364,84 +2646,64 @@ private fun ReceiveAmountContent( } @Composable -private fun ReceiveAddressBlock(address: String) { +private fun LightningAddressReceiveRow( + address: String, + onShowQR: () -> Unit +) { val clipboardManager = LocalClipboardManager.current - val context = LocalContext.current - - val qrBitmap = remember(address) { - val writer = QRCodeWriter() - val matrix = writer.encode(address, BarcodeFormat.QR_CODE, 512, 512) - val bitmap = Bitmap.createBitmap(matrix.width, matrix.height, Bitmap.Config.RGB_565) - for (x in 0 until matrix.width) { - for (y in 0 until matrix.height) { - bitmap.setPixel(x, y, if (matrix.get(x, y)) android.graphics.Color.BLACK else android.graphics.Color.WHITE) - } - } - bitmap - } - - val cardShape = RoundedCornerShape(16.dp) - val cardBg = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) - val actionShape = RoundedCornerShape(14.dp) - val actionBg = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(cardBg, cardShape) - .padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( - bitmap = qrBitmap.asImageBitmap(), - contentDescription = stringResource(R.string.cd_invoice_qr_code), - modifier = Modifier - .size(260.dp) - .background(Color.White, RoundedCornerShape(12.dp)) - .padding(8.dp) - ) - Spacer(Modifier.height(12.dp)) + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + HorizontalDivider(modifier = Modifier.weight(1f)) Text( - address, - style = MaterialTheme.typography.bodyMedium, + stringResource(R.string.wallet_receive_via_address), + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center + modifier = Modifier.padding(horizontal = 12.dp) ) + HorizontalDivider(modifier = Modifier.weight(1f)) } - - Spacer(Modifier.height(20.dp)) - + Spacer(Modifier.height(16.dp)) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .background(actionBg, actionShape) + .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(18.dp)) - Spacer(Modifier.width(6.dp)) - Text("Copy address", fontWeight = FontWeight.Medium) + 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(24.dp)) - TextButton( - onClick = { - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, address) - } - context.startActivity(Intent.createChooser(intent, "Share Lightning Address")) - }, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.textButtonColors(contentColor = WispThemeColors.zapColor) - ) { - Icon(Icons.Default.Share, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) - Text("Share", fontWeight = FontWeight.Medium) + 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 + ) } } } @@ -2772,7 +3034,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 @@ -2809,7 +3071,8 @@ private fun TransactionHistoryContent( private fun TransactionRow( tx: WalletTransaction, profileLookup: (String) -> com.wisp.app.nostr.ProfileData?, - displayMode: WalletBalanceDisplayMode = WalletBalanceDisplayMode.SATS + displayMode: WalletBalanceDisplayMode = WalletBalanceDisplayMode.SATS, + expandable: Boolean = false ) { val isIncoming = tx.type == "incoming" val amountSats = tx.amountMsats / 1000 @@ -2819,92 +3082,130 @@ private fun TransactionRow( val fiatCurrency by FiatPreferences.get(ctx).currency.collectAsState() val isHidden = displayMode == WalletBalanceDisplayMode.HIDDEN val isWalletFiat = displayMode == WalletBalanceDisplayMode.FIAT + val clipboardManager = LocalClipboardManager.current + var expanded by remember { mutableStateOf(false) } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Avatar or direction icon - if (profile?.picture != null) { - AsyncImage( - model = profile.picture, - contentDescription = profile.displayString, - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - ) - } else { - Box( - modifier = Modifier - .size(40.dp) - .background( - if (isIncoming) Color(0xFF2E7D32).copy(alpha = 0.1f) - else MaterialTheme.colorScheme.error.copy(alpha = 0.1f), - CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - if (isIncoming) Icons.Default.ArrowDownward else Icons.Default.ArrowUpward, - contentDescription = if (isIncoming) stringResource(R.string.wallet_received) else stringResource(R.string.wallet_sent), - tint = if (isIncoming) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error, - modifier = Modifier.size(20.dp) + 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 + ) { + // Avatar or direction icon + if (profile?.picture != null) { + AsyncImage( + model = profile.picture, + contentDescription = profile.displayString, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) ) + } else { + Box( + modifier = Modifier + .size(40.dp) + .background( + if (isIncoming) Color(0xFF2E7D32).copy(alpha = 0.1f) + else MaterialTheme.colorScheme.error.copy(alpha = 0.1f), + CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + if (isIncoming) Icons.Default.ArrowDownward else Icons.Default.ArrowUpward, + contentDescription = if (isIncoming) stringResource(R.string.wallet_received) else stringResource(R.string.wallet_sent), + tint = if (isIncoming) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + } } - } - Spacer(Modifier.width(12.dp)) + Spacer(Modifier.width(12.dp)) - // Name/description + time - Column(modifier = Modifier.weight(1f)) { - val displayLabel = if (profile != null) { - profile.displayString - } else { - // Try to get a meaningful description; skip raw zap request JSON - val desc = tx.description?.takeIf { - it.isNotBlank() && it != "null" && !it.trimStart().startsWith("{") + // Name/description + time + Column(modifier = Modifier.weight(1f)) { + val displayLabel = if (profile != null) { + profile.displayString + } else { + // Try to get a meaningful description; skip raw zap request JSON + val desc = tx.description?.takeIf { + it.isNotBlank() && it != "null" && !it.trimStart().startsWith("{") + } + desc ?: if (isIncoming) stringResource(R.string.wallet_received) else stringResource(R.string.wallet_sent) + } + Text( + displayLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + 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 + ) } - desc ?: if (isIncoming) stringResource(R.string.wallet_received) else stringResource(R.string.wallet_sent) } - Text( - displayLabel, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - 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 - // "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 "-" - val signColor = if (isIncoming) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error - when { - isHidden -> Text( - "$sign* * *", - style = MaterialTheme.typography.titleMedium, - color = signColor - ) - isWalletFiat -> { - val fiat = AmountFormatter.formatFiat(amountSats, fiatCurrency) - if (fiat != null) { - Text( - "$sign$fiat", - style = MaterialTheme.typography.titleMedium, - color = signColor - ) - } else { + // 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 "-" + val signColor = if (isIncoming) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error + when { + isHidden -> Text( + "$sign* * *", + style = MaterialTheme.typography.titleMedium, + color = signColor + ) + 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 = signColor + ) + else -> { Text( "$sign%,d".format(amountSats), style = MaterialTheme.typography.titleMedium, @@ -2918,45 +3219,125 @@ private fun TransactionRow( ) } } - fiatMode -> Text( - "$sign${AmountFormatter.formatFull(amountSats, ctx)}", - 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 - ) - } } - } - 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) + 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) } - fiatMode -> stringResource(R.string.wallet_fee_money, AmountFormatter.formatFull(feeSats, ctx)) - else -> stringResource(R.string.wallet_fee, feeSats) + Text( + feeText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } - Text( - feeText, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + } + + if (expandable) { + Spacer(Modifier.width(4.dp)) + Icon( + if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } + + if (expandable) { + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + TransactionDetailPanel(tx = tx, onCopy = { clipboardManager.setText(AnnotatedString(it)) }) + } + } + } +} + +@Composable +private fun TransactionDetailPanel(tx: WalletTransaction, onCopy: (String) -> Unit) { + val ctx = LocalContext.current + val amountSats = tx.amountMsats / 1000 + val feeSats = tx.feeMsats / 1000 + val dateStr = java.text.SimpleDateFormat("MMM d, yyyy HH:mm", java.util.Locale.US) + .format(java.util.Date((tx.settledAt ?: tx.createdAt) * 1000L)) + val idLabel = if (tx.isOnchain) "Transaction ID" else "Payment hash" + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + Spacer(Modifier.height(2.dp)) + + TxDetailRow("Status", if (tx.pending) "Pending" else "Completed") + TxDetailRow("Type", if (tx.isOnchain) "On-chain" else "Lightning") + TxDetailRow("Amount", "%,d sats".format(amountSats)) + if (feeSats > 0) TxDetailRow("Network fee", "%,d sats".format(feeSats)) + TxDetailRow("Date", dateStr) + if (!tx.description.isNullOrBlank() && !tx.description.trimStart().startsWith("{")) { + TxDetailRow("Note", tx.description) + } + TxDetailRow(idLabel, tx.paymentHash, mono = true, onCopy = { onCopy(tx.paymentHash) }) + + if (tx.isOnchain && tx.paymentHash.length == 64) { + val mempoolUrl = "https://mempool.space/tx/${tx.paymentHash}" + TextButton( + onClick = { + ctx.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(mempoolUrl))) + }, + contentPadding = PaddingValues(0.dp) + ) { + Text("View on mempool.space", style = MaterialTheme.typography.labelMedium) + } + } + + Spacer(Modifier.height(4.dp)) + } +} + +@Composable +private fun TxDetailRow( + label: String, + value: String, + mono: Boolean = false, + onCopy: (() -> Unit)? = null +) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { + Text( + label, + modifier = Modifier.width(110.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + value, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + fontFamily = if (mono) FontFamily.Monospace else null, + color = MaterialTheme.colorScheme.onSurface + ) + if (onCopy != null) { + Icon( + Icons.Default.ContentCopy, + contentDescription = "Copy", + modifier = Modifier + .size(14.dp) + .clickable(onClick = onCopy), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } 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 03996f9f..fe399ba4 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt @@ -1194,6 +1194,10 @@ class WalletViewModel( _sendError.value = null } + fun setSendAmount(value: String) { + _sendAmount.value = value + } + fun updateSendAmount(digit: Char) { val current = _sendAmount.value if (current.length < 10) { @@ -1209,7 +1213,9 @@ class WalletViewModel( } fun processInput(input: String = _sendInput.value) { - val trimmed = input.trim().removePrefix("lightning:") + val trimmed = input.trim() + .removePrefix("lightning:").removePrefix("LIGHTNING:") + .let { it.removePrefix("bitcoin:").removePrefix("BITCOIN:") } _sendError.value = null when { @@ -1358,10 +1364,10 @@ class WalletViewModel( _receiveAmount.value = value } - fun generateInvoice(amountSats: Long, description: String = "") { + fun generateInvoice(amountSats: Long, description: String = "", expirySecs: Int = 3600) { _isLoading.value = true viewModelScope.launch { - val result = activeProvider.makeInvoice(amountSats * 1000, description) + val result = activeProvider.makeInvoice(amountSats * 1000, description, expirySecs) result.fold( onSuccess = { invoice -> navigateTo(WalletPage.ReceiveInvoice(invoice, amountSats)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 095c1f8a..07a9b9dd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -789,6 +789,16 @@ NOTE (OPTIONAL) For coffee, etc. Create invoice + + EXPIRES + 1 hour + 24 hours + Custom + Hours until expiry + + or receive to your Lightning address + Copy address + Lightning address, invoice, or CLINK offer Wallet backup not found Your wallet recovery phrase is not backed up to any relay. If you lose this device, you could lose your funds. Backup Now @@ -874,6 +884,8 @@ Invite codes Generate invite code Latest invite code + Copy + Pending Copy code Copy invite link Assign role