From ed3371aa6fd00c87caec68de706b4b6eeb05a970 Mon Sep 17 00:00:00 2001 From: rasputin Date: Fri, 5 Jun 2026 23:52:30 +0200 Subject: [PATCH] feat: localisation with lyricist --- app/build.gradle.kts | 32 +++- .../bitcointxoko/gudariwallet/MainActivity.kt | 50 +++-- .../bitcointxoko/gudariwallet/ui/HomeTab.kt | 180 +++++++++++++----- .../gudariwallet/ui/WalletScreen.kt | 8 +- .../gudariwallet/ui/receive/ReceiveScreen.kt | 127 ++++++------ .../gudariwallet/ui/send/ConfirmCard.kt | 115 ++++++----- .../gudariwallet/ui/send/LnurlPayForm.kt | 52 +++-- .../gudariwallet/ui/send/RouteHintWarning.kt | 65 +++++-- .../gudariwallet/ui/send/SendScreen.kt | 56 ++++-- build.gradle.kts | 1 + gradle.properties | 6 +- gradle/libs.versions.toml | 4 +- 12 files changed, 453 insertions(+), 243 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8afb9e9..ca95122 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -55,6 +55,12 @@ android { srcDir("src/main/proto") } } + getByName("debug") { + java.srcDirs("build/generated/ksp/debug/kotlin") + } + getByName("release") { + java.srcDirs("build/generated/ksp/release/kotlin") + } } } @@ -124,9 +130,13 @@ dependencies { implementation(libs.androidx.compose.animation) // secp256k1 - dependencies { - implementation(libs.secp256k1.kmp.android) - } + implementation(libs.secp256k1.kmp.android) + + + implementation(libs.lyricist) + + // Required for @LyricistStrings code generation + ksp(libs.lyricist.processor) } protobuf { @@ -146,3 +156,19 @@ protobuf { } } } + +ksp { + // Make generated code internal instead of public + arg("lyricist.internalVisibility", "true") + + // Generate a `strings` shorthand instead of LocalStrings.current + arg("lyricist.generateStringsProperty", "true") + + // For multi-module projects (generates LocalDashboardStrings, etc.) + arg("lyricist.packageName", "com.bitcointxoko.gudariwallet") + arg("lyricist.moduleName", project.name) + + // Only for strings.xml migration: + // arg("lyricist.xml.resourcesPath", android.sourceSets["main"].res.srcDirs.first().absolutePath) + // arg("lyricist.xml.defaultLanguageTag", "en") +} \ No newline at end of file diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt index 6812cce..e4e1dd9 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt @@ -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 } } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HomeTab.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HomeTab.kt index e0b86c8..88411da 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HomeTab.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HomeTab.kt @@ -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 // ← 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>(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, + 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, - 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( } } } - diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt index 7a569cf..aba8e6f 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt @@ -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, - pendingPaymentUri : MutableState + pendingPaymentUri : MutableState, + lyricist : Lyricist ) { 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) { diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt index 5dd6538..8ca52e3 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt @@ -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" } } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/ConfirmCard.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/ConfirmCard.kt index 1615ade..bba3a72 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/ConfirmCard.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/ConfirmCard.kt @@ -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" + } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt index 47f277a..25a0424 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/LnurlPayForm.kt @@ -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" ) } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/RouteHintWarning.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/RouteHintWarning.kt index 118e3aa..6055895 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/RouteHintWarning.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/RouteHintWarning.kt @@ -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, - routeHintAliases: Map, + risk : RouteHintRisk, + allRouteHints : List, + routeHintAliases : Map, ) { + 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, diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendScreen.kt index c8266b1..180ac88 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/send/SendScreen.kt @@ -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" } } } diff --git a/build.gradle.kts b/build.gradle.kts index 18318be..0b5c6ec 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.ksp) apply false } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 534d305..8e9a99a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,4 +17,8 @@ kotlin.code.style=official # which conflicts with how KSP tries to register its generated sources. # This tells AGP to allow KSP to use the kotlin.sourceSets DSL to register its generated code # (the Room-generated AppDatabase_Impl etc.), which it needs to do. -android.disallowKotlinSourceSets=false \ No newline at end of file +android.disallowKotlinSourceSets=false + +ksp.useKsp2=true +ksp.incremental=true +ksp.incremental.log=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3bdac53..b656d5d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ protobufPlugin = "0.10.0" tink = "1.13.0" ui = "1.11.2" secp256k1-kmp = "0.23.0" +lyricist = "1.8.0" [libraries] androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } @@ -68,7 +69,8 @@ protobuf-protoc = { group = "com.google.protobuf", name = "prot tink-android = { group = "com.google.crypto.tink", name = "tink-android", version.ref = "tink" } androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" } secp256k1-kmp-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1-kmp" } - +lyricist = { module = "cafe.adriel.lyricist:lyricist", version.ref = "lyricist" } +lyricist-processor = { module = "cafe.adriel.lyricist:lyricist-processor", version.ref = "lyricist" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }