feat: localisation with lyricist

This commit is contained in:
2026-06-05 23:52:30 +02:00
parent 039f1c430a
commit ed3371aa6f
12 changed files with 453 additions and 243 deletions
@@ -36,6 +36,9 @@ import com.bitcointxoko.gudariwallet.util.IntentUtils
import kotlinx.coroutines.flow.MutableSharedFlow
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import cafe.adriel.lyricist.ProvideStrings
import cafe.adriel.lyricist.rememberStrings
import com.bitcointxoko.gudariwallet.i18n.AppStrings
class MainActivity : AppCompatActivity() {
@@ -113,29 +116,36 @@ class MainActivity : AppCompatActivity() {
setContent {
GudariWalletTheme {
val credentials by credentialsState.collectAsState()
// Treat null (loading) the same as not-onboarded to avoid a flash
val isOnboarded = credentials?.isOnboarded == true
// ── Lyricist ──────────────────────────────────────────────────
val lyricist = rememberAppStrings()
ProvideStrings(lyricist, LocalAppStrings) {
// ─────────────────────────────────────────────────────────────
if (isOnboarded) {
LaunchedEffect(Unit) {
WalletNotificationService.start(this@MainActivity)
requestNotificationPermissionIfNeeded()
promptBatteryOptimizationIfNeeded()
val credentials by credentialsState.collectAsState()
val isOnboarded = credentials?.isOnboarded == true
if (isOnboarded) {
LaunchedEffect(Unit) {
WalletNotificationService.start(this@MainActivity)
requestNotificationPermissionIfNeeded()
promptBatteryOptimizationIfNeeded()
}
WalletScreen(
vm = vm,
nfcVm = nfcVm,
pendingPaymentHash = pendingPaymentHash,
pendingPaymentUri = pendingPaymentUri,
lyricist = lyricist // ← pass down
)
} else {
OnboardingScreen(
secretStore = secretStore,
onComplete = { }
)
}
WalletScreen(
vm = vm,
nfcVm = nfcVm,
pendingPaymentHash = pendingPaymentHash,
pendingPaymentUri = pendingPaymentUri
)
} else {
OnboardingScreen(
secretStore = secretStore,
onComplete = { /* no-op: Flow update triggers recomposition automatically */ }
)
}
} // end ProvideStrings
}
}
}
@@ -21,6 +21,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Language // ← NEW
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
@@ -28,19 +29,25 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalFocusManager
import com.bitcointxoko.gudariwallet.util.formatFiat
import androidx.compose.ui.text.font.FontFamily
import com.bitcointxoko.gudariwallet.LocalAppStrings
import cafe.adriel.lyricist.Lyricist // ← NEW
import com.bitcointxoko.gudariwallet.i18n.AppStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeTab(
vm: WalletViewModel,
onHistoryClick: () -> Unit
onHistoryClick: () -> Unit,
lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings
) {
val balanceState by vm.balanceState.collectAsState()
val strings = LocalAppStrings.current
val balanceState by vm.balanceState.collectAsState()
val balanceHidden by vm.balanceHidden.collectAsState()
// ── NEW: language switcher state ──────────────────────────────────────────
val supportedTags = remember { listOf("en", "es") } // add more as you add locale files
val isRefreshing = balanceState is BalanceState.Loading
|| (balanceState as? BalanceState.Success)?.isRefreshing == true
@@ -63,7 +70,7 @@ fun HomeTab(
CircularProgressIndicator()
Spacer(Modifier.height(12.dp))
Text(
text = "Loading balance…",
text = strings.loadingBalance,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -72,7 +79,7 @@ fun HomeTab(
is BalanceState.Success -> {
val selectedCurrency by vm.selectedCurrency.collectAsState()
var showCurrencyPicker by remember { mutableStateOf(false) }
var isLoadingCurrencies by remember { mutableStateOf(false) }
var isLoadingCurrencies by remember { mutableStateOf(false) }
var currencyList by remember { mutableStateOf<List<String>>(emptyList()) }
LaunchedEffect(showCurrencyPicker) {
if (showCurrencyPicker && currencyList.isEmpty()) {
@@ -82,7 +89,7 @@ fun HomeTab(
}
}
Row(
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (balanceHidden)
@@ -91,8 +98,7 @@ fun HomeTab(
Icons.Filled.Visibility,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(20.dp)
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(8.dp))
@@ -112,16 +118,16 @@ fun HomeTab(
.widthIn(max = 280.dp)
.combinedClickable(
onClick = { if (!balanceHidden) vm.toggleBalanceVisibility() },
onClickLabel = if (balanceHidden) null else "Hide balance",
onClickLabel = if (balanceHidden) null else strings.hideBalance,
onLongClick = { if (balanceHidden) vm.toggleBalanceVisibility() },
onLongClickLabel = if (balanceHidden) "Reveal balance" else null,
onLongClickLabel = if (balanceHidden) strings.revealBalance else null,
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
)
Spacer(Modifier.width(8.dp))
Text(
text = "sats",
text = strings.sats,
style = MaterialTheme.typography.titleMedium.copy(
fontFamily = FontFamily.Monospace
),
@@ -132,7 +138,6 @@ fun HomeTab(
Spacer(Modifier.height(2.dp))
val currency = selectedCurrency
if (currency != null) {
// ── Fiat amount + tappable currency code ──────────────────────────
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
@@ -153,13 +158,12 @@ fun HomeTab(
)
Spacer(Modifier.width(4.dp))
}
// Currency code — always shown when a currency is selected, tappable to change
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(
onClick = { showCurrencyPicker = true },
onClickLabel = "Change currency",
onClickLabel = strings.changeCurrency,
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
@@ -175,35 +179,33 @@ fun HomeTab(
Spacer(Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.ArrowDropDown,
contentDescription = "Change currency",
contentDescription = strings.changeCurrency,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(14.dp)
)
}
}
} else {
// ── No currency selected — ghost prompt ───────────────────────────
TextButton(
onClick = { showCurrencyPicker = true },
onClick = { showCurrencyPicker = true },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
) {
Text(
text = "+ Show fiat",
text = strings.showFiat,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
)
}
}
Spacer(Modifier.height(8.dp))
if (!s.isRefreshing) {
val updatedLabel: String = remember(s.lastUpdated) {
formatLastUpdated(s.lastUpdated)
formatLastUpdated(s.lastUpdated, strings)
}
Text(
text = "Updated $updatedLabel",
text = strings.updatedAt(updatedLabel),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -213,11 +215,21 @@ fun HomeTab(
TextButton(onClick = onHistoryClick) {
Text(
text = "View History",
text = strings.viewHistory,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
// ── NEW: Language switcher ─────────────────────────────────
Spacer(Modifier.height(8.dp))
LanguageSwitcherButton(
currentTag = lyricist.languageTag,
supportedTags = supportedTags,
onSelect = { tag -> lyricist.languageTag = tag }
)
// ──────────────────────────────────────────────────────────
if (showCurrencyPicker) {
CurrencyPickerSheet(
currencies = currencyList,
@@ -242,7 +254,7 @@ fun HomeTab(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
)
Text(
text = "sats",
text = strings.sats,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
@@ -256,9 +268,9 @@ fun HomeTab(
) {
Text(
text = if (s.staleSats != null)
"Could not refresh — pull down to try again"
strings.couldNotRefresh
else
"Could not load balance — pull down to try again",
strings.couldNotLoadBalance,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(12.dp)
@@ -270,19 +282,99 @@ fun HomeTab(
}
}
// ── NEW: Language switcher composable ─────────────────────────────────────────
@Composable
private fun LanguageSwitcherButton(
currentTag : String,
supportedTags: List<String>,
onSelect : (String) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
// Human-readable display names for each tag
val displayName: (String) -> String = { tag ->
when (tag) {
"en" -> "English"
"es" -> "Español"
else -> tag.uppercase()
}
}
Box {
TextButton(
onClick = { expanded = true },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
) {
Icon(
imageVector = Icons.Filled.Language,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Spacer(Modifier.width(4.dp))
Text(
text = displayName(currentTag),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Icon(
imageVector = Icons.Filled.ArrowDropDown,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
supportedTags.forEach { tag ->
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = displayName(tag),
style = MaterialTheme.typography.bodyMedium,
color = if (tag == currentTag)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurface
)
}
},
trailingIcon = if (tag == currentTag) ({
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
}) else null,
onClick = {
onSelect(tag)
expanded = false
}
)
}
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
private fun formatSats(sats: Long): String =
"%,d".format(sats).replace(',', ' ')
private fun formatLastUpdated(epochMs: Long): String {
private fun formatLastUpdated(epochMs: Long, strings: AppStrings): String {
val ageMs = System.currentTimeMillis() - epochMs
return when {
ageMs < 60_000L -> "just now"
ageMs < 3_600_000L -> "${ageMs / 60_000L} min ago"
ageMs < 60_000L -> strings.justNow
ageMs < 3_600_000L -> strings.minAgo((ageMs / 60_000L).toInt())
else -> {
val formatter = java.time.format.DateTimeFormatter
.ofPattern("HH:mm")
.withZone(java.time.ZoneId.systemDefault())
"at ${formatter.format(java.time.Instant.ofEpochMilli(epochMs))}"
strings.atTime(formatter.format(java.time.Instant.ofEpochMilli(epochMs)))
}
}
}
@@ -291,17 +383,16 @@ private fun formatLastUpdated(epochMs: Long): String {
@Composable
private fun CurrencyPickerSheet(
currencies : List<String>,
isLoading : Boolean, // NEW — true while list is being fetched
isLoading : Boolean,
selectedCurrency: String?,
onSelect : (String?) -> Unit,
onDismiss : () -> Unit
) {
val strings = LocalAppStrings.current
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var query by remember { mutableStateOf("") }
val focusManager = LocalFocusManager.current
// Filter list client-side — no network, instant response
// Only active once the list is populated
val filtered = remember(query, currencies) {
if (query.isBlank()) currencies
else currencies.filter { it.contains(query.trim(), ignoreCase = true) }
@@ -313,18 +404,16 @@ private fun CurrencyPickerSheet(
) {
Column(modifier = Modifier.fillMaxWidth()) {
// ── Header ────────────────────────────────────────────────────────
Text(
text = "Select currency",
text = strings.selectCurrency,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
)
// ── "None" option — always pinned at top, never filtered ──────────
ListItem(
headlineContent = {
Text(
text = "None",
text = strings.currencyNone,
style = MaterialTheme.typography.bodyLarge,
color = if (selectedCurrency == null)
MaterialTheme.colorScheme.primary
@@ -334,7 +423,7 @@ private fun CurrencyPickerSheet(
},
supportingContent = {
Text(
text = "Hide fiat equivalent",
text = strings.hideFiatEquivalent,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -342,7 +431,7 @@ private fun CurrencyPickerSheet(
trailingContent = if (selectedCurrency == null) ({
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Selected",
contentDescription = strings.selected,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
@@ -351,12 +440,11 @@ private fun CurrencyPickerSheet(
)
HorizontalDivider(thickness = 1.dp)
// ── Search field — hidden while loading ───────────────────────────
if (!isLoading) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search…") },
placeholder = { Text(strings.search) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
@@ -378,10 +466,8 @@ private fun CurrencyPickerSheet(
)
}
// ── Body — spinner OR list OR empty state ─────────────────────────
when {
isLoading -> {
// Spinner while list is being fetched on first open
Box(
modifier = Modifier
.fillMaxWidth()
@@ -393,10 +479,9 @@ private fun CurrencyPickerSheet(
}
filtered.isEmpty() -> {
// No results after search
Text(
text = if (query.isBlank()) "No currencies available"
else "No currencies match \"$query\"",
text = if (query.isBlank()) strings.noCurrenciesAvailable
else strings.noCurrenciesMatch(query),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
@@ -424,7 +509,7 @@ private fun CurrencyPickerSheet(
trailingContent = if (isSelected) ({
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Selected",
contentDescription = strings.selected,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
@@ -439,4 +524,3 @@ private fun CurrencyPickerSheet(
}
}
}
@@ -53,7 +53,9 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import cafe.adriel.lyricist.Lyricist
import com.bitcointxoko.gudariwallet.MainActivity
import com.bitcointxoko.gudariwallet.i18n.AppStrings
import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
@@ -81,7 +83,8 @@ fun WalletScreen(
vm : WalletViewModel,
nfcVm : NfcViewModel, // ← new
pendingPaymentHash: MutableState<String?>,
pendingPaymentUri : MutableState<String?>
pendingPaymentUri : MutableState<String?>,
lyricist : Lyricist<AppStrings>
) {
val navController = rememberNavController()
val context = LocalContext.current
@@ -288,7 +291,8 @@ fun WalletScreen(
navController.navigate(ROUTE_HISTORY) {
launchSingleTop = true
}
}
},
lyricist = lyricist
)
}
composable(TabItem.Receive.route) {
@@ -21,14 +21,10 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Nfc
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.outlined.Nfc
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
@@ -60,6 +56,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.ReceiveState
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
import com.bitcointxoko.gudariwallet.ui.components.UnitWheelPicker
@@ -70,9 +67,10 @@ import kotlinx.coroutines.delay
@Composable
fun ReceiveScreen(
vm : WalletViewModel,
nfcVm : NfcViewModel, // ← new
nfcVm : NfcViewModel,
onNavigateToPaymentDetail: (checkingId: String) -> Unit
) {
val strings = LocalAppStrings.current
val receiveState by vm.receiveState.collectAsStateWithLifecycle()
val lightningAddress by vm.lightningAddress.collectAsStateWithLifecycle()
val fiatRate by vm.fiatSatsPerUnit.collectAsStateWithLifecycle()
@@ -93,19 +91,16 @@ fun ReceiveScreen(
brightnessOn = !brightnessOn
}
// Stop NFC emulation and reset receive state when leaving the screen
DisposableEffect(Unit) {
onDispose {
val lp = window.attributes
lp.screenBrightness = originalBrightness
window.attributes = lp
nfcVm.stopEmulating() // ← new: clean up HCE on exit
nfcVm.stopEmulating()
vm.resetReceiveState()
}
}
// Also stop emulating if the invoice is reset mid-session
// (e.g. user taps "Cancel" or "New Invoice")
LaunchedEffect(receiveState) {
if (receiveState !is ReceiveState.InvoiceReady) {
nfcVm.stopEmulating()
@@ -176,7 +171,7 @@ fun ReceiveScreen(
modifier = Modifier.padding(24.dp)
) {
Text(
text = "Error",
text = strings.receiveError, // ← "Error"
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error
)
@@ -188,7 +183,7 @@ fun ReceiveScreen(
)
Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = { vm.resetReceiveState() }) {
Text("Try Again")
Text(strings.tryAgain) // ← "Try Again"
}
}
}
@@ -204,14 +199,14 @@ fun ReceiveScreen(
fiatCurrency = fiatCurrency,
brightnessOn = brightnessOn,
onToggleBrightness = onToggleBrightness,
nfcVm = nfcVm, // ← new
nfcVm = nfcVm,
onReset = { vm.resetReceiveState() }
)
}
}
}
// ── LnurlWithdrawSheet (unchanged) ───────────────────────────────────────────
// ── LnurlWithdrawSheet ────────────────────────────────────────────────────────
@Composable
private fun LnurlWithdrawSheet(
@@ -219,6 +214,7 @@ private fun LnurlWithdrawSheet(
onConfirm: (Long) -> Unit,
onDismiss: () -> Unit
) {
val strings = LocalAppStrings.current
val fixedAmount = state.minSats == state.maxSats
var amountText by remember { mutableStateOf(state.maxSats.toString()) }
@@ -227,9 +223,9 @@ private fun LnurlWithdrawSheet(
fun validate(): Long? {
val parsed = amountText.trim().toLongOrNull()
return when {
parsed == null || parsed <= 0 -> { amountError = "Enter a valid amount"; null }
parsed < state.minSats -> { amountError = "Minimum is ${state.minSats} sats"; null }
parsed > state.maxSats -> { amountError = "Maximum is ${state.maxSats} sats"; null }
parsed == null || parsed <= 0 -> { amountError = strings.enterValidAmount; null } // ← "Enter a valid amount"
parsed < state.minSats -> { amountError = strings.minimumSats(state.minSats); null } // ← "Minimum is X sats"
parsed > state.maxSats -> { amountError = strings.maximumSats(state.maxSats); null } // ← "Maximum is X sats"
else -> { amountError = null; parsed }
}
}
@@ -250,7 +246,7 @@ private fun LnurlWithdrawSheet(
Spacer(Modifier.size(12.dp))
Column {
Text(
text = "Withdraw from",
text = strings.withdrawFrom, // ← "Withdraw from"
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -284,12 +280,12 @@ private fun LnurlWithdrawSheet(
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Amount",
text = strings.amount, // ← "Amount"
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
text = "${state.maxSats} sats",
text = strings.withdrawButtonAmount(state.maxSats), // ← "X sats"
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer
@@ -300,10 +296,12 @@ private fun LnurlWithdrawSheet(
OutlinedTextField(
value = amountText,
onValueChange = { amountText = it; amountError = null },
label = { Text("Amount (sats)") },
label = { Text(strings.amountInSats) }, // ← "Amount (sats)"
supportingText = {
if (amountError != null) Text(amountError!!, color = MaterialTheme.colorScheme.error)
else Text("${state.minSats} ${state.maxSats} sats")
if (amountError != null)
Text(amountError!!, color = MaterialTheme.colorScheme.error)
else
Text(strings.satsRange(state.minSats, state.maxSats)) // ← "X Y sats"
},
isError = amountError != null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
@@ -321,20 +319,23 @@ private fun LnurlWithdrawSheet(
},
modifier = Modifier.fillMaxWidth()
) {
Text("Withdraw ${if (fixedAmount) "${state.maxSats} sats" else ""}")
Text(
if (fixedAmount) strings.withdrawButtonAmount(state.maxSats) // ← "Withdraw X sats"
else strings.withdrawButton // ← "Withdraw"
)
}
TextButton(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) {
Text("Cancel")
Text(strings.cancel) // ← "Cancel"
}
}
}
// ── AmountUnit (unchanged) ────────────────────────────────────────────────────
// ── AmountUnit ────────────────────────────────────────────────────────────────
private enum class AmountUnit { SATS, FIAT }
// ── ReceiveIdleContent (unchanged) ───────────────────────────────────────────
// ── ReceiveIdleContent ────────────────────────────────────────────────────────
@Composable
private fun ReceiveIdleContent(
@@ -348,6 +349,7 @@ private fun ReceiveIdleContent(
isLoading : Boolean,
onCreateInvoice : (Long, String) -> Unit
) {
val strings = LocalAppStrings.current
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
@@ -376,7 +378,7 @@ private fun ReceiveIdleContent(
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Receive",
text = strings.receive, // ← "Receive"
style = MaterialTheme.typography.headlineSmall
)
if (lightningAddress != null) {
@@ -417,11 +419,11 @@ private fun ReceiveIdleContent(
Spacer(Modifier.height(12.dp))
QrDisplayCard(
content = lightningAddress!!,
contentDescription = "Lightning address QR code",
clipLabel = "Lightning Address",
shareTitle = "Share Lightning Address",
textToCopy = lightningAddress!!,
content = lightningAddress,
contentDescription = strings.lightningAddressQrCode, // ← "Lightning address QR code"
clipLabel = strings.lightningAddressLabel, // ← "Lightning Address"
shareTitle = strings.shareLightningAddress, // ← "Share Lightning Address"
textToCopy = lightningAddress,
brightnessOn = brightnessOn,
onToggleBrightness = onToggleBrightness
)
@@ -461,7 +463,7 @@ private fun ReceiveIdleContent(
}
}
// ── AmountStepContent (unchanged) ────────────────────────────────────────────
// ── AmountStepContent ─────────────────────────────────────────────────────────
@Composable
private fun AmountStepContent(
@@ -471,6 +473,7 @@ private fun AmountStepContent(
onCollapseAddress : () -> Unit,
onNext : (amountText: String, activeUnit: AmountUnit) -> Unit
) {
val strings = LocalAppStrings.current
val showFiatToggle = fiatRate != null && fiatCurrency != null
var amountText by remember { mutableStateOf("") }
@@ -481,7 +484,7 @@ private fun AmountStepContent(
val amountValid = parsedAmount != null && parsedAmount > 0
fun tryNext() {
if (!amountValid) amountError = "Enter a valid amount"
if (!amountValid) amountError = strings.enterValidAmount // ← "Enter a valid amount"
else { amountError = null; onNext(amountText, activeUnit) }
}
@@ -538,7 +541,7 @@ private fun AmountStepContent(
)
UnitWheelPicker(
units = if (showFiatToggle) listOf("sats", fiatCurrency!!) else listOf("sats"),
units = if (showFiatToggle) listOf(strings.sats, fiatCurrency!!) else listOf(strings.sats),
selectedIndex = if (activeUnit == AmountUnit.SATS) 0 else 1,
onIndexSelected = { newIndex ->
val newUnit = if (newIndex == 0) AmountUnit.SATS else AmountUnit.FIAT
@@ -556,12 +559,12 @@ private fun AmountStepContent(
enabled = amountText.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
Text("Next")
Text(strings.next) // ← "Next"
}
}
}
// ── MemoStepContent (unchanged) ──────────────────────────────────────────────
// ── MemoStepContent ───────────────────────────────────────────────────────────
@Composable
private fun MemoStepContent(
@@ -571,6 +574,7 @@ private fun MemoStepContent(
onCreateInvoice : (memo: String) -> Unit,
onBack : () -> Unit
) {
val strings = LocalAppStrings.current
var memo by remember { mutableStateOf("") }
Column(
@@ -582,8 +586,8 @@ private fun MemoStepContent(
OutlinedTextField(
value = memo,
onValueChange = { memo = it },
label = { Text("Memo (optional)") },
placeholder = { Text("What's this for?") },
label = { Text(strings.memoOptional) }, // ← "Memo (optional)"
placeholder = { Text(strings.memoPlaceholder) }, // ← "What's this for?"
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
@@ -597,16 +601,19 @@ private fun MemoStepContent(
enabled = !isLoading,
modifier = Modifier.fillMaxWidth()
) {
Text(if (isLoading) "Creating invoice…" else "Create Invoice")
Text(
if (isLoading) strings.creatingInvoice // ← "Creating invoice…"
else strings.createInvoice // ← "Create Invoice"
)
}
TextButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) {
Text("← Back")
Text(strings.back) // ← "← Back"
}
}
}
// ── ReceiveInvoiceContent — NFC toggle added ──────────────────────────────────
// ── ReceiveInvoiceContent ─────────────────────────────────────────────────────
@Composable
private fun ReceiveInvoiceContent(
@@ -618,6 +625,7 @@ private fun ReceiveInvoiceContent(
nfcVm : NfcViewModel,
onReset : () -> Unit
) {
val strings = LocalAppStrings.current
val isEmulating by nfcVm.isNfcEmulating.collectAsStateWithLifecycle()
var secondsLeft by remember {
@@ -644,7 +652,6 @@ private fun ReceiveInvoiceContent(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// ── Amount ────────────────────────────────────────────────────────────
AmountDisplay(amountSats = state.amountSats, fiatLabel = fiatLabel)
if (state.memo.isNotBlank()) {
@@ -655,12 +662,11 @@ private fun ReceiveInvoiceContent(
)
}
// ── QR + action row (Share / Copy / Brightness / NFC) ─────────────────
QrDisplayCard(
content = state.bolt11.uppercase(),
contentDescription = "Lightning invoice QR code",
clipLabel = "Invoice",
shareTitle = "Share Invoice",
contentDescription = strings.lightningInvoiceQrCode, // ← "Lightning invoice QR code"
clipLabel = strings.invoiceLabel, // ← "Invoice"
shareTitle = strings.shareInvoice, // ← "Share Invoice"
textToCopy = state.bolt11,
brightnessOn = brightnessOn,
onToggleBrightness = onToggleBrightness,
@@ -678,16 +684,15 @@ private fun ReceiveInvoiceContent(
.weight(1f)
)
// ── Status row — restored to original simple format ───────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)
) {
if (!isExpired) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Text(
text = "Waiting…",
text = strings.waiting, // ← "Waiting…"
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -698,7 +703,7 @@ private fun ReceiveInvoiceContent(
)
} else {
Text(
text = "Invoice expired",
text = strings.invoiceExpired, // ← "Invoice expired"
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
@@ -709,13 +714,15 @@ private fun ReceiveInvoiceContent(
onClick = onReset,
modifier = Modifier.fillMaxWidth()
) {
Text(if (isExpired) "New Invoice" else "Cancel")
Text(
if (isExpired) strings.newInvoice // ← "New Invoice"
else strings.cancel // ← "Cancel"
)
}
}
}
// ── ReceiveSuccessContent (unchanged) ────────────────────────────────────────
// ── ReceiveSuccessContent ─────────────────────────────────────────────────────
@Composable
private fun ReceiveSuccessContent(
@@ -726,6 +733,7 @@ private fun ReceiveSuccessContent(
onDone : () -> Unit,
onDetails : () -> Unit
) {
val strings = LocalAppStrings.current
val fiatLabel = fiatLabel(amountSats, fiatRate, fiatCurrency)
Column(
@@ -740,7 +748,10 @@ private fun ReceiveSuccessContent(
modifier = Modifier.size(72.dp)
)
Spacer(Modifier.height(16.dp))
Text("Payment Received!", style = MaterialTheme.typography.headlineSmall)
Text(
text = strings.paymentReceived, // ← "Payment Received!"
style = MaterialTheme.typography.headlineSmall
)
Spacer(Modifier.height(8.dp))
AmountDisplay(amountSats = amountSats, fiatLabel = fiatLabel)
if (!memo.isNullOrBlank()) {
@@ -749,11 +760,11 @@ private fun ReceiveSuccessContent(
}
Spacer(Modifier.height(32.dp))
Button(onClick = onDetails, modifier = Modifier.fillMaxWidth()) {
Text("Details")
Text(strings.details) // ← "Details"
}
Spacer(Modifier.height(32.dp))
TextButton(onClick = onDone, modifier = Modifier.fillMaxWidth()) {
Text("Done")
Text(strings.done) // ← "Done"
}
}
}
@@ -24,6 +24,7 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.fragment.app.FragmentActivity
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.DecodedInvoice
import com.bitcointxoko.gudariwallet.ui.SendState
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
@@ -44,16 +45,19 @@ internal fun Bolt11ConfirmCard(
fiatRate : Double?,
fiatCurrency: String?
) {
val strings = LocalAppStrings.current
val fiatLbl = fiatLabel(state.amountSats, fiatRate, fiatCurrency)
ConfirmCardContent(
title = "Invoice Details",
invoice = state,
fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency),
onPay = {
vm.requestPayment(activity, "Send ${state.amountSats} sats") {
vm.payBolt11(state.bolt11)
}
title = strings.invoiceDetails, // ← "Invoice Details"
invoice = state,
fiatLabel = fiatLbl,
onPay = {
vm.requestPayment(
activity,
strings.sendBiometricPrompt(state.amountSats) // ← "Send X sats"
) { vm.payBolt11(state.bolt11) }
},
onCancel = { vm.resetSendState() }
onCancel = { vm.resetSendState() }
)
}
@@ -65,17 +69,20 @@ internal fun LnurlInvoiceConfirmCard(
fiatRate : Double?,
fiatCurrency: String?
) {
val label = payingToLabel(state.lnurl, state.domain)
val strings = LocalAppStrings.current
val label = payingToLabel(state.lnurl, state.domain)
val fiatLbl = fiatLabel(state.amountSats, fiatRate, fiatCurrency)
ConfirmCardContent(
title = "Paying to $label",
invoice = state,
fiatLabel = fiatLabel(state.amountSats, fiatRate, fiatCurrency),
onPay = {
vm.requestPayment(activity, "Send ${state.amountSats} sats to $label") {
vm.payLnurlInvoice(state)
}
title = strings.payingTo(label), // ← "Paying to X"
invoice = state,
fiatLabel = fiatLbl,
onPay = {
vm.requestPayment(
activity,
strings.sendBiometricPromptTo(state.amountSats, label) // ← "Send X sats to Y"
) { vm.payLnurlInvoice(state) }
},
onCancel = { vm.resetSendState() }
onCancel = { vm.resetSendState() }
)
}
@@ -87,6 +94,7 @@ private fun ConfirmCardContent(
onPay : () -> Unit,
onCancel : () -> Unit
) {
val strings = LocalAppStrings.current
val amountSats = invoice.amountSats
val memo = invoice.memo
val nodeAlias = invoice.nodeAlias
@@ -100,7 +108,7 @@ private fun ConfirmCardContent(
val expiresAt = invoice.expiresAt
val context = LocalContext.current
// ── Live expiry countdown — ticks every second ────────────────────────
// ── Live expiry countdown — ticks every second ────────────────────────────
var now by remember { mutableStateOf(Instant.now()) }
LaunchedEffect(expiresAt) {
while (true) {
@@ -108,31 +116,37 @@ private fun ConfirmCardContent(
now = Instant.now()
}
}
val isExpired = now.isAfter(expiresAt)
val secondsLeft = expiresAt.epochSecond - now.epochSecond
val isExpired = now.isAfter(expiresAt)
val secondsLeft = expiresAt.epochSecond - now.epochSecond
val expiryCountdown = when {
isExpired -> "Expired"
secondsLeft < 60 -> "${secondsLeft}s"
secondsLeft < 3600 -> "${secondsLeft / 60}m ${secondsLeft % 60}s"
else -> "${secondsLeft / 3600}h ${(secondsLeft % 3600) / 60}m"
isExpired -> strings.expiryExpired // ← "Expired"
secondsLeft < 60 -> strings.expirySeconds(secondsLeft) // ← "Xs"
secondsLeft < 3600 -> strings.expiryMinutes(
secondsLeft / 60,
secondsLeft % 60
) // ← "Xm Ys"
else -> strings.expiryHours(
secondsLeft / 3600,
(secondsLeft % 3600) / 60
) // ← "Xh Ym"
}
val expiryColor = when {
isExpired -> MaterialTheme.colorScheme.error
secondsLeft < 60 -> MaterialTheme.colorScheme.error
secondsLeft < 300 -> MaterialTheme.colorScheme.tertiary // ~warning colour
else -> MaterialTheme.colorScheme.onSurfaceVariant
isExpired -> MaterialTheme.colorScheme.error
secondsLeft < 60 -> MaterialTheme.colorScheme.error
secondsLeft < 300 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
// ── createdAt formatted as local time ─────────────────────────────────
// ── createdAt formatted as local time ─────────────────────────────────────
val createdAtLabel = remember(createdAt) {
val zone = ZoneId.systemDefault()
val zone = ZoneId.systemDefault()
val invoiceDay = createdAt.atZone(zone).toLocalDate()
val today = java.time.LocalDate.now(zone)
val timeStr = DateTimeFormatter.ofPattern("HH:mm").withZone(zone).format(createdAt)
when (invoiceDay) {
today -> "Today, $timeStr"
today.minusDays(1) -> "Yesterday, $timeStr"
else -> DateTimeFormatter
today -> strings.createdToday(timeStr) // ← "Today, HH:mm"
today.minusDays(1) -> strings.createdYesterday(timeStr) // ← "Yesterday, HH:mm"
else -> DateTimeFormatter
.ofPattern("dd MMM yyyy, HH:mm")
.withZone(zone)
.format(createdAt)
@@ -148,14 +162,16 @@ private fun ConfirmCardContent(
Spacer(Modifier.height(12.dp))
DetailRow(
label = "Amount",
value = "$amountSats sats",
subtitle = fiatLabel
label = strings.amount, // ← "Amount"
value = if (fiatLabel != null)
strings.confirmAmountWithFiat(amountSats, fiatLabel) // ← "X sats · Y EUR"
else
strings.confirmAmount(amountSats), // ← "X sats"
)
if (memo.isNotBlank()) {
Spacer(Modifier.height(4.dp))
DetailRow("Memo", memo)
DetailRow(strings.memo, memo) // ← "Memo"
}
if (!payee.isNullOrBlank()) {
@@ -165,7 +181,7 @@ private fun ConfirmCardContent(
Spacer(Modifier.height(4.dp))
DetailRow(
label = "Destination",
label = strings.destination, // ← "Destination"
value = destinationLabel,
valueColor = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
@@ -178,25 +194,22 @@ private fun ConfirmCardContent(
)
}
// ── NEW: Created at ───────────────────────────────────────────
Spacer(Modifier.height(4.dp))
DetailRow(
label = "Created",
label = strings.created, // ← "Created"
value = createdAtLabel
)
// ── NEW: Expiry countdown ─────────────────────────────────────
Spacer(Modifier.height(4.dp))
DetailRow(
label = "Expires in",
label = strings.expiresIn, // ← "Expires in"
value = expiryCountdown,
valueColor = expiryColor
)
// ── NEW: Payment hash ─────────────────────────────────────────
Spacer(Modifier.height(4.dp))
DetailRow(
label = "Hash",
label = strings.hash, // ← "Hash"
value = "${paymentHash.take(8)}${paymentHash.takeLast(8)}",
trailingContent = {
CopyIconButton("payment_hash", paymentHash)
@@ -205,7 +218,7 @@ private fun ConfirmCardContent(
Spacer(Modifier.height(4.dp))
DetailRow(
label = "Invoice",
label = strings.invoice, // ← "Invoice"
value = bolt11.take(20) + "" + bolt11.takeLast(6),
trailingContent = {
CopyIconButton("lightning_invoice", bolt11)
@@ -227,7 +240,7 @@ private fun ConfirmCardContent(
Button(
onClick = onPay,
enabled = !isExpired, // ── NEW: disable Pay if invoice has expired
enabled = !isExpired,
modifier = Modifier.fillMaxWidth()
) {
PayButtonText(amountSats = invoice.amountSats, fiatLabel = fiatLabel)
@@ -239,13 +252,17 @@ private fun ConfirmCardContent(
@Composable
private fun PayButtonText(amountSats: Long, fiatLabel: String?) {
val strings = LocalAppStrings.current
Text(
if (fiatLabel != null) "Pay $amountSats sats · $fiatLabel"
else "Pay $amountSats sats"
if (fiatLabel != null) strings.payButtonWithFiat(amountSats, fiatLabel) // ← "Pay X sats · Y EUR"
else strings.payButton(amountSats) // ← "Pay X sats"
)
}
@Composable
private fun PayCancelButton(onCancel: () -> Unit) {
OutlinedButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { Text("Cancel") }
val strings = LocalAppStrings.current
OutlinedButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) {
Text(strings.cancel) // ← "Cancel"
}
}
@@ -17,6 +17,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.SendState
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
import com.bitcointxoko.gudariwallet.util.WalletConstants
@@ -32,47 +33,61 @@ internal fun LnurlPayForm(
fiatRate : Double?,
fiatCurrency: String?
) {
val strings = LocalAppStrings.current
var amount by remember { mutableStateOf(state.minSats.toString()) }
var comment by remember { mutableStateOf("") }
val isFixed = state.minSats == state.maxSats
// Long? — null means the field is empty or non-numeric, not zero.
// This is the key hardening: 0L can no longer silently pass through.
val sats: Long? = amount.toLongOrNull()
val isAmountValid = sats != null && sats in state.minSats..state.maxSats
val fiatLabel: String? = fiatLabel(sats, fiatRate, fiatCurrency)
Column {
Text("Paying to ${payingToLabel(state.lnurl, state.domain)}", style = MaterialTheme.typography.titleMedium)
Text(
text = strings.payingTo(payingToLabel(state.lnurl, state.domain)), // ← "Paying to X"
style = MaterialTheme.typography.titleMedium
)
if (state.description.isNotBlank()) {
Spacer(Modifier.height(4.dp))
Text(state.description, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = amount,
onValueChange = { if (it.all(Char::isDigit)) amount = it },
label = { Text("Amount (sats)") },
isError = amount.isNotBlank() && !isAmountValid,
value = amount,
onValueChange = { if (it.all(Char::isDigit)) amount = it },
label = { Text(strings.amountInSats) }, // ← "Amount (sats)"
isError = amount.isNotBlank() && !isAmountValid,
supportingText = {
when {
// Show error when user has typed something invalid
// Error: typed something out of range
amount.isNotBlank() && !isAmountValid -> {
Text(
text = "Enter an amount between ${state.minSats} and ${state.maxSats} sats",
text = strings.lnurlAmountError( // ← "Enter an amount between X and Y sats"
state.minSats,
state.maxSats
),
color = MaterialTheme.colorScheme.error
)
}
// Show fiat range when rate is available
// Fiat range hint when rate is available
fiatRate != null && fiatCurrency != null -> {
val minFiat = formatFiat(satsToFiat(state.minSats, fiatRate), fiatCurrency)
val maxFiat = formatFiat(satsToFiat(state.maxSats, fiatRate), fiatCurrency)
if (isFixed) Text(minFiat)
else Text("${state.minSats} ${state.maxSats} sats · $minFiat $maxFiat")
if (isFixed)
Text(strings.lnurlFiatRange(minFiat, maxFiat)) // ← "X Y EUR" (fixed amount)
else
Text( // ← "X Y sats · A B EUR"
strings.lnurlSatsAndFiatRange(
state.minSats, state.maxSats,
minFiat, maxFiat
)
)
}
// Sats-only range when no rate
!isFixed -> Text("${state.minSats} ${state.maxSats} sats")
// Sats-only range when no fiat rate
!isFixed -> Text(strings.satsRange(state.minSats, state.maxSats)) // ← "X Y sats"
else -> {}
}
},
@@ -86,15 +101,14 @@ internal fun LnurlPayForm(
OutlinedTextField(
value = comment,
onValueChange = { if (it.length <= state.commentAllowed) comment = it },
label = { Text("Comment (optional)") },
supportingText = { Text("${comment.length}/${state.commentAllowed}") },
label = { Text(strings.commentOptional(state.commentAllowed)) }, // ← "Comment (optional, max X chars)"
supportingText = { Text(strings.commentCounter(comment.length, state.commentAllowed)) }, // ← "X/Y"
modifier = Modifier.fillMaxWidth()
)
}
Spacer(Modifier.height(16.dp))
Button(
onClick = {
// Double-guard: sats must be non-null and in range before firing
onClick = {
if (sats != null && isAmountValid) {
vm.fetchLnurlInvoice(
rawRes = state.rawRes,
@@ -109,8 +123,8 @@ internal fun LnurlPayForm(
modifier = Modifier.fillMaxWidth()
) {
Text(
if (fiatLabel != null) "Pay $amount sats · $fiatLabel"
else "Pay $amount sats"
if (fiatLabel != null) strings.lnurlPayButtonWithFiat(amount, fiatLabel) // ← "Pay X sats · Y EUR"
else strings.lnurlPayButton(amount) // ← "Pay X sats"
)
}
}
@@ -29,11 +29,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.bitcointxoko.gudariwallet.api.RouteHintHop
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.components.CopyIconButton
import com.bitcointxoko.gudariwallet.util.RiskLevel
import com.bitcointxoko.gudariwallet.util.RouteHintRisk
@@ -41,10 +43,11 @@ import com.bitcointxoko.gudariwallet.util.baseFeeLabel
@Composable
internal fun RouteHintWarningBanner(
risk : RouteHintRisk,
allRouteHints : List<RouteHintHop>,
routeHintAliases: Map<String, String>,
risk : RouteHintRisk,
allRouteHints : List<RouteHintHop>,
routeHintAliases : Map<String, String>,
) {
val strings = LocalAppStrings.current
val isHigh = risk.level == RiskLevel.HIGH
val containerCol = if (isHigh) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.tertiaryContainer
@@ -73,30 +76,45 @@ internal fun RouteHintWarningBanner(
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
// ── Title: High / Elevated ────────────────────────────────
Text(
text = if (isHigh) "High routing fees in invoice"
else "Elevated routing fees in invoice",
text = if (isHigh) strings.routeHintHighFees // ← "High routing fees in invoice"
else strings.routeHintElevatedFees, // ← "Elevated routing fees in invoice"
style = MaterialTheme.typography.labelMedium,
color = contentCol
)
Spacer(Modifier.height(4.dp))
// ── Forced channel explanation ────────────────────────────
Text(
text = "This invoice forces payment through a private channel " +
"operated by $offendingNode.",
text = strings.routeHintForcedChannel(offendingNode), // ← "This invoice forces payment through…"
style = MaterialTheme.typography.bodySmall,
color = contentCol
)
Spacer(Modifier.height(4.dp))
val feeRateLine = buildString {
append("Effective fee rate: ${risk.effectivePpm} ppm")
append(" (${risk.worstHop.ppmFee} ppm")
if (risk.worstHop.baseFeeMsat > 0) append(" + ${baseFeeLabel(risk.worstHop.baseFeeMsat)} base")
append(")")
}
Text(text = feeRateLine, style = MaterialTheme.typography.bodySmall, color = contentCol)
// ── Fee rate line ─────────────────────────────────────────
val feeRateLine = if (risk.worstHop.baseFeeMsat > 0)
strings.routeHintFeeRateBase( // ← "Effective fee rate: X ppm (Y ppm + Z base)"
risk.effectivePpm,
risk.worstHop.ppmFee,
baseFeeLabel(risk.worstHop.baseFeeMsat)
)
else
strings.routeHintFeeRate( // ← "Effective fee rate: X ppm (Y ppm)"
risk.effectivePpm,
risk.worstHop.ppmFee
)
Text(
text = "Estimated extra fee on this payment: " +
"~$estimatedFeeSats sat${if (estimatedFeeSats == 1L) "" else "s"}",
text = feeRateLine,
style = MaterialTheme.typography.bodySmall,
color = contentCol
)
// ── Estimated extra fee ───────────────────────────────────
Text(
text = strings.routeHintEstimatedFee(estimatedFeeSats), // ← "Estimated extra fee on this payment: ~X sats"
style = MaterialTheme.typography.bodySmall,
color = contentCol
)
@@ -111,7 +129,8 @@ internal fun RouteHintWarningBanner(
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp)
) {
Text(
text = if (detailsExpanded) "Hide details ▴" else "See details ▾",
text = if (detailsExpanded) strings.routeHintHideDetails // ← "Hide details ▴"
else strings.routeHintShowDetails, // ← "See details ▾"
style = MaterialTheme.typography.labelSmall,
color = contentCol
)
@@ -136,12 +155,14 @@ private fun RouteHintDetailsPanel(
worstKey : String,
tintColor: Color
) {
val strings = LocalAppStrings.current
val context = LocalContext.current
Column(modifier = Modifier.padding(top = 6.dp)) {
HorizontalDivider(color = tintColor.copy(alpha = 0.3f))
Spacer(Modifier.height(6.dp))
Text(
text = "Private route hint hops",
text = strings.routeHintHopsHeader, // ← "Private route hint hops"
style = MaterialTheme.typography.labelSmall,
color = tintColor.copy(alpha = 0.7f)
)
@@ -164,8 +185,7 @@ private fun RouteHintDetailsPanel(
text = displayName,
style = MaterialTheme.typography.bodySmall.copy(
textDecoration = TextDecoration.Underline,
fontWeight = if (isWorst) androidx.compose.ui.text.font.FontWeight.Bold
else androidx.compose.ui.text.font.FontWeight.Normal
fontWeight = if (isWorst) FontWeight.Bold else FontWeight.Normal
),
color = tintColor,
maxLines = 1,
@@ -178,7 +198,10 @@ private fun RouteHintDetailsPanel(
)
CopyIconButton("node_id", hop.publicKey, size = 28.dp, iconSize = 14.dp)
Text(
text = "${hop.ppmFee} ppm · ${baseFeeLabel(hop.baseFeeMsat)} base",
text = strings.routeHintHopFees( // ← "X ppm · Y base"
hop.ppmFee,
baseFeeLabel(hop.baseFeeMsat)
),
style = MaterialTheme.typography.labelSmall,
color = tintColor.copy(alpha = 0.85f),
maxLines = 1,
@@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.LocalAppStrings
import com.bitcointxoko.gudariwallet.ui.BalanceState
import com.bitcointxoko.gudariwallet.ui.NfcOfferState
import com.bitcointxoko.gudariwallet.ui.SendState
@@ -39,6 +40,7 @@ fun SendScreen(
nfcVm : NfcViewModel,
onViewDetails: (paymentHash: String, record: PaymentRecord) -> Unit = { _, _ -> }
) {
val strings = LocalAppStrings.current
val sendState : SendState by vm.sendState.collectAsStateWithLifecycle()
var input by remember { mutableStateOf("") }
val context = LocalContext.current
@@ -82,7 +84,10 @@ fun SendScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Send", style = MaterialTheme.typography.headlineSmall)
Text(
text = strings.send, // ← "Send"
style = MaterialTheme.typography.headlineSmall
)
// NFC indicator — only visible in Idle/Error, lights up when offer pending
if (sendState is SendState.Idle || sendState is SendState.Error) {
@@ -92,7 +97,7 @@ fun SendScreen(
) {
Icon(
imageVector = Icons.Filled.Nfc,
contentDescription = "Tap NFC device to receive invoice",
contentDescription = strings.tapNfcHint, // ← "Tap NFC device to receive invoice"
tint = if (nfcOffer is NfcOfferState.Detected)
MaterialTheme.colorScheme.primary
else
@@ -107,8 +112,8 @@ fun SendScreen(
OutlinedTextField(
value = input,
onValueChange = { input = SendInputDetector.normalize(it) },
label = { Text("Invoice, LNURL, or Lightning Address") },
placeholder = { Text("lnbc… / lnurl1… / user@domain.com") },
label = { Text(strings.sendInputLabel) }, // ← "Invoice, LNURL, or Lightning Address"
placeholder = { Text(strings.sendInputPlaceholder) }, // ← "lnbc… / lnurl1… / user@domain.com"
modifier = Modifier.fillMaxWidth(),
enabled = sendState is SendState.Idle || sendState is SendState.Error,
singleLine = false,
@@ -126,6 +131,7 @@ fun SendScreen(
}
when (val state = sendState) {
is SendState.Idle -> {
Button(
onClick = {
@@ -149,21 +155,21 @@ fun SendScreen(
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text("Paste")
Text(strings.paste) // ← "Paste"
}
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = { if (input.isNotBlank()) vm.scan(input) },
enabled = input.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) { Text("Continue") }
) { Text(strings.continueButton) } // ← "Continue"
}
is SendState.Scanning -> {
Spacer(Modifier.height(16.dp))
CircularProgressIndicator()
Spacer(Modifier.height(8.dp))
Text("Resolving…")
Text(strings.resolving) // ← "Resolving…"
}
is SendState.Bolt11Decoded -> {
@@ -199,7 +205,7 @@ fun SendScreen(
Spacer(Modifier.height(16.dp))
CircularProgressIndicator()
Spacer(Modifier.height(8.dp))
Text("Sending payment…")
Text(strings.sendingPayment) // ← "Sending payment…"
}
is SendState.PaymentSent -> {
@@ -210,10 +216,13 @@ fun SendScreen(
color = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(8.dp))
Text("Payment sent!", style = MaterialTheme.typography.headlineSmall)
Text(
text = strings.paymentSent, // ← "Payment sent!"
style = MaterialTheme.typography.headlineSmall
)
Spacer(Modifier.height(4.dp))
Text(
text = "${state.amountSats} sats",
text = strings.amountSats(state.amountSats), // ← "X sats"
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val fiatAmount = if (fiatRate != null && fiatCurrency != null) {
@@ -221,21 +230,26 @@ fun SendScreen(
} else null
if (fiatAmount != null && fiatCurrency != null) {
Text(
text = "${formatFiat(fiatAmount, fiatCurrency)}",
text = strings.fiatEquivalent( // ← "≈ X.XX EUR"
formatFiat(fiatAmount, fiatCurrency)
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(4.dp))
val ppm = if (state.feeSats != null && state.feeSats != 0L) {
feePpm(
amountMsat = state.amountSats * 1_000L,
feeMsat = state.feeSats * 1_000L
)
} else null
val feeText = when {
state.feeSats == null || state.feeSats == 0L -> "No fees"
state.feeSats == null || state.feeSats == 0L ->
strings.noFees // ← "No fees"
else -> {
val ppm = feePpm(
amountMsat = state.amountSats * 1_000L,
feeMsat = state.feeSats * 1_000L
)
val ppmSuffix = if (ppm != null) " (${ppm} ppm)" else ""
"Routing fee: ${state.feeSats} sat${if (state.feeSats == 1L) "" else "s"}$ppmSuffix"
strings.routingFee(state.feeSats, ppmSuffix) // ← "Routing fee: X sats (Y ppm)"
}
}
Text(
@@ -253,12 +267,12 @@ fun SendScreen(
},
enabled = state.pendingRecord != null,
modifier = Modifier.fillMaxWidth()
) { Text("Details") }
) { Text(strings.details) } // ← "Details"
Spacer(Modifier.height(8.dp))
Button(
onClick = { vm.resetSendState(); input = "" },
modifier = Modifier.fillMaxWidth()
) { Text("Send Another") }
) { Text(strings.sendAnother) } // ← "Send Another"
}
is SendState.Error -> {
@@ -273,12 +287,12 @@ fun SendScreen(
onClick = { if (input.isNotBlank()) vm.scan(input) },
enabled = input.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) { Text("Try Again") }
) { Text(strings.tryAgain) } // ← "Try Again"
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = { vm.resetSendState(); input = "" },
modifier = Modifier.fillMaxWidth()
) { Text("Clear") }
) { Text(strings.clear) } // ← "Clear"
}
}
}