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