feat: add NIP-47 wallet interface with balance, send, receive, and transactions
Adds an Alby Go-inspired wallet interface accessible from the left drawer menu. Uses existing NWC connection from zap settings to communicate with the wallet via NIP-47 methods (get_balance, get_info, pay_invoice, make_invoice, list_transactions). New files: - WalletViewModel: manages wallet state and NWC requests - WalletScreen: balance display with send/receive action buttons - WalletSendScreen: paste BOLT-11 invoice and pay - WalletReceiveScreen: create invoice with QR code display - WalletTransactionsScreen: scrollable transaction history Also adds generic sendNwcRequest() to NwcSignerState and Account for sending arbitrary NIP-47 requests beyond just pay_invoice. https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ
This commit is contained in:
@@ -171,6 +171,7 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache
|
|||||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.Request
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.Response
|
import com.vitorpamplona.quartz.nip47WalletConnect.Response
|
||||||
import com.vitorpamplona.quartz.nip56Reports.ReportType
|
import com.vitorpamplona.quartz.nip56Reports.ReportType
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
@@ -581,6 +582,14 @@ class Account(
|
|||||||
|
|
||||||
suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(nip47SignerState)
|
suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(nip47SignerState)
|
||||||
|
|
||||||
|
suspend fun sendNwcRequest(
|
||||||
|
request: Request,
|
||||||
|
onResponse: (Response?) -> Unit,
|
||||||
|
) {
|
||||||
|
val (event, relay) = nip47SignerState.sendNwcRequest(request, onResponse)
|
||||||
|
client.send(event, setOf(relay))
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun sendZapPaymentRequestFor(
|
suspend fun sendZapPaymentRequestFor(
|
||||||
bolt11: String,
|
bolt11: String,
|
||||||
zappedNote: Note?,
|
zappedNote: Note?,
|
||||||
|
|||||||
+39
@@ -138,6 +138,45 @@ class NwcSignerState(
|
|||||||
return zapPaymentResponseDecryptionCache.value.decryptResponse(event)
|
return zapPaymentResponseDecryptionCache.value.decryptResponse(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a generic NIP-47 request to the connected wallet.
|
||||||
|
* Subscribes to responses and waits up to 60s for a reply.
|
||||||
|
*
|
||||||
|
* @param request the NIP-47 request to send
|
||||||
|
* @param onResponse callback to handle the response from the wallet
|
||||||
|
* @return a pair containing the request event and target relay URL
|
||||||
|
* @throws IllegalArgumentException if no NIP-47 wallet is set up
|
||||||
|
*/
|
||||||
|
suspend fun sendNwcRequest(
|
||||||
|
request: Request,
|
||||||
|
onResponse: (Response?) -> Unit,
|
||||||
|
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
|
||||||
|
val walletService = nip47Setup.value ?: throw IllegalArgumentException("No NIP47 setup")
|
||||||
|
|
||||||
|
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, nip47Signer.value)
|
||||||
|
|
||||||
|
val filter =
|
||||||
|
NWCPaymentQueryState(
|
||||||
|
fromServiceHex = walletService.pubKeyHex,
|
||||||
|
toUserHex = event.pubKey,
|
||||||
|
replyingToHex = event.id,
|
||||||
|
relay = walletService.relayUri,
|
||||||
|
)
|
||||||
|
|
||||||
|
nwcFilterAssembler.subscribe(filter)
|
||||||
|
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
delay(60000)
|
||||||
|
nwcFilterAssembler.unsubscribe(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.consume(event, null, true, walletService.relayUri) {
|
||||||
|
onResponse(decryptResponse(it))
|
||||||
|
}
|
||||||
|
|
||||||
|
return Pair(event, walletService.relayUri)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends a zap payment request to a connected Lightning wallet.
|
* Sends a zap payment request to a connected Lightning wallet.
|
||||||
* Subscribes to responses and waits up to 60s for a reply.
|
* Subscribes to responses and waits up to 60s for a reply.
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScr
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletTransactionsScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
|
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
|
||||||
import com.vitorpamplona.amethyst.ui.uriToRoute
|
import com.vitorpamplona.amethyst.ui.uriToRoute
|
||||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||||
@@ -151,6 +155,11 @@ fun AppNavigation(
|
|||||||
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
|
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
|
||||||
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
|
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
|
||||||
|
|
||||||
|
composableFromEnd<Route.Wallet> { WalletScreen(accountViewModel, nav) }
|
||||||
|
composableFromEnd<Route.WalletSend> { WalletSendScreen(accountViewModel, nav) }
|
||||||
|
composableFromEnd<Route.WalletReceive> { WalletReceiveScreen(accountViewModel, nav) }
|
||||||
|
composableFromEnd<Route.WalletTransactions> { WalletTransactionsScreen(accountViewModel, nav) }
|
||||||
|
|
||||||
composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
|
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.MyFollowPackView> { FollowPackScreen(it.dTag, accountViewModel, nav) }
|
composableFromEndArgs<Route.MyFollowPackView> { FollowPackScreen(it.dTag, accountViewModel, nav) }
|
||||||
|
|||||||
+9
@@ -49,6 +49,7 @@ import androidx.compose.material.icons.filled.Delete
|
|||||||
import androidx.compose.material.icons.outlined.CollectionsBookmark
|
import androidx.compose.material.icons.outlined.CollectionsBookmark
|
||||||
import androidx.compose.material.icons.outlined.Drafts
|
import androidx.compose.material.icons.outlined.Drafts
|
||||||
import androidx.compose.material.icons.outlined.GroupAdd
|
import androidx.compose.material.icons.outlined.GroupAdd
|
||||||
|
import androidx.compose.material.icons.outlined.AccountBalanceWallet
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -463,6 +464,14 @@ fun ListContent(
|
|||||||
route = Route.Drafts,
|
route = Route.Drafts,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
NavigationRow(
|
||||||
|
title = R.string.wallet,
|
||||||
|
icon = Icons.Outlined.AccountBalanceWallet,
|
||||||
|
tint = MaterialTheme.colorScheme.onBackground,
|
||||||
|
nav = nav,
|
||||||
|
route = Route.Wallet,
|
||||||
|
)
|
||||||
|
|
||||||
NavigationRow(
|
NavigationRow(
|
||||||
title = R.string.route_chess,
|
title = R.string.route_chess,
|
||||||
icon = R.drawable.ic_chess,
|
icon = R.drawable.ic_chess,
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ sealed class Route {
|
|||||||
|
|
||||||
@Serializable object Chess : Route()
|
@Serializable object Chess : Route()
|
||||||
|
|
||||||
|
@Serializable object Wallet : Route()
|
||||||
|
|
||||||
|
@Serializable object WalletSend : Route()
|
||||||
|
|
||||||
|
@Serializable object WalletReceive : Route()
|
||||||
|
|
||||||
|
@Serializable object WalletTransactions : Route()
|
||||||
|
|
||||||
@Serializable object Search : Route()
|
@Serializable object Search : Route()
|
||||||
|
|
||||||
@Serializable object SecurityFilters : Route()
|
@Serializable object SecurityFilters : Route()
|
||||||
|
|||||||
+268
@@ -0,0 +1,268 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
import java.text.NumberFormat
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun WalletReceiveScreen(
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
|
) {
|
||||||
|
val walletViewModel: WalletViewModel = viewModel()
|
||||||
|
|
||||||
|
LaunchedEffect(accountViewModel) {
|
||||||
|
walletViewModel.init(accountViewModel.account)
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose { walletViewModel.resetReceiveState() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val receiveState by walletViewModel.receiveState.collectAsState()
|
||||||
|
var amountText by remember { mutableStateOf("") }
|
||||||
|
var descriptionText by remember { mutableStateOf("") }
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringRes(R.string.wallet_receive)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { nav.popBack() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringRes(R.string.back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(padding)
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
when (val state = receiveState) {
|
||||||
|
is ReceiveState.Idle -> {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = amountText,
|
||||||
|
onValueChange = { amountText = it.filter { c -> c.isDigit() } },
|
||||||
|
label = { Text(stringRes(R.string.wallet_amount_sats)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = descriptionText,
|
||||||
|
onValueChange = { descriptionText = it },
|
||||||
|
label = { Text(stringRes(R.string.wallet_description)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val amount = amountText.toLongOrNull()
|
||||||
|
if (amount != null && amount > 0) {
|
||||||
|
walletViewModel.createInvoice(
|
||||||
|
amountSats = amount,
|
||||||
|
description = descriptionText.ifBlank { null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
enabled = amountText.isNotBlank() && (amountText.toLongOrNull() ?: 0L) > 0,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringRes(R.string.wallet_create_invoice),
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReceiveState.Creating -> {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(48.dp))
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
stringRes(R.string.wallet_creating_invoice),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReceiveState.Created -> {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
val formattedAmount =
|
||||||
|
remember(state.amount) {
|
||||||
|
val fmt = NumberFormat.getIntegerInstance()
|
||||||
|
fmt.format(state.amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "$formattedAmount ${stringRes(R.string.wallet_sats)}",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
QrCodeDrawer(
|
||||||
|
contents = state.invoice,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = state.invoice,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("invoice", state.invoice))
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(48.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ContentCopy,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(stringRes(R.string.wallet_copy_invoice))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReceiveState.Error -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
state.message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Button(onClick = { walletViewModel.resetReceiveState() }) {
|
||||||
|
Text(stringRes(R.string.back))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+279
@@ -0,0 +1,279 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.List
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
import java.text.NumberFormat
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun WalletScreen(
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
|
) {
|
||||||
|
val walletViewModel: WalletViewModel = viewModel()
|
||||||
|
|
||||||
|
LaunchedEffect(accountViewModel) {
|
||||||
|
walletViewModel.init(accountViewModel.account)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringRes(R.string.wallet)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { nav.popBack() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringRes(R.string.back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
if (!walletViewModel.hasWalletSetup()) {
|
||||||
|
NoWalletSetup(
|
||||||
|
modifier = Modifier.padding(padding),
|
||||||
|
nav = nav,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
WalletHomeContent(
|
||||||
|
walletViewModel = walletViewModel,
|
||||||
|
modifier = Modifier.padding(padding),
|
||||||
|
nav = nav,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NoWalletSetup(
|
||||||
|
modifier: Modifier,
|
||||||
|
nav: INav,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringRes(R.string.wallet_no_connection),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = stringRes(R.string.wallet_no_connection_description),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
Button(onClick = { nav.nav(Route.Nip47NWCSetup()) }) {
|
||||||
|
Text(stringRes(R.string.wallet_setup))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WalletHomeContent(
|
||||||
|
walletViewModel: WalletViewModel,
|
||||||
|
modifier: Modifier,
|
||||||
|
nav: INav,
|
||||||
|
) {
|
||||||
|
val balance by walletViewModel.balanceSats.collectAsState()
|
||||||
|
val walletAlias by walletViewModel.walletAlias.collectAsState()
|
||||||
|
val isLoading by walletViewModel.isLoading.collectAsState()
|
||||||
|
val error by walletViewModel.error.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
walletViewModel.fetchBalance()
|
||||||
|
walletViewModel.fetchInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
// Wallet name
|
||||||
|
if (walletAlias != null) {
|
||||||
|
Text(
|
||||||
|
text = walletAlias!!,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Balance display
|
||||||
|
if (isLoading && balance == null) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(48.dp))
|
||||||
|
} else {
|
||||||
|
val formattedBalance =
|
||||||
|
remember(balance) {
|
||||||
|
val fmt = NumberFormat.getIntegerInstance()
|
||||||
|
fmt.format(balance ?: 0L)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = formattedBalance,
|
||||||
|
fontSize = 48.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringRes(R.string.wallet_sats),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error
|
||||||
|
if (error != null) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = error!!,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { nav.nav(Route.WalletReceive) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(56.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors =
|
||||||
|
ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ArrowDownward,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(stringRes(R.string.wallet_receive), fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { nav.nav(Route.WalletSend) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(56.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ArrowUpward,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(stringRes(R.string.wallet_send), fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Transactions button
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { nav.nav(Route.WalletTransactions) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.List,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(stringRes(R.string.wallet_transactions))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
+219
@@ -0,0 +1,219 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
|
||||||
|
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.ContentPaste
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun WalletSendScreen(
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
|
) {
|
||||||
|
val walletViewModel: WalletViewModel = viewModel()
|
||||||
|
|
||||||
|
LaunchedEffect(accountViewModel) {
|
||||||
|
walletViewModel.init(accountViewModel.account)
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose { walletViewModel.resetSendState() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val sendState by walletViewModel.sendState.collectAsState()
|
||||||
|
var invoiceText by remember { mutableStateOf("") }
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringRes(R.string.wallet_send)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { nav.popBack() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringRes(R.string.back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(padding)
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
when (val state = sendState) {
|
||||||
|
is SendState.Idle -> {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = invoiceText,
|
||||||
|
onValueChange = { invoiceText = it },
|
||||||
|
label = { Text(stringRes(R.string.wallet_paste_invoice)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
minLines = 3,
|
||||||
|
maxLines = 5,
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
val clip = clipboard.primaryClip
|
||||||
|
if (clip != null && clip.itemCount > 0) {
|
||||||
|
invoiceText = clip.getItemAt(0).text?.toString() ?: ""
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ContentPaste,
|
||||||
|
contentDescription = "Paste",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (invoiceText.isNotBlank()) {
|
||||||
|
walletViewModel.sendPayment(invoiceText.trim())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
enabled = invoiceText.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringRes(R.string.wallet_pay),
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is SendState.Sending -> {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(48.dp))
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
stringRes(R.string.wallet_payment_sending),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
is SendState.Success -> {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
stringRes(R.string.wallet_payment_success),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Button(
|
||||||
|
onClick = { nav.popBack() },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
) {
|
||||||
|
Text(stringRes(R.string.back))
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
is SendState.Error -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
state.message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Button(onClick = { walletViewModel.resetSendState() }) {
|
||||||
|
Text(stringRes(R.string.back))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+226
@@ -0,0 +1,226 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransaction
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransactionType
|
||||||
|
import java.text.NumberFormat
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun WalletTransactionsScreen(
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
|
) {
|
||||||
|
val walletViewModel: WalletViewModel = viewModel()
|
||||||
|
|
||||||
|
LaunchedEffect(accountViewModel) {
|
||||||
|
walletViewModel.init(accountViewModel.account)
|
||||||
|
walletViewModel.fetchTransactions()
|
||||||
|
}
|
||||||
|
|
||||||
|
val transactions by walletViewModel.transactions.collectAsState()
|
||||||
|
val isLoading by walletViewModel.isLoading.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringRes(R.string.wallet_transactions)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { nav.popBack() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringRes(R.string.back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { walletViewModel.fetchTransactions() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Refresh,
|
||||||
|
contentDescription = stringRes(R.string.wallet_refresh),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
if (isLoading && transactions.isEmpty()) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(padding)
|
||||||
|
.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(48.dp))
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
stringRes(R.string.wallet_loading),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (transactions.isEmpty()) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(padding)
|
||||||
|
.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringRes(R.string.wallet_no_transactions),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.padding(padding),
|
||||||
|
) {
|
||||||
|
items(transactions) { tx ->
|
||||||
|
TransactionItem(tx)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TransactionItem(tx: NwcTransaction) {
|
||||||
|
val isIncoming = tx.type == NwcTransactionType.INCOMING
|
||||||
|
val amountSats = (tx.amount ?: 0L) / 1000L
|
||||||
|
val formattedAmount =
|
||||||
|
remember(amountSats) {
|
||||||
|
val fmt = NumberFormat.getIntegerInstance()
|
||||||
|
(if (isIncoming) "+" else "-") + fmt.format(amountSats)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dateText =
|
||||||
|
remember(tx.created_at) {
|
||||||
|
tx.created_at?.let {
|
||||||
|
val sdf = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault())
|
||||||
|
sdf.format(Date(it * 1000L))
|
||||||
|
} ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector =
|
||||||
|
if (isIncoming) Icons.Filled.ArrowDownward else Icons.Filled.ArrowUpward,
|
||||||
|
contentDescription =
|
||||||
|
if (isIncoming) {
|
||||||
|
stringRes(R.string.wallet_incoming)
|
||||||
|
} else {
|
||||||
|
stringRes(R.string.wallet_outgoing)
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint =
|
||||||
|
if (isIncoming) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = tx.description ?: if (isIncoming) stringRes(R.string.wallet_incoming) else stringRes(R.string.wallet_outgoing),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = dateText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "$formattedAmount sats",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color =
|
||||||
|
if (isIncoming) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onBackground
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceMethod
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoMethod
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceMethod
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceSuccessResponse
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorResponse
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransaction
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod
|
||||||
|
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
sealed class SendState {
|
||||||
|
data object Idle : SendState()
|
||||||
|
|
||||||
|
data object Sending : SendState()
|
||||||
|
|
||||||
|
data class Success(
|
||||||
|
val preimage: String?,
|
||||||
|
) : SendState()
|
||||||
|
|
||||||
|
data class Error(
|
||||||
|
val message: String,
|
||||||
|
) : SendState()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class ReceiveState {
|
||||||
|
data object Idle : ReceiveState()
|
||||||
|
|
||||||
|
data object Creating : ReceiveState()
|
||||||
|
|
||||||
|
data class Created(
|
||||||
|
val invoice: String,
|
||||||
|
val amount: Long,
|
||||||
|
) : ReceiveState()
|
||||||
|
|
||||||
|
data class Error(
|
||||||
|
val message: String,
|
||||||
|
) : ReceiveState()
|
||||||
|
}
|
||||||
|
|
||||||
|
class WalletViewModel : ViewModel() {
|
||||||
|
private var account: Account? = null
|
||||||
|
|
||||||
|
private val _balanceSats = MutableStateFlow<Long?>(null)
|
||||||
|
val balanceSats = _balanceSats.asStateFlow()
|
||||||
|
|
||||||
|
private val _walletAlias = MutableStateFlow<String?>(null)
|
||||||
|
val walletAlias = _walletAlias.asStateFlow()
|
||||||
|
|
||||||
|
private val _transactions = MutableStateFlow<List<NwcTransaction>>(emptyList())
|
||||||
|
val transactions = _transactions.asStateFlow()
|
||||||
|
|
||||||
|
private val _isLoading = MutableStateFlow(false)
|
||||||
|
val isLoading = _isLoading.asStateFlow()
|
||||||
|
|
||||||
|
private val _error = MutableStateFlow<String?>(null)
|
||||||
|
val error = _error.asStateFlow()
|
||||||
|
|
||||||
|
private val _sendState = MutableStateFlow<SendState>(SendState.Idle)
|
||||||
|
val sendState = _sendState.asStateFlow()
|
||||||
|
|
||||||
|
private val _receiveState = MutableStateFlow<ReceiveState>(ReceiveState.Idle)
|
||||||
|
val receiveState = _receiveState.asStateFlow()
|
||||||
|
|
||||||
|
fun init(account: Account) {
|
||||||
|
this.account = account
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasWalletSetup(): Boolean = account?.nip47SignerState?.hasWalletConnectSetup() == true
|
||||||
|
|
||||||
|
fun fetchBalance() {
|
||||||
|
val acc = account ?: return
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
_isLoading.value = true
|
||||||
|
_error.value = null
|
||||||
|
try {
|
||||||
|
acc.sendNwcRequest(GetBalanceMethod.create()) { response ->
|
||||||
|
when (response) {
|
||||||
|
is GetBalanceSuccessResponse -> {
|
||||||
|
// NWC balance is in millisats, convert to sats
|
||||||
|
_balanceSats.value = (response.result?.balance ?: 0L) / 1000L
|
||||||
|
}
|
||||||
|
is NwcErrorResponse -> {
|
||||||
|
_error.value = response.error?.message ?: "Balance request failed"
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_error.value = e.message
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchInfo() {
|
||||||
|
val acc = account ?: return
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
acc.sendNwcRequest(GetInfoMethod.create()) { response ->
|
||||||
|
when (response) {
|
||||||
|
is GetInfoSuccessResponse -> {
|
||||||
|
_walletAlias.value = response.result?.alias
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// ignore info errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchTransactions(
|
||||||
|
limit: Int = 20,
|
||||||
|
offset: Int = 0,
|
||||||
|
) {
|
||||||
|
val acc = account ?: return
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
_isLoading.value = true
|
||||||
|
try {
|
||||||
|
acc.sendNwcRequest(
|
||||||
|
ListTransactionsMethod.create(
|
||||||
|
limit = limit,
|
||||||
|
offset = offset,
|
||||||
|
unpaid = false,
|
||||||
|
),
|
||||||
|
) { response ->
|
||||||
|
when (response) {
|
||||||
|
is ListTransactionsSuccessResponse -> {
|
||||||
|
_transactions.value = response.result?.transactions ?: emptyList()
|
||||||
|
}
|
||||||
|
is NwcErrorResponse -> {
|
||||||
|
_error.value = response.error?.message ?: "Failed to load transactions"
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_error.value = e.message
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendPayment(bolt11: String) {
|
||||||
|
val acc = account ?: return
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
_sendState.value = SendState.Sending
|
||||||
|
try {
|
||||||
|
acc.sendNwcRequest(PayInvoiceMethod.create(bolt11)) { response ->
|
||||||
|
when (response) {
|
||||||
|
is PayInvoiceSuccessResponse -> {
|
||||||
|
_sendState.value = SendState.Success(response.result?.preimage)
|
||||||
|
// Refresh balance after payment
|
||||||
|
fetchBalance()
|
||||||
|
}
|
||||||
|
is PayInvoiceErrorResponse -> {
|
||||||
|
_sendState.value = SendState.Error(
|
||||||
|
response.error?.message ?: "Payment failed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is NwcErrorResponse -> {
|
||||||
|
_sendState.value = SendState.Error(
|
||||||
|
response.error?.message ?: "Payment failed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
_sendState.value = SendState.Error("Unexpected response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_sendState.value = SendState.Error(e.message ?: "Payment failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createInvoice(
|
||||||
|
amountSats: Long,
|
||||||
|
description: String? = null,
|
||||||
|
) {
|
||||||
|
val acc = account ?: return
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
_receiveState.value = ReceiveState.Creating
|
||||||
|
try {
|
||||||
|
// NWC expects millisats
|
||||||
|
acc.sendNwcRequest(
|
||||||
|
MakeInvoiceMethod.create(
|
||||||
|
amount = amountSats * 1000L,
|
||||||
|
description = description,
|
||||||
|
),
|
||||||
|
) { response ->
|
||||||
|
when (response) {
|
||||||
|
is MakeInvoiceSuccessResponse -> {
|
||||||
|
val invoice = response.result?.invoice
|
||||||
|
if (invoice != null) {
|
||||||
|
_receiveState.value = ReceiveState.Created(invoice, amountSats)
|
||||||
|
} else {
|
||||||
|
_receiveState.value = ReceiveState.Error("No invoice returned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is NwcErrorResponse -> {
|
||||||
|
_receiveState.value = ReceiveState.Error(
|
||||||
|
response.error?.message ?: "Invoice creation failed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
_receiveState.value = ReceiveState.Error("Unexpected response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_receiveState.value = ReceiveState.Error(e.message ?: "Invoice creation failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetSendState() {
|
||||||
|
_sendState.value = SendState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetReceiveState() {
|
||||||
|
_receiveState.value = ReceiveState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_error.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1236,6 +1236,29 @@
|
|||||||
<string name="route_global">Global</string>
|
<string name="route_global">Global</string>
|
||||||
<string name="route_video">Shorts</string>
|
<string name="route_video">Shorts</string>
|
||||||
<string name="route_chess">Chess</string>
|
<string name="route_chess">Chess</string>
|
||||||
|
<string name="wallet">Wallet</string>
|
||||||
|
<string name="wallet_balance">Balance</string>
|
||||||
|
<string name="wallet_send">Send</string>
|
||||||
|
<string name="wallet_receive">Receive</string>
|
||||||
|
<string name="wallet_transactions">Transactions</string>
|
||||||
|
<string name="wallet_no_connection">No wallet connected</string>
|
||||||
|
<string name="wallet_no_connection_description">Set up a Nostr Wallet Connect (NWC) connection in your zap settings to use the wallet.</string>
|
||||||
|
<string name="wallet_setup">Set Up Wallet</string>
|
||||||
|
<string name="wallet_sats">sats</string>
|
||||||
|
<string name="wallet_paste_invoice">Paste a BOLT-11 invoice</string>
|
||||||
|
<string name="wallet_pay">Pay</string>
|
||||||
|
<string name="wallet_payment_success">Payment successful</string>
|
||||||
|
<string name="wallet_payment_sending">Sending payment…</string>
|
||||||
|
<string name="wallet_amount_sats">Amount (sats)</string>
|
||||||
|
<string name="wallet_description">Description (optional)</string>
|
||||||
|
<string name="wallet_create_invoice">Create Invoice</string>
|
||||||
|
<string name="wallet_creating_invoice">Creating invoice…</string>
|
||||||
|
<string name="wallet_copy_invoice">Copy Invoice</string>
|
||||||
|
<string name="wallet_no_transactions">No transactions yet</string>
|
||||||
|
<string name="wallet_loading">Loading…</string>
|
||||||
|
<string name="wallet_incoming">Received</string>
|
||||||
|
<string name="wallet_outgoing">Sent</string>
|
||||||
|
<string name="wallet_refresh">Refresh</string>
|
||||||
<string name="route_security_filters">Security Filters</string>
|
<string name="route_security_filters">Security Filters</string>
|
||||||
<string name="route_import_follows">Import Follows</string>
|
<string name="route_import_follows">Import Follows</string>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user