feat: nwc
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
package com.bitcointxoko.gudariwallet.api
|
package com.bitcointxoko.gudariwallet.api
|
||||||
|
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Header
|
import retrofit2.http.Header
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.PUT
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
import retrofit2.http.Url
|
import retrofit2.http.Url
|
||||||
@@ -144,4 +146,61 @@ interface LNbitsApi {
|
|||||||
*/
|
*/
|
||||||
@GET("api/v1/currencies")
|
@GET("api/v1/currencies")
|
||||||
suspend fun getSupportedCurrencies(): List<String>
|
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("data") val data: List<PaymentRecord>,
|
||||||
@SerializedName("total") val total: Int
|
@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 {
|
override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse {
|
||||||
return api.getPaymentDetail(secrets.invoiceKey(), checkingId)
|
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
|
package com.bitcointxoko.gudariwallet.data
|
||||||
|
|
||||||
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
|
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.PaginatedPaymentsResponse
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse
|
import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse
|
||||||
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
@@ -41,4 +43,20 @@ interface WalletRepository {
|
|||||||
suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse
|
suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse
|
||||||
suspend fun getFiatRate(currency: String): Double
|
suspend fun getFiatRate(currency: String): Double
|
||||||
suspend fun getSupportedCurrencies(): List<String>
|
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.ui.text.input.ImeAction
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.filled.Cable
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import com.bitcointxoko.gudariwallet.util.formatFiat
|
import com.bitcointxoko.gudariwallet.util.formatFiat
|
||||||
@@ -38,6 +39,7 @@ import com.bitcointxoko.gudariwallet.i18n.AppStrings
|
|||||||
fun HomeTab(
|
fun HomeTab(
|
||||||
vm: WalletViewModel,
|
vm: WalletViewModel,
|
||||||
onHistoryClick: () -> Unit,
|
onHistoryClick: () -> Unit,
|
||||||
|
onNwcClick : () -> Unit,
|
||||||
lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings
|
lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings
|
||||||
) {
|
) {
|
||||||
val strings = LocalAppStrings.current
|
val strings = LocalAppStrings.current
|
||||||
@@ -50,235 +52,252 @@ fun HomeTab(
|
|||||||
|
|
||||||
val isRefreshing = balanceState is BalanceState.Loading
|
val isRefreshing = balanceState is BalanceState.Loading
|
||||||
|| (balanceState as? BalanceState.Success)?.isRefreshing == true
|
|| (balanceState as? BalanceState.Success)?.isRefreshing == true
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
PullToRefreshBox(
|
PullToRefreshBox(
|
||||||
isRefreshing = isRefreshing,
|
isRefreshing = isRefreshing,
|
||||||
onRefresh = { vm.refreshBalance() },
|
onRefresh = { vm.refreshBalance() },
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
) {
|
||||||
when (val s = balanceState) {
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
is BalanceState.Loading -> {
|
.fillMaxSize()
|
||||||
CircularProgressIndicator()
|
.verticalScroll(rememberScrollState())
|
||||||
Spacer(Modifier.height(12.dp))
|
.padding(24.dp),
|
||||||
Text(
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
text = strings.loadingBalance,
|
verticalArrangement = Arrangement.Center
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
when (val s = balanceState) {
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
|
is BalanceState.Loading -> {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = if (balanceHidden) "••••••" else formatSats(s.sats),
|
text = strings.loadingBalance,
|
||||||
style = MaterialTheme.typography.displayLarge.copy(
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
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
|
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(2.dp))
|
|
||||||
val currency = selectedCurrency
|
is BalanceState.Success -> {
|
||||||
if (currency != null) {
|
val selectedCurrency by vm.selectedCurrency.collectAsState()
|
||||||
Row(
|
var showCurrencyPicker by remember { mutableStateOf(false) }
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
var isLoadingCurrencies by remember { mutableStateOf(false) }
|
||||||
horizontalArrangement = Arrangement.Center
|
var currencyList by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
) {
|
LaunchedEffect(showCurrencyPicker) {
|
||||||
if (s.fiatAmount != null && s.fiatCurrency != null) {
|
if (showCurrencyPicker && currencyList.isEmpty()) {
|
||||||
Text(
|
isLoadingCurrencies = true
|
||||||
text = if (balanceHidden) "••••"
|
currencyList = vm.getSupportedCurrencies()
|
||||||
else formatFiat(s.fiatAmount, s.fiatCurrency),
|
isLoadingCurrencies = false
|
||||||
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,
|
Row(
|
||||||
modifier = Modifier
|
verticalAlignment = Alignment.CenterVertically
|
||||||
.clickable(
|
) {
|
||||||
onClick = { showCurrencyPicker = true },
|
Icon(
|
||||||
onClickLabel = strings.changeCurrency,
|
imageVector = if (balanceHidden)
|
||||||
indication = null,
|
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() }
|
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(
|
||||||
text = currency,
|
text = strings.showFiat,
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontFamily = FontFamily.Monospace
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
|
||||||
),
|
|
||||||
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(
|
Spacer(Modifier.height(8.dp))
|
||||||
onClick = { showCurrencyPicker = true },
|
|
||||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
|
if (!s.isRefreshing) {
|
||||||
) {
|
val updatedLabel: String = remember(s.lastUpdated) {
|
||||||
|
formatLastUpdated(s.lastUpdated, strings)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = strings.showFiat,
|
text = strings.updatedAt(updatedLabel),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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) {
|
TextButton(onClick = onHistoryClick) {
|
||||||
val updatedLabel: String = remember(s.lastUpdated) {
|
Text(
|
||||||
formatLastUpdated(s.lastUpdated, strings)
|
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))
|
// ── NEW: Language switcher ─────────────────────────────────
|
||||||
|
|
||||||
TextButton(onClick = onHistoryClick) {
|
|
||||||
Text(
|
|
||||||
text = strings.viewHistory,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── NEW: Language switcher ─────────────────────────────────
|
|
||||||
// Spacer(Modifier.height(8.dp))
|
// Spacer(Modifier.height(8.dp))
|
||||||
// LanguageSwitcherButton(
|
// LanguageSwitcherButton(
|
||||||
// currentTag = lyricist.languageTag,
|
// currentTag = lyricist.languageTag,
|
||||||
// supportedTags = supportedTags,
|
// supportedTags = supportedTags,
|
||||||
// onSelect = { tag -> lyricist.languageTag = tag }
|
// onSelect = { tag -> lyricist.languageTag = tag }
|
||||||
// )
|
// )
|
||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if (showCurrencyPicker) {
|
if (showCurrencyPicker) {
|
||||||
CurrencyPickerSheet(
|
CurrencyPickerSheet(
|
||||||
currencies = currencyList,
|
currencies = currencyList,
|
||||||
selectedCurrency = selectedCurrency,
|
selectedCurrency = selectedCurrency,
|
||||||
onSelect = { code ->
|
onSelect = { code ->
|
||||||
vm.setSelectedCurrency(code)
|
vm.setSelectedCurrency(code)
|
||||||
showCurrencyPicker = false
|
showCurrencyPicker = false
|
||||||
},
|
},
|
||||||
isLoading = isLoadingCurrencies,
|
isLoading = isLoadingCurrencies,
|
||||||
onDismiss = { showCurrencyPicker = false }
|
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(
|
is BalanceState.Error -> {
|
||||||
color = MaterialTheme.colorScheme.errorContainer,
|
if (s.staleSats != null) {
|
||||||
shape = MaterialTheme.shapes.small,
|
Text(
|
||||||
modifier = Modifier.fillMaxWidth(0.85f)
|
text = if (balanceHidden) "••••••" else formatSats(s.staleSats),
|
||||||
) {
|
style = MaterialTheme.typography.displayLarge.copy(
|
||||||
Text(
|
fontFamily = FontFamily.Monospace
|
||||||
text = if (s.staleSats != null)
|
),
|
||||||
strings.couldNotRefresh
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||||||
else
|
)
|
||||||
strings.couldNotLoadBalance,
|
Text(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
text = strings.sats,
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.padding(12.dp)
|
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.HistoryViewModelFactory
|
||||||
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
|
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
|
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.receive.ReceiveScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
|
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
|
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_SCANNER = "scanner"
|
||||||
private const val ROUTE_HISTORY = "history"
|
private const val ROUTE_HISTORY = "history"
|
||||||
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
|
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
|
||||||
|
private const val ROUTE_NWC = "nwc"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WalletScreen(
|
fun WalletScreen(
|
||||||
@@ -120,6 +123,9 @@ fun WalletScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
|
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 navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = navBackStackEntry?.destination?.route
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
@@ -127,10 +133,12 @@ fun WalletScreen(
|
|||||||
val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL
|
val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL
|
||||||
&& currentRoute != ROUTE_SCANNER
|
&& currentRoute != ROUTE_SCANNER
|
||||||
&& currentRoute != ROUTE_HISTORY
|
&& currentRoute != ROUTE_HISTORY
|
||||||
|
&& currentRoute != ROUTE_NWC
|
||||||
|
|
||||||
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|
||||||
|| currentRoute == ROUTE_PAYMENT_DETAIL
|
|| currentRoute == ROUTE_PAYMENT_DETAIL
|
||||||
|| currentRoute == ROUTE_SCANNER
|
|| currentRoute == ROUTE_SCANNER
|
||||||
|
|| currentRoute == ROUTE_NWC
|
||||||
|
|
||||||
// ── Notification tap ──────────────────────────────────────────────────────
|
// ── Notification tap ──────────────────────────────────────────────────────
|
||||||
val hash = pendingPaymentHash.value
|
val hash = pendingPaymentHash.value
|
||||||
@@ -322,6 +330,11 @@ fun WalletScreen(
|
|||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onNwcClick = {
|
||||||
|
navController.navigate(ROUTE_NWC) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
lyricist = lyricist
|
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 ────
|
// ── 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