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