mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
feat(amethyst): scaffold NIP-60 Cashu wallet UI + state
Adds the user-visible scaffolding for a Cashu wallet alongside the
existing NWC wallets. View-only for now — minting, send/receive, and
NIP-61 nutzaps land in a follow-up commit on this branch.
UI
* AddWalletScreen is now a wallet-type chooser. The existing NWC
flow moves verbatim to AddNwcWalletScreen; AddCashuWalletScreen
is new: takes one or more mint URLs, auto-generates a separate
P2PK key for nutzap receiving (or accepts a pasted hex key), and
publishes a kind:17375 wallet event via the account's signer
using CashuWalletEvent.build(mints, privkey).
* CashuWalletScreen renders the wallet's mint list, total balance
in sats (summed across all unspent kind:7375 token events the
signer can decrypt, with rollover applied via the `del` field),
and a chronological history view sourced from kind:7376.
* WalletScreen surfaces the Cashu wallet as a card under "Your
Wallets" when one exists, so the Wallets entry point shows both
wallet kinds side by side.
Relay subscription
* CashuWalletFilterAssembler (commons) subscribes one filter per
relay covering kinds 17375/7375/7376/7374/10019 by author and
one targeting inbound kind:9321 via #p. Not yet wired into
Account.kt — the view path works because we feed our own writes
through cache.justConsumeMyOwnEvent. Cross-device sync requires
the assembler subscription wiring, which comes next.
Plumbing
* Routes.WalletAddNwc / WalletAddCashu / CashuWallet added and
registered in AppNavigation.
* CashuWalletEvent.createAddress(pubKey) mirrors MetadataEvent for
looking up the replaceable wallet event from LocalCache.
Compiles clean on playDebug + fdroidDebug; BDHKE jvm tests still
pass (7/7).
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit is contained in:
@@ -186,7 +186,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppsSc
|
||||
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.hls.NewHlsVideoScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddCashuWalletScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddNwcWalletScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddWalletScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuWalletScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainTransactionsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletDetailScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen
|
||||
@@ -291,6 +294,9 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.OnchainTransactions> { OnchainTransactionsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.WalletDetail> { WalletDetailScreen(it.walletId, accountViewModel, nav) }
|
||||
composableFromEnd<Route.WalletAdd> { AddWalletScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.WalletAddNwc> { AddNwcWalletScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.WalletAddCashu> { AddCashuWalletScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.CashuWallet> { CashuWalletScreen(accountViewModel, nav) }
|
||||
|
||||
composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
|
||||
|
||||
@@ -170,6 +170,12 @@ sealed class Route {
|
||||
|
||||
@Serializable object WalletAdd : Route()
|
||||
|
||||
@Serializable object WalletAddNwc : Route()
|
||||
|
||||
@Serializable object WalletAddCashu : Route()
|
||||
|
||||
@Serializable object CashuWallet : Route()
|
||||
|
||||
@Serializable object Search : Route()
|
||||
|
||||
@Serializable object SecurityFilters : Route()
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* 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.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
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.Switch
|
||||
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.mutableStateListOf
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
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 AddCashuWalletScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val viewModel: CashuWalletViewModel = viewModel()
|
||||
LaunchedEffect(Unit) { viewModel.init(accountViewModel) }
|
||||
|
||||
val mints = remember { mutableStateListOf<String>() }
|
||||
var mintInput by remember { mutableStateOf("") }
|
||||
var autoGenPrivkey by remember { mutableStateOf(true) }
|
||||
var manualPrivkey by remember { mutableStateOf("") }
|
||||
val createState by viewModel.createState.collectAsState()
|
||||
|
||||
LaunchedEffect(createState) {
|
||||
if (createState is CashuWalletCreateState.Success) {
|
||||
nav.popBack()
|
||||
viewModel.resetCreateState()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringRes(R.string.wallet_add_cashu_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { nav.popBack() }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
|
||||
contentDescription = stringRes(R.string.back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.cashu_mints),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
mints.forEachIndexed { index, mint ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = mint,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
IconButton(onClick = { mints.removeAt(index) }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Delete,
|
||||
contentDescription = stringRes(R.string.cashu_remove_mint),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = mintInput,
|
||||
onValueChange = { mintInput = it },
|
||||
label = { Text(stringRes(R.string.cashu_mint_url)) },
|
||||
placeholder = { Text("https://mint.example.com") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val trimmed = mintInput.trim().trimEnd('/')
|
||||
if (trimmed.isNotEmpty() && trimmed !in mints) {
|
||||
mints.add(trimmed)
|
||||
mintInput = ""
|
||||
}
|
||||
},
|
||||
enabled = mintInput.isNotBlank(),
|
||||
) {
|
||||
Icon(MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = stringRes(R.string.cashu_p2pk_section),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.cashu_p2pk_explainer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(stringRes(R.string.cashu_p2pk_autogen))
|
||||
Switch(checked = autoGenPrivkey, onCheckedChange = { autoGenPrivkey = it })
|
||||
}
|
||||
|
||||
if (!autoGenPrivkey) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = manualPrivkey,
|
||||
onValueChange = { manualPrivkey = it },
|
||||
label = { Text(stringRes(R.string.cashu_p2pk_manual_label)) },
|
||||
placeholder = { Text("hex…") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
val err = (createState as? CashuWalletCreateState.Error)?.message
|
||||
if (err != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = err,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.saveWallet(
|
||||
mints = mints.toList(),
|
||||
autoGenPrivkey = autoGenPrivkey,
|
||||
manualPrivkey = manualPrivkey.takeIf { !autoGenPrivkey },
|
||||
)
|
||||
},
|
||||
enabled =
|
||||
mints.isNotEmpty() &&
|
||||
createState !is CashuWalletCreateState.Saving,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringRes(R.string.wallet_save))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
/*
|
||||
* 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.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.material3.Button
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.components.util.getText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.painterRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddNwcWalletScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val walletViewModel: WalletViewModel = viewModel()
|
||||
walletViewModel.init(accountViewModel)
|
||||
|
||||
var walletName by remember { mutableStateOf("") }
|
||||
var nwcUri by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var qrScanning by remember { mutableStateOf(false) }
|
||||
|
||||
val uri = LocalUriHandler.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringRes(R.string.wallet_add_nwc_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { nav.popBack() }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
|
||||
contentDescription = stringRes(R.string.back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = walletName,
|
||||
onValueChange = { walletName = it },
|
||||
label = { Text(stringRes(R.string.wallet_name)) },
|
||||
placeholder = { Text(stringRes(R.string.wallet_name_hint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Connect action buttons: Connect Wallet app, Paste, QR scan
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = ButtonBorder,
|
||||
onClick = {
|
||||
try {
|
||||
uri.openUri(
|
||||
"nostrnwc://connect?appname=Amethyst&appicon=https%3A%2F%2Fraw.githubusercontent.com%2Fvitorpamplona%2Famethyst%2Frefs%2Fheads%2Fmain%2Ficon.png&callback=amethyst%2Bwalletconnect%3A%2F%2Fdlnwc",
|
||||
)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.couldnt_find_nwc_wallets,
|
||||
R.string.couldnt_find_nwc_wallets_description,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(text = stringRes(R.string.wallet_connect_connect_app))
|
||||
}
|
||||
|
||||
Spacer(DoubleHorzSpacer)
|
||||
|
||||
// Paste from clipboard
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val clipText = clipboardManager.getText()
|
||||
if (clipText != null) {
|
||||
nwcUri = clipText
|
||||
error = null
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ContentPaste,
|
||||
contentDescription = stringRes(id = R.string.paste_from_clipboard),
|
||||
modifier = Size24Modifier,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
// QR code scanner
|
||||
IconButton(onClick = { qrScanning = true }) {
|
||||
Icon(
|
||||
painter = painterRes(R.drawable.ic_qrcode, 3),
|
||||
contentDescription = stringRes(id = R.string.accessibility_scan_qr_code),
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (qrScanning) {
|
||||
SimpleQrCodeScanner {
|
||||
qrScanning = false
|
||||
if (!it.isNullOrEmpty()) {
|
||||
nwcUri = it
|
||||
error = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = nwcUri,
|
||||
onValueChange = {
|
||||
nwcUri = it
|
||||
error = null
|
||||
},
|
||||
label = { Text(stringRes(R.string.wallet_paste_uri)) },
|
||||
placeholder = { Text("nostr+walletconnect://...") },
|
||||
minLines = 3,
|
||||
maxLines = 5,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
if (error != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = error!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
try {
|
||||
val parsed = Nip47WalletConnect.parse(nwcUri.trim())
|
||||
walletViewModel.addWallet(walletName.trim(), parsed)
|
||||
nav.popBack()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
error = e.message ?: "Invalid NWC connection URI"
|
||||
}
|
||||
},
|
||||
enabled = nwcUri.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringRes(R.string.wallet_save))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
-161
@@ -20,6 +20,8 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -29,66 +31,44 @@ 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.material3.Button
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
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.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.components.util.getText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.painterRes
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Wallet-type chooser. Each card routes to a dedicated setup screen for the
|
||||
* selected wallet kind.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddWalletScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
@Suppress("UNUSED_PARAMETER") accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val walletViewModel: WalletViewModel = viewModel()
|
||||
walletViewModel.init(accountViewModel)
|
||||
|
||||
var walletName by remember { mutableStateOf("") }
|
||||
var nwcUri by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var qrScanning by remember { mutableStateOf(false) }
|
||||
|
||||
val uri = LocalUriHandler.current
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringRes(R.string.wallet_add_connection)) },
|
||||
title = { Text(stringRes(R.string.wallet_add_choose_type)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { nav.popBack() }) {
|
||||
Icon(
|
||||
@@ -105,136 +85,75 @@ fun AddWalletScreen(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp),
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = walletName,
|
||||
onValueChange = { walletName = it },
|
||||
label = { Text(stringRes(R.string.wallet_name)) },
|
||||
placeholder = { Text(stringRes(R.string.wallet_name_hint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
WalletTypeCard(
|
||||
icon = MaterialSymbols.Bolt,
|
||||
title = stringRes(R.string.wallet_add_nwc_title),
|
||||
description = stringRes(R.string.wallet_add_nwc_description),
|
||||
onClick = { nav.nav(Route.WalletAddNwc) },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Connect action buttons: Connect Wallet app, Paste, QR scan
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = ButtonBorder,
|
||||
onClick = {
|
||||
try {
|
||||
uri.openUri(
|
||||
"nostrnwc://connect?appname=Amethyst&appicon=https%3A%2F%2Fraw.githubusercontent.com%2Fvitorpamplona%2Famethyst%2Frefs%2Fheads%2Fmain%2Ficon.png&callback=amethyst%2Bwalletconnect%3A%2F%2Fdlnwc",
|
||||
)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
accountViewModel.toastManager.toast(
|
||||
R.string.couldnt_find_nwc_wallets,
|
||||
R.string.couldnt_find_nwc_wallets_description,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(text = stringRes(R.string.wallet_connect_connect_app))
|
||||
}
|
||||
|
||||
Spacer(DoubleHorzSpacer)
|
||||
|
||||
// Paste from clipboard
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val clipText = clipboardManager.getText()
|
||||
if (clipText != null) {
|
||||
nwcUri = clipText
|
||||
error = null
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ContentPaste,
|
||||
contentDescription = stringRes(id = R.string.paste_from_clipboard),
|
||||
modifier = Size24Modifier,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
// QR code scanner
|
||||
IconButton(onClick = { qrScanning = true }) {
|
||||
Icon(
|
||||
painter = painterRes(R.drawable.ic_qrcode, 3),
|
||||
contentDescription = stringRes(id = R.string.accessibility_scan_qr_code),
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (qrScanning) {
|
||||
SimpleQrCodeScanner {
|
||||
qrScanning = false
|
||||
if (!it.isNullOrEmpty()) {
|
||||
nwcUri = it
|
||||
error = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = nwcUri,
|
||||
onValueChange = {
|
||||
nwcUri = it
|
||||
error = null
|
||||
},
|
||||
label = { Text(stringRes(R.string.wallet_paste_uri)) },
|
||||
placeholder = { Text("nostr+walletconnect://...") },
|
||||
minLines = 3,
|
||||
maxLines = 5,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
WalletTypeCard(
|
||||
icon = MaterialSymbols.AccountBalanceWallet,
|
||||
title = stringRes(R.string.wallet_add_cashu_title),
|
||||
description = stringRes(R.string.wallet_add_cashu_description),
|
||||
onClick = { nav.nav(Route.WalletAddCashu) },
|
||||
)
|
||||
|
||||
if (error != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = error!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
try {
|
||||
val parsed = Nip47WalletConnect.parse(nwcUri.trim())
|
||||
walletViewModel.addWallet(walletName.trim(), parsed)
|
||||
nav.popBack()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
error = e.message ?: "Invalid NWC connection URI"
|
||||
}
|
||||
},
|
||||
enabled = nwcUri.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringRes(R.string.wallet_save))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WalletTypeCard(
|
||||
icon: MaterialSymbol,
|
||||
title: String,
|
||||
description: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.ArrowForward,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
/*
|
||||
* 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.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
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 com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
|
||||
import java.text.DateFormat
|
||||
import java.text.NumberFormat
|
||||
import java.util.Date
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CashuWalletScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val viewModel: CashuWalletViewModel = viewModel()
|
||||
LaunchedEffect(Unit) { viewModel.init(accountViewModel) }
|
||||
|
||||
val walletEvent by viewModel.walletEvent.collectAsState()
|
||||
val mints by viewModel.mints.collectAsState()
|
||||
val balanceSats by viewModel.balanceSats.collectAsState()
|
||||
val history by viewModel.history.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringRes(R.string.cashu_wallet_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { nav.popBack() }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
|
||||
contentDescription = stringRes(R.string.back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { nav.nav(Route.WalletAddCashu) }) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Edit,
|
||||
contentDescription = stringRes(R.string.cashu_edit_wallet),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
if (walletEvent == null) {
|
||||
EmptyCashuWallet(
|
||||
modifier = Modifier.padding(padding),
|
||||
onCreate = { nav.nav(Route.WalletAddCashu) },
|
||||
)
|
||||
} else {
|
||||
CashuWalletContent(
|
||||
modifier = Modifier.padding(padding),
|
||||
balanceSats = balanceSats,
|
||||
mints = mints,
|
||||
history = history,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyCashuWallet(
|
||||
modifier: Modifier,
|
||||
onCreate: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.cashu_no_wallet),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.cashu_no_wallet_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(onClick = onCreate) {
|
||||
Text(stringRes(R.string.wallet_add_cashu_title))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CashuWalletContent(
|
||||
modifier: Modifier,
|
||||
balanceSats: Long,
|
||||
mints: List<String>,
|
||||
history: List<CashuSpendingHistoryEvent>,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
BalanceCard(balanceSats)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.cashu_mints),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
items(mints, key = { it }) { mint ->
|
||||
MintRow(mint)
|
||||
}
|
||||
|
||||
if (history.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.cashu_history),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
items(history, key = { it.id }) { entry ->
|
||||
HistoryRow(entry)
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(modifier = Modifier.height(24.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BalanceCard(balanceSats: Long) {
|
||||
val formatted =
|
||||
remember(balanceSats) {
|
||||
NumberFormat.getIntegerInstance().format(balanceSats)
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.cashu_balance),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = formatted,
|
||||
fontSize = 36.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.wallet_sats),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MintRow(mint: String) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AccountBalanceWallet,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(text = mint, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HistoryRow(entry: CashuSpendingHistoryEvent) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = entry.id.take(12) + "…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
DateFormat
|
||||
.getDateTimeInstance()
|
||||
.format(Date(entry.createdAt * 1000)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* 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.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke
|
||||
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed class CashuWalletCreateState {
|
||||
data object Idle : CashuWalletCreateState()
|
||||
|
||||
data object Saving : CashuWalletCreateState()
|
||||
|
||||
data object Success : CashuWalletCreateState()
|
||||
|
||||
data class Error(
|
||||
val message: String,
|
||||
) : CashuWalletCreateState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight ViewModel for the NIP-60 Cashu wallet screens — view + create.
|
||||
*
|
||||
* Send/receive/mint operations will land in a follow-up commit alongside the
|
||||
* BDHKE+mint-HTTP layer; this model only covers the view + bootstrap path.
|
||||
*/
|
||||
class CashuWalletViewModel : ViewModel() {
|
||||
private var account: Account? = null
|
||||
private var accountViewModel: AccountViewModel? = null
|
||||
|
||||
private val _walletEvent = MutableStateFlow<CashuWalletEvent?>(null)
|
||||
val walletEvent = _walletEvent.asStateFlow()
|
||||
|
||||
private val _mints = MutableStateFlow<List<String>>(emptyList())
|
||||
val mints = _mints.asStateFlow()
|
||||
|
||||
private val _balanceSats = MutableStateFlow(0L)
|
||||
val balanceSats = _balanceSats.asStateFlow()
|
||||
|
||||
private val _tokenEvents = MutableStateFlow<List<CashuTokenEvent>>(emptyList())
|
||||
val tokenEvents = _tokenEvents.asStateFlow()
|
||||
|
||||
private val _history = MutableStateFlow<List<CashuSpendingHistoryEvent>>(emptyList())
|
||||
val history = _history.asStateFlow()
|
||||
|
||||
private val _createState = MutableStateFlow<CashuWalletCreateState>(CashuWalletCreateState.Idle)
|
||||
val createState = _createState.asStateFlow()
|
||||
|
||||
fun init(accountViewModel: AccountViewModel) {
|
||||
this.accountViewModel = accountViewModel
|
||||
this.account = accountViewModel.account
|
||||
refresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the local cache for our wallet event and its associated token /
|
||||
* history events, decrypting whatever the current signer can decrypt and
|
||||
* re-emitting state flows.
|
||||
*
|
||||
* Today this is fire-and-forget. Reactive observation of the underlying
|
||||
* notes is a follow-up — for the first slice, the screen calls refresh()
|
||||
* on entry and after a write.
|
||||
*/
|
||||
fun refresh() {
|
||||
val acc = account ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val pubKey = acc.signer.pubKey
|
||||
val cache = LocalCache
|
||||
|
||||
val walletNote = cache.getOrCreateAddressableNote(CashuWalletEvent.createAddress(pubKey))
|
||||
val walletEvt = walletNote.event as? CashuWalletEvent
|
||||
_walletEvent.value = walletEvt
|
||||
|
||||
if (walletEvt != null) {
|
||||
runCatching { walletEvt.mints(acc.signer) }
|
||||
.onSuccess { _mints.value = it }
|
||||
} else {
|
||||
_mints.value = emptyList()
|
||||
}
|
||||
|
||||
val tokenList = mutableListOf<CashuTokenEvent>()
|
||||
val historyList = mutableListOf<CashuSpendingHistoryEvent>()
|
||||
cache.notes.forEach { _, note ->
|
||||
val e = note.event ?: return@forEach
|
||||
if (e.pubKey != pubKey) return@forEach
|
||||
when (e) {
|
||||
is CashuTokenEvent -> tokenList.add(e)
|
||||
is CashuSpendingHistoryEvent -> historyList.add(e)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
// Apply rollover: drop any token whose ID appears in a newer token's `del`.
|
||||
val deletedIds = mutableSetOf<String>()
|
||||
val decoded = mutableMapOf<String, TokenContent>()
|
||||
tokenList.forEach { tok ->
|
||||
runCatching { tok.tokenContent(acc.signer) }
|
||||
.getOrNull()
|
||||
?.let { content ->
|
||||
decoded[tok.id] = content
|
||||
deletedIds.addAll(content.del)
|
||||
}
|
||||
}
|
||||
val unspent = tokenList.filter { it.id !in deletedIds }
|
||||
_tokenEvents.value = unspent.sortedByDescending { it.createdAt }
|
||||
|
||||
val balance = unspent.sumOf { decoded[it.id]?.totalAmount() ?: 0L }
|
||||
_balanceSats.value = balance
|
||||
|
||||
_history.value = historyList.sortedByDescending { it.createdAt }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and publishes a kind:17375 wallet event. If `autoGenPrivkey` is true,
|
||||
* a fresh P2PK private key is generated for receiving NIP-61 nutzaps.
|
||||
*
|
||||
* The wallet event is replaceable, so calling this again with different mints
|
||||
* (or a different privkey) overwrites the previous one — the existing logic
|
||||
* in account.sendLiterallyEverywhere takes care of broadcasting and local
|
||||
* cache consumption.
|
||||
*/
|
||||
fun saveWallet(
|
||||
mints: List<String>,
|
||||
autoGenPrivkey: Boolean,
|
||||
manualPrivkey: String? = null,
|
||||
) {
|
||||
val vm = accountViewModel ?: return
|
||||
val acc = account ?: return
|
||||
|
||||
if (mints.isEmpty()) {
|
||||
_createState.value = CashuWalletCreateState.Error("Add at least one mint")
|
||||
return
|
||||
}
|
||||
|
||||
_createState.value = CashuWalletCreateState.Saving
|
||||
vm.launchSigner {
|
||||
try {
|
||||
val privkey =
|
||||
when {
|
||||
autoGenPrivkey -> Bdhke.randomScalar().toHexKey()
|
||||
!manualPrivkey.isNullOrBlank() -> manualPrivkey.trim()
|
||||
else -> null
|
||||
}
|
||||
|
||||
val template = CashuWalletEvent.build(mints, privkey, acc.signer)
|
||||
val event = acc.signer.sign(template)
|
||||
acc.sendLiterallyEverywhere(event)
|
||||
_createState.value = CashuWalletCreateState.Success
|
||||
refresh()
|
||||
} catch (e: Exception) {
|
||||
_createState.value =
|
||||
CashuWalletCreateState.Error(e.message ?: "Failed to save wallet")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetCreateState() {
|
||||
_createState.value = CashuWalletCreateState.Idle
|
||||
}
|
||||
}
|
||||
+89
-1
@@ -61,6 +61,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -90,7 +91,15 @@ fun WalletScreen(
|
||||
val walletViewModel: WalletViewModel = viewModel()
|
||||
walletViewModel.init(accountViewModel)
|
||||
|
||||
val hasWallet by walletViewModel.hasWalletSetup.collectAsState()
|
||||
val cashuWalletViewModel: CashuWalletViewModel = viewModel()
|
||||
LaunchedEffect(Unit) { cashuWalletViewModel.init(accountViewModel) }
|
||||
|
||||
val hasNwcWallet by walletViewModel.hasWalletSetup.collectAsState()
|
||||
val cashuWalletEvent by cashuWalletViewModel.walletEvent.collectAsState()
|
||||
val hasCashuWallet = cashuWalletEvent != null
|
||||
val hasWallet = hasNwcWallet || hasCashuWallet
|
||||
val cashuBalanceSats by cashuWalletViewModel.balanceSats.collectAsState()
|
||||
val cashuMints by cashuWalletViewModel.mints.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
@@ -145,6 +154,9 @@ fun WalletScreen(
|
||||
modifier = Modifier,
|
||||
listState = listState,
|
||||
nav = nav,
|
||||
hasCashuWallet = hasCashuWallet,
|
||||
cashuBalanceSats = cashuBalanceSats,
|
||||
cashuMintCount = cashuMints.size,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -191,6 +203,9 @@ private fun MultiWalletHomeContent(
|
||||
modifier: Modifier,
|
||||
listState: LazyListState,
|
||||
nav: INav,
|
||||
hasCashuWallet: Boolean,
|
||||
cashuBalanceSats: Long,
|
||||
cashuMintCount: Int,
|
||||
) {
|
||||
val walletInfoList by walletViewModel.walletInfoList.collectAsState()
|
||||
|
||||
@@ -246,6 +261,16 @@ private fun MultiWalletHomeContent(
|
||||
)
|
||||
}
|
||||
|
||||
if (hasCashuWallet) {
|
||||
item {
|
||||
CashuWalletRow(
|
||||
balanceSats = cashuBalanceSats,
|
||||
mintCount = cashuMintCount,
|
||||
onClick = { nav.nav(Route.CashuWallet) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
@@ -462,6 +487,69 @@ private fun WalletCard(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CashuWalletRow(
|
||||
balanceSats: Long,
|
||||
mintCount: Int,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val formattedBalance =
|
||||
remember(balanceSats) { NumberFormat.getIntegerInstance().format(balanceSats) }
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AccountBalanceWallet,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(28.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringRes(R.string.cashu_wallet_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
pluralStringResource(
|
||||
R.plurals.cashu_wallet_subtitle_mints,
|
||||
mintCount,
|
||||
mintCount,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = formattedBalance,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.wallet_sats),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenameWalletDialog(
|
||||
currentName: String,
|
||||
|
||||
@@ -1868,6 +1868,24 @@
|
||||
<string name="wallet_no_wallets">No wallets connected</string>
|
||||
<string name="wallet_no_wallets_description">Connect one or more NWC wallets to send and receive payments.</string>
|
||||
<string name="wallet_add_connection">Add NWC Connection</string>
|
||||
<string name="wallet_add_choose_type">Choose wallet type</string>
|
||||
<string name="wallet_add_nwc_title">Connect a Lightning wallet (NWC)</string>
|
||||
<string name="wallet_add_nwc_description">Pay via an external Lightning wallet using Nostr Wallet Connect.</string>
|
||||
<string name="wallet_add_cashu_title">Cashu wallet (NIP-60)</string>
|
||||
<string name="wallet_add_cashu_description">An in-app ecash wallet backed by a Cashu mint. Tokens are stored encrypted on relays.</string>
|
||||
<string name="cashu_wallet_title">Cashu Wallet</string>
|
||||
<string name="cashu_no_wallet">No Cashu wallet yet</string>
|
||||
<string name="cashu_no_wallet_description">Create a Cashu wallet to hold ecash tokens and receive nutzaps.</string>
|
||||
<string name="cashu_balance">Balance</string>
|
||||
<string name="cashu_mints">Mints</string>
|
||||
<string name="cashu_mint_url">Mint URL</string>
|
||||
<string name="cashu_remove_mint">Remove mint</string>
|
||||
<string name="cashu_history">History</string>
|
||||
<string name="cashu_edit_wallet">Edit wallet</string>
|
||||
<string name="cashu_p2pk_section">Nutzap key (advanced)</string>
|
||||
<string name="cashu_p2pk_explainer">A separate private key used only to receive NIP-61 nutzaps. Not your Nostr identity key.</string>
|
||||
<string name="cashu_p2pk_autogen">Auto-generate</string>
|
||||
<string name="cashu_p2pk_manual_label">P2PK private key (hex)</string>
|
||||
<string name="wallet_paste_uri">Paste nostr+walletconnect:// URI</string>
|
||||
<string name="wallet_save">Save</string>
|
||||
<string name="wallet_rename">Rename</string>
|
||||
@@ -2779,6 +2797,11 @@
|
||||
<item quantity="other">%1$d years</item>
|
||||
</plurals>
|
||||
|
||||
<plurals name="cashu_wallet_subtitle_mints">
|
||||
<item quantity="one">%1$d mint</item>
|
||||
<item quantity="other">%1$d mints</item>
|
||||
</plurals>
|
||||
|
||||
<string name="event_sync_less_than_until"><%1$s</string>
|
||||
<string name="event_sync_status_connecting">Connecting</string>
|
||||
<string name="event_sync_status_downloading">Downloading</string>
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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.commons.relayClient.assemblers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
|
||||
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
|
||||
/**
|
||||
* Query state for the NIP-60 / NIP-61 wallet subscription.
|
||||
*
|
||||
* `pubkey` is the wallet owner — used both as `authors=` for their own
|
||||
* NIP-60 events and as the `#p` tag value for inbound nutzaps.
|
||||
*/
|
||||
data class CashuWalletQueryState(
|
||||
val pubkey: HexKey,
|
||||
val relays: Set<NormalizedRelayUrl>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Subscribes to all NIP-60 / NIP-61 events that participate in this
|
||||
* account's Cashu wallet:
|
||||
*
|
||||
* - kind 17375 — the wallet event (replaceable)
|
||||
* - kind 7375 — unspent proofs (token events)
|
||||
* - kind 7376 — spending history
|
||||
* - kind 7374 — quote state events
|
||||
* - kind 10019 — nutzap info (replaceable, for incoming nutzaps)
|
||||
* - kind 9321 — inbound nutzaps tagged with the user's pubkey
|
||||
*
|
||||
* All authored events are queried by `authors=[pubkey]`; nutzaps are queried by
|
||||
* `#p=[pubkey]` (we receive them, not send them, from this filter's perspective).
|
||||
*/
|
||||
class CashuWalletFilterAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<CashuWalletQueryState>,
|
||||
) : SingleSubEoseManager<CashuWalletQueryState>(client, allKeys, invalidateAfterEose = true) {
|
||||
override fun distinct(key: CashuWalletQueryState): Any = key.pubkey
|
||||
|
||||
override fun updateFilter(
|
||||
keys: List<CashuWalletQueryState>,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter>? {
|
||||
if (keys.isEmpty()) return null
|
||||
|
||||
val pubkey = keys.first().pubkey
|
||||
val relays =
|
||||
keys
|
||||
.flatMap { it.relays }
|
||||
.toSet()
|
||||
.ifEmpty { return null }
|
||||
|
||||
val ownedFilter =
|
||||
Filter(
|
||||
kinds =
|
||||
listOf(
|
||||
CashuWalletEvent.KIND,
|
||||
CashuTokenEvent.KIND,
|
||||
CashuSpendingHistoryEvent.KIND,
|
||||
CashuMintQuoteEvent.KIND,
|
||||
NutzapInfoEvent.KIND,
|
||||
),
|
||||
authors = listOf(pubkey),
|
||||
)
|
||||
|
||||
val inboundNutzapsFilter =
|
||||
Filter(
|
||||
kinds = listOf(NutzapEvent.KIND),
|
||||
tags = mapOf("p" to listOf(pubkey)),
|
||||
)
|
||||
|
||||
return relays.flatMap { relay ->
|
||||
val sinceTime = since?.get(relay)?.time
|
||||
listOf(
|
||||
RelayBasedFilter(
|
||||
relay,
|
||||
if (sinceTime != null) ownedFilter.copy(since = sinceTime) else ownedFilter,
|
||||
),
|
||||
RelayBasedFilter(
|
||||
relay,
|
||||
if (sinceTime != null) inboundNutzapsFilter.copy(since = sinceTime) else inboundNutzapsFilter,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.quartz.nip60Cashu.wallet
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
@@ -71,6 +72,9 @@ class CashuWalletEvent(
|
||||
companion object {
|
||||
const val KIND = 17375
|
||||
const val ALT_DESCRIPTION = "Cashu wallet"
|
||||
const val FIXED_D_TAG = ""
|
||||
|
||||
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG)
|
||||
|
||||
suspend fun build(
|
||||
mints: List<String>,
|
||||
|
||||
Reference in New Issue
Block a user