diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 06091a298d..467870518c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { OnchainTransactionsScreen(accountViewModel, nav) } composableFromEndArgs { WalletDetailScreen(it.walletId, accountViewModel, nav) } composableFromEnd { AddWalletScreen(accountViewModel, nav) } + composableFromEnd { AddNwcWalletScreen(accountViewModel, nav) } + composableFromEnd { AddCashuWalletScreen(accountViewModel, nav) } + composableFromEnd { CashuWalletScreen(accountViewModel, nav) } composableFromEnd { ListOfPeopleListsScreen(accountViewModel, nav) } composableFromEndArgs { PeopleListScreen(it.dTag, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index f771a61300..fc1f7c85a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddCashuWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddCashuWalletScreen.kt new file mode 100644 index 0000000000..b1659ceb8d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddCashuWalletScreen.kt @@ -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() } + 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)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddNwcWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddNwcWalletScreen.kt new file mode 100644 index 0000000000..8dd5a7e8d6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddNwcWalletScreen.kt @@ -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(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)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt index 8b9997478d..493ba871ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt @@ -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(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)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt new file mode 100644 index 0000000000..1cd9cbc5b9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt @@ -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, + history: List, +) { + 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, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt new file mode 100644 index 0000000000..e0f502ecf0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt @@ -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(null) + val walletEvent = _walletEvent.asStateFlow() + + private val _mints = MutableStateFlow>(emptyList()) + val mints = _mints.asStateFlow() + + private val _balanceSats = MutableStateFlow(0L) + val balanceSats = _balanceSats.asStateFlow() + + private val _tokenEvents = MutableStateFlow>(emptyList()) + val tokenEvents = _tokenEvents.asStateFlow() + + private val _history = MutableStateFlow>(emptyList()) + val history = _history.asStateFlow() + + private val _createState = MutableStateFlow(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() + val historyList = mutableListOf() + 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() + val decoded = mutableMapOf() + 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, + 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt index 57d31f3d22..5a07bc2c2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt @@ -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, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index ce1b6759e0..142245949e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1868,6 +1868,24 @@ No wallets connected Connect one or more NWC wallets to send and receive payments. Add NWC Connection + Choose wallet type + Connect a Lightning wallet (NWC) + Pay via an external Lightning wallet using Nostr Wallet Connect. + Cashu wallet (NIP-60) + An in-app ecash wallet backed by a Cashu mint. Tokens are stored encrypted on relays. + Cashu Wallet + No Cashu wallet yet + Create a Cashu wallet to hold ecash tokens and receive nutzaps. + Balance + Mints + Mint URL + Remove mint + History + Edit wallet + Nutzap key (advanced) + A separate private key used only to receive NIP-61 nutzaps. Not your Nostr identity key. + Auto-generate + P2PK private key (hex) Paste nostr+walletconnect:// URI Save Rename @@ -2779,6 +2797,11 @@ %1$d years + + %1$d mint + %1$d mints + + <%1$s Connecting Downloading diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt new file mode 100644 index 0000000000..356edb3648 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt @@ -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, +) + +/** + * 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, +) : SingleSubEoseManager(client, allKeys, invalidateAfterEose = true) { + override fun distinct(key: CashuWalletQueryState): Any = key.pubkey + + override fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List? { + 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, + ), + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletEvent.kt index 348864116f..ac31be04c5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletEvent.kt @@ -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,