feat: localisation with lyricist

This commit is contained in:
2026-06-06 01:12:20 +02:00
parent e09c40e981
commit 50d952b4d7
15 changed files with 725 additions and 203 deletions
@@ -94,7 +94,23 @@ val EnHomeStrings = AppStrings(
// ── ReceiveScreen — success ─────────────────────────────────────────────── // ── ReceiveScreen — success ───────────────────────────────────────────────
paymentReceived = "Payment Received!", paymentReceived = "Payment Received!",
// ── SendScreen — general ────────────────────────────────────────────────── // ── AmountDisplay ─────────────────────────────────────────────────────────
amountDisplaySats = { amount -> "$amount ${if (amount == 1L) "sat" else "sats"}" },
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
brightnessReduce = "Reduce brightness",
brightnessBoost = "Boost brightness",
qrShare = "Share",
qrCopy = "Copy",
qrNfcStop = "Stop NFC sharing",
qrNfcStart = "Share via NFC",
// ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "Failed to create invoice",
receiveVmWithdrawRejected = "Withdraw service rejected the invoice",
receiveVmWithdrawFailed = "Withdraw failed",
// ── SendScreen — general ──────────────────────────────────────────────────
send = "Send", send = "Send",
paste = "Paste", paste = "Paste",
continueButton = "Continue", continueButton = "Continue",
@@ -159,4 +175,124 @@ val EnHomeStrings = AppStrings(
routeHintHideDetails = "Hide details ▴", routeHintHideDetails = "Hide details ▴",
routeHintHopsHeader = "Private route hint hops", routeHintHopsHeader = "Private route hint hops",
routeHintHopFees = { ppm, baseLabel -> "$ppm ppm · $baseLabel base" }, routeHintHopFees = { ppm, baseLabel -> "$ppm ppm · $baseLabel base" },
// ── HistoryScreen ─────────────────────────────────────────────────────────
historyTitle = "History",
historyClose = "Close",
historySearchPayments = "Search payments",
historyFilterPayments = "Filter payments",
historyRetry = "Retry",
historyNoPayments = "No payments yet.",
historySearchPlaceholder = "Search transactions",
historySearchHelp = "Search help",
historySearchPaste = "Paste",
historySearchClear = "Clear search",
historySearchChip = { tail -> "search: …$tail" },
historyClearSearch = "Clear search",
filterDirectionOutgoing = "Outgoing",
filterDirectionIncoming = "Incoming",
filterClearDirection = "Clear direction filter",
filterStatusCompleted = "Completed",
filterStatusPending = "Pending",
filterStatusFailed = "Failed",
filterRemoveStatus = "Remove status filter",
filterRemoveType = "Remove type filter",
filterAmountRange = { min, max -> "$min$max sats" },
filterAmountMin = { min -> "$min sats" },
filterAmountMax = { max -> "$max sats" },
filterClearAmount = "Clear amount filter",
filterDateThisWeek = "This week",
filterDateThisMonth = "This month",
filterDateThisYear = "This year",
filterDateRange = { from, to -> "$from$to" },
filterDateFrom = { from -> "From $from" },
filterDateUntil = { until -> "Until $until" },
filterClearDate = "Clear date filter",
// ── FilterBottomSheet ─────────────────────────────────────────────────────
filterSheetTitle = "Filter payments",
filterSectionDirection = "Direction",
filterDirectionAll = "All",
filterSectionStatus = "Status",
filterSectionType = "Type",
filterSectionAmount = "Amount (sats)",
filterAmountMin_label = "Min",
filterAmountMax_label = "Max",
filterAmountSuffix = "sats",
filterSectionDate = "Date",
filterDateFrom_label = "From",
filterDateTo_label = "To",
filterDialogOk = "OK",
filterDialogCancel = "Cancel",
// ── SearchInfoSheet ───────────────────────────────────────────────────────
searchInfoTitle = "What can I search for?",
searchInfoSubtitle = "Type any part of the following to find a payment:",
searchInfoMemoTitle = "Memo",
searchInfoMemoDesc = "The message or description attached to a payment. " +
"For example, \"coffee\" or \"rent March\".",
searchInfoInvoiceTitle = "Invoice",
searchInfoInvoiceDesc = "The payment request you scanned or pasted to send a payment. " +
"It usually starts with \"lnbc…\". " +
"You can paste just the first or last few characters.",
searchInfoHashTitle = "Hash",
searchInfoHashDesc = "A unique code that identifies this payment on the " +
"Lightning Network. Useful if someone asks you to " +
"confirm a specific transaction.",
searchInfoPreimageTitle = "Preimage",
searchInfoPreimageDesc = "A secret code that proves a payment was received. " +
"Only available after a payment completes successfully.",
searchInfoNodeAliasTitle = "Node alias",
searchInfoNodeAliasDesc = "The name of the Lightning node you sent a payment to. " +
"For example, \"ACINQ\" or \"Wallet of Satoshi\". " +
"Aliases are resolved automatically for outgoing payments.",
searchInfoNodeIdTitle = "Node ID (pubkey)",
searchInfoNodeIdDesc = "The public key of the destination node. " +
"A 66-character hex string — you can paste just the first few characters.",
searchInfoFooter = "You can paste a full value or search with just a few characters — " +
"partial matches work too.",
// ── PaymentDetailScreen ───────────────────────────────────────────────────
detailOutgoingTitle = "Outgoing payment",
detailIncomingTitle = "Incoming payment",
detailBack = "Back",
detailCouldNotLoad = "Could not load full details",
detailFeeLabel = { feeSat, fiatPart, ppmPart ->
"Fee: $feeSat sats$fiatPart$ppmPart"
},
detailSectionDetails = "Details",
detailRowDate = "Date",
detailRowMemo = "Memo",
detailRowMemoEmpty = "",
detailRowType = "Type",
detailRowComment = "Comment",
detailSectionSuccessAction = "Success Action",
detailSuccessMessage = "Message",
detailSuccessUrl = "URL",
detailSuccessDescription = "Description",
detailSectionTechnical = "Technical",
detailRowPaymentHash = "Hash",
detailRowPreimage = "Preimage",
detailRowDestination = "Destination",
detailCopyNodeId = "Copy node ID",
detailRowBolt11 = "BOLT11",
// ── PaymentRow ────────────────────────────────────────────────────────────
paymentRowSentCD = "Sent",
paymentRowReceivedCD = "Received",
paymentRowNoMemo = "No memo",
paymentRowPending = "Pending",
// ── OnboardingScreen ──────────────────────────────────────────────────────
onboardingTitle = "Connect to LNbits",
onboardingSubtitle = "Enter your LNbits server URL and API keys.",
onboardingServerUrl = "Server URL",
onboardingServerUrlHint = "https://bitcointxoko.org",
onboardingInvoiceKey = "Invoice Key (read-only)",
onboardingAdminKey = "Admin Key (for sending)",
onboardingHideAdminKey = "Hide",
onboardingShowAdminKey = "Show",
onboardingFieldsRequired = "All fields are required.",
onboardingConnect = "Connect",
) )
@@ -94,7 +94,23 @@ val EsHomeStrings = AppStrings(
// ── ReceiveScreen — success ─────────────────────────────────────────────── // ── ReceiveScreen — success ───────────────────────────────────────────────
paymentReceived = "¡Pago recibido!", paymentReceived = "¡Pago recibido!",
// ── SendScreen — general ────────────────────────────────────────────────── // ── AmountDisplay ─────────────────────────────────────────────────────────
amountDisplaySats = { amount -> "$amount ${if (amount == 1L) "sat" else "sats"}" },
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
brightnessReduce = "Reducir brillo",
brightnessBoost = "Aumentar brillo",
qrShare = "Compartir",
qrCopy = "Copiar",
qrNfcStop = "Detener NFC",
qrNfcStart = "Compartir por NFC",
// ── ReceiveViewModel ──────────────────────────────────────────────────────
receiveVmFailedToCreateInvoice = "No se pudo crear la factura",
receiveVmWithdrawRejected = "El servicio de retiro rechazó la factura",
receiveVmWithdrawFailed = "Error al retirar",
// ── SendScreen — general ──────────────────────────────────────────────────
send = "Enviar", send = "Enviar",
paste = "Pegar", paste = "Pegar",
continueButton = "Continuar", continueButton = "Continuar",
@@ -159,4 +175,123 @@ val EsHomeStrings = AppStrings(
routeHintHideDetails = "Ocultar detalles ▴", routeHintHideDetails = "Ocultar detalles ▴",
routeHintHopsHeader = "Saltos de ruta privada", routeHintHopsHeader = "Saltos de ruta privada",
routeHintHopFees = { ppm, baseLabel -> "$ppm ppm · $baseLabel base" }, routeHintHopFees = { ppm, baseLabel -> "$ppm ppm · $baseLabel base" },
// ── HistoryScreen ─────────────────────────────────────────────────────────
historyTitle = "Historial",
historyClose = "Cerrar",
historySearchPayments = "Buscar pagos",
historyFilterPayments = "Filtrar pagos",
historyRetry = "Reintentar",
historyNoPayments = "Aún no hay pagos.",
historySearchPlaceholder = "Buscar transacciones",
historySearchHelp = "Ayuda de búsqueda",
historySearchPaste = "Pegar",
historySearchClear = "Limpiar búsqueda",
historySearchChip = { tail -> "búsqueda: …$tail" },
historyClearSearch = "Limpiar búsqueda",
filterDirectionOutgoing = "Salientes",
filterDirectionIncoming = "Entrantes",
filterClearDirection = "Quitar filtro de dirección",
filterStatusCompleted = "Completado",
filterStatusPending = "Pendiente",
filterStatusFailed = "Fallido",
filterRemoveStatus = "Quitar filtro de estado",
filterRemoveType = "Quitar filtro de tipo",
filterAmountRange = { min, max -> "$min$max sats" },
filterAmountMin = { min -> "$min sats" },
filterAmountMax = { max -> "$max sats" },
filterClearAmount = "Quitar filtro de cantidad",
filterDateThisWeek = "Esta semana",
filterDateThisMonth = "Este mes",
filterDateThisYear = "Este año",
filterDateRange = { from, to -> "$from$to" },
filterDateFrom = { from -> "Desde $from" },
filterDateUntil = { until -> "Hasta $until" },
filterClearDate = "Quitar filtro de fecha",
// ── FilterBottomSheet ─────────────────────────────────────────────────────
filterSheetTitle = "Filtrar pagos",
filterSectionDirection = "Dirección",
filterDirectionAll = "Todos",
filterSectionStatus = "Estado",
filterSectionType = "Tipo",
filterSectionAmount = "Cantidad (sats)",
filterAmountMin_label = "Mín.",
filterAmountMax_label = "Máx.",
filterAmountSuffix = "sats",
filterSectionDate = "Fecha",
filterDateFrom_label = "Desde",
filterDateTo_label = "Hasta",
filterDialogOk = "Aceptar",
filterDialogCancel = "Cancelar",
// ── SearchInfoSheet ───────────────────────────────────────────────────────
searchInfoTitle = "¿Qué puedo buscar?",
searchInfoSubtitle = "Escribe cualquier parte de lo siguiente para encontrar un pago:",
searchInfoMemoTitle = "Nota",
searchInfoMemoDesc = "El mensaje o descripción adjunto a un pago. " +
"Por ejemplo, \"café\" o \"alquiler marzo\".",
searchInfoInvoiceTitle = "Factura",
searchInfoInvoiceDesc = "La solicitud de pago que escaneaste o pegaste para enviar un pago. " +
"Normalmente empieza por \"lnbc…\". " +
"Puedes pegar solo los primeros o últimos caracteres.",
searchInfoHashTitle = "Hash",
searchInfoHashDesc = "Un código único que identifica este pago en la " +
"red Lightning. Útil si alguien te pide que " +
"confirmes una transacción específica.",
searchInfoPreimageTitle = "Preimagen",
searchInfoPreimageDesc = "Un código secreto que demuestra que se recibió un pago. " +
"Solo disponible después de que un pago se complete correctamente.",
searchInfoNodeAliasTitle = "Alias del nodo",
searchInfoNodeAliasDesc = "El nombre del nodo Lightning al que enviaste un pago. " +
"Por ejemplo, \"ACINQ\" o \"Wallet of Satoshi\". " +
"Los alias se resuelven automáticamente para pagos salientes.",
searchInfoNodeIdTitle = "ID del nodo (clave pública)",
searchInfoNodeIdDesc = "La clave pública del nodo de destino. " +
"Una cadena hexadecimal de 66 caracteres — puedes pegar solo los primeros.",
searchInfoFooter = "Puedes pegar un valor completo o buscar con solo unos pocos caracteres — " +
"las coincidencias parciales también funcionan.",
// ── PaymentDetailScreen ───────────────────────────────────────────────────
detailOutgoingTitle = "Pago saliente",
detailIncomingTitle = "Pago entrante",
detailBack = "Atrás",
detailCouldNotLoad = "No se pudieron cargar los detalles completos",
detailFeeLabel = { feeSat, fiatPart, ppmPart ->
"Comisión: $feeSat sats$fiatPart$ppmPart"
},
detailSectionDetails = "Detalles",
detailRowDate = "Fecha",
detailRowMemo = "Nota",
detailRowMemoEmpty = "",
detailRowType = "Tipo",
detailRowComment = "Comentario",
detailSectionSuccessAction = "Acción de éxito",
detailSuccessMessage = "Mensaje",
detailSuccessUrl = "URL",
detailSuccessDescription = "Descripción",
detailSectionTechnical = "Técnico",
detailRowPaymentHash = "Hash",
detailRowPreimage = "Preimagen",
detailRowDestination = "Destino",
detailCopyNodeId = "Copiar ID del nodo",
detailRowBolt11 = "BOLT11",
// ── PaymentRow ────────────────────────────────────────────────────────────
paymentRowSentCD = "Enviado",
paymentRowReceivedCD = "Recibido",
paymentRowNoMemo = "Sin nota",
paymentRowPending = "Pendiente",
// ── OnboardingScreen ──────────────────────────────────────────────────────
onboardingTitle = "Conectar a LNbits",
onboardingSubtitle = "Introduce la URL de tu servidor LNbits y las claves API.",
onboardingServerUrl = "URL del servidor",
onboardingServerUrlHint = "https://bitcointxoko.org",
onboardingInvoiceKey = "Clave de factura (solo lectura)",
onboardingAdminKey = "Clave de administrador (para enviar)",
onboardingHideAdminKey = "Ocultar",
onboardingShowAdminKey = "Mostrar",
onboardingFieldsRequired = "Todos los campos son obligatorios.",
onboardingConnect = "Conectar",
) )
@@ -103,6 +103,22 @@ data class AppStrings(
// ── ReceiveScreen — success ─────────────────────────────────────────────── // ── ReceiveScreen — success ───────────────────────────────────────────────
val paymentReceived : String, val paymentReceived : String,
// ── AmountDisplay ─────────────────────────────────────────────────────────
val amountDisplaySats : (amount: Long) -> String,
// ── BrightnessToggleButton & QrDisplayCard ────────────────────────────────
val brightnessReduce : String,
val brightnessBoost : String,
val qrShare : String,
val qrCopy : String,
val qrNfcStop : String,
val qrNfcStart : String,
// ── ReceiveViewModel ──────────────────────────────────────────────────────
val receiveVmFailedToCreateInvoice : String,
val receiveVmWithdrawRejected : String,
val receiveVmWithdrawFailed : String,
// ── SendScreen — general ────────────────────────────────────────────────── // ── SendScreen — general ──────────────────────────────────────────────────
val send : String, val send : String,
val paste : String, val paste : String,
@@ -168,4 +184,111 @@ data class AppStrings(
val routeHintHideDetails : String, val routeHintHideDetails : String,
val routeHintHopsHeader : String, val routeHintHopsHeader : String,
val routeHintHopFees : (ppm: Long, baseLabel: String) -> String, val routeHintHopFees : (ppm: Long, baseLabel: String) -> String,
// ── HistoryScreen ─────────────────────────────────────────────────────────
val historyTitle : String,
val historyClose : String,
val historySearchPayments : String,
val historyFilterPayments : String,
val historyRetry : String,
val historyNoPayments : String,
val historySearchPlaceholder : String,
val historySearchHelp : String,
val historySearchPaste : String,
val historySearchClear : String,
val historySearchChip : (tail: String) -> String,
val historyClearSearch : String,
val filterDirectionOutgoing : String,
val filterDirectionIncoming : String,
val filterClearDirection : String,
val filterStatusCompleted : String,
val filterStatusPending : String,
val filterStatusFailed : String,
val filterRemoveStatus : String,
val filterRemoveType : String,
val filterAmountRange : (min: Long, max: Long) -> String,
val filterAmountMin : (min: Long) -> String,
val filterAmountMax : (max: Long) -> String,
val filterClearAmount : String,
val filterDateThisWeek : String,
val filterDateThisMonth : String,
val filterDateThisYear : String,
val filterDateRange : (from: String, to: String) -> String,
val filterDateFrom : (from: String) -> String,
val filterDateUntil : (until: String) -> String,
val filterClearDate : String,
// ── FilterBottomSheet ─────────────────────────────────────────────────────
val filterSheetTitle : String,
val filterSectionDirection : String,
val filterDirectionAll : String,
val filterSectionStatus : String,
val filterSectionType : String,
val filterSectionAmount : String,
val filterAmountMin_label : String,
val filterAmountMax_label : String,
val filterAmountSuffix : String,
val filterSectionDate : String,
val filterDateFrom_label : String,
val filterDateTo_label : String,
val filterDialogOk : String,
val filterDialogCancel : String,
// ── SearchInfoSheet ───────────────────────────────────────────────────────
val searchInfoTitle : String,
val searchInfoSubtitle : String,
val searchInfoMemoTitle : String,
val searchInfoMemoDesc : String,
val searchInfoInvoiceTitle : String,
val searchInfoInvoiceDesc : String,
val searchInfoHashTitle : String,
val searchInfoHashDesc : String,
val searchInfoPreimageTitle : String,
val searchInfoPreimageDesc : String,
val searchInfoNodeAliasTitle : String,
val searchInfoNodeAliasDesc : String,
val searchInfoNodeIdTitle : String,
val searchInfoNodeIdDesc : String,
val searchInfoFooter : String,
// ── PaymentDetailScreen ───────────────────────────────────────────────────
val detailOutgoingTitle : String,
val detailIncomingTitle : String,
val detailBack : String,
val detailCouldNotLoad : String,
val detailFeeLabel : (feeSat: Long, fiatPart: String, ppmPart: String) -> String,
val detailSectionDetails : String,
val detailRowDate : String,
val detailRowMemo : String,
val detailRowMemoEmpty : String,
val detailRowType : String,
val detailRowComment : String,
val detailSectionSuccessAction: String,
val detailSuccessMessage : String,
val detailSuccessUrl : String,
val detailSuccessDescription : String,
val detailSectionTechnical : String,
val detailRowPaymentHash : String,
val detailRowPreimage : String,
val detailRowDestination : String,
val detailCopyNodeId : String,
val detailRowBolt11 : String,
// ── PaymentRow ────────────────────────────────────────────────────────────
val paymentRowSentCD : String,
val paymentRowReceivedCD : String,
val paymentRowNoMemo : String,
val paymentRowPending : String,
// ── OnboardingScreen ──────────────────────────────────────────────────────
val onboardingTitle : String,
val onboardingSubtitle : String,
val onboardingServerUrl : String,
val onboardingServerUrlHint : String,
val onboardingInvoiceKey : String,
val onboardingAdminKey : String,
val onboardingHideAdminKey : String,
val onboardingShowAdminKey : String,
val onboardingFieldsRequired : String,
val onboardingConnect : String,
) )
@@ -1,24 +1,45 @@
package com.bitcointxoko.gudariwallet.ui package com.bitcointxoko.gudariwallet.ui
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.* import androidx.compose.material3.Button
import androidx.compose.runtime.* import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.* import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.security.SecretStore import com.bitcointxoko.gudariwallet.security.SecretStore
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
fun OnboardingScreen( fun OnboardingScreen(
secretStore: SecretStore, secretStore: SecretStore,
onComplete: () -> Unit onComplete : () -> Unit
) { ) {
val strings = LocalAppStrings.current
var baseUrl by remember { mutableStateOf("https://bitcointxoko.org") } var baseUrl by remember { mutableStateOf("https://bitcointxoko.org") }
var invoiceKey by remember { mutableStateOf("") } var invoiceKey by remember { mutableStateOf("") }
var adminKey by remember { mutableStateOf("") } var adminKey by remember { mutableStateOf("") }
@@ -26,60 +47,63 @@ fun OnboardingScreen(
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(32.dp), .padding(32.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text("Connect to LNbits", style = MaterialTheme.typography.headlineMedium) Text(
text = strings.onboardingTitle, // ← "Connect to LNbits"
style = MaterialTheme.typography.headlineMedium
)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
"Enter your LNbits server URL and API keys.", text = strings.onboardingSubtitle, // ← "Enter your LNbits server URL and API keys."
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
OutlinedTextField( OutlinedTextField(
value = baseUrl, value = baseUrl,
onValueChange = { baseUrl = it }, onValueChange = { baseUrl = it },
label = { Text("Server URL") }, label = { Text(strings.onboardingServerUrl) }, // ← "Server URL"
placeholder = { Text("https://bitcointxoko.org") }, placeholder = { Text(strings.onboardingServerUrlHint) }, // ← "https://bitcointxoko.org"
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri) keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
OutlinedTextField( OutlinedTextField(
value = invoiceKey, value = invoiceKey,
onValueChange = { invoiceKey = it }, onValueChange = { invoiceKey = it },
label = { Text("Invoice Key (read-only)") }, label = { Text(strings.onboardingInvoiceKey) }, // ← "Invoice Key (read-only)"
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
visualTransformation = PasswordVisualTransformation() visualTransformation = PasswordVisualTransformation()
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
OutlinedTextField( OutlinedTextField(
value = adminKey, value = adminKey,
onValueChange = { adminKey = it }, onValueChange = { adminKey = it },
label = { Text("Admin Key (for sending)") }, label = { Text(strings.onboardingAdminKey) }, // ← "Admin Key (for sending)"
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
visualTransformation = if (adminVisible) VisualTransformation.None visualTransformation = if (adminVisible) VisualTransformation.None
else PasswordVisualTransformation(), else PasswordVisualTransformation(),
trailingIcon = { trailingIcon = {
IconButton(onClick = { adminVisible = !adminVisible }) { IconButton(onClick = { adminVisible = !adminVisible }) {
Icon( Icon(
imageVector = if (adminVisible) Icons.Default.VisibilityOff imageVector = if (adminVisible) Icons.Default.VisibilityOff
else Icons.Default.Visibility, else Icons.Default.Visibility,
contentDescription = if (adminVisible) "Hide" else "Show" contentDescription = if (adminVisible) strings.onboardingHideAdminKey // ← "Hide"
else strings.onboardingShowAdminKey // ← "Show"
) )
} }
} }
@@ -87,8 +111,11 @@ fun OnboardingScreen(
error?.let { error?.let {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text(it, color = MaterialTheme.colorScheme.error, Text(
style = MaterialTheme.typography.bodySmall) text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
} }
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
@@ -96,7 +123,7 @@ fun OnboardingScreen(
Button( Button(
onClick = { onClick = {
if (baseUrl.isBlank() || invoiceKey.isBlank() || adminKey.isBlank()) { if (baseUrl.isBlank() || invoiceKey.isBlank() || adminKey.isBlank()) {
error = "All fields are required." error = strings.onboardingFieldsRequired // ← "All fields are required."
return@Button return@Button
} }
scope.launch { scope.launch {
@@ -106,8 +133,7 @@ fun OnboardingScreen(
}, },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text("Connect") Text(strings.onboardingConnect) // ← "Connect"
} }
} }
} }
@@ -1,7 +1,6 @@
package com.bitcointxoko.gudariwallet.ui package com.bitcointxoko.gudariwallet.ui
import android.util.Log import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
@@ -47,10 +46,33 @@ class WalletViewModel(
val fiatCurrency: String? get() = fiatVm.fiatCurrency val fiatCurrency: String? get() = fiatVm.fiatCurrency
val receiveState: StateFlow<ReceiveState> get() = receiveVm.receiveState val receiveState: StateFlow<ReceiveState> get() = receiveVm.receiveState
val navigateToReceive: SharedFlow<Unit> get() = receiveVm.navigateToReceive val navigateToReceive: SharedFlow<Unit> get() = receiveVm.navigateToReceive
fun createInvoice(amountSats: Long, memo: String) = receiveVm.createInvoice(amountSats, memo) fun createInvoice(
amountSats : Long,
memo : String,
failedToCreateInvoice : String,
) = receiveVm.createInvoice(
amountSats = amountSats,
memo = memo,
failedToCreateInvoice = failedToCreateInvoice,
)
fun resetReceiveState() = receiveVm.resetReceiveState() fun resetReceiveState() = receiveVm.resetReceiveState()
fun executeWithdraw(k1: String, callback: String, amountSats: Long, memo: String) = fun executeWithdraw(
receiveVm.executeWithdraw(k1, callback, amountSats, memo) k1 : String,
callback : String,
amountSats : Long,
memo : String,
failedToCreateInvoice : String,
withdrawRejected : String,
withdrawFailed : String,
) = receiveVm.executeWithdraw(
k1 = k1,
callback = callback,
amountSats = amountSats,
memo = memo,
failedToCreateInvoice = failedToCreateInvoice,
withdrawRejected = withdrawRejected,
withdrawFailed = withdrawFailed,
)
val sendState: StateFlow<SendState> get() = sendVm.sendState val sendState: StateFlow<SendState> get() = sendVm.sendState
fun refreshBalance() = balanceVm.refreshBalance() fun refreshBalance() = balanceVm.refreshBalance()
fun scan(rawInput: String) = sendVm.scan(rawInput) fun scan(rawInput: String) = sendVm.scan(rawInput)
@@ -31,6 +31,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -47,6 +48,8 @@ internal fun FilterBottomSheet(
onDateFilterSet : (min: Long?, max: Long?) -> Unit, onDateFilterSet : (min: Long?, max: Long?) -> Unit,
onDateClearRequested: () -> Unit onDateClearRequested: () -> Unit
) { ) {
val strings = LocalAppStrings.current
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
sheetState = sheetState sheetState = sheetState
@@ -56,15 +59,16 @@ internal fun FilterBottomSheet(
.fillMaxWidth() .fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp) .padding(start = 24.dp, end = 24.dp, bottom = 32.dp)
) { ) {
// ── Sheet title ───────────────────────────────────────────────────
Text( Text(
text = "Filter payments", text = strings.filterSheetTitle, // ← "Filter payments"
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
// ── Direction ──────────────────────────────────────────────────── // ── Direction ────────────────────────────────────────────────────
Text( Text(
text = "Direction", text = strings.filterSectionDirection, // ← "Direction"
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -72,9 +76,9 @@ internal fun FilterBottomSheet(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DirectionFilter.entries.forEach { option -> DirectionFilter.entries.forEach { option ->
val label = when (option) { val label = when (option) {
DirectionFilter.ALL -> "All" DirectionFilter.ALL -> strings.filterDirectionAll // ← "All"
DirectionFilter.OUTGOING -> "Outgoing" DirectionFilter.OUTGOING -> strings.filterDirectionOutgoing // ← "Outgoing"
DirectionFilter.INCOMING -> "Incoming" DirectionFilter.INCOMING -> strings.filterDirectionIncoming // ← "Incoming"
} }
FilterChip( FilterChip(
selected = currentFilter.direction == option, selected = currentFilter.direction == option,
@@ -85,9 +89,9 @@ internal fun FilterBottomSheet(
} }
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
// ── Status ─────────────────────────────────────────────────────── // ── Status ───────────────────────────────────────────────────────
Text( Text(
text = "Status", text = strings.filterSectionStatus, // ← "Status"
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -100,9 +104,9 @@ internal fun FilterBottomSheet(
label = { label = {
Text( Text(
when (option) { when (option) {
StatusFilter.COMPLETED -> "Completed" StatusFilter.COMPLETED -> strings.filterStatusCompleted // ← "Completed"
StatusFilter.PENDING -> "Pending" StatusFilter.PENDING -> strings.filterStatusPending // ← "Pending"
StatusFilter.FAILED -> "Failed" StatusFilter.FAILED -> strings.filterStatusFailed // ← "Failed"
} }
) )
} }
@@ -110,11 +114,11 @@ internal fun FilterBottomSheet(
} }
} }
// ── Type (dynamic, only shown when tags exist) ─────────────────── // ── Type (dynamic, only shown when tags exist) ───────────────────
if (availableTypes.isNotEmpty()) { if (availableTypes.isNotEmpty()) {
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
Text( Text(
text = "Type", text = strings.filterSectionType, // ← "Type"
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -131,9 +135,9 @@ internal fun FilterBottomSheet(
} }
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
// ── Amount (sats) ──────────────────────────────────────────────── // ── Amount (sats) ────────────────────────────────────────────────
Text( Text(
text = "Amount (sats)", text = strings.filterSectionAmount, // ← "Amount (sats)"
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -154,8 +158,8 @@ internal fun FilterBottomSheet(
OutlinedTextField( OutlinedTextField(
value = minText, value = minText,
onValueChange = { minText = it.filter(Char::isDigit); commitAmount() }, onValueChange = { minText = it.filter(Char::isDigit); commitAmount() },
label = { Text("Min") }, label = { Text(strings.filterAmountMin_label) }, // ← "Min"
suffix = { Text("sats") }, suffix = { Text(strings.filterAmountSuffix) }, // ← "sats"
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@@ -164,8 +168,8 @@ internal fun FilterBottomSheet(
OutlinedTextField( OutlinedTextField(
value = maxText, value = maxText,
onValueChange = { maxText = it.filter(Char::isDigit); commitAmount() }, onValueChange = { maxText = it.filter(Char::isDigit); commitAmount() },
label = { Text("Max") }, label = { Text(strings.filterAmountMax_label) }, // ← "Max"
suffix = { Text("sats") }, suffix = { Text(strings.filterAmountSuffix) }, // ← "sats"
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@@ -173,9 +177,9 @@ internal fun FilterBottomSheet(
} }
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
// ── Date ───────────────────────────────────────────────────────── // ── Date ─────────────────────────────────────────────────────────
Text( Text(
text = "Date", text = strings.filterSectionDate, // ← "Date"
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -185,9 +189,9 @@ internal fun FilterBottomSheet(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DatePreset.entries.forEach { preset -> DatePreset.entries.forEach { preset ->
val label = when (preset) { val label = when (preset) {
DatePreset.THIS_WEEK -> "This week" DatePreset.THIS_WEEK -> strings.filterDateThisWeek // ← "This week"
DatePreset.THIS_MONTH -> "This month" DatePreset.THIS_MONTH -> strings.filterDateThisMonth // ← "This month"
DatePreset.THIS_YEAR -> "This year" DatePreset.THIS_YEAR -> strings.filterDateThisYear // ← "This year"
} }
FilterChip( FilterChip(
selected = currentFilter.datePreset == preset, selected = currentFilter.datePreset == preset,
@@ -213,14 +217,22 @@ internal fun FilterBottomSheet(
onClick = { showFromPicker = true }, onClick = { showFromPicker = true },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text(currentFilter.minCreatedAt?.let { formatEpochShort(it) } ?: "From") Text(
currentFilter.minCreatedAt
?.let { formatEpochShort(it) }
?: strings.filterDateFrom_label // ← "From"
)
} }
Text("", style = MaterialTheme.typography.bodyLarge) Text("", style = MaterialTheme.typography.bodyLarge)
OutlinedButton( OutlinedButton(
onClick = { showToPicker = true }, onClick = { showToPicker = true },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text(currentFilter.maxCreatedAt?.let { formatEpochShort(it) } ?: "To") Text(
currentFilter.maxCreatedAt
?.let { formatEpochShort(it) }
?: strings.filterDateTo_label // ← "To"
)
} }
} }
@@ -235,10 +247,12 @@ internal fun FilterBottomSheet(
val epochSec = pickerState.selectedDateMillis?.div(1000) val epochSec = pickerState.selectedDateMillis?.div(1000)
onDateFilterSet(epochSec, currentFilter.maxCreatedAt) onDateFilterSet(epochSec, currentFilter.maxCreatedAt)
showFromPicker = false showFromPicker = false
}) { Text("OK") } }) { Text(strings.filterDialogOk) } // ← "OK"
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showFromPicker = false }) { Text("Cancel") } TextButton(onClick = { showFromPicker = false }) {
Text(strings.filterDialogCancel) // ← "Cancel"
}
} }
) { DatePicker(state = pickerState) } ) { DatePicker(state = pickerState) }
} }
@@ -255,10 +269,12 @@ internal fun FilterBottomSheet(
val epochSec = pickerState.selectedDateMillis?.div(1000)?.let { it + 86399L } val epochSec = pickerState.selectedDateMillis?.div(1000)?.let { it + 86399L }
onDateFilterSet(currentFilter.minCreatedAt, epochSec) onDateFilterSet(currentFilter.minCreatedAt, epochSec)
showToPicker = false showToPicker = false
}) { Text("OK") } }) { Text(strings.filterDialogOk) } // ← "OK"
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showToPicker = false }) { Text("Cancel") } TextButton(onClick = { showToPicker = false }) {
Text(strings.filterDialogCancel) // ← "Cancel"
}
} }
) { DatePicker(state = pickerState) } ) { DatePicker(state = pickerState) }
} }
@@ -57,6 +57,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.HistoryState import com.bitcointxoko.gudariwallet.ui.HistoryState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -68,6 +69,7 @@ fun HistoryScreen(
onClose : () -> Unit, onClose : () -> Unit,
onPaymentClick : (PaymentRecord) -> Unit onPaymentClick : (PaymentRecord) -> Unit
) { ) {
val strings = LocalAppStrings.current
val state by vm.filteredState.collectAsState() val state by vm.filteredState.collectAsState()
val filter by vm.filter.collectAsState() val filter by vm.filter.collectAsState()
val fiatCurrency by vm.fiatCurrency.collectAsState() val fiatCurrency by vm.fiatCurrency.collectAsState()
@@ -86,12 +88,12 @@ fun HistoryScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("History") }, title = { Text(strings.historyTitle) }, // ← "History"
navigationIcon = { navigationIcon = {
IconButton(onClick = onClose) { IconButton(onClick = onClose) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = "Close" contentDescription = strings.historyClose // ← "Close"
) )
} }
}, },
@@ -105,7 +107,7 @@ fun HistoryScreen(
}) { }) {
Icon( Icon(
imageVector = Icons.Default.Search, imageVector = Icons.Default.Search,
contentDescription = "Search payments" contentDescription = strings.historySearchPayments // ← "Search payments"
) )
} }
} }
@@ -115,7 +117,7 @@ fun HistoryScreen(
IconButton(onClick = { filterSheetVisible = true }) { IconButton(onClick = { filterSheetVisible = true }) {
Icon( Icon(
imageVector = Icons.Default.FilterList, imageVector = Icons.Default.FilterList,
contentDescription = "Filter payments" contentDescription = strings.historyFilterPayments // ← "Filter payments"
) )
} }
} }
@@ -154,8 +156,8 @@ fun HistoryScreen(
val searchActive = filter.searchQuery.isNotBlank() val searchActive = filter.searchQuery.isNotBlank()
if (isFiltered || searchActive) { if (isFiltered || searchActive) {
ActiveFilterChips( ActiveFilterChips(
filter = filter, filter = filter,
searchActive = searchActive, searchActive = searchActive,
onClearDirection = { vm.setDirectionFilter(DirectionFilter.ALL) }, onClearDirection = { vm.setDirectionFilter(DirectionFilter.ALL) },
onClearStatus = { vm.toggleStatusFilter(it) }, onClearStatus = { vm.toggleStatusFilter(it) },
onClearType = { vm.toggleTypeFilter(it) }, onClearType = { vm.toggleTypeFilter(it) },
@@ -187,7 +189,9 @@ fun HistoryScreen(
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Button(onClick = { vm.refresh() }) { Text("Retry") } Button(onClick = { vm.refresh() }) {
Text(strings.historyRetry) // ← "Retry"
}
} }
} }
@@ -205,7 +209,7 @@ fun HistoryScreen(
if (s.payments.isEmpty()) { if (s.payments.isEmpty()) {
Text( Text(
text = "No payments yet.", text = strings.historyNoPayments, // ← "No payments yet."
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -294,6 +298,7 @@ private fun SearchBar(
onClear : () -> Unit, onClear : () -> Unit,
onInfoClick : () -> Unit onInfoClick : () -> Unit
) { ) {
val strings = LocalAppStrings.current
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -303,13 +308,13 @@ private fun SearchBar(
IconButton(onClick = onInfoClick) { IconButton(onClick = onInfoClick) {
Icon( Icon(
imageVector = Icons.Default.Info, imageVector = Icons.Default.Info,
contentDescription = "Search help" contentDescription = strings.historySearchHelp // ← "Search help"
) )
} }
TextField( TextField(
value = query, value = query,
onValueChange = onQueryChange, onValueChange = onQueryChange,
placeholder = { Text("Search transactions") }, placeholder = { Text(strings.historySearchPlaceholder) }, // ← "Search transactions"
singleLine = true, singleLine = true,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
@@ -330,7 +335,7 @@ private fun SearchBar(
}) { }) {
Icon( Icon(
imageVector = Icons.Default.ContentPaste, imageVector = Icons.Default.ContentPaste,
contentDescription = "Paste" contentDescription = strings.historySearchPaste // ← "Paste"
) )
} }
IconButton( IconButton(
@@ -339,7 +344,7 @@ private fun SearchBar(
) { ) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = "Clear search", contentDescription = strings.historySearchClear, // ← "Clear search"
tint = if (query.isNotEmpty()) LocalContentColor.current tint = if (query.isNotEmpty()) LocalContentColor.current
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
) )
@@ -360,6 +365,7 @@ private fun ActiveFilterChips(
onClearDate : () -> Unit, onClearDate : () -> Unit,
onClearSearch : () -> Unit onClearSearch : () -> Unit
) { ) {
val strings = LocalAppStrings.current
FlowRow( FlowRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -374,15 +380,15 @@ private fun ActiveFilterChips(
label = { label = {
Text( Text(
when (filter.direction) { when (filter.direction) {
DirectionFilter.OUTGOING -> "Outgoing" DirectionFilter.OUTGOING -> strings.filterDirectionOutgoing // ← "Outgoing"
else -> "Incoming" else -> strings.filterDirectionIncoming // ← "Incoming"
} }
) )
}, },
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = "Clear direction filter", contentDescription = strings.filterClearDirection, // ← "Clear direction filter"
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -395,16 +401,16 @@ private fun ActiveFilterChips(
label = { label = {
Text( Text(
when (s) { when (s) {
StatusFilter.COMPLETED -> "Completed" StatusFilter.COMPLETED -> strings.filterStatusCompleted // ← "Completed"
StatusFilter.PENDING -> "Pending" StatusFilter.PENDING -> strings.filterStatusPending // ← "Pending"
StatusFilter.FAILED -> "Failed" StatusFilter.FAILED -> strings.filterStatusFailed // ← "Failed"
} }
) )
}, },
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = "Remove status filter", contentDescription = strings.filterRemoveStatus, // ← "Remove status filter"
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -418,7 +424,7 @@ private fun ActiveFilterChips(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = "Remove type filter", contentDescription = strings.filterRemoveType, // ← "Remove type filter"
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -427,11 +433,11 @@ private fun ActiveFilterChips(
if (filter.minAmountSat != null || filter.maxAmountSat != null) { if (filter.minAmountSat != null || filter.maxAmountSat != null) {
val label = when { val label = when {
filter.minAmountSat != null && filter.maxAmountSat != null -> filter.minAmountSat != null && filter.maxAmountSat != null ->
"${filter.minAmountSat}${filter.maxAmountSat} sats" strings.filterAmountRange(filter.minAmountSat, filter.maxAmountSat) // ← "XY sats"
filter.minAmountSat != null -> filter.minAmountSat != null ->
"${filter.minAmountSat} sats" strings.filterAmountMin(filter.minAmountSat) // ← "≥ X sats"
else -> else ->
"${filter.maxAmountSat} sats" strings.filterAmountMax(filter.maxAmountSat!!) // ← "≤ X sats"
} }
InputChip( InputChip(
selected = true, selected = true,
@@ -440,7 +446,7 @@ private fun ActiveFilterChips(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = "Clear amount filter", contentDescription = strings.filterClearAmount, // ← "Clear amount filter"
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -450,16 +456,19 @@ private fun ActiveFilterChips(
val maxCreatedAt = filter.maxCreatedAt val maxCreatedAt = filter.maxCreatedAt
if (minCreatedAt != null || maxCreatedAt != null) { if (minCreatedAt != null || maxCreatedAt != null) {
val label = when (filter.datePreset) { val label = when (filter.datePreset) {
DatePreset.THIS_WEEK -> "This week" DatePreset.THIS_WEEK -> strings.filterDateThisWeek // ← "This week"
DatePreset.THIS_MONTH -> "This month" DatePreset.THIS_MONTH -> strings.filterDateThisMonth // ← "This month"
DatePreset.THIS_YEAR -> "This year" DatePreset.THIS_YEAR -> strings.filterDateThisYear // ← "This year"
null -> when { null -> when {
minCreatedAt != null && maxCreatedAt != null -> minCreatedAt != null && maxCreatedAt != null ->
"${formatEpochShort(minCreatedAt)}${formatEpochShort(maxCreatedAt)}" strings.filterDateRange( // ← "XY"
formatEpochShort(minCreatedAt),
formatEpochShort(maxCreatedAt)
)
minCreatedAt != null -> minCreatedAt != null ->
"From ${formatEpochShort(minCreatedAt)}" strings.filterDateFrom(formatEpochShort(minCreatedAt)) // ← "From X"
else -> else ->
"Until ${formatEpochShort(maxCreatedAt!!)}" strings.filterDateUntil(formatEpochShort(maxCreatedAt!!)) // ← "Until X"
} }
} }
InputChip( InputChip(
@@ -469,7 +478,7 @@ private fun ActiveFilterChips(
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = "Clear date filter", contentDescription = strings.filterClearDate, // ← "Clear date filter"
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -479,11 +488,11 @@ private fun ActiveFilterChips(
InputChip( InputChip(
selected = true, selected = true,
onClick = onClearSearch, onClick = onClearSearch,
label = { Text("search: …${filter.searchQuery.takeLast(8)}") }, label = { Text(strings.historySearchChip(filter.searchQuery.takeLast(8))) }, // ← "search: …XXXXXXXX"
trailingIcon = { trailingIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = "Clear search", contentDescription = strings.historyClearSearch, // ← "Clear search"
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
@@ -43,6 +43,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.DetailState import com.bitcointxoko.gudariwallet.ui.DetailState
import com.bitcointxoko.gudariwallet.util.feePpm import com.bitcointxoko.gudariwallet.util.feePpm
import com.bitcointxoko.gudariwallet.util.formatFiatForSats import com.bitcointxoko.gudariwallet.util.formatFiatForSats
@@ -57,6 +58,7 @@ fun PaymentDetailScreen(
vm : HistoryViewModel, vm : HistoryViewModel,
onBack : () -> Unit onBack : () -> Unit
) { ) {
val strings = LocalAppStrings.current
val detailState by vm.detailState.collectAsState() val detailState by vm.detailState.collectAsState()
val fiatCurrency by vm.fiatCurrency.collectAsState() val fiatCurrency by vm.fiatCurrency.collectAsState()
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState() val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
@@ -82,10 +84,18 @@ fun PaymentDetailScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(if (payment.isOutgoing) "Outgoing payment" else "Incoming payment") }, title = {
Text(
if (payment.isOutgoing) strings.detailOutgoingTitle // ← "Outgoing payment"
else strings.detailIncomingTitle // ← "Incoming payment"
)
},
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = strings.detailBack // ← "Back"
)
} }
} }
) )
@@ -106,7 +116,7 @@ fun PaymentDetailScreen(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
text = "Could not load full details", text = strings.detailCouldNotLoad, // ← "Could not load full details"
color = MaterialTheme.colorScheme.onErrorContainer, color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp) modifier = Modifier.padding(12.dp)
@@ -141,6 +151,7 @@ private fun PaymentDetailContent(
fiatSatsPerUnit : Double?, fiatSatsPerUnit : Double?,
modifier : Modifier = Modifier modifier : Modifier = Modifier
) { ) {
val strings = LocalAppStrings.current
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing) val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
val context = LocalContext.current val context = LocalContext.current
@@ -195,11 +206,12 @@ private fun PaymentDetailContent(
amountMsat = kotlin.math.abs(payment.amountMsat), amountMsat = kotlin.math.abs(payment.amountMsat),
feeMsat = kotlin.math.abs(payment.feeMsat) feeMsat = kotlin.math.abs(payment.feeMsat)
) )
val feeLabel = buildString { // Build the optional parts separately so the lambda stays clean
append("Fee: ${payment.feeSat} sats") val fiatPart = if (feeFiat != null) " ($feeFiat)" else ""
if (feeFiat != null) append(" ($feeFiat)") val ppmPart = if (ppm != null) " · $ppm ppm" else ""
if (ppm != null) append(" · $ppm ppm") val feeLabel = strings.detailFeeLabel( // ← "Fee: X sats (fiat) · Y ppm"
} payment.feeSat, fiatPart, ppmPart
)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(
text = feeLabel, text = feeLabel,
@@ -215,14 +227,17 @@ private fun PaymentDetailContent(
// ── Basic info ─────────────────────────────────────────────────────── // ── Basic info ───────────────────────────────────────────────────────
item { item {
DetailSection(title = "Details") { DetailSection(title = strings.detailSectionDetails) { // ← "Details"
DetailRow("Date", formatTimestamp(payment.createdAt, payment.time)) DetailRow(strings.detailRowDate, formatTimestamp(payment.createdAt, payment.time)) // ← "Date"
DetailRow("Memo", payment.memo?.takeIf { it.isNotBlank() } ?: "") DetailRow(
strings.detailRowMemo, // ← "Memo"
payment.memo?.takeIf { it.isNotBlank() } ?: strings.detailRowMemoEmpty // ← "—"
)
if (payment.extra?.tag != null) { if (payment.extra?.tag != null) {
DetailRow("Type", payment.extra.tag) DetailRow(strings.detailRowType, payment.extra.tag) // ← "Type"
} }
if (payment.extra?.comment?.isNotBlank() == true) { if (payment.extra?.comment?.isNotBlank() == true) {
DetailRow("Comment", payment.extra.comment) DetailRow(strings.detailRowComment, payment.extra.comment) // ← "Comment"
} }
} }
} }
@@ -230,20 +245,20 @@ private fun PaymentDetailContent(
// ── Success action (LNURL) ─────────────────────────────────────────── // ── Success action (LNURL) ───────────────────────────────────────────
payment.extra?.successAction?.let { action -> payment.extra?.successAction?.let { action ->
item { item {
DetailSection(title = "Success Action") { DetailSection(title = strings.detailSectionSuccessAction) { // ← "Success Action"
action.message?.let { DetailRow("Message", it) } action.message?.let { DetailRow(strings.detailSuccessMessage, it) } // ← "Message"
action.url?.let { DetailRow("URL", it) } action.url?.let { DetailRow(strings.detailSuccessUrl, it) } // ← "URL"
action.description?.let { DetailRow("Description", it) } action.description?.let { DetailRow(strings.detailSuccessDescription, it) } // ← "Description"
} }
} }
} }
// ── Technical ──────────────────────────────────────────────────────── // ── Technical ────────────────────────────────────────────────────────
item { item {
DetailSection(title = "Technical") { DetailSection(title = strings.detailSectionTechnical) { // ← "Technical"
DetailRow( DetailRow(
label = "Payment Hash", label = strings.detailRowPaymentHash, // ← "Payment Hash"
value = payment.paymentHash, value = payment.paymentHash,
monospace = true, monospace = true,
copyable = true, copyable = true,
onCopy = { onCopy = {
@@ -256,8 +271,8 @@ private fun PaymentDetailContent(
) )
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) { if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
DetailRow( DetailRow(
label = "Preimage", label = strings.detailRowPreimage, // ← "Preimage"
value = payment.preimage, value = payment.preimage,
monospace = true, monospace = true,
copyable = true, copyable = true,
onCopy = { onCopy = {
@@ -273,11 +288,11 @@ private fun PaymentDetailContent(
val ambossUrl = "https://amboss.space/node/$pubkey" val ambossUrl = "https://amboss.space/node/$pubkey"
val label = alias ?: "${pubkey.take(8)}${pubkey.takeLast(8)}" val label = alias ?: "${pubkey.take(8)}${pubkey.takeLast(8)}"
DetailRow( DetailRow(
label = "Destination", label = strings.detailRowDestination, // ← "Destination"
value = label, value = label,
valueColor = MaterialTheme.colorScheme.primary, valueColor = MaterialTheme.colorScheme.primary,
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline, textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
onValueClick = { onValueClick = {
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri())) context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
}, },
trailingContent = { trailingContent = {
@@ -293,7 +308,7 @@ private fun PaymentDetailContent(
) { ) {
Icon( Icon(
imageVector = Icons.Outlined.ContentCopy, imageVector = Icons.Outlined.ContentCopy,
contentDescription = "Copy node ID", contentDescription = strings.detailCopyNodeId, // ← "Copy node ID"
modifier = Modifier.size(14.dp), modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -303,8 +318,8 @@ private fun PaymentDetailContent(
} }
if (!payment.bolt11.isNullOrBlank()) { if (!payment.bolt11.isNullOrBlank()) {
DetailRow( DetailRow(
label = "BOLT11", label = strings.detailRowBolt11, // ← "BOLT11"
value = payment.bolt11, value = payment.bolt11,
monospace = true, monospace = true,
copyable = true, copyable = true,
maxLines = 3, maxLines = 3,
@@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
import com.bitcointxoko.gudariwallet.util.formatFiatForSats import com.bitcointxoko.gudariwallet.util.formatFiatForSats
@@ -33,8 +34,9 @@ internal fun PaymentRow(
fiatSatsPerUnit : Double?, fiatSatsPerUnit : Double?,
onClick : () -> Unit onClick : () -> Unit
) { ) {
val strings = LocalAppStrings.current
val isPending = isPendingStatus(payment.status) val isPending = isPendingStatus(payment.status)
val semantic = semanticColors() val semantic = semanticColors()
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing) val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
val icon = if (payment.isOutgoing) Icons.Default.ArrowUpward val icon = if (payment.isOutgoing) Icons.Default.ArrowUpward
else Icons.Default.ArrowDownward else Icons.Default.ArrowDownward
@@ -54,7 +56,8 @@ internal fun PaymentRow(
) { ) {
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = if (payment.isOutgoing) "Sent" else "Received", contentDescription = if (payment.isOutgoing) strings.paymentRowSentCD // ← "Sent"
else strings.paymentRowReceivedCD, // ← "Received"
tint = amountColor, tint = amountColor,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
@@ -62,7 +65,8 @@ internal fun PaymentRow(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = payment.memo?.takeIf { it.isNotBlank() } ?: "No memo", text = payment.memo?.takeIf { it.isNotBlank() }
?: strings.paymentRowNoMemo, // ← "No memo"
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
@@ -84,7 +88,7 @@ internal fun PaymentRow(
contentColor = semantic.onWarningContainer contentColor = semantic.onWarningContainer
) { ) {
Text( Text(
text = "Pending", text = strings.paymentRowPending, // ← "Pending"
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
) )
@@ -13,6 +13,7 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -20,6 +21,8 @@ internal fun SearchInfoSheet(visible: Boolean, onDismiss: () -> Unit) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
if (!visible) return if (!visible) return
val strings = LocalAppStrings.current
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
sheetState = sheetState sheetState = sheetState
@@ -30,55 +33,45 @@ internal fun SearchInfoSheet(visible: Boolean, onDismiss: () -> Unit) {
.padding(start = 24.dp, end = 24.dp, bottom = 40.dp) .padding(start = 24.dp, end = 24.dp, bottom = 40.dp)
) { ) {
Text( Text(
text = "What can I search for?", text = strings.searchInfoTitle, // ← "What can I search for?"
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
text = "Type any part of the following to find a payment:", text = strings.searchInfoSubtitle, // ← "Type any part of the following…"
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
SearchInfoItem( SearchInfoItem(
title = "Memo", title = strings.searchInfoMemoTitle, // ← "Memo"
description = "The message or description attached to a payment. " + description = strings.searchInfoMemoDesc
"For example, \"coffee\" or \"rent March\"."
) )
SearchInfoItem( SearchInfoItem(
title = "Invoice", title = strings.searchInfoInvoiceTitle, // ← "Invoice"
description = "The payment request you scanned or pasted to send " + description = strings.searchInfoInvoiceDesc
"a payment. It usually starts with \"lnbc…\". " +
"You can paste just the first or last few characters."
) )
SearchInfoItem( SearchInfoItem(
title = "Hash", title = strings.searchInfoHashTitle, // ← "Hash"
description = "A unique code that identifies this payment on the " + description = strings.searchInfoHashDesc
"Lightning Network. Useful if someone asks you to " +
"confirm a specific transaction."
) )
SearchInfoItem( SearchInfoItem(
title = "Preimage", title = strings.searchInfoPreimageTitle, // ← "Preimage"
description = "A secret code that proves a payment was received. " + description = strings.searchInfoPreimageDesc
"Only available after a payment completes successfully."
) )
SearchInfoItem( SearchInfoItem(
title = "Node alias", title = strings.searchInfoNodeAliasTitle, // ← "Node alias"
description = "The name of the Lightning node you sent a payment to. " + description = strings.searchInfoNodeAliasDesc
"For example, \"ACINQ\" or \"Wallet of Satoshi\". " +
"Aliases are resolved automatically for outgoing payments."
) )
SearchInfoItem( SearchInfoItem(
title = "Node ID (pubkey)", title = strings.searchInfoNodeIdTitle, // ← "Node ID (pubkey)"
description = "The public key of the destination node. " + description = strings.searchInfoNodeIdDesc
"A 66-character hex string — you can paste just the first few characters."
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text( Text(
text = "You can paste a full value or search with just a few characters — " + text = strings.searchInfoFooter, // ← "You can paste a full value…"
"partial matches work too.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -100,4 +93,4 @@ private fun SearchInfoItem(title: String, description: String) {
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
@@ -9,19 +9,23 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
/** /**
* Displays a sats amount in bold with an optional fiat subtitle. * Displays a sats amount in bold with an optional fiat subtitle.
* Uses singular "sat" when amountSats == 1, plural "sats" otherwise.
* Used in MemoStepContent, ReceiveInvoiceContent, and ReceiveSuccessContent. * Used in MemoStepContent, ReceiveInvoiceContent, and ReceiveSuccessContent.
*/ */
@Composable @Composable
internal fun AmountDisplay( internal fun AmountDisplay(
amountSats : Long, amountSats : Long,
fiatLabel : String?, fiatLabel : String?,
style : TextStyle = MaterialTheme.typography.titleLarge style : TextStyle = MaterialTheme.typography.titleLarge
) { ) {
val strings = LocalAppStrings.current
Text( Text(
text = "$amountSats sats", text = strings.amountDisplaySats(amountSats), // ← "1 sat" or "X sats"
style = style, style = style,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
@@ -7,18 +7,21 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import com.bitcointxoko.gudariwallet.LocalAppStrings
@Composable @Composable
internal fun BrightnessToggleButton( internal fun BrightnessToggleButton(
isOn : Boolean, isOn : Boolean,
onToggle: () -> Unit onToggle: () -> Unit
) { ) {
val strings = LocalAppStrings.current
IconButton(onClick = onToggle) { IconButton(onClick = onToggle) {
Icon( Icon(
imageVector = if (isOn) Icons.Filled.BrightnessHigh imageVector = if (isOn) Icons.Filled.BrightnessHigh
else Icons.Filled.BrightnessLow, else Icons.Filled.BrightnessLow,
contentDescription = if (isOn) "Reduce brightness" contentDescription = if (isOn) strings.brightnessReduce // ← "Reduce brightness"
else "Boost brightness", else strings.brightnessBoost, // ← "Boost brightness"
tint = if (isOn) MaterialTheme.colorScheme.primary tint = if (isOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -35,6 +35,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap import androidx.core.graphics.createBitmap
import androidx.core.graphics.set import androidx.core.graphics.set
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.util.IntentUtils import com.bitcointxoko.gudariwallet.util.IntentUtils
import com.google.zxing.BarcodeFormat import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter import com.google.zxing.qrcode.QRCodeWriter
@@ -69,6 +70,7 @@ internal fun QrDisplayCard(
nfcEnabled : Boolean = true, nfcEnabled : Boolean = true,
qrModifier : Modifier = Modifier.size(260.dp) qrModifier : Modifier = Modifier.size(260.dp)
) { ) {
val strings = LocalAppStrings.current
val bitmap = remember(content) { generateQrBitmap(content).asImageBitmap() } val bitmap = remember(content) { generateQrBitmap(content).asImageBitmap() }
val context = LocalContext.current val context = LocalContext.current
val clipboard = LocalClipboard.current val clipboard = LocalClipboard.current
@@ -79,7 +81,7 @@ internal fun QrDisplayCard(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// ── QR image ───────────────────────────────────────────────────────── // ── QR image ─────────────────────────────────────────────────────────
Image( Image(
bitmap = bitmap, bitmap = bitmap,
contentDescription = contentDescription, contentDescription = contentDescription,
@@ -101,7 +103,7 @@ internal fun QrDisplayCard(
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Share, imageVector = Icons.Filled.Share,
contentDescription = "Share", contentDescription = strings.qrShare, // ← "Share"
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
@@ -118,34 +120,35 @@ internal fun QrDisplayCard(
) { ) {
Icon( Icon(
imageVector = Icons.Filled.ContentCopy, imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy", contentDescription = strings.qrCopy, // ← "Copy"
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
// Brightness // Brightness
FilledTonalIconButton( FilledTonalIconButton(onClick = onToggleBrightness) {
onClick = onToggleBrightness
) {
Icon( Icon(
imageVector = Icons.Filled.WbSunny, imageVector = Icons.Filled.WbSunny,
contentDescription = if (brightnessOn) "Reduce brightness" else "Boost brightness", contentDescription = if (brightnessOn) strings.brightnessReduce // ← "Reduce brightness"
else strings.brightnessBoost, // ← "Boost brightness"
tint = if (brightnessOn) MaterialTheme.colorScheme.primary tint = if (brightnessOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
// NFC — only shown when caller passes isNfcEmulating // NFC — only shown when caller passes isNfcEmulating
if (isNfcEmulating != null && onToggleNfc != null) { if (isNfcEmulating != null && onToggleNfc != null) {
FilledTonalIconButton( FilledTonalIconButton(
onClick = onToggleNfc, onClick = onToggleNfc,
enabled = nfcEnabled enabled = nfcEnabled
) { ) {
Icon( Icon(
imageVector = if (isNfcEmulating) Icons.Filled.Nfc else Icons.Outlined.Nfc, imageVector = if (isNfcEmulating) Icons.Filled.Nfc
contentDescription = if (isNfcEmulating) "Stop NFC sharing" else "Share via NFC", else Icons.Outlined.Nfc,
contentDescription = if (isNfcEmulating) strings.qrNfcStop // ← "Stop NFC sharing"
else strings.qrNfcStart, // ← "Share via NFC"
tint = if (isNfcEmulating) MaterialTheme.colorScheme.primary tint = if (isNfcEmulating) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
@@ -153,7 +156,7 @@ internal fun QrDisplayCard(
} }
} }
// ── QR generation ──────────────────────────────────────────────────────────── // ── QR generation ────────────────────────────────────────────────────────────
private const val QR_BITMAP_SIZE = 512 private const val QR_BITMAP_SIZE = 512
@@ -133,7 +133,11 @@ fun ReceiveScreen(
onToggleBrightness = onToggleBrightness, onToggleBrightness = onToggleBrightness,
isLoading = receiveState is ReceiveState.AwaitingInvoice, isLoading = receiveState is ReceiveState.AwaitingInvoice,
onCreateInvoice = { amountSats, memo -> onCreateInvoice = { amountSats, memo ->
vm.createInvoice(amountSats, memo) vm.createInvoice(
amountSats,
memo,
failedToCreateInvoice = strings.receiveVmFailedToCreateInvoice
)
} }
) )
} }
@@ -146,7 +150,10 @@ fun ReceiveScreen(
k1 = state.k1, k1 = state.k1,
callback = state.callback, callback = state.callback,
amountSats = amountSats, amountSats = amountSats,
memo = state.defaultDescription memo = state.defaultDescription,
failedToCreateInvoice = strings.receiveVmFailedToCreateInvoice,
withdrawRejected = strings.receiveVmWithdrawRejected,
withdrawFailed = strings.receiveVmWithdrawFailed,
) )
}, },
onDismiss = { vm.resetReceiveState() } onDismiss = { vm.resetReceiveState() }
@@ -21,7 +21,8 @@ class ReceiveViewModel(
private val repo: WalletRepository, private val repo: WalletRepository,
) : ViewModel() { ) : ViewModel() {
private val _receiveState = MutableStateFlow<ReceiveState>(ReceiveState.Idle) private val _receiveState =
MutableStateFlow<ReceiveState>(ReceiveState.Idle)
val receiveState: StateFlow<ReceiveState> = _receiveState val receiveState: StateFlow<ReceiveState> = _receiveState
private val _navigateToReceive = MutableSharedFlow<Unit>(extraBufferCapacity = 1) private val _navigateToReceive = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
@@ -40,8 +41,16 @@ class ReceiveViewModel(
} }
} }
// ── Invoice creation ───────────────────────────────────────────────────── // ── Invoice creation ─────────────────────────────────────────────────────
fun createInvoice(amountSats: Long, memo: String) { /**
* @param failedToCreateInvoice Localised error string from the call site,
* e.g. `strings.receiveVmFailedToCreateInvoice`
*/
fun createInvoice(
amountSats : Long,
memo : String,
failedToCreateInvoice : String, // ← injected from Composable
) {
viewModelScope.launch { viewModelScope.launch {
_receiveState.value = ReceiveState.AwaitingInvoice _receiveState.value = ReceiveState.AwaitingInvoice
runCatching { repo.createInvoice(amountSats, memo) } runCatching { repo.createInvoice(amountSats, memo) }
@@ -64,17 +73,21 @@ class ReceiveViewModel(
} }
.onFailure { e -> .onFailure { e ->
Log.e(TAG, "RECEIVE [CREATE INVOICE ERROR] ${e.message}") Log.e(TAG, "RECEIVE [CREATE INVOICE ERROR] ${e.message}")
_receiveState.value = ReceiveState.Error(parseApiError(e, "Failed to create invoice")) _receiveState.value = ReceiveState.Error(
parseApiError(e, failedToCreateInvoice) // ← localised
)
} }
} }
} }
fun resetReceiveState() { _receiveState.value = ReceiveState.Idle } fun resetReceiveState() { _receiveState.value = ReceiveState.Idle }
// ── LNURL-withdraw ─────────────────────────────────────────────────────── // ── LNURL-withdraw ───────────────────────────────────────────────────────
// Called from WalletViewModel's scan handlers when a withdrawRequest is detected. fun handleWithdrawResponse(
fun handleWithdrawResponse(raw: com.bitcointxoko.gudariwallet.api.LnurlScanResponse, lnurl: String) { raw : com.bitcointxoko.gudariwallet.api.LnurlScanResponse,
lnurl : String
) {
val minSats = (raw.minWithdrawable ?: 0L) / WalletConstants.MSAT_PER_SAT val minSats = (raw.minWithdrawable ?: 0L) / WalletConstants.MSAT_PER_SAT
val maxSats = (raw.maxWithdrawable ?: 0L) / WalletConstants.MSAT_PER_SAT val maxSats = (raw.maxWithdrawable ?: 0L) / WalletConstants.MSAT_PER_SAT
_receiveState.value = ReceiveState.LnurlWithdrawReady( _receiveState.value = ReceiveState.LnurlWithdrawReady(
@@ -90,20 +103,31 @@ class ReceiveViewModel(
Log.d(TAG, "LNURL-WITHDRAW [NAV EVENT] min=$minSats max=$maxSats sats — navigating to Receive") Log.d(TAG, "LNURL-WITHDRAW [NAV EVENT] min=$minSats max=$maxSats sats — navigating to Receive")
} }
/**
* @param failedToCreateInvoice `strings.receiveVmFailedToCreateInvoice`
* @param withdrawRejected `strings.receiveVmWithdrawRejected`
* @param withdrawFailed `strings.receiveVmWithdrawFailed`
*/
fun executeWithdraw( fun executeWithdraw(
k1 : String, k1 : String,
callback : String, callback : String,
amountSats: Long, amountSats : Long,
memo : String memo : String,
failedToCreateInvoice : String, // ← injected from Composable
withdrawRejected : String, // ← injected from Composable
withdrawFailed : String, // ← injected from Composable
) { ) {
viewModelScope.launch { viewModelScope.launch {
_receiveState.value = ReceiveState.AwaitingInvoice _receiveState.value = ReceiveState.AwaitingInvoice
val invoiceResult = runCatching { repo.createInvoice(amountSats, memo) } val invoiceResult = runCatching { repo.createInvoice(amountSats, memo) }
if (invoiceResult.isFailure) { if (invoiceResult.isFailure) {
val e = invoiceResult.exceptionOrNull() ?: Exception("Failed to create invoice") val e = invoiceResult.exceptionOrNull()
?: Exception(failedToCreateInvoice)
Log.e(TAG, "LNURL-WITHDRAW [INVOICE ERROR] ${e.message}") Log.e(TAG, "LNURL-WITHDRAW [INVOICE ERROR] ${e.message}")
_receiveState.value = ReceiveState.Error(parseApiError(e, "Failed to create invoice")) _receiveState.value = ReceiveState.Error(
parseApiError(e, failedToCreateInvoice) // ← localised
)
return@launch return@launch
} }
val invoice = invoiceResult.getOrThrow() val invoice = invoiceResult.getOrThrow()
@@ -122,15 +146,17 @@ class ReceiveViewModel(
) )
} else { } else {
val reason = response.reason?.ifBlank { null } val reason = response.reason?.ifBlank { null }
?: "Withdraw service rejected the invoice" ?: withdrawRejected // ← localised
Log.w(TAG, "LNURL-WITHDRAW [CALLBACK ERR] $reason") Log.w(TAG, "LNURL-WITHDRAW [CALLBACK ERR] $reason")
_receiveState.value = ReceiveState.Error(reason) _receiveState.value = ReceiveState.Error(reason)
} }
} }
.onFailure { e -> .onFailure { e ->
Log.w(TAG, "LNURL-WITHDRAW [CALLBACK FAIL] ${e.message}") Log.w(TAG, "LNURL-WITHDRAW [CALLBACK FAIL] ${e.message}")
_receiveState.value = ReceiveState.Error(parseApiError(e, "Withdraw failed")) _receiveState.value = ReceiveState.Error(
parseApiError(e, withdrawFailed) // ← localised
)
} }
} }
} }
} }