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:
Claude
2026-03-14 03:52:41 +00:00
parent 83a6feeca4
commit 4b76c70c29
11 changed files with 1353 additions and 0 deletions
@@ -171,6 +171,7 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -581,6 +582,14 @@ class Account(
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(
bolt11: String,
zappedNote: Note?,
@@ -138,6 +138,45 @@ class NwcSignerState(
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.
* 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.threadview.ThreadScreen
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.uriToRoute
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@@ -151,6 +155,11 @@ fun AppNavigation(
composable<Route.Notification> { NotificationScreen(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) }
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
composableFromEndArgs<Route.MyFollowPackView> { FollowPackScreen(it.dTag, accountViewModel, nav) }
@@ -49,6 +49,7 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.Drafts
import androidx.compose.material.icons.outlined.GroupAdd
import androidx.compose.material.icons.outlined.AccountBalanceWallet
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -463,6 +464,14 @@ fun ListContent(
route = Route.Drafts,
)
NavigationRow(
title = R.string.wallet,
icon = Icons.Outlined.AccountBalanceWallet,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Wallet,
)
NavigationRow(
title = R.string.route_chess,
icon = R.drawable.ic_chess,
@@ -43,6 +43,14 @@ sealed class 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 SecurityFilters : Route()
@@ -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))
}
}
}
}
}
}
}
@@ -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))
}
}
@@ -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))
}
}
}
}
}
}
}
@@ -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
},
)
}
}
@@ -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
}
}
+23
View File
@@ -1236,6 +1236,29 @@
<string name="route_global">Global</string>
<string name="route_video">Shorts</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_import_follows">Import Follows</string>