feat: nwc
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
package com.bitcointxoko.gudariwallet.api
|
||||
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.Url
|
||||
@@ -144,4 +146,61 @@ interface LNbitsApi {
|
||||
*/
|
||||
@GET("api/v1/currencies")
|
||||
suspend fun getSupportedCurrencies(): List<String>
|
||||
|
||||
// ── NWC Provider ──────────────────────────────────────────────────────────
|
||||
|
||||
@GET("nwcprovider/api/v1/permissions")
|
||||
suspend fun getNwcPermissions(
|
||||
@Header("X-Api-Key") apiKey: String
|
||||
): Map<String, Any>
|
||||
|
||||
@GET("nwcprovider/api/v1/nwc")
|
||||
suspend fun getNwcKeys(
|
||||
@Header("X-Api-Key") adminKey: String,
|
||||
@Query("include_expired") includeExpired: Boolean = false
|
||||
): List<NwcGetResponse>
|
||||
|
||||
@GET("nwcprovider/api/v1/nwc/{pubkey}")
|
||||
suspend fun getNwcKey(
|
||||
@Header("X-Api-Key") apiKey: String,
|
||||
@Path("pubkey") pubkey: String,
|
||||
@Query("include_expired") includeExpired: Boolean = false,
|
||||
@Query("refresh_last_used") refreshLastUsed: Boolean = false
|
||||
): NwcGetResponse
|
||||
|
||||
@PUT("nwcprovider/api/v1/nwc/{pubkey}")
|
||||
suspend fun registerNwcKey(
|
||||
@Header("X-Api-Key") adminKey: String,
|
||||
@Path("pubkey") pubkey: String,
|
||||
@Body request: NwcRegistrationRequest
|
||||
): NwcGetResponse
|
||||
|
||||
@DELETE("nwcprovider/api/v1/nwc/{pubkey}")
|
||||
suspend fun deleteNwcKey(
|
||||
@Header("X-Api-Key") adminKey: String,
|
||||
@Path("pubkey") pubkey: String
|
||||
)
|
||||
|
||||
@GET("nwcprovider/api/v1/pairing/{secret}")
|
||||
suspend fun getNwcPairingUrl(
|
||||
@Header("X-Api-Key") apiKey: String,
|
||||
@Path("secret") secret: String
|
||||
): String
|
||||
|
||||
@GET("nwcprovider/api/v1/config")
|
||||
suspend fun getNwcConfig(
|
||||
@Header("X-Api-Key") adminKey: String
|
||||
): Map<String, Any>
|
||||
|
||||
@GET("nwcprovider/api/v1/config/{key}")
|
||||
suspend fun getNwcConfigValue(
|
||||
@Header("X-Api-Key") adminKey: String,
|
||||
@Path("key") key: String
|
||||
): Map<String, Any>
|
||||
|
||||
@POST("nwcprovider/api/v1/config")
|
||||
suspend fun setNwcConfig(
|
||||
@Header("X-Api-Key") adminKey: String,
|
||||
@Body config: Map<String, Any>
|
||||
): Map<String, Any>
|
||||
}
|
||||
|
||||
@@ -322,3 +322,43 @@ data class PaginatedPaymentsResponse(
|
||||
@SerializedName("data") val data: List<PaymentRecord>,
|
||||
@SerializedName("total") val total: Int
|
||||
)
|
||||
|
||||
// ── NWC Provider models ───────────────────────────────────────────────────────
|
||||
|
||||
data class NwcKey(
|
||||
val pubkey: String,
|
||||
val wallet: String,
|
||||
val description: String,
|
||||
val expires_at: Long,
|
||||
val permissions: String, // comma-separated permission names
|
||||
val created_at: Long,
|
||||
val last_used: Long
|
||||
)
|
||||
|
||||
data class NwcBudget(
|
||||
val id: Int,
|
||||
val pubkey: String,
|
||||
val budget_msats: Long,
|
||||
val refresh_window: Int, // seconds
|
||||
val created_at: Long,
|
||||
val used_budget_msats: Long = 0
|
||||
)
|
||||
|
||||
/** Response for GET /nwc and GET /nwc/{pubkey} */
|
||||
data class NwcGetResponse(
|
||||
val data: NwcKey,
|
||||
val budgets: List<NwcBudget>
|
||||
)
|
||||
|
||||
data class NwcNewBudget(
|
||||
val budget_msats: Long,
|
||||
val refresh_window: Int // seconds
|
||||
)
|
||||
|
||||
/** Request body for PUT /nwc/{pubkey} */
|
||||
data class NwcRegistrationRequest(
|
||||
val permissions: List<String>,
|
||||
val description: String,
|
||||
val expires_at: Long, // unix timestamp; 0 = no expiry
|
||||
val budgets: List<NwcNewBudget> = emptyList()
|
||||
)
|
||||
|
||||
@@ -286,4 +286,134 @@ class LNbitsWalletRepository(
|
||||
override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse {
|
||||
return api.getPaymentDetail(secrets.invoiceKey(), checkingId)
|
||||
}
|
||||
|
||||
// ── NWC Provider ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the map of supported NWC permission names.
|
||||
*/
|
||||
override suspend fun getNwcPermissions(): Map<String, Any> {
|
||||
Timber.d("NWC [PERMISSIONS] fetching supported permissions")
|
||||
val result = api.getNwcPermissions(secrets.invoiceKey())
|
||||
Timber.i("NWC [PERMISSIONS] received ${result.size} permission(s)")
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all NWC connection keys for the current wallet.
|
||||
*/
|
||||
override suspend fun getNwcKeys(includeExpired: Boolean): List<NwcGetResponse> {
|
||||
Timber.d("NWC [KEYS ] fetching all keys (includeExpired=$includeExpired)")
|
||||
val result = api.getNwcKeys(secrets.adminKey(), includeExpired)
|
||||
Timber.i("NWC [KEYS ] received ${result.size} key(s)")
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single NWC connection key by its public key.
|
||||
*/
|
||||
override suspend fun getNwcKey(
|
||||
pubkey : String,
|
||||
includeExpired : Boolean,
|
||||
refreshLastUsed : Boolean
|
||||
): NwcGetResponse {
|
||||
Timber.d("NWC [KEY ] fetching key pubkey=${pubkey.take(16)}…")
|
||||
val result = api.getNwcKey(
|
||||
apiKey = secrets.invoiceKey(),
|
||||
pubkey = pubkey,
|
||||
includeExpired = includeExpired,
|
||||
refreshLastUsed = refreshLastUsed
|
||||
)
|
||||
Timber.i("NWC [KEY ] fetched key pubkey=${pubkey.take(16)}… description='${result.data.description}'")
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers / creates a new NWC connection key.
|
||||
* Requires admin key.
|
||||
*/
|
||||
override suspend fun registerNwcKey(
|
||||
pubkey : String,
|
||||
request : NwcRegistrationRequest
|
||||
): NwcGetResponse {
|
||||
Timber.d(
|
||||
"NWC [REGISTER ] pubkey=${pubkey.take(16)}… " +
|
||||
"permissions=${request.permissions} " +
|
||||
"expires_at=${request.expires_at} " +
|
||||
"budgets=${request.budgets.size}"
|
||||
)
|
||||
val result = api.registerNwcKey(
|
||||
adminKey = secrets.adminKey(),
|
||||
pubkey = pubkey,
|
||||
request = request
|
||||
)
|
||||
Timber.i("NWC [REGISTER ] registered key pubkey=${pubkey.take(16)}… ✓")
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an NWC connection key by its public key.
|
||||
* Requires admin key.
|
||||
*/
|
||||
override suspend fun deleteNwcKey(pubkey: String) {
|
||||
Timber.d("NWC [DELETE ] pubkey=${pubkey.take(16)}…")
|
||||
api.deleteNwcKey(
|
||||
adminKey = secrets.adminKey(),
|
||||
pubkey = pubkey
|
||||
)
|
||||
Timber.i("NWC [DELETE ] deleted key pubkey=${pubkey.take(16)}… ✓")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the pairing URL (nostr+walletconnect://…) for a given secret.
|
||||
*/
|
||||
override suspend fun getNwcPairingUrl(secret: String): String {
|
||||
Timber.d("NWC [PAIRING ] fetching pairing URL for secret=${secret.take(8)}…")
|
||||
val url = api.getNwcPairingUrl(
|
||||
apiKey = secrets.invoiceKey(),
|
||||
secret = secret
|
||||
)
|
||||
Timber.i("NWC [PAIRING ] pairing URL received ✓")
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all extension configuration settings.
|
||||
* Requires admin key.
|
||||
*/
|
||||
override suspend fun getNwcConfig(): Map<String, Any> {
|
||||
Timber.d("NWC [CONFIG ] fetching all config settings")
|
||||
val result = api.getNwcConfig(secrets.adminKey())
|
||||
Timber.i("NWC [CONFIG ] received ${result.size} setting(s)")
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single configuration value by key.
|
||||
* Requires admin key.
|
||||
*/
|
||||
override suspend fun getNwcConfigValue(key: String): Map<String, Any> {
|
||||
Timber.d("NWC [CONFIG ] fetching config key='$key'")
|
||||
val result = api.getNwcConfigValue(
|
||||
adminKey = secrets.adminKey(),
|
||||
key = key
|
||||
)
|
||||
Timber.i("NWC [CONFIG ] received value for key='$key'")
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets / updates one or more configuration values.
|
||||
* Requires admin key.
|
||||
*/
|
||||
override suspend fun setNwcConfig(config: Map<String, Any>): Map<String, Any> {
|
||||
Timber.d("NWC [CONFIG SET] updating ${config.size} setting(s): keys=${config.keys}")
|
||||
val result = api.setNwcConfig(
|
||||
adminKey = secrets.adminKey(),
|
||||
config = config
|
||||
)
|
||||
Timber.i("NWC [CONFIG SET] config updated ✓")
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.bitcointxoko.gudariwallet.data
|
||||
|
||||
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
|
||||
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
|
||||
import com.bitcointxoko.gudariwallet.api.NwcRegistrationRequest
|
||||
import com.bitcointxoko.gudariwallet.api.PaginatedPaymentsResponse
|
||||
import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse
|
||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||
@@ -41,4 +43,20 @@ interface WalletRepository {
|
||||
suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse
|
||||
suspend fun getFiatRate(currency: String): Double
|
||||
suspend fun getSupportedCurrencies(): List<String>
|
||||
suspend fun getNwcPermissions(): Map<String, Any>
|
||||
suspend fun getNwcKeys(includeExpired: Boolean = false): List<NwcGetResponse>
|
||||
suspend fun getNwcKey(
|
||||
pubkey : String,
|
||||
includeExpired : Boolean = false,
|
||||
refreshLastUsed : Boolean = false
|
||||
): NwcGetResponse
|
||||
suspend fun registerNwcKey(
|
||||
pubkey : String,
|
||||
request : NwcRegistrationRequest
|
||||
): NwcGetResponse
|
||||
suspend fun deleteNwcKey(pubkey: String)
|
||||
suspend fun getNwcPairingUrl(secret: String): String
|
||||
suspend fun getNwcConfig(): Map<String, Any>
|
||||
suspend fun getNwcConfigValue(key: String): Map<String, Any>
|
||||
suspend fun setNwcConfig(config: Map<String, Any>): Map<String, Any>
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.Language // ← NEW
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.filled.Cable
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import com.bitcointxoko.gudariwallet.util.formatFiat
|
||||
@@ -38,6 +39,7 @@ import com.bitcointxoko.gudariwallet.i18n.AppStrings
|
||||
fun HomeTab(
|
||||
vm: WalletViewModel,
|
||||
onHistoryClick: () -> Unit,
|
||||
onNwcClick : () -> Unit,
|
||||
lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings
|
||||
) {
|
||||
val strings = LocalAppStrings.current
|
||||
@@ -50,235 +52,252 @@ fun HomeTab(
|
||||
|
||||
val isRefreshing = balanceState is BalanceState.Loading
|
||||
|| (balanceState as? BalanceState.Success)?.isRefreshing == true
|
||||
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = { vm.refreshBalance() },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = { vm.refreshBalance() },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
when (val s = balanceState) {
|
||||
|
||||
is BalanceState.Loading -> {
|
||||
CircularProgressIndicator()
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = strings.loadingBalance,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
is BalanceState.Success -> {
|
||||
val selectedCurrency by vm.selectedCurrency.collectAsState()
|
||||
var showCurrencyPicker by remember { mutableStateOf(false) }
|
||||
var isLoadingCurrencies by remember { mutableStateOf(false) }
|
||||
var currencyList by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
LaunchedEffect(showCurrencyPicker) {
|
||||
if (showCurrencyPicker && currencyList.isEmpty()) {
|
||||
isLoadingCurrencies = true
|
||||
currencyList = vm.getSupportedCurrencies()
|
||||
isLoadingCurrencies = false
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (balanceHidden)
|
||||
Icons.Filled.VisibilityOff
|
||||
else
|
||||
Icons.Filled.Visibility,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
when (val s = balanceState) {
|
||||
|
||||
is BalanceState.Loading -> {
|
||||
CircularProgressIndicator()
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = if (balanceHidden) "••••••" else formatSats(s.sats),
|
||||
style = MaterialTheme.typography.displayLarge.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = if (balanceHidden)
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f)
|
||||
else
|
||||
MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 280.dp)
|
||||
.combinedClickable(
|
||||
onClick = { if (!balanceHidden) vm.toggleBalanceVisibility() },
|
||||
onClickLabel = if (balanceHidden) null else strings.hideBalance,
|
||||
onLongClick = { if (balanceHidden) vm.toggleBalanceVisibility() },
|
||||
onLongClickLabel = if (balanceHidden) strings.revealBalance else null,
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = strings.sats,
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
text = strings.loadingBalance,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Spacer(Modifier.height(2.dp))
|
||||
val currency = selectedCurrency
|
||||
if (currency != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
if (s.fiatAmount != null && s.fiatCurrency != null) {
|
||||
Text(
|
||||
text = if (balanceHidden) "••••"
|
||||
else formatFiat(s.fiatAmount, s.fiatCurrency),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = if (balanceHidden)
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
|
||||
maxLines = 1,
|
||||
softWrap = false
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
|
||||
is BalanceState.Success -> {
|
||||
val selectedCurrency by vm.selectedCurrency.collectAsState()
|
||||
var showCurrencyPicker by remember { mutableStateOf(false) }
|
||||
var isLoadingCurrencies by remember { mutableStateOf(false) }
|
||||
var currencyList by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
LaunchedEffect(showCurrencyPicker) {
|
||||
if (showCurrencyPicker && currencyList.isEmpty()) {
|
||||
isLoadingCurrencies = true
|
||||
currencyList = vm.getSupportedCurrencies()
|
||||
isLoadingCurrencies = false
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
onClick = { showCurrencyPicker = true },
|
||||
onClickLabel = strings.changeCurrency,
|
||||
indication = null,
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (balanceHidden)
|
||||
Icons.Filled.VisibilityOff
|
||||
else
|
||||
Icons.Filled.Visibility,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = if (balanceHidden) "••••••" else formatSats(s.sats),
|
||||
style = MaterialTheme.typography.displayLarge.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = if (balanceHidden)
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f)
|
||||
else
|
||||
MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 280.dp)
|
||||
.combinedClickable(
|
||||
onClick = { if (!balanceHidden) vm.toggleBalanceVisibility() },
|
||||
onClickLabel = if (balanceHidden) null else strings.hideBalance,
|
||||
onLongClick = { if (balanceHidden) vm.toggleBalanceVisibility() },
|
||||
onLongClickLabel = if (balanceHidden) strings.revealBalance else null,
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
)
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = strings.sats,
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Spacer(Modifier.height(2.dp))
|
||||
val currency = selectedCurrency
|
||||
if (currency != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
if (s.fiatAmount != null && s.fiatCurrency != null) {
|
||||
Text(
|
||||
text = if (balanceHidden) "••••"
|
||||
else formatFiat(s.fiatAmount, s.fiatCurrency),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = if (balanceHidden)
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
|
||||
maxLines = 1,
|
||||
softWrap = false
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
onClick = { showCurrencyPicker = true },
|
||||
onClickLabel = strings.changeCurrency,
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
)
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = currency,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = 0.75f
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowDropDown,
|
||||
contentDescription = strings.changeCurrency,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TextButton(
|
||||
onClick = { showCurrencyPicker = true },
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = currency,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
|
||||
)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowDropDown,
|
||||
contentDescription = strings.changeCurrency,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
text = strings.showFiat,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TextButton(
|
||||
onClick = { showCurrencyPicker = true },
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
|
||||
) {
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (!s.isRefreshing) {
|
||||
val updatedLabel: String = remember(s.lastUpdated) {
|
||||
formatLastUpdated(s.lastUpdated, strings)
|
||||
}
|
||||
Text(
|
||||
text = strings.showFiat,
|
||||
text = strings.updatedAt(updatedLabel),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (!s.isRefreshing) {
|
||||
val updatedLabel: String = remember(s.lastUpdated) {
|
||||
formatLastUpdated(s.lastUpdated, strings)
|
||||
TextButton(onClick = onHistoryClick) {
|
||||
Text(
|
||||
text = strings.viewHistory,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = strings.updatedAt(updatedLabel),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
TextButton(onClick = onHistoryClick) {
|
||||
Text(
|
||||
text = strings.viewHistory,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
// ── NEW: Language switcher ─────────────────────────────────
|
||||
// ── NEW: Language switcher ─────────────────────────────────
|
||||
// Spacer(Modifier.height(8.dp))
|
||||
// LanguageSwitcherButton(
|
||||
// currentTag = lyricist.languageTag,
|
||||
// supportedTags = supportedTags,
|
||||
// onSelect = { tag -> lyricist.languageTag = tag }
|
||||
// )
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
if (showCurrencyPicker) {
|
||||
CurrencyPickerSheet(
|
||||
currencies = currencyList,
|
||||
selectedCurrency = selectedCurrency,
|
||||
onSelect = { code ->
|
||||
vm.setSelectedCurrency(code)
|
||||
showCurrencyPicker = false
|
||||
},
|
||||
isLoading = isLoadingCurrencies,
|
||||
onDismiss = { showCurrencyPicker = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is BalanceState.Error -> {
|
||||
if (s.staleSats != null) {
|
||||
Text(
|
||||
text = if (balanceHidden) "••••••" else formatSats(s.staleSats),
|
||||
style = MaterialTheme.typography.displayLarge.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||||
)
|
||||
Text(
|
||||
text = strings.sats,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
if (showCurrencyPicker) {
|
||||
CurrencyPickerSheet(
|
||||
currencies = currencyList,
|
||||
selectedCurrency = selectedCurrency,
|
||||
onSelect = { code ->
|
||||
vm.setSelectedCurrency(code)
|
||||
showCurrencyPicker = false
|
||||
},
|
||||
isLoading = isLoadingCurrencies,
|
||||
onDismiss = { showCurrencyPicker = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = Modifier.fillMaxWidth(0.85f)
|
||||
) {
|
||||
Text(
|
||||
text = if (s.staleSats != null)
|
||||
strings.couldNotRefresh
|
||||
else
|
||||
strings.couldNotLoadBalance,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
is BalanceState.Error -> {
|
||||
if (s.staleSats != null) {
|
||||
Text(
|
||||
text = if (balanceHidden) "••••••" else formatSats(s.staleSats),
|
||||
style = MaterialTheme.typography.displayLarge.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||||
)
|
||||
Text(
|
||||
text = strings.sats,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = Modifier.fillMaxWidth(0.85f)
|
||||
) {
|
||||
Text(
|
||||
text = if (s.staleSats != null)
|
||||
strings.couldNotRefresh
|
||||
else
|
||||
strings.couldNotLoadBalance,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── NWC button — top-right corner ─────────────────────────────────
|
||||
IconButton(
|
||||
onClick = onNwcClick,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Cable,
|
||||
contentDescription = "Wallet Connect",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
|
||||
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
|
||||
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.nwc.NwcScreen
|
||||
import com.bitcointxoko.gudariwallet.ui.nwc.NwcViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
|
||||
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
|
||||
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
|
||||
@@ -81,6 +83,7 @@ private const val ROUTE_HOME = "home"
|
||||
private const val ROUTE_SCANNER = "scanner"
|
||||
private const val ROUTE_HISTORY = "history"
|
||||
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
|
||||
private const val ROUTE_NWC = "nwc"
|
||||
|
||||
@Composable
|
||||
fun WalletScreen(
|
||||
@@ -120,6 +123,9 @@ fun WalletScreen(
|
||||
)
|
||||
}
|
||||
val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
|
||||
val nwcVm: NwcViewModel = viewModel(
|
||||
factory = NwcViewModel.Factory(repo = vm.repo) // same pattern as historyVm
|
||||
)
|
||||
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
@@ -127,10 +133,12 @@ fun WalletScreen(
|
||||
val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL
|
||||
&& currentRoute != ROUTE_SCANNER
|
||||
&& currentRoute != ROUTE_HISTORY
|
||||
&& currentRoute != ROUTE_NWC
|
||||
|
||||
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|
||||
|| currentRoute == ROUTE_PAYMENT_DETAIL
|
||||
|| currentRoute == ROUTE_SCANNER
|
||||
|| currentRoute == ROUTE_NWC
|
||||
|
||||
// ── Notification tap ──────────────────────────────────────────────────────
|
||||
val hash = pendingPaymentHash.value
|
||||
@@ -322,6 +330,11 @@ fun WalletScreen(
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNwcClick = {
|
||||
navController.navigate(ROUTE_NWC) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
lyricist = lyricist
|
||||
)
|
||||
}
|
||||
@@ -415,6 +428,18 @@ fun WalletScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
composable(
|
||||
ROUTE_NWC,
|
||||
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
||||
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
||||
) {
|
||||
NwcScreen(
|
||||
viewModel = nwcVm,
|
||||
onBack = {
|
||||
navController.popBackStack(ROUTE_HOME, inclusive = false)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Clipboard banner — floats over content, slides in from top ────
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitcointxoko.gudariwallet.api.NwcBudget
|
||||
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
// ── Available permissions ─────────────────────────────────────────────────────
|
||||
private data class NwcPermission(
|
||||
val serverKey : String, // what we send in the permissions array
|
||||
val displayLabel : String, // shown in the UI
|
||||
val default : Boolean // pre-checked by default
|
||||
)
|
||||
private val ALL_PERMISSIONS = listOf(
|
||||
NwcPermission("pay", "Send payments", default = true),
|
||||
NwcPermission("invoice", "Create invoices", default = true),
|
||||
NwcPermission("lookup", "Lookup invoice status", default = true),
|
||||
NwcPermission("history", "Read transaction history", default = true),
|
||||
NwcPermission("balance", "Read wallet balance", default = true),
|
||||
NwcPermission("info", "Read account info", default = true)
|
||||
)
|
||||
|
||||
// ── Screen ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NwcScreen(
|
||||
viewModel : NwcViewModel,
|
||||
onBack : () -> Unit
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val isDeleting by viewModel.isDeleting.collectAsStateWithLifecycle()
|
||||
val isCreating by viewModel.isCreating.collectAsStateWithLifecycle()
|
||||
val pairingUrl by viewModel.pairingUrl.collectAsStateWithLifecycle()
|
||||
|
||||
var showAddSheet by remember { mutableStateOf(false) }
|
||||
var pendingDeletePubkey by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Wallet Connect") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { showAddSheet = true }) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add connection")
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
|
||||
val isRefreshing = uiState is NwcUiState.Loading
|
||||
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = { viewModel.loadConnections() },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
when (val state = uiState) {
|
||||
is NwcUiState.Loading -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
is NwcUiState.Error -> {
|
||||
NwcErrorContent(
|
||||
message = state.message,
|
||||
onRetry = { viewModel.loadConnections() },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
is NwcUiState.Success -> {
|
||||
if (state.connections.isEmpty()) {
|
||||
NwcEmptyContent(
|
||||
onAddNew = { showAddSheet = true },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(
|
||||
items = state.connections,
|
||||
key = { it.data.pubkey }
|
||||
) { connection ->
|
||||
NwcConnectionCard(
|
||||
connection = connection,
|
||||
isDeleting = isDeleting,
|
||||
onDeleteClick = { pendingDeletePubkey = connection.data.pubkey }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add connection sheet ───────────────────────────────────────────────────
|
||||
if (showAddSheet) {
|
||||
NwcAddConnectionSheet(
|
||||
isCreating = isCreating,
|
||||
onCreate = { description, permissions, expiresAt ->
|
||||
viewModel.createConnection(
|
||||
description = description,
|
||||
permissions = permissions,
|
||||
expiresAt = expiresAt
|
||||
)
|
||||
showAddSheet = false
|
||||
},
|
||||
onDismiss = { showAddSheet = false }
|
||||
)
|
||||
}
|
||||
|
||||
// ── Pairing URL dialog ─────────────────────────────────────────────────────
|
||||
pairingUrl?.let { url ->
|
||||
NwcPairingUrlDialog(
|
||||
url = url,
|
||||
onDismiss = { viewModel.clearPairingUrl() }
|
||||
)
|
||||
}
|
||||
|
||||
// ── Delete confirmation dialog ─────────────────────────────────────────────
|
||||
pendingDeletePubkey?.let { pubkey ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingDeletePubkey = null },
|
||||
title = { Text("Remove connection?") },
|
||||
text = { Text("This will permanently revoke this Nostr Wallet Connect key. Any app using it will lose access.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.deleteConnection(pubkey)
|
||||
pendingDeletePubkey = null
|
||||
}
|
||||
) { Text("Remove", color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingDeletePubkey = null }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add connection bottom sheet ───────────────────────────────────────────────
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun NwcAddConnectionSheet(
|
||||
isCreating : Boolean,
|
||||
onCreate : (description: String, permissions: List<String>, expiresAt: Long) -> Unit,
|
||||
onDismiss : () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
var description by remember { mutableStateOf("") }
|
||||
var selectedPermissions by remember {
|
||||
mutableStateOf(ALL_PERMISSIONS.filter { it.default }.map { it.serverKey }.toSet())
|
||||
}
|
||||
var neverExpires by remember { mutableStateOf(true) }
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
|
||||
Text(
|
||||
text = "New connection",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
// ── Description ───────────────────────────────────────────────
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Label (e.g. Zeus, Alby)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// ── Permissions ───────────────────────────────────────────────
|
||||
Text(
|
||||
text = "Permissions",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
ALL_PERMISSIONS.forEach { perm ->
|
||||
val checked = perm.serverKey in selectedPermissions
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = { on ->
|
||||
selectedPermissions = if (on)
|
||||
selectedPermissions + perm.serverKey
|
||||
else
|
||||
selectedPermissions - perm.serverKey
|
||||
}
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = perm.displayLabel,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Expiry ────────────────────────────────────────────────────
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = neverExpires,
|
||||
onCheckedChange = { neverExpires = it }
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Never expires",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
// ── Create button ─────────────────────────────────────────────
|
||||
Button(
|
||||
onClick = {
|
||||
onCreate(
|
||||
description.trim().ifBlank { "Unnamed" },
|
||||
selectedPermissions.toList(),
|
||||
if (neverExpires) 0L else 0L // expiry picker can be added later
|
||||
)
|
||||
},
|
||||
enabled = selectedPermissions.isNotEmpty() && !isCreating,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (isCreating) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text(if (isCreating) "Creating…" else "Create connection")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pairing URL dialog ────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun NwcPairingUrlDialog(
|
||||
url : String,
|
||||
onDismiss : () -> Unit
|
||||
) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Connection created") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = "Copy this connection string into your app. It will not be shown again.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text(
|
||||
text = url,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
modifier = Modifier.padding(12.dp),
|
||||
maxLines = 6,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = copied) {
|
||||
Text(
|
||||
text = "✓ Copied to clipboard",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
clipboard.setText(AnnotatedString(url))
|
||||
copied = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Copy")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Done") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ── Connection Card ───────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun NwcConnectionCard(
|
||||
connection : NwcGetResponse,
|
||||
isDeleting : Boolean,
|
||||
onDeleteClick: () -> Unit,
|
||||
modifier : Modifier = Modifier
|
||||
) {
|
||||
val key = connection.data
|
||||
val budgets = connection.budgets
|
||||
val expired = key.expires_at > 0 && key.expires_at < Instant.now().epochSecond
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (expired)
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = key.description.ifBlank { "Unnamed connection" },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (expired) {
|
||||
Text(
|
||||
text = "Expired",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDeleteClick, enabled = !isDeleting) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Delete connection",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = key.pubkey.take(16) + "…" + key.pubkey.takeLast(8),
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
if (key.permissions.isNotBlank()) {
|
||||
NwcPermissionChips(permissions = key.permissions)
|
||||
}
|
||||
|
||||
NwcMetaRow(
|
||||
label = "Expires",
|
||||
value = if (key.expires_at <= 0L) "Never" else formatEpoch(key.expires_at)
|
||||
)
|
||||
NwcMetaRow(
|
||||
label = "Last used",
|
||||
value = if (key.last_used <= 0L) "Never" else formatEpoch(key.last_used)
|
||||
)
|
||||
|
||||
if (budgets.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
budgets.forEach { NwcBudgetRow(budget = it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Permission chips ──────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun NwcPermissionChips(permissions: String, modifier: Modifier = Modifier) {
|
||||
val perms = remember(permissions) {
|
||||
permissions.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
}
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
perms.forEach { perm ->
|
||||
SuggestionChip(
|
||||
onClick = {},
|
||||
label = {
|
||||
Text(
|
||||
text = perm.replace("_", " "),
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Budget row ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun NwcBudgetRow(budget: NwcBudget, modifier: Modifier = Modifier) {
|
||||
val totalSats = budget.budget_msats / 1_000L
|
||||
val usedSats = budget.used_budget_msats / 1_000L
|
||||
val progress = if (totalSats > 0) (usedSats.toFloat() / totalSats.toFloat()).coerceIn(0f, 1f) else 0f
|
||||
val windowHours = budget.refresh_window / 3600
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("Budget (${windowHours}h window)", style = MaterialTheme.typography.labelMedium)
|
||||
Text(
|
||||
"$usedSats / $totalSats sats",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (progress >= 1f) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.fillMaxWidth().height(6.dp),
|
||||
color = if (progress >= 1f) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Meta row ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun NwcMetaRow(label: String, value: String, modifier: Modifier = Modifier) {
|
||||
Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Empty / Error states ──────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun NwcEmptyContent(onAddNew: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text("No connections yet", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Tap + to connect an app via Nostr Wallet Connect.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Button(onClick = onAddNew) {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Add connection")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NwcErrorContent(message: String, onRetry: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text("Something went wrong", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
OutlinedButton(onClick = onRetry) { Text("Retry") }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
private val dateFormatter: DateTimeFormatter =
|
||||
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
|
||||
.withZone(ZoneId.systemDefault())
|
||||
|
||||
private fun formatEpoch(epochSeconds: Long): String =
|
||||
runCatching { dateFormatter.format(Instant.ofEpochSecond(epochSeconds)) }.getOrDefault("—")
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.nwc
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
|
||||
import com.bitcointxoko.gudariwallet.api.NwcNewBudget
|
||||
import com.bitcointxoko.gudariwallet.api.NwcRegistrationRequest
|
||||
import com.bitcointxoko.gudariwallet.data.WalletRepository
|
||||
import com.bitcointxoko.gudariwallet.util.NwcKeyGenerator
|
||||
import com.bitcointxoko.gudariwallet.util.NwcKeypair
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
private const val TAG = "NwcViewModel"
|
||||
|
||||
// ── UI State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
sealed interface NwcUiState {
|
||||
data object Loading : NwcUiState
|
||||
data class Success(val connections: List<NwcGetResponse>) : NwcUiState
|
||||
data class Error(val message: String) : NwcUiState
|
||||
}
|
||||
|
||||
// ── ViewModel ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class NwcViewModel(
|
||||
private val repo: WalletRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow<NwcUiState>(NwcUiState.Loading)
|
||||
val uiState: StateFlow<NwcUiState> = _uiState.asStateFlow()
|
||||
|
||||
/** Whether a delete operation is currently in flight (disables buttons). */
|
||||
private val _isDeleting = MutableStateFlow(false)
|
||||
val isDeleting: StateFlow<Boolean> = _isDeleting.asStateFlow()
|
||||
|
||||
// Holds the keypair only while the "add connection" flow is active.
|
||||
// Cleared as soon as the user dismisses the QR screen.
|
||||
private val _pendingKeypair = MutableStateFlow<NwcKeypair?>(null)
|
||||
val pendingKeypair: StateFlow<NwcKeypair?> = _pendingKeypair.asStateFlow()
|
||||
|
||||
/** True while a createConnection call is in flight. */
|
||||
private val _isCreating = MutableStateFlow(false)
|
||||
val isCreating: StateFlow<Boolean> = _isCreating.asStateFlow()
|
||||
|
||||
/** Holds the pairing URL after a successful registration, until dismissed. */
|
||||
private val _pairingUrl = MutableStateFlow<String?>(null)
|
||||
val pairingUrl: StateFlow<String?> = _pairingUrl.asStateFlow()
|
||||
|
||||
init {
|
||||
loadConnections()
|
||||
}
|
||||
|
||||
// ── Load ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fun loadConnections(includeExpired: Boolean = false) {
|
||||
Timber.d("[$TAG] loadConnections(includeExpired=$includeExpired)")
|
||||
_uiState.value = NwcUiState.Loading
|
||||
viewModelScope.launch {
|
||||
runCatching { repo.getNwcKeys(includeExpired) }
|
||||
.onSuccess { keys ->
|
||||
Timber.i("[$TAG] loaded ${keys.size} NWC connection(s)")
|
||||
_uiState.value = NwcUiState.Success(keys)
|
||||
}
|
||||
.onFailure { e ->
|
||||
Timber.e(e, "[$TAG] failed to load NWC connections")
|
||||
_uiState.value = NwcUiState.Error(
|
||||
e.message ?: "Failed to load connections"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a keypair, registers it with the server, then fetches
|
||||
* the nostr+walletconnect:// pairing URL.
|
||||
* Private key is held only in local scope — never stored.
|
||||
*/
|
||||
fun createConnection(
|
||||
description : String,
|
||||
permissions : List<String>,
|
||||
expiresAt : Long = 0L,
|
||||
budgets : List<NwcNewBudget> = emptyList()
|
||||
) {
|
||||
Timber.d("[$TAG] createConnection description='$description' permissions=$permissions")
|
||||
_isCreating.value = true
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
val keypair = NwcKeyGenerator.generate()
|
||||
Timber.d("[$TAG] generated keypair pubkey=${keypair.pubKeyHex.take(16)}…")
|
||||
check(keypair.pubKeyHex.length == 64) {
|
||||
"pubKeyHex must be 64 hex chars, got ${keypair.pubKeyHex.length}"
|
||||
}
|
||||
check(keypair.privKeyHex.length == 64) {
|
||||
"privKeyHex must be 64 hex chars, got ${keypair.privKeyHex.length}"
|
||||
}
|
||||
Timber.d("[$TAG] generated keypair pubkey=${keypair.pubKeyHex.take(16)}… (${keypair.pubKeyHex.length} chars)")
|
||||
Timber.d("[$TAG] REGISTERING pubkey=${keypair.pubKeyHex}")
|
||||
Timber.d("[$TAG] PAIRING secret=${keypair.privKeyHex}")
|
||||
|
||||
repo.registerNwcKey(
|
||||
pubkey = keypair.pubKeyHex,
|
||||
request = NwcRegistrationRequest(
|
||||
description = description,
|
||||
permissions = permissions,
|
||||
expires_at = expiresAt,
|
||||
budgets = budgets
|
||||
)
|
||||
)
|
||||
Timber.i("[$TAG] registered key ✓")
|
||||
|
||||
val url = repo.getNwcPairingUrl(keypair.privKeyHex)
|
||||
Timber.i("[$TAG] pairing URL fetched ✓")
|
||||
Timber.d("[$TAG] URI=$url")
|
||||
url
|
||||
}
|
||||
.onSuccess { url ->
|
||||
_pairingUrl.value = url
|
||||
loadConnections()
|
||||
}
|
||||
.onFailure { e ->
|
||||
Timber.e(e, "[$TAG] createConnection failed")
|
||||
_uiState.value = NwcUiState.Error(e.message ?: "Failed to create connection")
|
||||
}
|
||||
.also { _isCreating.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
/** Wipes the pairing URL from memory once the user has copied it. */
|
||||
fun clearPairingUrl() {
|
||||
Timber.d("[$TAG] clearing pairing URL from memory")
|
||||
_pairingUrl.value = null
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
|
||||
fun deleteConnection(pubkey: String) {
|
||||
Timber.d("[$TAG] deleteConnection pubkey=${pubkey.take(16)}…")
|
||||
_isDeleting.value = true
|
||||
viewModelScope.launch {
|
||||
runCatching { repo.deleteNwcKey(pubkey) }
|
||||
.onSuccess {
|
||||
Timber.i("[$TAG] deleted connection pubkey=${pubkey.take(16)}… ✓")
|
||||
// Remove locally for instant feedback, then reload to sync
|
||||
val current = _uiState.value
|
||||
if (current is NwcUiState.Success) {
|
||||
_uiState.value = NwcUiState.Success(
|
||||
current.connections.filter { it.data.pubkey != pubkey }
|
||||
)
|
||||
}
|
||||
loadConnections()
|
||||
}
|
||||
.onFailure { e ->
|
||||
Timber.e(e, "[$TAG] failed to delete connection pubkey=${pubkey.take(16)}…")
|
||||
_uiState.value = NwcUiState.Error(
|
||||
e.message ?: "Failed to delete connection"
|
||||
)
|
||||
}
|
||||
.also { _isDeleting.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Factory ───────────────────────────────────────────────────────────────
|
||||
|
||||
class Factory(private val repo: WalletRepository) :
|
||||
ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
NwcViewModel(repo) as T
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.bitcointxoko.gudariwallet.util
|
||||
|
||||
import fr.acinq.secp256k1.Secp256k1
|
||||
import java.security.SecureRandom
|
||||
|
||||
data class NwcKeypair(
|
||||
val privKeyHex: String, // 32-byte hex — the "secret" in the pairing URL
|
||||
val pubKeyHex : String // 32-byte BIP340 x-only hex — the {pubkey} path param
|
||||
)
|
||||
|
||||
object NwcKeyGenerator {
|
||||
|
||||
private val rng = SecureRandom()
|
||||
|
||||
fun generate(): NwcKeypair {
|
||||
val secp256k1 = Secp256k1.get()
|
||||
|
||||
val privKey = ByteArray(32)
|
||||
do { rng.nextBytes(privKey) } while (!secp256k1.secKeyVerify(privKey))
|
||||
|
||||
// BIP340: if the derived pubkey has odd Y, negate the private key.
|
||||
// The connecting app (Damus, Zeus, etc.) will do the same negation
|
||||
// before signing — so we must register the pubkey of the (possibly negated) key.
|
||||
val bip340PrivKey = normalizeToBip340(secp256k1, privKey)
|
||||
|
||||
val pubKeyInternal = secp256k1.pubkeyCreate(bip340PrivKey)
|
||||
val pubKeyCompressed = secp256k1.pubKeyCompress(pubKeyInternal) // 33 bytes: 02||x or 03||x
|
||||
|
||||
// After BIP340 normalization the prefix is always 02 (even Y).
|
||||
// x-only = drop the prefix byte.
|
||||
val xOnlyPubKey = pubKeyCompressed.copyOfRange(1, 33)
|
||||
|
||||
return NwcKeypair(
|
||||
privKeyHex = bip340PrivKey.toHex(), // ← send the (possibly negated) privkey as secret
|
||||
pubKeyHex = xOnlyPubKey.toHex()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* BIP340 normalization: if the public key derived from [privKey] has an odd Y coordinate
|
||||
* (compressed prefix 0x03), negate the private key so that the resulting pubkey has even Y.
|
||||
* This ensures the secret we hand to the connecting app and the pubkey we register
|
||||
* are consistent with how BIP340/Schnorr signing works.
|
||||
*/
|
||||
private fun normalizeToBip340(secp256k1: Secp256k1, privKey: ByteArray): ByteArray {
|
||||
val pubKeyInternal = secp256k1.pubkeyCreate(privKey)
|
||||
val pubKeyCompressed = secp256k1.pubKeyCompress(pubKeyInternal)
|
||||
val hasOddY = pubKeyCompressed[0] == 0x03.toByte()
|
||||
|
||||
return if (hasOddY) {
|
||||
// Negate: n - privKey (secp256k1 group order)
|
||||
val n = "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141"
|
||||
val negated = (java.math.BigInteger(n, 16) - java.math.BigInteger(1, privKey))
|
||||
.toByteArray()
|
||||
.let { bytes ->
|
||||
// BigInteger.toByteArray() may prepend a 0x00 sign byte — strip it and pad to 32
|
||||
val stripped = if (bytes[0] == 0x00.toByte()) bytes.copyOfRange(1, bytes.size) else bytes
|
||||
ByteArray(32 - stripped.size) + stripped // left-pad with zeros if needed
|
||||
}
|
||||
negated
|
||||
} else {
|
||||
privKey
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String =
|
||||
joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user