From 8ef2d809016d7c1b71a3d1ad6ab14233d6506f72 Mon Sep 17 00:00:00 2001 From: rasputin Date: Sat, 6 Jun 2026 01:50:29 +0200 Subject: [PATCH] feat: localisation with lyricist --- .../gudariwallet/i18n/EnStrings.kt | 16 +- .../gudariwallet/i18n/EsStrings.kt | 18 +- .../bitcointxoko/gudariwallet/i18n/Strings.kt | 14 ++ .../gudariwallet/ui/WalletScreen.kt | 28 ++- .../gudariwallet/ui/WalletViewModel.kt | 42 +++- .../gudariwallet/ui/send/ConfirmCard.kt | 16 +- .../gudariwallet/ui/send/LnurlPayForm.kt | 6 +- .../gudariwallet/ui/send/SendScreen.kt | 22 +- .../gudariwallet/ui/send/SendViewModel.kt | 223 ++++++++++-------- 9 files changed, 257 insertions(+), 128 deletions(-) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EnStrings.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EnStrings.kt index ae13ca7..550d61c 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EnStrings.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EnStrings.kt @@ -110,7 +110,7 @@ val EnHomeStrings = AppStrings( receiveVmWithdrawRejected = "Withdraw service rejected the invoice", receiveVmWithdrawFailed = "Withdraw failed", -// ── SendScreen — general ────────────────────────────────────────────────── + // ── SendScreen — general ────────────────────────────────────────────────── send = "Send", paste = "Paste", continueButton = "Continue", @@ -176,6 +176,20 @@ val EnHomeStrings = AppStrings( routeHintHopsHeader = "Private route hint hops", routeHintHopFees = { ppm, baseLabel -> "$ppm ppm · $baseLabel base" }, + // ── SendViewModel ───────────────────────────────────────────────────────── + sendVmLnurlAuthNotSupported = "Lightning Login (LNURL-auth) is not yet supported", + sendVmUnrecognisedInput = "Unrecognised input — paste a BOLT-11 invoice, LNURL, or Lightning Address", + sendVmPaymentFailed = "Payment failed", + sendVmCouldNotFetchInvoice = "Could not fetch invoice from recipient", + sendVmCouldNotDecodeInvoice = "Could not decode invoice", + sendVmAuthFailed = { msg -> "Authentication failed: $msg" }, + sendVmOnChainNotSupported = "On-chain Bitcoin payments are not supported — share a BIP-21 URI with a lightning= parameter", + sendVmChannelRequestNotSupported = "Channel requests (lnurlc) are not supported", + sendVmCouldNotResolveAddress = "Could not resolve address", + sendVmEmptyResponse = "Empty response from server", + sendVmUnsupportedType = { tag -> "Unsupported type: $tag" }, + sendVmPaymentFailedRescan = "Payment failed — could not re-fetch address", + // ── HistoryScreen ───────────────────────────────────────────────────────── historyTitle = "History", historyClose = "Close", diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EsStrings.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EsStrings.kt index b730d8a..a752e0a 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EsStrings.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/EsStrings.kt @@ -110,7 +110,7 @@ val EsHomeStrings = AppStrings( receiveVmWithdrawRejected = "El servicio de retiro rechazó la factura", receiveVmWithdrawFailed = "Error al retirar", -// ── SendScreen — general ────────────────────────────────────────────────── + // ── SendScreen — general ────────────────────────────────────────────────── send = "Enviar", paste = "Pegar", continueButton = "Continuar", @@ -176,7 +176,21 @@ val EsHomeStrings = AppStrings( routeHintHopsHeader = "Saltos de ruta privada", routeHintHopFees = { ppm, baseLabel -> "$ppm ppm · $baseLabel base" }, - // ── HistoryScreen ───────────────────────────────────────────────────────── + // ── SendViewModel ───────────────────────────────────────────────────────── + sendVmLnurlAuthNotSupported = "Lightning Login (LNURL-auth) aún no está disponible", + sendVmUnrecognisedInput = "Entrada no reconocida — pega una factura BOLT-11, LNURL o dirección Lightning", + sendVmPaymentFailed = "Pago fallido", + sendVmCouldNotFetchInvoice = "No se pudo obtener la factura del destinatario", + sendVmCouldNotDecodeInvoice = "No se pudo decodificar la factura", + sendVmAuthFailed = { msg -> "Autenticación fallida: $msg" }, + sendVmOnChainNotSupported = "Los pagos Bitcoin en cadena no están disponibles — comparte un URI BIP-21 con el parámetro lightning=", + sendVmChannelRequestNotSupported = "Las solicitudes de canal (lnurlc) no están disponibles", + sendVmCouldNotResolveAddress = "No se pudo resolver la dirección", + sendVmEmptyResponse = "Respuesta vacía del servidor", + sendVmUnsupportedType = { tag -> "Tipo no compatible: $tag" }, + sendVmPaymentFailedRescan = "Pago fallido — no se pudo volver a obtener la dirección", + +// ── HistoryScreen ───────────────────────────────────────────────────────── historyTitle = "Historial", historyClose = "Cerrar", historySearchPayments = "Buscar pagos", diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/Strings.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/Strings.kt index f06b7b6..297eb3e 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/Strings.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/i18n/Strings.kt @@ -185,6 +185,20 @@ data class AppStrings( val routeHintHopsHeader : String, val routeHintHopFees : (ppm: Long, baseLabel: String) -> String, + // ── SendViewModel ───────────────────────────────────────────────────────── + val sendVmLnurlAuthNotSupported : String, + val sendVmUnrecognisedInput : String, + val sendVmPaymentFailed : String, + val sendVmCouldNotFetchInvoice : String, + val sendVmCouldNotDecodeInvoice : String, + val sendVmAuthFailed : (msg: CharSequence) -> String, + val sendVmOnChainNotSupported : String, + val sendVmChannelRequestNotSupported : String, + val sendVmCouldNotResolveAddress : String, + val sendVmEmptyResponse : String, + val sendVmUnsupportedType : (tag: String) -> String, + val sendVmPaymentFailedRescan : String, + // ── HistoryScreen ───────────────────────────────────────────────────────── val historyTitle : String, val historyClose : String, diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt index e1f888d..d1d4855 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt @@ -64,6 +64,7 @@ import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen import com.bitcointxoko.gudariwallet.ui.send.SendScreen +import com.bitcointxoko.gudariwallet.ui.send.SendStrings import com.bitcointxoko.gudariwallet.util.SendInputType import kotlinx.coroutines.delay @@ -90,6 +91,22 @@ fun WalletScreen( lyricist : Lyricist ) { val strings = LocalAppStrings.current + val sendStrings = remember(strings) { + SendStrings( + lnurlAuthNotSupported = strings.sendVmLnurlAuthNotSupported, + unrecognisedInput = strings.sendVmUnrecognisedInput, + paymentFailed = strings.sendVmPaymentFailed, + couldNotFetchInvoice = strings.sendVmCouldNotFetchInvoice, + couldNotDecodeInvoice = strings.sendVmCouldNotDecodeInvoice, + onChainNotSupported = strings.sendVmOnChainNotSupported, + channelRequestNotSupported = strings.sendVmChannelRequestNotSupported, + couldNotResolveAddress = strings.sendVmCouldNotResolveAddress, + emptyResponse = strings.sendVmEmptyResponse, + unsupportedType = strings.sendVmUnsupportedType, + paymentFailedRescan = strings.sendVmPaymentFailedRescan, + authFailed = strings.sendVmAuthFailed, + ) + } val navController = rememberNavController() val context = LocalContext.current val historyFactory = remember { @@ -153,7 +170,7 @@ fun WalletScreen( if (intentUri != null) { Log.d("WalletScreen", "INTENT [URI] deep link received: $intentUri") pendingPaymentUri.value = null - vm.scan(intentUri) + vm.scan(intentUri, sendStrings) navController.navigate(TabItem.Send.route) { launchSingleTop = true } @@ -316,7 +333,8 @@ fun WalletScreen( navController.navigate("payment_detail/$checkingId") { launchSingleTop = true } - } + }, + sendStrings ) } @@ -324,7 +342,7 @@ fun WalletScreen( composable(ROUTE_SCANNER) { QrScannerScreen( onScanned = { scanned -> - vm.scan(scanned) + vm.scan(scanned, sendStrings) val wentBack = navController.popBackStack(TabItem.Send.route, inclusive = false) if (!wentBack) { navController.navigate(TabItem.Send.route) { @@ -418,7 +436,7 @@ fun WalletScreen( .fillMaxWidth() .clickable { vm.dismissClipboardOffer() - vm.scan(offer.raw) + vm.scan(offer.raw, sendStrings) navController.navigate(TabItem.Send.route) { launchSingleTop = true } @@ -474,7 +492,7 @@ fun WalletScreen( modifier = Modifier .fillMaxWidth() .clickable { - vm.scan(offer.raw) + vm.scan(offer.raw, sendStrings) nfcVm.dismissNfcOffer() navController.navigate(TabItem.Send.route) { launchSingleTop = true diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt index d82caea..17ce022 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt @@ -17,6 +17,7 @@ import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel +import com.bitcointxoko.gudariwallet.ui.send.SendStrings import com.bitcointxoko.gudariwallet.ui.send.SendViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn @@ -75,15 +76,38 @@ class WalletViewModel( ) val sendState: StateFlow get() = sendVm.sendState fun refreshBalance() = balanceVm.refreshBalance() - fun scan(rawInput: String) = sendVm.scan(rawInput) - fun payBolt11(bolt11: String) = sendVm.payBolt11(bolt11) - fun payLnurl(rawRes: LnurlScanResponse, lnurl: String, amountMsat: Long, comment: String?) = - sendVm.payLnurl(rawRes, lnurl, amountMsat, comment) - fun fetchLnurlInvoice(rawRes: LnurlScanResponse, lnurl: String, domain: String, amountMsat: Long, comment: String?) = - sendVm.fetchLnurlInvoice(rawRes, lnurl, domain, amountMsat, comment) - fun payLnurlInvoice(state: SendState.LnurlInvoiceReady) = sendVm.payLnurlInvoice(state) - fun requestPayment(activity: FragmentActivity, subtitle: String, pay: () -> Unit) = - sendVm.requestPayment(activity, subtitle, pay) + fun scan(rawInput: String, strings: SendStrings) = + sendVm.scan(rawInput, strings) + + fun payBolt11(bolt11: String, strings: SendStrings) = + sendVm.payBolt11(bolt11, strings) + + fun payLnurl( + rawRes : LnurlScanResponse, + lnurl : String, + amountMsat: Long, + comment : String?, + strings : SendStrings, + ) = sendVm.payLnurl(rawRes, lnurl, amountMsat, comment, strings) + + fun fetchLnurlInvoice( + rawRes : LnurlScanResponse, + lnurl : String, + domain : String, + amountMsat: Long, + comment : String?, + strings : SendStrings, + ) = sendVm.fetchLnurlInvoice(rawRes, lnurl, domain, amountMsat, comment, strings) + + fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) = + sendVm.payLnurlInvoice(state, strings) + + fun requestPayment( + activity : FragmentActivity, + subtitle : String, + strings : SendStrings, + pay : () -> Unit, + ) = sendVm.requestPayment(activity, subtitle, strings, pay) fun resetSendState() = sendVm.resetSendState() val lightningAddress: StateFlow = lnAddressStore.lightningAddressFlow diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/ConfirmCard.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/ConfirmCard.kt index bba3a72..2f7e892 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/ConfirmCard.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/ConfirmCard.kt @@ -43,7 +43,8 @@ internal fun Bolt11ConfirmCard( activity : FragmentActivity, vm : WalletViewModel, fiatRate : Double?, - fiatCurrency: String? + fiatCurrency: String?, + sendStrings : SendStrings, ) { val strings = LocalAppStrings.current val fiatLbl = fiatLabel(state.amountSats, fiatRate, fiatCurrency) @@ -54,8 +55,9 @@ internal fun Bolt11ConfirmCard( onPay = { vm.requestPayment( activity, - strings.sendBiometricPrompt(state.amountSats) // ← "Send X sats" - ) { vm.payBolt11(state.bolt11) } + strings.sendBiometricPrompt(state.amountSats), + strings = sendStrings, // ← "Send X sats" + ) { vm.payBolt11(state.bolt11, sendStrings) } }, onCancel = { vm.resetSendState() } ) @@ -67,7 +69,8 @@ internal fun LnurlInvoiceConfirmCard( activity : FragmentActivity, vm : WalletViewModel, fiatRate : Double?, - fiatCurrency: String? + fiatCurrency: String?, + sendStrings : SendStrings, ) { val strings = LocalAppStrings.current val label = payingToLabel(state.lnurl, state.domain) @@ -79,8 +82,9 @@ internal fun LnurlInvoiceConfirmCard( onPay = { vm.requestPayment( activity, - strings.sendBiometricPromptTo(state.amountSats, label) // ← "Send X sats to Y" - ) { vm.payLnurlInvoice(state) } + strings.sendBiometricPromptTo(state.amountSats, label), + strings = sendStrings, // ← "Send X sats to Y" + ) { vm.payLnurlInvoice(state, sendStrings) } }, onCancel = { vm.resetSendState() } ) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt index 25a0424..bf4645f 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt @@ -31,7 +31,8 @@ internal fun LnurlPayForm( state : SendState.LnurlReady, vm : WalletViewModel, fiatRate : Double?, - fiatCurrency: String? + fiatCurrency: String?, + sendStrings: SendStrings ) { val strings = LocalAppStrings.current @@ -115,7 +116,8 @@ internal fun LnurlPayForm( lnurl = state.lnurl, domain = state.domain, amountMsat = sats * WalletConstants.MSAT_PER_SAT, - comment = comment.takeIf { it.isNotBlank() } + comment = comment.takeIf { it.isNotBlank() }, + strings = sendStrings ) } }, diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendScreen.kt index 180ac88..1154a80 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendScreen.kt @@ -38,7 +38,8 @@ import kotlinx.coroutines.launch fun SendScreen( vm : WalletViewModel, nfcVm : NfcViewModel, - onViewDetails: (paymentHash: String, record: PaymentRecord) -> Unit = { _, _ -> } + onViewDetails: (paymentHash: String, record: PaymentRecord) -> Unit = { _, _ -> }, + sendStrings: SendStrings ) { val strings = LocalAppStrings.current val sendState : SendState by vm.sendState.collectAsStateWithLifecycle() @@ -63,7 +64,7 @@ fun SendScreen( val offer = nfcOffer if (offer is NfcOfferState.Detected && sendState is SendState.Idle) { input = offer.raw - vm.scan(offer.raw) + vm.scan(offer.raw, sendStrings) nfcVm.dismissNfcOffer() } } @@ -124,7 +125,7 @@ fun SendScreen( imeAction = ImeAction.Go ), keyboardActions = KeyboardActions( - onGo = { if (input.isNotBlank()) vm.scan(input) } + onGo = { if (input.isNotBlank()) vm.scan(input, sendStrings) } ), ) Spacer(Modifier.height(8.dp)) @@ -143,7 +144,7 @@ fun SendScreen( ?.toString() if (!text.isNullOrBlank()) { input = SendInputDetector.normalize(text) - vm.scan(text) + vm.scan(text, sendStrings) } } }, @@ -159,7 +160,7 @@ fun SendScreen( } Spacer(Modifier.height(8.dp)) OutlinedButton( - onClick = { if (input.isNotBlank()) vm.scan(input) }, + onClick = { if (input.isNotBlank()) vm.scan(input, sendStrings) }, enabled = input.isNotBlank(), modifier = Modifier.fillMaxWidth() ) { Text(strings.continueButton) } // ← "Continue" @@ -178,7 +179,8 @@ fun SendScreen( activity = activity, vm = vm, fiatRate = fiatRate, - fiatCurrency = fiatCurrency + fiatCurrency = fiatCurrency, + sendStrings ) } @@ -187,7 +189,8 @@ fun SendScreen( state = state, vm = vm, fiatRate = fiatRate, - fiatCurrency = fiatCurrency + fiatCurrency = fiatCurrency, + sendStrings ) } @@ -197,7 +200,8 @@ fun SendScreen( activity = activity, vm = vm, fiatRate = fiatRate, - fiatCurrency = fiatCurrency + fiatCurrency = fiatCurrency, + sendStrings ) } @@ -284,7 +288,7 @@ fun SendScreen( ) Spacer(Modifier.height(12.dp)) Button( - onClick = { if (input.isNotBlank()) vm.scan(input) }, + onClick = { if (input.isNotBlank()) vm.scan(input, sendStrings) }, enabled = input.isNotBlank(), modifier = Modifier.fillMaxWidth() ) { Text(strings.tryAgain) } // ← "Try Again" diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt index 4f0d62f..0f68405 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendViewModel.kt @@ -26,13 +26,32 @@ import kotlinx.coroutines.launch private const val TAG = "SendViewModel" +// ── String bundle injected at the call site ─────────────────────────────────── +/** + * All user-visible strings produced by [SendViewModel]. + * Populate from [LocalAppStrings.current] in the owning Composable and pass + * to [SendViewModel.scan], [SendViewModel.payBolt11], etc. + */ +data class SendStrings( + val lnurlAuthNotSupported : String, + val unrecognisedInput : String, + val paymentFailed : String, + val couldNotFetchInvoice : String, + val couldNotDecodeInvoice : String, + val onChainNotSupported : String, + val channelRequestNotSupported : String, + val couldNotResolveAddress : String, + val emptyResponse : String, + val unsupportedType : (tag: String) -> String, + val paymentFailedRescan : String, + val authFailed : (msg: String) -> String, +) + class SendViewModel( private val repo : WalletRepository, private val aliasRepo : NodeAliasRepository, private val lnurlCache : LnurlCacheRepository, private val balanceVm : BalanceViewModel, - // Called when a withdrawRequest is scanned — hands off to ReceiveViewModel - // without creating a hard dependency between the two. private val onWithdrawScanned: (raw: LnurlScanResponse, lnurl: String) -> Unit, ) : ViewModel() { @@ -41,33 +60,32 @@ class SendViewModel( // ── Entry point ────────────────────────────────────────────────────────── - fun scan(rawInput: String) { + fun scan(rawInput: String, strings: SendStrings) { val normalized = SendInputDetector.normalize(rawInput) val type = SendInputDetector.detect(rawInput) Log.d(TAG, "SCAN [DETECT] input='$rawInput' → type=$type") when (type) { - SendInputType.Unknown -> { - val msg = if (rawInput.trim().lowercase().startsWith("keyauth")) { - "Lightning Login (LNURL-auth) is not yet supported" - } else { - "Unrecognised input — paste a BOLT-11 invoice, LNURL, or Lightning Address" - } + SendInputType.Unknown -> { + val msg = if (rawInput.trim().lowercase().startsWith("keyauth")) + strings.lnurlAuthNotSupported // ← "Lightning Login (LNURL-auth) is not yet supported" + else + strings.unrecognisedInput // ← "Unrecognised input — paste a BOLT-11 invoice, LNURL, or Lightning Address" _sendState.value = SendState.Error(msg) } - SendInputType.Bip21 -> handleBip21Scan(rawInput) - SendInputType.Lud17Url -> handleLud17Scan(rawInput) - SendInputType.Bolt11 -> handleBolt11Scan(normalized) - else -> handleLnurlScan(normalized) + SendInputType.Bip21 -> handleBip21Scan(rawInput, strings) + SendInputType.Lud17Url -> handleLud17Scan(rawInput, strings) + SendInputType.Bolt11 -> handleBolt11Scan(normalized, strings) + else -> handleLnurlScan(normalized, strings) } } // ── Payment ────────────────────────────────────────────────────────────── - fun payBolt11(bolt11: String) { + fun payBolt11(bolt11: String, strings: SendStrings) { viewModelScope.launch { - val decoded = _sendState.value as? SendState.Bolt11Decoded - val amountSats = (_sendState.value as? SendState.Bolt11Decoded)?.amountSats ?: 0L + val decoded = _sendState.value as? SendState.Bolt11Decoded + val amountSats = decoded?.amountSats ?: 0L _sendState.value = SendState.Paying runCatching { repo.payBolt11(bolt11) } .onSuccess { response -> @@ -92,7 +110,9 @@ class SendViewModel( } .onFailure { e -> Log.e(TAG, "SEND [PAY BOLT11 ERROR] ${e.message}") - _sendState.value = SendState.Error(parseApiError(e, "Payment failed")) + _sendState.value = SendState.Error( + parseApiError(e, strings.paymentFailed) // ← "Payment failed" + ) } } } @@ -101,23 +121,26 @@ class SendViewModel( rawRes : LnurlScanResponse, lnurl : String, amountMsat: Long, - comment : String? + comment : String?, + strings : SendStrings, ) { viewModelScope.launch { _sendState.value = SendState.Paying // ── 1. Initial attempt (direct → proxy) - val initial = tryPayLnurlWithFallback(rawRes, lnurl, amountMsat, comment, "INITIAL") + val initial = tryPayLnurlWithFallback(rawRes, lnurl, amountMsat, comment, "INITIAL", strings) if (initial.isSuccess) { recordPaymentSuccess(initial.getOrThrow(), amountMsat) return@launch } // ── 2. Both paths failed — check if retrying is worthwhile - val initialError = initial.exceptionOrNull() ?: Exception("Payment failed") + val initialError = initial.exceptionOrNull() ?: Exception(strings.paymentFailed) if (isTerminalPayError(initialError)) { Log.w(TAG, "LNURL [PAY TERMINAL] ${initialError.message} — not retrying") - _sendState.value = SendState.Error(parseApiError(initialError, "Payment failed")) + _sendState.value = SendState.Error( + parseApiError(initialError, strings.paymentFailed) // ← "Payment failed" + ) return@launch } @@ -125,7 +148,7 @@ class SendViewModel( lnurlCache.invalidate(lnurl) // ── 3. Re-fetch metadata - val freshRaw = rescanLnurl(lnurl) ?: return@launch // error already set + val freshRaw = rescanLnurl(lnurl, strings) ?: return@launch // error already set val scanProtocolError = lnurlProtocolError(freshRaw) if (scanProtocolError != null) { @@ -136,29 +159,28 @@ class SendViewModel( lnurlCache.put(lnurl, freshRaw) // ── 4. Final attempt (direct → proxy) with fresh metadata - val retry = tryPayLnurlWithFallback(freshRaw, lnurl, amountMsat, comment, "RETRY") + val retry = tryPayLnurlWithFallback(freshRaw, lnurl, amountMsat, comment, "RETRY", strings) if (retry.isSuccess) { recordPaymentSuccess(retry.getOrThrow(), amountMsat) return@launch } // ── 5. Final failure - val retryError = retry.exceptionOrNull() ?: Exception("Payment failed") + val retryError = retry.exceptionOrNull() ?: Exception(strings.paymentFailed) Log.e(TAG, "LNURL [PAY FINAL ERR] ${retryError.message}") - _sendState.value = SendState.Error(parseApiError(retryError, "Payment failed")) + _sendState.value = SendState.Error( + parseApiError(retryError, strings.paymentFailed) // ← "Payment failed" + ) } } - /** - * Step 1: fetch the LNURL callback invoice, decode it, analyse route hints. - * Called after the user taps "Pay" in LnurlPayForm and passes biometric auth. - */ fun fetchLnurlInvoice( rawRes : LnurlScanResponse, lnurl : String, domain : String, amountMsat: Long, - comment : String? + comment : String?, + strings : SendStrings, ) { viewModelScope.launch { _sendState.value = SendState.Scanning @@ -168,7 +190,7 @@ class SendViewModel( }.getOrElse { e -> Log.e(TAG, "LNURL [FETCH INVOICE ERR] ${e.message}") _sendState.value = SendState.Error( - parseApiError(e, "Could not fetch invoice from recipient") + parseApiError(e, strings.couldNotFetchInvoice) // ← "Could not fetch invoice from recipient" ) return@launch } @@ -177,7 +199,9 @@ class SendViewModel( repo.decodeBolt11(bolt11) }.getOrElse { e -> Log.e(TAG, "LNURL [DECODE ERR] ${e.message}") - _sendState.value = SendState.Error(parseApiError(e, "Could not decode invoice")) + _sendState.value = SendState.Error( + parseApiError(e, strings.couldNotDecodeInvoice) // ← "Could not decode invoice" + ) return@launch } @@ -225,18 +249,14 @@ class SendViewModel( } } - /** - * Step 2: pay the bolt11 fetched in fetchLnurlInvoice. - * Tries payBolt11 directly first; falls back to the full payLnurl path. - */ - fun payLnurlInvoice(state: SendState.LnurlInvoiceReady) { + fun payLnurlInvoice(state: SendState.LnurlInvoiceReady, strings: SendStrings) { viewModelScope.launch { _sendState.value = SendState.Paying val directResult = runCatching { repo.payBolt11(state.bolt11) } - val paymentHash = directResult.getOrThrow().paymentHash if (directResult.isSuccess) { + val paymentHash = directResult.getOrThrow().paymentHash val feeSats = runCatching { repo.getPaymentDetail(paymentHash).details?.feeSat }.getOrNull() @@ -265,7 +285,8 @@ class SendViewModel( rawRes = state.rawRes, lnurl = state.lnurl, amountMsat = state.amountMsat, - comment = state.comment + comment = state.comment, + strings = strings, ) } } @@ -274,9 +295,10 @@ class SendViewModel( * Biometric gate. Shows prompt if device supports it; calls [pay] directly if not. */ fun requestPayment( - activity: FragmentActivity, - subtitle: String, - pay : () -> Unit + activity : FragmentActivity, + subtitle : String, + strings : SendStrings, + pay : () -> Unit ) { if (!BiometricHelper.canAuthenticate(activity)) { pay() @@ -287,7 +309,9 @@ class SendViewModel( subtitle = subtitle, onSuccess = pay, onError = { _, msg -> - _sendState.value = SendState.Error("Authentication failed: $msg") + _sendState.value = SendState.Error( + strings.authFailed(msg.toString()) // ← "Authentication failed: $msg" + ) } ) } @@ -296,24 +320,25 @@ class SendViewModel( // ── Scan handlers ──────────────────────────────────────────────────────── - private fun handleBip21Scan(rawInput: String) { + private fun handleBip21Scan(rawInput: String, strings: SendStrings) { val params = parseBip21Params(rawInput) val bolt11 = params["lightning"] val lnurl = params["lnurl"] ?: params["lnurlp"] when { - bolt11 != null -> scan(bolt11) - lnurl != null -> scan(lnurl) + bolt11 != null -> scan(bolt11, strings) + lnurl != null -> scan(lnurl, strings) else -> _sendState.value = SendState.Error( - "On-chain Bitcoin payments are not supported — " + - "share a BIP-21 URI with a lightning= parameter" + strings.onChainNotSupported // ← "On-chain Bitcoin payments are not supported…" ) } } - private fun handleLud17Scan(rawInput: String) { + private fun handleLud17Scan(rawInput: String, strings: SendStrings) { val scheme = rawInput.trim().lowercase().substringBefore("://") if (scheme == "lnurlc") { - _sendState.value = SendState.Error("Channel requests (lnurlc) are not supported") + _sendState.value = SendState.Error( + strings.channelRequestNotSupported // ← "Channel requests (lnurlc) are not supported" + ) return } val httpsUrl = "https://" + rawInput.trim().substringAfter("://") @@ -333,18 +358,20 @@ class SendViewModel( _sendState.value = buildLnurlReadyState(raw, httpsUrl) } else -> _sendState.value = SendState.Error( - "Unsupported LNURL type: ${raw.tag ?: "unknown"}" + strings.unsupportedType(raw.tag ?: "unknown") // ← "Unsupported type: $tag" ) } } .onFailure { e -> Log.e(TAG, "LUD17 [ERROR] ${e.message}") - _sendState.value = SendState.Error(parseApiError(e, "Could not resolve address")) + _sendState.value = SendState.Error( + parseApiError(e, strings.couldNotResolveAddress) // ← "Could not resolve address" + ) } } } - private fun handleBolt11Scan(normalized: String) { + private fun handleBolt11Scan(normalized: String, strings: SendStrings) { _sendState.value = SendState.Scanning viewModelScope.launch { runCatching { repo.decodeBolt11(normalized) } @@ -383,12 +410,14 @@ class SendViewModel( } .onFailure { e -> Log.e(TAG, "SEND [DECODE ERROR] ${e.message}") - _sendState.value = SendState.Error(parseApiError(e, "Could not decode invoice")) + _sendState.value = SendState.Error( + parseApiError(e, strings.couldNotDecodeInvoice) // ← "Could not decode invoice" + ) } } } - private fun handleLnurlScan(normalized: String) { + private fun handleLnurlScan(normalized: String, strings: SendStrings) { _sendState.value = SendState.Scanning viewModelScope.launch { // 1. Cache hit @@ -424,8 +453,8 @@ class SendViewModel( _sendState.value = SendState.Idle onWithdrawScanned(scanned.rawRes, normalized) } - null -> _sendState.value = SendState.Error("Empty response from server") - else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}") + null -> _sendState.value = SendState.Error(strings.emptyResponse) // ← "Empty response from server" + else -> _sendState.value = SendState.Error(strings.unsupportedType(scanned.tag)) // ← "Unsupported type: $tag" } return@launch } @@ -448,12 +477,14 @@ class SendViewModel( _sendState.value = SendState.Idle onWithdrawScanned(scanned.rawRes, normalized) } - null -> _sendState.value = SendState.Error("Empty response from server") - else -> _sendState.value = SendState.Error("Unsupported type: ${scanned.tag}") + null -> _sendState.value = SendState.Error(strings.emptyResponse) // ← "Empty response from server" + else -> _sendState.value = SendState.Error(strings.unsupportedType(scanned.tag)) // ← "Unsupported type: $tag" } } .onFailure { e -> - _sendState.value = SendState.Error(parseApiError(e, "Could not resolve address")) + _sendState.value = SendState.Error( + parseApiError(e, strings.couldNotResolveAddress) // ← "Could not resolve address" + ) } } } @@ -474,16 +505,14 @@ class SendViewModel( } private suspend fun recordPaymentSuccess( - paymentHash: String, - amountMsat: Long, - bolt11 : String? = null, - memo : String? = null, - pubkey : String? = null, - alias : String? = null + paymentHash : String, + amountMsat : Long, + bolt11 : String? = null, + memo : String? = null, + pubkey : String? = null, + alias : String? = null ) { - val feeSats = runCatching { - repo.getPaymentDetail(paymentHash).details?.feeSat - }.getOrNull() + val feeSats = runCatching { repo.getPaymentDetail(paymentHash).details?.feeSat }.getOrNull() val amountSats = amountMsat / WalletConstants.MSAT_PER_SAT _sendState.value = SendState.PaymentSent( amountSats = amountSats, @@ -507,7 +536,8 @@ class SendViewModel( lnurl : String, amountMsat: Long, comment : String?, - label : String + label : String, + strings : SendStrings, ): Result { val direct = runCatching { repo.payLnurlDirect(rawRes, amountMsat, comment) } if (direct.isSuccess) { @@ -522,10 +552,12 @@ class SendViewModel( return Result.success(proxy.getOrThrow().paymentHash) } Log.w(TAG, "LNURL [PAY $label PROXY FAIL] ${proxy.exceptionOrNull()?.message}") - return Result.failure(proxy.exceptionOrNull() ?: Exception("Payment failed")) + return Result.failure( + proxy.exceptionOrNull() ?: Exception(strings.paymentFailed) // ← "Payment failed" + ) } - private suspend fun rescanLnurl(lnurl: String): LnurlScanResponse? { + private suspend fun rescanLnurl(lnurl: String, strings: SendStrings): LnurlScanResponse? { val direct = runCatching { repo.scanLnurlDirect(lnurl) } if (direct.isSuccess) { Log.d(TAG, "LNURL [RESCAN DIRECT] success") @@ -538,7 +570,10 @@ class SendViewModel( Log.e(TAG, "LNURL [RESCAN ERR] ${proxy.exceptionOrNull()?.message}") _sendState.value = SendState.Error( - parseApiError(proxy.exceptionOrNull() ?: Exception(), "Payment failed — could not re-fetch address") + parseApiError( + proxy.exceptionOrNull() ?: Exception(), + strings.paymentFailedRescan // ← "Payment failed — could not re-fetch address" + ) ) return null } @@ -560,14 +595,14 @@ class SendViewModel( ) { payee?.let { pubkey -> viewModelScope.launch { - val alias = aliasRepo.resolve(pubkey) ?: return@launch + val alias = aliasRepo.resolve(pubkey) ?: return@launch val current = readState() copyAlias(current, alias)?.let { _sendState.value = it } } } routeHints.map { it.publicKey }.distinct().forEach { pubkey -> viewModelScope.launch { - val alias = aliasRepo.resolve(pubkey) ?: return@launch + val alias = aliasRepo.resolve(pubkey) ?: return@launch val current = readState() copyHint(current, pubkey, alias)?.let { _sendState.value = it } } @@ -589,26 +624,26 @@ class SendViewModel( } private fun buildPendingRecord( - paymentHash: String, - amountSats : Long, - feeSats : Long?, - bolt11 : String?, - memo : String?, - pubkey : String?, - alias : String? + paymentHash : String, + amountSats : Long, + feeSats : Long?, + bolt11 : String?, + memo : String?, + pubkey : String?, + alias : String? ): PaymentRecord = PaymentRecord( paymentHash = paymentHash, - checkingId = paymentHash, - amountMsat = -(amountSats * 1_000L), // negative = outgoing - feeMsat = (feeSats ?: 0L) * 1_000L, - memo = memo, - time = (System.currentTimeMillis() / 1_000L).toString(), - status = "complete", - bolt11 = bolt11, - pending = false, - preimage = null, - extra = null, - pubkey = pubkey, - alias = alias + checkingId = paymentHash, + amountMsat = -(amountSats * 1_000L), + feeMsat = (feeSats ?: 0L) * 1_000L, + memo = memo, + time = (System.currentTimeMillis() / 1_000L).toString(), + status = "complete", + bolt11 = bolt11, + pending = false, + preimage = null, + extra = null, + pubkey = pubkey, + alias = alias ) } \ No newline at end of file