mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
feat(clink): add CLINK debit as a payment source in the Wallet screen
Surfaces debits in the existing wallet list and add flow: - WalletViewModel reads clinkDebitWallets and emits them as spend-only rows (canShowBalance=false) in the unified walletInfoList; the default radio spans both types via setDefaultPaymentSource; remove/rename route by type. - WalletScreen renders debit rows with a Pay only badge instead of a balance and disables the NWC-only detail navigation for them. - AddWalletScreen offers a CLINK Debit type; AddClinkDebitWalletScreen pastes or scans an ndebit1 pointer (no secret) and saves it as a payment source. - New Route.WalletAddClinkDebit + AppNavigation registration. :amethyst compiles. UI rendering and the end-to-end debit payout remain untested on device.
This commit is contained in:
@@ -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<Route.WalletAdd> { AddWalletScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.WalletAddNwc> { AddNwcWalletScreen(accountViewModel, nav, it.nip47) }
|
||||
composableFromEnd<Route.WalletAddCashu> { AddCashuWalletScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.WalletAddClinkDebit> { AddClinkDebitWalletScreen(accountViewModel, nav, it.ndebit) }
|
||||
composableFromEnd<Route.CashuWallet> { CashuWalletScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.CashuWalletSettings> { CashuWalletSettingsScreen(accountViewModel, nav) }
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+214
@@ -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<String?>(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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-5
@@ -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) {
|
||||
|
||||
+71
-16
@@ -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<List<NwcWalletEntryNorm>>(emptyList())
|
||||
val wallets = _wallets.asStateFlow()
|
||||
|
||||
private val _debitWallets = MutableStateFlow<List<ClinkDebitWalletEntryNorm>>(emptyList())
|
||||
val debitWallets = _debitWallets.asStateFlow()
|
||||
|
||||
private val _defaultWalletId = MutableStateFlow<String?>(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()
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,11 @@
|
||||
<string name="clink_lightning_offer">Lightning Offer</string>
|
||||
<string name="clink_requesting_invoice">Requesting invoice…</string>
|
||||
<string name="clink_debit_no_response">The debit service did not complete the payment.</string>
|
||||
<string name="clink_debit_pay_only">Pay only</string>
|
||||
<string name="wallet_add_clink_title">CLINK Debit</string>
|
||||
<string name="wallet_add_clink_description">Pay and zap from a wallet that pre-authorized your account. Spend only — no balance or history.</string>
|
||||
<string name="wallet_add_clink_invalid">Invalid CLINK debit pointer. Expected an ndebit1… string.</string>
|
||||
<string name="wallet_paste_ndebit">Paste ndebit pointer</string>
|
||||
<string name="pay">Pay</string>
|
||||
<string name="lightning_tips">Lightning Tips</string>
|
||||
<string name="note_to_receiver">Note to Receiver</string>
|
||||
|
||||
Reference in New Issue
Block a user