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 95264c003d..b6b99019e1 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 @@ -197,6 +197,7 @@ 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.AddClinkDebitWalletScreen 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 @@ -317,6 +318,7 @@ fun BuildNavigation( composableFromEnd { AddWalletScreen(accountViewModel, nav) } composableFromEndArgs { AddNwcWalletScreen(accountViewModel, nav, it.nip47) } composableFromEnd { AddCashuWalletScreen(accountViewModel, nav) } + composableFromEndArgs { AddClinkDebitWalletScreen(accountViewModel, nav, it.ndebit) } composableFromEnd { CashuWalletScreen(accountViewModel, nav) } composableFromEnd { CashuWalletSettingsScreen(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 9c592b4e8a..748a84b465 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 @@ -210,6 +210,10 @@ sealed class Route { @Serializable object WalletAddCashu : Route() + @Serializable data class WalletAddClinkDebit( + val ndebit: String? = null, + ) : Route() + @Serializable object CashuWallet : Route() @Serializable object CashuWalletSettings : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddClinkDebitWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddClinkDebitWalletScreen.kt new file mode 100644 index 0000000000..9de69a8906 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddClinkDebitWalletScreen.kt @@ -0,0 +1,214 @@ +/* + * 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.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.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.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.Size24Modifier +import kotlinx.coroutines.launch + +/** + * Adds a CLINK Debits pointer (`ndebit1…`) as a spend-only payment source. Unlike NWC + * there is no secret to paste — authorization is the account's own identity, pre-approved + * on the wallet service — so this screen only collects a name and the pointer. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddClinkDebitWalletScreen( + accountViewModel: AccountViewModel, + nav: INav, + ndebit: String? = null, +) { + val walletViewModel: WalletViewModel = viewModel() + walletViewModel.init(accountViewModel) + + var walletName by remember { mutableStateOf("") } + var ndebitUri by remember { mutableStateOf(ndebit.orEmpty()) } + var error by remember { mutableStateOf(null) } + var qrScanning by remember { mutableStateOf(false) } + + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.wallet_add_clink_title)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding) + .imePadding() + .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)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + // Paste from clipboard + IconButton( + onClick = { + scope.launch { + val clipText = clipboardManager.getText() + if (clipText != null) { + ndebitUri = 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()) { + ndebitUri = it + error = null + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = ndebitUri, + onValueChange = { + ndebitUri = it + error = null + }, + label = { Text(stringRes(R.string.wallet_paste_ndebit)) }, + placeholder = { Text("ndebit1...") }, + 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)) + + val invalidMessage = stringRes(R.string.wallet_add_clink_invalid) + Button( + onClick = { + if (walletViewModel.addClinkDebitWallet(walletName.trim(), ndebitUri.trim())) { + nav.popBack() + } else { + error = invalidMessage + } + }, + enabled = ndebitUri.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 0c4d4e90de..2c33e6523f 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 @@ -105,6 +105,12 @@ fun AddWalletScreen( description = stringRes(R.string.wallet_add_cashu_description), onClick = { nav.popUpTo(Route.WalletAddCashu, Route.WalletAdd::class) }, ) + WalletTypeCard( + icon = MaterialSymbols.Bolt, + title = stringRes(R.string.wallet_add_clink_title), + description = stringRes(R.string.wallet_add_clink_description), + onClick = { nav.popUpTo(Route.WalletAddClinkDebit(), Route.WalletAdd::class) }, + ) } } } 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 cf096aa501..16a91b6d84 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 @@ -234,8 +234,11 @@ private fun MultiWalletHomeContent( WalletCard( walletInfo = walletInfo, onSelect = { - walletViewModel.selectWallet(walletInfo.walletId) - nav.nav(Route.WalletDetail(walletInfo.walletId)) + // The detail screen is NWC-only (balance/transactions); debits have neither. + if (walletInfo.canShowBalance) { + walletViewModel.selectWallet(walletInfo.walletId) + nav.nav(Route.WalletDetail(walletInfo.walletId)) + } }, onSetDefault = { walletViewModel.setDefaultWallet(walletInfo.walletId) @@ -325,7 +328,7 @@ private fun WalletCard( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onSelect), + .clickable(enabled = walletInfo.canShowBalance, onClick = onSelect), shape = RoundedCornerShape(16.dp), border = if (walletInfo.isDefault) { @@ -377,8 +380,14 @@ private fun WalletCard( } } - // Balance - if (walletInfo.isLoading && walletInfo.balanceSats == null) { + // Balance — debits are spend-only, so show a capability badge instead. + if (!walletInfo.canShowBalance) { + Text( + text = stringRes(R.string.clink_debit_pay_only), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (walletInfo.isLoading && walletInfo.balanceSats == null) { CircularProgressIndicator(modifier = Modifier.size(24.dp)) } else { Column(horizontalAlignment = Alignment.End) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index 4b6bfca561..3b30e3d020 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -22,9 +22,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser +import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod @@ -94,6 +97,8 @@ data class WalletInfo( val isDefault: Boolean = false, val isLoading: Boolean = false, val error: String? = null, + // CLINK debits are spend-only: no balance/transactions to fetch or show. + val canShowBalance: Boolean = true, ) private const val NWC_TIMEOUT_MS = 30_000L @@ -111,23 +116,43 @@ class WalletViewModel : ViewModel() { private val _wallets = MutableStateFlow>(emptyList()) val wallets = _wallets.asStateFlow() + private val _debitWallets = MutableStateFlow>(emptyList()) + val debitWallets = _debitWallets.asStateFlow() + private val _defaultWalletId = MutableStateFlow(null) val defaultWalletId = _defaultWalletId.asStateFlow() val walletInfoList = - combine(_wallets, _defaultWalletId, walletInfoMap) { wallets, defaultId, infoMap -> - wallets.map { wallet -> - val info = infoMap[wallet.id] - WalletInfo( - walletId = wallet.id, - name = wallet.name, - alias = info?.alias, - balanceSats = info?.balanceSats, - isDefault = wallet.id == defaultId || (defaultId == null && wallet == wallets.firstOrNull()), - isLoading = info?.isLoading == true, - error = info?.error, - ) - } + combine(_wallets, _debitWallets, _defaultWalletId, walletInfoMap) { wallets, debits, defaultId, infoMap -> + // The unified default falls back to the first source overall (NWC before debits). + val effectiveDefault = defaultId ?: wallets.firstOrNull()?.id ?: debits.firstOrNull()?.id + + val nwcRows = + wallets.map { wallet -> + val info = infoMap[wallet.id] + WalletInfo( + walletId = wallet.id, + name = wallet.name, + alias = info?.alias, + balanceSats = info?.balanceSats, + isDefault = wallet.id == effectiveDefault, + isLoading = info?.isLoading == true, + error = info?.error, + canShowBalance = true, + ) + } + + val debitRows = + debits.map { debit -> + WalletInfo( + walletId = debit.id, + name = debit.name, + isDefault = debit.id == effectiveDefault, + canShowBalance = false, + ) + } + + nwcRows + debitRows }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) // Selected wallet for detail view @@ -218,8 +243,9 @@ class WalletViewModel : ViewModel() { fun refreshWalletList() { val acc = account ?: return _wallets.value = acc.settings.nwcWallets.value + _debitWallets.value = acc.settings.clinkDebitWallets.value _defaultWalletId.value = acc.settings.defaultPaymentSourceId.value - _hasWalletSetup.value = _wallets.value.isNotEmpty() + _hasWalletSetup.value = _wallets.value.isNotEmpty() || _debitWallets.value.isNotEmpty() } fun refreshWalletSetup() { @@ -264,10 +290,35 @@ class WalletViewModel : ViewModel() { fun removeWallet(walletId: String) { val acc = account ?: return - acc.settings.removeNwcWallet(walletId) + if (_debitWallets.value.any { it.id == walletId }) { + acc.settings.removeClinkDebitWallet(walletId) + } else { + acc.settings.removeNwcWallet(walletId) + } refreshWalletList() } + /** Adds a CLINK debit pointer (`ndebit1…`) as a spend-only payment source. */ + fun addClinkDebitWallet( + name: String, + ndebit: String, + ): Boolean { + val acc = account ?: return false + val pointer = ClinkPointerParser.parse(ndebit.trim()) as? NDebit ?: return false + val entry = + ClinkDebitWalletEntryNorm( + id = + java.util.UUID + .randomUUID() + .toString(), + name = name.ifBlank { "Debit" }, + pointer = pointer, + ) + acc.settings.addClinkDebitWallet(entry) + refreshWalletList() + return true + } + fun addWallet( name: String, uri: Nip47WalletConnect.Nip47URINorm, @@ -292,7 +343,11 @@ class WalletViewModel : ViewModel() { newName: String, ) { val acc = account ?: return - acc.settings.renameNwcWallet(walletId, newName) + if (_debitWallets.value.any { it.id == walletId }) { + acc.settings.renameClinkDebitWallet(walletId, newName) + } else { + acc.settings.renameNwcWallet(walletId, newName) + } refreshWalletList() } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d9e9596484..7b05d87c4c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -115,6 +115,11 @@ Lightning Offer Requesting invoice… The debit service did not complete the payment. + Pay only + CLINK Debit + Pay and zap from a wallet that pre-authorized your account. Spend only — no balance or history. + Invalid CLINK debit pointer. Expected an ndebit1… string. + Paste ndebit pointer Pay Lightning Tips Note to Receiver